diff options
| author | srdusr <[email protected]> | 2026-06-11 00:37:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-06-11 00:37:00 +0200 |
| commit | 6114567258bcad0517a0d881168711aaacdba5d5 (patch) | |
| tree | 6bb9f3af9cfe0c857140a51f7992e3853cb2a236 /internal/proxy | |
| parent | a2362fc08c31b23fb971279f8b160555123ad2f6 (diff) | |
| download | mitmux-6114567258bcad0517a0d881168711aaacdba5d5.tar.gz mitmux-6114567258bcad0517a0d881168711aaacdba5d5.zip | |
Intruder: Battering ram, Pitchfork, and Cluster bomb attack modes
Generalizes Intrude beyond Sniper to all four of Burp's attack modes
(proxy.AttackMode). Sniper and Battering ram only ever need one shared
payload set; Pitchfork and Cluster bomb are inherently per-position, so
they take one payload set per §marked§ position instead.
Request-set generation (intrudeValues) is pure and side-effect free,
so the total request count is validated against the existing 1000
cap before anything is dispatched - Cluster bomb's product is checked
incrementally, one payload set at a time, so a pathological product
bails out before ever trying to enumerate it. This also makes the
combinatorics unit-testable without a live target.
IntrudeResultMsg now reports Values (one substitution per marked
position, in order) instead of a single Position/Payload pair, since
three of the four modes touch multiple positions per request.
TUI: `a` cycles the attack mode. Pitchfork/Cluster bomb reuse the
existing single Payloads pane rather than a new multi-widget editor -
sets are separated by a `---` delimiter line, in position order.
Verified live against a real daemon: all four modes produce the
expected substitution values and request counts, and pitchfork
correctly rejects a payload-set count that doesn't match the
template's marked positions.
Diffstat (limited to 'internal/proxy')
| -rw-r--r-- | internal/proxy/intrude.go | 233 | ||||
| -rw-r--r-- | internal/proxy/intrude_test.go | 118 |
2 files changed, 289 insertions, 62 deletions
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 { |