diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/ipc/ipc.go | 48 | ||||
| -rw-r--r-- | internal/ipc/server.go | 14 | ||||
| -rw-r--r-- | internal/proxy/intrude.go | 233 | ||||
| -rw-r--r-- | internal/proxy/intrude_test.go | 118 |
4 files changed, 326 insertions, 87 deletions
diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 89a8343..d581313 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "mitmux/internal/proxy" "mitmux/internal/rules" "mitmux/internal/scope" "mitmux/internal/store" @@ -35,9 +36,13 @@ type Request struct { 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 "intrude": the attack mode (proxy.Sniper and so on - empty + // defaults to Sniper) and its payload sets. Sniper and BatteringRam + // only ever use PayloadSets[0] (one shared set); Pitchfork and + // ClusterBomb require exactly one set per §marked§ position, in + // order - see proxy.Intrude. + Mode proxy.AttackMode `json:"mode,omitempty"` + PayloadSets [][]string `json:"payload_sets,omitempty"` // For "intrude": optional Go regexps evaluated against each result's // response bytes. GrepMatch flags whether it matched at all; @@ -118,10 +123,14 @@ type StatusMsg struct { HistoryCount int64 `json:"history_count"` } -// IntrudeResultMsg is one completed Intruder attack request. +// IntrudeResultMsg is one completed Intruder attack request. Values holds +// what was substituted into each §marked§ position for this request, in +// position order - for Sniper, every entry but the one fuzzed position +// equals that position's base value; for the other three modes every +// entry is an actual payload. type IntrudeResultMsg struct { - Position int `json:"position"` - Payload string `json:"payload"` + Iteration int `json:"iteration"` + Values []string `json:"values"` EntryID int64 `json:"entry_id"` StatusCode int `json:"status_code"` RespSize int `json:"resp_size"` @@ -468,23 +477,24 @@ 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. grepMatch/grepExtract are optional Go regexps evaluated -// server-side against each result's response bytes (empty string -// disables either check) - see IntrudeResultMsg. 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, an unparseable grep regexp) 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, grepMatch, grepExtract string) (<-chan IntrudeResultMsg, func() error, error) { +// Intrude starts an attack (see proxy.Intrude and proxy.AttackMode): +// template must contain at least one §marked§ position. mode selects how +// payloadSets combine across positions; "" defaults to Sniper. +// grepMatch/grepExtract are optional Go regexps evaluated server-side +// against each result's response bytes (empty string disables either +// check) - see IntrudeResultMsg. 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, an +// unparseable grep regexp) 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, mode proxy.AttackMode, payloadSets [][]string, grepMatch, grepExtract 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) } - req := Request{Type: "intrude", Scheme: scheme, Host: host, Raw: template, Payloads: payloads, GrepMatch: grepMatch, GrepExtract: grepExtract} + req := Request{Type: "intrude", Scheme: scheme, Host: host, Raw: template, Mode: mode, PayloadSets: payloadSets, GrepMatch: grepMatch, GrepExtract: grepExtract} if err := json.NewEncoder(conn).Encode(req); err != nil { conn.Close() return nil, nil, err diff --git a/internal/ipc/server.go b/internal/ipc/server.go index c83254e..c890a54 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -9,6 +9,7 @@ import ( "regexp" "sync" + "mitmux/internal/proxy" "mitmux/internal/rules" "mitmux/internal/scope" "mitmux/internal/store" @@ -20,11 +21,12 @@ 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 - +// Intruder runs an attack (Sniper, Battering ram, Pitchfork, or Cluster +// bomb - see proxy.AttackMode) 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 + Intrude(ctx context.Context, scheme, host string, template []byte, mode proxy.AttackMode, payloadSets [][]string, + onResult func(iteration int, values []string, entry *store.Entry, sendErr error) bool) error } // Hub fans out newly captured history entries to subscribed clients. @@ -174,9 +176,9 @@ func (s *Server) handleConn(conn net.Conn) { } grepExtractRe = re } - 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} + err := s.intruder.Intrude(context.Background(), req.Scheme, req.Host, req.Raw, req.Mode, req.PayloadSets, + func(iteration int, values []string, entry *store.Entry, sendErr error) bool { + r := IntrudeResultMsg{Iteration: iteration, Values: values} if sendErr != nil { r.Error = sendErr.Error() } diff --git a/internal/proxy/intrude.go b/internal/proxy/intrude.go index 6595c75..3538f8d 100644 --- a/internal/proxy/intrude.go +++ b/internal/proxy/intrude.go @@ -1,10 +1,8 @@ // 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. +// the syntax) and fuzz them across four attack modes - Sniper, Battering +// ram, Pitchfork, and Cluster bomb - matching Burp's own naming and +// semantics for how positions and payload sets combine. package proxy import ( @@ -17,11 +15,33 @@ import ( 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. +// maxIntrudeRequests caps the number of requests one attack can send - a +// safety limit against an accidental huge wordlist (or, for Cluster bomb, +// a payload-set product) turning into an unbounded flood, not a tuned +// production value. const maxIntrudeRequests = 1000 +// AttackMode selects how payload sets combine across marked positions, +// matching Burp's own four attack types. +type AttackMode string + +const ( + // Sniper fuzzes one position at a time through a single shared + // payload set; every other marked position holds its base value. + // Requests: positions × len(payloads). + Sniper AttackMode = "sniper" + // BatteringRam sends the same payload, from a single shared payload + // set, into every marked position at once. Requests: len(payloads). + BatteringRam AttackMode = "battering_ram" + // Pitchfork walks one payload set per position in lockstep - request + // i takes payload i from every set. Requests: the shortest set's + // length (Burp's own convention when sets are uneven). + Pitchfork AttackMode = "pitchfork" + // ClusterBomb tries every combination of one payload set per + // position. Requests: the product of every set's length. + ClusterBomb AttackMode = "cluster_bomb" +) + // IntrudePosition is one marked, resolved insertion point. type IntrudePosition struct { Index int // 0-based, in order of appearance @@ -52,11 +72,11 @@ func ParseMarkers(template []byte) (positions []IntrudePosition, stripped []byte 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) { +// buildRequestValues re-derives offsets from template's ORIGINAL marker +// layout (rather than operating on already-stripped bytes) and substitutes +// values[i] for the i-th marked position, in order. len(values) must equal +// the number of marked positions in template. +func buildRequestValues(template []byte, values []string) ([]byte, error) { parts := bytes.Split(template, []byte(marker)) if len(parts)%2 != 1 { return nil, fmt.Errorf("unterminated %s marker", marker) @@ -65,11 +85,10 @@ func buildRequest(template []byte, active int, payload string) ([]byte, error) { pos := 0 for i, part := range parts { if i%2 == 1 { - if pos == active { - buf.WriteString(payload) - } else { - buf.Write(part) + if pos >= len(values) { + return nil, fmt.Errorf("position %d has no value", pos) } + buf.WriteString(values[pos]) pos++ continue } @@ -78,48 +97,166 @@ func buildRequest(template []byte, active int, payload string) ([]byte, error) { 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 - } +// intrudeValues computes, for one full attack, every position-substitution +// set to send - one []string per request (indexed by position, in send +// order) - according to mode. Pure and side-effect free, so the request +// count can be validated against maxIntrudeRequests before anything is +// dispatched, and so it's testable without a live target. +// +// payloadSets[0] is the shared payload set for Sniper and BatteringRam, +// which only ever need one. Pitchfork and ClusterBomb are inherently +// per-position - theirs is the whole point of the two modes - so they +// require exactly len(positions) sets, one per marked position in order. +func intrudeValues(mode AttackMode, positions []IntrudePosition, payloadSets [][]string) ([][]string, error) { if len(positions) == 0 { - return fmt.Errorf("no %s-marked positions in the request template", marker) + return nil, fmt.Errorf("no %s-marked positions in the request template", marker) } - if len(payloads) == 0 { - return fmt.Errorf("no payloads") + if len(payloadSets) == 0 || len(payloadSets[0]) == 0 { + return nil, 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) + + bases := make([]string, len(positions)) + for i, p := range positions { + bases[i] = p.Base } - for _, pos := range positions { + var out [][]string + switch mode { + case "", Sniper: + payloads := payloadSets[0] + if total := len(positions) * len(payloads); total > maxIntrudeRequests { + return nil, fmt.Errorf("attack would send %d requests (%d positions × %d payloads), over the %d limit", + total, len(positions), len(payloads), maxIntrudeRequests) + } + for posIdx := range positions { + for _, payload := range payloads { + values := append([]string(nil), bases...) + values[posIdx] = payload + out = append(out, values) + } + } + + case BatteringRam: + payloads := payloadSets[0] + if len(payloads) > maxIntrudeRequests { + return nil, fmt.Errorf("attack would send %d requests, over the %d limit", len(payloads), maxIntrudeRequests) + } for _, payload := range payloads { - raw, err := buildRequest(template, pos.Index, payload) - if err != nil { - return err + values := make([]string, len(positions)) + for i := range values { + values[i] = payload + } + out = append(out, values) + } + + case Pitchfork: + if len(payloadSets) != len(positions) { + return nil, fmt.Errorf("pitchfork needs one payload set per position (%d positions, %d payload sets given)", + len(positions), len(payloadSets)) + } + n := len(payloadSets[0]) + for _, set := range payloadSets { + if len(set) == 0 { + return nil, fmt.Errorf("no payloads") } - raw = fixContentLength(raw) + if len(set) < n { + n = len(set) + } + } + if n > maxIntrudeRequests { + return nil, fmt.Errorf("attack would send %d requests, over the %d limit", n, maxIntrudeRequests) + } + for i := 0; i < n; i++ { + values := make([]string, len(positions)) + for p := range positions { + values[p] = payloadSets[p][i] + } + out = append(out, values) + } - // 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") + case ClusterBomb: + if len(payloadSets) != len(positions) { + return nil, fmt.Errorf("cluster bomb needs one payload set per position (%d positions, %d payload sets given)", + len(positions), len(payloadSets)) + } + // Checked incrementally, one set at a time, so a pathological + // product (e.g. three sets of 10000) bails out before ever + // trying to enumerate it, not after. + total := 1 + for _, set := range payloadSets { + if len(set) == 0 { + return nil, fmt.Errorf("no payloads") + } + total *= len(set) + if total > maxIntrudeRequests { + return nil, fmt.Errorf("attack would send at least %d requests, over the %d limit", total, maxIntrudeRequests) + } + } + idx := make([]int, len(positions)) + for { + values := make([]string, len(positions)) + for p := range positions { + values[p] = payloadSets[p][idx[p]] + } + out = append(out, values) - if !onResult(pos.Index, payload, e, sendErr) { - return nil + // Odometer increment, rightmost (last) position fastest - + // matches Burp's own cluster-bomb iteration order. + p := len(positions) - 1 + for p >= 0 { + idx[p]++ + if idx[p] < len(payloadSets[p]) { + break + } + idx[p] = 0 + p-- + } + if p < 0 { + break } } + + default: + return nil, fmt.Errorf("unknown attack mode %q", mode) + } + return out, nil +} + +// Intrude runs one attack of the given mode over a §marked§ request +// template. onResult is called synchronously after each request completes +// - with a 0-based iteration index, the values substituted into each +// marked position for that request (indexed by position, same order as +// ParseMarkers), 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, mode AttackMode, payloadSets [][]string, + onResult func(iteration int, values []string, entry *store.Entry, sendErr error) bool) error { + positions, _, err := ParseMarkers(template) + if err != nil { + return err + } + + requests, err := intrudeValues(mode, positions, payloadSets) + if err != nil { + return err + } + + for i, values := range requests { + raw, err := buildRequestValues(template, values) + if err != nil { + return err + } + raw = fixContentLength(raw) + + // 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(i, values, e, sendErr) { + return nil + } } return nil } diff --git a/internal/proxy/intrude_test.go b/internal/proxy/intrude_test.go index 6a0007f..59aa5a7 100644 --- a/internal/proxy/intrude_test.go +++ b/internal/proxy/intrude_test.go @@ -1,6 +1,7 @@ package proxy import ( + "fmt" "reflect" "testing" ) @@ -66,36 +67,35 @@ func TestParseMarkers(t *testing.T) { } } -func TestBuildRequest(t *testing.T) { +func TestBuildRequestValues(t *testing.T) { template := "GET /a/§1§/b/§2§/c/§3§ HTTP/1.1" tests := []struct { - active int - payload string - want string + values []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"}, + {[]string{"PAYLOAD", "2", "3"}, "GET /a/PAYLOAD/b/2/c/3 HTTP/1.1"}, + {[]string{"1", "PAYLOAD", "3"}, "GET /a/1/b/PAYLOAD/c/3 HTTP/1.1"}, + {[]string{"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) + got, err := buildRequestValues([]byte(template), tt.values) if err != nil { - t.Fatalf("active=%d: unexpected error: %v", tt.active, err) + t.Fatalf("values=%v: unexpected error: %v", tt.values, err) } if string(got) != tt.want { - t.Errorf("active=%d: got %q, want %q", tt.active, got, tt.want) + t.Errorf("values=%v: got %q, want %q", tt.values, got, tt.want) } } } -func TestBuildRequestPayloadContainingMarkerChar(t *testing.T) { +func TestBuildRequestValuesPayloadContainingMarkerChar(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. + // reinterpreted as a marker - buildRequestValues splits the ORIGINAL + // template, never the already-substituted result. template := "GET /§1§/§2§ HTTP/1.1" - got, err := buildRequest([]byte(template), 0, "§injected§") + got, err := buildRequestValues([]byte(template), []string{"§injected§", "2"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -105,6 +105,96 @@ func TestBuildRequestPayloadContainingMarkerChar(t *testing.T) { } } +func TestIntrudeValuesSniper(t *testing.T) { + positions := []IntrudePosition{{Index: 0, Base: "a"}, {Index: 1, Base: "b"}} + got, err := intrudeValues(Sniper, positions, [][]string{{"1", "2"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := [][]string{ + {"1", "b"}, {"2", "b"}, // position 0 fuzzed, position 1 at base + {"a", "1"}, {"a", "2"}, // position 1 fuzzed, position 0 at base + } + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestIntrudeValuesBatteringRam(t *testing.T) { + positions := []IntrudePosition{{Index: 0, Base: "a"}, {Index: 1, Base: "b"}} + got, err := intrudeValues(BatteringRam, positions, [][]string{{"1", "2"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Same payload lands in every position at once, unlike Sniper. + want := [][]string{{"1", "1"}, {"2", "2"}} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestIntrudeValuesPitchfork(t *testing.T) { + positions := []IntrudePosition{{Index: 0, Base: "a"}, {Index: 1, Base: "b"}} + got, err := intrudeValues(Pitchfork, positions, [][]string{{"1", "2", "3"}, {"x", "y"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Walks both sets in lockstep; stops at the shorter set's length (2), + // silently ignoring "3" from the longer one - Burp's own convention. + want := [][]string{{"1", "x"}, {"2", "y"}} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestIntrudeValuesPitchforkRequiresOneSetPerPosition(t *testing.T) { + positions := []IntrudePosition{{Index: 0, Base: "a"}, {Index: 1, Base: "b"}} + _, err := intrudeValues(Pitchfork, positions, [][]string{{"1", "2"}}) + if err == nil { + t.Fatal("expected error for one payload set across two positions") + } +} + +func TestIntrudeValuesClusterBomb(t *testing.T) { + positions := []IntrudePosition{{Index: 0, Base: "a"}, {Index: 1, Base: "b"}} + got, err := intrudeValues(ClusterBomb, positions, [][]string{{"1", "2"}, {"x", "y"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Every combination - the rightmost (last) position cycles fastest. + want := [][]string{{"1", "x"}, {"1", "y"}, {"2", "x"}, {"2", "y"}} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestIntrudeValuesClusterBombOverCapRejected(t *testing.T) { + positions := []IntrudePosition{{Index: 0, Base: "a"}, {Index: 1, Base: "b"}, {Index: 2, Base: "c"}} + big := make([]string, 20) + for i := range big { + big[i] = fmt.Sprintf("v%d", i) + } + // 20 * 20 * 20 = 8000, comfortably over the 1000 cap. + _, err := intrudeValues(ClusterBomb, positions, [][]string{big, big, big}) + if err == nil { + t.Fatal("expected the request-count cap to reject this attack") + } +} + +func TestIntrudeValuesOverCapRejected(t *testing.T) { + positions := []IntrudePosition{{Index: 0, Base: "a"}} + big := make([]string, maxIntrudeRequests+1) + for i := range big { + big[i] = fmt.Sprintf("v%d", i) + } + if _, err := intrudeValues(Sniper, positions, [][]string{big}); err == nil { + t.Error("Sniper: expected the request-count cap to reject this attack") + } + if _, err := intrudeValues(BatteringRam, positions, [][]string{big}); err == nil { + t.Error("BatteringRam: expected the request-count cap to reject this attack") + } +} + func TestIntrudeRequestCount(t *testing.T) { positions, _, err := ParseMarkers([]byte("GET /§a§/§b§ HTTP/1.1")) if err != nil { |