Skip to content

Patch the Planet: One TFTP request can keep the server busy indefinitely

Low
gpotter2 published GHSA-v79w-4r7w-q392 Sep 8, 2026

Package

pip scapy (pip)

Affected versions

<2.8.0

Patched versions

2.8.0

Description

What happens

One external TFTP read request leaves Scapy's read-server automaton transmitting the same data
block every three seconds until it receives a matching acknowledgement. There is no retry count or
terminal timeout, so a requester that sends no ACK keeps the server instance occupied indefinitely
and prevents it from accepting another request.

Background — what this code does

Trivial File Transfer Protocol (TFTP) sends a file as numbered data blocks over UDP. The receiver
acknowledges each block, and the sender retransmits a block after a timeout when its
acknowledgement may have been lost.

TFTP_RRQ_server is Scapy's answering automaton for read requests. An application explicitly
starts it with an in-memory file store or a served directory. The automaton receives an RRQ, sends the
first data block, and waits for the corresponding ACK before moving to the next block or returning to
request handling.

How the code is reached

An unauthenticated requester sends an ordinary, wire-sized RRQ for a filename present in the
configured store or directory. No malformed packet or changed Scapy configuration is needed. If
the requester sends no ACK, the ordinary loss-recovery timeout repeatedly enters the data-sending
state.

This is response generation caused by an external stimulus: the behavior occurs in an answering
automaton after receiving the request, rather than while an operator crafts a packet directly.

Why it matters

The automaton handles one transfer at a time. While it waits for the missing ACK, later RRQs do
not reach the request-handling state. The server also continues sending one data packet every three
seconds without receiving any further packet from the requester.

The reproduced 47-byte RRQ caused block 1 to be sent at 0, 3, 6, 9, and 12 seconds. The automaton
was still active when the bounded observation ended. The same request followed by ACK 1 sent one
data packet and completed.

Reproduce it

The following program uses an in-process socket and the real three-second automaton timer. It
needs no live capture or privileges.

import time

from scapy.automaton import select_objects
from scapy.layers.inet import IP, UDP
from scapy.layers.tftp import TFTP, TFTP_ACK, TFTP_DATA, TFTP_RRQ, TFTP_RRQ_server

def request():
    return (
        IP(src="198.51.100.66", dst="192.0.2.10")
        / UDP(sport=40000, dport=0x2807)
        / TFTP()
        / TFTP_RRQ(filename="served.txt")
    )

def ack():
    return (
        IP(src="198.51.100.66", dst="192.0.2.10")
        / UDP(sport=40000, dport=0x2807)
        / TFTP()
        / TFTP_ACK(block=1)
    )

def run(send_ack):
    class WireSocket:
        packets = [request()] + ([ack()] if send_ack else [])
        sent = []

        def __init__(self, iface):
            self.iface = iface

        def recv(self, size=None):
            return self.packets.pop(0)

        def send(self, packet, *args, **kwargs):
            self.sent.append((time.monotonic(), packet.copy()))

        def close(self):
            pass

        @classmethod
        def select(cls, inputs, remain):
            sockets = [sock for sock in inputs if isinstance(sock, cls)]
            if sockets:
                if sockets[0].packets:
                    return sockets
                inputs = [sock for sock in inputs if not isinstance(sock, cls)]
            return select_objects(inputs, remain)

    server = TFTP_RRQ_server(
        ip="192.0.2.10",
        sport=0x2807,
        store={"served.txt": b"attacker-selected-response"},
        serve_one=True,
        ll=WireSocket,
        recvsock=WireSocket,
    )
    server.runbg()
    if send_ack:
        deadline = time.monotonic() + 1
        while server.isrunning() and time.monotonic() < deadline:
            time.sleep(0.01)
    else:
        time.sleep(12.5)
    running = server.isrunning()
    if running:
        server.forcestop()
    server.destroy()
    started = WireSocket.sent[0][0]
    sends = [
        (round(when - started, 2), packet[TFTP_DATA].block)
        for when, packet in WireSocket.sent
    ]
    return running, sends

print("no ACK:", run(False))
print("ACK control:", run(True))

Commit 1f870205baae8baf1718bc20700d0d9ffc0e4324 prints:

no ACK: (True, [(0.0, 1), (3.0, 1), (6.0, 1), (9.0, 1), (12.0, 1)])
ACK control: (False, [(0.0, 1)])

With the proposed fix, the no-ACK result becomes:

no ACK: (False, [(0.0, 1), (3.0, 1), (6.0, 1), (9.0, 1)])

Where it goes wrong

scapy/layers/tftp.py:524-533 sends the current data block and enters the same state on every timeout:

@ATMT.state()
def SEND_FILE(self):
    self.send(
        self.l3 / TFTP_DATA(block=self.blk % 65536) /
        self.data[(self.blk - 1) * self.blksize:self.blk * self.blksize]
    )

@ATMT.timeout(SEND_FILE, 3)
def timeout_waiting_ack(self):
    raise self.SEND_FILE()

There is no counter or alternate timeout transition.

scapy/layers/tftp.py:535-553
shows that only a matching ACK advances the block and allows the transfer to finish:

if TFTP_ACK in pkt and pkt[TFTP_ACK].block == self.blk:
    raise self.RECEIVED_ACK()
# ...
def RECEIVED_ACK(self):
    self.blk += 1

Suggested fix

Allow three retransmissions for each data block. Reset the retry count when a new request arrives
and, on the first timeout for a new block, after the previous block's matching ACK. Keeping the
bookkeeping on the timeout path avoids adding work to every valid ACK.

After three retransmissions, a server created with serve_one=True terminates. A reusable server
returns to its request-waiting state. The initial data send and three loss-recovery attempts
remain available, while an acknowledged transfer is unchanged.

The attached patch adds a regression to the existing test/scapy/layers/tftp.uts suite. Its
socket helper is local to the test and checks both the no-ACK limit and an immediate-ACK control.
Removing the source change makes the test fail; applying it produces one passing focused test.

The configured BSD suite reported 291 passed and the same 3 existing interactive/startup failures
before and after the patch.

Performance impact of the fix, measured on one computer by running the same test before and after:
a matching ACK took 452.1 ns before and 425.9 ns after, 5.8% faster — a difference of 26 ns.
Repeat runs of that test moved by about 2%, so that difference is larger than the test's own
variation.

Affected

  • Package: Scapy · Branch: master
  • Confirmed on: commit
    1f870205baae8baf1718bc20700d0d9ffc0e4324,
    version 2.7.1rc1.post100
  • Severity: Medium — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L (5.3). The score reflects
    unauthenticated network access and a low-rate loss of availability for one explicitly started
    TFTP server instance, without demonstrated confidentiality or integrity impact.
  • CWE: CWE-400, Uncontrolled Resource Consumption

Credit

Reported by: Clinton Thomas (@KernelClint) of Trail of Bits, in collaboration with OpenAI.
Found with GPT-5.6-Cyber as part of the Patch the Planet security initiative.

Severity

Low

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
High
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L

CVE ID

No known CVE

Weaknesses

Uncontrolled Resource Consumption

The product does not properly control the allocation and maintenance of a limited resource. Learn more on MITRE.

Credits