CVE-2026-87864: IMAP APPEND plus CRLF conversion command injection
What the report claims
When CURLOPT_CRLF is enabled, curl's IMAP APPEND command declares an incorrect byte count in the literal, causing overflow bytes to be parsed as new IMAP commands.
Compare with curl's recorded outcome
Known vulnerability
A real vulnerability: CVE-2026-87864 IMAP APPEND plus CRLF conversion command injection
Shown for comparison after analysis. The pipeline did not see this label.
ProveIt's finding
The code supports the reported flaw. A maintainer should review its impact and decide what to do next.
Why this finding?
- imap_perform_append sends original file size in APPEND {N} literal (lib/imap.c:943-944)
- Reader stack is initialized with original size (lib/imap.c:894, lib/sendf.c:1156)
- CRLF conversion reader expands LF→CRLF but reports unknown total length (-1), so converted size is never computed
- Curl_req_send_more transmits until EOS with no check against declared literal size (lib/request.c:458-479)
- Attacker controlling upload content can place IMAP commands after the boundary; they execute in authenticated session
- No bounds check, pre-computation, or rejection of the CURLOPT_CRLF+APPEND combination exists in provided code
How this analysis ran 5 claims · 11 citations verified
Checked against curl tested commit c2676bf9e639 (c2676bf9e639). Test evaluation split. Citation verification checks that a referenced line was in the evidence; it does not prove the model interpreted it correctly.
Review a draft reply to the reporter
Edit and verify before sending.
Thank you for the detailed report and reproduction. The code confirms the framing mismatch: imap_perform_append declares the original size in the APPEND literal (lib/imap.c:943) while the send loop (lib/request.c:458) transmits the CRLF-expanded body without stopping at that boundary. This allows command injection in the authenticated IMAP session. We will treat this as a security issue and evaluate the suggested fixes (reject the combination or pre-compute converted size).
Inspect all claims and source code 5 claims · 5 code excerpts
Report claims
curl's IMAP APPEND command declares the original input file size in the {N} literal but transmits more bytes due to CRLF expansion
Read the reasoning
The code shows imap_perform_append sends the original file size in the IMAP APPEND {N} literal (line 943-944) while setting up the reader with that same original size (line 894). When CRLF conversion is enabled, a line-conversion reader (cr-lineconv) is stacked on top, which expands bare LF to CRLF, causing more bytes to be transmitted than declared.
in lib/imap.c, imap_perform_append() sets up the upload reader with the original size
Read the reasoning
Directly visible in imap_perform_append at line 894.
enabling CURLOPT_CRLF or CURLOPT_TRANSFERTEXT=1 during an authenticated IMAP APPEND upload
Read the reasoning
The deterministic checks confirm both options exist. CURLOPT_CRLF / CURLOPT_TRANSFERTEXT=1 triggers the line-conversion reader stack in the creader layer (not shown in provided snippets but confirmed by symbol existence and curl architecture).
overflow bytes are parsed as new IMAP commands in the same authenticated session
Read the reasoning
Curl_req_send_more (the send loop) has no awareness of the IMAP literal size; it transmits until the reader signals EOS. The server, per IMAP protocol (RFC 3501), consumes exactly the declared N bytes as the literal body, then parses subsequent bytes as new commands in the same authenticated session.
upload a 66-byte file with 22 bare LFs followed by a suffix, enable --crlf, and perform IMAP APPEND; the server consumes only the declared size and parses the remaining bytes as commands
Read the reasoning
The reproduction describes a concrete instance of the general mechanism: 22 bare LFs expand by 22 bytes, pushing attacker-controlled suffix past the declared boundary. The code path supports this exactly.
What the report says about other projects
not addressed The server consumes exactly N bytes as the literal, and the overflow bytes are parsed as new IMAP commands in the same authenticated session.
Read the original report (excerpt)
Source code
| 853 | static CURLcode imap_perform_append(struct Curl_easy *data, |
| 854 | struct imap_conn *imapc, |
| 855 | struct IMAP *imap) |
| 856 | { |
| 857 | CURLcode result = CURLE_OK; |
| 858 | char *mailbox; |
| 859 | struct dynbuf flags; |
| 860 | |
| 861 | /* Check we have a mailbox */ |
| 862 | if(!imap->mailbox) { |
| 863 | failf(data, "Cannot APPEND without a mailbox."); |
| 864 | return CURLE_URL_MALFORMAT; |
| 865 | } |
| 866 | |
| 867 | #ifndef CURL_DISABLE_MIME |
| 868 | /* Prepare the mime data if some. */ |
| 869 | if(IS_MIME_POST(data)) { |
| 870 | curl_mimepart *postp = data->set.mimepostp; |
| 871 | |
| 872 | /* Use the whole structure as data. */ |
| 873 | postp->flags &= ~(unsigned int)MIME_BODY_ONLY; |
| 874 | |
| 875 | /* Add external headers and mime version. */ |
| 876 | curl_mime_headers(postp, data->set.headers, 0); |
| 877 | result = Curl_mime_prepare_headers(data, postp, NULL, |
| 878 | NULL, MIMESTRATEGY_MAIL); |
| 879 | |
| 880 | if(!result) |
| 881 | if(!Curl_checkheaders(data, STRCONST("Mime-Version"))) |
| 882 | result = Curl_mime_add_header(&postp->curlheaders, |
| 883 | "Mime-Version: 1.0"); |
| 884 | |
| 885 | if(!result) |
| 886 | result = Curl_creader_set_mime(data, postp); |
| 887 | if(result) |
| 888 | return result; |
| 889 | data->state.infilesize = Curl_creader_client_length(data); |
| 890 | } |
| 891 | else |
| 892 | #endif |
| 893 | { |
| 894 | result = Curl_creader_set_fread(data, data->state.infilesize); |
| C1: Curl_creader_set_fread called with original infilesize | |
| C2: Curl_creader_set_fread(data, data->state.infilesize) sets up reader with original size | |
| C3: Reader setup path used for non-MIME uploads | |
| 895 | if(result) |
| 896 | return result; |
| 897 | } |
| 898 | |
| 899 | /* Check we know the size of the upload */ |
| 900 | if(data->state.infilesize < 0) { |
| C2: Explicit check that infilesize >= 0, confirming known size is required | |
| 901 | failf(data, "Cannot APPEND with unknown input file size"); |
| 902 | return CURLE_UPLOAD_FAILED; |
| 903 | } |
| 904 | |
| 905 | /* Make sure the mailbox is in the correct atom format */ |
| 906 | mailbox = imap_atom(imap->mailbox, FALSE); |
| 907 | if(!mailbox) |
| 908 | return CURLE_OUT_OF_MEMORY; |
| 909 | |
| 910 | /* Generate flags string and send the APPEND command */ |
| 911 | curlx_dyn_init(&flags, 100); |
| 912 | if(data->set.upload_flags) { |
| 913 | int i; |
| 914 | struct ulbits ulflag[] = { |
| 915 | { CURLULFLAG_ANSWERED, "Answered" }, |
| 916 | { CURLULFLAG_DELETED, "Deleted" }, |
| 917 | { CURLULFLAG_DRAFT, "Draft" }, |
| 918 | { CURLULFLAG_FLAGGED, "Flagged" }, |
| 919 | { CURLULFLAG_SEEN, "Seen" }, |
| 920 | { 0, NULL } |
| 921 | }; |
| 922 | |
| 923 | result = CURLE_OUT_OF_MEMORY; |
| 924 | if(curlx_dyn_add(&flags, " (")) { |
| 925 | goto cleanup; |
| 926 | } |
| 927 | |
| 928 | for(i = 0; ulflag[i].bit; i++) { |
| 929 | if(data->set.upload_flags & ulflag[i].bit) { |
| 930 | if((curlx_dyn_len(&flags) > 2 && curlx_dyn_add(&flags, " ")) || |
| 931 | curlx_dyn_add(&flags, "\\") || |
| 932 | curlx_dyn_add(&flags, ulflag[i].flag)) |
| 933 | goto cleanup; |
| 934 | } |
| 935 | } |
| 936 | |
| 937 | if(curlx_dyn_add(&flags, ")")) |
| 938 | goto cleanup; |
| 939 | } |
| 940 | else if(curlx_dyn_add(&flags, "")) |
| 941 | goto cleanup; |
| 942 | |
| 943 | result = imap_sendf(data, imapc, "APPEND %s%s {%" FMT_OFF_T "}", |
| C1: imap_sendf called with data->state.infilesize as the literal size in APPEND command | |
| C5: APPEND literal uses original infilesize | |
| 944 | mailbox, curlx_dyn_ptr(&flags), data->state.infilesize); |
| 945 | |
| 946 | cleanup: |
| 947 | curlx_dyn_free(&flags); |
| 948 | curlx_free(mailbox); |
| 949 | |
| 950 | if(!result) |
| 951 | imap_state(data, imapc, IMAP_APPEND); |
| 952 | |
| 953 | return result; |
| 954 | } |
| 1146 | CURLcode Curl_creader_set_fread(struct Curl_easy *data, curl_off_t len) |
| 1147 | { |
| 1148 | CURLcode result; |
| 1149 | struct Curl_creader *r; |
| 1150 | struct cr_in_ctx *ctx; |
| 1151 | |
| 1152 | result = Curl_creader_create(&r, data, &cr_in, CURL_CR_CLIENT); |
| 1153 | if(result || !r) |
| 1154 | goto out; |
| 1155 | ctx = r->ctx; |
| 1156 | ctx->total_len = len; |
| C1: creader context stores original len as total_len | |
| C5: Reader tracks original length, not converted length | |
| 1157 | |
| 1158 | cl_reset_reader(data); |
| 1159 | result = do_init_reader_stack(data, r); |
| 1160 | out: |
| 1161 | CURL_TRC_READ(data, "add fread reader, len=%" FMT_OFF_T " -> %d", |
| 1162 | len, (int)result); |
| 1163 | return result; |
| 1164 | } |
| 458 | CURLcode Curl_req_send_more(struct Curl_easy *data) |
| C4: Curl_req_send_more drains reader until eos_read without reference to declared literal size | |
| 459 | { |
| 460 | CURLcode result; |
| 461 | |
| 462 | /* Fill our send buffer if more from client can be read. */ |
| 463 | if(!data->req.upload_aborted && |
| 464 | !data->req.eos_read && |
| 465 | !Curl_xfer_send_is_paused(data) && |
| 466 | !Curl_bufq_is_full(&data->req.sendbuf)) { |
| 467 | size_t nread; |
| 468 | result = Curl_bufq_sipn(&data->req.sendbuf, 0, |
| C4: Loop continues while !data->req.eos_read and sendbuf not full | |
| C5: Send loop ignores literal boundary | |
| 469 | add_from_client, data, &nread); |
| 470 | if(result && result != CURLE_AGAIN) |
| 471 | return result; |
| 472 | } |
| 473 | |
| 474 | result = req_flush(data); |
| 475 | if(result == CURLE_AGAIN) |
| 476 | result = CURLE_OK; |
| 477 | |
| 478 | return result; |
| 479 | } |
Show 36 lines (not cited by the judge)
| 156 | static CURLcode imap_sendf(struct Curl_easy *data, |
| 157 | struct imap_conn *imapc, |
| 158 | const char *fmt, ...) CURL_PRINTF(3, 0); |
| 159 | static CURLcode imap_sendf(struct Curl_easy *data, |
| 160 | struct imap_conn *imapc, |
| 161 | const char *fmt, ...) |
| 162 | { |
| 163 | CURLcode result = CURLE_OK; |
| 164 | |
| 165 | DEBUGASSERT(fmt); |
| 166 | |
| 167 | /* Calculate the tag based on the connection ID and command ID */ |
| 168 | curl_msnprintf(imapc->resptag, sizeof(imapc->resptag), "%c%03d", |
| 169 | 'A' + curlx_sltosi((long)(data->conn->connection_id % 26)), |
| 170 | ++imapc->cmdid); |
| 171 | |
| 172 | /* start with a blank buffer */ |
| 173 | curlx_dyn_reset(&imapc->dyn); |
| 174 | |
| 175 | /* append tag + space + fmt */ |
| 176 | result = curlx_dyn_addf(&imapc->dyn, "%s %s", imapc->resptag, fmt); |
| 177 | if(!result) { |
| 178 | va_list ap; |
| 179 | va_start(ap, fmt); |
| 180 | #ifdef __clang__ |
| 181 | #pragma clang diagnostic push |
| 182 | #pragma clang diagnostic ignored "-Wformat-nonliteral" |
| 183 | #endif |
| 184 | result = Curl_pp_vsendf(data, &imapc->pp, curlx_dyn_ptr(&imapc->dyn), ap); |
| 185 | #ifdef __clang__ |
| 186 | #pragma clang diagnostic pop |
| 187 | #endif |
| 188 | va_end(ap); |
| 189 | } |
| 190 | return result; |
| 191 | } |
Show 7 lines (not cited by the judge)
| 237 | char *curlx_dyn_ptr(const struct dynbuf *s) |
| 238 | { |
| 239 | DEBUGASSERT(s); |
| 240 | DEBUGASSERT(s->init == DYNINIT); |
| 241 | DEBUGASSERT(!s->leng || s->bufr); |
| 242 | return s->bufr; |
| 243 | } |