curl · Report #4131903
A server response that answers nothing is used as the next transfer's reply — and with IMAP, a fully compliant server does this by accident
How it was found
Name: Maturi Jaswanth Sai Madhu Mohan
Product: curl / libcurl — all four pingpong protocols demonstrated: IMAP, POP3, SMTP and FTP — the same protocol set curl's own CVE-2026-92387 names
Affected: reproduced on current git master dbb8262a9c (8.23.1-DEV, fetched and rebuilt
today), on d1d4b5931a, on local 8.23.0-DEV and 8.22.0-DEV builds, and on the distro
curl 8.21.0 binary in /usr/bin/curl. The mechanism predates all of them.
Class: response misattribution across transfers on a reused connection. In the FTP variant it also steers where curl opens the next outbound data connection.
Severity: I suggest Low, and would understand Medium on the strength of the IMAP leg, which needs no attacker at all — just a server behaving normally while a client fetches two messages. Nothing here is memory unsafety and no credential is leaked.
The one-sentence version
When a pingpong server sends a line that no command asked for, curl keeps it in the connection's receive buffer and answers the next command with it. Demonstrated on all four protocols — IMAP, POP3, SMTP and FTP — the same set curl's own CVE-2026-92387 names. Depending on the protocol the next transfer gets the wrong content with exit 0 (POP3, IMAP with a body), fails on a reply that belongs to another command (IMAP, SMTP), or opens its data connection where a stray line pointed (FTP). It survives curl's own connection-reuse check because that check polls the socket once, at reuse time.
Why this is in scope, and not a severity opinion
On 2026-10-15 curl merged ef6b17cf12 "conncache: poll an idle connection for pending input on
every reuse" (Bug: hackerone 4098761, Closes #23412). Its message states the policy in curl's
own words:
An idle HTTP/1.1 connection that had received unsolicited data from the server was then reused within that second and the stray bytes came back as the response to the next request, so every later response on that connection was shifted by one. […] RFC 9112 section 9.2 says a client must not treat data received with no outstanding request as a response, so poll for pending input on every reuse of an idle non-multiplexed connection […]
This report is the same consequence, on the pingpong protocols, with that commit present. The poll is a single snapshot of the socket at the moment reuse is decided. It cannot cover the case where the server pushes its unrequested reply a moment later — which is exactly what a server that wants to desynchronise a reused connection would do. The remaining gap is not the poll's timing, it is that the pingpong parser has no notion of which command a reply answers: whatever is next in the buffer becomes the response.
Each ingredient of this bug already has a CVE
| precedent | what curl treated as a vulnerability | where it lands here |
|---|---|---|
| CVE-2026-92387 — advisory 2026-10-14, "never-ending loop via server response omission", affected 8.10.0–8.22.0, severity Low | "By permanently omitting the final control-response line, a malicious FTP, IMAP, POP3, or SMTP server can exploit libcurl and completely bypass its configured transfer timeout mechanism"; its recommendation C is "Avoid using the ping-pong protocols" | the threat model is accepted: a server on exactly these four protocols, exploiting how its responses are framed — and graded Low. This report covers the same four protocols and the same framing weakness, with the addition that the IMAP trigger needs no malice |
| CVE-2020-8284 — "trusting FTP PASV responses" | curl connecting wherever a 227 line told it to; mitigated by --ftp-skip-pasv-ip, on by default | the same trust, reached differently: this 227 answers nothing. The default mitigation still holds the address and lets the port move; with --no-ftp-skip-pasv-ip both move |
| CVE-2026-86411 — "FTP connection reuse inherits client-certificate"; CVE-2023-27535 — "FTP too eager connection reuse" | per-connection state from one transfer affecting a later transfer that reused the connection | the same shape, except the inherited state is the pingpong receive buffer instead of a credential |
hackerone 4098761 → ef6b17cf12 (2026-10-15) | "the stray bytes came back as the response to the next request, so every later response on that connection was shifted by one" | the identical consequence, on the protocols whose buffered leftovers that commit's socket poll cannot see |
I am not asking for a higher band than curl gives this family: CVE-2026-92387 is Low and so is my suggestion. The difference is the outcome — theirs is a hang, mine is wrong content delivered with exit code 0, and an outbound data connection to a port the server picked in a line that answered nothing.
Summary
One curl invocation, two transfers, one connection. The server sends one line that no command asked for. curl takes it as the answer to the second transfer's command:
- IMAP — a compliant server does this on its own, no attacker. Untagged responses are legal at
any moment in RFC 3501, and servers really send them when a phone marks mail read. One such line
landing between two fetches makes the second fetch fail with exit 8 (
Failed to parse FETCH response), and if the untagged response carries a body, that body is written out as the next message's content with exit 0. - POP3 — content substitution, reported as success.
message 2written to disk is attacker-chosen text. The honest reply toRETR 2is displaced and becomes the new leftover, so everything after it is shifted by one — the wording ofef6b17cf12's own bug report. Exit code 0, no warning. - SMTP — the next submission is lost. The stray
250is taken as the reply to the nextMAIL FROM, every following reply slides by one,DATAis answered with a250where354is required, and the submission fails with exit 55. Stated precisely because it matters: curl does not report a false success here — the damage is a lost mail, not a silent one. - FTP — the next data connection goes where the stray line points. The unrequested reply is
227 Entering Passive Mode (…)naming a port. Transfer 2 opens its data connection there and the honest227for that samePASVgoes unused. With default options the port is attacker- chosen and the address stays curl's own; with--no-ftp-skip-pasv-ipthe line chooses the address too.
Evidence 1 — IMAP, with a server doing nothing unusual
Two messages, one command line, one connection. Between the two fetches the server sends
* 2 FETCH (UID 2 FLAGS (\Seen))
which is what any IMAP server does when another client marks a message read — RFC 3501 permits untagged responses at any time. curl then asks for message 2 and reads that leftover as its reply:
> A004 FETCH 1 BODY[]
< * 1 FETCH (UID 1 BODY[] {20} HONEST-BODY-OF-MSG-1
< A004 OK FETCH completed
< * 2 FETCH (UID 2 FLAGS (\Seen)) <- untagged, nothing outstanding
> A005 FETCH 2 BODY[]
< * 2 FETCH (UID 2 FLAGS (\Seen)) <- curl parses THIS as A005's answer
* Failed to parse FETCH response.
exit=8 m1='HONEST-BODY-OF-MSG-1' m2=<no file>
The honest * 2 FETCH (UID 2 BODY[] …) that the server did send never reaches the application.
Same shape, with a body in the untagged response, and it is silent:
curl exit code : 0
message 1 received : HONEST-BODY-OF-MSG-1
message 2 received : OTHER-SESSION-CONTENT-XY (the server's honest answer was HONEST-BODY-OF-MSG-2)
Control — identical two-fetch command, no untagged line: both messages honest, exit 0, one
connection. (A body-bearing untagged FETCH is less common unsolicited than a FLAGS one; the
FLAGS case is the one that needs nothing unusual from the server at all, and it is the one that
fails the transfer.)
I also checked the six untagged forms a server really uses — * N EXISTS, * N EXPUNGE,
* BYE, * STATUS, * N FETCH (FLAGS …), * N FETCH (BODY[] …) — inserted during a transfer
as well as between transfers. During a transfer curl handles every one of them correctly; only
the between-commands case mis-attributes them. That is consistent with the leftover being replayed
by the connection-level buffer rather than by IMAP's own parser.
Evidence 2 — POP3 (all three scenarios come from one run of poc.py)
1/3 — the bug. Server pushes one multi-line reply 30 ms after finishing message 1, with nothing outstanding. curl then starts transfer 2 on the reused connection.
0.442s CURL -> SERVER RETR 1
0.442s SERVER -> CURL +OK 1 octets / HONEST-MESSAGE-ONE / . (answer to RETR 1)
0.472s SERVER -> CURL +OK 1 octets / ATTACKER-INJECTED-CONTENT / . <<< UNSOLICITED, NOTHING OUTSTANDING
0.472s CURL -> SERVER RETR 2
0.472s SERVER -> CURL +OK 1 octets / HONEST-MESSAGE-TWO / . (honest answer to RETR 2)
curl exit code : 0
TCP connections used : 1
message 1 received : HONEST-MESSAGE-ONE
message 2 received : ATTACKER-INJECTED-CONTENT (server's honest answer was HONEST-MESSAGE-TWO)
VERDICT : BUG REPRODUCED - attacker text delivered as message 2, exit code 0, on the
reused connection
The honest reply was sent and the application never saw it.
2/3 — the control that makes the mechanism clear. Identical server, but transfer 2 is made to
start ~3 s later (--rate 1/3s), so curl's reuse check now runs after the stray bytes have
arrived. curl refuses the connection and opens a fresh one, and message 2 is honest:
curl exit code : 0
TCP connections used : 2 (the connection was NOT reused)
message 2 received : HONEST-MESSAGE-TWO
So ef6b17cf12 is working as designed and I am not claiming it is broken. What this pair shows is
that a check at reuse time cannot protect a transfer whose response is what the stray data gets
confused with. The server decides the timing; a server that pushes the line after curl's check
still gets it delivered as the next response.
3/3 — A/B control. Same script, unsolicited reply switched off: honest message 2, exit 0, one connection. The only difference between 1/3 and 3/3 is that one reply.
Evidence 3 — SMTP: the next submission is lost
The server pushes 250 STRAY-QUEUED-NOTHING-OUTSTANDING right after honestly accepting message 1.
Transfer 2 then submits on the reused connection, and the server answers every one of its commands
correctly:
> MAIL FROM:<[email protected]> (submission 2)
< 250 STRAY-QUEUED-NOTHING-OUTSTANDING <- the stray, not a reply to this
> RCPT TO:<[email protected]>
< 250 2.1.0 Sender ok <- the reply that belongs to MAIL FROM
> RCPT TO:<[email protected]>
< 250 2.1.5 Recipient ok <- the reply to the RCPT before it
> DATA
< 250 2.1.5 Recipient ok <- a 250 where 354 is required
curl exit code : 55 messages the server actually accepted: 1 of 2
Everything is shifted by one, so the second submission dies on a 250 that was the answer to
RCPT TO. Control (same two submissions, no stray line): both accepted, exit 0.
I am deliberately not dressing this up: curl reports failure rather than a false success, so what the stray line costs here is a mail that never got sent, not a phantom confirmation.
Evidence 4 — FTP (default options, then --no-ftp-skip-pasv-ip, then control)
Port 54127 is the honest passive port, 55967 is the one named by the unrequested line. The attacker listener is bound only on 55967, so an accepted connection there cannot have come from anything else. Verbatim from the run:
5.477s CURL -> SERVER PASV
5.477s SERVER -> CURL 227 Entering Passive Mode (127,0,0,1,211,111). (honest answer to PASV #1)
5.477s SERVER -> CURL *** HONEST data listener on 127.0.0.1:54127 ACCEPTED a connection from 127.0.0.1:48798 ***
5.548s SERVER -> CURL 227 Entering Passive Mode (127,0,0,1,218,159). <<< UNSOLICITED - NO COMMAND OUTSTANDING
5.628s CURL -> SERVER PASV
5.628s SERVER -> CURL 227 Entering Passive Mode (127,0,0,1,211,111). (honest answer to PASV #2)
5.628s SERVER -> CURL *** ATTACKER data listener on 127.0.0.1:55967 ACCEPTED a connection from 127.0.0.1:35910 ***
(curl's own -v output for the same two transfers)
5.630s curl < 227 Entering Passive Mode (127,0,0,1,211,111).
5.630s curl * Connecting to 127.0.0.1 port 54127
5.630s curl > PASV
5.630s curl < 227 Entering Passive Mode (127,0,0,1,218,159).
5.630s curl * Connecting to 127.0.0.1 port 55967
218,159 is 55967 — the port from the line that answered nothing, and curl's own verbose output
shows it connecting there for transfer 2 while the honest 227 for that PASV (54127) is consumed
by the following command.
With --no-ftp-skip-pasv-ip, the same single line moves the address as well; the listener is
bound on 127.0.0.2 only:
==> UNSOLICITED 227 -> 127.0.0.2:<attacker port> (NO COMMAND OUTSTANDING)
*** EVILIP listener ACCEPTED data connection 127.0.0.2:<attacker port> ***
Control (identical setup, unsolicited line switched off): both transfers use the honest port,
both files contain HONEST-DATA, exit code 0.
Bounding the impact honestly: in the FTP case the transfer then fails (rc=19, one file short), so
what I can show is an outbound connection to an attacker-chosen port/address and a lost or
substituted file — not a silent success. The POP3 case is the silent one.
Root cause: two paths, and they need different fixes
lib/pingpong.c keeps one receive buffer per connection, and it deliberately retains the bytes
that follow a response's final line:
lib/pingpong.c:308-316— at end of response the final line is kept for the outer parser andpp->overflowis set to the byte count after it (:314);lib/pingpong.c:246-252— on the next call the retained remainder is replayed (:251);lib/pop3.c:1242-1264— POP3's loop says "keep only the overflow" (:1247) and keeps those bytes for the next command; FTP does the same atlib/ftp.c:480,:500,:3209.
That state is attached to the connection, not the easy handle —
pop3_setup_connection() uses Curl_conn_meta_set(conn, CURL_META_POP3_CONN, pop3c, pop3_conn_dtor) (lib/pop3.c:1687) and it is freed only when the connection dies. So a second
easy handle reusing the connection inherits the buffer.
The reuse decision cannot see any of that. Curl_cpool_conn_seems_healthy() computes poll_input
(lib/conncache.c:1056) and calls Curl_conn_is_alive(), whose socket filter ends at
lib/cf-socket.c:1829 — r = Curl_poll(pfd, 1, 0);. Curl_conn_data_pending() exists and is what
would expose buffered input, but it is never consulted in the reuse path.
I separated the two ways a stray line ends up answering the wrong command, by patching and re-running:
Path A — the stray was already read into curl's buffer before the transfer ended. Proven by
patching: adding a Curl_pp_reset_recv() call to pop3_done(), imap_done(), smtp_done() and
ftp_done() (drop pp->recvbuf, nfinal, overflow when a transfer finishes) fixes the FTP
leg — both FTP scenarios go from data connections accepted: HONEST, ATTACKER to
HONEST, HONEST with exit 0, and the FTP control scenario is unaffected. The patch is attached as
pingpong-reset-recv-at-transfer-end.patch; it is 49 added lines and builds warning-free.
Path B — the stray arrives after the reuse poll and after the previous transfer ended. This is what the POP3, IMAP and SMTP scenarios do, and it is why they still reproduce with path A closed: the bytes were never in curl's buffer at transfer-end, they were in flight. No snapshot check can catch them. The only real defence is per-command correlation:
- IMAP has it and does not use it here.
imap_endofresp()(lib/imap.c) correctly separates tagged replies (matched againstimapc->resptag) from untagged ones. The damage is done one step later: an untaggedFETCHthat does not match what the command asked for is fed into the FETCH parser and fails the command (Failed to parse FETCH response, exit 8) instead of being treated as the information it is while curl keeps waiting for its tagged reply. That is a protocol-level fix in IMAP's own state machine, and it is the one I would attempt first. - POP3 and SMTP have nothing to correlate with — a bare
+OK/250carries no request identifier. For those two, a client genuinely cannot tell an early reply from an unsolicited one over TCP. I am not going to pretend the patch above fixes them, and I would rather hear how the maintainers want to handle that than invent a framing scheme.
Steps to reproduce
Needs only python3 (stdlib) and a curl with IMAP + POP3 + SMTP + FTP support (standard builds).
Localhost, no root, no external host, ~25 seconds.
cd report-a-pingpong-reuse-bypass
CURL=/path/to/curl python3 poc.py # all eleven scenarios + verdicts
CURL=/path/to/curl python3 poc.py imap # the IMAP three - compliant server
CURL=/path/to/curl python3 poc.py smtp # the SMTP pair
CURL=/path/to/curl python3 poc.py pop3 # the POP3 three
CURL=/path/to/curl python3 poc.py ftp # the FTP three
poc.py prints both sides of the conversation with timestamps, picks free ports by itself, and
ends with a BUG REPRODUCED / control behaves correctly verdict per scenario. Retrieved files are
left in a temp directory whose path it prints.
Manual equivalent of the POP3 case, if you would rather drive it yourself — the important part is two URLs in one command line, which is what forces the reuse:
curl -v -u u:p -o msg1.txt pop3://<server>/1 -o msg2.txt pop3://<server>/2
against a server that, immediately after completing message 1's . CRLF, sends
+OK 1 octets\r\nATTACKER-INJECTED-CONTENT\r\n.\r\n
Separate curl invocations do not reproduce it (each process has its own connection); I made that mistake first, which is why it is written down.
Suggested fix
Reading the "Root cause" section above first, because the honest answer is that one patch does not cover it:
- IMAP: an untagged response must never be able to fail the command that happens to be outstanding. Handle it as information and keep waiting for the tagged reply. This is the one that needs no attacker and is entirely within curl's reach — IMAP already parses tags correctly, so this is about what the FETCH state machine does with a line it has already identified as untagged.
- All four: drop the pingpong receive cache when a transfer ends, so bytes retained from the
previous transfer cannot be replayed into the next one. I have this working (
49lines,pingpong-reset-recv-at-transfer-end.patch); it verifiably fixes the FTP leg. Offered as a starting point, not as the answer to path B. - POP3 / SMTP: since these cannot be correlated client-side, the conservative option is to
refuse reuse of a pooled pingpong connection whose buffer was non-empty at transfer end (curl
already knows:
pp->overflow/Curl_pp_moredata()), which turns path A into a fresh connection instead of a shifted response. - FTP additionally: only accept a
227/229as data-connection information if aPASV/EPSVwas actually outstanding when it was read.
I would rather send the version the maintainers would accept, so I am holding the patch until I
hear which of these directions they want — and I am happy to be told the framing belongs in the
protocol modules rather than in lib/pingpong.c.
** What is in this submission**
REPORT.md this file poc.py the whole PoC - eleven scenarios, prints both sides of every conversation live proof.png one page: the five results with their controls, rendered from the PoC output
`python3 poc.py` needs only stdlib python3 and a curl with IMAP / POP3 / SMTP / FTP support. It binds
127.0.0.1 only, picks its own free ports and takes about 25 seconds. Everything in this report's
excerpts — including each server's own log of what it sent and when — is printed as it happens, so
the transcript can be regenerated rather than taken on trust.
The patch mentioned under "Suggested fix" is deliberately not attached: it is my own verification
tool, it fixes only path A, and attaching a partial fix to a security report helps nobody. Say the
word and I will send it or open a PR against whichever direction the maintainers prefer.
Impact
Headline — the one sentence that carries it: │ What curl writes to your output file, and whether it tells you the transfer succeeded, is supposed to be determined by the server's answer to the request curl actually made. On all four pingpong protocols a line the server sent while nothing was outstanding becomes the answer to the next command — so curl reports the wrong bytes as the right ones, or fails a command the server had already answered correctly.
Per leg (demonstrated → consequence → who can trigger it):
- IMAP — an ordinary untagged * N FETCH (FLAGS …) between two fetches makes the second fetch fail (exit 8, file never written); an untagged FETCH carrying a body writes those bytes into the next message's file with exit 0. → No attacker needed. Any compliant server pushing a normal change notification while a phone/desktop touches the same mailbox. This is the leg to lead with.
- POP3 — attacker-chosen text delivered as message 2, honest reply displaced, everything after shifted by one, exit 0. → Mail content the mailbox never stored reaches the app as a real message with a success code; anything downstream (storage, filters, scanners, display) trusts attacker-chosen text.
- SMTP — stray 250 shifts replies until DATA gets a 250 instead of 354; submission lost, exit 55, server accepted 1 of 2. → I state explicitly that curl does not report a false success here, because that was the first thing I looked for and it isn't true.
- FTP — transfer 2's data connection opened on the port named by a 227 that answered no PASV (address too with --no-ftp-skip-pasv-ip); transfer then fails exit 19. → An outbound connection whose destination is taken from a non-reply, plus a lost download.