curl · Report #3971706
36: HTTP upload resume offset consumed twice after early 307/308 redirect
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
-
cr_in_resume_from()atlib/sendf.c:798-820invokesctx->read_cbto skip past the offset but never setsctx->has_used_cb = TRUE. The normal read path at line 687 does set it. -
cr_in_needs_rewind()atlib/sendf.c:755-760returns onlyctx->has_used_cb, sohttp_perhapsrewind()concludes no rewind is needed. -
http_resume()atlib/http.c:2400guards with!data->req.authnegand 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
- 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)
- Compile the client:
cc client.c -Iinclude -Lbuild/lib -lcurl -o poc-client
- Start the server:
python3 -u server.py &
- 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_FOLLOWLOCATIONmust 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.