srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-06-11 00:37:00 +0200
committersrdusr <[email protected]>2026-06-11 00:37:00 +0200
commit6114567258bcad0517a0d881168711aaacdba5d5 (patch)
tree6bb9f3af9cfe0c857140a51f7992e3853cb2a236
parenta2362fc08c31b23fb971279f8b160555123ad2f6 (diff)
downloadmitmux-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.
-rw-r--r--PLAN.md20
-rw-r--r--README.md29
-rw-r--r--cmd/mitmux/main.go106
-rw-r--r--internal/ipc/ipc.go48
-rw-r--r--internal/ipc/server.go14
-rw-r--r--internal/proxy/intrude.go233
-rw-r--r--internal/proxy/intrude_test.go118
7 files changed, 451 insertions, 117 deletions
diff --git a/PLAN.md b/PLAN.md
index 8c86d18..87c01cf 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -65,13 +65,19 @@ hudsucker) - same problem, worth studying even though this build is Go.
form isn't possible yet - only rewriting/removing existing ones. The
underlying engine (rules.ApplyHeaders) already supports arbitrary
text-block edits; it's specifically the form UI that's constrained.
-- Step 7 (Intruder-equivalent) shipped Sniper only: one payload set,
- one §marked§ position fuzzed at a time, every other marked position
- held at its base value - the mode that covers most real Intruder
- usage. Battering ram / pitchfork / cluster bomb aren't implemented.
- Sequential sending only (no concurrency), capped at 1000 generated
- requests as a fixed safety limit against an accidental huge wordlist
- combined with several positions. Reuses the Repeater send primitive
+- Step 7 (Intruder-equivalent) now covers all four of Burp's attack
+ modes (proxy.AttackMode: Sniper, BatteringRam, Pitchfork,
+ ClusterBomb). Sniper and BatteringRam only ever need one shared
+ payload set; Pitchfork and ClusterBomb are inherently per-position,
+ so they need one set per §marked§ position - the request-generation
+ logic (intrudeValues) is pure and side-effect free specifically so
+ the request count (positions × payloads for Sniper, a product for
+ ClusterBomb) can be validated against the 1000-request cap before
+ anything is dispatched, and so it's unit-testable without a live
+ target. The TUI reuses the single Payloads pane for per-position sets
+ too, split by a `---` delimiter line, rather than adding a
+ multi-widget payload-set editor. Sequential sending only (no
+ concurrency). Reuses the Repeater send primitive
(proxy.Server.sendRaw) directly - an attack is just that primitive
run in a loop with generated bytes - and results land in the same
history table tagged source="intruder", same as Repeater's
diff --git a/README.md b/README.md
index ea2a885..4d424e6 100644
--- a/README.md
+++ b/README.md
@@ -40,9 +40,10 @@ list of what's deliberately not implemented (and why), see
tabs: sending an entry to Repeater opens a new tab rather than
replacing whatever's already there, so you can iterate on several
requests side by side.
-- **Intruder** (Sniper only): mark positions in a request template
- with `§markers§`, supply a payload list, fuzz one position at a time
- against a shared payload set. Results land in the same history table
+- **Intruder**: mark positions in a request template with
+ `§markers§`, supply payloads, and fuzz them with Sniper, Battering
+ ram, Pitchfork, or Cluster bomb - Burp's own four attack modes.
+ Results land in the same history table
as everything else, searchable the same way. Payload processing
(optional case and encode rules, applied to every payload before it's
sent) and grep-match/grep-extract (flag or pull text out of each
@@ -368,10 +369,24 @@ and `ctrl+w` is the editor's own delete-word-backward while composing).
### Intruder
-Beyond marking `§positions§` and supplying payloads, two more things are
-configurable before `ctrl+r` starts the attack - both normal-mode-only
+Beyond marking `§positions§` and supplying payloads, three more things
+are configurable before `ctrl+r` starts the attack - all normal-mode-only
shortcuts, available from any pane:
+- `a` cycles the **attack mode**: Sniper, Battering ram, Pitchfork,
+ Cluster bomb - Burp's own four, same semantics. Sniper fuzzes one
+ marked position at a time through a single shared payload set, every
+ other position held at its base value. Battering ram sends the same
+ payload, from that same single set, into every marked position at
+ once. Pitchfork and Cluster bomb are inherently per-position - that's
+ their whole point - so they need one payload set per marked position
+ instead of one shared set: put them in the same Payloads pane,
+ separated by a line containing exactly `---`, in position order.
+ Pitchfork walks all sets in lockstep, one request per index, stopping
+ at the shortest set's length. Cluster bomb tries every combination
+ (the last position cycles fastest), so its request count is the
+ product of every set's length - capped at 1000 requests like every
+ other mode, checked before anything is sent.
- `c` / `e` cycle **payload processing**: an optional case rule
(off/upper/lower) and an optional encode rule (off/URL/Base64/Hex/
HTML), shown in the status line above the results table. Applied to
@@ -518,8 +533,8 @@ messages for what was checked and how.
Deliberate scope decisions, not oversights - see `PLAN.md` for the
reasoning behind each:
-- Intruder: Sniper attack only (no battering ram / pitchfork / cluster
- bomb), sequential sending, capped at 1000 requests per attack
+- Intruder: sequential sending only (no concurrent workers), capped at
+ 1000 requests per attack across all four modes
- `mitmuxd -install-ca` prints per-OS trust-store install steps; it
never runs them for you (see Quick start above for why)
- No WebSocket interception
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go
index d11d18b..304d3fc 100644
--- a/cmd/mitmux/main.go
+++ b/cmd/mitmux/main.go
@@ -20,6 +20,7 @@ import (
"mitmux/internal/ca"
"mitmux/internal/ipc"
+ "mitmux/internal/proxy"
"mitmux/internal/rules"
"mitmux/internal/scope"
"mitmux/internal/store"
@@ -199,6 +200,7 @@ type model struct {
intruderResults table.Model
intruderRows []ipc.IntrudeResultMsg
intruderFocus intruderFocus
+ intruderMode proxy.AttackMode
intruderRunning bool
intruderCount int
intruderCh <-chan ipc.IntrudeResultMsg
@@ -331,12 +333,12 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string)
itmpl.ta.ShowLineNumbers = false
ipayloads := newViTextarea()
- ipayloads.ta.Placeholder = "payloads, one per line"
+ ipayloads.ta.Placeholder = "payloads, one per line (pitchfork/cluster bomb: separate one set per position with a line of ---)"
ipayloads.ta.ShowLineNumbers = false
iresultsCols := []table.Column{
- {Title: "Pos", Width: 4},
- {Title: "Payload", Width: 20},
+ {Title: "#", Width: 4},
+ {Title: "Payload(s)", Width: 24},
{Title: "Status", Width: 6},
{Title: "Size", Width: 8},
{Title: "Time", Width: 8},
@@ -708,6 +710,7 @@ func (m *model) enterIntruder(d *ipc.EntryDetail) {
m.intruderRows = nil
setTableRows(&m.intruderResults, nil)
m.intruderFocus = focusTemplate
+ m.intruderMode = proxy.Sniper
m.intruderRunning = false
m.intruderCount = 0
m.payloadCase = payloadCaseNone
@@ -718,7 +721,7 @@ func (m *model) enterIntruder(d *ipc.EntryDetail) {
m.grepMatchInput.SetValue("")
m.grepExtractInput.SetValue("")
m.mode = viewIntruder
- m.statusMsg = "wrap positions to fuzz in § (ctrl+g), fill payloads, ctrl+r to start"
+ m.statusMsg = "wrap positions to fuzz in § (ctrl+g), fill payloads, a to change attack mode, ctrl+r to start"
}
type intrudeStartedMsg struct {
@@ -732,20 +735,58 @@ type intrudeResultMsg struct {
ok bool
}
+// parsePayloadSets turns the Payloads textarea into one or more payload
+// sets. Sniper and BatteringRam only ever need one shared set, so every
+// non-empty line is a payload. Pitchfork and ClusterBomb are inherently
+// per-position, so the same textarea instead holds several sets separated
+// by a line containing exactly "---", in position order - proxy.Intrude
+// validates the count matches the template's marked positions.
+func parsePayloadSets(text string, mode proxy.AttackMode, caseRule payloadCaseRule, encodeRule payloadEncodeRule) [][]string {
+ lines := strings.Split(text, "\n")
+ if mode != proxy.Pitchfork && mode != proxy.ClusterBomb {
+ var set []string
+ for _, line := range lines {
+ if line != "" {
+ set = append(set, applyPayloadRules(line, caseRule, encodeRule))
+ }
+ }
+ if set == nil {
+ return nil
+ }
+ return [][]string{set}
+ }
+
+ var sets [][]string
+ var cur []string
+ flush := func() {
+ if cur != nil {
+ sets = append(sets, cur)
+ cur = nil
+ }
+ }
+ for _, line := range lines {
+ if strings.TrimSpace(line) == "---" {
+ flush()
+ continue
+ }
+ if line != "" {
+ cur = append(cur, applyPayloadRules(line, caseRule, encodeRule))
+ }
+ }
+ flush()
+ return sets
+}
+
func (m *model) startIntrude() tea.Cmd {
scheme, host := m.intruderScheme, m.intruderHost
// Same CRLF restoration as Repeater, same trade-off - see sendRepeat.
template := []byte(strings.ReplaceAll(m.intruderTemplate.Value(), "\n", "\r\n"))
- var payloads []string
- for _, line := range strings.Split(m.intruderPayloads.Value(), "\n") {
- if line != "" {
- payloads = append(payloads, applyPayloadRules(line, m.payloadCase, m.payloadEncode))
- }
- }
+ mode := m.intruderMode
+ payloadSets := parsePayloadSets(m.intruderPayloads.Value(), mode, m.payloadCase, m.payloadEncode)
path := m.socketPath
grepMatch, grepExtract := m.grepMatchSrc, m.grepExtractSrc
return func() tea.Msg {
- ch, closeFn, err := ipc.Intrude(path, scheme, host, template, payloads, grepMatch, grepExtract)
+ ch, closeFn, err := ipc.Intrude(path, scheme, host, template, mode, payloadSets, grepMatch, grepExtract)
return intrudeStartedMsg{ch: ch, close: closeFn, err: err}
}
}
@@ -1752,6 +1793,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.intruderTemplate.InsertRune('§')
}
return m, nil
+ case "a":
+ if !editing && !m.intruderRunning {
+ m.intruderMode = nextAttackMode(m.intruderMode)
+ return m, nil
+ }
case "c":
if !editing {
m.payloadCase = (m.payloadCase + 1) % payloadCaseRule(len(payloadCaseNames))
@@ -2339,6 +2385,34 @@ func scopeRowsFor(rs []scope.Rule) []table.Row {
return rows
}
+// nextAttackMode cycles Sniper -> BatteringRam -> Pitchfork -> ClusterBomb
+// -> Sniper.
+func nextAttackMode(mode proxy.AttackMode) proxy.AttackMode {
+ switch mode {
+ case proxy.Sniper:
+ return proxy.BatteringRam
+ case proxy.BatteringRam:
+ return proxy.Pitchfork
+ case proxy.Pitchfork:
+ return proxy.ClusterBomb
+ default:
+ return proxy.Sniper
+ }
+}
+
+func attackModeLabel(mode proxy.AttackMode) string {
+ switch mode {
+ case proxy.BatteringRam:
+ return "battering ram"
+ case proxy.Pitchfork:
+ return "pitchfork"
+ case proxy.ClusterBomb:
+ return "cluster bomb"
+ default:
+ return "sniper"
+ }
+}
+
func (m *model) intruderView() string {
var b strings.Builder
title := fmt.Sprintf(" intruder - %s://%s ", sanitizeLine(m.intruderScheme), sanitizeLine(m.intruderHost))
@@ -2382,8 +2456,8 @@ func (m *model) intruderView() string {
if grepExtract == "" {
grepExtract = "(none)"
}
- b.WriteString(fmt.Sprintf("payload rules: case=%s encode=%s · grep-match: %s · grep-extract: %s",
- payloadCaseNames[m.payloadCase], payloadEncodeNames[m.payloadEncode], grepMatch, grepExtract))
+ b.WriteString(fmt.Sprintf("attack: %s · payload rules: case=%s encode=%s · grep-match: %s · grep-extract: %s",
+ attackModeLabel(m.intruderMode), payloadCaseNames[m.payloadCase], payloadEncodeNames[m.payloadEncode], grepMatch, grepExtract))
b.WriteString("\n")
}
@@ -2404,7 +2478,7 @@ func (m *model) intruderView() string {
if m.grepEditing != 0 {
b.WriteString(helpStyle.Render("enter confirm · esc cancel · ctrl+c quit"))
} else {
- b.WriteString(helpStyle.Render("i to edit (vi keys) · tab switch pane · ctrl+g insert § · c/e cycle case/encode · m/v edit grep-match/extract · ctrl+r start · enter (results) view · esc back/stop · ? help · ctrl+c quit"))
+ b.WriteString(helpStyle.Render("i to edit (vi keys) · tab switch pane · ctrl+g insert § · a cycle attack mode · c/e cycle case/encode · m/v edit grep-match/extract · ctrl+r start · enter (results) view · esc back/stop · ? help · ctrl+c quit"))
}
return b.String()
}
@@ -2421,8 +2495,8 @@ func intrudeRowsFor(rs []ipc.IntrudeResultMsg) []table.Row {
match = "✓"
}
rows[i] = table.Row{
- fmt.Sprintf("%d", r.Position),
- sanitizeLine(r.Payload),
+ fmt.Sprintf("%d", r.Iteration),
+ sanitizeLine(strings.Join(r.Values, " | ")),
status,
humanBytes(r.RespSize),
r.Duration.Round(time.Millisecond).String(),
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 {