srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-08-28 09:42:00 +0200
committersrdusr <[email protected]>2026-08-28 09:42:00 +0200
commit2ee4a95c9ccc701482c88f58840568738354dc36 (patch)
treeaa8800f9ccd1b2b58663795a508258b7cf4588c0
parent8748df8a30b038e429aeeff1684e1014f42a68ee (diff)
downloadmitmux-2ee4a95c9ccc701482c88f58840568738354dc36.tar.gz
mitmux-2ee4a95c9ccc701482c88f58840568738354dc36.zip
Fourth plugin: bpscanner, a Backslash Powered Scanner-style detector
- Phase 1 plugin ecosystem complete The last of the four "cheap IPC win" plugins identified in the original research pass. Different mechanism from the other three, deliberately: where paramminer finds parameters that shouldn't exist, bpscanner tests parameters that already do, asking a more general question than a signature-based scanner does - does the backend treat syntactically-significant characters ('"\<>(){}$;|&, covering SQL quoting, HTML/JS, shell metacharacters, and template syntax at once) differently than an equal-length string of inert filler? That question doesn't need to know what the backend is built on, the whole appeal of the real tool this borrows its name and idea from. For each existing query parameter, sends two same-length replacement values wrapped in a stable marker - one filler, one special-character - and checks whether the marker itself came back intact, not just whether the response looks different overall. An endpoint that never reflects the parameter at all naturally produces "both intact: false," which correctly isn't a finding - the marker-reflection design avoids false-positiving on the common case of a parameter that's read but never echoed. Verified live against a deliberately realistic scenario: an origin with one endpoint that strips a few special characters before reflecting a parameter (a naive-sanitizer/WAF-like pattern) and one that reflects verbatim (the control case) - the sanitizing endpoint was correctly tagged with the exact differential (control_marker_intact: true, special_marker_intact: false), the verbatim endpoint correctly left alone. This closes Phase 1: every plugin identified as a "cheap IPC win" - no new protocol capability needed beyond tag_entry itself - is now real, working, and live-verified (authcheck, paramminer, jslibscan, bpscanner).
-rw-r--r--PLAN.md50
-rw-r--r--PLUGINS.md22
-rw-r--r--README.md9
-rw-r--r--plugins/bpscanner/main.go291
4 files changed, 355 insertions, 17 deletions
diff --git a/PLAN.md b/PLAN.md
index 014c902..ed55d0c 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1018,7 +1018,49 @@ was real: an entry captured *before* the fix (same file, same content,
requested moments earlier against the buggy binary) sat right next to
the correctly-tagged one in the history list, untagged.
-Next: the remaining Phase 1 plugin (Backslash Powered Scanner - no new
-protocol capability needed, same pattern the three shipped plugins
-already validate), then the live-RPC protocol addition for JWT
-Editor/SAML Raider.
+### Fourth plugin shipped: `plugins/bpscanner` - Phase 1 complete
+
+A Backslash Powered Scanner-style generic injection detector, and the
+last of the four "cheap IPC win" plugins identified in the original
+research pass. Different mechanism from the other three, deliberately:
+where paramminer finds parameters that shouldn't exist, bpscanner tests
+parameters that already do, by asking a more general question than a
+signature-based scanner does - does the backend treat
+syntactically-significant characters (`'"\<>(){}$;|&`, covering SQL
+quoting, HTML/JS, shell metacharacters, and template syntax at once)
+differently than an equal-length string of inert filler? That question
+doesn't need to know what the backend is built on, which is the whole
+appeal of the real tool this borrows its name and idea from.
+
+For each existing query parameter, sends two same-length replacement
+values wrapped in a stable marker (`zzMARKzz`) - one filler, one
+special-character - and checks not just whether the response *looks*
+different (length/status diffing, what the other three probing-based
+plugins use) but whether the marker itself came back *intact*: if the
+filler value survives unmodified but the special-character one doesn't
+(stripped, escaped, or altered), or the two produce different status
+codes outright, something downstream is interpreting those characters
+rather than treating the parameter as inert data. An endpoint that
+doesn't reflect input at all naturally produces "both intact: false,"
+which correctly isn't a finding - the marker-reflection design avoids
+false-positiving on the (very common) case of a parameter that's read
+but never echoed anywhere.
+
+Verified live end to end against a deliberately realistic scenario: a
+real origin with one endpoint that reflects a query parameter after
+stripping a few special characters (a naive-sanitizer/WAF-like pattern,
+a genuinely common real-world shape) and one that reflects verbatim
+with no stripping (the control case) - the sanitizing endpoint was
+correctly tagged (`control_marker_intact: true`,
+`special_marker_intact: false`, exactly the differential the plugin is
+built to catch), the verbatim endpoint was correctly left alone,
+confirmed via the JSON tag data in the TUI.
+
+This closes Phase 1: every plugin identified as a "cheap IPC win" - no
+new protocol capability needed beyond `tag_entry` itself - is now real,
+working, and live-verified (authcheck, paramminer, jslibscan,
+bpscanner). Next: the live-RPC protocol addition for JWT Editor/SAML
+Raider - a genuinely different shape of plugin (interactive, on-demand
+from the TUI, not just subscribe-and-tag) - or the bigger separate-
+project items (active scanning, Collaborator/OAST, a crawler) if that's
+the higher priority instead.
diff --git a/PLUGINS.md b/PLUGINS.md
index 69961d3..a10915f 100644
--- a/PLUGINS.md
+++ b/PLUGINS.md
@@ -11,15 +11,19 @@ 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`, `plugins/paramminer`, and `plugins/jslibscan` 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), a Param
-Miner-style hidden parameter prober (probes a small wordlist of
-candidate query parameters, tags the entry if any noticeably change the
-response), and a Retire.js-style passive scanner for known-vulnerable
-JS library versions (reads response bodies already captured by ordinary
-proxying, no probing at all) - all written to only ever exercise what's
+`plugins/authcheck`, `plugins/paramminer`, `plugins/jslibscan`, and
+`plugins/bpscanner` 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), a Param Miner-style hidden parameter prober (probes a small
+wordlist of candidate query parameters, tags the entry if any noticeably
+change the response), a Retire.js-style passive scanner for
+known-vulnerable JS library versions (reads response bodies already
+captured by ordinary proxying, no probing at all), and a Backslash
+Powered Scanner-style generic injection detector (mutates each existing
+query parameter's value with syntactically-significant characters vs. an
+equal-length inert control, tags the entry if a stable marker survives
+one but not the other) - all 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
diff --git a/README.md b/README.md
index 4c73472..54c3e3a 100644
--- a/README.md
+++ b/README.md
@@ -70,10 +70,11 @@ 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`/`plugins/paramminer`/`plugins/jslibscan` for
- real, working ones (an Autorize-style authorization checker, a Param
- Miner-style hidden parameter prober, a Retire.js-style scanner for
- known-vulnerable JS library versions).
+ `plugins/authcheck`/`plugins/paramminer`/`plugins/jslibscan`/
+ `plugins/bpscanner` for real, working ones (an Autorize-style
+ authorization checker, a Param Miner-style hidden parameter prober, a
+ Retire.js-style scanner for known-vulnerable JS library versions, a
+ Backslash Powered Scanner-style generic injection detector).
- **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/bpscanner/main.go b/plugins/bpscanner/main.go
new file mode 100644
index 0000000..dd93af4
--- /dev/null
+++ b/plugins/bpscanner/main.go
@@ -0,0 +1,291 @@
+// Command bpscanner is a reference mitmux plugin - a Backslash Powered
+// Scanner-style generic injection detector. Where most scanners test
+// known payloads for known technologies (`' OR 1=1--` for SQL, `<script>`
+// for XSS), this tests a more general question: does the backend do
+// anything different with syntactically-significant characters than it
+// does with an equal-length string of inert ones? That question doesn't
+// need to know what the backend is built on to be worth asking, which
+// is the appeal of the real tool this borrows its name and idea from.
+//
+// For every existing query parameter on a captured GET request, sends
+// two same-length replacement values wrapped in a stable marker: one
+// full of characters that mean something in a lot of common contexts
+// (quotes, angle brackets, shell/template metacharacters - `'"\<>(){}$;|&`),
+// one full of harmless filler. If the marker comes back intact for the
+// filler value but broken, altered, or missing for the special-character
+// one - or the two get different status codes outright - something
+// downstream is interpreting those characters rather than treating the
+// parameter as inert data, tagged "bpscanner:hit" complementing whatever
+// paramminer already covers (which finds parameters that shouldn't
+// exist at all; this tests parameters that already do).
+//
+// Speaks the wire protocol directly, no internal/ipc import - see
+// plugins/authcheck's package doc for why, and PLUGINS.md for the
+// protocol.
+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"`
+}
+
+type entryDetail struct {
+ summary
+ StatusCode int `json:"status_code"`
+ RequestRaw []byte `json:"request_raw"`
+ ResponseRaw []byte `json:"response_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")
+}
+
+// marker wraps both the special and control payloads so a response can
+// be checked for whether it survived intact, not just whether the
+// response as a whole "looks different" - a much more targeted signal
+// than length/status diffing alone. Chosen unlikely to collide with
+// real content.
+const marker = "zzMARKzz"
+
+// special covers characters meaningful across a lot of common
+// contexts at once - SQL quoting, HTML/JS, shell metacharacters,
+// template/expression syntax - without committing to any one of them.
+// filler is the same length, entirely inert.
+const (
+ special = `'"\<>(){}$;|&`
+ filler = `AAAAAAAAAAAAA`
+)
+
+type hit struct {
+ Parameter string `json:"parameter"`
+ ControlStatus int `json:"control_status"`
+ SpecialStatus int `json:"special_status"`
+ ControlIntact bool `json:"control_marker_intact"`
+ SpecialIntact bool `json:"special_marker_intact"`
+}
+
+func main() {
+ socketPath := flag.String("socket", "", "daemon control socket path (default: same as mitmux itself)")
+ pluginName := flag.String("name", "bpscanner", "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)
+ }
+
+ // Dedup by endpoint AND parameter name - unlike paramminer (which
+ // only cares about the endpoint, since it's testing candidates that
+ // don't depend on what's already there), this needs a fresh entry
+ // per distinct parameter worth testing.
+ probed := map[string]bool{}
+
+ log.Printf("bpscanner: 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
+ }
+ if err := scanEntry(actor, *pluginName, sum.ID, probed); err != nil {
+ log.Printf("entry #%d: %v", sum.ID, err)
+ }
+ }
+}
+
+func scanEntry(c *client, pluginName string, id int64, probed map[string]bool) 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
+ }
+ query := baseReq.URL.Query()
+ if len(query) == 0 {
+ return nil // nothing to mutate
+ }
+
+ var hits []hit
+ for param := range query {
+ key := detail.Method + " " + detail.Scheme + "://" + detail.Host + detail.Path + "?" + param
+ if probed[key] {
+ continue
+ }
+ probed[key] = true
+
+ controlStatus, controlIntact, err := probeParam(c, detail.Scheme, detail.Host, baseReq, param, filler)
+ if err != nil {
+ log.Printf("entry #%d param %q control probe: %v", id, param, err)
+ continue
+ }
+ specialStatus, specialIntact, err := probeParam(c, detail.Scheme, detail.Host, baseReq, param, special)
+ if err != nil {
+ log.Printf("entry #%d param %q special probe: %v", id, param, err)
+ continue
+ }
+
+ // The interesting case: the harmless filler survives intact but
+ // the special-character payload doesn't, or the two get
+ // different status codes outright. Both marker-intact and both
+ // broken (or both status codes matching) isn't a finding - that
+ // just means the value never reaches anywhere meaningful.
+ if controlStatus != specialStatus || (controlIntact && !specialIntact) {
+ hits = append(hits, hit{
+ Parameter: param,
+ ControlStatus: controlStatus,
+ SpecialStatus: specialStatus,
+ ControlIntact: controlIntact,
+ SpecialIntact: specialIntact,
+ })
+ }
+ }
+
+ 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: "bpscanner: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 injection point(s): %s", id, detail.Path, strings.Join(names, ", "))
+ return nil
+}
+
+// probeParam resends baseReq with param's value replaced by
+// marker+payload+marker, returning the response's status code and
+// whether both marker occurrences came back intact and unmodified.
+func probeParam(c *client, scheme, host string, baseReq *http.Request, param, payload string) (status int, markerIntact bool, err error) {
+ u := *baseReq.URL
+ q := u.Query()
+ value := marker + payload + marker
+ q.Set(param, value)
+ u.RawQuery = q.Encode()
+
+ req2 := baseReq.Clone(baseReq.Context())
+ req2.URL = &u
+
+ var buf bytes.Buffer
+ if err := req2.Write(&buf); err != nil {
+ return 0, false, fmt.Errorf("rebuild request: %w", err)
+ }
+
+ resp, err := c.call(request{Type: "repeat", Scheme: scheme, Host: host, Raw: buf.Bytes()})
+ if err != nil {
+ return 0, false, fmt.Errorf("repeat: %w", err)
+ }
+ if resp.Detail == nil {
+ return 0, false, fmt.Errorf("repeat: no detail in response")
+ }
+
+ body := string(resp.Detail.ResponseRaw)
+ intact := strings.Count(body, marker) >= 2 && strings.Contains(body, marker+payload+marker)
+ return resp.Detail.StatusCode, intact, nil
+}