diff options
| author | srdusr <[email protected]> | 2026-08-26 01:06:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-08-26 01:06:00 +0200 |
| commit | cfe01fef65af082dccfc69b2fc78cb07a36ac2b4 (patch) | |
| tree | efc0b0468a0f43bd9c2f3f061c2a6a0b71d021ab | |
| parent | 9e94bcbc939afd38b45f9ef42e1b1666fafd8d45 (diff) | |
| download | mitmux-cfe01fef65af082dccfc69b2fc78cb07a36ac2b4.tar.gz mitmux-cfe01fef65af082dccfc69b2fc78cb07a36ac2b4.zip | |
Second plugin: paramminer, a Param Miner-style hidden parameter prober
For every distinct GET endpoint (deduplicated in-memory so revisiting
a URL doesn't rerun the whole wordlist each time), sends a fresh
baseline resend plus one probe per candidate from a ~40-entry wordlist
of parameter names real backends surprisingly often read even when
never part of any observed request (debug, admin, redirect, role,
token, and similar). A probe whose response differs from baseline by
more than a small threshold (body length, or a different status
outright) is a likely hit, tagged paramminer:hit with the parameter
name and both response sizes as evidence. Deliberately GET-only with a
modest wordlist, not exhaustive POST/JSON-aware probing - same "small
honest v1" reasoning as authcheck's single-identity simplification.
Same discipline as authcheck: speaks the wire protocol directly, no
internal/ipc import, proving PLUGINS.md's documented protocol is
actually sufficient on its own.
Found and documented two real, non-obvious net/http behaviors while
building this: Request.Write ignores the RequestURI field entirely
(confirmed directly - a deliberately stale RequestURI still produced
the correct output, since Write derives the request line from
Request.URL instead) and silently adds a default User-Agent header if
the cloned request didn't already have one. Neither affects
correctness here since baseline and every probe get identical
treatment, but both are worth knowing before reusing this resend
pattern elsewhere.
Verified live end to end: a real daemon, a real Python origin with a
genuinely hidden debug parameter that substantially changes the
response, and a control endpoint that's stable regardless of any extra
parameter - the hidden-parameter endpoint was correctly tagged with
exactly the right parameter name, the stable one correctly left alone,
confirmed via tag: search and visually in the TUI with the JSON-array
tag payload rendering correctly.
| -rw-r--r-- | PLAN.md | 54 | ||||
| -rw-r--r-- | PLUGINS.md | 24 | ||||
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | plugins/paramminer/main.go | 318 |
4 files changed, 387 insertions, 14 deletions
@@ -927,7 +927,53 @@ the TUI (tmux, real keystrokes): the `Tags` column badge, `T`'s tag list, and the tag detail view's JSON-colorized data, ANSI-verified, all showing the plugin's actual findings. -Next: the remaining Phase 1 plugins (Param Miner, Backslash Powered -Scanner, Retire.js - no new protocol capability needed, same pattern -`authcheck` already validates), then the live-RPC protocol addition for -JWT Editor/SAML Raider. +### Second plugin shipped: `plugins/paramminer` + +A Param Miner-style hidden parameter prober. For every distinct +`GET` endpoint (deduplicated in-memory by method+scheme+host+path for +the plugin's own runtime, so re-visiting the same URL doesn't re-run +the whole wordlist against it every time), sends a fresh baseline +resend plus one probe per candidate from a hand-picked ~40-entry +wordlist (`debug`, `admin`, `redirect`, `role`, `token`, and similar - +the parameter names real backends most often surprisingly read even +when never part of any observed request), each adding exactly that one +query parameter. A probe whose response differs from baseline by more +than a small absolute-and-relative body-length threshold (or a +different status outright) is a likely hit, tagged `paramminer:hit` +with the parameter name and both response sizes as evidence. +Deliberately `GET`-only with a modest wordlist, not the exhaustive +POST/JSON-aware probing real Param Miner does - same "small honest v1" +reasoning as `authcheck`'s single-identity simplification. + +Two things worth knowing, found while building and verifying this one: +`http.Request.Write` (used to rebuild each probe request after mutating +its query string) silently adds a default `User-Agent` header if the +cloned request didn't already have one and completely ignores the +`Request.RequestURI` field when serializing - confirmed directly +rather than assumed, by writing a request with a deliberately stale +`RequestURI` and observing the output still came out correct because +`Write` derives the request line from `Request.URL` instead. Neither +affects correctness here (baseline and every probe get the identical +treatment, so it can't produce a false diff), but both are worth +knowing before reusing this pattern elsewhere. Also: every probe +becomes its own `source: "repeater"` history row, same as `authcheck`'s +resends - expected (every resend is auditable, the same as a human +using Repeater by hand) but worth calling out, since a wordlist-driven +plugin can visibly fill the history view; `source:proxy` in search +filters the noise back out. Documented in `PLUGINS.md`. + +Verified live end to end: a real daemon, a real Python origin with one +endpoint that has a genuinely hidden `debug` parameter changing its +response substantially (20 bytes -> 58 bytes direct; 164 -> 202 +through the full probe pipeline) and one that's stable regardless of +any extra parameter (the control case) - the hidden-parameter endpoint +was correctly tagged with exactly the right parameter name, the stable +endpoint was correctly left alone, confirmed via `tag:` search and +visually in the TUI with the JSON-array tag data rendering correctly +(the first tag payload to exercise `jsoncolor.go`'s top-level-array +path outside its own unit tests). + +Next: the remaining Phase 1 plugins (Backslash Powered Scanner, +Retire.js - no new protocol capability needed, same pattern `authcheck` +and `paramminer` already validate), then the live-RPC protocol addition +for JWT Editor/SAML Raider. @@ -11,13 +11,16 @@ Go), and means a plugin can be developed and tested against the exact same socket the TUI is already using, with `mitmux` itself open in another terminal watching what happens in real time. -`plugins/authcheck` is a real, working reference implementation - an -Autorize-style authorization checker (resends a captured request with -its auth header stripped, tags the entry if the response still -succeeds) - written to only ever exercise what's documented on this -page, not any of mitmux's own internal Go packages, specifically so it -proves this protocol is sufficient on its own. Worth reading alongside -this document, or just copying as a starting point. +`plugins/authcheck` and `plugins/paramminer` are real, working +reference implementations - an Autorize-style authorization checker +(resends a captured request with its auth header stripped, tags the +entry if the response still succeeds) and a Param Miner-style hidden +parameter prober (probes a small wordlist of candidate query +parameters, tags the entry if any noticeably change the response) - +both written to only ever exercise what's documented on this page, not +any of mitmux's own internal Go packages, specifically so they prove +this protocol is sufficient on its own. Worth reading alongside this +document, or just copying as a starting point. ## Connecting @@ -65,7 +68,12 @@ handful of these: `scheme://host` exactly as given - no normalization, no header injection, no auto-fixed `Content-Length` - and records the exchange to history with `source: "repeater"`. This is what an Autorize- or - Param-Miner-style plugin uses to send its own probe requests. + Param-Miner-style plugin uses to send its own probe requests. Every + probe becomes its own history row - a plugin sending many probes per + entry (Param Miner's wordlist, say) will visibly fill the history + view with them. That's intentional (every resend is auditable, same + as a human using Repeater by hand), and `source:proxy` in search + filters them back out when they're just noise. - **`list`** / **`search`** - read existing history, same filters the TUI's own `/` search uses (`status:`, `source:`, `flagged:`, `tag:`, free text). Useful for a plugin that reconciles past traffic on @@ -70,8 +70,9 @@ the same socket the TUI itself uses - see [`PLUGINS.md`](PLUGINS.md). a badge in the history list, searchable via `tag:name`, viewable (`T` from detail view) with JSON data syntax-highlighted the same way a pretty-printed response is. See [`PLUGINS.md`](PLUGINS.md) and - `plugins/authcheck` for a real, working one (an Autorize-style - authorization checker). + `plugins/authcheck`/`plugins/paramminer` for real, working ones (an + Autorize-style authorization checker, a Param Miner-style hidden + parameter prober). - **Comparer**: mark one entry (`c`), then `c` on a different entry to see a colored unified diff of either side's request or response. - **Decoder**: standalone URL/Base64/Hex/HTML encode and decode (`d`), diff --git a/plugins/paramminer/main.go b/plugins/paramminer/main.go new file mode 100644 index 0000000..390879c --- /dev/null +++ b/plugins/paramminer/main.go @@ -0,0 +1,318 @@ +// Command paramminer is a reference mitmux plugin - a Param Miner-style +// hidden parameter prober. For every captured GET request, sends a +// clean baseline resend (exact original, unmodified) plus one probe per +// candidate parameter name from a small built-in wordlist, each adding +// exactly that one query parameter. A probe whose response differs +// meaningfully from the baseline (different status, or a body length +// that differs by more than a small threshold) suggests the backend +// actually reads and acts on a parameter that was never part of the +// original request - the class of bug Param Miner exists to find. +// Matches are tagged "paramminer:hit" on the original entry. +// +// Deliberately GET-only and a modest ~40-entry wordlist, not the +// thousands of candidates and POST/JSON-body probing real Param Miner +// covers - see PLAN.md's plugin section for why a small, honest v1 +// beats a slow one pretending to be exhaustive. Speaks the wire +// protocol directly rather than importing mitmux's own internal Go +// packages - see plugins/authcheck's package doc for why, and +// PLUGINS.md for the protocol this and any other plugin, in any +// language, follows. +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strings" +) + +type request struct { + Type string `json:"type"` + ID int64 `json:"id,omitempty"` + Scheme string `json:"scheme,omitempty"` + Host string `json:"host,omitempty"` + Raw []byte `json:"raw,omitempty"` + TagPlugin string `json:"tag_plugin,omitempty"` + Tag string `json:"tag,omitempty"` + TagData string `json:"tag_data,omitempty"` +} + +type summary struct { + ID int64 `json:"id"` + Method string `json:"method"` + Scheme string `json:"scheme"` + Host string `json:"host"` + Path string `json:"path"` + Source string `json:"source"` + RespSize int `json:"resp_size"` +} + +type entryDetail struct { + summary + StatusCode int `json:"status_code"` + RequestRaw []byte `json:"request_raw"` +} + +type response struct { + Type string `json:"type"` + New *summary `json:"new,omitempty"` + Detail *entryDetail `json:"detail,omitempty"` + Error string `json:"error,omitempty"` +} + +type client struct { + conn net.Conn + enc *json.Encoder + dec *json.Decoder +} + +func dial(path string) (*client, error) { + conn, err := net.Dial("unix", path) + if err != nil { + return nil, err + } + return &client{conn: conn, enc: json.NewEncoder(conn), dec: json.NewDecoder(conn)}, nil +} + +func (c *client) call(req request) (response, error) { + if err := c.enc.Encode(req); err != nil { + return response{}, err + } + var resp response + if err := c.dec.Decode(&resp); err != nil { + return response{}, err + } + if resp.Type == "error" { + return response{}, fmt.Errorf("%s", resp.Error) + } + return resp, nil +} + +func defaultSocketPath() string { + if rt := os.Getenv("XDG_RUNTIME_DIR"); rt != "" { + return filepath.Join(rt, "mitmux.sock") + } + cfg, err := os.UserConfigDir() + if err != nil { + return "mitmux.sock" + } + return filepath.Join(cfg, "mitmux", "mitmux.sock") +} + +// candidates is a small, hand-picked set of parameter names real +// backends surprisingly often read even when they're never part of any +// documented or observed request - debug/internal switches, alternate +// output formats, and access-control shortcuts being the most common +// real findings this kind of probe turns up. +var candidates = []string{ + "debug", "test", "admin", "internal", "verbose", "trace", + "format", "output", "callback", "jsonp", + "redirect", "return", "return_url", "next", "url", "continue", + "id", "user_id", "uid", "account_id", + "role", "access", "level", "scope", + "token", "api_key", "apikey", "key", "secret", + "env", "environment", "stage", "staging", "preview", + "force", "bypass", "skip_auth", "override", "unsafe", +} + +// diffThreshold is the minimum absolute AND relative body-length +// difference from baseline before a probe counts as a hit - small +// enough to catch a real behavior change, large enough to shrug off a +// timestamp or request-id echoed back in an otherwise-identical body. +const ( + diffThresholdBytes = 16 + diffThresholdPercent = 0.02 +) + +type hit struct { + Parameter string `json:"parameter"` + BaselineStatus int `json:"baseline_status"` + BaselineLength int `json:"baseline_length"` + ProbeStatus int `json:"probe_status"` + ProbeLength int `json:"probe_length"` +} + +func main() { + socketPath := flag.String("socket", "", "daemon control socket path (default: same as mitmux itself)") + pluginName := flag.String("name", "paramminer", "name this plugin tags entries as") + flag.Parse() + + path := *socketPath + if path == "" { + path = defaultSocketPath() + } + + actor, err := dial(path) + if err != nil { + log.Fatalf("dial %s: %v", path, err) + } + defer actor.conn.Close() + + subConn, err := net.Dial("unix", path) + if err != nil { + log.Fatalf("dial %s (subscribe): %v", path, err) + } + defer subConn.Close() + if err := json.NewEncoder(subConn).Encode(request{Type: "subscribe"}); err != nil { + log.Fatalf("subscribe: %v", err) + } + + // Probing the same endpoint every single time it's seen again would + // flood a host with the same wordlist over and over for no new + // information - an in-memory, run-lifetime dedup by method+scheme+ + // host+path is enough to keep this a one-time cost per endpoint. + probed := map[string]bool{} + + log.Printf("paramminer: watching live traffic on %s", path) + dec := json.NewDecoder(subConn) + for { + var resp response + if err := dec.Decode(&resp); err != nil { + log.Fatalf("subscribe feed closed: %v", err) + } + if resp.Type != "new" || resp.New == nil { + continue + } + sum := *resp.New + if sum.Source != "proxy" || sum.Method != "GET" { + continue + } + key := sum.Method + " " + sum.Scheme + "://" + sum.Host + sum.Path + if probed[key] { + continue + } + probed[key] = true + + if err := probeEntry(actor, *pluginName, sum.ID); err != nil { + log.Printf("entry #%d: %v", sum.ID, err) + } + } +} + +func probeEntry(c *client, pluginName string, id int64) error { + resp, err := c.call(request{Type: "get", ID: id}) + if err != nil { + return fmt.Errorf("get: %w", err) + } + if resp.Detail == nil { + return fmt.Errorf("get: no detail in response") + } + detail := *resp.Detail + + baseReq, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(detail.RequestRaw))) + if err != nil { + return nil // not a well-formed request we can safely reparse - skip, not fatal + } + + // A fresh baseline resend, not the originally captured response - + // avoids comparing against a response that's stale relative to + // whatever server-side state has changed since it was captured, and + // keeps the comparison apples-to-apples with probes sent moments + // later under the same conditions. + baseline, err := resendWithQuery(c, detail.Scheme, detail.Host, baseReq, "") + if err != nil { + return fmt.Errorf("baseline resend: %w", err) + } + + var hits []hit + for _, param := range candidates { + probeResp, err := resendWithQuery(c, detail.Scheme, detail.Host, baseReq, param) + if err != nil { + log.Printf("entry #%d probe %q: %v", id, param, err) + continue + } + if isDifferent(baseline, probeResp) { + hits = append(hits, hit{ + Parameter: param, + BaselineStatus: baseline.status, + BaselineLength: baseline.length, + ProbeStatus: probeResp.status, + ProbeLength: probeResp.length, + }) + } + } + + if len(hits) == 0 { + return nil + } + + data, err := json.Marshal(hits) + if err != nil { + return fmt.Errorf("marshal hits: %w", err) + } + if _, err := c.call(request{Type: "tag_entry", ID: id, TagPlugin: pluginName, Tag: "paramminer:hit", TagData: string(data)}); err != nil { + return fmt.Errorf("tag_entry: %w", err) + } + names := make([]string, len(hits)) + for i, h := range hits { + names[i] = h.Parameter + } + log.Printf("#%d %s -> possible hidden parameter(s): %s", id, detail.Path, strings.Join(names, ", ")) + return nil +} + +type probeResult struct { + status int + length int +} + +// resendWithQuery clones baseReq, adds param=1 to its query string (a +// no-op empty param means "the clean baseline, no candidate added"), +// and resends it via the daemon's repeat primitive. Note for anyone +// reusing this pattern: Request.Write ignores the RequestURI field +// entirely (verified directly - confirmed empty/stale RequestURI still +// produces the correct line, since Write derives it from req.URL, not +// that field) and silently adds a default User-Agent if the cloned +// request didn't already have one. Both baseline and every probe get +// the same treatment, so it can't cause a false diff between them - +// just a known way this resend isn't byte-for-byte identical to the +// original beyond the one intentional change. +func resendWithQuery(c *client, scheme, host string, baseReq *http.Request, param string) (probeResult, error) { + u := *baseReq.URL + if param != "" { + q := u.Query() + q.Set(param, "1") + u.RawQuery = q.Encode() + } + + req2 := baseReq.Clone(baseReq.Context()) + req2.URL = &u + + var buf bytes.Buffer + if err := req2.Write(&buf); err != nil { + return probeResult{}, fmt.Errorf("rebuild request: %w", err) + } + + resp, err := c.call(request{Type: "repeat", Scheme: scheme, Host: host, Raw: buf.Bytes()}) + if err != nil { + return probeResult{}, fmt.Errorf("repeat: %w", err) + } + if resp.Detail == nil { + return probeResult{}, fmt.Errorf("repeat: no detail in response") + } + return probeResult{status: resp.Detail.StatusCode, length: resp.Detail.RespSize}, nil +} + +func isDifferent(baseline, probe probeResult) bool { + if baseline.status != probe.status { + return true + } + diff := probe.length - baseline.length + if diff < 0 { + diff = -diff + } + if diff < diffThresholdBytes { + return false + } + if baseline.length == 0 { + return diff > 0 + } + return float64(diff)/float64(baseline.length) >= diffThresholdPercent +} |