Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/ccpp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
os: macos-15
compiler: clang++
build_type: Release
testing: 'OFF'
testing: 'ON'
env:
CXX: ${{ matrix.compiler }}
BUILD_TYPE: ${{ matrix.build_type }}
Expand Down Expand Up @@ -65,7 +65,6 @@ jobs:
- name: Build
run: cmake --build build --config "$BUILD_TYPE" --parallel 2
- name: Test
if: runner.os == 'Linux'
run: ctest --test-dir build -C "$BUILD_TYPE" --output-on-failure
- name: Install
run: cmake --install build --config "$BUILD_TYPE" --prefix "$RUNNER_TEMP/udpreplay-install"
Expand Down
9 changes: 8 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
set(_testing_default ON)
endif()
endif()
option(BUILD_TESTING "Enable Linux timing tests (requires Expect)" ${_testing_default})
option(BUILD_TESTING "Enable packet tests and Linux timing tests" ${_testing_default})

# Homebrew's keg-only libpcap is not on the default compiler search path.
set(_pcap_prefixes)
Expand Down Expand Up @@ -53,6 +53,13 @@ include(GNUInstallDirs)
install(TARGETS udpreplay RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}")

include(CTest)
if(BUILD_TESTING)
find_package(Python3 COMPONENTS Interpreter REQUIRED)
add_test(NAME packet-validation
COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/test/packet-validation.py"
"$<TARGET_FILE:udpreplay>")
set_tests_properties(packet-validation PROPERTIES TIMEOUT 30)
endif()
if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL "Linux")
find_program(EXPECT_EXECUTABLE NAMES expect REQUIRED)
find_program(TIME_EXECUTABLE NAMES time REQUIRED)
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ usage: udpreplay [-i iface] [-l] [-s speed] [-c millisec] [-r repeat] [-t ttl] p
$ udpreplay -i eth0 example.pcap
```

IPv4 fragments and invalid packet lengths stop replay with an error.
Reassemble fragmented captures first, for example with
[IPDefragUtil](https://github.com/seladb/PcapPlusPlus/tree/master/Examples/IPDefragUtil):

```sh
IPDefragUtil input.pcap -o reassembled.pcap -a
udpreplay reassembled.pcap
```

## Building & Installing

Requires Linux or macOS, CMake 3.20+, a C++11 compiler, and libpcap headers
Expand All @@ -44,6 +53,8 @@ location, add `-DCMAKE_PREFIX_PATH=/path/to/libpcap` when configuring.
Linux timing tests are enabled by default when Expect is installed. Run them
with `ctest --test-dir build --output-on-failure`, or disable them with
`-DBUILD_TESTING=OFF` when configuring.
Tests also require Python 3 for packet validation; on macOS, enable them with
`-DBUILD_TESTING=ON`.

## About

Expand Down
96 changes: 71 additions & 25 deletions src/udpreplay.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,20 @@ int main(int argc, char *argv[]) {
return 1;
}

auto packet_error = [&](const char *message) {
std::cerr << message << std::endl;
pcap_close(handle);
return 1;
};

timespec start = {-1, -1};
timespec pcap_start = {-1, -1};

pcap_pkthdr header;
pcap_pkthdr *packet_header;
const u_char *p;
while ((p = pcap_next(handle, &header))) {
int packet_status;
while ((packet_status = pcap_next_ex(handle, &packet_header, &p)) == 1) {
const auto &header = *packet_header;
if (start.tv_nsec == -1) {
if (clock_gettime(CLOCK_MONOTONIC, &start) == -1) {
std::cerr << "clock_gettime: " << strerror(errno) << std::endl;
Expand All @@ -162,27 +170,68 @@ int main(int argc, char *argv[]) {
header.ts.tv_usec; // Note PCAP_TSTAMP_PRECISION_NANO
}
if (header.len != header.caplen) {
continue;
return packet_error("Invalid packet: captured and original lengths differ");
}
auto eth = reinterpret_cast<const ether_header *>(p);
if (header.caplen < sizeof(ether_header)) {
return packet_error("Invalid packet: truncated Ethernet header");
}
ether_header eth;
memcpy(&eth, p, sizeof(eth));
size_t offset = sizeof(eth);
auto protocol = ntohs(eth.ether_type);

// jump over and ignore vlan tags
while (ntohs(eth->ether_type) == ETHERTYPE_VLAN) {
p += 4;
eth = reinterpret_cast<const ether_header *>(p);
// Jump over VLAN tags, checking each tag before reading its protocol.
while (protocol == ETHERTYPE_VLAN) {
if (header.caplen - offset < 4) {
return packet_error("Invalid packet: truncated VLAN header");
}
uint16_t encapsulated_protocol;
memcpy(&encapsulated_protocol, p + offset + 2,
sizeof(encapsulated_protocol));
protocol = ntohs(encapsulated_protocol);
offset += 4;
}
if (ntohs(eth->ether_type) != ETHERTYPE_IP) {
if (protocol != ETHERTYPE_IP) {
continue;
}
auto ip = reinterpret_cast<const struct ip *>(p + sizeof(ether_header));
if (ip->ip_v != 4) {
if (header.caplen - offset < sizeof(struct ip)) {
return packet_error("Invalid packet: truncated IPv4 header");
}
// Ethernet payloads need not be aligned for native IP/UDP structs.
struct ip ip;
memcpy(&ip, p + offset, sizeof(ip));
if (ip.ip_v != 4) {
continue;
}
if (ip->ip_p != IPPROTO_UDP) {
const size_t ip_header_length = ip.ip_hl * 4;
const size_t ip_length = ntohs(ip.ip_len);
if (ip_header_length < sizeof(ip) ||
ip_header_length > header.caplen - offset ||
ip_length < ip_header_length || ip_length > header.caplen - offset) {
return packet_error("Invalid packet: invalid IPv4 length");
}
if (ip.ip_p != IPPROTO_UDP) {
continue;
}
auto udp = reinterpret_cast<const udphdr *>(p + sizeof(ether_header) +
ip->ip_hl * 4);
if (ntohs(ip.ip_off) & (IP_MF | IP_OFFMASK)) {
return packet_error("IPv4 fragments are not supported; reassemble the "
"capture before replay");
}
if (ip_length - ip_header_length < sizeof(udphdr)) {
return packet_error("Invalid packet: truncated UDP header");
}
udphdr udp;
memcpy(&udp, p + offset + ip_header_length, sizeof(udp));
#ifdef __GLIBC__
const size_t udp_length = ntohs(udp.len);
#else
const size_t udp_length = ntohs(udp.uh_ulen);
#endif
if (udp_length < sizeof(udp) || udp_length > ip_length - ip_header_length) {
return packet_error("Invalid packet: invalid UDP length");
}
const ssize_t len = udp_length - sizeof(udp);
const u_char *d = p + offset + ip_header_length + sizeof(udp);
if (interval != -1) {
// Use constant packet rate
deadline.tv_sec += interval / 1000L;
Expand Down Expand Up @@ -236,23 +285,15 @@ int main(int argc, char *argv[]) {
#endif
}

#ifdef __GLIBC__
ssize_t len = ntohs(udp->len) - 8;
#else
ssize_t len = ntohs(udp->uh_ulen) - 8;
#endif
const u_char *d =
&p[sizeof(ether_header) + ip->ip_hl * 4 + sizeof(udphdr)];

sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
#ifdef __GLIBC__
addr.sin_port = udp->dest;
addr.sin_port = udp.dest;
#else
addr.sin_port = udp->uh_dport;
addr.sin_port = udp.uh_dport;
#endif
addr.sin_addr = {ip->ip_dst};
addr.sin_addr = ip.ip_dst;
auto n = sendto(fd, d, len, 0, reinterpret_cast<sockaddr *>(&addr),
sizeof(addr));
if (n != len) {
Expand All @@ -261,6 +302,11 @@ int main(int argc, char *argv[]) {
}
}

if (packet_status == -1) {
std::cerr << "pcap_read: " << pcap_geterr(handle) << std::endl;
pcap_close(handle);
return 1;
}
pcap_close(handle);
}

Expand Down
136 changes: 136 additions & 0 deletions test/packet-validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT

import socket
import struct
import subprocess
import sys
import tempfile
from pathlib import Path


def checksum(data):
value = sum(struct.unpack('!' + 'H' * (len(data) // 2), data))
while value >> 16:
value = (value & 65535) + (value >> 16)
return ~value & 65535


def ipv4(data, *, ihl=5, length=None, flags=0, options=b'', protocol=17):
header = struct.pack('!BBHHHBBH4s4s', 0x40 | ihl, 0,
20 + len(options) + len(data) if length is None else length,
123, flags, 64, protocol, 0,
socket.inet_aton('127.0.0.1'), socket.inet_aton('127.0.0.1'))
header += options
return header[:10] + struct.pack('!H', checksum(header)) + header[12:] + data


def ethernet(data, tags=0, protocol=0x0800):
return (bytes(12) + struct.pack('!H', 0x8100 if tags else protocol)
+ b''.join(struct.pack('!HH', 1, 0x8100 if i + 1 < tags else protocol)
for i in range(tags)) + data)


def capture(packet, *, caplen=None, wirelen=None):
caplen = len(packet) if caplen is None else caplen
wirelen = caplen if wirelen is None else wirelen
return (struct.pack('<IHHIIII', 0xa1b2c3d4, 2, 4, 0, 0, max(caplen, 1), 1)
+ struct.pack('<IIII', 0, 0, caplen, wirelen) + packet)


def main():
binary = str(Path(sys.argv[1]).resolve())
with tempfile.TemporaryDirectory() as directory, socket.socket(
socket.AF_INET, socket.SOCK_DGRAM) as receiver:
receiver.bind(('127.0.0.1', 0))
port = receiver.getsockname()[1]

def udp(payload=b'packet-validation', length=None):
return struct.pack('!HHHH', 12345, port,
len(payload) + 8 if length is None else length, 0) + payload

def check(name, contents, payload=None, error=None, allow_size_limit=False):
path = Path(directory) / (name + '.pcap')
path.write_bytes(contents)
result = subprocess.run([binary, '-c', '0', str(path)],
capture_output=True, text=True, timeout=5)
size_limited = (allow_size_limit and sys.platform == 'darwin'
and result.returncode == 1
and result.stderr.strip() == 'sendto: Message too long')
if size_limited:
# macOS commonly limits outgoing UDP datagrams below 65,507
# bytes. The parser must still accept this valid packet and
# reach sendto, which reports the OS limit.
payload = None
elif error:
assert result.returncode == 1, (name, result)
assert error in result.stderr, (name, result.stderr)
assert 'AddressSanitizer' not in result.stderr, (name, result.stderr)
assert 'runtime error:' not in result.stderr, (name, result.stderr)
else:
assert result.returncode == 0, (name, result.stderr)
receiver.settimeout(0.02 if payload is None else 2)
try:
received = receiver.recv(65535)
except TimeoutError:
received = None
assert received == payload, (name, None if received is None else len(received))
print(name + (': PASS (OS datagram limit)' if size_limited else ': PASS'))

for name, payload, tags, options, flags in [
('ordinary', b'packet-validation', 0, b'', 0),
('vlan', b'vlan', 1, b'', 0),
('double-vlan', b'double-vlan', 2, b'', 0),
('ip-options', b'options', 0, b'\x01' * 4, 0),
('empty-udp', b'', 0, b'', 0),
('large-udp', b'A' * 3000, 0, b'', 0),
('maximum-udp', b'A' * 65507, 0, b'', 0),
('dont-fragment', b'DF', 0, b'', 0x4000),
]:
packet = ethernet(ipv4(udp(payload), ihl=5 + len(options) // 4,
options=options, flags=flags), tags)
check(name, capture(packet), payload=payload,
allow_size_limit=name == 'maximum-udp')
check('ethernet-padding', capture(ethernet(ipv4(udp(b'padding'))) + bytes(30)),
payload=b'padding')

for name, flags in [('first-fragment', 0x2000),
('intermediate-fragment', 0x2000 | 185),
('final-fragment', 185), ('offset-only', 1)]:
check(name, capture(ethernet(ipv4(udp(), flags=flags))),
error='IPv4 fragments are not supported')
# Reproduce issue #36's first fragment and full-datagram UDP length.
check('mtu-fragment', capture(ethernet(ipv4(udp(b'A' * 3000)[:1480], flags=0x2000))),
error='IPv4 fragments are not supported')

invalid = [
('empty-frame', b''),
('short-ethernet', bytes(13)),
('short-vlan', bytes(12) + b'\x81\x00' + bytes(3)),
('short-inner-vlan', bytes(12) + b'\x81\x00\x00\x01\x81\x00'),
('short-ip', ethernet(bytes(19))),
('small-ihl', ethernet(ipv4(udp(), ihl=4))),
('large-ihl', ethernet(ipv4(udp(), ihl=15))),
('short-options', ethernet(ipv4(b'', ihl=6))),
('small-ip-length', ethernet(ipv4(udp(), length=19))),
('oversized-ip-length', ethernet(ipv4(udp(), length=65535))),
('short-udp-header', ethernet(ipv4(bytes(7)))),
('zero-udp-length', ethernet(ipv4(udp(length=0)))),
('small-udp-length', ethernet(ipv4(udp(length=7)))),
('oversized-udp-length', ethernet(ipv4(udp(length=65535)))),
# Available captured bytes must not override a shorter IP length.
('udp-past-ip-length', ethernet(ipv4(udp(), length=28))),
]
for name, packet in invalid:
check(name, capture(packet), error='Invalid packet:')
check('snaplen-truncation', capture(ethernet(ipv4(udp())), wirelen=1000),
error='captured and original lengths differ')
check('invalid-record-length', capture(ethernet(ipv4(udp())), wirelen=1),
error='captured and original lengths differ')
check('truncated-record', capture(bytes(5), caplen=14), error='pcap_read:')
check('non-ip', capture(ethernet(bytes(28), protocol=0x0806)))
check('non-udp', capture(ethernet(ipv4(bytes(20), protocol=6))))


if __name__ == '__main__':
main()
Loading