Forty-Seven Defects in FTP, and the Data Channel Still Trusts Anyone

FTP opens a second connection for every transfer, and the oldest defect in the protocol is failing to check who shows up on it. We tested seven servers and uncovered 47 defects. Six of the seven accept a data connection from anybody, and six of the seven can be talked outside the directory they serve.

FTP is two connections. Commands go over one, and every listing and every file goes over a second one opened for the purpose. The control connection is where authentication happens. The data connection is where the data is, and it is opened separately, to or from an address that one of the two ends names.

Nothing in the protocol links them. The server has to do that itself, by comparing the peer on the data connection against the peer it already authenticated on the control connection.

We tested seven independent FTP servers and uncovered 47 defects. Six of the seven never make that comparison.

The second connection, and who gets to use it

In passive mode the server opens a listener and announces the port. Whoever connects to it first becomes the data connection.

Product C accepts whoever arrives. Product D accepts whoever arrives, and its listener outlives the session that created it. Product G accepts whoever arrives, and reads the control peer's address exactly once, for logging. Product B compares nothing in either direction. Product F has no origin validation outbound or inbound. Product E fills in the data peer's address on accept and then never looks at it.

In active mode the direction reverses: the client names an address and the server connects out to it. Product B, Product F and Product G dial whatever address the command contains, which makes the server a connection source on behalf of an unauthenticated peer. Product B's dial has no timeout at all, so an address that silently drops the handshake holds the session's worker for roughly two minutes per command.

This is not a subtle defect, and it is not new. It is the first thing anyone reviewing an FTP implementation is supposed to check, and five of six shipping servers do not do it.

Six of the seven can be talked out of their directory

A server that serves a directory has to guarantee that nothing it is asked for resolves outside it. Six implementations, six different ways of not guaranteeing it. The seventh, Product E, is the only one here that resolves the path once and checks the result it is about to open.

Product D has the most interesting one. Its containment check counts path components and counts how many of them are .., then compares the two numbers. It is a ratio rather than a boundary, so a path with enough components to outnumber its own ascents passes, whatever directory it actually lands in. The same product's size command performs no validation at all, while the two commands either side of it both call the checker.

Product A cleans the client's path twice under two different separator alphabets. The first pass normalises under one set of rules and the second under another, and the string that survives both is not the string either pass was validating.

Product B builds the path, then tests the raw argument against a hard-coded string to decide whether the request is for the root, then deletes the built path. The guard and the operation are looking at two different values.

Product C has four. Three handlers resolve a path through a helper whose own documentation says it does not enforce containment and whose callers are expected to check the result, and all three skip the check. One of those three writes. The fourth lets the served directory itself be removed.

Product F's check is a prefix comparison against the configured home directory, performed after the path has been fully resolved. Resolution catches the ascent, and the prefix test then accepts any sibling directory whose name starts with the same characters.

Product G has the quietest one. Its filename length guard calls a UTF-8 length function that returns zero to signal "this is not UTF-8", and the caller reads that zero as a length. One invalid byte in a filename turns the guard off.

Before anyone has logged in

Product A, Product B and Product C read the control line with no length limit, on the socket as it is, before authentication.

In each case the buffer involved looks bounded and is not. One is a stream buffer whose default maximum is the largest value its size type can hold. One is a buffered reader whose 4096 bytes are a refill size rather than a line limit. One is an 8192-byte reader feeding a line accumulator that grows until the terminator arrives. A client that opens a connection and sends bytes without a newline is an allocation with no ceiling, and it has supplied no credential to get there.

The ones that never crash

Five of these defects stop a server permanently without killing the process, which means a test that watches for process death sees nothing at all.

Product A and Product C both end their accept loop on a single failed accept. The process stays alive, the listening socket stays open, connections are still accepted by the kernel, and no client is ever served again. On a release build Product A does not even log it, because the diagnostic is compiled out.

Product A also leaves its data queue unserviced, giving a session that answers every command with a success reply while transferring nothing, and then growing without a bound.

Product E needs no failure at all. It serves one connection at a time to completion and reads the control line a byte at a time with no deadline, so a peer that connects and sends nothing holds the server for as long as it cares to, before authentication and at the cost of one socket.

Product B and Product C both parse command arguments in quadratic time, in the option-stripping loop of the listing command, where each turn copies the entire remainder. Product C has a second one that is worse, because it runs for every command that takes an argument, before any handler sees it: a trailing-whitespace strip written as a regular expression that is attempted at every position in the string.

Product A has the one that is not even a denial of service. Its file cache keys on a path and hands a later reader an earlier reader's mapping, so a transfer can complete successfully and return a truncated file. Nothing fails. The client is told the transfer worked.

When the line is capped, the overflows come back

Product G is an embedded library rather than a daemon, serves one session at a time, caps its control line at 263 bytes and holds all its state in fixed arrays. That cap removes the unbounded-allocation class entirely.

What is left is the class the cap does not touch. A command with no argument leaves the parameter pointer null and two handlers dereference it without checking. A different handler walks the argument with a pointer it has already advanced four times before testing it. A path builder uses a string concatenation whose length argument bounds the source rather than the destination. Another copies a 263-byte buffer into a 255-byte one. And the line reader bounds only the branch that stores an ordinary character, so the byte that terminates a maximum-length line writes one past the end of the command buffer and lands on the field stored next to it.

Short inputs, fixed buffers, eight defects. The lesson is that capping the input changes which class of defect you have, not whether you have one.

The one that destroys data on the way to failing

Product E truncates the destination file the moment an upload command is parsed, and only then waits for the data connection it is going to receive the contents on.

If that connection never arrives, or arrives from somebody who sends nothing, the file is already empty. The command fails, the client is told it failed, and the previous contents are gone. An attacker who can reach the control connection can therefore empty files without ever transferring a byte, and the server's own error reply is the record that it happened.

Per-command leaks

Product F overwrites a directory listing vector and a filename string on every listing without releasing the previous ones, and overwrites an open file handle on every retrieve or store without closing the previous one. Both are one command per leak, and both commands are ones a client is supposed to issue repeatedly.

Product D accumulates transfer sessions with no cap, each holding a two-megabyte buffer. Product B leaks a listener for every passive connection that is set up and never used, which is a client sending one command and not following up.

Finding these with Penzzer

Select the INET4 or INET6 module, choose FTP, and point the run at the server.

FTP is driven as a protocol rather than as a socket to write commands into. A run authenticates where credentials are configured and exercises the surface from both sides of the login boundary, drives both passive and active data channel setup, and judges replies against the three-digit reply code structure and the multi-line continuation rules the specification fixes.

Ten attack modules run alongside:

| Module | The shape it provokes | | --- | --- | | Data channel origin hijack | A data connection from a peer that is not the authenticated control peer, in both directions | | Data channel resource exhaustion | Channels set up and abandoned, and transfers that accumulate state per command | | Path containment escape | Arguments that resolve outside the served directory, including the forms a normalisation pass creates rather than removes | | Unbounded control line | A pre-auth line that never terminates | | Command argument overflow | Arguments at and past the sizes a fixed buffer assumes, including the empty argument | | PORT and EPRT argument abuse | Address and port arguments that are out of range, malformed, missing their separators, or absent entirely | | Quadratic argument parsing | Arguments whose parsing cost rises faster than their length | | Command stall | A command that holds the server's worker, including an address that accepts nothing | | Listener accept reset | The conditions that end an accept loop while the process stays alive | | Reply and command framing abuse | Commands and replies at the edges of the framing, including a handler that answers twice |

‍

Two of those modules exist because of what this family shows about oracles. A server whose accept loop has ended is alive, listening and answering nothing, and a server that completes a truncated transfer reports success. Neither is visible to anything watching for a crash, so both are judged by what the protocol does afterwards rather than by whether the process is still running. One of the seven here recovers every panic it raises, so on that product a crash oracle alone would report a clean run across all six of its defects.

What this article is, and is not

The 47 defects above are a sample of what this kind of testing surfaces, not a description of Penzzer's FTP coverage. They are what seven specific implementations happened to get wrong.

The attack library spans defect classes well beyond what this article shows: memory safety, information disclosure, resource exhaustion, availability, hangs and algorithmic complexity, input validation, protocol state logic, type and format handling, and error handling. Across the 107 protocols with a protocol-aware target, a run also generates its test cases from the structure of the protocol itself, walking each field through the boundaries where parsers break.

The same limit applies in reverse. What a test looks for is a property of the test, not a claim about your device.

If you ship an FTP server

Start with the two questions that six out of seven got wrong, twice over.

When a data connection is established, do you compare its peer against the peer you authenticated, and do you refuse it otherwise? And is your containment check performed on the exact string you are about to open, after every normalisation pass, rather than on an argument you cleaned earlier or a count you derived from it?

Then one more, which is where half the remaining defects in this article live. If your server stopped serving but did not exit, would you find out?

Other Post
Uncover Hidden Vulnerabilities

Identify security flaws before attackers do, automatically and at scale with Penzzer's intelligent fuzzing engine.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.