srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-04-16 00:24:00 +0200
committersrdusr <[email protected]>2026-04-16 00:24:00 +0200
commit70b1c783715eddc9886e3681f6570d49ea8357ed (patch)
treea7453cfbc45ca84fe777dae72e3ca2d498782d38 /internal
parentcecf6f0ea3d5e4706c134f708c50b00c519d536f (diff)
downloadmitmux-70b1c783715eddc9886e3681f6570d49ea8357ed.tar.gz
mitmux-70b1c783715eddc9886e3681f6570d49ea8357ed.zip
Fix Intruder silently hanging on body-parameter fuzzing
internal/proxy/intrude.go's buildRequest substitutes marker text but never recalculated Content-Length. A payload's length routinely differs from the base value it replaces, so any body-parameter fuzzing attack left a stale Content-Length from the original captured request in every substituted request. When the declared length is larger than the actual body sent, the target server blocks waiting for bytes that never arrive, and each such request eats the full 60s upstreamTimeout before failing - silently, with no error or warning anywhere. Since body- parameter fuzzing is one of the most common Intruder use cases and payload lengths vary within essentially every real attack, this made most real body-fuzzing attacks take payloads×60s for no visible reason. Found by a live audit that timed identical attacks: URL-only marker fuzzing (no body length change) completed in single-digit milliseconds per payload; the same attack with the marker in a body parameter took 60s per payload. fixContentLength recalculates an existing Content-Length header to match the actual body length after substitution, called right after buildRequest in the Intrude loop. Deliberately narrow: only touches a request with exactly one Content-Length header and a clean header/body boundary. Zero found means nothing to fix (unchanged). More than one is left alone too - a request smuggling test's own deliberately ambiguous framing, where guessing which one to "fix" would be worse than leaving both as the user built them. This is Intruder-specific, not a change to Repeater: what the user types into Repeater still goes on the wire completely unmodified, no auto-fixed Content-Length there, same as always. A fuzzed value's length is a side effect of automated substitution Intruder performs on the user's behalf, not a deliberate edit the way a Repeater request is. internal/proxy/intrude_test.go: TestFixContentLength covers recalculating a stale length, case-insensitive header matching, no-header and no-boundary no-ops, and the two-headers-left-alone case. Verified live against a real HTTP target and a real daemon (JSON over the control socket, not the TUI, for precise timing): a template with Content-Length declared far larger than any actual substituted body - the exact hang-triggering direction - completed all 4 payloads in 0.01s total, and the recorded history entries carry exactly the correct recalculated Content-Length for each (10/19/11/14, byte-for-byte matching each actual body). go build/vet/gofmt/test/mod tidy all clean.
Diffstat (limited to 'internal')
-rw-r--r--internal/proxy/intrude.go55
-rw-r--r--internal/proxy/intrude_test.go47
2 files changed, 102 insertions, 0 deletions
diff --git a/internal/proxy/intrude.go b/internal/proxy/intrude.go
index 933a309..6595c75 100644
--- a/internal/proxy/intrude.go
+++ b/internal/proxy/intrude.go
@@ -108,6 +108,7 @@ func (s *Server) Intrude(ctx context.Context, scheme, host string, template []by
if err != nil {
return err
}
+ raw = fixContentLength(raw)
// sendRaw is already self-bounding (dialForRepeat's own dial
// timeout, then conn.SetDeadline for the rest), so ctx here
@@ -122,3 +123,57 @@ func (s *Server) Intrude(ctx context.Context, scheme, host string, template []by
}
return nil
}
+
+// fixContentLength recalculates an existing Content-Length header to
+// match raw's actual body length after marker substitution. A fuzzed
+// payload routinely differs in length from the base value it replaces;
+// left as-is, a Content-Length carried over unchanged from the original
+// captured request makes the target server wait for bytes that will
+// never arrive, hanging that request for the full upstream timeout -
+// confirmed: identical attacks against a URL-only marker (no body
+// length change) completed in single-digit milliseconds per payload,
+// the same attack with the marker inside a body parameter took 60s per
+// payload. This is Intruder-specific, not something Repeater does to
+// what's typed: a fuzzed value's length is a side effect of automated
+// substitution, whereas a Repeater edit is deliberate and Repeater's own
+// "no auto-fixed Content-Length" behavior is unchanged.
+//
+// Only touches a request with exactly one Content-Length header and a
+// clean header/body boundary - zero found means nothing to fix, more
+// than one is a request smuggling test's own deliberately ambiguous
+// framing, and guessing which one to rewrite there would be worse than
+// leaving both alone.
+func fixContentLength(raw []byte) []byte {
+ sep := []byte("\r\n\r\n")
+ idx := bytes.Index(raw, sep)
+ if idx < 0 {
+ return raw
+ }
+ headerBlock, body := raw[:idx], raw[idx+len(sep):]
+
+ lines := bytes.Split(headerBlock, []byte("\r\n"))
+ foundIdx, count := -1, 0
+ for i, line := range lines {
+ if i == 0 {
+ continue // request line, not a header
+ }
+ colon := bytes.IndexByte(line, ':')
+ if colon < 0 {
+ continue
+ }
+ if bytes.EqualFold(bytes.TrimSpace(line[:colon]), []byte("Content-Length")) {
+ count++
+ foundIdx = i
+ }
+ }
+ if count != 1 {
+ return raw
+ }
+
+ lines[foundIdx] = []byte(fmt.Sprintf("Content-Length: %d", len(body)))
+ var out bytes.Buffer
+ out.Write(bytes.Join(lines, []byte("\r\n")))
+ out.Write(sep)
+ out.Write(body)
+ return out.Bytes()
+}
diff --git a/internal/proxy/intrude_test.go b/internal/proxy/intrude_test.go
index 5df80e6..6a0007f 100644
--- a/internal/proxy/intrude_test.go
+++ b/internal/proxy/intrude_test.go
@@ -114,3 +114,50 @@ func TestIntrudeRequestCount(t *testing.T) {
t.Fatalf("expected 2 positions, got %d", len(positions))
}
}
+
+func TestFixContentLength(t *testing.T) {
+ tests := []struct {
+ name string
+ raw string
+ want string
+ }{
+ {
+ name: "recalculates a stale length",
+ raw: "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 3\r\n\r\nfuzzedvalue",
+ want: "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 11\r\n\r\nfuzzedvalue",
+ },
+ {
+ name: "case-insensitive header name",
+ raw: "POST / HTTP/1.1\r\nHost: x\r\ncontent-length: 1\r\n\r\nabc",
+ want: "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 3\r\n\r\nabc",
+ },
+ {
+ name: "no content-length header - unchanged",
+ raw: "GET /§1§ HTTP/1.1\r\nHost: x\r\n\r\n",
+ want: "GET /§1§ HTTP/1.1\r\nHost: x\r\n\r\n",
+ },
+ {
+ name: "no body boundary - unchanged",
+ raw: "GET / HTTP/1.1\r\nHost: x",
+ want: "GET / HTTP/1.1\r\nHost: x",
+ },
+ {
+ name: "two content-length headers - left alone, ambiguous smuggling case",
+ raw: "POST / HTTP/1.1\r\nContent-Length: 3\r\nContent-Length: 999\r\n\r\nabc",
+ want: "POST / HTTP/1.1\r\nContent-Length: 3\r\nContent-Length: 999\r\n\r\nabc",
+ },
+ {
+ name: "empty body recalculates to zero",
+ raw: "POST / HTTP/1.1\r\nContent-Length: 5\r\n\r\n",
+ want: "POST / HTTP/1.1\r\nContent-Length: 0\r\n\r\n",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := fixContentLength([]byte(tt.raw))
+ if string(got) != tt.want {
+ t.Errorf("fixContentLength(%q) = %q, want %q", tt.raw, got, tt.want)
+ }
+ })
+ }
+}