diff options
| -rw-r--r-- | PLAN.md | 28 | ||||
| -rw-r--r-- | README.md | 33 | ||||
| -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 | ||||
| -rw-r--r-- | internal/ipc/ipc.go | 46 | ||||
| -rw-r--r-- | internal/ipc/server.go | 30 |
7 files changed, 362 insertions, 28 deletions
@@ -124,10 +124,30 @@ the correct tab (send results carry the tab index they belong to), and the status line/response pane it's shown in only updates live if that tab is still the one on screen. -Still open from "worth considering": Intruder payload processing -(encoding/case rules) and grep-match/grep-extract on results, CA install -UX per OS, multiple proxy listeners and upstream proxy chaining. None of -these are started yet. +Shipped since: 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), cycled with `c`/`e` - is applied +client-side to each payload line before it ever crosses the IPC socket, +case first then encode (encoding an already-case-folded value is safe; +the reverse would corrupt e.g. Base64 padding), since it's a pure string +transform with no proxy-side state involved and reuses the Decoder's own +`urlEncodeAll`. Grep-match/grep-extract are optional Go regexps +(`m`/`v` to edit, both gated to normal mode and both revert-on-esc / +validate-on-enter the same way the history list's `/` search box +works), evaluated server-side in `internal/ipc/server.go`'s "intrude" +handler against each result's actual response bytes - chosen over a +client-side implementation because the daemon already has `entry. +ResponseRaw` in hand right where the result is built, and Burp's own +grep options work the same way (matched against the real response, not +a client-refetched copy). Grep-match flags a result (shown as a Match +column); grep-extract captures the first submatch (or the whole match +if the pattern has no capturing group) into an Extract column. Both are +configured once before `ctrl+r` starts an attack and apply for that run +only - matching Burp, which doesn't retroactively re-grep already-fired +requests if you change the options mid-attack. + +Still open from "worth considering": CA install UX per OS, multiple +proxy listeners and upstream proxy chaining. Neither is started yet. Skipped deliberately (from the research, matches this tool's stated scope): active/passive vulnerability scanning, plugin marketplace, @@ -43,7 +43,11 @@ list of what's deliberately not implemented (and why), see - **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 - as everything else, searchable the same way. + 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 + result's response with a regexp) are both configurable before starting + an attack - see [Intruder](#intruder) below. - **Match-and-replace**: header rewrite rules (add, remove, or modify) for requests and/or responses, applied live as traffic passes through. History still shows what was actually sent/received on each @@ -218,6 +222,33 @@ closes the active one. All three only fire in normal mode, so they don't interfere with typing (`[`/`]` show up in JSON bodies constantly, 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 +shortcuts, available from any pane: + +- `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 + every payload, case first then encode, right before it's substituted + into the request - case-folding an already-encoded value would + corrupt it (e.g. uppercasing Base64 padding), so case always runs on + the original text first. +- `m` / `v` edit **grep-match** / **grep-extract**, each a Go regexp + evaluated against every result's actual response bytes (same `enter` + confirms / `esc` cancels pattern as the history list's `/` search - an + invalid regexp is rejected with an error rather than silently + accepted). Grep-match flags a result (a `Match` column) if the pattern + is found anywhere in the response; grep-extract captures the first + submatch - or the whole match, if the pattern has no capturing group - + into an `Extract` column. Both are optional and independent; leave + either blank to skip that check. + +Both settings apply for the attack you're about to start - changing +them mid-run doesn't retroactively re-evaluate requests already sent, +matching Burp's own behavior. + ### Match-and-replace rules Press `m` from the history view. Rules match request or response 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) + } + }) + } +} diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 7d81473..270b8e4 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -38,6 +38,16 @@ type Request struct { // turn (Sniper-style - see proxy.Intrude). Payloads []string `json:"payloads,omitempty"` + // For "intrude": optional Go regexps evaluated against each result's + // response bytes. GrepMatch flags whether it matched at all; + // GrepExtract additionally captures text (first submatch if the + // pattern has a capturing group, else the whole match) into the + // result. Either or both may be empty to skip that check. Compiled + // and validated once, server-side, before the attack starts - a bad + // pattern fails the same way a bad marker or empty payload set does. + GrepMatch string `json:"grep_match,omitempty"` + GrepExtract string `json:"grep_extract,omitempty"` + // For "rules_save": add (Rule.ID == 0) or update (Rule.ID != 0) a // match-and-replace rule. For "rules_delete"/"rules_toggle": RuleID // (and RuleEnabled for toggle) identify the target. @@ -72,13 +82,15 @@ type StatusMsg struct { // IntrudeResultMsg is one completed Intruder attack request. type IntrudeResultMsg struct { - Position int `json:"position"` - Payload string `json:"payload"` - EntryID int64 `json:"entry_id"` - StatusCode int `json:"status_code"` - RespSize int `json:"resp_size"` - Duration time.Duration `json:"duration"` - Error string `json:"error,omitempty"` + Position int `json:"position"` + Payload string `json:"payload"` + EntryID int64 `json:"entry_id"` + StatusCode int `json:"status_code"` + RespSize int `json:"resp_size"` + Duration time.Duration `json:"duration"` + Error string `json:"error,omitempty"` + GrepMatch bool `json:"grep_match,omitempty"` + GrepExtract string `json:"grep_extract,omitempty"` } // EntryDetail is a full history entry, raw bytes included. @@ -307,18 +319,22 @@ func Subscribe(path string) (<-chan store.Summary, func() error, error) { // Intrude starts a Sniper attack (see proxy.Intrude): template must // contain at least one §marked§ position, fuzzed in turn through -// payloads. 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) 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) (<-chan IntrudeResultMsg, func() error, error) { +// 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) { conn, err := net.Dial("unix", path) if err != nil { return nil, nil, fmt.Errorf("dial %s: %w", path, err) } - if err := json.NewEncoder(conn).Encode(Request{Type: "intrude", Scheme: scheme, Host: host, Raw: template, Payloads: payloads}); err != nil { + req := Request{Type: "intrude", Scheme: scheme, Host: host, Raw: template, Payloads: payloads, 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 28279a7..9602912 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -6,6 +6,7 @@ import ( "io" "log" "net" + "regexp" "sync" "mitmux/internal/rules" @@ -155,6 +156,23 @@ func (s *Server) handleConn(conn net.Conn) { enc.Encode(Response{Type: "error", Error: "intruder not available"}) continue } + var grepMatchRe, grepExtractRe *regexp.Regexp + if req.GrepMatch != "" { + re, err := regexp.Compile(req.GrepMatch) + if err != nil { + enc.Encode(Response{Type: "error", Error: "grep-match: " + err.Error()}) + continue + } + grepMatchRe = re + } + if req.GrepExtract != "" { + re, err := regexp.Compile(req.GrepExtract) + if err != nil { + enc.Encode(Response{Type: "error", Error: "grep-extract: " + err.Error()}) + continue + } + 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} @@ -169,6 +187,18 @@ func (s *Server) handleConn(conn net.Conn) { if entry.Error != "" && r.Error == "" { r.Error = entry.Error } + if grepMatchRe != nil { + r.GrepMatch = grepMatchRe.Match(entry.ResponseRaw) + } + if grepExtractRe != nil { + if m := grepExtractRe.FindSubmatch(entry.ResponseRaw); m != nil { + if len(m) > 1 { + r.GrepExtract = string(m[1]) + } else { + r.GrepExtract = string(m[0]) + } + } + } } return enc.Encode(Response{Type: "intrude_result", IntrudeResult: &r}) == nil }) |