What Nine TFTP Servers Got Wrong

We put nine independent TFTP server implementations through protocol-aware fuzzing and uncovered 44 defects. Almost all of them are the same five bugs, repeated across codebases that share no code.

TFTP is 45 years old, fits in a few hundred lines, and is still sitting on the management port of switches, PXE boot servers, VoIP phones and industrial controllers. Its simplicity is exactly why people keep reimplementing it, and why those reimplementations keep failing the same way.

We tested nine independent TFTP servers: C, C++, Rust and Qt codebases, on Windows and Linux, written by different people over three decades, sharing no lineage and no parser. Across them we uncovered 44 defects, nearly all with a proof of concept that reproduces the failure on a running target.

The interesting result is not the count. It is that nine unrelated codebases converged on the same five mistakes.

What follows is a sample: the defects these nine products happened to have. It is not the boundary of what this kind of testing finds, and the last section says what else a run goes looking for.

The protocol gives you nothing to work with

Every finding below is reachable by anyone who can put one UDP datagram on the listening port. There is no prior exchange, no handshake and no state.

TFTP has no authentication, and none of the nine servers adds any. The protocol's only session binding is the transfer identifier. RFC 1350 says a server must check that packets in a transfer come from the port that started it. That is the whole security model, and as we will see, it is frequently not implemented at all.

A request packet is a 2-byte opcode followed by a run of NUL-terminated strings: filename, mode, then option name/value pairs. That shape, "walk forward until you find a NUL", is the origin of most of what follows.

Bug 1: trusting that the NUL is there

The most common defect, by a wide margin. The parser walks the request with strlen, memchr or a pointer loop, and nothing guarantees the terminator is inside the datagram.

  • Product A calls strlen on a filename field that need not contain a NUL at all. A separate path computes n - 4 on a short datagram and reads 3,584 bytes past a 516-byte stack array, straight into the uploaded file, handing the client whatever was on the stack.
  • Product C finds the filename's NUL, steps one byte past it, and reads the mode field without ever checking that such a byte is inside the packet. Its option walker has the same shape: the loop bound constrains where a string may start, never where it ends.
  • Product B's field scan stops when it reaches its own end pointer, then dereferences that pointer to decide whether it stopped on a NUL.
  • Product D sizes its receive buffer from the datagram, then casts it to a 4-byte header struct. A 0-to-3-byte datagram reads off the end.
  • Product G skips the filename's terminator but not the mode's, so the option list is parsed one field to the left. RFC 2347 option negotiation simply does not work, and nobody noticed.

In Product C, a 2-byte ERROR packet makes a length subtraction underflow in 32-bit unsigned arithmetic, producing a memchr count of 4,294,967,294.

Bug 2: sizing a buffer from a number the client chose

The blksize option (RFC 2348) lets a client ask for a larger block. Five servers take that number and size something with it.

  • Product H builds the option acknowledgement into a 512-byte stack buffer and memcpys the assembled OACK into it with no output bound.
  • Product B overflows its OACK heap buffer the same way.
  • Product C allocates 5 + blksize bytes per DATA packet with no upper bound.
  • Product D stores the length in a 16-bit field, so a large blksize wraps the DATA packet length to 0 to 3 bytes.
  • Product F reserves 2 × windowsize × blksize. At the maxima that is 7.99 GiB per request, and there is no connection limit anywhere in the tree. 163,800 datagrams in 41 seconds killed the daemon.

Note what the OACK cases have in common: the server is overflowing a buffer while building its reply. A fuzzer watching only for malformed input to be rejected will never see it.

Bug 3: options that park the server

Three servers adopt a timing value from the request without an upper clamp, usually sitting directly beside a sibling option that is clamped correctly.

Product F clamps blksize, windowsize and timeout, then adopts utimeout with only a lower bound and windowwait with no bound at all. Both land in a sleep on the worker thread. u64::MAX is accepted. Product E clamps block size and window size against a server maximum and then adopts timeout with no maximum. Product D reads the timeout option's first byte, "clamps" it to a range it cannot leave, 1 to 255, and arms a 1 ms retransmit that never stops.

Two of these are non-standard options. A generator built only from RFC 2347, 2348, 2349 and 7440 will never emit utimeout or windowwait, and will never find them.

Bug 4: no transfer-ID check

The protocol's only session binding, and it is missing in three of the nine. Product A never records or compares a transfer identifier, and passes every worker thread the listening socket, so RFC 1350's requirement of an ephemeral port per transfer is not implemented at all. Product I overwrites the session's record of the peer on every datagram received. Product B does not enforce the DATA block number.

Where this is missing, any host that can reach the port can inject data into somebody else's transfer.

Bug 5: one request, one leaked resource, no cap

TFTP servers are tiny and tend to be written as though nobody would send two requests. Product D leaks a socket and an open file per read request. Product I leaks a descriptor per datagram. Its sibling handler has exactly the guard that is missing, added for an earlier bug report, with a comment describing this hazard in so many words. Product G spawns a thread per request with no cap, and aborts the process when thread creation fails. Product E binds the socket before checking the connection limit.

Related, and worth its own mention: state that outlives the request that set it. Product I frees the stored filename, keeps the pointer, and later logs through it. Its option state is never cleared, so one request's blksize silently applies to the next client's. Product A will dispatch a 2-byte datagram using the previous datagram's filename.

Three that are just the filesystem

Product A passes the request filename to fopen with no root, no chdir, no chroot, no prefix and no check for ... Product C validates one string and opens a different one. Product D, under its shipped default invocation, will serve any world-readable file on the host.

Finding these with Penzzer

Everything above is reachable from one unauthenticated UDP datagram, which is exactly the shape Penzzer's TFTP target is built for.

Select the INET4 module (or INET6, since TFTP is a dual-stack target), choose TFTP as the protocol, and point it at the device under test. No credentials, no setup, no instrumentation on the target.

A run is not a replay of the bugs above. Three things happen:

Protocol-aware fuzzing. Penzzer builds test cases by walking each field of the TFTP packet through a range of values, concentrating on the boundaries where parsers usually break. It knows the opcode, the filename and mode fields, the option name/value pairs, the block and error-code fields, and it varies them as fields rather than as random offsets, including the case that general-purpose mutators systematically miss, a declared length or count set against something other than the bytes that actually follow it. That sweep is generated from the protocol's structure, so it covers field combinations no human wrote down and no audit happened to reach.

The curated attack modules. Separately, eleven TFTP modules fire specific proof-of-concept cases. These are the ones that line up with what we found:

Defect classAttack modules
Trusting the NULTruncated packets, RRQ/WRQ field abuse, Long filename and mode overflow
Buffers sized from client numbersOption negotiation abuse
Options that park the serverOption negotiation abuse
Missing transfer-ID checkTransfer identifier injection
Leaked resources, no capRequest flood resource exhaustion, Worker and accept starvation
State reuse across requestsCross request state reuse
Filesystem reachPath traversal
Block/ACK handlingData ACK block abuse, Undefined and malformed opcodes

General network testing. The same target can also be put through vulnerability and misconfiguration scanning and a port scan, which is usually how a TFTP service that nobody meant to leave running gets found in the first place.

What this article is, and is not

The 44 defects above are a sample of what this kind of testing surfaces, not a description of Penzzer's TFTP coverage. They are what nine specific products happened to get wrong.

The tooling is not bounded by them. The field walk is generated from the protocol, so it goes looking for defect shapes that nothing in this article exhibits, and the attack library as a whole spans defect classes well beyond what a TFTP server can even express: memory safety, information disclosure, resource exhaustion, availability, hangs and algorithmic complexity, input validation, protocol state logic, type and format handling, and error handling. A run looks for all of those regardless of whether any of them is present.

The same honesty applies in reverse. What a test looks for is a property of the test, not a claim about your device. A clean run means those cases did not provoke anything, and a finding reports what actually crossed the wire, not what we hoped it proved.

Every defect described here is one a TFTP run can provoke from the outside, against a device whose source you will never see and whose firmware you cannot instrument. That is the point of testing a protocol rather than a codebase: the same run that uncovered these in nine servers is the run you can point at a tenth.

The useful generalisation

If you ship, buy or operate something with a TFTP server on it, the five questions worth asking are: does the parser bound every walk, does it size any buffer from blksize, does it clamp every timing option, does it check the transfer identifier, and does it cap concurrent transfers?

Nine independent teams answered at least one of those wrong. Several answered all five wrong. The failure modes do not depend on the language: the Rust implementations are free of the memory-safety bugs and have the resource exhaustion and unclamped-option bugs instead.

A protocol being simple does not make its implementations safe. It makes them numerous.

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.