diff options
Diffstat (limited to 'plugins')
| -rw-r--r-- | plugins/paramminer/main.go | 318 |
1 files changed, 318 insertions, 0 deletions
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 +} |