From 6114567258bcad0517a0d881168711aaacdba5d5 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:37:00 +0200 Subject: Intruder: Battering ram, Pitchfork, and Cluster bomb attack modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/proxy/intrude_test.go | 118 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 104 insertions(+), 14 deletions(-) (limited to 'internal/proxy/intrude_test.go') 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 { -- cgit v1.2.3