diff options
| author | srdusr <[email protected]> | 2026-03-27 21:32:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-03-27 21:32:00 +0200 |
| commit | b11e60dcccc836bf67b3720930600f7112f624dc (patch) | |
| tree | 99c263a8d6c386af43df145445c12effcdd5b202 /cmd | |
| parent | 2bb1425dd0da46d8a3df0b888411f21340783d71 (diff) | |
| download | mitmux-b11e60dcccc836bf67b3720930600f7112f624dc.tar.gz mitmux-b11e60dcccc836bf67b3720930600f7112f624dc.zip | |
Intruder payload processing and grep-match/grep-extract
Payload processing: an optional case rule (upper/lower) and an optional
encode rule (URL/Base64/Hex/HTML) applied to every payload line before
it's substituted into the request, cycled with 'c'/'e'. Case always
runs before encode - folding an already-encoded value would corrupt it
(e.g. uppercasing Base64 padding). Applied entirely client-side in
startIntrude() (payload_rules.go): a pure string transform with no
proxy-side state, so it needs no protocol changes and reuses the
Decoder's own urlEncodeAll.
Grep-match/grep-extract: two optional Go regexps, edited with 'm'/'v'
using the same modal edit-buffer pattern as the history list's '/'
search (enter validates-and-commits, esc reverts to the last-confirmed
pattern, an unparseable regexp is rejected with an error rather than
silently accepted). Evaluated server-side, in internal/ipc/server.go's
"intrude" handler, against each result's actual entry.ResponseRaw -
that's where the real response bytes already are, and it's how Burp's
own grep options work (matched against the real response, not a
client-refetched copy). Grep-match flags a result (new Match column);
grep-extract captures the first submatch, or the whole match if the
pattern has no capturing group (new Extract column). Both patterns are
compiled once before the attack starts and apply for that run only, not
retroactively if changed mid-attack.
All four new keys (c/e/m/v) are gated to normal mode, checked in the
view's outer key switch before ever reaching the template/payloads
vi-textareas - otherwise they'd be either untypeable letters or steal
keystrokes mid-edit. Same discipline as the Repeater tab keys.
internal/ipc: Request gained GrepMatch/GrepExtract string fields (for
"intrude"), IntrudeResultMsg gained GrepMatch bool/GrepExtract string,
and the client Intrude() helper takes the two pattern strings as new
trailing parameters.
Verified live in tmux against a running daemon and real httpbin.org
traffic: built a template with a §marked§ query param, payloads 1/2/3,
grep-match `"id": "2"` and grep-extract `"id": "([0-9]+)"`, ran the
attack and confirmed the Match column flagged only the payload=2 row
and Extract correctly pulled 1/2/3 from each response respectively;
cycled case/encode through all states; confirmed an invalid regexp
(`[abc`) is rejected with a visible error and esc correctly reverts to
the last-confirmed pattern instead of committing the invalid one.
(Also confirmed, incidentally: a batch of vi normal-mode two-key
commands like "gg"/"dd" sent as one multi-character tmux send-keys
argument doesn't reliably reach the app as separate keystrokes - a
tmux scripting artifact, not a bug in the vi-mode implementation, which
works correctly when each key is sent as its own event, as any real
keypress would be.)
go build/vet/gofmt/test/mod tidy all clean.
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/mitmux/main.go | 161 | ||||
| -rw-r--r-- | cmd/mitmux/payload_rules.go | 61 | ||||
| -rw-r--r-- | cmd/mitmux/payload_rules_test.go | 31 |
3 files changed, 245 insertions, 8 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index 31491e9..1d6d3c5 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "time" @@ -171,6 +172,24 @@ type model struct { intruderCh <-chan ipc.IntrudeResultMsg intruderClose func() error + // Payload processing: case/encode rules applied to each payload + // line client-side before it's sent (see payload_rules.go). + payloadCase payloadCaseRule + payloadEncode payloadEncodeRule + + // Grep-match/grep-extract: optional regexps evaluated server-side + // against each result's response bytes (see proxy request "intrude" + // handling in internal/ipc/server.go). grepEditing mirrors the + // searching/searchInput pattern used by the history list's '/': + // 0 = not editing, 1 = editing the match pattern, 2 = editing the + // extract pattern; the *Src fields hold the last-confirmed pattern, + // the *Input fields are the live edit buffer. + grepEditing int + grepMatchSrc string + grepExtractSrc string + grepMatchInput textinput.Model + grepExtractInput textinput.Model + daemonStatus *ipc.StatusMsg prevMode viewMode // for the ? help screen's "esc back" target @@ -242,11 +261,13 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) iresultsCols := []table.Column{ {Title: "Pos", Width: 4}, - {Title: "Payload", Width: 24}, + {Title: "Payload", Width: 20}, {Title: "Status", Width: 6}, - {Title: "Size", Width: 10}, + {Title: "Size", Width: 8}, {Title: "Time", Width: 8}, - {Title: "Error", Width: 20}, + {Title: "Match", Width: 5}, + {Title: "Extract", Width: 18}, + {Title: "Error", Width: 14}, } iresults := table.New(table.WithColumns(iresultsCols), table.WithFocused(true)) iresults.SetStyles(st) @@ -255,6 +276,11 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) din.ta.Placeholder = "text to encode/decode" din.ta.ShowLineNumbers = false + gmIn := textinput.New() + gmIn.Placeholder = "regexp - flags a result if it matches the response" + geIn := textinput.New() + geIn.Placeholder = "regexp - extracts first capture group (or whole match) from the response" + return &model{ client: client, subCh: subCh, @@ -271,6 +297,8 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) intruderTemplate: itmpl, intruderPayloads: ipayloads, intruderResults: iresults, + grepMatchInput: gmIn, + grepExtractInput: geIn, decoderInput: din, } } @@ -477,6 +505,13 @@ func (m *model) enterIntruder(d *ipc.EntryDetail) { m.intruderFocus = focusTemplate m.intruderRunning = false m.intruderCount = 0 + m.payloadCase = payloadCaseNone + m.payloadEncode = payloadEncodeNone + m.grepEditing = 0 + m.grepMatchSrc = "" + m.grepExtractSrc = "" + m.grepMatchInput.SetValue("") + m.grepExtractInput.SetValue("") m.mode = viewIntruder m.statusMsg = "wrap positions to fuzz in § (ctrl+g), fill payloads, ctrl+r to start" } @@ -499,12 +534,13 @@ func (m *model) startIntrude() tea.Cmd { var payloads []string for _, line := range strings.Split(m.intruderPayloads.Value(), "\n") { if line != "" { - payloads = append(payloads, line) + payloads = append(payloads, applyPayloadRules(line, 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) + ch, closeFn, err := ipc.Intrude(path, scheme, host, template, payloads, grepMatch, grepExtract) return intrudeStartedMsg{ch: ch, close: closeFn, err: err} } } @@ -649,14 +685,18 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ruleMatch.Width = formWidth m.ruleReplace.Width = formWidth - itmplHeight := (h - 8) / 3 + // h-9 rather than h-8: one extra line reserved for the payload + // rules / grep-match-extract status line in intruderView. + itmplHeight := (h - 9) / 3 ipayloadsHeight := itmplHeight m.intruderTemplate.SetWidth(msg.Width) m.intruderTemplate.SetHeight(itmplHeight) m.intruderPayloads.SetWidth(msg.Width) m.intruderPayloads.SetHeight(ipayloadsHeight) m.intruderResults.SetWidth(msg.Width) - m.intruderResults.SetHeight(h - 8 - itmplHeight - ipayloadsHeight) + m.intruderResults.SetHeight(h - 9 - itmplHeight - ipayloadsHeight) + m.grepMatchInput.Width = msg.Width - 2 + m.grepExtractInput.Width = msg.Width - 2 return m, nil case listLoadedMsg: @@ -1080,6 +1120,51 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case viewIntruder: + // Editing a grep pattern is a modal overlay on top of the + // normal template/payloads/results panes, same pattern as + // the history list's '/' search: enter commits (after + // validating the regexp compiles), esc discards the edit and + // reverts to the last-confirmed pattern. + if m.grepEditing != 0 { + input := &m.grepMatchInput + if m.grepEditing == 2 { + input = &m.grepExtractInput + } + switch msg.String() { + case "enter": + v := input.Value() + if v != "" { + if _, err := regexp.Compile(v); err != nil { + m.statusMsg = "invalid regexp: " + err.Error() + return m, nil + } + } + if m.grepEditing == 1 { + m.grepMatchSrc = v + } else { + m.grepExtractSrc = v + } + m.grepEditing = 0 + input.Blur() + m.statusMsg = "" + return m, nil + case "esc": + if m.grepEditing == 1 { + input.SetValue(m.grepMatchSrc) + } else { + input.SetValue(m.grepExtractSrc) + } + m.grepEditing = 0 + input.Blur() + return m, nil + case "ctrl+c": + return m, tea.Quit + } + var cmd tea.Cmd + *input, cmd = input.Update(msg) + return m, cmd + } + editing := (m.intruderFocus == focusTemplate && m.intruderTemplate.Mode() == viInsert) || (m.intruderFocus == focusPayloads && m.intruderPayloads.Mode() == viInsert) switch msg.String() { @@ -1116,6 +1201,30 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.intruderTemplate.InsertRune('§') } return m, nil + case "c": + if !editing { + m.payloadCase = (m.payloadCase + 1) % payloadCaseRule(len(payloadCaseNames)) + return m, nil + } + case "e": + if !editing { + m.payloadEncode = (m.payloadEncode + 1) % payloadEncodeRule(len(payloadEncodeNames)) + return m, nil + } + case "m": + if !editing { + m.grepEditing = 1 + m.grepMatchInput.SetValue(m.grepMatchSrc) + m.grepMatchInput.CursorEnd() + return m, m.grepMatchInput.Focus() + } + case "v": + if !editing { + m.grepEditing = 2 + m.grepExtractInput.SetValue(m.grepExtractSrc) + m.grepExtractInput.CursorEnd() + return m, m.grepExtractInput.Focus() + } case "tab": m.intruderFocus = (m.intruderFocus + 1) % 3 if m.intruderFocus == focusTemplate { @@ -1344,6 +1453,8 @@ func (m *model) helpView() string { "ctrl+g (Intruder template only) insert a § marker at cursor", "]/[ (Repeater only) next/previous tab", "ctrl+w (Repeater only) close current tab", + "c / e (Intruder only) cycle payload case / encode rule", + "m / v (Intruder only) edit grep-match / grep-extract regexp", ) section("Rules", "a add rule enter / e edit selected", @@ -1588,6 +1699,30 @@ func (m *model) intruderView() string { b.WriteString("\n") b.WriteString(m.intruderPayloads.View()) b.WriteString("\n") + + switch m.grepEditing { + case 1: + b.WriteString("grep-match (regexp): ") + b.WriteString(m.grepMatchInput.View()) + b.WriteString("\n") + case 2: + b.WriteString("grep-extract (regexp): ") + b.WriteString(m.grepExtractInput.View()) + b.WriteString("\n") + default: + grepMatch := m.grepMatchSrc + if grepMatch == "" { + grepMatch = "(none)" + } + grepExtract := m.grepExtractSrc + 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("\n") + } + b.WriteString(m.intruderResults.View()) b.WriteString("\n") @@ -1602,7 +1737,11 @@ func (m *model) intruderView() string { b.WriteString(statusStyle.Render(m.statusMsg)) b.WriteString("\n") } - b.WriteString(helpStyle.Render("i to edit (vi keys) · tab switch pane · ctrl+g insert § · ctrl+r start · enter (results) view · esc back/stop · ? help · ctrl+c quit")) + 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")) + } return b.String() } @@ -1613,12 +1752,18 @@ func intrudeRowsFor(rs []ipc.IntrudeResultMsg) []table.Row { if r.StatusCode == 0 { status = "ERR" } + match := "" + if r.GrepMatch { + match = "✓" + } rows[i] = table.Row{ fmt.Sprintf("%d", r.Position), r.Payload, status, humanBytes(r.RespSize), r.Duration.Round(time.Millisecond).String(), + match, + r.GrepExtract, r.Error, } } diff --git a/cmd/mitmux/payload_rules.go b/cmd/mitmux/payload_rules.go new file mode 100644 index 0000000..6302f54 --- /dev/null +++ b/cmd/mitmux/payload_rules.go @@ -0,0 +1,61 @@ +package main + +import ( + "encoding/base64" + "encoding/hex" + "html" + "strings" +) + +// payloadCaseRule is an optional case transform applied to each Intruder +// payload before it's sent - Burp calls this class of feature "payload +// processing". Applied client-side, before the payload list ever crosses +// the IPC socket, since it's a pure string transform with no proxy-side +// state involved. +type payloadCaseRule int + +const ( + payloadCaseNone payloadCaseRule = iota + payloadCaseUpper + payloadCaseLower +) + +var payloadCaseNames = []string{"off", "upper", "lower"} + +// payloadEncodeRule is an optional encoding applied to each payload after +// the case rule, right before it's substituted into the request template. +type payloadEncodeRule int + +const ( + payloadEncodeNone payloadEncodeRule = iota + payloadEncodeURL + payloadEncodeBase64 + payloadEncodeHex + payloadEncodeHTML +) + +var payloadEncodeNames = []string{"off", "URL", "Base64", "Hex", "HTML"} + +// applyPayloadRules runs the case rule, then the encode rule, over one raw +// payload line. Order matters: case-folding an already-encoded payload +// (e.g. uppercasing "aGVsbG8=") would corrupt it, so case always runs +// first, against the original text. +func applyPayloadRules(s string, c payloadCaseRule, e payloadEncodeRule) string { + switch c { + case payloadCaseUpper: + s = strings.ToUpper(s) + case payloadCaseLower: + s = strings.ToLower(s) + } + switch e { + case payloadEncodeURL: + s = urlEncodeAll(s) + case payloadEncodeBase64: + s = base64.StdEncoding.EncodeToString([]byte(s)) + case payloadEncodeHex: + s = hex.EncodeToString([]byte(s)) + case payloadEncodeHTML: + s = html.EscapeString(s) + } + return s +} diff --git a/cmd/mitmux/payload_rules_test.go b/cmd/mitmux/payload_rules_test.go new file mode 100644 index 0000000..466f14f --- /dev/null +++ b/cmd/mitmux/payload_rules_test.go @@ -0,0 +1,31 @@ +package main + +import "testing" + +func TestApplyPayloadRules(t *testing.T) { + tests := []struct { + name string + input string + c payloadCaseRule + e payloadEncodeRule + want string + }{ + {"no rules", "Hello World", payloadCaseNone, payloadEncodeNone, "Hello World"}, + {"upper only", "Hello World", payloadCaseUpper, payloadEncodeNone, "HELLO WORLD"}, + {"lower only", "Hello World", payloadCaseLower, payloadEncodeNone, "hello world"}, + {"url encode only", "a b/c", payloadCaseNone, payloadEncodeURL, "a%20b%2Fc"}, + {"base64 encode only", "hello", payloadCaseNone, payloadEncodeBase64, "aGVsbG8="}, + {"hex encode only", "hi", payloadCaseNone, payloadEncodeHex, "6869"}, + {"html encode only", "<x>", payloadCaseNone, payloadEncodeHTML, "<x>"}, + {"upper then url encode", "a b", payloadCaseUpper, payloadEncodeURL, "A%20B"}, + {"lower then base64 - case runs before encode", "HELLO", payloadCaseLower, payloadEncodeBase64, "aGVsbG8="}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := applyPayloadRules(tt.input, tt.c, tt.e) + if got != tt.want { + t.Errorf("applyPayloadRules(%q, %v, %v) = %q, want %q", tt.input, tt.c, tt.e, got, tt.want) + } + }) + } +} |