diff options
| author | srdusr <[email protected]> | 2026-02-03 00:38:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-02-03 00:38:00 +0200 |
| commit | ce6ce32469da720105258cb66e0274b2b009cd1d (patch) | |
| tree | bf1766efcdb323755e5cf3128a74b137eeb34b88 | |
| parent | c2443f27ef5a844f045c038c7689d217d1dbf0c4 (diff) | |
| download | mitmux-ce6ce32469da720105258cb66e0274b2b009cd1d.tar.gz mitmux-ce6ce32469da720105258cb66e0274b2b009cd1d.zip | |
Intruder-equivalent: Sniper attacks with § markers
Implements build-order step 7, the last (optional) item. Scoped to
Sniper only - one payload set, one §-marked position fuzzed at a time,
others held at their base value - since that covers most real Intruder
usage; battering ram / pitchfork / cluster bomb aren't implemented.
Sequential sending, capped at 1000 generated requests as a fixed safety
limit.
internal/proxy: repeat.go's Repeat() is refactored into a shared
sendRaw(..., source) primitive so Intrude can reuse the exact same
raw-byte send/record path with source="intruder" instead of
duplicating it. intrude.go adds ParseMarkers/buildRequest (marker
parsing and payload substitution, covered by intrude_test.go - this is
fiddly byte-splicing logic, worth locking down with real tests rather
than trusting it by inspection) and Intrude(), which walks positions ×
payloads calling sendRaw and streaming each result through a callback.
internal/ipc gains a dedicated streaming "intrude" connection (same
shape as Subscribe, but blocking sends rather than drop-on-slow-
consumer - each result is the attack's actual data, not a
notification). cmd/mitmux gains an Intruder view: editable request
template (ctrl+p inserts a § marker at the cursor - typing § directly
also works, ctrl+p just doesn't require a keyboard layout that can
produce it), editable payload list, and a live results table wired to
the existing detail view (selecting a row and hitting enter opens the
full request/response for that specific attack request).
Verified live against real external traffic: a Sniper attack against
httpbin.org/status/§200§ with payloads 200/404/500 produced exactly the
three corresponding real status codes back (not a canned/local result),
confirmed the three requests landed in history tagged source="intruder"
with the § markers correctly stripped from what was actually sent, and
confirmed opening a result row's full detail from the results table.
This closes out the full build order from PLAN.md (steps 1-7).
| -rw-r--r-- | PLAN.md | 11 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 313 | ||||
| -rw-r--r-- | internal/ipc/ipc.go | 86 | ||||
| -rw-r--r-- | internal/ipc/server.go | 44 | ||||
| -rw-r--r-- | internal/proxy/intrude.go | 124 | ||||
| -rw-r--r-- | internal/proxy/intrude_test.go | 116 | ||||
| -rw-r--r-- | internal/proxy/repeat.go | 28 |
7 files changed, 681 insertions, 41 deletions
@@ -63,3 +63,14 @@ 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 + (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 + source="repeater", rather than a separate results store. diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index 68d42ed..c53f975 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -58,7 +58,7 @@ func main() { } defer subClose() - m := newModel(client, subCh) + m := newModel(client, subCh, path) p := tea.NewProgram(m, tea.WithAltScreen()) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "mitmux: %v\n", err) @@ -73,6 +73,7 @@ const ( viewDetail viewRepeater viewRules + viewIntruder ) type detailTab int @@ -99,9 +100,18 @@ const ( fieldRegex ) +type intruderFocus int + +const ( + focusTemplate intruderFocus = iota + focusPayloads + focusResults +) + type model struct { - client *ipc.Client - subCh <-chan store.Summary + client *ipc.Client + subCh <-chan store.Summary + socketPath string mode viewMode entries []store.Summary @@ -137,13 +147,25 @@ type model struct { ruleRegex bool ruleField ruleField + intruderScheme string + intruderHost string + intruderTemplate textarea.Model + intruderPayloads textarea.Model + intruderResults table.Model + intruderRows []ipc.IntrudeResultMsg + intruderFocus intruderFocus + intruderRunning bool + intruderCount int + intruderCh <-chan ipc.IntrudeResultMsg + intruderClose func() error + statusMsg string width int height int ready bool } -func newModel(client *ipc.Client, subCh <-chan store.Summary) *model { +func newModel(client *ipc.Client, subCh <-chan store.Summary, socketPath string) *model { columns := []table.Column{ {Title: "ID", Width: 5}, {Title: "Method", Width: 7}, @@ -188,18 +210,41 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary) *model { replaceIn := textinput.New() replaceIn.Placeholder = "replacement" + itmpl := textarea.New() + itmpl.Placeholder = "raw request bytes - wrap positions to fuzz in § markers, e.g. /users/§123§" + itmpl.ShowLineNumbers = false + + ipayloads := textarea.New() + ipayloads.Placeholder = "payloads, one per line" + ipayloads.ShowLineNumbers = false + + iresultsCols := []table.Column{ + {Title: "Pos", Width: 4}, + {Title: "Payload", Width: 24}, + {Title: "Status", Width: 6}, + {Title: "Size", Width: 10}, + {Title: "Time", Width: 8}, + {Title: "Error", Width: 20}, + } + iresults := table.New(table.WithColumns(iresultsCols), table.WithFocused(true)) + iresults.SetStyles(st) + return &model{ - client: client, - subCh: subCh, - mode: viewList, - table: t, - reqArea: ta, - searchInput: si, - rulesTable: rt, - ruleName: nameIn, - ruleMatch: matchIn, - ruleReplace: replaceIn, - ruleScope: "request", + client: client, + subCh: subCh, + socketPath: socketPath, + mode: viewList, + table: t, + reqArea: ta, + searchInput: si, + rulesTable: rt, + ruleName: nameIn, + ruleMatch: matchIn, + ruleReplace: replaceIn, + ruleScope: "request", + intruderTemplate: itmpl, + intruderPayloads: ipayloads, + intruderResults: iresults, } } @@ -213,10 +258,13 @@ type newEntryMsg struct { ok bool } +// detailLoadedMsg carries a freshly loaded entry, plus where to route it: +// "" for the plain detail view, "repeater" or "intruder" to seed and +// jump straight to those views instead. type detailLoadedMsg struct { - detail *ipc.EntryDetail - err error - openRepeater bool + detail *ipc.EntryDetail + err error + dest string } type repeatSentMsg struct { @@ -240,10 +288,10 @@ func (m *model) waitForEntry() tea.Msg { return newEntryMsg{entry: e, ok: ok} } -func (m *model) loadDetail(id int64, openRepeater bool) tea.Cmd { +func (m *model) loadDetail(id int64, dest string) tea.Cmd { return func() tea.Msg { d, err := m.client.Get(id) - return detailLoadedMsg{detail: d, err: err, openRepeater: openRepeater} + return detailLoadedMsg{detail: d, err: err, dest: dest} } } @@ -275,6 +323,57 @@ func (m *model) enterRepeater(d *ipc.EntryDetail) { m.statusMsg = "" } +// enterIntruder seeds the Intruder view from an already-loaded entry. +// The template starts with no § markers - the user adds them by hand +// (or ctrl+p at the cursor) around whatever they want to fuzz. +func (m *model) enterIntruder(d *ipc.EntryDetail) { + m.intruderScheme = d.Scheme + m.intruderHost = d.Host + m.intruderTemplate.SetValue(strings.ReplaceAll(string(d.RequestRaw), "\r\n", "\n")) + m.intruderTemplate.Focus() + m.intruderPayloads.Blur() + m.intruderRows = nil + m.intruderResults.SetRows(nil) + m.intruderFocus = focusTemplate + m.intruderRunning = false + m.intruderCount = 0 + m.mode = viewIntruder + m.statusMsg = "wrap positions to fuzz in § (ctrl+p), fill payloads, ctrl+r to start" +} + +type intrudeStartedMsg struct { + ch <-chan ipc.IntrudeResultMsg + close func() error + err error +} + +type intrudeResultMsg struct { + result ipc.IntrudeResultMsg + ok bool +} + +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, line) + } + } + path := m.socketPath + return func() tea.Msg { + ch, closeFn, err := ipc.Intrude(path, scheme, host, template, payloads) + return intrudeStartedMsg{ch: ch, close: closeFn, err: err} + } +} + +func (m *model) waitForIntrudeResult() tea.Msg { + r, ok := <-m.intruderCh + return intrudeResultMsg{result: r, ok: ok} +} + type rulesLoadedMsg struct { rules []rules.Rule err error @@ -400,6 +499,15 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ruleName.Width = formWidth m.ruleMatch.Width = formWidth m.ruleReplace.Width = formWidth + + itmplHeight := (msg.Height - 8) / 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(msg.Height - 8 - itmplHeight - ipayloadsHeight) return m, nil case listLoadedMsg: @@ -433,9 +541,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusMsg = "get error: " + msg.err.Error() return m, nil } - if msg.openRepeater { + switch msg.dest { + case "repeater": m.enterRepeater(msg.detail) return m, nil + case "intruder": + m.enterIntruder(msg.detail) + return m, nil } m.detail = msg.detail m.activeTab = tabRequest @@ -473,6 +585,30 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusMsg = "rule " + msg.action return m, m.loadRules + case intrudeStartedMsg: + if msg.err != nil { + m.intruderRunning = false + m.statusMsg = "start error: " + msg.err.Error() + return m, nil + } + m.intruderCh = msg.ch + m.intruderClose = msg.close + m.intruderRunning = true + m.intruderCount = 0 + m.statusMsg = "attack running..." + return m, m.waitForIntrudeResult + + case intrudeResultMsg: + if !msg.ok { + m.intruderRunning = false + m.statusMsg = fmt.Sprintf("attack finished (%d requests)", m.intruderCount) + return m, nil + } + m.intruderCount++ + m.intruderRows = append(m.intruderRows, msg.result) + m.intruderResults.SetRows(intrudeRowsFor(m.intruderRows)) + return m, m.waitForIntrudeResult + case tea.KeyMsg: switch m.mode { case viewList: @@ -502,12 +638,17 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if row := m.table.Cursor(); row >= 0 && row < len(m.entries) { m.mode = viewDetail m.statusMsg = "" - return m, m.loadDetail(m.entries[row].ID, false) + return m, m.loadDetail(m.entries[row].ID, "") } case "r": if row := m.table.Cursor(); row >= 0 && row < len(m.entries) { m.statusMsg = "" - return m, m.loadDetail(m.entries[row].ID, true) + return m, m.loadDetail(m.entries[row].ID, "repeater") + } + case "i": + if row := m.table.Cursor(); row >= 0 && row < len(m.entries) { + m.statusMsg = "" + return m, m.loadDetail(m.entries[row].ID, "intruder") } case "/": m.searching = true @@ -542,6 +683,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.enterRepeater(m.detail) } return m, nil + case "i": + if m.detail != nil { + m.enterIntruder(m.detail) + } + return m, nil case "tab": if m.activeTab == tabRequest { m.activeTab = tabResponse @@ -662,6 +808,65 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.rulesTable, cmd = m.rulesTable.Update(msg) return m, cmd + + case viewIntruder: + switch msg.String() { + case "esc": + if m.intruderRunning && m.intruderClose != nil { + m.intruderClose() + m.intruderRunning = false + } + m.mode = viewList + m.intruderTemplate.Blur() + return m, nil + case "ctrl+c": + return m, tea.Quit + case "ctrl+r": + if !m.intruderRunning { + m.statusMsg = "starting attack..." + return m, m.startIntrude() + } + return m, nil + case "ctrl+p": + if m.intruderFocus == focusTemplate { + m.intruderTemplate.InsertRune('§') + } + return m, nil + case "tab": + m.intruderFocus = (m.intruderFocus + 1) % 3 + if m.intruderFocus == focusTemplate { + m.intruderTemplate.Focus() + m.intruderPayloads.Blur() + } else if m.intruderFocus == focusPayloads { + m.intruderTemplate.Blur() + m.intruderPayloads.Focus() + } else { + m.intruderTemplate.Blur() + m.intruderPayloads.Blur() + } + return m, nil + case "enter": + if m.intruderFocus == focusResults { + if row := m.intruderResults.Cursor(); row >= 0 && row < len(m.intruderRows) { + id := m.intruderRows[row].EntryID + if id != 0 { + m.mode = viewDetail + return m, m.loadDetail(id, "") + } + } + return m, nil + } + } + var cmd tea.Cmd + switch m.intruderFocus { + case focusTemplate: + m.intruderTemplate, cmd = m.intruderTemplate.Update(msg) + case focusPayloads: + m.intruderPayloads, cmd = m.intruderPayloads.Update(msg) + case focusResults: + m.intruderResults, cmd = m.intruderResults.Update(msg) + } + return m, cmd } } return m, nil @@ -681,6 +886,8 @@ func (m *model) View() string { return m.ruleFormView() } return m.rulesView() + case viewIntruder: + return m.intruderView() default: return m.listView() } @@ -712,9 +919,9 @@ func (m *model) listView() string { b.WriteString(statusStyle.Render(m.statusMsg)) b.WriteString("\n") } - help := "↑/↓ navigate · enter view · r repeater · / search · m rules · q quit" + help := "↑/↓ navigate · enter view · r repeater · i intruder · / search · m rules · q quit" if m.query != "" { - help = "↑/↓ navigate · enter view · r repeater · / search · m rules · esc clear filter · q quit" + help = "↑/↓ navigate · enter view · r repeater · i intruder · / search · m rules · esc clear filter · q quit" } b.WriteString(helpStyle.Render(help)) return b.String() @@ -742,7 +949,7 @@ func (m *model) detailView() string { b.WriteString("\n") b.WriteString(m.viewport.View()) b.WriteString("\n") - b.WriteString(helpStyle.Render("tab switch · ↑/↓ scroll · r repeater · esc back · q quit")) + b.WriteString(helpStyle.Render("tab switch · ↑/↓ scroll · r repeater · i intruder · esc back · q quit")) return b.String() } @@ -846,6 +1053,60 @@ func rulesRowsFor(rs []rules.Rule) []table.Row { return rows } +func (m *model) intruderView() string { + var b strings.Builder + title := fmt.Sprintf(" intruder - %s://%s ", m.intruderScheme, m.intruderHost) + if m.intruderRunning { + title = fmt.Sprintf(" intruder - %s://%s (running, %d sent) ", m.intruderScheme, m.intruderHost, m.intruderCount) + } + b.WriteString(titleStyle.Render(title)) + b.WriteString("\n") + + label := func(focus intruderFocus, text string) string { + if m.intruderFocus == focus { + return tabActive.Render(text) + } + return tabInactive.Render(text) + } + b.WriteString(label(focusTemplate, "Template (§mark§ positions)")) + b.WriteString(label(focusPayloads, "Payloads")) + b.WriteString(label(focusResults, fmt.Sprintf("Results (%d)", len(m.intruderRows)))) + b.WriteString("\n") + + b.WriteString(m.intruderTemplate.View()) + b.WriteString("\n") + b.WriteString(m.intruderPayloads.View()) + b.WriteString("\n") + b.WriteString(m.intruderResults.View()) + b.WriteString("\n") + + if m.statusMsg != "" { + b.WriteString(statusStyle.Render(m.statusMsg)) + b.WriteString("\n") + } + b.WriteString(helpStyle.Render("tab switch pane · ctrl+p insert § · ctrl+r start · enter (results) view · esc back/stop · ctrl+c quit")) + return b.String() +} + +func intrudeRowsFor(rs []ipc.IntrudeResultMsg) []table.Row { + rows := make([]table.Row, len(rs)) + for i, r := range rs { + status := fmt.Sprintf("%d", r.StatusCode) + if r.StatusCode == 0 { + status = "ERR" + } + rows[i] = table.Row{ + fmt.Sprintf("%d", r.Position), + r.Payload, + status, + humanBytes(r.RespSize), + r.Duration.Round(time.Millisecond).String(), + r.Error, + } + } + return rows +} + func exactSuffix(exact bool) string { if exact { return ", exact" diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 19c9c23..03a4bc7 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -10,6 +10,7 @@ import ( "fmt" "net" "sync" + "time" "mitmux/internal/rules" "mitmux/internal/store" @@ -17,7 +18,7 @@ import ( // Request is sent by a client to the daemon. type Request struct { - Type string `json:"type"` // "list", "get", "subscribe", "repeat", "rules_list", "rules_save", "rules_delete", or "rules_toggle" + Type string `json:"type"` // "list", "get", "subscribe", "repeat", "intrude", "rules_list", "rules_save", "rules_delete", or "rules_toggle" Limit int `json:"limit,omitempty"` BeforeID int64 `json:"before_id,omitempty"` ID int64 `json:"id,omitempty"` @@ -28,10 +29,15 @@ type Request struct { Query string `json:"query,omitempty"` // For "repeat": send Raw to scheme://host exactly as given. + // For "intrude": Raw is the §marked§ template - see proxy.Intrude. Scheme string `json:"scheme,omitempty"` 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 "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. @@ -42,12 +48,27 @@ type Request struct { // Response is sent by the daemon to a client. type Response struct { - Type string `json:"type"` // "list", "get", "new", "repeat", "rules", or "error" + Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "intrude_result", "intrude_done", or "error" Entries []store.Summary `json:"entries,omitempty"` // for "list" Detail *EntryDetail `json:"detail,omitempty"` // for "get" and "repeat" New *store.Summary `json:"new,omitempty"` // for "new" (subscribe push) Rules []rules.Rule `json:"rules,omitempty"` // for "rules" - Error string `json:"error,omitempty"` + + // For "intrude_result": one completed attack request. + IntrudeResult *IntrudeResultMsg `json:"intrude_result,omitempty"` + + Error string `json:"error,omitempty"` +} + +// 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"` } // EntryDetail is a full history entry, raw bytes included. @@ -237,3 +258,62 @@ 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. 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) { + 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 { + conn.Close() + return nil, nil, err + } + + dec := json.NewDecoder(conn) + var first Response + if err := dec.Decode(&first); err != nil { + conn.Close() + return nil, nil, err + } + if first.Type == "error" { + conn.Close() + return nil, nil, errors.New(first.Error) + } + + ch := make(chan IntrudeResultMsg) + go func() { + defer close(ch) + deliver := func(resp Response) bool { + switch resp.Type { + case "intrude_result": + if resp.IntrudeResult != nil { + ch <- *resp.IntrudeResult + } + return true + default: // "intrude_done", or anything else - stop + return false + } + } + if !deliver(first) { + return + } + for { + var resp Response + if err := dec.Decode(&resp); err != nil { + return + } + if !deliver(resp) { + return + } + } + }() + return ch, conn.Close, nil +} diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 1fe8d8f..50dff7f 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -18,6 +18,13 @@ 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 - +// 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 +} + // Hub fans out newly captured history entries to subscribed clients. type Hub struct { mu sync.Mutex @@ -62,12 +69,17 @@ type Server struct { db *store.Store hub *Hub repeater Repeater + intruder Intruder } // NewServer creates a control-protocol Server backed by db, broadcasting -// through hub and sending Repeater requests through rep. +// through hub and sending Repeater/Intruder requests through rep. func NewServer(db *store.Store, hub *Hub, rep Repeater) *Server { - return &Server{db: db, hub: hub, repeater: rep} + s := &Server{db: db, hub: hub, repeater: rep} + if in, ok := rep.(Intruder); ok { + s.intruder = in + } + return s } // Serve accepts connections on ln until it returns an error (e.g. the @@ -128,6 +140,34 @@ func (s *Server) handleConn(conn net.Conn) { } enc.Encode(Response{Type: "repeat", Detail: detailFromEntry(e)}) + case "intrude": + if s.intruder == nil { + enc.Encode(Response{Type: "error", Error: "intruder not available"}) + continue + } + 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} + if sendErr != nil { + r.Error = sendErr.Error() + } + if entry != nil { + r.EntryID = entry.ID + r.StatusCode = entry.StatusCode + r.RespSize = len(entry.ResponseRaw) + r.Duration = entry.Duration + if entry.Error != "" && r.Error == "" { + r.Error = entry.Error + } + } + return enc.Encode(Response{Type: "intrude_result", IntrudeResult: &r}) == nil + }) + if err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "intrude_done"}) + case "rules_list": rs, err := s.db.ListRules() if err != nil { diff --git a/internal/proxy/intrude.go b/internal/proxy/intrude.go new file mode 100644 index 0000000..933a309 --- /dev/null +++ b/internal/proxy/intrude.go @@ -0,0 +1,124 @@ +// 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. +package proxy + +import ( + "bytes" + "context" + "fmt" + + "mitmux/internal/store" +) + +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. +const maxIntrudeRequests = 1000 + +// IntrudePosition is one marked, resolved insertion point. +type IntrudePosition struct { + Index int // 0-based, in order of appearance + Base string // the text between its markers +} + +// ParseMarkers finds every §base§ pair in template and returns the +// resolved positions plus template with the markers stripped out (the +// form actually used as the base request when no position is being +// fuzzed). An odd number of § markers is a user error - unterminated +// marker - reported rather than guessed at. +func ParseMarkers(template []byte) (positions []IntrudePosition, stripped []byte, err error) { + parts := bytes.Split(template, []byte(marker)) + if len(parts)%2 != 1 { + return nil, nil, fmt.Errorf("unterminated %s marker - markers must come in pairs", marker) + } + if len(parts) == 1 { + return nil, template, nil + } + + var buf bytes.Buffer + for i, part := range parts { + if i%2 == 1 { + positions = append(positions, IntrudePosition{Index: len(positions), Base: string(part)}) + } + buf.Write(part) + } + 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) { + parts := bytes.Split(template, []byte(marker)) + if len(parts)%2 != 1 { + return nil, fmt.Errorf("unterminated %s marker", marker) + } + var buf bytes.Buffer + pos := 0 + for i, part := range parts { + if i%2 == 1 { + if pos == active { + buf.WriteString(payload) + } else { + buf.Write(part) + } + pos++ + continue + } + buf.Write(part) + } + 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 + } + if len(positions) == 0 { + return fmt.Errorf("no %s-marked positions in the request template", marker) + } + if len(payloads) == 0 { + return 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) + } + + for _, pos := range positions { + for _, payload := range payloads { + raw, err := buildRequest(template, pos.Index, payload) + if err != nil { + return err + } + + // 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(pos.Index, payload, e, sendErr) { + return nil + } + } + } + return nil +} diff --git a/internal/proxy/intrude_test.go b/internal/proxy/intrude_test.go new file mode 100644 index 0000000..5df80e6 --- /dev/null +++ b/internal/proxy/intrude_test.go @@ -0,0 +1,116 @@ +package proxy + +import ( + "reflect" + "testing" +) + +func TestParseMarkers(t *testing.T) { + tests := []struct { + name string + template string + wantPos []IntrudePosition + wantOut string + wantErr bool + }{ + { + name: "single position", + template: "GET /users/§123§ HTTP/1.1", + wantPos: []IntrudePosition{{Index: 0, Base: "123"}}, + wantOut: "GET /users/123 HTTP/1.1", + }, + { + name: "two positions", + template: "GET /a/§1§/b/§2§ HTTP/1.1", + wantPos: []IntrudePosition{{Index: 0, Base: "1"}, {Index: 1, Base: "2"}}, + wantOut: "GET /a/1/b/2 HTTP/1.1", + }, + { + name: "no markers", + template: "GET / HTTP/1.1", + wantPos: nil, + wantOut: "GET / HTTP/1.1", + }, + { + name: "empty marker", + template: "GET /§§ HTTP/1.1", + wantPos: []IntrudePosition{{Index: 0, Base: ""}}, + wantOut: "GET / HTTP/1.1", + }, + { + name: "unterminated marker", + template: "GET /§broken HTTP/1.1", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pos, out, err := ParseMarkers([]byte(tt.template)) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(pos, tt.wantPos) { + t.Errorf("positions = %+v, want %+v", pos, tt.wantPos) + } + if string(out) != tt.wantOut { + t.Errorf("stripped = %q, want %q", out, tt.wantOut) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + template := "GET /a/§1§/b/§2§/c/§3§ HTTP/1.1" + + tests := []struct { + active int + payload 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"}, + } + + for _, tt := range tests { + got, err := buildRequest([]byte(template), tt.active, tt.payload) + if err != nil { + t.Fatalf("active=%d: unexpected error: %v", tt.active, err) + } + if string(got) != tt.want { + t.Errorf("active=%d: got %q, want %q", tt.active, got, tt.want) + } + } +} + +func TestBuildRequestPayloadContainingMarkerChar(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. + template := "GET /§1§/§2§ HTTP/1.1" + got, err := buildRequest([]byte(template), 0, "§injected§") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "GET /§injected§/2 HTTP/1.1" + if string(got) != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestIntrudeRequestCount(t *testing.T) { + positions, _, err := ParseMarkers([]byte("GET /§a§/§b§ HTTP/1.1")) + if err != nil { + t.Fatal(err) + } + if len(positions) != 2 { + t.Fatalf("expected 2 positions, got %d", len(positions)) + } +} diff --git a/internal/proxy/repeat.go b/internal/proxy/repeat.go index cecf481..3ee7cea 100644 --- a/internal/proxy/repeat.go +++ b/internal/proxy/repeat.go @@ -25,38 +25,46 @@ import ( // HTTP/2's binary framing, so the connection is negotiated HTTP/1.1-only // rather than letting the server pick. func (s *Server) Repeat(ctx context.Context, scheme, host string, raw []byte) (*store.Entry, error) { + return s.sendRaw(ctx, scheme, host, raw, "repeater") +} + +// sendRaw is the shared raw-byte send/record primitive behind Repeat and +// Intrude - same wire behavior (exact bytes, HTTP/1.1-only, bounded by +// upstreamTimeout), tagged with whichever source called it so history +// can tell repeater sends from intruder attack requests apart. +func (s *Server) sendRaw(ctx context.Context, scheme, host string, raw []byte, source string) (*store.Entry, error) { started := time.Now() method, path := parseRequestLine(raw) conn, err := dialForRepeat(ctx, scheme, host) if err != nil { - return s.recordRepeat(started, time.Since(started), scheme, host, method, path, raw, nil, 0, err.Error()) + return s.recordRaw(started, time.Since(started), scheme, host, method, path, raw, nil, 0, err.Error(), source) } defer conn.Close() // See the matching comment in forward(): without this, a hung // server - or a user-edited request malformed enough that nothing - // ever replies - blocks this Repeat call, and the IPC connection - // handling it, forever. + // ever replies - blocks this call, and whatever's waiting on it + // (an IPC connection, or an entire Intruder attack), forever. conn.SetDeadline(time.Now().Add(upstreamTimeout)) if _, err := conn.Write(raw); err != nil { - return s.recordRepeat(started, time.Since(started), scheme, host, method, path, raw, nil, 0, err.Error()) + return s.recordRaw(started, time.Since(started), scheme, host, method, path, raw, nil, 0, err.Error(), source) } tee := newTeeConn(conn) resp, err := http.ReadResponse(bufio.NewReader(tee), &http.Request{Method: method}) duration := time.Since(started) if err != nil { - return s.recordRepeat(started, duration, scheme, host, method, path, raw, nil, 0, err.Error()) + return s.recordRaw(started, duration, scheme, host, method, path, raw, nil, 0, err.Error(), source) } defer resp.Body.Close() io.Copy(io.Discard, resp.Body) - return s.recordRepeat(started, duration, scheme, host, method, path, raw, tee.Take(), resp.StatusCode, "") + return s.recordRaw(started, duration, scheme, host, method, path, raw, tee.Take(), resp.StatusCode, "", source) } -func (s *Server) recordRepeat(started time.Time, duration time.Duration, scheme, host, method, path string, - reqRaw, respRaw []byte, status int, errMsg string) (*store.Entry, error) { +func (s *Server) recordRaw(started time.Time, duration time.Duration, scheme, host, method, path string, + reqRaw, respRaw []byte, status int, errMsg, source string) (*store.Entry, error) { e := &store.Entry{ StartedAt: started, Duration: duration, @@ -70,12 +78,12 @@ func (s *Server) recordRepeat(started time.Time, duration time.Duration, scheme, RequestExact: true, ResponseExact: respRaw != nil, Error: errMsg, - Source: "repeater", + Source: source, } if s.store != nil { id, err := s.store.Insert(e) if err != nil { - return nil, fmt.Errorf("store repeater entry: %w", err) + return nil, fmt.Errorf("store %s entry: %w", source, err) } e.ID = id if s.OnEntry != nil { |