curl · Report #3776433
TFTP upload ignores --continue-at / CURLOPT_RESUME_FROM and leaks skipped local file prefix
How it was found
Summary
TFTP uploads ignore the configured resume offset. When a caller runs curl -C N -T file tftp://... or uses libcurl with CURLOPT_UPLOAD and CURLOPT_RESUME_FROM*, curl should skip the first N bytes of the local source before uploading. Instead, the TFTP code sends the complete local file from byte zero, advertises the complete size in the TFTP tsize option, and exits successfully. This can disclose local file bytes that the caller explicitly intended not to send. The TFTP server does not need to send malformed packets or behave maliciously; normal ACK responses are enough to trigger the issue.
Documentation contract
This is not relying on undocumented behavior. docs/cmdline-opts/continue-at.md says the offset is the exact number of bytes skipped from the beginning of the source file before transfer. docs/libcurl/opts/CURLOPT_RESUME_FROM.md and docs/libcurl/opts/CURLOPT_RESUME_FROM_LARGE.md both list Protocol: All and describe the option as the byte offset where the transfer should start.
Affected version
Reproduced locally on current master:
curl 8.21.0-DEV (Linux) libcurl/8.21.0-DEV OpenSSL/3.0.13 zlib/1.3 brotli/1.1.0 zstd/1.5.5
Release-Date: [unreleased]
Protocols: dict file ftp ftps gopher gophers http https imap imaps ipfs ipns mqtt mqtts pop3 pop3s rtsp smtp smtps telnet tftp ws wss
Features: alt-svc AsynchDNS brotli HSTS HTTPS-proxy IPv6 Largefile libz SSL threadsafe TLS-SRP UnixSockets zstd
Platform: Linux x86_64.
Root cause
setup_range() stores the requested resume offset in data->state.resume_from for all protocols. Other upload-capable protocols explicitly honor that value. For example, HTTP upload calls Curl_creader_resume_from(), FTP/SFTP skip or seek the local input and decrease the upload size.
The TFTP upload path does not do that. In lib/tftp.c, tftp_send_first() uses the full data->state.infilesize for progress and for the TFTP tsize option. Later, tftp_tx() reads from Curl_client_read() immediately from the beginning of the source stream. There is no check for data->state.resume_from, no local seek/skip, and no reduction of the upload size.
Relevant TFTP behavior:
/* tftp_send_first(): full local size is used for upload progress */
if(data->state.infilesize != -1)
Curl_pgrsSetUploadSize(data, data->state.infilesize);
/* tftp_send_first(): full local size is advertised as tsize */
curl_msnprintf(buf, sizeof(buf), "%" FMT_OFF_T,
data->state.upload && (data->state.infilesize != -1) ?
data->state.infilesize : 0);
/* tftp_tx(): reads upload bytes without applying data->state.resume_from */
result = Curl_client_read(data, bufptr, state->blksize - state->sbytes,
&cb, &eos);
Steps To Reproduce
The following PoC is self-contained. It creates a temporary local upload file with a secret prefix and a public suffix, starts a minimal local TFTP sink that only ACKs packets, then uploads with -C 15. Expected behavior is that only PUBLIC_SUFFIX__ is sent. Vulnerable behavior is that the TFTP server receives SECRET_PREFIX__PUBLIC_SUFFIX__.
Copy and paste the whole block. It uses curl from PATH by default. To test a source-tree build, run it with CURL_BIN=/path/to/curl. The PoC is inline and does not call any external helper files.
CURL_BIN="${CURL_BIN:-curl}" python3 - <<'PY'
import json
import os
import socket
import struct
import subprocess
import tempfile
import threading
from pathlib import Path
CURL_BIN = os.environ["CURL_BIN"]
SECRET = b"SECRET_PREFIX__"
PUBLIC = b"PUBLIC_SUFFIX__"
UPLOAD = SECRET + PUBLIC
SKIP = len(SECRET)
def pkt(op, block_or_error=0, payload=b""):
return struct.pack("!HH", op, block_or_error) + payload
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("127.0.0.1", 0))
sock.settimeout(10)
port = sock.getsockname()[1]
events = []
received = bytearray()
def server():
try:
req, peer = sock.recvfrom(4096)
parts = req[2:].split(b"\0")
events.append({
"event": "wrq",
"length": len(req),
"fields": [p.decode("latin1") for p in parts[:6]],
})
# Normal TFTP WRQ acknowledgement. The server does not need malformed
# packets or special behavior to trigger the issue.
sock.sendto(pkt(4, 0), peer)
while True:
data, peer = sock.recvfrom(4096)
if len(data) < 4:
events.append({"event": "short", "length": len(data)})
continue
op, block = struct.unpack("!HH", data[:4])
body = data[4:]
events.append({
"event": "data",
"block": block,
"length": len(body),
"prefix": body[:16].decode("latin1"),
})
if op == 3:
received.extend(body)
sock.sendto(pkt(4, block), peer)
if len(body) < 512:
break
finally:
sock.close()
with tempfile.TemporaryDirectory() as tmpdir:
upload_path = Path(tmpdir) / "upload.bin"
upload_path.write_bytes(UPLOAD)
version = subprocess.run(
[CURL_BIN, "--version"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=5,
)
print("\n".join(version.stdout.splitlines()[:4]))
thread = threading.Thread(target=server)
thread.start()
curl = subprocess.run(
[
CURL_BIN,
"-q",
"--silent",
"--show-error",
"--max-time",
"5",
"-C",
str(SKIP),
"-T",
str(upload_path),
f"tftp://127.0.0.1:{port}/resume.bin",
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=10,
)
thread.join(timeout=12)
if thread.is_alive():
raise RuntimeError("TFTP sink did not finish")
actual = bytes(received).decode("latin1")
expected = PUBLIC.decode("ascii")
if curl.stdout:
print("curl_stdout:", curl.stdout.strip())
if curl.stderr:
print("curl_stderr:", curl.stderr.strip())
print("curl_exit:", curl.returncode)
print("expected_uploaded_bytes:", repr(expected))
print("actual_uploaded_bytes: ", repr(actual))
print("events:")
print(json.dumps(events, indent=2))
if actual == UPLOAD.decode("ascii") and curl.returncode == 0:
print("VULNERABLE: curl ignored -C 15 and uploaded the skipped prefix")
elif actual == expected:
print("NOT VULNERABLE: curl honored the resume offset")
else:
print("UNEXPECTED RESULT")
PY
Observed output on the affected build:
curl 8.21.0-DEV (Linux) libcurl/8.21.0-DEV OpenSSL/3.0.13 zlib/1.3 brotli/1.1.0 zstd/1.5.5
Release-Date: [unreleased]
Protocols: dict file ftp ftps gopher gophers http https imap imaps ipfs ipns mqtt mqtts pop3 pop3s rtsp smtp smtps telnet tftp ws wss
Features: alt-svc AsynchDNS brotli HSTS HTTPS-proxy IPv6 Largefile libz SSL threadsafe TLS-SRP UnixSockets zstd
curl_exit: 0
expected_uploaded_bytes: 'PUBLIC_SUFFIX__'
actual_uploaded_bytes: 'SECRET_PREFIX__PUBLIC_SUFFIX__'
events:
[
{
"event": "wrq",
"length": 50,
"fields": [
"resume.bin",
"octet",
"tsize",
"30",
"blksize",
"512"
]
},
{
"event": "data",
"block": 1,
"length": 30,
"prefix": "SECRET_PREFIX__P"
}
]
VULNERABLE: curl ignored -C 15 and uploaded the skipped prefix
Expected behavior
With -C 15, curl should skip the first 15 bytes of the local upload source before sending data. The TFTP peer should receive only:
PUBLIC_SUFFIX__
The TFTP tsize option, if sent, should also reflect the resumed upload size, not the full local file size.
Actual behavior
curl uploads the complete local file:
SECRET_PREFIX__PUBLIC_SUFFIX__
The TFTP WRQ also advertises the full local file length with tsize=30, even though the caller requested upload from offset 15.
Impact
Impact
This is an information disclosure issue. A caller can use --continue-at / CURLOPT_RESUME_FROM* to intentionally exclude a local file prefix from a transfer, for example when resuming a failed upload, uploading only a suffix, or avoiding retransmission of already-present content. In the TFTP upload path, curl violates that boundary and sends the skipped prefix anyway. If the skipped region contains credentials, tokens, private headers, or other sensitive local data, those bytes are disclosed to the TFTP peer or to anyone observing the TFTP traffic. The command exits successfully, so automation can treat the upload as correct while the unintended bytes have already left the host.