srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--PLAN.md54
-rw-r--r--PLUGINS.md24
-rw-r--r--README.md5
-rw-r--r--plugins/paramminer/main.go318
4 files changed, 387 insertions, 14 deletions
diff --git a/PLAN.md b/PLAN.md
index 529503d..9a53d0e 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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.
diff --git a/PLUGINS.md b/PLUGINS.md
index 630f211..d6c0ee9 100644
--- a/PLUGINS.md
+++ b/PLUGINS.md
@@ -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
diff --git a/README.md b/README.md
index 4eace20..b23ba4e 100644
--- a/README.md
+++ b/README.md
@@ -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
+}