We didn’t set out to find a fix bypass. We typed one sentence into a terminal and went to make coffee.

One Prompt

It started with a single prompt. We had a CVE - CVE-2026-28296 - CRLF injection in the GVFS FTP backend. The kind of vulnerability that sounds simple on paper but has interesting implications when you look at the actual code paths. We wanted a working lab environment, a PoC, the whole thing.

The EIP MCP server was already connected to our Claude Code setup. We’d been using it for triage work - looking up CVEs, checking EPSS scores, the usual. But this time we wanted to push further. Not just “tell me about this CVE” but “build me a complete environment to reproduce it.”

So we typed it. One line. Build a PoC lab for CVE-2026-28296. Then we sat back and watched the agent work.

The Agent Goes to Work

The first thing the agent did was pull the full intelligence brief from EIP. One MCP tool call - get_vulnerability - and it had everything: CVSS vector, CWE classification, affected versions, the fix commit hash, links to the upstream issue and merge request. The kind of context that normally takes ten minutes of tab-switching.

CVE-2026-28296  MEDIUM  CWE-93
GVFS FTP Backend - CRLF Command Injection
CVSS: 4.3  Fix: commit 21dda190

From there, it cloned the GVFS source and started reading code. Not randomly - it went straight for the functions mentioned in the fix commit. g_vfs_ftp_file_new_from_gvfs() in gvfsftpfile.c. The g_vfs_ftp_task_sendv() function that builds FTP commands. The g_vfs_ftp_connection_send() that puts bytes on the wire.

It understood the vulnerability within minutes. FTP commands are terminated by \r\n. If you embed \r\n in a file path and nobody sanitizes it, the path becomes two commands. Classic protocol injection - the kind of thing that’s been around since the early days of SMTP header injection. Old bug class, new context.

Building the Lab

Here’s where it got interesting. The agent didn’t just explain the vulnerability - it built a complete reproduction environment. Docker Compose with four containers:

  • A normal FTP server (vsftpd) with test files
  • A vulnerable GVFS client (version 1.54.2, pre-fix)
  • A patched GVFS client (HEAD, with the fix)
  • A malicious FTP server (custom Python, raw sockets - more on this in a minute)

The GVFS Dockerfiles clone the source from GitLab at build time, check out the right version, and compile with only the FTP backend enabled. The agent figured out which meson flags to set, which glib version each release needs, which Debian base image provides the right dependencies. Bookworm for the vulnerable build (glib 2.74), Trixie for HEAD (glib 2.83+).

The first PoC script was straightforward. Connect to the FTP server, send a RETR command with \r\nSTAT embedded in the path, watch the server process both commands. Then do the same thing through the actual GVFS stack - gio cat with %0d%0a in the URI - to prove the injection reaches the wire.

This would have been enough for a standard CVE writeup. Working PoC, Docker lab, clear demonstration. Ship it.

But the agent wasn’t done reading.

“This Fix Looks Incomplete”

While tracing the code paths for the PoC, the agent was reading both versions of gvfsftpfile.c - the vulnerable one and the patched one. The fix adds a strpbrk check in g_vfs_ftp_file_new_from_gvfs():

if (strpbrk (gvfs_path, "\r\n") != NULL)
  {
    g_set_error_literal (error,
        G_IO_ERROR, G_IO_ERROR_INVALID_FILENAME,
        _("Filename contains invalid characters."));
    return NULL;
  }

Good fix. Blocks CRLF in user-supplied paths. Every caller was updated to check for NULL.

Then the agent looked at the other constructor - g_vfs_ftp_file_new_from_ftp(). Same file, thirty lines down. This one handles server-supplied paths. Paths that come from the FTP server itself. And it has zero validation:

GVfsFtpFile *
g_vfs_ftp_file_new_from_ftp (GVfsBackendFtp *ftp, const char *ftp_path)
{
  // No strpbrk check. No validation. Nothing.
  file->ftp_path = g_strdup (ftp_path);
  file->gvfs_path = g_vfs_ftp_file_compute_gvfs_path (ftp_path);
  return file;
}

Both functions feed into the exact same downstream sinks. g_vfs_ftp_file_get_ftp_path() is used in CWD %s, SIZE %s, RETR %s, DELE %s - eleven command sites across gvfsftpdircache.c and gvfsbackendftp.c. The fix locked the front door and left the back door wide open.

The agent flagged it immediately: “The fix validates user-supplied paths but leaves server-supplied paths completely unvalidated.”

We sat up a little straighter.

Following the Tainted Data

The agent traced the data flow from server to injection point. Here’s what it found.

When GVFS lists a directory, it sends LIST and parses the response. The parsing happens in gvfsftpdircache.c. For symlinks, ParseFTPList extracts the link target from the listing line. That target is server-controlled text - whatever the FTP server puts after the -> in its ls -l output.

The target gets stored via g_file_info_set_symlink_target(). Later, when GVFS resolves the symlink, it calls g_vfs_ftp_dir_cache_funcs_resolve_default() at line 850:

link = g_vfs_ftp_file_new_from_ftp (task->backend, new_path->str);

new_path->str is the symlink target. From the server. Unvalidated.

That file object then gets used in FTP commands. If the symlink target contains \r, the command splits. A malicious FTP server can make GVFS execute arbitrary FTP commands just by serving a directory listing with a crafted symlink.

The attack works like this: server returns a LIST response containing:

lrwxrwxrwx 1 user user 22 Jan  1 00:00 evillink -> /tmp\rDELE /etc/passwd

GVFS parses it. Strips the trailing \r before \n (line 670). The embedded \r in the middle survives. When GVFS resolves the symlink, it sends CWD /tmp\r\nDELE /etc/passwd\r\n. The server sees two commands.

No user interaction required beyond connecting to the server. File managers do directory listings automatically.

The Malicious Server

To prove the bypass, we needed a server that speaks FTP at the byte level - not a library like pyftpdlib that would normalize the output, but raw socket control over every byte in the LIST response.

The agent built one. A complete FTP server in Python, handling USER, PASS, SYST, FEAT, PWD, CWD, PASV, LIST, RETR, SIZE, STAT, QUIT - everything GVFS needs for a directory listing. The key part is the LIST response for /pub/:

LIST_ENTRY_EVILLINK = (
    "lrwxrwxrwx 1 user user   22 Jan  1 00:00 evillink -> "
    + "/tmp\rSTAT\r"
    + "\n"
)

Those raw bytes go straight to the data connection. GVFS reads the line, strips the trailing \r, and hands ParseFTPList a symlink target with \r still embedded. The injection survives the fix.

Three-Phase Proof

The bypass PoC runs in three phases:

Phase 1 - Direct CRLF in URI. On the vulnerable client, gio cat with %0d%0a passes the injection through. On the patched client, it’s blocked with “Filename contains invalid characters.” The fix works for this vector.

Phase 2 - Raw FTP protocol. Connect to the malicious server, pull the LIST, show the tainted symlink bytes. Then send what GVFS would send: CWD /tmp\rSTAT\r\n. The server processes both commands - 250 for CWD, 211 for STAT.

Phase 3 - Full GVFS stack. gio list ftp://malicious-ftpserver/pub/ through the actual GVFS backend. The symlink resolution triggers the code path. Even on the patched build.

The comparison table tells the story:

Attack VectorVulnerable (1.54.2)Patched (HEAD)
Direct CRLF in URIVULNERABLEBLOCKED
Symlink target (from LIST)VULNERABLEVULNERABLE

What the MCP Connection Changed

We could have done all of this manually. Read the NVD entry. Clone the source. Grep for the fix commit. Trace the code paths by hand. Build the Dockerfiles. Write the PoC.

We’ve done it that way before. Many times. It takes a day, maybe two, depending on the complexity of the codebase and how deep you need to go. It’s the kind of work that’s hard to shortcut - you need to actually read the code, understand the architecture, trace data flows.

This time it started with one prompt and zero follow-up. The agent pulled the CVE intelligence brief from EIP, cloned the source, read the fix commit, traced the code paths, built four Docker containers, wrote two PoC scripts, and discovered the bypass - all autonomously. We didn’t guide it to look at the other constructor. We didn’t tell it to check server-supplied paths. It got there on its own because it was reading the neighboring code as part of building the PoC.

What the MCP connection changed was the starting velocity. The agent didn’t spend twenty minutes gathering context from six different sources. It had everything in its first tool call - severity, CWE, fix commit, affected versions, upstream references. It went from “CVE-2026-28296” to “I understand the vulnerability and here’s the relevant source code” in under a minute. From there it just kept going.

The bypass wasn’t found by a scanner or a fuzzer. It was found by reading code with context. The kind of thing a human researcher would catch during a thorough review, but might miss if they were focused only on the fixed function. The agent read everything in scope because nobody told it to stop.

The EIP data provided the starting context. The AI did the analysis. The bypass fell out naturally.

The Proper Fix

The fix is straightforward. Add the same strpbrk check to g_vfs_ftp_file_new_from_ftp():

if (strpbrk (ftp_path, "\r\n") != NULL)
  {
    g_warning ("FTP path from server contains CR/LF, rejecting");
    return NULL;
  }

And update callers - primarily g_vfs_ftp_dir_cache_funcs_resolve_default() at line 850 - to handle a NULL return. The same pattern the original fix used for the other constructor.

We’ve reported the bypass to the GVFS maintainers with a full lab environment and reproduction steps.

What This Means

This isn’t a story about AI replacing security researchers. The vulnerability class is old. The code is readable. A human auditor looking at the fix diff would likely have caught the gap - if they’d looked at the neighboring function. The point is that they didn’t. The fix was reviewed, merged, and released. Nobody noticed the other constructor.

What’s different here is the workflow. An AI agent, connected to exploit intelligence through MCP, with the ability to read source code and reason about it. The intelligence platform provides the context - what the CVE is, where the fix is, what’s affected. The agent provides the analysis - reading code, tracing data flows, building reproductions.

The bypass was an accident. We typed one line into a terminal and got back a four-container lab, two PoC scripts, and a fix bypass that the upstream reviewers missed. Not because the agent is smarter than a human reviewer. Because it reads everything in scope, including the functions next to the one that was fixed, and nobody told it that wasn’t part of the job.

One prompt. Old-school vulnerability research. Modern tooling. Sometimes the combination surprises you.

The full lab and PoC are on GitHub .


CVEForge Series: