Tcpreplay 4.6.0 is a major release built around three things: a new CMake build system, two high-throughput packet-injection backends that now genuinely reach line rate, and a set of security fixes on top of 4.5.5. It also folds in a correctness pass on the TX_RING send path, several new CLI features, and a jumbo-frame bug that had been silently truncating packets.
Highlights
- Completely updated documentation site — tcpreplay.appneta.com now has a modern, task-oriented guide: installation, quickstart, performance testing, fast-path backends, and a reference page per tool
- CMake build, alongside the existing autotools flow (
cmake -B build && cmake --build build) - AF_XDP (
--xdp) now reaches line rate on 1GigE — from an initial ~13k pps to ~305k pps (941 Mbps) on commodity e1000 hardware, ahead of the default injector - io_uring (
--io-uring) batches sends instead of one syscall per packet — ~11% less CPU per packet --raw— a new PF_INET/SOCK_RAW injection path that sends through the local IP stack--loss— random percent packet-loss simulation for testing against non-ideal network conditions- 3 new security advisories fixed on top of 4.5.5
- Jumbo frames fixed — a long-standing bug truncated any frame beyond an MTU that needed more than one page of ring buffer to a flat 4096 bytes
- TX_RING reliability fixed — the default Linux send path could silently drop and reorder packets; both are now fixed
New features
| Feature | Flag | Notes |
|---|---|---|
| io_uring injection | --io-uring |
Async submission via Linux io_uring; portable to any modern kernel |
| AF_XDP injection | --xdp |
Kernel-bypass via an XDP socket; falls back automatically if the adapter can't support it |
| — queue selection | --xdp-queue |
Bind to a specific adapter queue instead of always queue 0 |
| — strict mode | --xdp-no-fallback |
Fail rather than silently fall back — for benchmarking, where a silent change of injector would change what's measured |
| — batch tuning | --xdp-batch-size |
For very high-speed links (100GigE+) with small packets; not normally needed |
| Raw IP socket injection | --raw |
Routes through the kernel's IP stack (routing, netfilter); IPv4-only, rebuilds L2 framing |
| Packet-loss simulation | --loss |
Independently drops each packet at a given 0–100% probability (contributed by @dsseng) |
| Raw IP (L3-only) interfaces | — | Replay onto WireGuard/tun-style interfaces with no L2 header |
| Static library | libtcpreplay |
The replay engine is now installable as a standalone C library |
| CMake build | — | Full feature parity with autotools; every --enable-*/--with-* has a CMake equivalent |
Performance: getting AF_XDP to actually earn its name
--xdp was close to unusable in practice — it worked on some adapters and not others, occasionally hung, and where it did run, underperformed the default injector by orders of magnitude. Chasing that down turned up four separate, compounding bugs:
- No attach-mode fallback. AF_XDP socket setup never told the kernel which mode to bind in, so it silently required native driver XDP support — absent on
e1000,e1000e, and plenty of other common adapters — with no fallback to generic (SKB) mode. - Unbounded hangs. Two send-path loops retried forever with no timeout, so a driver that accepted the socket but never actually transmitted wedged the whole replay in an unkillable 100% CPU spin.
- A ring descriptor leak in
--xdp-batch-size. Reserved-but-unused TX descriptors were never returned, so any batch size that didn't evenly divide the pcap's packet count caused throughput to collapse — measured as low as 120 pps. - No pipelining. The send path indexed its packet buffer modulo the batch size, so it had to block until an entire batch fully completed before it could prepare the next one — a full TX completion round-trip per packet.
With all four fixed — plus a --loop bug that desynced the ring after the first pass and wedged, --xdp-queue, and the auto-fallback above — a real-world before/after on a 1GigE e1000:
| pps | throughput | |
|---|---|---|
--xdp, before this work |
13,344 | 41 Mbps |
--xdp, fully fixed |
305,168 | 941 Mbps |
| default injector (TX_RING), for reference | 242,979 | 749 Mbps |
That's a 23× improvement, and AF_XDP now saturates a 1GigE link where the default injector doesn't.
io_uring got a smaller but genuine fix along the way: it previously issued one io_uring_enter() syscall per packet — no better than a plain send() loop — plus extra bookkeeping on top, so it was slower than not using it at all. Submissions now batch 64 deep with strict in-order delivery, cutting syscall count 64× for roughly 11% less CPU per packet.
Reliability and correctness fixes
- TX_RING was silently dropping and reordering packets (the default Linux send path). It filled ring frames out of order, which both misordered packets on the wire and permanently stranded any frame the kernel hadn't finished with — discarded at teardown after already being counted as successfully sent. In the worst case, a short replay reported 100% of packets sent while transmitting none. Fixed by filling the ring strictly in order and draining it before statistics are read.
- Jumbo frames truncated to 4096 bytes. A block-sizing bug in the TX_RING setup divided the frame size back down to a single page after correctly growing the block to fit the MTU, silently mangling any capture with frames larger than ~4KB.
--xdphung indefinitely rather than failing when AF_XDP couldn't be used, and returned success on some fatal send errors, hiding failures from CI and scripts.- Assorted build-system correctness fixes:
configure.accould enable AF_XDP support from a bare link test without the matching headers, producing a build that failed partway through; cached CMake feature probes could report a library missing after it had since been installed, with no indication a re-probe was needed.
Security
Three new advisories are fixed beyond 4.5.5, all reported by tinyb0y:
| Advisory | Severity | Component |
|---|---|---|
| GHSA-fwcr-mqg6-hqmx — CVSS 7.8 | High | Stack buffer overflow in the tcpprep --services file parser |
| GHSA-m6w7-8497-g9c9 — CVSS 7.7 | High | Heap out-of-bounds read via --pktlen with --preload-pcap |
| GHSA-5q26-7fxx-v8fh — CVSS 7.1 | High | Heap out-of-bounds access in ARP address rewriting |
(Everyone already on 4.5.5 has the fragroute and get_layer4_v6 advisories that were fixed alongside these — those were backported there separately, so they aren't new here.)
SECURITY.md is now at the repo root, picked up by GitHub's Security tab, and documents the disclosure policy and preferred reporting channel.
Build system
- CMake (
cmake -B build && cmake --build build) is now the primary, recommended way to build the suite, with feature-for-feature parity to the autotools./configureflags. - GNU AutoGen is no longer required to build from a git checkout (only
python3andasciidoctor); it remains needed only for one legacy template file. - Release tarballs ship pre-built files for both build systems, so a tarball build needs neither python3 nor asciidoctor.
Credits
Thanks to @dsseng for --loss, @Steve-Tech for reporting and diagnosing the jumbo-frame truncation, and tinyb0y for the security research behind this release's advisories.
What's Changed
- xdp: revert the top-speed default for
--xdp-batch-sizeback to 1 (#1084) — now that sends pipeline across the whole umem, a batch of 1 already reaches line rate on a 1GigE e1000 (941 Mbps / 305k pps).--xdp-batch-sizestays available for links this can't yet saturate — 100GigE and up, especially with small packets — where a deeper batch may still help - xdp: pipeline AF_XDP sends by spreading packets over the whole umem instead of recycling the first
batch_sizeframes (#1084) — the send path no longer waits for each batch to complete before preparing the next packet, only when the next batch would reuse a frame still in flight. The umem is 4096 frames whether used or not, so this costs no extra memory; at a batch of 64 only 1.5% of it was being used. End-of-replay draining now covers AF_XDP too, so the reported counts describe what actually reached the wire - xdp: batch AF_XDP sends 64 deep by default when replaying at top speed (#1084) — the send path waits for each batch to complete before preparing the next packet, so a batch of one costs a full TX completion round-trip per packet: ~13k pps on an e1000 against ~254k at 64. Paced replays keep a batch of 1, since
--pps/--mbps/--multiplierare only honoured there - xdp: fix
--xdp-batch-sizecollapsing throughput (#1084) — a full batch of TX descriptors was reserved but only the filled ones submitted, so libxdp's cached producer index crept ahead on every short batch until the ring looked full;--xdp-batch-size=64against a 179-packet pcap ran at 120 pps, now ~685k. Unused reservations are handed back - xdp: fix
--xdpdelivering only the first--loopiteration and then wedging (#1082) — the TX ring's cached producer/consumer indices were zeroed between loops, desynchronising them from the kernel's and stranding every later descriptor; libxdp maintains them itself - xdp: add
--xdp-queueto select which adapter queue the AF_XDP socket binds to, instead of always using queue 0 (#1082) - xdp: fall back to the default injection method, with a warning, when AF_XDP cannot be set up on the adapter, rather than failing outright;
--xdp-no-fallbackrestores the hard error for benchmarking, where a silent change of injector would change what is measured (#1082) - txring: stop truncating jumbo frames to 4096 bytes (#1079) —
txring_mkreq()grew the block by whole pages until an MTU-sized frame fit, then dividedtp_frame_sizeby that page count, putting it straight back to one page; a 9000-byte MTU therefore got 4096-byte frames. The block is now given to a single frame. Reported by @Steve-Tech, who also found the cause - xdp: pick the AF_XDP attach mode from what the driver supports instead of assuming native (#1080) —
create_xsk_socket()never setxdp_flags, so--xdponly ever worked on adapters with native XDP and simply failed on e1000, e1000e and friends; it now falls back to generic/SKB mode, rebuilding the umem because a failed bind leaves the old one attached - xdp: bound the two TX loops that retried forever with no timeout and no abort check, so a driver that binds an AF_XDP socket but never transmits is reported rather than wedging tcpreplay in an unkillable 100% CPU spin;
kick_tx()also calledexit(0)— success — on a fatal send error, hiding it from scripts and CI - xdp: route libbpf/libxdp logging through
dbg()instead of letting it print over the replay, keeping the last warning so a non-debug build still says why AF_XDP could not be set up - configure: require both halves before enabling libxdp/libbpf —
AC_CHECK_LIB's default action definedHAVE_LIBXDP/HAVE_LIBBPFoff a bare link test, but both gate#includes indefines.h, so a system with the shared library and no headers compiled--xdpin and then failed to build; the configure summary also reported only the compile half, so it could disagree with what was actually built. Now matches CMake, and says which half is missing instead of dropping--xdpsilently (usuallylibxdp-devwithoutlibbpf-dev, since<xdp/xsk.h>includes<bpf/libbpf.h>) - cmake: flag a feature the summary reports as "no" when the library is actually installed and only an earlier run's cached probe says otherwise — CMake never re-runs a cached check, so "configure, apt install libxdp-dev, rebuild" silently left
--xdpcompiled out with nothing saying why; the footnote now gives thecmake -U "HAVE_*"escape hatch - txring: fill the Linux TX_RING in order so packets aren't stranded unsent (#1078) —
txring_put()skipped past frames the kernel still owned, buttpacket_snd()stops at the first frame that isn'tTP_STATUS_SEND_REQUEST, so every skip stranded the rest of the ring; those frames were discarded at teardown having already been counted as sent (-l 1reported 179 sent, transmitted 0), and filling out of order also reordered the wire - txring: drain the TX ring at the end of a replay, bounded at 2s like netmap's (#560, #1005), via a new
sendpacket_drain(); undrained packets now count as failed rather than sent, and fix a truncation bound that let an oversized packetmemcpypast the mapping - io_uring: batch submissions 64 deep instead of one
io_uring_enter()per packet (#1074), cutting 17900 syscalls to 280 for ~11% less CPU per packet, and address the socket by registered index (IOSQE_FIXED_FILE) - io_uring: chain each batch with
IOSQE_IO_HARDLINKso the kernel issues it in order, and addsendpacket_flush()so a partly filled batch doesn't sit in the queue across a paced replay's sleep — a--pps=10run delivered nothing for 1.8s, then all 20 packets at once - docs: move
SECURITY.mdfromdocs/to the repo root so it's picked up by GitHub's Security tab, and expand it with a Scope section (what's in/out of scope for reports), a Disclosure Policy, GitHub private vulnerability reporting as the preferred report channel, and a Published Advisories pointer, alongside the existing CVSS-based supported-versions table and email contact - SECURITY: fix stack buffer overflow in the
tcpprep --servicesfile parser (GHSA-fwcr-mqg6-hqmx, CWE-121).parse_services()(src/common/services.c) matched each line against"([0-9]+)/(tcp|udp)"and copied the port substring into a 10-byte stack buffer withstrncpy()using the regex match length as the count. The digit group has no upper bound, so a services file with an overlong run of digits before/tcpoverflowedport[](and, at larger sizes, triggeredstrncpysource/dest overlap). Each copy is now clamped to its destination size. Reported by tinyb0y - SECURITY: fix heap out-of-bounds access in ARP address rewriting (GHSA-5q26-7fxx-v8fh, CWE-787).
rewrite_iparp()andrandomize_iparp()(src/tcpedit/edit_packet.c) computed the sender/target IP field offsets from the packet's ownar_hln/ar_plnbytes (attacker-controlled, 0-255) after checking only that the 8-byte ARP base header was present. A crafted ARP packet with largear_hln/ar_plnreached up to ~773 bytes past the header; viatcpreplay/tcpreplay-edit --preload-pcap(whose cache buffer is onlycaplen+512) this is an out-of-bounds read and 4-byte write. Both functions now requirear_pln == 4and the full IPv4 ARP payload to fit within the captured L3 length before dereferencing;rewrite_iparp()gained anl3lenparameter for this. Reported by tinyb0y - SECURITY: fix heap out-of-bounds read via
--pktlenwith--preload-pcap(GHSA-m6w7-8497-g9c9, CWE-125).send_packets()(src/send_packets.c) sized the packet cache buffer fromcaplenbut, under--pktlen, sentpkthdr.lenbytes — the on-the-wire length, which for a truncated capture can exceedcaplen(up toMAX_SNAPLEN, 256KB), reading and transmitting adjacent heap memory past the allocation. The send length is now clamped tocaplenat every send site. Reported by tinyb0y - test: align the OK/FAILED status column for the three fragroute test targets. Their labels are long enough that the two-tab prefix pushed the status to the next tab stop (column 56) while every other target lands at column 48; dropped to a single tab so the whole suite lines up on screen
- SECURITY: fix off-by-one heap buffer overflow in the fragroute tcp_chaff module (GHSA-v8c4-9w98-9v6v, CWE-193, CWE-787).
tcp_chaff_apply()(src/fragroute/mod_tcp_chaff.c) calledrand_strset()with(pkt_end - pkt_tcp_data + 1), writing one byte past the end of the allocationpkt_dup()had just made — the same defect as GHSA-m655-53p4-6qm8 in ip_chaff, in a module that advisory did not cover. Triggered by any tcp_chaff directive against an ordinary TCP packet. Found while auditing the ip_chaff fix - fragroute: check
pkt_new()/pkt_dup()for failure in the ip_frag, tcp_seg and tcp_chaff modules. Six call sites inip_frag_apply_ipv4(),ip_frag_apply_ipv6(),tcp_seg_apply()andtcp_chaff_apply()dereferenced the returned packet immediately, so an allocation failure crashed on a NULL pointer instead of aborting the rule - SECURITY: fix stack buffer overflow in the fragroute rules-file parser (GHSA-777w-9599-w8g4, CWE-787). On a successful parse,
mod_open()(src/fragroute/mod.c) accumulated a"<mod> -> <mod> -> ..."diagnostic of every parsed rule into aBUFSIZ(8192) local, then copied it into the caller's errbuf with an unboundedsprintf(); callers size errbuf atFRAGROUTE_ERRBUF_LEN(1024), so a rules file with a few hundred valid one-word directives overflowed a stack buffer in an ancestor frame (tcprewrite'smain()) by several kilobytes, before any packet was processed. The message was written only on the success path, where errbuf is never read, so the whole diagnostic has been removed; the remaining errbuf writes inmod_open()are now bounded withsnprintf(). Reported by tinyb0y - SECURITY: fix out-of-bounds write on an empty fragroute rules file (GHSA-p7xp-4gj2-x56c, CWE-787, CWE-191). The same removed diagnostic trimmed its trailing
" -> "withbuf[strlen(buf) - 4] = '\0'. A rules file that parsed cleanly but produced no rules (empty, or only comments and blank lines) leftbufempty, so the index underflowedsize_tand wrote before the start of the buffer. Found while reviewing GHSA-777w-9599-w8g4; fixed by the same removal - SECURITY: fix off-by-one heap buffer overflow in the fragroute ip_chaff module (GHSA-m655-53p4-6qm8, CWE-193, CWE-787).
ip_chaff_apply()(src/fragroute/mod_ip_chaff.c) calledrand_strset()with(pkt_end - pkt_ip_data + 1), writing one byte past the end of the allocationpkt_dup()had just made; the call precedes the subtype switch, so it ran for every ip_chaff invocation (dup, opt, or a numeric TTL) on any ordinary IP packet. Also fixed in the same path:pkt_dup()(src/fragroute/pkt.c) never initialized the duplicate'spkt_buf_size, which ip_chaff's opt subtype then passed toip_add_option()as a capacity bound, and its out-of-memory path freed the source packet — still linked in the caller's pktq — withfree()rather than releasing the half-built duplicate with the matchingbrel(). ip_chaff now also checkspkt_dup()for failure. Reported by tinyb0y - SECURITY: fix heap buffer overflow via negative fragment/segment size in the fragroute ip_frag and tcp_seg modules (GHSA-27v4-xhfx-g2rx, CWE-787, CWE-190). Both modules parsed their size argument with
strtol()and rejected only zero; ip_frag's "must be a multiple of 8" check also passed negative multiples (-8 % 8 == 0in C). A negative size then defeated the "does this fragment fit" guard (a signed comparison against a positiveptrdiff_t) and reachedmemcpy()as a huge implicitly-convertedsize_t, inip_frag_apply_ipv4(),ip_frag_apply_ipv6()andtcp_seg_apply(). Both modules now require 1..IP_LEN_MAX at rules-file parse time, which also rejectsstrtol()overflow truncating to a negative int. Reported by tinyb0y - fragroute: don't free a rule that is still linked into the rule list.
mod_open()'s cleanup freed its last-allocated rule unconditionally; this was harmless only because the removed success-path diagnostic left the variable NULL, and would otherwise have left a dangling entry formod_apply()to walk on every packet - tcpreplay: add
--loss, random percent packet loss simulation (#755, #1055; contributed by @dsseng) — each packet is independently dropped with the given 0-100 probability, useful for testing/debugging UDP-based software (e.g. realtime audio streaming) against non-ideal network conditions; final scale/naming and--helpwording per maintainer review - tcpreplay: add
--raw, a PF_INET/SOCK_RAW packet-injection backend (#465, originally attempted in #511) — lets replayed traffic pass through the local IP stack (routing, netfilter/iptables) instead of going straight onto the wire via PF_PACKET; trades off L2 fidelity (kernel builds its own Ethernet framing) and is IPv4-only for now - build: add CMake build support alongside autotools (#688)
- tcpreplay: add Linux io_uring packet injection support via
--io-uring(#954) - cmake: fail configure on stale pre-generated AutoOpts files in the source tree (#1020)
- tcpreplay: support raw IP (L3-only) interfaces such as WireGuard and tun (#988)
- tcpedit: fix
tcprewrite --dlt=rawproducing corrupt output (#1023) - tcprewrite: preserve nanosecond timestamp resolution (#621)
- netmap: fail cleanly on unconfigured interfaces reporting zero TX rings/slots (#113)
- libtcpreplay: install the replay engine as a static C library (#133)
- build: replace GNU autogen with a python3/asciidoctor-based generator (#895 phase 2)
- build: GNU autogen no longer required to build, generated files committed to git; fixes Debian Bug#1076243 (#895 phase 1)
- build: CMake dist tarballs now build standalone; fix two plugins missing from dist (#1037)
- cmake: fix configuration summary display and a LIBXDP false negative (#1039)
- common: fix
check_list()return type mismatch build warning - common: fix Linux TX_RING support, broken by a header/probe collision (#1043 #1044)
- libopts: fix out-of-bounds read in
--save-optshandling (#931)
Verifying the download
The .tar.gz/.tar.xz tarballs are accompanied by detached PGP signatures (.asc), signed with the tcpreplay release key (tcpreplay@appneta.com, fingerprint 84E4FA215C934A7D97DC76D5E9E2149793BDE17E):
gpg --verify tcpreplay-4.6.0.tar.xz.asc tcpreplay-4.6.0.tar.xz
Full Changelog: v4.5.5...v4.6.0
Download the release by clicking the tcpreplay* assets below ...