diff options
| -rw-r--r-- | PLAN.md | 20 | ||||
| -rw-r--r-- | README.md | 29 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 106 | ||||
| -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 |
7 files changed, 451 insertions, 117 deletions
@@ -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 @@ -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 { |