A null terminator, a NetWare FTP server from 1997, and one of C’s oldest standard library quirks combine to leak other users’ HTTP requests out of Squid Proxy. I pulled the live source, traced the exact allocation path, and the disclosure timeline turned out messier than the original report let on.
I’ve covered a lot of Squid CVEs over the years, and I still wasn’t ready for this one. Squidbleed (CVE-2026-47729) is a heap buffer overread in Squid Proxy’s FTP gateway that leaks raw heap memory – including other users’ HTTP Authorization headers, cookies, and session tokens – to anyone who can get the proxy to fetch a directory listing from an FTP server they control. The bug traces back to a commit from January 1997. It survived three decades of releases, code reviews, and independent security audits, and it took an AI model spelunking through three-decade-old FTP parsing code to surface it.
The discovery came from Calif Security Research, working with Claude Mythos Preview. It’s their second major disclosure this month – two weeks earlier they published HTTP/2 Bomb, a denial-of-service chain against nginx, Apache, IIS, Envoy, and Pingora, found by OpenAI’s Codex. This time the target was Squid, and the researcher’s prompt was effectively fuzzing-by-instruction: “spawn more agents to investigate the full FTP state machine behavior better.” One of the first things that came back was a one-character logic bug sitting underneath Squid’s FTP-to-HTML directory listing converter.
I didn’t want to just relay the original writeup, so I pulled Squid’s actual upstream source i.e. squid-cache/squid on GitHub, the real src/clients/FtpGateway.cc and src/mem/ allocator code to verify every claim against the live codebase rather than take it on faith. What follows is the full mechanism, traced line by line through real Squid source, the memory allocator internals that turn a one-byte logic error into a cross-user credential leak, the patch, and a disclosure timeline that turns out to have a wrinkle the original report doesn’t mention.
Where Squid Fits and Why FTP Is Still in It

Squid is a caching forward proxy. Schools, ISPs, corporate networks, and in-flight WiFi systems put it in front of user traffic to cache static content, enforce access policy, and sometimes intercept and inspect traffic. If you’ve ever connected to airplane WiFi, there’s a decent chance Squid sat between you and the internet, the Calif researcher hit exactly that scenario on a recent flight, running a build close to a decade old, and yes, vulnerable.
Squid still ships with FTP gateway support enabled out of the box, and Safe_ports – the default access-control list governing which destination ports the proxy is allowed to reach which includes port 21 by default. So when a client requests an ftp:// URL through the proxy, Squid connects to that FTP server, issues a LIST command on the data channel, and converts whatever comes back into an HTML directory listing for the browser to render. No special config flags, no opt-in. It’s worked this way since the Clinton administration, and it still does on a stock install today.
The problem is that FTP’s LIST output was never standardized. It’s whatever the server feels like printing, conventionally resembling ls -l output but with enough variance across implementations that parsing it reliably has been a documented nightmare for as long as FTP has existed. Daniel J. Bernstein – DJB, of qmail and djbdns fame wrote an entire reference page on how genuinely inconsistent the format is across servers, and how hard that makes writing a parser that doesn’t silently misbehave on some vendor’s particular formatting choices. When DJB calls a parsing problem hard, that’s worth taking seriously.
The 1997 NetWare Fix That Planted the Bug
Squid’s FTP listing parser predates Squid’s own Git history. The relevant commit, bb97dd37a, dates to January 18, 1997, and its changelog entry describes fixing ftpget to recognize NetWare servers and skip extra whitespace before the filename.
NetWare was Novell’s network operating system, dominant in corporate file-and-print serving through the late ’80s and ’90s. NetWare’s bundled FTP daemon had its own formatting quirk: four spaces between the timestamp and the filename, instead of the single space nearly every other FTP server used.
d [R----F--] supervisor 512 Jan 16 18:53 login- [R----F--] rhesus 214059 Oct 20 15:27 cx.exe
To handle that, the 1997 fix introduced a flag – flags.skip_whitespace that gets set whenever the server’s welcome banner contains the literal string “NetWare.” When that flag is set, the parser walks forward past all consecutive whitespace before treating the next byte as the start of the filename, instead of skipping just one space the way every other code path does.

Inside the Real Parser: ftpListParseParts()
I pulled the live function out of Squid’s current src/clients/FtpGateway.cc (it’s static ftpListParts * ftpListParseParts(const char *buf, struct Ftp::GatewayFlags flags), currently around line 532). It’s worth walking through the whole thing, because the bug only makes sense once you see the full token-based design it’s embedded in.
Each LIST line first gets duplicated and tokenized on whitespace using strtok, capped at 64 tokens:
MAX_TOKENSxbuf = xstrdup(buf);for (t = strtok(xbuf, w_space); t && n_tokens < MAX_TOKENS; t = strtok(nullptr, w_space)) { tokens[n_tokens].token = xstrdup(t); tokens[n_tokens].pos = t - xbuf; ++n_tokens;}
The parser then scans those tokens for a recognizable month name, using a small static lookup table (is_month() against Jan…Dec), then validates that the surrounding tokens look like a file size, a day, and a year or hh:mm time, using three precompiled POSIX regexes – scan_ftp_integer, scan_ftp_time, and friends which are initialized once on first use:
regcomp(&scan_ftp_integer, "^[0123456789]+$", REG_EXTENDED | REG_NOSUB);regcomp(&scan_ftp_time, "^[0123456789:]+$", REG_EXTENDED | REG_NOSUB);
Once it has a plausible month day year-or-time triple, it has to handle the genuine formatting ambiguity FTP servers introduce: is there one space between the day and the year, or two? Squid handles this by re-rendering the candidate date two different ways with snprintf and comparing both against the original bytes (isTypeA for the two-space form, isTypeB for the one-space form), rather than trying to regex-match the spacing directly. It’s a slightly unusual approach, but a defensible one for a format this inconsistent.
Once a match is confirmed, this is where the vulnerable logic lives. copyFrom gets repositioned to just past the parsed date token, and then the parser tries to walk past any separator whitespace to reach the actual filename:
// point after tokens[i+2] :copyFrom = buf + tokens[i + 2].pos + strlen(tokens[i + 2].token);if (flags.skip_whitespace) { while (strchr(w_space, *copyFrom)) ++copyFrom;} else { /* Handle the following four formats: * "MMM DD YYYY Name" * "MMM DD YYYYName" * "MMM DD YYYY Name" * "MMM DD YYYY Name" * Assuming a single space between date and filename * suggested by: Nathan.Bailey@cc.monash.edu.au and * Mike Battersby <mike@starbug.bofh.asn.au> */ if (strchr(w_space, *copyFrom)) ++copyFrom;}p->name = xstrdup(copyFrom);
(That’s the pre-patch version. I’ll get to the one-line fix shortly.) Two details worth noting that the original report skips over. First, #define w_space " \t\n\r" – the loop only treats space, tab, newline, and carriage return as “whitespace to skip,” nothing else. Second, after the name is extracted, there’s a follow-on piece of logic for symlinks: if the entry’s type character is 'l' and the extracted name contains the literal substring " -> ", Squid splits it there and stores the link target separately:
if (p->type == 'l' && (t = strstr(p->name, " -> "))) { *t = '\0'; p->link = xstrdup(t + 4);}
That detail matters later, because it means whatever garbage the overread pulls out of adjacent heap memory also gets scanned for an " -> " substring and potentially split into a fake “symlink target” field too, if the leaked bytes happen to contain one.
How the Welcome Banner Flips the Switch
flags.skip_whitespace isn’t set anywhere near the parser itself. It’s set during connection setup, in the FTP control-channel state machine, specifically in ftpReadWelcome(), the handler for FTP reply code 220 (the server’s initial welcome banner):
static voidftpReadWelcome(Ftp::Gateway * ftpState){ int code = ftpState->ctrl.replycode; ... if (code == 220) { if (ftpState->ctrl.message) { if (strstr(ftpState->ctrl.message->key, "NetWare")) ftpState->flags.skip_whitespace = 1; } ftpSendUser(ftpState); } ...}
This is the entire attacker-facing trigger condition: a malicious FTP server just has to answer the control connection with something like 220 NetWare FTP Server ready. and every subsequent directory listing from that session runs through the vulnerable branch. Nothing about this requires anonymous login, a specific FTP server software stack, or any non-default Squid configuration, it’s a single substring match against attacker-controlled banner text, evaluated the moment the control connection opens.
One Buffer, Many Lines: How the Listing Loop Allocates Memory
This is the part the original disclosure summarizes but doesn’t fully unpack, and it changes the technical story in an important way once you see the real code.
Squid doesn’t allocate a fresh buffer per line of a directory listing. It allocates one 4 KB scratch buffer for the entire listing response, reuses it for every line via a bounded copy, and only frees it once the whole listing has been processed:
line = (char *)memAllocate(MEM_4K_BUF);...for (; s < end; s += strcspn(s, crlf), s += strspn(s, crlf)) { linelen = strcspn(s, crlf) + 1; if (linelen < 2) break; if (linelen > 4096) linelen = 4096; xstrncpy(line, s, linelen); ... if (htmlifyListEntry(line, html)) { ... }}...memFree(line, MEM_4K_BUF);
xstrncpy writes exactly linelen bytes into line and null-terminates at that point. It does not clear the rest of the 4096-byte buffer. So at the moment a short, filename-less LIST entry is copied in, everything past that line’s own embedded null terminator, all the way out to the 4096-byte boundary, is whatever was already sitting in that memory: either content from a longer line earlier in the same listing, or critically, for the very first line processed after memAllocate(MEM_4K_BUF) hands back a freshly recycled chunk, content left over from whatever this exact pool slot held the last time it was allocated to something else entirely.
htmlifyListEntry() itself, which calls the parser, also caps individual input lines at 1024 bytes before even attempting to parse them:
boolFtp::Gateway::htmlifyListEntry(const char *line, PackableStream &html){ if (strlen(line) > 1024) { html << "<tr><td colspan=\"5\">" << line << "</td></tr>\n"; return true; } ... ftpListParts *parts = ftpListParseParts(line, flags);
That cap limits how long the attacker’s own crafted LIST line can be but it does nothing to limit how far the overread can travel, because the overread isn’t bounded by the input line’s length at all. It’s bounded only by the first non-whitespace, non-null byte the loop happens to encounter while walking through stale memory that was never part of the attacker’s input to begin with. Once xstrdup(copyFrom) runs, it keeps copying until it hits any null byte, wherever that happens to be which is exactly why ASAN caught a read of 4,065 bytes, landing precisely at the edge of the 4,096-byte allocation: the stale tail of that buffer apparently contained no null byte and no character outside w_space for the entire remaining span, so the walk consumed nearly the whole buffer before AddressSanitizer’s redzone instrumentation stopped it. On a non-instrumented production build, there’s no redzone, the read just continues into whatever adjacent heap chunk happens to follow, and keeps going until it finds an actual zero byte somewhere in that chunk too.
The strchr(‘\0’) Trap
strchr(haystack, needle) searches haystack for the first occurrence of the byte needle, including the implicit NUL terminator at the end of the string – this is explicit, standard-mandated behavior. C11 §7.24.5.2 defines the string passed to strchr as including its terminating null character as part of the search space. Call strchr(w_space, '\0') and you do not get NULL back. You get a valid pointer, to the terminator itself, because '\0' is, by definition, part of every C string.
Nobody writing while (strchr(w_space, *copyFrom)) ++copyFrom; is thinking about that case. The mental model is “keep skipping while we’re looking at whitespace, stop at the first non-whitespace byte,” with the implicit, unstated assumption that hitting the end of the line will naturally fail the strchr check and break the loop. It doesn’t, because the terminator itself satisfies the search.
So: what happens when a LIST entry has a parseable timestamp but no filename after it?
d [R----F--] supervisor 512 Jan 16 18:53
copyFrom lands exactly on that line’s null terminator. strchr(w_space, *copyFrom) evaluates strchr(w_space, '\0'), returns non-NULL, and the loop body executes: ++copyFrom. Now copyFrom points one byte past the line’s own content, into whatever stale bytes occupy the rest of the 4096-byte MEM_4K_BUF allocation. AddressSanitizer flags it instantly:
==1==ERROR: AddressSanitizer: heap-buffer-overflow ...READ of size 4065 ...0 bytes after 4096-byte region [...] #1 0x... in xstrdup #2 0x... in Ftp::Gateway::htmlifyListEntry
The Memory Pool Architecture That Makes This Exploitable
A heap overread by itself, even one this large, would be a crash bug or an info leak of a few stray bytes. What makes Squidbleed dangerous is what specifically sits in that adjacent heap memory, and that comes down to how Squid manages allocations internally, I went and pulled src/mem/old_api.cc and src/mem/PoolChunked.cc directly to verify this rather than take the original report’s allocator claims at face value.
Squid doesn’t call malloc/free directly for most hot-path allocations. It runs its own slab-style pooled allocator, bucketed by size, defined via memFindBufSizeType():
| Net size requested | Pool | Slot size |
|---|---|---|
| ≤ 32 B | MEM_32B_BUF | 32 B |
| ≤ 64 B | MEM_64B_BUF | 64 B |
| ≤ 128 B | MEM_128B_BUF | 128 B |
| ≤ 256 B | MEM_256B_BUF | 256 B |
| ≤ 512 B | MEM_512B_BUF | 512 B |
| ≤ 1 KB | MEM_1K_BUF | 1,024 B |
| ≤ 2 KB | MEM_2K_BUF | 2,048 B |
| ≤ 4 KB | MEM_4K_BUF | 4,096 B |
| ≤ 8 KB | MEM_8K_BUF | 8,192 B |
| ≤ 16 KB | MEM_16K_BUF | 16,384 B |
| ≤ 32 KB | MEM_32K_BUF | 32,768 B |
| ≤ 64 KB | MEM_64K_BUF | 65,536 B |
| > 64 KB | none (MEM_NONE) | falls through to a direct, uncategorized xmalloc |
When a buffer is freed, MemPoolChunked::push() doesn’t return it to the system allocator. It pushes it onto an in-process, intrusive singly-linked freelist for that pool – the freed object’s own first few bytes are reused to store the “next” pointer:
voidMemPoolChunked::push(void *obj){ if (doZero) memset(obj, 0, objectSize); Free = (void **)obj; *Free = freeCache; freeCache = obj; ...}
The next allocation request of the same size class pops straight off the top of that list (freeCache) in MemPoolChunked::get() – last in, first out. No syscall, no walking the heap, just a pointer swap. This is exactly why it’s fast, and exactly why it’s dangerous here: the very next memAllocate(MEM_4K_BUF) call, anywhere in the process, gets handed the literal bytes of whatever was freed most recently into that pool, untouched, unless doZero is set.
And here’s the detail the original report states as a general claim (“the pool does not zero recycled buffers”) that I wanted to confirm directly against source rather than repeat secondhand. Every fixed-size buffer pool in Squid is explicitly initialized with zeroing turned off:
// src/mem/old_api.ccmemDataInit(MEM_32B_BUF, "32B Buffer", 32, 10, false);memDataInit(MEM_64B_BUF, "64B Buffer", 64, 10, false);memDataInit(MEM_128B_BUF, "128B Buffer", 128, 10, false);memDataInit(MEM_256B_BUF, "256B Buffer", 256, 10, false);memDataInit(MEM_512B_BUF, "512B Buffer", 512, 10, false);memDataInit(MEM_1K_BUF, "1K Buffer", 1024, 10, false);memDataInit(MEM_2K_BUF, "2K Buffer", 2048, 10, false);memDataInit(MEM_4K_BUF, "4K Buffer", 4096, 10, false); // <- our poolmemDataInit(MEM_8K_BUF, "8K Buffer", 8192, 10, false);memDataInit(MEM_16K_BUF, "16K Buffer", 16384, 10, false);memDataInit(MEM_32K_BUF, "32K Buffer", 32768, 10, false);memDataInit(MEM_64K_BUF, "64K Buffer", 65536, 10, false);
That trailing false is the doZero argument, threaded straight through to Mem::Allocator::zeroBlocks(). Mem::Allocator‘s own field declaration defaults doZero to true, Squid is deliberately, explicitly opting these specific pools out of that default, almost certainly because zeroing 4 KB on every single free, only to have it immediately overwritten by the next consumer’s own write, is wasted CPU at proxy-scale traffic volumes. There’s even a maintainer comment sitting directly above the push() zeroing logic that reads almost like a warning in hindsight:
voidMemPoolChunked::push(void *obj){ void **Free; /* XXX We should figure out a sane way of avoiding having to clear * all buffers. For example data buffers such as used by MemBuf do * not really need to be cleared.. There was a condition based on * the object size here, but such condition is not safe. */ if (doZero) memset(obj, 0, objectSize);
So the performance optimization is intentional, documented, and as per the squid team’s own XXX note, already flagged internally as something whose safety conditions are tricky to get right. Squidbleed is what happens when a consumer of one of these unzeroed pools assumes its own bounds-checking is airtight, and it isn’t.
From Stale Bytes to a Stolen Authorization Header
The remaining question is what realistically ends up in MEM_4K_BUF next to FTP listing data, and that’s where the HTTP request path comes back in. This varies by Squid version, which matters a lot for real-world exploitability:
- Squid 7.x:
CLIENT_REQ_BUF_SZis 4096 bytes, and incoming client HTTP requests are allocated viamemAllocBuf(), which routes straight intoMEM_4K_BUFthroughmemFindBufSizeType(). Almost any HTTP request under 4 KB – meaning the overwhelming majority of real requests, headers and all lives, at some point in its lifecycle, in exactly the pool the FTP parser overreads from. - Pre-7.x, including Squid 5.7 (what current stable Debian ships, and what the PoC was validated against): incoming requests instead use
memAllocString(), which draws from a separate “4 KB Strings” pool, not the one the FTP gateway touches directly. But Squid still copies the outgoing, proxied copy of the request into aMemBufbefore forwarding it upstream. That buffer starts inMEM_2K_BUFby default and gets promoted toMEM_4K_BUFautomatically the moment the request exceeds 2 KB – trivially common once you account for cookies, aBearertoken, or a handful of custom headers. Once that promoted buffer is freed, anyone spraying FTPLISTrequests through the same proxy is positioned to reclaim it straight off the freelist on their very next overread.
Once the attacker’s overread does land on a buffer that recently held a victim’s request, the leaked bytes – verbatim header text, potentially including Authorization: Basic ... or Authorization: Bearer ..., session cookies, CSRF tokens, whatever happened to be in that 4 KB window get treated by Squid as if they were a literal filename. They’re passed through rfc1738_escape_part() for URL-encoding and dropped directly into the href attribute of an anchor tag in the rendered directory listing:
SBuf href(prefix);href.append(rfc1738_escape_part(parts->name));...html << "<tr class=\"entry\">" "<td class=\"icon\"><a href=\"" << href << "\">" << icon << "</a></td>"
That’s the full path from “stale heap bytes” to “percent-encoded string sitting in an href attribute the attacker’s own browser or script just rendered.” Reverse the percent-encoding and you have the victim’s original header bytes back, byte for byte.
Demonstrating It
The writeup describes a working, fully reproducible demonstration: a stock, default-configuration Squid proxy, a small victim web app gating an admin page behind HTTP Basic authentication, and an attacker positioned only on the same shared proxy – no access to the victim’s connection, browser, or machine required. By running a malicious FTP server that answers with a NetWare-flavored banner and a filename-less LIST entry and continuously polling the proxy for that listing while the victim logs in, they recovered the victim’s Authorization: Basic credential out of Squid’s live heap. Nothing crashes on the Squid side. The access log shows an ordinary 200 OK for a directory listing request. cache.log shows nothing unusual. From the proxy operator’s vantage point, the attack is silent.
I’m intentionally not reproducing the attacker’s scripts here, even though I pulled the proof-of-concept repository to verify the architecture matches what’s described above (it does, exactly, same NetWare-banner trigger, same filename-less LIST line, same MEM_4K_BUF reclaim-via-polling approach this writeup derives from first principles). The mechanism is fully documented above, which is what’s needed to understand, detect, and patch this. Working, ready-to-run credential-theft tooling against a bug whose official fix hasn’t even shipped yet is a different kind of artifact than an explanation, and that’s the line I hold regardless of target. If you operate Squid infrastructure you own and need to verify exposure under controlled conditions, the researchers’ PoCs are public at the link below. Treat it the way you’d treat any working credential-extraction exploit: lab only, authorized targets only, patch first.
PoCs: https://github.com/califio/publications/tree/main/MADBugs/squidbleed
The Fix
The patch is a single added condition, applied identically to both branches of the whitespace-skipping logic:
if (flags.skip_whitespace) {
- while (strchr(w_space, *copyFrom))
+ while (*copyFrom && strchr(w_space, *copyFrom))
++copyFrom;
} else {
...
- if (strchr(w_space, *copyFrom))
+ if (*copyFrom && strchr(w_space, *copyFrom))
++copyFrom;
}
Short-circuit evaluation means *copyFrom gets checked for non-zero before strchr is ever called. The instant copyFrom reaches the line’s terminator, the left side of && evaluates false and the whole expression short-circuits – strchr never sees the NUL byte at all, and the loop exits cleanly instead of treating the terminator as a valid whitespace match. I confirmed this exact patch is already merged into Squid’s master branch as of this writing – src/clients/FtpGateway.cc upstream now reads while (*copyFrom && strchr(w_space, *copyFrom)) verbatim.
Severity, and a Disclosure Timeline That’s Messier Than It Looks
As of publication, CVE-2026-47729 shows up in CVE aggregator feeds with its CVSS score still listed as pending, the CVE Numbering Authority has reserved the identifier, but a formal severity score hasn’t been published yet. Don’t read “no score yet” as “low severity”: cross-tenant credential disclosure on a piece of infrastructure that sits, by design, between many users and the internet is about as serious as memory-safety bugs get, scoring pending or not. Germany’s BSI (Bundesamt für Sicherheit in der Informationstechnik) has already tracked it under its own advisory ID alongside CVE-2026-50012, and it’s picked up independent technical coverage and social discussion across the European infosec community within days of disclosure.
Calif’s original post lists this disclosure sequence:
- 2026-04-17: Initial report filed by Lam Jun Rong of Calif.io
- 2026-04-17: Fix posted
- 2026-04-19: Fix merged into master/v8
- 2026-05-07: Independent report filed separately by Youssef Awad
- 2026-05-17: Fix merged into the v7 branch
- 2026-06-08: Squid 7.6 released
- 2026-06-10: Blog post published
That last line undersells what actually happened, and it’s worth checking the primary source rather than the summary. Squid maintainer Amos Jeffries posted a correction to the oss-sec mailing list clarifying that Squid 7.6 did not actually ship the Squidbleed fix. The 7.6 release covers a separate vulnerability – CVE-2026-50012 and the real CVE-2026-47729 patch is scheduled for 7.7. If you patched to 7.6 assuming you’d also closed Squidbleed, you haven’t.
The Companion Bug: CVE-2026-50012
Squid 7.6 did land a fix for a second, independently serious vulnerability disclosed in the same window: a heap-based buffer overflow in cache_digest reply handling, present in builds compiled with --enable-cache-digests. Per the published advisory, this one lets a trusted upstream server – one your Squid instance has already decided to fetch from – trigger a heap overflow by returning a maliciously crafted reply to a cache_digest request. Where it applies, the stated impact runs past a crash into potential arbitrary code execution. It’s a narrower bug in terms of which builds are affected (cache digests are an opt-in compile flag, not universally enabled), but a more severe one in terms of ceiling. Worth checking your build flags either way.
Squid’s Long History With Exactly This Class of Bug
Three decades, multiple full audits, and this specific code path has leaked information before. Squid’s own public advisory archive running back to at least 2016 documents a steady cadence of buffer overflows, denial-of-service conditions across HTTP and ESI response processing, and, notably, prior information-disclosure issues specifically inside the FTP gateway. Independent security researchers have separately catalogued dozens of additional vulnerabilities and previously-unpatched issues across the wider codebase through dedicated audit projects. Earlier this year, a separate and unrelated memory-disclosure bug, CVE-2026-33515, was found in Squid’s ICP (Internet Cache Protocol) request handling, an out-of-bounds read leaking small amounts of process memory in malformed-request error responses, affecting versions 3.0 through 7.4 when ICP is explicitly enabled, fixed in 7.5. Different subsystem, different root cause, same broad category: a thirty-year-old C codebase, still finding new ways to read past the end of a buffer it built itself.
None of that prior scrutiny caught Squidbleed. That’s not really a knock on the auditors – the bug requires connecting three facts that no single piece of code states outright: a 1997 compatibility shim for a defunct network OS, the C standard’s specific, non-obvious definition of what strchr does with a null byte, and the internal reuse behavior of a custom allocator that isn’t documented anywhere near the parsing code that depends on its zeroing behavior. Any one of those facts in isolation looks completely unremarkable. It’s the conjunction that’s dangerous, and conjunctions like that are exactly what’s easy to miss during a normal review pass and comparatively tractable for a model that can hold all three pieces of context at once and is willing to “spawn more agents” to chase the FTP state machine until something doesn’t add up.
What to Do About It
If you run Squid:
- Disable FTP gateway support if you don’t need it. Every major browser, Chrome included, dropped native FTP support years ago, so legitimate FTP traffic through a corporate or campus proxy is close to nonexistent in 2026. Turning it off removes this entire bug class and the prior FTP gateway disclosure issue before it, for free. Treat unused protocol handlers as attack surface, independent of any specific CVE.
- Don’t treat Squid 7.6 as the fix for CVE-2026-47729. It isn’t. Track 7.7 (or your distribution’s specific backport of the patched
FtpGateway.cclogic) for the actual Squidbleed fix, and verify the patched line –while (*copyFrom && strchr(w_space, *copyFrom))is present in your build’s source before considering yourself covered. - If you compile with
--enable-cache-digests, confirm you’re also covered for CVE-2026-50012; it shipped separately, in 7.6. - If you operate a shared proxy – school, café, corporate guest network, in-flight WiFi and can’t patch immediately, removing port 21 from
Safe_portsor blocking outbound FTP from the proxy closes this specific attack path even on an unpatched build. - For detection on infrastructure you can’t immediately patch: watch proxy access logs for FTP control-channel banners containing “NetWare” from external hosts, and for unusually repetitive
ftp://LISTrequests against the same destination in short windows both are consistent with this attack’s setup phase, though neither is conclusive on its own given legitimate NetWare FTP servers still exist in the wild in small numbers.
Twenty-nine years is a long time for a bug to wait. The fix that planted it was solving a real, narrow compatibility problem for a network operating system that barely exists anymore, written by someone with no way of knowing that “skip past the extra whitespace” and “the C standard library’s exact definition of a string” would, decades later, combine with an allocator optimization to produce a cross-tenant credential leak. That’s not really a story about careless code. It’s a story about how much accumulated, unexamined assumption sits underneath software that’s been “working fine” for three decades and how cheaply that assumption can be tested once you point the right kind of attention at it.








