diff options
Diffstat (limited to 'internal')
| -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) + } + }) + } +} |