Twenty-Six Defects in HTTP, and None of Them in the Parser

We tested seven HTTP implementations and uncovered 26 defects. Two came back clean, and both of them were the request parser. Everything that broke was the code around it: the limits, the framing, the buffers and the state that outlived the request.

HTTP is the protocol everything else borrowed. It carries SOAP in UPnP, management interfaces in telephony stacks, firmware images in update clients, credential issuance in media servers, and configuration in devices that have no business speaking it at all. Very little of that code is a web server, and almost none of it is maintained by anyone who thinks of themselves as working on a web server.

We tested seven implementations and uncovered 26 defects.

Two came back clean. Both of them were request parsers, and one is among the most heavily fuzzed pieces of HTTP code in existence: forty thousand seeded cases against exact-size buffers under sanitizers produced nothing. Product A's request parser was clean as well.

That is the shape of this family. The parser is the part people harden, and it is not where HTTP breaks.

Lengths that go straight to an allocator

Product F takes the declared content length and hands it to a vector resize. It takes the chunk size and hands that to a resize too. It takes the response status code, which is whatever bytes happen to sit between the first and second space of a status line, and hands that to an integer conversion that throws on input it cannot parse.

Three fields, three values that arrive from the network, three uses with nothing in between.

Product G has the arithmetic version. Its chunk length is accumulated into a 32-bit unsigned value and then added to an offset, so a chunk size line of FFFFFFF6 wraps the sum to exactly zero. Further down, a header allocation adds two values in signed 32-bit arithmetic, overflows, and returns a block smaller than the thing about to be written into it. A third takes a Content-Length at or above two gigabytes and narrows it to a negative value on the way into the allocator, so the out-of-memory guard written for exactly this case is never reached. That one is 62 bytes of request.

In all three the length the code acts on is a different number from the length the sender declared, which is the condition every length-field test is looking for.

Defaults that ship as unlimited

Five of Product F's nine defects exist for one reason: four of its five parser limits ship with a default of zero, documented as unlimited.

The start line grows without a bound. The header section grows without a bound. The field count has no ceiling. The body limit is off. The code implementing each limit is present and correct, and the check reads if (limit != 0 && value > limit), so shipping zero turns every one of them off at once.

An operator who never set these has an HTTP server that will accumulate a request line until memory runs out. The default is the configuration almost everyone runs.

The same absence shows up differently in Product E, a telephony proxy whose HTTP interface is for administration. It reads in eight-kilobyte chunks and appends each one to a receive buffer, then tries to parse, and if the request is incomplete it waits for more. There is no cap on what the buffer may reach, so a request that never completes is a memory consumer with a one-line sender. Its XML-RPC command channel has the same shape, byte for byte.

Parsing that gets slower the more you send

Two of Product F's defects are quadratic. The query string split walks the remaining string on every iteration, and so does the cookie split, which is nested one level deeper and therefore quadratic twice over. Neither is a memory safety defect and neither will show up as a crash. They show up as a server that stops answering, driven by a single request whose cost rises with the square of its length.

This class only becomes reachable because of the previous section. A header section with no size cap means the cookie line has no size cap, and a quadratic parse over an unbounded input is a denial of service with no exploit in it.

The attacker is the server you asked

Product C is an HTTP client. It asks a dynamic DNS provider what its address is and reports back, so every byte it parses comes from a response to a request it chose to make. That makes the server the attacker, and a client that trusts a response because it initiated the exchange has the trust boundary in the wrong place.

The response handler writes its terminating byte one past the end of the allocation, every time, by construction. A token is copied into a fixed stack buffer with the bound checked against the buffer rather than the data, so an unterminated token reads past it. An address scanned out of the response body has no length limit and then reaches a fixed URL buffer.

The one worth the most attention is quieter. A single hostile response on one code path writes a marker file, and the presence of that file permanently disables updates for that hostname. It survives restarts. The same path then walks a list and frees an entry while continuing to use it.

So one response stops the client from ever updating again, and the way it does so is reachable memory corruption.

State that outlives the request

Product F keeps a set of expected trailer fields in its parser. The reset between requests clears the parser's own copy, while the set already handed to the decoder is only replaced when a new Trailer: header appears. On a connection where one request declares trailers and the next does not, the second request is parsed against the first request's expectations. It also merges chunked trailer fields into the header map the application reads, so a field that arrives after the body is indistinguishable from one that arrived before it.

Product B is a credential endpoint on an unauthenticated path. It stores an attacker-sized username for a hard-coded twenty-four hours, reads its expiry map from a background ticker without the lock every other reader takes, and logs every request at debug level including the credential it has just issued.

It also indexes a query parameter slice without checking that the parameter was present, so a request that omits one parameter panics the process. That is a denial of service with no payload, from an endpoint that requires no credentials to reach, in a server whose job is to hand out credentials.

Error paths that are not paths

Product G has two defects that are purely about what happens when something goes wrong. An empty TE: header parses as success and leaves its field null, because the loop body is the only thing that ever assigns it and an empty header runs the body zero times. And a chunk length that arrives at exactly zero after the arithmetic above reaches an assertion rather than a check, so a release build and a debug build fail differently on the same input.

Product C dereferences null on the permanent error path of its transfer library, which is to say the path taken when the remote end has told it something is wrong.

These are the states a conforming peer never produces, which is precisely why they are never exercised. They are also trivially producible by a peer that is not trying to conform.

Finding these with Penzzer

Select the INET4 or INET6 module, choose HTTP/1.1 or HTTP Server, with or without TLS, and point the run at the target.

HTTP is driven as a protocol rather than as a socket to write bytes into. A run exercises the request line and its three components, the header section and the fields within it, the Content-Length and Transfer-Encoding framing including the disagreement between them, chunk size lines and their extensions, the trailer section, connection reuse and pipelining across requests, and the response side for targets that act as clients. Field values are walked through the boundaries where parsers break, including the ones that are not numeric at all.

Six attack modules run alongside:

| Module | The shape it provokes | | --- | --- | | Request line and header bounds | Components and field values at and past the sizes a parser assumes | | Unbounded start line and header section | Input that accumulates because no ceiling was configured | | Chunked framing abuse | Chunk sizes whose arithmetic lands somewhere other than where it was declared | | Trailer section abuse | Fields arriving after the body, and reaching the application's header map | | Quadratic field parsing | Field and cookie lines whose cost rises faster than their length | | Per request leak | State from one request still present in the next, on a reused connection |

‍

Response times are measured against a matched control, so a target that is simply slow is not reported as quadratic, and connection state is compared across reuse so a leak is identified by what changed rather than by what was sent.

What this article is, and is not

The 26 defects above are a sample of what this kind of testing surfaces, not a description of Penzzer's HTTP coverage. They are what seven specific implementations happened to get wrong.

The attack library spans defect classes well beyond what this article shows: memory safety, information disclosure, resource exhaustion, availability, hangs and algorithmic complexity, input validation, protocol state logic, type and format handling, and error handling. Across the 107 protocols with a protocol-aware target, a run also generates its test cases from the structure of the protocol itself, walking each field through the boundaries where parsers break.

The same limit applies in reverse. What a test looks for is a property of the test, not a claim about your device.

If your product speaks HTTP and is not a web server

That describes most of the HTTP in the world, and all seven targets here.

Four questions. Do your parser limits have non-zero defaults, or does an operator who configured nothing have no limits at all? Does every length that arrives from the network get bounded before it reaches an allocator, in the width the arithmetic is actually performed in? Does your parser state reset completely between requests on a reused connection, including the state you handed to something else? And when your code is the client, is the response treated as attacker-controlled, or as trustworthy because you asked for it?

The two implementations that came back clean here are evidence for the general case, not against it. Both were parsers, written to be parsers, and fuzzed as parsers. The other five were HTTP inside something that was busy being a telephony proxy, a media signalling server or a DNS updater, and that is where all 25 defects are.

Other Post
Uncover Hidden Vulnerabilities

Identify security flaws before attackers do, automatically and at scale with Penzzer's intelligent fuzzing engine.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.