
Capture Packets in OpenWRT With eBPF: What to Know First
Start with tcpdump if you need a pcap file; use eBPF when you need kernel-side counters, filters, or custom events. For an openwrt packet capture on a small router, that split keeps the job honest: tcpdump gives Wireshark a standard capture, while eBPF trades full packet visibility for lower overhead and targeted telemetry. I build and flash custom firmware so I can run a practical OpenWRT eBPF packet capture on small routers without guessing. I focus on concrete steps: kernel config flags, compiling from source, and verifying support with bpftool.
Last updated: 2026-07-31
I explain three workable capture paths: quick XDP loads through iproute2 or xdp-tools, debugfs prints using bpf_trace_printk, and a user-space loader that polls perf stats. Each method trades CPU, RAM, and flash for observability. I call out common pitfalls: endianness, header paths in staging_dir, and architecture mismatches. You’ll also see how HTTP tracing hooks accept4/read/write/close push truncated payloads through perf buffers to limit overhead.
Key takeaways
- Use
tcpdumpfirst when you need a normal packet capture for Wireshark. - Use eBPF when you need targeted counters, XDP actions, or process-level socket tracing.
- Enable
CONFIG_BPFandCONFIG_BPF_SYSCALLbefore building the firmware. - Add
CONFIG_NET_CLS_BPFandCONFIG_NET_ACT_BPFwhen you need the traffic-control path. - Verify support with
bpftool prog list, interface counters, and the kernel log. - Keep payloads short. Full packet data costs memory, storage, and user-space processing.
- On small routers,
libbpfis the practical choice for a deployed loader. BCC is mainly for fast experiments. - A bubblewrap
EPERMfromPR_CAPBSET_DROPorPR_SET_NO_NEW_PRIVSusually points to sandbox or kernel policy, not broken BPF code.
What do you need before starting on OpenWRT?
Start by pinning the target hardware, firmware branch, and toolchain. OpenWrt builds are tied to the target architecture, kernel headers, feeds, and C library. If those drift apart, the compiler gives you errors that look unrelated until you compare the paths.
Match the target and subtarget to the router SoC. Back up the configuration before flashing, and keep a known-good image available. A custom kernel is useful only while the router still boots.
I confirm resources on both machines:
- The build host needs enough storage for the source tree, toolchain, staging files, and image artifacts.
- The router needs enough free flash for kernel modules and user-space tools.
- The router also needs enough RAM for BPF maps, perf buffers, and the loader.
- An attached storage device or remote capture host is safer than filling
/tmp.
For a broader setup sequence, use the guide on building the BPF toolchain on OpenWRT. Keep the source tree, SDK, and staging directory in one workspace. That avoids the usual missing-header chase.
Which OpenWrt version, toolchain, and router resources matter?
Use a supported OpenWrt release branch that matches your device. Do not copy a kernel object built for another target and hope the loader will forgive it. The architecture, kernel headers, compiler, and C library all need to agree.
I record the build identity before compiling:
git clone https://git.openwrt.org/openwrt/openwrt.git
cd openwrt
git rev-parse HEAD
make menuconfig
make defconfig
The commit output tells you which source tree produced the image. make defconfig resolves defaults before the expensive build starts.
For an existing SDK, export the staging path before compiling BPF objects:
export STAGING_DIR="$PWD/staging_dir"
Then point the compiler at the SDK's BPF headers rather than headers installed on the host. Host headers describe the host. That is not a small difference.
What build dependencies and SDK setup are required?
Install the normal OpenWrt build tools on a Linux host:
sudo apt update
sudo apt install build-essential git libncurses5-dev gawk gettext unzip file zlib1g-dev
The source tree supplies the OpenWrt build system. The SDK supplies target headers, libraries, and the cross-compiler. Use the SDK when you are building an out-of-tree loader or BPF object for an existing firmware target.
For production, I choose libbpf. It gives you a small C-based loader with direct control over maps, links, and polling. I use BCC for rapid prototypes only when the target can afford its runtime and language dependencies.
Run a verbose dry build before changing BPF code:
make defconfig
make -j"$(nproc)" V=s
The verbose output shows the actual compiler, include paths, linker flags, and target architecture. Save it. When a build fails later, the command line is more useful than the final error line.
How do you enable kernel support for eBPF?
Enable the BPF options in make menuconfig, then rebuild the kernel and modules. Do not assume a router with a recent-looking kernel already has the options you need. OpenWrt trims kernels to fit the target, and unused features are often left out.
For a basic eBPF build, enable:
CONFIG_BPF=y
CONFIG_BPF_SYSCALL=y
CONFIG_NET_CLS_BPF=m
CONFIG_NET_ACT_BPF=m
CONFIG_BPF enables the core BPF machinery. CONFIG_BPF_SYSCALL allows user space to load and inspect programs through the BPF system call. The NET_CLS and NET_ACT options support BPF through the traffic-control path.
XDP also depends on the network driver and attach mode. A program can pass the kernel checks and still fail to attach natively because the driver does not support that path. Generic XDP may work, but it costs more than driver-level attachment.
Use the OpenWrt eBPF kernel configuration guide when you need to compare the firmware configuration with the running device.
Which kernel options should you check first?
Check the running configuration instead of guessing from the firmware name. If the kernel exposes its configuration, run:
zgrep -E 'CONFIG_BPF|CONFIG_NET_CLS_BPF|CONFIG_NET_ACT_BPF' /proc/config.gz
If /proc/config.gz does not exist, inspect the configuration used by the build tree. The important part is that the running kernel and the BPF object agree.
Then install and run bpftool:
opkg update
opkg install bpftool
bpftool prog list
bpftool map list
bpftool prog list shows loaded programs, their types, and attachment details. bpftool map list shows whether the maps your loader expects actually exist. An empty list is not a failure by itself. It means nothing is loaded yet.
For a quick syscall check, trace the loader:
strace -f -e bpf,prctl,capset,execve ./loader
The bpf calls show where loading fails. The prctl and capset calls expose sandbox and capability problems that otherwise get reduced to a useless EPERM.
How do you build, flash, and confirm the firmware?
Build the image with verbose output:
make -j"$(nproc)" V=s
The firmware and packages appear below bin/targets. Select the file that matches the exact device profile. A similar filename is not good enough.
Flash through LuCI or sysupgrade, depending on the device and recovery path. Keep a serial console or recovery method available if the router supports one. Do not test an experimental kernel on the only router in the house.
After rebooting, confirm the kernel and tools before loading a program:
uname -a
which bpftool
bpftool prog list
ip -details link show
Then load a no-op program or attach a test program to the intended interface. Confirm the attachment with bpftool, ip -details link show, or xdp-loader status. A successful build is not proof that the running router can load the object.
What common build problems should you expect?
Most failures come from target mismatch, stale headers, or a loader linked against the wrong C library. The error message is usually accurate. It is the assumption behind the command that is wrong.
| Problem | What you see | What I check |
|---|---|---|
| Wrong image architecture | The router will not boot, or modules refuse to load | Target and subtarget under bin/targets |
| Missing staging headers | Out-of-tree BPF code fails to compile | STAGING_DIR and SDK include paths |
| Endianness mismatch | Counters or map values look wrong | Host-to-target serialization |
| Wrong C library | Attach calls fail with vague errors | Cross-compiler and target libc |
| Missing kernel option | Loader reports an unsupported operation | Running kernel configuration |
| Driver lacks native XDP | XDP attach fails in driver mode | Generic or supported attach mode |
Full /tmp | Capture or loader exits while writing | df -h and capture destination |
Do not fix a header problem by adding random host include directories. That can make the build pass while producing an object for the wrong kernel. Compare the compiler command and the staging path first.
How do you run an OpenWRT packet capture with eBPF?

Use tcpdump when the deliverable is a pcap. Use eBPF when the deliverable is a decision, such as packet counters, early drops, socket events, or a narrow protocol signal. An eBPF packet capture is not automatically better because it uses newer kernel features.
Start with tcpdump when you need packets
tcpdump uses libpcap, so install the matching package from the OpenWrt feed:
opkg update
opkg list | grep '^tcpdump'
opkg install tcpdump
A full build has more protocol and link-layer support. A smaller build such as tcpdump-mini uses fewer resources but may omit protocol decoders or filter features. Check what is installed instead of assuming the package variant.
List capture interfaces:
tcpdump -D
Then capture on the interface that actually carries the traffic:
tcpdump -ni br-lan -s 0 -w /tmp/capture.pcap 'host 192.168.1.10'
-D tells you which interfaces libpcap can see. -n avoids reverse DNS lookups. -i selects the interface. -s 0 keeps the full packet where the driver and memory allow it. The filter limits the capture before it reaches the output file.
For a live Wireshark session, stream the pcap over SSH instead of filling router storage:
ssh root@router 'tcpdump -ni br-lan -s 0 -w - "host 192.168.1.10"' \
| wireshark -k -i -
This is usually better than writing to /tmp, which is commonly backed by RAM. If the command runs for a long time, save the capture on the workstation or attached storage.
Use filters that match the question
Choose the interface first, then add the smallest filter that answers the question.
tcpdump -ni br-lan 'tcp port 80 or tcp port 443'
tcpdump -ni br-lan 'ip6'
tcpdump -ni br-lan 'ether host aa:bb:cc:dd:ee:ff'
tcpdump -ni br-lan 'net 192.168.1.0/24'
If a filter fails on a minimal build, capture without the filter and reduce the output later. A missing protocol parser is a package limitation, not a network fault.
rpcapd can provide remote capture through the rpcap protocol, but I do not make it the default. It adds another daemon, another listener, and another authentication problem. An SSH pipe is easier to inspect and leaves less exposed on the router.
Use XDP when you need early packet events
For fast attachment, use iproute2:
ip link set dev eth0 xdp obj prog.o sec xdp_pass
bpftool prog list
ip -details link show dev eth0
The first command loads the object and attaches the xdp_pass section. The next two commands confirm that the program exists and that the interface reports an XDP attachment.
With xdp-tools, use the loader and status commands:
xdp-loader load -d eth0 prog.o
xdp-loader status
xdp-loader status tells you whether the program is attached and which mode the tool selected. If native driver attachment fails, test a generic mode supported by that installed version. Generic mode is useful for diagnosis, but it is not free.
XDP is good for counters, early filtering, and packet decisions. It is a poor choice when you need a complete pcap with every protocol detail. For that job, keep tcpdump.
Use debugfs when the router has little user space
For a small device, add a controlled bpf_trace_printk call and read the trace buffer:
mount -t debugfs debugfs /sys/kernel/debug
cat /sys/kernel/debug/tracing/trace
This path needs no full user-space loader. It is useful for proving that a hook runs and that a field has the value you expect.
It is also easy to abuse. Do not print once per packet during a busy capture. Rate-limit the event, print only the fields needed to prove the path, and remove the debug call after the test. The trace buffer is a debugging aid, not a packet transport.
Use a user-space loader for live counters
A small loader gives you control over attachment, maps, event polling, and shutdown. I keep the design narrow:
- Open the object compiled for the target kernel.
- Load the program and maps.
- Attach to XDP, traffic control, or the chosen trace hook.
- Poll counters or perf buffers.
- Print or export only the fields needed by the monitor.
- Detach cleanly when the process exits.
The loader reads counters such as rx_packets and rx_bytes, then computes packets per second from timestamps. It can pin maps so a restart does not discard state, provided the map layout remains compatible.
Handle endianness and map sizes explicitly. A loader that works on the build host can still report nonsense on the router if it reads target data with host assumptions.
| Method | Use it for | Main cost |
|---|---|---|
tcpdump and libpcap | Full packet capture and Wireshark analysis | Storage and packet-copy overhead |
ip link XDP attach | Fast tests and early packet handling | Limited user-space visibility |
xdp-loader | Managed XDP attachment and status | Extra tool and mode differences |
bpf_trace_printk | Quick on-device debugging | Verbose trace output |
libbpf loader | Live counters, maps, and perf events | More code and RAM |
How do HTTP hooks and perf buffers fit into packet capture?
Trace socket syscalls when you need process-level HTTP flow data, not wire-level packet bytes. Hook accept4, read, write, and close with entry and exit probes.
On entry, store the arguments in a map keyed by process ID and file descriptor. On return, read the saved context, check the return value, and record the result. This avoids losing the original pointer or descriptor while the syscall runs.
A syscall hook sees what a process reads or writes. It does not preserve packet boundaries, and HTTPS payloads are encrypted unless you trace after decryption inside the process. That distinction matters. Calling this a packet capture does not make it one.
Why should payloads go through perf buffers?
Send small, structured events through perf buffers rather than copying arbitrary payloads into every event. I split the data into separate event types:
socket_openfor connection metadata.socket_datafor bounded payload fragments.- Close events for cleanup and final status.
Each record can carry the total size, offset, direction, process ID, file descriptor, and timestamp. Set a fixed payload limit and reconstruct larger messages in user space from several events.
The limit protects the router from a single large write. It also keeps the user-space parser predictable. The real problem is not only CPU use. It is the combined cost of map writes, perf-buffer copies, event polling, and output formatting.
How do XDP counters expose useful traffic information?
Keep the XDP record small:
struct xdp_stats {
__u64 rx_packets;
__u64 rx_bytes;
__u64 last_ts;
};
Update the record in the XDP path, then poll it from user space. Compute packet and byte rates from the timestamp rather than treating one read as a complete measurement.
Use bpftool map dump when you need to inspect values without the loader:
bpftool map list
bpftool map dump id MAP_ID
The map output tells you whether the program is updating state. If the counters stay at zero, check the interface, attachment mode, map lookup path, and packet direction before changing the arithmetic.
For a reusable monitor, the traffic monitoring with eBPF guide covers the same map-and-counter pattern in more detail.
Can kernel tools detect wireless interference and fading?

Use kernel counters to confirm traffic behavior, but do not expect XDP to provide radio measurements. The Linux XDP path does not expose signal-to-noise ratio or signal strength. Driver statistics and wireless tools provide that information.
For co-channel interference, run iperf3 while bringing up an overlapping access point. Log packet and byte counters, retransmits, and throughput over time. A falling rate with rising retransmits is useful evidence, but it does not tell you which radio condition caused it.
For fading, change distance and add physical obstructions. Record the same counters while the link changes. Keep the test repeatable, or you will confuse interference, rate control, and ordinary client movement.
Why does bubblewrap return EPERM from prctl_caps?
A bubblewrap failure mentioning prctl_caps, PR_CAPBSET_DROP, or PR_SET_NO_NEW_PRIVS usually means the sandbox cannot apply one of its requested process restrictions. That is separate from whether the kernel can load an eBPF program.
PR_CAPBSET_DROP removes a capability from the process capability bounding set. PR_SET_NO_NEW_PRIVS prevents the process from gaining new privileges across an execve. Both can return EPERM when the process lacks the required privilege or the kernel was built without the needed namespace or security support.
Trace the failing call:
strace -f -e prctl,capset,execve bwrap ...
Then check the kernel log:
dmesg -T | tail -50
logread | tail -50
If the failure occurs before your loader starts, do not change BPF programs yet. Check whether the firmware includes the kernel features bubblewrap expects, whether the process is already inside a restricted container, and whether the launcher is trying to create a user namespace.
Do not “fix” this with a broad capability grant or a permissive file mode. That hides the policy failure and weakens the device. Run the loader without bubblewrap for a controlled test, or rebuild the sandbox assumptions for the target kernel.
What should you do next for reliability and performance?
Keep the first test small. Load one program, attach one interface, update one map, and inspect the output. A large multi-hook build gives you too many places to blame when nothing works.
I use this order:
- Confirm the interface with
ip -br linkandip -br addr. - Capture a small sample with
tcpdump. - Load a no-op BPF program.
- Confirm the program with
bpftool prog list. - Add one counter or event.
- Reproduce the traffic.
- Compare the counter with
tcpdumpoutput. - Only then add payload handling or multiple hooks.
Use journalctl on a systemd-based build, but use logread and dmesg on the usual OpenWrt image. Always add a time window or reproduce the fault while following the log. Old messages are how you end up debugging a failure from the previous boot.
For production loaders, prefer libbpf and keep the binary small. Use BCC when you need to answer a question quickly and the device can support it. Do not ship a Python-heavy prototype to a router with limited flash and RAM because the first test was convenient.
FAQ
Can eBPF replace tcpdump on OpenWrt?
No. eBPF can count, filter, classify, and trace selected events, but it does not automatically produce the full pcap workflow that Wireshark expects. Use tcpdump for packet files and eBPF for targeted kernel telemetry.
Can an XDP program see traffic on every OpenWrt interface?
No. XDP attaches to a specific network interface and depends on how that interface is implemented. Check the physical interface, bridge layout, VLAN path, and driver support before assuming traffic will reach the hook.
Why do my eBPF counters disagree with tcpdump?
They may run at different points in the stack. XDP sees packets early, while tcpdump uses a packet-socket path later in processing. VLAN handling, dropped packets, cloned traffic, and direction can all change the totals.
Should I store captured payloads in a BPF map?
Usually not. Maps are good for state and counters, not an unbounded packet queue. Emit bounded records through a perf buffer or ring buffer, then write the data from user space where you can control storage and backpressure.
Related: A Practical Linux Tcpdump Workflow for Packet Capture
