HexBrain

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

N/Astatus
mediumseverity
5upvotes
22 Oct 2026disclosed

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

precedentwhat curl treated as a vulnerabilitywhere 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 defaultthe 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 connectionthe 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:

  1. 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.
  2. POP3 — content substitution, reported as success. message 2 written to disk is attacker-chosen text. The honest reply to RETR 2 is displaced and becomes the new leftover, so everything after it is shifted by one — the wording of ef6b17cf12's own bug report. Exit code 0, no warning.
  3. SMTP — the next submission is lost. The stray 250 is taken as the reply to the next MAIL FROM, every following reply slides by one, DATA is answered with a 250 where 354 is 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.
  4. 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 honest 227 for that same PASV goes unused. With default options the port is attacker- chosen and the address stays curl's own; with --no-ftp-skip-pasv-ip the 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 and pp->overflow is 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 at lib/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 against imapc->resptag) from untagged ones. The damage is done one step later: an untagged FETCH that 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/250 carries 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:

  1. 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.
  2. 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 (49 lines, 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.
  3. 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.
  4. FTP additionally: only accept a 227/229 as data-connection information if a PASV/EPSV was 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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original