// 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 }