curl · Report #4025056
HTTP/1.1 response framing violation and unsafe connection reuse when transfer decoding is disabled
How it was found
Summary:
libcurl can incorrectly reuse an HTTP/1.1 connection after a response containing both Content-Length and a non-chunked Transfer-Encoding when transfer decoding is disabled with CURLOPT_HTTP_TRANSFER_DECODING = 0L (--raw behavior). In this state, libcurl can treat Content-Length as the response boundary even though Transfer-Encoding is present and the response should instead be close-delimited when the final transfer coding is not chunked. I can reproduce this deterministically by sending the response headers plus exactly five body bytes, after which libcurl considers the first response complete and sends a second request on the same TCP connection. The attached PoC demonstrates the first response being accepted as HELLO and a subsequent response being accepted as POISON after the unsafe connection reuse. I isolated the regression to commit 403364293005ed54ef6230a214acee43e867995d, with curl 8.0.1 testing negative and curl 8.1.0 testing positive using the same PoC. I also reproduced the behavior against the curl-8_22_0 source tag, so the issue is present in curl 8.22.0.
Affected version
I reproduced the issue against curl/libcurl 8.22.0 built from the curl-8_22_0 source tag:
Tag: curl-8_22_0
Commit: 01346829096c61b372692f6dc43ffa778c6caccd
Platform: macOS ARM64
Build: Debug shared libcurl with AddressSanitizer and UndefinedBehaviorSanitizer
The test executable was verified with otool -L to resolve the locally built libcurl:
@rpath/libcurl-d.4.dylib
rather than macOS /usr/lib/libcurl.4.dylib.
I also performed an A/B test across the apparent release boundary:
curl 8.0.1: not affected by this test
curl 8.1.0: affected
curl 8.22.0: affected
Git history identifies the introducing commit as:
403364293005ed54ef6230a214acee43e867995d
Author: Daniel Stenberg
CommitDate: Fri Apr 7 13:39:20 2023 +0200
Subject: content_encoding: only do tranfer-encoding compression if asked to
curl-8_0_1 is the nearest preceding release and curl-8_1_0 is the first release containing that commit.
Steps To Reproduce:
The attached PoC contains:
poc_server.py
poc_client.c
README.md
The server is intentionally restricted to 127.0.0.1:18081.
1. Build curl/libcurl
Build the curl version to be tested as a shared library. For my 8.22.0 test I used a Debug ASan/UBSan build.
For example:
SRC="/path/to/curl/source"
BUILD="/path/to/curl/build"
SAN_FLAGS="-O0 -g -fsanitize=address,undefined -fno-omit-frame-pointer"
cmake \
-S "$SRC" \
-B "$BUILD" \
-DCMAKE_BUILD_TYPE=Debug \
-DBUILD_SHARED_LIBS=ON \
-DCMAKE_C_FLAGS="$SAN_FLAGS" \
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined" \
-DCMAKE_SHARED_LINKER_FLAGS="-fsanitize=address,undefined"
cmake --build "$BUILD"
2. Compile the attached client against that build
LIBCURL=$(find "$BUILD/lib" \
-maxdepth 1 \
-type f \
-name 'libcurl*.dylib' \
| head -1)
clang \
-g -O0 \
-fsanitize=address,undefined \
-I"$SRC/include" \
poc_client.c \
"$LIBCURL" \
-Wl,-rpath,"$BUILD/lib" \
-o poc_client
Verify that the intended build is being used:
otool -L ./poc_client
The test should not be considered valid if this unexpectedly resolves to the macOS system copy of libcurl.
3. Start the attached test server
python3 poc_server.py
The first response sent by the server is:
HTTP/1.1 200 OK
Content-Length: 5
Transfer-Encoding: gzip
Connection: keep-alive
HELLO
Importantly, the server sends exactly the headers plus the five bytes HELLO and then waits.
It does not send the next HTTP response until it observes whether libcurl sends request 2 on the existing TCP connection.
4. Run the attached libcurl client
export ASAN_OPTIONS="abort_on_error=1:detect_leaks=0:symbolize=1"
export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1"
DYLD_LIBRARY_PATH="$BUILD/lib" ./poc_client
The client uses:
CURLOPT_HTTP_VERSION = CURL_HTTP_VERSION_1_1;
CURLOPT_HTTP_TRANSFER_DECODING = 0L;
and performs two sequential requests using the same easy handle.
5. Observe the affected behavior
Against curl 8.1.0 and curl 8.22.0, the server observes:
===== REQUEST 2 ON SAME CONNECTION =====
GET /two HTTP/1.1
Host: 127.0.0.1:18081
Accept: */*
RESULT: CURL REUSED CONNECTION AFTER NON-CHUNKED TRANSFER-ENCODING RESPONSE
The client output includes:
< HTTP/1.1 200 OK
< Content-Length: 5
< Transfer-Encoding: gzip
< Connection: keep-alive
* Connection #0 to host 127.0.0.1 left intact
* Re-using existing connection #0 with host 127.0.0.1
FIRST rc=0 (No error)
FIRST body=[HELLO]
SECOND rc=0 (No error)
SECOND body=[POISON]
This demonstrates that libcurl considers response 1 complete after the five bytes specified by Content-Length, keeps the connection reusable, and sends request 2 on that same TCP connection.
6. Regression control using curl 8.0.1
Running the identical PoC against curl 8.0.1 produces different behavior.
The server observes:
NO SECOND REQUEST ON CONNECTION 1
Closing connection 1.
CONNECTION 2:
===== REQUEST 2 ON NEW CONNECTION =====
GET /two HTTP/1.1
The client reports:
* Error while processing content unencoding: incorrect header check
* Closing connection 0
FIRST rc=61 (Unrecognized or bad HTTP Content or Transfer-Encoding)
FIRST body=[]
SECOND rc=0 (No error)
SECOND body=[SECOND_OK]
Therefore the first TCP connection is not reused in 8.0.1.
7. Commit-level regression test
I also built and tested the parent and child of the suspected introducing commit.
Before:
403364293005ed54ef6230a214acee43e867995d^
the first connection is not reused.
At:
403364293005ed54ef6230a214acee43e867995d
the first connection is reused and the result is:
FIRST body=[HELLO]
SECOND body=[POISON]
The commit changes lib/http.c from:
if(!k->chunk) {
to:
if(!k->chunk && data->set.http_transfer_encoding) {
around the code that performs:
connclose(conn, "HTTP/1.1 transfer-encoding without chunks");
k->ignore_cl = TRUE;
The same commit also adds an early successful return to the transfer-decoding path in lib/content_encoding.c when transfer decoding was not requested:
if(is_transfer && !data->set.http_transfer_encoding)
/* not requested, ignore */
return CURLE_OK;
My interpretation is that this couples the HTTP message-framing decision to the application's transfer-decoding preference. When decoding is disabled, the non-chunked Transfer-Encoding is skipped, the connection-close/ignore_cl path is also skipped, and Content-Length remains capable of delimiting the response.
8. Expected behavior
CURLOPT_HTTP_TRANSFER_DECODING should control whether libcurl decodes transfer codings before exposing data to the application; it should not change the HTTP message boundary.
For an applicable HTTP/1.1 response containing:
Content-Length: 5
Transfer-Encoding: gzip
Content-Length should not determine the response boundary.
Because the final transfer coding is not chunked, the response is close-delimited and the connection should not be considered reusable after five bytes.
9. Negative control with transfer decoding enabled
The attached client can also be run with:
DYLD_LIBRARY_PATH="$BUILD/lib" ./poc_client --decode
which uses:
CURLOPT_HTTP_TRANSFER_DECODING = 1L;
The supplied malformed response does not result in the same unsafe connection reuse on the current code path.
10. Security impact
The demonstrated primitive is HTTP/1.1 response desynchronization / response substitution caused by incorrect response-boundary handling.
The PoC proves that libcurl can send a subsequent HTTP request on a TCP connection that should still be delimited by closure of the previous response.
I have deliberately not claimed cross-origin or cross-trust-boundary exploitation because I have not demonstrated that scenario.
The current PoC therefore establishes the framing error and unsafe connection-reuse primitive, while leaving final security severity and broader exploitability for discussion with the curl security team.
11. AI usage disclosure
I used ChatGPT as an assistant during parts of this research, including helping navigate Git history, organize local reproduction steps, and draft this report.
I manually executed and verified the technical claims contained in this submission against locally checked-out curl source trees and locally built libcurl binaries, including the 8.0.1/8.1.0 release A/B test, commit-level A/B test, dynamic-library linkage, and curl 8.22.0 reproduction.
I am disclosing this AI usage explicitly in accordance with the curl HackerOne program policy.
Again: there is no bug-bounty for curl. We do not offer any rewards, only proper credits.
I understand that the curl program does not offer monetary rewards and I am submitting this report for responsible disclosure and appropriate
Impact
Summary:
The demonstrated impact is an HTTP/1.1 response desynchronization / response-substitution primitive caused by incorrect message-boundary handling.
When CURLOPT_HTTP_TRANSFER_DECODING is disabled, libcurl can treat Content-Length as the end of a response even though a non-chunked Transfer-Encoding is present. This allows libcurl to mark the connection as reusable too early and send a subsequent request on the same TCP connection.
In the attached PoC, libcurl accepts HELLO as response 1, reuses the same connection, and then accepts attacker-controlled POISON as response 2. This demonstrates that an attacker controlling the HTTP response can influence the interpretation of subsequent responses on that connection.
I have not demonstrated cross-origin or cross-trust-boundary exploitation, credential theft, or data disclosure, so I am not claiming those impacts. The confirmed impact is unsafe HTTP response framing and persistent-connection reuse, with potential for response confusion in applications that perform sequential requests over reused HTTP/1.1 connections.