srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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 {