diff options
| author | srdusr <[email protected]> | 2026-02-03 00:38:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-02-03 00:38:00 +0200 |
| commit | ce6ce32469da720105258cb66e0274b2b009cd1d (patch) | |
| tree | bf1766efcdb323755e5cf3128a74b137eeb34b88 /internal | |
| parent | c2443f27ef5a844f045c038c7689d217d1dbf0c4 (diff) | |
| download | mitmux-ce6ce32469da720105258cb66e0274b2b009cd1d.tar.gz mitmux-ce6ce32469da720105258cb66e0274b2b009cd1d.zip | |
Intruder-equivalent: Sniper attacks with § markers
Implements build-order step 7, the last (optional) item. Scoped to
Sniper only - one payload set, one §-marked position fuzzed at a time,
others held at their base value - since that covers most real Intruder
usage; battering ram / pitchfork / cluster bomb aren't implemented.
Sequential sending, capped at 1000 generated requests as a fixed safety
limit.
internal/proxy: repeat.go's Repeat() is refactored into a shared
sendRaw(..., source) primitive so Intrude can reuse the exact same
raw-byte send/record path with source="intruder" instead of
duplicating it. intrude.go adds ParseMarkers/buildRequest (marker
parsing and payload substitution, covered by intrude_test.go - this is
fiddly byte-splicing logic, worth locking down with real tests rather
than trusting it by inspection) and Intrude(), which walks positions ×
payloads calling sendRaw and streaming each result through a callback.
internal/ipc gains a dedicated streaming "intrude" connection (same
shape as Subscribe, but blocking sends rather than drop-on-slow-
consumer - each result is the attack's actual data, not a
notification). cmd/mitmux gains an Intruder view: editable request
template (ctrl+p inserts a § marker at the cursor - typing § directly
also works, ctrl+p just doesn't require a keyboard layout that can
produce it), editable payload list, and a live results table wired to
the existing detail view (selecting a row and hitting enter opens the
full request/response for that specific attack request).
Verified live against real external traffic: a Sniper attack against
httpbin.org/status/§200§ with payloads 200/404/500 produced exactly the
three corresponding real status codes back (not a canned/local result),
confirmed the three requests landed in history tagged source="intruder"
with the § markers correctly stripped from what was actually sent, and
confirmed opening a result row's full detail from the results table.
This closes out the full build order from PLAN.md (steps 1-7).
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/ipc/ipc.go | 86 | ||||
| -rw-r--r-- | internal/ipc/server.go | 44 | ||||
| -rw-r--r-- | internal/proxy/intrude.go | 124 | ||||
| -rw-r--r-- | internal/proxy/intrude_test.go | 116 | ||||
| -rw-r--r-- | internal/proxy/repeat.go | 28 |
5 files changed, 383 insertions, 15 deletions
diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 19c9c23..03a4bc7 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -10,6 +10,7 @@ import ( "fmt" "net" "sync" + "time" "mitmux/internal/rules" "mitmux/internal/store" @@ -17,7 +18,7 @@ import ( // Request is sent by a client to the daemon. type Request struct { - Type string `json:"type"` // "list", "get", "subscribe", "repeat", "rules_list", "rules_save", "rules_delete", or "rules_toggle" + Type string `json:"type"` // "list", "get", "subscribe", "repeat", "intrude", "rules_list", "rules_save", "rules_delete", or "rules_toggle" Limit int `json:"limit,omitempty"` BeforeID int64 `json:"before_id,omitempty"` ID int64 `json:"id,omitempty"` @@ -28,10 +29,15 @@ type Request struct { Query string `json:"query,omitempty"` // For "repeat": send Raw to scheme://host exactly as given. + // For "intrude": Raw is the §marked§ template - see proxy.Intrude. Scheme string `json:"scheme,omitempty"` Host string `json:"host,omitempty"` Raw []byte `json:"raw,omitempty"` + // For "intrude": the payload set, applied to each marked position in + // turn (Sniper-style - see proxy.Intrude). + Payloads []string `json:"payloads,omitempty"` + // For "rules_save": add (Rule.ID == 0) or update (Rule.ID != 0) a // match-and-replace rule. For "rules_delete"/"rules_toggle": RuleID // (and RuleEnabled for toggle) identify the target. @@ -42,12 +48,27 @@ type Request struct { // Response is sent by the daemon to a client. type Response struct { - Type string `json:"type"` // "list", "get", "new", "repeat", "rules", or "error" + Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "intrude_result", "intrude_done", or "error" Entries []store.Summary `json:"entries,omitempty"` // for "list" Detail *EntryDetail `json:"detail,omitempty"` // for "get" and "repeat" New *store.Summary `json:"new,omitempty"` // for "new" (subscribe push) Rules []rules.Rule `json:"rules,omitempty"` // for "rules" - Error string `json:"error,omitempty"` + + // For "intrude_result": one completed attack request. + IntrudeResult *IntrudeResultMsg `json:"intrude_result,omitempty"` + + Error string `json:"error,omitempty"` +} + +// IntrudeResultMsg is one completed Intruder attack request. +type IntrudeResultMsg struct { + Position int `json:"position"` + Payload string `json:"payload"` + EntryID int64 `json:"entry_id"` + StatusCode int `json:"status_code"` + RespSize int `json:"resp_size"` + Duration time.Duration `json:"duration"` + Error string `json:"error,omitempty"` } // EntryDetail is a full history entry, raw bytes included. @@ -237,3 +258,62 @@ func Subscribe(path string) (<-chan store.Summary, func() error, error) { }() return ch, conn.Close, nil } + +// Intrude starts a Sniper attack (see proxy.Intrude): template must +// contain at least one §marked§ position, fuzzed in turn through +// payloads. Unlike Subscribe's live feed, no result is ever dropped for +// a slow consumer - each one is the attack's actual data, not a +// notification with the real thing recoverable elsewhere. A setup error +// (bad markers, empty payload set, too many requests) is returned +// directly rather than through the channel. The returned channel closes +// when the attack finishes or the connection is closed early. +func Intrude(path, scheme, host string, template []byte, payloads []string) (<-chan IntrudeResultMsg, func() error, error) { + conn, err := net.Dial("unix", path) + if err != nil { + return nil, nil, fmt.Errorf("dial %s: %w", path, err) + } + if err := json.NewEncoder(conn).Encode(Request{Type: "intrude", Scheme: scheme, Host: host, Raw: template, Payloads: payloads}); err != nil { + conn.Close() + return nil, nil, err + } + + dec := json.NewDecoder(conn) + var first Response + if err := dec.Decode(&first); err != nil { + conn.Close() + return nil, nil, err + } + if first.Type == "error" { + conn.Close() + return nil, nil, errors.New(first.Error) + } + + ch := make(chan IntrudeResultMsg) + go func() { + defer close(ch) + deliver := func(resp Response) bool { + switch resp.Type { + case "intrude_result": + if resp.IntrudeResult != nil { + ch <- *resp.IntrudeResult + } + return true + default: // "intrude_done", or anything else - stop + return false + } + } + if !deliver(first) { + return + } + for { + var resp Response + if err := dec.Decode(&resp); err != nil { + return + } + if !deliver(resp) { + return + } + } + }() + return ch, conn.Close, nil +} diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 1fe8d8f..50dff7f 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -18,6 +18,13 @@ type Repeater interface { Repeat(ctx context.Context, scheme, host string, raw []byte) (*store.Entry, error) } +// Intruder runs a Sniper attack over a §marked§ request template - +// implemented by *proxy.Server. +type Intruder interface { + Intrude(ctx context.Context, scheme, host string, template []byte, payloads []string, + onResult func(position int, payload string, entry *store.Entry, sendErr error) bool) error +} + // Hub fans out newly captured history entries to subscribed clients. type Hub struct { mu sync.Mutex @@ -62,12 +69,17 @@ type Server struct { db *store.Store hub *Hub repeater Repeater + intruder Intruder } // NewServer creates a control-protocol Server backed by db, broadcasting -// through hub and sending Repeater requests through rep. +// through hub and sending Repeater/Intruder requests through rep. func NewServer(db *store.Store, hub *Hub, rep Repeater) *Server { - return &Server{db: db, hub: hub, repeater: rep} + s := &Server{db: db, hub: hub, repeater: rep} + if in, ok := rep.(Intruder); ok { + s.intruder = in + } + return s } // Serve accepts connections on ln until it returns an error (e.g. the @@ -128,6 +140,34 @@ func (s *Server) handleConn(conn net.Conn) { } enc.Encode(Response{Type: "repeat", Detail: detailFromEntry(e)}) + case "intrude": + if s.intruder == nil { + enc.Encode(Response{Type: "error", Error: "intruder not available"}) + continue + } + err := s.intruder.Intrude(context.Background(), req.Scheme, req.Host, req.Raw, req.Payloads, + func(position int, payload string, entry *store.Entry, sendErr error) bool { + r := IntrudeResultMsg{Position: position, Payload: payload} + if sendErr != nil { + r.Error = sendErr.Error() + } + if entry != nil { + r.EntryID = entry.ID + r.StatusCode = entry.StatusCode + r.RespSize = len(entry.ResponseRaw) + r.Duration = entry.Duration + if entry.Error != "" && r.Error == "" { + r.Error = entry.Error + } + } + return enc.Encode(Response{Type: "intrude_result", IntrudeResult: &r}) == nil + }) + if err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "intrude_done"}) + case "rules_list": rs, err := s.db.ListRules() if err != nil { diff --git a/internal/proxy/intrude.go b/internal/proxy/intrude.go new file mode 100644 index 0000000..933a309 --- /dev/null +++ b/internal/proxy/intrude.go @@ -0,0 +1,124 @@ +// Intruder-equivalent: mark positions in a raw request template with § +// (Burp's own marker character, so anyone who's used Burp already knows +// the syntax), and Sniper-attack them - one position fuzzed at a time +// through a shared payload set, every other marked position holding its +// base value. Battering ram / pitchfork / cluster bomb are not +// implemented; Sniper covers the large majority of real Intruder usage +// and this whole feature is explicitly optional in the build order. +package proxy + +import ( + "bytes" + "context" + "fmt" + + "mitmux/internal/store" +) + +const marker = "§" + +// maxIntrudeRequests caps positions × payloads for one attack - a safety +// limit against an accidental huge wordlist times several positions +// turning into an unbounded flood, not a tuned production value. +const maxIntrudeRequests = 1000 + +// IntrudePosition is one marked, resolved insertion point. +type IntrudePosition struct { + Index int // 0-based, in order of appearance + Base string // the text between its markers +} + +// ParseMarkers finds every §base§ pair in template and returns the +// resolved positions plus template with the markers stripped out (the +// form actually used as the base request when no position is being +// fuzzed). An odd number of § markers is a user error - unterminated +// marker - reported rather than guessed at. +func ParseMarkers(template []byte) (positions []IntrudePosition, stripped []byte, err error) { + parts := bytes.Split(template, []byte(marker)) + if len(parts)%2 != 1 { + return nil, nil, fmt.Errorf("unterminated %s marker - markers must come in pairs", marker) + } + if len(parts) == 1 { + return nil, template, nil + } + + var buf bytes.Buffer + for i, part := range parts { + if i%2 == 1 { + positions = append(positions, IntrudePosition{Index: len(positions), Base: string(part)}) + } + buf.Write(part) + } + return positions, buf.Bytes(), nil +} + +// buildRequest re-inserts each position's base value into stripped +// (computed relative to the ORIGINAL template's marker layout, so this +// re-derives offsets rather than operating on the already-stripped +// bytes) except for `active`, which gets payload instead. +func buildRequest(template []byte, active int, payload string) ([]byte, error) { + parts := bytes.Split(template, []byte(marker)) + if len(parts)%2 != 1 { + return nil, fmt.Errorf("unterminated %s marker", marker) + } + var buf bytes.Buffer + pos := 0 + for i, part := range parts { + if i%2 == 1 { + if pos == active { + buf.WriteString(payload) + } else { + buf.Write(part) + } + pos++ + continue + } + buf.Write(part) + } + return buf.Bytes(), nil +} + +// Intrude runs a Sniper attack: template must contain at least one +// §marked§ position. For each position, in order, every payload is sent +// with that position replaced by the payload and all others at their +// base value; onResult is called synchronously after each request +// completes - with the position index, the payload used, the resulting +// entry (nil if sendErr is set), and any send error - so a caller can +// stream progress, and stops the attack early if it returns false. +func (s *Server) Intrude(ctx context.Context, scheme, host string, template []byte, payloads []string, + onResult func(position int, payload string, entry *store.Entry, sendErr error) bool) error { + positions, _, err := ParseMarkers(template) + if err != nil { + return err + } + if len(positions) == 0 { + return fmt.Errorf("no %s-marked positions in the request template", marker) + } + if len(payloads) == 0 { + return fmt.Errorf("no payloads") + } + if total := len(positions) * len(payloads); total > maxIntrudeRequests { + return fmt.Errorf("attack would send %d requests (%d positions × %d payloads), over the %d limit", + total, len(positions), len(payloads), maxIntrudeRequests) + } + + for _, pos := range positions { + for _, payload := range payloads { + raw, err := buildRequest(template, pos.Index, payload) + if err != nil { + return err + } + + // sendRaw is already self-bounding (dialForRepeat's own dial + // timeout, then conn.SetDeadline for the rest), so ctx here + // only needs to carry cancellation - e.g. the IPC connection + // driving this attack closing mid-run. + e, sendErr := s.sendRaw(ctx, scheme, host, raw, "intruder") + + if !onResult(pos.Index, payload, e, sendErr) { + return nil + } + } + } + return nil +} diff --git a/internal/proxy/intrude_test.go b/internal/proxy/intrude_test.go new file mode 100644 index 0000000..5df80e6 --- /dev/null +++ b/internal/proxy/intrude_test.go @@ -0,0 +1,116 @@ +package proxy + +import ( + "reflect" + "testing" +) + +func TestParseMarkers(t *testing.T) { + tests := []struct { + name string + template string + wantPos []IntrudePosition + wantOut string + wantErr bool + }{ + { + name: "single position", + template: "GET /users/§123§ HTTP/1.1", + wantPos: []IntrudePosition{{Index: 0, Base: "123"}}, + wantOut: "GET /users/123 HTTP/1.1", + }, + { + name: "two positions", + template: "GET /a/§1§/b/§2§ HTTP/1.1", + wantPos: []IntrudePosition{{Index: 0, Base: "1"}, {Index: 1, Base: "2"}}, + wantOut: "GET /a/1/b/2 HTTP/1.1", + }, + { + name: "no markers", + template: "GET / HTTP/1.1", + wantPos: nil, + wantOut: "GET / HTTP/1.1", + }, + { + name: "empty marker", + template: "GET /§§ HTTP/1.1", + wantPos: []IntrudePosition{{Index: 0, Base: ""}}, + wantOut: "GET / HTTP/1.1", + }, + { + name: "unterminated marker", + template: "GET /§broken HTTP/1.1", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pos, out, err := ParseMarkers([]byte(tt.template)) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(pos, tt.wantPos) { + t.Errorf("positions = %+v, want %+v", pos, tt.wantPos) + } + if string(out) != tt.wantOut { + t.Errorf("stripped = %q, want %q", out, tt.wantOut) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + template := "GET /a/§1§/b/§2§/c/§3§ HTTP/1.1" + + tests := []struct { + active int + payload string + want string + }{ + {0, "PAYLOAD", "GET /a/PAYLOAD/b/2/c/3 HTTP/1.1"}, + {1, "PAYLOAD", "GET /a/1/b/PAYLOAD/c/3 HTTP/1.1"}, + {2, "PAYLOAD", "GET /a/1/b/2/c/PAYLOAD HTTP/1.1"}, + } + + for _, tt := range tests { + got, err := buildRequest([]byte(template), tt.active, tt.payload) + if err != nil { + t.Fatalf("active=%d: unexpected error: %v", tt.active, err) + } + if string(got) != tt.want { + t.Errorf("active=%d: got %q, want %q", tt.active, got, tt.want) + } + } +} + +func TestBuildRequestPayloadContainingMarkerChar(t *testing.T) { + // A payload that itself contains the marker character must not be + // reinterpreted as a marker on a later buildRequest call - each call + // re-splits the ORIGINAL template, not the previously built request. + template := "GET /§1§/§2§ HTTP/1.1" + got, err := buildRequest([]byte(template), 0, "§injected§") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "GET /§injected§/2 HTTP/1.1" + if string(got) != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestIntrudeRequestCount(t *testing.T) { + positions, _, err := ParseMarkers([]byte("GET /§a§/§b§ HTTP/1.1")) + if err != nil { + t.Fatal(err) + } + if len(positions) != 2 { + t.Fatalf("expected 2 positions, got %d", len(positions)) + } +} diff --git a/internal/proxy/repeat.go b/internal/proxy/repeat.go index cecf481..3ee7cea 100644 --- a/internal/proxy/repeat.go +++ b/internal/proxy/repeat.go @@ -25,38 +25,46 @@ import ( // HTTP/2's binary framing, so the connection is negotiated HTTP/1.1-only // rather than letting the server pick. func (s *Server) Repeat(ctx context.Context, scheme, host string, raw []byte) (*store.Entry, error) { + return s.sendRaw(ctx, scheme, host, raw, "repeater") +} + +// sendRaw is the shared raw-byte send/record primitive behind Repeat and +// Intrude - same wire behavior (exact bytes, HTTP/1.1-only, bounded by +// upstreamTimeout), tagged with whichever source called it so history +// can tell repeater sends from intruder attack requests apart. +func (s *Server) sendRaw(ctx context.Context, scheme, host string, raw []byte, source string) (*store.Entry, error) { started := time.Now() method, path := parseRequestLine(raw) conn, err := dialForRepeat(ctx, scheme, host) if err != nil { - return s.recordRepeat(started, time.Since(started), scheme, host, method, path, raw, nil, 0, err.Error()) + return s.recordRaw(started, time.Since(started), scheme, host, method, path, raw, nil, 0, err.Error(), source) } defer conn.Close() // See the matching comment in forward(): without this, a hung // server - or a user-edited request malformed enough that nothing - // ever replies - blocks this Repeat call, and the IPC connection - // handling it, forever. + // ever replies - blocks this call, and whatever's waiting on it + // (an IPC connection, or an entire Intruder attack), forever. conn.SetDeadline(time.Now().Add(upstreamTimeout)) if _, err := conn.Write(raw); err != nil { - return s.recordRepeat(started, time.Since(started), scheme, host, method, path, raw, nil, 0, err.Error()) + return s.recordRaw(started, time.Since(started), scheme, host, method, path, raw, nil, 0, err.Error(), source) } tee := newTeeConn(conn) resp, err := http.ReadResponse(bufio.NewReader(tee), &http.Request{Method: method}) duration := time.Since(started) if err != nil { - return s.recordRepeat(started, duration, scheme, host, method, path, raw, nil, 0, err.Error()) + return s.recordRaw(started, duration, scheme, host, method, path, raw, nil, 0, err.Error(), source) } defer resp.Body.Close() io.Copy(io.Discard, resp.Body) - return s.recordRepeat(started, duration, scheme, host, method, path, raw, tee.Take(), resp.StatusCode, "") + return s.recordRaw(started, duration, scheme, host, method, path, raw, tee.Take(), resp.StatusCode, "", source) } -func (s *Server) recordRepeat(started time.Time, duration time.Duration, scheme, host, method, path string, - reqRaw, respRaw []byte, status int, errMsg string) (*store.Entry, error) { +func (s *Server) recordRaw(started time.Time, duration time.Duration, scheme, host, method, path string, + reqRaw, respRaw []byte, status int, errMsg, source string) (*store.Entry, error) { e := &store.Entry{ StartedAt: started, Duration: duration, @@ -70,12 +78,12 @@ func (s *Server) recordRepeat(started time.Time, duration time.Duration, scheme, RequestExact: true, ResponseExact: respRaw != nil, Error: errMsg, - Source: "repeater", + Source: source, } if s.store != nil { id, err := s.store.Insert(e) if err != nil { - return nil, fmt.Errorf("store repeater entry: %w", err) + return nil, fmt.Errorf("store %s entry: %w", source, err) } e.ID = id if s.OnEntry != nil { |