HexBrain

curl · Report #3971538

27: IMAP custom FETCH listing classification skips literal boundaries, enabling response desynchronization

Informativestatus
18upvotes
10 Sept 2026disclosed

How it was found

Summary

is_custom_fetch_listing_match() in lib/imap.c classifies any custom FETCH whose sequence set contains : or , as a "listing." For that classification, imap_state_listsearch_resp() skips literal detection entirely. The ping-pong line parser then scans the server-declared literal body as IMAP protocol lines. A tagged completion line embedded inside attacker-controlled message content terminates the active request prematurely, and the remaining literal bytes are interpreted as responses to a subsequent request on the reused connection.

The result is that content from one fetched message can be returned as the body of a different requested message, while the genuine response is suppressed.

Root cause

is_custom_fetch_listing_match() at lib/imap.c:1181-1197:

static bool is_custom_fetch_listing_match(const char *params)
{
  if(*params++ != ' ')
    return FALSE;
  while(ISDIGIT(*params)) {
    params++;
    if(*params == 0)
      return FALSE;
  }
  if(*params == ':')
    return TRUE;
  if(*params == ',')
    return TRUE;
  return FALSE;
}

This classifies UID FETCH 1:* BODY[] as a listing purely because of the : in the sequence set, without considering the requested data items.

imap_state_listsearch_resp() at lib/imap.c:1233-1235:

if(imapcode == '*' && is_custom_fetch_listing(imap)) {
  /* custom FETCH or UID FETCH for listing is not handled here */
}

This branch prevents the else if(imapcode == '*') block from executing. That block is the one that calls imap_find_literal(), parses {size}, and sets up proper literal consumption. With it skipped, Curl_pp_readresp() in lib/pingpong.c processes every \r\n-terminated line inside the literal as a protocol response. Any line matching the active tag terminates the request.

Reproduction

From a curl source checkout at or after commit c2676bf:

2. Run

Place client.c, server.py, and Dockerfile directory inside the curl source tree, then:

docker build -t curl-imap-desync -f poc/Dockerfile .
docker run --rm curl-imap-desync

3. Expected output

MODE=vulnerable
FIRST_RESULT=0
FIRST_BYTES=0
SECOND_RESULT=0
SECOND_BODY=FORGED-FOR-UID-456
VERDICT=VULNERABLE
MODE=control
FIRST_RESULT=0
FIRST_BYTES=266361
SECOND_RESULT=0
SECOND_BODY=GENUINE-FOR-UID-456
VERDICT=CONTROL_OK

The vulnerable case shows SECOND_BODY=FORGED-FOR-UID-456 — the attacker's payload was returned as the body of the second, unrelated FETCH. The matched control changes only the first request from UID FETCH 1:* BODY[] to UID FETCH 1 BODY[]. With the single-UID form, curl honors the literal boundary and returns GENUINE-FOR-UID-456.

The server withholds the forged FETCH response until after curl has sent the second command, proving the substitution happens through connection reuse rather than buffered data.

Impact

An attacker who can place a message in the target's IMAP mailbox (i.e., send an email) can embed a ~266 KB spray of fake tagged completion lines covering all 26 tag prefixes and all 256 command-counter values. When an application fetches this message as part of a multi-UID custom FETCH (e.g., UID FETCH 1:* BODY[]) and then reuses the connection for a second request, curl returns the attacker-controlled payload as the body of the second request, suppressing the server's genuine response.

Demonstrated impact is integrity and availability. I have not demonstrated confidentiality loss, command injection, memory corruption, or code execution.

Exploitation requires an application to:

  1. issue a custom multi-message content FETCH containing : or , via CURLOPT_CUSTOMREQUEST;
  2. include an attacker-controlled message in that FETCH;
  3. reuse the same authenticated IMAP connection for a subsequent request; and
  4. trust libcurl's association between requests and returned message bodies.

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