HexBrain

curl · Report #3971706

36: HTTP upload resume offset consumed twice after early 307/308 redirect

Informativestatus
3upvotes
7 Sept 2026disclosed

How it was found

Summary

A read-backed HTTP POST using CURLOPT_RESUME_FROM_LARGE with a non-seekable source silently uploads the wrong data when the server returns a 307 or 308 redirect before the request body is sent. curl_easy_perform() returns CURLE_OK, so the caller cannot detect the substitution.

The bug is in cr_in_resume_from() (lib/sendf.c): when it falls back to reading-and-discarding through ctx->read_cb (no seek callback available), it does not set ctx->has_used_cb. On redirect, cr_in_needs_rewind() checks only that flag and reports no rewind needed. A fresh reader is created and http_resume() applies the persistent resume_from offset a second time. For a source of configured size N and offset O, the followed request sends N−O bytes starting at source offset 2O instead of O.

Introduced by commit 14bcea074a ("lib: enhance client reader resume + rewind"), present since curl 8.7.0. Tested against commit c2676bf9e639.

Root cause detail

  1. cr_in_resume_from() at lib/sendf.c:798-820 invokes ctx->read_cb to skip past the offset but never sets ctx->has_used_cb = TRUE. The normal read path at line 687 does set it.

  2. cr_in_needs_rewind() at lib/sendf.c:755-760 returns only ctx->has_used_cb, so http_perhapsrewind() concludes no rewind is needed.

  3. http_resume() at lib/http.c:2400 guards with !data->req.authneg and a comment saying "only act on the first request," but that condition does not exclude redirects. On the followed 308, set_reader() + http_resume() runs again (lines 3112-3116) and applies the offset a second time.

Reproduction

Prerequisites

Docker and a curl source tree at commit c2676bf9e639 or later (the bug is still present on HEAD).

Steps

  1. Build curl from source at commit c2676bf9e639 (or any commit since 8.7.0):
cmake -S . -B build \
  -DBUILD_SHARED_LIBS=ON -DBUILD_CURL_EXE=OFF -DBUILD_TESTING=OFF \
  -DCURL_USE_OPENSSL=OFF -DCURL_USE_LIBPSL=OFF -DCURL_ZLIB=OFF
cmake --build build -j$(nproc)
  1. Compile the client:
cc client.c -Iinclude -Lbuild/lib -lcurl -o poc-client
  1. Start the server:
python3 -u server.py &
  1. Run the client:
LD_LIBRARY_PATH=build/lib ./poc-client

Expected output (fixed curl)

The sink receives 4096 bytes of B (byte value 66), curl_easy_perform returns 0, and the callback consumes 8192 bytes total (4096 skipped + 4096 sent).

Actual output (vulnerable curl)

SINK_LENGTH=4096
SINK_BYTE_VALUES=[67]
VERDICT=VULNERABLE
CURL_RESULT=0
SOURCE_BYTES_CONSUMED=12288

The sink receives 4096 bytes of C (byte value 67). The callback consumed 12288 bytes: the offset was applied twice (4096 skipped on first request + 4096 skipped again on redirected request + 4096 C bytes sent). curl_easy_perform returns CURLE_OK.

Suggested fix

Set ctx->has_used_cb = TRUE in cr_in_resume_from() when the fallback read-and-discard path invokes ctx->read_cb. This makes cr_in_needs_rewind() return true after resume, so redirect handling requests a rewind. Seekable sources rewind and reapply the offset once; non-seekable sources fail with CURLE_SEND_FAIL_REWIND instead of silently uploading the wrong interval.

I am aware that docs/cmdline-opts/continue-at.md describes HTTP upload resume as non-standard and "not guaranteed." The issue here is the local reader-position bookkeeping and the silent wrong-body success, not server-side interoperability.

Impact

A malicious or misconfigured server returning an early body-preserving redirect causes libcurl to upload bytes from a different region of the application's upload source while reporting success. The caller has no indication that the wrong data was sent.

Constraints:

  • Requires a read-backed POST/PUT with a positive resume offset.
  • The source must lack a seek callback (non-seekable).
  • CURLOPT_FOLLOWLOCATION must be enabled (off by default in libcurl).
  • The server must return 307/308 before the body is sent.
  • The read callback must be able to supply data beyond the configured upload size.

Does not read arbitrary process memory; the extra bytes come from the application's own read callback.

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