diff options
| author | srdusr <[email protected]> | 2026-04-16 00:24:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-04-16 00:24:00 +0200 |
| commit | 70b1c783715eddc9886e3681f6570d49ea8357ed (patch) | |
| tree | a7453cfbc45ca84fe777dae72e3ca2d498782d38 | |
| parent | cecf6f0ea3d5e4706c134f708c50b00c519d536f (diff) | |
| download | mitmux-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.
| -rw-r--r-- | internal/proxy/intrude.go | 55 | ||||
| -rw-r--r-- | internal/proxy/intrude_test.go | 47 |
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) + } + }) + } +} |