CVE-2026-82672: a function called ignore_until_crlf, and the two points I left on the table
A parser disagreement I reported in Mint, the low-level HTTP client for Elixir: the chunk-size line checked its hexadecimal digits and threw away everything after them. The fix deleted the function. The maintainers also corrected my score, upward, and that turned out to be the more useful lesson.
Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every byte in it crossed a loopback socket on my own machine. The issue was reported privately through the project’s own security advisory process, and is written up here only after the advisory, the fixed release and the CVE record were public.
Mint calls itself a functional, low-level HTTP client for Elixir, and that is a fair description of how it reads. The parser is built the way you would want a parser built: small functions, each doing one legible thing, most of them short enough to take in at a glance.
One of them was called ignore_until_crlf.
def ignore_until_crlf(<<>>), do: :more
def ignore_until_crlf(<<"\r\n", rest::binary>>), do: {:ok, rest}
def ignore_until_crlf(<<_char, rest::binary>>), do: ignore_until_crlf(rest)
Three clauses, no surprises, and a name that tells you exactly what will happen. It ignores bytes until it reaches a CRLF.
What it was ignoring was the tail of a chunk-size line. In a chunked HTTP/1 response every chunk is announced by its length in hexadecimal, and RFC 9112 §7.1 permits exactly one thing after that number: a ;-introduced chunk extension. Mint read the digits with one function and passed whatever followed to this one, which discarded it without looking.
So the grammar Mint actually accepted was 1*HEXDIG *OCTET CRLF. Any bytes at all, as long as a CRLF eventually turned up.
If you use mint, upgrade to 1.10.1. Everything up to and including 1.10.0 is affected. The advisory is GHSA-rj5m-69wp-cxq9, the identifier is CVE-2026-82672, assigned by the Erlang Ecosystem Foundation. The two records do not reliably link to each other, so cite both.
Neither function was wrong
The other half of the pair is chunk_size/1:
def chunk_size(<<char, rest::binary>>) when is_hex_digit(char) do
parse_hex_prefix(rest, hex_digit_value(char), 1)
end
defp parse_hex_prefix(<<>>, _acc, _digit_count), do: :more
defp parse_hex_prefix(rest, acc, _digit_count), do: {:ok, acc, rest}
It stops at the first byte that is not a hexadecimal digit and hands the remainder back untouched. That is a correct answer to the question how many leading hex digits are there, and it is a question worth asking.
Neither function is wrong on its own. Read either one in review and you would approve it. The defect lives in the gap: no function in the pair was ever asked is this a well-formed chunk-size line, so nobody answered it. The grammar was an emergent property of two reasonable decisions, and emergent properties do not show up in diffs.
One module over, the same codebase knew better
Six weeks earlier, Mint had tightened the sibling parser for the same class of problem. content_length_header/1 trims trailing whitespace and then requires that everything left is a digit:
Content-Length: +5 -> {:invalid_content_length_header, "+5"}
Content-Length: 5ZZZ -> {:invalid_content_length_header, "5ZZZ"}
Content-Length: 5 9 -> {:invalid_content_length_header, "5 9"}
That parse asks the whole question. It does not extract a number and move on; it establishes that the field is a number and nothing else.
Meanwhile, in the same release, on the same connection, for the other way of framing a body:
chunk-size line "5ZZZZZ" -> accepted, chunk size 5
chunk-size line "5 anything at all" -> accepted, chunk size 5
chunk-size line "5\tfoo" -> accepted, chunk size 5
chunk-size line "5 9" -> accepted, chunk size 5
chunk-size line "5}~!" -> accepted, chunk size 5
Two parsers in one client, disagreeing about the same class of input, six weeks after one of them was taught the rule.
The tolerance also covered the token that ends the message body:
"0ZZZZ" as terminator -> accepted, the body ends here
"0 9" as terminator -> accepted, the body ends here
Which means the disagreement was not only about how long a chunk is. It could be about where the response stops — and on a pooled keep-alive connection, where a response stops is where the next one is assumed to begin.
The controls are other people’s bug reports
A permissive parser is easy to “prove” by accident. Point your harness at a build without the earlier fixes and everything is accepted, including things that were repaired a year ago, and you have learned nothing.
So the positive controls here are the proofs of concept from Mint’s own previous advisories, and they have to fail:
chunk-size line "+5" -> refused, :invalid_chunk_size (CVE-2026-59249's PoC)
chunk-size line "Z5" -> refused, :invalid_chunk_size
chunk-size line "00000000000000005" -> refused, :invalid_chunk_size (the 16-digit bound)
Those three refusals are what give the five acceptances above any meaning. They establish that the build under test is the fixed one, and that whatever is still getting through is new.
The legal case has to keep working too, or the report is asking for a regression:
chunk-size line "5" -> accepted, body "hello"
chunk-size line "5;name=value" -> accepted, body "hello" (a chunk extension, and legal)
Everything ran against mint 1.10.0 from Hex, driving Mint.HTTP1 at a loopback TCP server that writes the chunk-size line byte for byte. Containerised, --network none, and repeated on OTP 27 and OTP 28 so the result is not an artefact of one runtime.
What I did not build
I did not build a smuggling chain, and the report says so in those words.
The primitive is a disagreement, and a disagreement needs a second party. Plenty of intermediaries stop at the first non-hex byte exactly as Mint did, and they would have agreed with it all day. The divergence needs a fronting element stricter than Mint — which is, precisely, the configuration the Content-Length fix had been written for six weeks earlier by the same maintainers.
I named no proxy and paired nothing. I made no availability claim either: the chunk-size line was already length-bounded by two earlier fixes, so a long tail is not a memory-exhaustion path.
The claim was narrow. Everything that is neither a hex digit, nor a ;-introduced extension, nor the CRLF is accepted, and one parser in the same codebase already refuses that class of input.
Then they corrected my score, upward
Here is the part I did not expect.
I sent the report at CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:N/I:L/A:N — 4.0. The Erlang Ecosystem Foundation published it at CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N — 6.3.
Two points higher. And they got there while rejecting one of my claims: I had asserted S:C, scope changed, and they set SC:N/SI:N/SA:N, keeping the consequences inside the vulnerable system. They agreed with the mechanism, disagreed with my one expansive reading, and still came out above me.
The entire difference is two metrics:
| mine (3.1) | theirs (4.0) | |
|---|---|---|
| Attack Complexity | AC:H |
AC:L |
| Attack Requirements | (no such metric) | AT:P |
The finding has a precondition: it needs an intermediary stricter than Mint, on a pooled connection. That is real, it is outside the attacker’s control, and any honest score has to carry it somewhere.
In CVSS 3.1 there is exactly one place to put it. AC:H is the only lever available, so a deployment precondition and a genuinely flaky exploit receive the same treatment. And AC:H is expensive, because it is also meant to say this may not work when you try it. My attack is not flaky at all — against a strict intermediary it works every time. I paid a reliability penalty in order to describe a configuration.
CVSS 4.0 split those two ideas apart. Attack Requirements exists for exactly this: conditions of the deployment or of the target’s state that the attacker does not control. Put the precondition in AT:P, where it belongs, and AC goes back to describing the attack, which is AC:L.
Same bug, same facts, same honesty about the precondition. One metric that could hold it, instead of one that could not.
I do not think the 4.0 was dishonest and I would send it again with 3.1 in the box. I would rather under-claim than defend a number I cannot. But this is the first time a maintainer has shown me that the number was wrong while the judgment behind it was fine, and the lesson is not “score higher”. It is that when a finding’s weight sits in a precondition, 3.1 has nowhere to put it, and reaching for 4.0 is not inflation.
It is worth noting who was able to make that correction. The Erlang Ecosystem Foundation is its own CNA, so there was no third-party request, no queue and no form: the people who read the code assigned the identifier and scored it themselves, in the same week. Of the six published identifiers I have been credited on, not one came from a repository host acting as CNA.
The fix deletes the honest function
The patch is c823778, +56/−4 in the parser and about a hundred and thirty lines of new tests.
The first thing it does is remove ignore_until_crlf/1. All three clauses, gone. Nothing in Mint ignores until CRLF any more.
In its place is chunk_extensions/1, a small state machine that walks the tail deliberately — optional whitespace, ;-introduced extension names, token or quoted-string values, then the CRLF — and returns :invalid_chunk_size for anything else. It runs on the terminating chunk as well as on data chunks, which is the half that decided where the body ended.
The maintainers also tested the thing I care most about: the delivery, not just the input. Each malformed line is exercised both as a whole response and byte by byte, because a parser that refuses 5ZZZ when it arrives at once and accepts it when it arrives in four packets has not been fixed. ericmj wrote the fix and whatyouhide reviewed it; both are credited on the advisory.
The shape to look for
What stays with me is that ignore_until_crlf was not a bug hiding behind a clever name. It was a bug announced by an honest one.
Every function did what it was called. Nothing in the code was lying, no line looked wrong in review, and the sibling parser one module away had already been taught the stricter rule without anyone noticing that this one had not.
Parsers tend to split into “read the value” and “handle the rest”. The second half is where the grammar actually lives, and it is the half that gets a name describing movement rather than a decision — ignore_, skip_, consume_, advance_past_. When you find one, the question worth asking is not whether it does what it is called. It is which function in the pair was supposed to have checked, and whether anybody ever wrote it.
Reported 8 September through the project’s private advisory flow. Fixed, published and tagged v1.10.1 on 19 September — eleven days.
Eurico Nicacio — @h3llh0und