Files
badtests/RESULTS.md
2026-02-14 00:27:48 -05:00

17 KiB
Raw Permalink Blame History

BadTest Results

Tested against badtests.py on port 60666 using curl 8.5.0, urllib.request, http.client, requests, and aiohttp.

Legend: βœ… = accepted/worked | ❌ = rejected/error | ⚠️ = accepted but weird behavior


🚨 Critical: Header Injection via CRLF

Endpoint: /ct-crlf

A Content-Type header with embedded \r\n injects a fake X-Injected: true header. Every single client accepts it as a real header.

curl -sv http://127.0.0.1:60666/ct-crlf
< Content-Type: text/html
< X-Injected: true

Python (all 4 libraries):

# urllib, http.client, requests, aiohttp all show:
Headers: {'Content-Type': 'text/html', 'X-Injected': 'true'}

Impact: Full header injection. An attacker controlling header values can inject arbitrary headers into responses. This enables cache poisoning, XSS via injected headers, session fixation, etc.


🚨 Critical: HTTP Request Smuggling via Duplicate Content-Length

Endpoint: /hdr-dup-cl

Server sends two Content-Length headers: 5 and 999. Different clients pick different values.

curl -sv http://127.0.0.1:60666/hdr-dup-cl
< Content-Length: 5
< Content-Length: 999
* transfer closed with 994 bytes remaining to read
hello

curl uses the second CL (999), reads 5 bytes, then reports 994 remaining.

# urllib.request β€” uses FIRST Content-Length (5), returns body fine
Headers: {'Content-Length': '5'}
Body:    b'hello'

# http.client β€” exposes BOTH headers
Headers: [('Content-Length', '5'), ('Content-Length', '999')]
Body:    b'hello'

# requests β€” REJECTS
ERROR: InvalidHeader: Content-Length contained multiple unmatching values (5, 999)

# aiohttp β€” REJECTS
ERROR: Duplicate Content-Length

Impact: Classic request smuggling vector. A proxy using the first CL and a backend using the second will desync, allowing request smuggling attacks.


🚨 Critical: Content-Length + Transfer-Encoding (Smuggling)

Endpoint: /te-double

Server sends both Content-Length: 5 and Transfer-Encoding: chunked.

curl -sv http://127.0.0.1:60666/te-double
< Content-Length: 5
< Transfer-Encoding: chunked
hello

curl reads body via CL, ignores the chunked TE.

# urllib/http.client/requests β€” all accept both headers, read body fine
Headers: {'Content-Length': '5', 'Transfer-Encoding': 'chunked'}
Body:    b'hello'

# aiohttp β€” REJECTS (correct per RFC 7230)
ERROR: Transfer-Encoding can't be present with Content-Length

Impact: The exact CL+TE desync that enables HTTP request smuggling. Only aiohttp correctly rejects this.


⚠️ High: Python stdlib Accepts ANY Content-Length Value

Endpoints: /cl-word, /cl-empty, /cl-negative, /cl-float, /cl-hex, /cl-null, /cl-nan, /cl-inf, /cl-negative-one

Python's urllib.request, http.client, and requests accept literally ANY value for Content-Length β€” words, negative numbers, floats, hex, NaN, Infinity, null bytes. They just store the raw string and read the body from the connection close.

Endpoint curl urllib/http.client/requests aiohttp
/cl-word (banana) ❌ Invalid CL value βœ… Status 200, body b'hello' ❌ 400
/cl-empty ("") ❌ Invalid CL value βœ… Status 200, body b'hello' ❌ 400
/cl-negative (-99999999999999999) ❌ Invalid CL value βœ… Status 200, body b'hello' ❌ 400
/cl-float (3.14159) ⚠️ Reads 3 bytes "hel" βœ… Status 200, body b'hello' ❌ 400
/cl-hex (0xDEAD) ⚠️ Accepts header, empty body βœ… Status 200, body b'hello' ❌ 400
/cl-null (5\x00) ❌ Nul byte in header βœ… Status 200, body b'hello' ❌ 400
/cl-nan (NaN) ❌ Invalid CL value βœ… Status 200, body b'hello' ❌ 400
/cl-inf (Infinity) ❌ Invalid CL value βœ… Status 200, body b'hello' ❌ 400
/cl-negative-one (-1) ❌ Invalid CL value βœ… Status 200, body b'hello' ❌ 400

curl is interesting with /cl-float:

curl -sv http://127.0.0.1:60666/cl-float
< Content-Length: 3.14159
hel

curl truncates the float to integer 3 and reads exactly 3 bytes.

Impact: Python stdlib libraries never validate Content-Length. If your code trusts Content-Length to be a valid integer, you'll crash or behave unexpectedly. aiohttp is the only Python lib that validates.


⚠️ High: Content-Length Mismatch Silently Truncates

Endpoint: /cl-mismatch β€” CL says 1, body is 30 bytes ("this is way more than one byte")

curl -sv http://127.0.0.1:60666/cl-mismatch
< Content-Length: 1
* transfer closed with ... bytes remaining
t
# urllib/http.client/requests β€” all return only 1 byte
Body: b't'

# aiohttp β€” REJECTS
ERROR: Data after `Connection: close`

Impact: Data truncation. If a server misconfigures CL, most clients silently drop the rest of the body. Only aiohttp flags this.


⚠️ High: Null Bytes Accepted in Headers by Python stdlib

Endpoints: /ct-null, /cl-null, /hdr-null

curl -sv http://127.0.0.1:60666/ct-null
* Nul byte in header
* Closing connection

curl correctly rejects null bytes in headers.

# urllib/http.client/requests β€” ACCEPT null bytes
Headers: {'Content-Type': 'text/\x00html'}
Body:    b'hello'

# aiohttp on /ct-null β€” also ACCEPTS
Headers: {'Content-Type': 'text/\x00html'}

Impact: Null byte injection in headers. Can bypass security filters that use C-style string comparison (null terminates the string early).


⚠️ High: 1000 Headers β€” curl and aiohttp Accept, stdlib Rejects

Endpoint: /hdr-1000

curl -sv http://127.0.0.1:60666/hdr-1000
< X-H0: 0
< X-H1: 1
... (all 1000 accepted)
hello

curl accepts all 1000 headers with no limit.

# urllib/http.client/requests β€” REJECT
ERROR: HTTPException: got more than 100 headers

# aiohttp β€” ACCEPTS all 1000
Status: 200
Body: b'hello'

Impact: Python stdlib has a 100-header limit (good defense against slow header attacks). curl and aiohttp don't enforce any limit, making them vulnerable to memory exhaustion via header flooding.


⚠️ High: Gzip Bomb Behavior

Endpoint: /net-gzip-bomb β€” 9.7KB compressed β†’ 10MB decompressed

curl -sv -o /dev/null http://127.0.0.1:60666/net-gzip-bomb
< Content-Encoding: gzip
< Content-Length: 9750
{ [9750 bytes data]

curl downloads 9750 raw bytes (doesn't auto-decompress without --compressed).

# urllib/http.client/requests β€” get raw gzip bytes (no auto-decompress)
Body: b'\x1f\x8b\x08\x00...' (9750 raw bytes)

# aiohttp β€” AUTO-DECOMPRESSES! Returns null bytes
Body: b'\x00\x00\x00\x00\x00...' (8192 bytes of zeros, capped by our read limit)

Impact: aiohttp auto-decompresses gzip, so a 9.7KB download becomes 10MB in memory. Without read limits, this would be a denial-of-service via memory exhaustion.


⚠️ High: False Content-Encoding Accepted by Most

Endpoint: /enc-gzip-lie β€” says Content-Encoding: gzip but body is plaintext

curl -sv http://127.0.0.1:60666/enc-gzip-lie
< Content-Encoding: gzip
this is definitely not gzipped

curl delivers the plaintext body despite the gzip header.

# urllib/http.client/requests β€” accept, return plaintext
Body: b'this is definitely not gzipped'

# aiohttp β€” REJECTS
ERROR: Can not decode content-encoding: gzip

Impact: Most clients ignore the Content-Encoding mismatch. If your app trusts the header to decompress, it'll crash. Only aiohttp validates.


⚠️ Medium: Invalid Status Codes

Endpoints: /status-neg, /status-zero, /status-999, /status-huge, /status-word

Endpoint curl urllib http.client requests aiohttp
/status-neg (-1) ❌ Unsupported subversion ❌ BadStatusLine ❌ BadStatusLine ❌ ConnectionError ❌ 400
/status-zero (0) ❌ Unsupported subversion ❌ BadStatusLine ❌ BadStatusLine ❌ ConnectionError ❌ 400
/status-999 (999) βœ… Shows "999" ❌ HTTPError 999 βœ… Status 999 βœ… Status 999 βœ… Status 999
/status-huge (99999) ❌ Unsupported subversion ❌ BadStatusLine ❌ BadStatusLine ❌ ConnectionError ❌ 400
/status-word (BANANA) ❌ Unsupported subversion ❌ BadStatusLine ❌ BadStatusLine ❌ ConnectionError ❌ 400

Notable: Status 999 is accepted by curl, http.client, requests, and aiohttp. Only urllib raises an error (treats any non-standard code β‰₯400 as an HTTPError). A 3-digit status code is technically valid per HTTP spec even if unregistered.


⚠️ Medium: Oversized Headers

Endpoints: /hdr-mega-name (64KB header name), /hdr-mega-val (1MB header value)

# All Python libs on /hdr-mega-name:
# urllib/http.client/requests: LineTooLong: got more than 65536 bytes
# aiohttp: Got more than 8190 bytes (65542)

curl accepts the 64KB header name but silently closes on the 1MB value.

Impact: aiohttp has the strictest limit (8190 bytes per header line). Python stdlib allows up to 64KB. curl has no apparent limit for header names.


⚠️ Medium: Transfer-Encoding: banana Accepted by All

Endpoint: /te-invalid

curl -sv http://127.0.0.1:60666/te-invalid
< Transfer-Encoding: banana
hello

Every client accepts Transfer-Encoding: banana without complaint. They all fall back to reading until connection close.

Impact: No client validates the Transfer-Encoding value. A garbage TE value is silently ignored.


⚠️ Medium: Garbage Location URL Causes Different Failures

Endpoint: /redir-garbage β€” Location contains \x00\xff://πŸ’€:99999/../../etc/passwd

curl -sv http://127.0.0.1:60666/redir-garbage
* Nul byte in header
* Closing connection
# urllib β€” follows redirect, gets 404 (tries to resolve the garbage URL)
ERROR: HTTPError: HTTP Error 404: Not Found

# http.client β€” returns raw 301 with the garbage Location
Headers: [('Location', '\x00ΓΏ://Γ°\x9f\x92\x80:99999/../../etc/passwd')]

# requests β€” CRASHES on the byte
ERROR: UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff

# aiohttp β€” accepts with surrogate escapes
Headers: {'Location': '\x00\udcff://πŸ’€:99999/../../etc/passwd'}

Impact: Potential SSRF. urllib actually tries to follow the redirect. requests crashes entirely. http.client exposes path traversal payload.


⚠️ Medium: Redirect Chain Handling

Endpoint: /redir-chain β€” infinite 302 chain with unique URLs

curl -sv -L --max-redirs 5 http://127.0.0.1:60666/redir-chain
< Location: /redir-chain?n=1
... (follows 5 hops)
* Maximum (5) redirects followed
# urllib β€” detects infinite loop
ERROR: HTTP Error 302: The HTTP server returned a redirect error that would lead to an infinite loop.

# http.client β€” returns first 302 (doesn't follow)
Status: 302, Location: /redir-chain?n=1

# requests β€” returns first 302 (allow_redirects=False)
Status: 302

# aiohttp β€” returns first 302 (allow_redirects=False)
Status: 302

⚠️ Medium: Slowloris Timing

Endpoints: /slow-headers (1 byte per 0.5s), /slow-body (1 byte per second)

curl -sv --max-time 2 http://127.0.0.1:60666/slow-body
* Operation timed out after 2001 milliseconds with 2 out of 26 bytes received
ab
# urllib/http.client/requests on /slow-body β€” WAIT THE FULL 26 SECONDS
Body: b'abcdefghijklmnopqrstuvwxyz'

# aiohttp β€” gets only 1 byte before timeout
Body: b'a'

# aiohttp on /slow-headers β€” times out completely
ERROR: TimeoutError

Impact: Python stdlib clients with no timeout will block for the full duration of a slowloris attack. urllib/http.client/requests all waited 26 seconds for the slow body. Only aiohttp's timeout saved it.


⚠️ Medium: Headers Without Colons / Empty Names

Endpoint: /hdr-no-colon β€” sends BrokenHeaderNoColon as a header line

curl -sv http://127.0.0.1:60666/hdr-no-colon
* Header without colon
* Closing connection
# urllib/http.client/requests β€” accept, but headers dict is EMPTY
Headers: {}
Body: b'hello'

# aiohttp β€” REJECTS
ERROR: Invalid header token

Endpoint: /hdr-empty-name β€” sends : empty_name

# urllib/http.client/requests β€” accept, but the empty-name header is silently dropped
# aiohttp β€” REJECTS: "Invalid header token"

ℹ️ Low: Protocol Violations

Endpoint curl Python libs
/proto-garbage (random bytes) ❌ HTTP/0.9 not allowed ❌ BadStatusLine (all)
/proto-empty (0 bytes) ❌ Empty reply ❌ RemoteDisconnected (all)
/proto-half (HTTP/9.9) ❌ Unsupported version ❌ UnknownProtocol (all)
/proto-no-headers (no headers) βœ… Accepts, reads body βœ… All accept, body b'hello'
/proto-just-newlines (only CRLF) ❌ HTTP/0.9 not allowed ❌ BadStatusLine (all)

Notable: /proto-no-headers β€” a response with just a status line and body (no headers at all) is accepted by every client. This means you can serve valid responses without any headers.


ℹ️ Low: Encoding Mismatches

Endpoint: /enc-utf16 β€” body is UTF-16LE, header says charset=utf-8

All clients return the raw bytes without detecting the mismatch. No client validates that the body matches the declared charset.

Endpoint: /html-utf16 β€” full HTML page encoded as UTF-16LE with no charset declaration

curl -sv http://127.0.0.1:60666/html-utf16
< h t m l > < h e a d > < t i t l e > U T F - 1 6 L E ...

All clients return the raw UTF-16LE bytes. curl shows the spaced-out text.


ℹ️ Low: HTML Content Tests

These endpoints return valid HTTP with unusual HTML content. All clients accept them since the HTTP layer is valid:

Endpoint What it does All clients
/html-long-title 1000-char <title> βœ… Accepted
/html-multiline-title Newlines in <title> βœ… Accepted
/html-irc-title \r\nQUIT in <title> βœ… Accepted
/html-unicode Random Unicode 0x1000-0x3000 βœ… Accepted
/html-meta-refresh <meta refresh> loop βœ… Accepted (browser-only effect)
/ansi-chaos ANSI escape codes βœ… Accepted (terminal-only effect)

Summary: Client Strictness Ranking

Most strict β†’ Least strict:

  1. aiohttp β€” Validates Content-Length format, rejects null bytes in headers, enforces 8190-byte header limit, rejects duplicate CL, rejects CL+TE combo, validates gzip encoding, enforces timeouts. Best security posture.

  2. curl β€” Rejects invalid CL values, null bytes, headers without colons. But accepts 1000 headers, emoji Content-Type, status 999, and the CL+TE combo. Interesting float-to-int truncation on CL.

  3. requests β€” Rejects duplicate Content-Length. But accepts everything else that urllib accepts. Crashes on \xff byte in Location header.

  4. urllib.request / http.client β€” Accept almost everything. No CL validation, no null byte rejection, no header count limit enforcement (100 limit exists but only in stdlib). Blindly trusts whatever the server sends. Worst security posture.


Summary Table

Test curl urllib http.client requests aiohttp
CL: banana ❌ βœ… βœ… βœ… ❌
CL: -1 ❌ βœ… βœ… βœ… ❌
CL: 3.14159 ⚠️ 3 bytes βœ… βœ… βœ… ❌
CL: 0xDEAD ⚠️ empty βœ… βœ… βœ… ❌
CL: mismatch ⚠️ truncate ⚠️ truncate ⚠️ truncate ⚠️ truncate ❌
CL: null byte ❌ βœ… βœ… βœ… ❌
CT: CRLF inject βœ… injected βœ… injected βœ… injected βœ… injected βœ… injected
CT: emoji βœ… βœ… βœ… βœ… βœ…
CT: 10KB βœ… βœ… βœ… βœ… ❌ >8190
Status: -1 ❌ ❌ ❌ ❌ ❌
Status: 999 βœ… ❌ HTTPError βœ… βœ… βœ…
64KB header name βœ… ❌ >65536 ❌ >65536 ❌ >65536 ❌ >8190
1000 headers βœ… ❌ >100 ❌ >100 ❌ >100 βœ…
Dup Content-Length ⚠️ uses 2nd ⚠️ uses 1st ⚠️ both ❌ ❌
CL + TE (smuggle) ⚠️ uses CL βœ… both βœ… both βœ… both ❌
TE: banana βœ… βœ… βœ… βœ… βœ…
Bad chunk size ❌ ❌ ❌ ❌ ❌
No headers βœ… βœ… βœ… βœ… βœ…
Null in header ❌ βœ… βœ… βœ… ❌
Header no colon ❌ βœ… empty βœ… empty βœ… empty ❌
Gzip lie βœ… raw βœ… raw βœ… raw βœ… raw ❌ decode fail
Gzip bomb βœ… raw βœ… raw βœ… raw βœ… raw ⚠️ decompresses
Slowloris body ⚠️ 2s timeout ⚠️ 26s wait ⚠️ 26s wait ⚠️ 26s wait ⚠️ 1 byte
Garbage Location ❌ null byte ⚠️ follows βœ… raw ❌ crash βœ… surrogates