Breaking Code · 02

How Wireshark sees it?

What really happens between a packet hitting your network card and one dissected row showing up on screen.

10 min read
Breaking Code — a Revibe series

Type this in your terminal:

tshark -i en0

Or open Wireshark, double-click your Wi-Fi interface, and watch the packet list start filling up. Either way, within a fraction of a second, rows begin scrolling past — each one a TCP segment or a DNS query or a TLS handshake, fully labelled, source and destination already resolved.

It feels like the program is just "listening to the wire." It isn't. Between an electrical pulse on a Cat6 cable and a row of text on your screen, there's a remarkable amount of careful work. The kernel scoops the packet off the wire. A small, separately-privileged program catches it. It writes the packet to a pipe. A second program reads the pipe, hands the bytes to a chain of protocol parsers, and each parser walks the buffer with bounds-checked accessors so a hostile packet can't crash the analyser.

This is the walk — seven stops, in order, with no skipping ahead.


1. The packet arrives

The journey starts before Wireshark is even involved. Your network card — the chip on your laptop's motherboard or the radio in your Wi-Fi adapter — receives an electromagnetic signal, decodes it into bytes, and uses DMA (direct memory access) to drop those bytes into a region of system memory called a ring buffer. The kernel manages that buffer; user-space programs can't reach the card directly.

sequenceDiagram participant Wire as Cable / radio participant NIC as Network card participant Kernel as Kernel ring buffer participant BPF as BPF / AF_PACKET socket Wire->>NIC: electrical / RF signal NIC->>NIC: decode frame, checksum NIC->>Kernel: DMA write to ring buffer Kernel->>Kernel: dispatch to protocol stack Kernel-->>BPF: also clone to capture taps

The packet is already in RAM before any user-space program knows it exists.

Normally the kernel just routes the packet up the TCP/IP stack to whichever application is listening. But the kernel also exposes a second tap: a way for a program to ask, "send me a copy of every packet that arrives on this interface, not just the ones addressed to me." On Linux this is called AF_PACKET; on macOS and BSDs it's the BPF device (/dev/bpf*); on Windows it's a kernel driver called Npcap. They differ in detail but do the same job — clone every frame into a queue a user-space program can read.

Wireshark doesn't talk to any of these directly. It uses a library called libpcap (Windows: Npcap) that hides all three behind one tidy API. Open a handle, set a filter, ask for packets. The library figures out which kernel mechanism to use.

This is also where the first decision gets made: promiscuous mode. By default a network card ignores frames that aren't addressed to it. Putting the card into promiscuous mode tells it to keep everything it can hear, addressed or not. On a switched network that doesn't get you much — the switch already filters traffic per port — but on Wi-Fi it gets you everyone else's traffic, which is exactly why doing this requires elevated privileges.


2. Why the catcher runs alone

Capturing every packet on a network interface requires root on Linux/macOS and Administrator on Windows. There's no way around that — the kernel won't hand raw frames to an unprivileged process. So one of the very first decisions in Wireshark's design was: do we run the whole 1.5-million-line GUI as root, or do we split the privileged bit out?

The split bit is a separate binary called dumpcap. It's tiny by Wireshark standards: one C file, around 6,000 lines, and it does exactly one job — pull packets out of libpcap and write them to a file or a pipe. The main GUI runs as you, the regular user. When you click Start, it shells out to dumpcap, which is the process that actually elevates and touches the network.

sequenceDiagram participant User as You (regular user) participant WS as Wireshark GUI participant DC as dumpcap (privileged) participant PCAP as libpcap / Npcap participant K as Kernel User->>WS: click Start on en0 WS->>DC: fork + exec, request capture DC->>PCAP: pcap_create("en0") PCAP->>K: open BPF / AF_PACKET socket K-->>PCAP: file descriptor DC->>WS: pipe ready, streaming Note over WS,DC: GUI runs as user, dumpcap as root

Privilege separation: the smallest possible binary holds the dangerous power.

This is a security pattern called privilege separation, and the reason it matters is harsh. Wireshark's protocol dissectors — thousands of them — handle untrusted input that comes from anywhere on the internet. A bug in the QUIC dissector or the SMB parser, given a malicious packet, could in principle be coaxed into executing attacker code. If Wireshark ran as root, that attacker would own the machine. With dumpcap separated out, even a worst-case dissector exploit only gets attacker code running as the user, not root.

The tradeoff is an extra process and an extra pipe, but the security calculus is so favourable that every modern protocol analyser on every platform now does it this way.


3. The pump

Once dumpcap has a libpcap handle, the actual capture is — honestly — not very dramatic. It's a loop. Wait until the kernel says there's a packet. Pull as many packets as are ready. Do something with each. Go back to waiting.

The wait happens with select(), the classic Unix call that blocks until a file descriptor has data. The pull happens with pcap_dispatch(), libpcap's "give me up to N packets, calling this function on each" primitive. The do-something is a function you hand to libpcap as a callback.

Strip away the platform #ifdefs and the error handling, and the heart of dumpcap is this:

// dumpcap.c (simplified)
while (ld->go) {
    sel_ret = cap_pipe_select(pcap_src->pcap_fd);

    if (sel_ret > 0) {
        // kernel says packets are ready
        inpkts = pcap_dispatch(
            pcap_src->pcap_h,
            -1,                              // -1 = drain everything queued
            capture_loop_write_packet_cb,    // called once per packet
            (uint8_t *)pcap_src);

        if (inpkts < 0)
            ld->go = false;                  // libpcap signalled stop
    }
}
The whole capture engine, in about a dozen lines.

Three details in this loop are worth pointing out, because they shape everything that comes later.

pcap_dispatch is the moment dumpcap asks the kernel (via libpcap) for the packets that have piled up. The kernel answers in batches, not one at a time, which matters a lot when you're capturing on a busy interface — one system call can deliver dozens of frames. On modern Linux libpcap uses memory-mapped capture (PACKET_MMAP), where the ring buffer is mapped straight into user space, and "delivery" is just walking a list of pointers.

The -1 argument tells libpcap to drain every packet currently queued before returning. On Windows, where there's no way to interrupt a capture mid-call, dumpcap asks for one packet at a time instead, so it can check for a stop signal between every packet. Same loop, different fairness tradeoff.

The callback, capture_loop_write_packet_cb, is what fires per packet. And the moment it fires, the packet has officially crossed from the kernel into Wireshark's world.


4. Out the pipe

Inside capture_loop_write_packet_cb, dumpcap takes the raw bytes and the timestamp that libpcap handed it, wraps them in a pcapng block, and writes the block somewhere. "Somewhere" is usually one of two things: a real file on disk (if you asked for a file) or a pipe back to the parent Wireshark GUI (if you didn't).

// dumpcap.c (simplified)
static void
capture_loop_write_packet_cb(uint8_t *pcap_src_p,
                             const struct pcap_pkthdr *phdr,
                             const uint8_t *pd)
{
    capture_src *pcap_src = (capture_src *)pcap_src_p;

    if (!global_ld.go) return;       // stop requested, drop this packet

    if (global_capture_opts.use_pcapng) {
        successful = pcapng_write_enhanced_packet_block(
            global_ld.pdh,
            NULL,
            phdr->ts.tv_sec, (int32_t)phdr->ts.tv_usec,
            phdr->caplen, phdr->len,
            pcap_src->idb_id,
            ts_mul, pd, 0,
            &global_ld.bytes_written, &err);
    } else {
        successful = libpcap_write_packet(...);
    }
}
One packet, wrapped and written. Called once per frame.

pcapng is the modern capture file format — "pcap next generation" — and it's what makes a capture portable. The block contains the packet bytes, the interface they came from, a high-resolution timestamp, and slots for comments and metadata. Open that file on a different machine ten years from now and Wireshark will read it back identically.

The pipe back to the GUI uses the same format. The GUI reads pcapng blocks off the pipe as fast as dumpcap writes them, and feeds each one into its own analysis engine. From here on, dumpcap is no longer in the story — the bytes are in Wireshark proper.


5. The dissection chain

Once the GUI has the raw packet bytes, it asks a subsystem called Epan — the "Ethernet Packet Analyzer," though by now it dissects far more than Ethernet — to figure out what's actually inside the packet. The result is the tree you see in the bottom pane of Wireshark: Frame → Ethernet → IPv4 → TCP → TLS → HTTP, expandable layer by layer.

That tree isn't computed by one big function that knows all the protocols. It's computed by a chain of small functions, each of which knows exactly one protocol, and each of which hands control to the next based on what it found.

flowchart LR A[Raw bytes] --> B[dissect_frame] B --> C[dissect_ethernet
reads dst, src, type] C --> D{type field} D -->|0x0800| E[dissect_ip] D -->|0x86dd| F[dissect_ipv6] E --> G{protocol = 6} G -->|TCP| H[dissect_tcp] H --> I[dissect_tls
port 443] I --> J[dissect_http2]

Each dissector is a function pointer. The chain is built at runtime, packet by packet.

The mechanism is a registry: when Wireshark starts up, every protocol registers itself with a key (an Ethernet type, a port number, a magic byte sequence). At dissection time, the current dissector looks at a field, finds the next dissector keyed off that value, and hands over. It's one function call:

// epan/packet.c
int
call_dissector_only(dissector_handle_t handle, tvbuff_t *tvb,
                    packet_info *pinfo, proto_tree *tree, void *data)
{
    int ret;

    DISSECTOR_ASSERT(handle != NULL);
    ret = call_dissector_work(handle, tvb, pinfo, tree, true, data);
    return ret;
}
Every protocol layer you see in the dissection tree passes through this.

The four arguments tell you everything about how the system is wired. handle is the next dissector in the chain — a pointer to a function. tvb is the packet bytes (more on this in a moment). pinfo is the per-packet context everyone shares: source, destination, the dissection tree so far, the layer counter. tree is the node in the protocol tree this dissector is allowed to add children to.

The beauty of the design is that adding support for a new protocol — say, a fresh LLM inference protocol someone defines next week — is just writing one function and registering it under the right port. No core code needs to change. The two thousand-plus dissectors that ship with Wireshark are all just plugins into this one registry.


6. The safety net

Dissecting an arbitrary packet from the open internet is dangerous work. The packet might be malformed, might be deliberately crafted to crash you, might claim a length field of two billion bytes when only forty are present. A naive dissector that just read bytes off the buffer would be a goldmine of buffer-overflow vulnerabilities.

Wireshark solves this by never letting dissectors read the buffer directly. Every access goes through a wrapper called a tvbuff — a "tapped virtual buffer." You don't write buf[offset]; you write tvb_get_uint8(tvb, offset), and the function bounds-checks the offset before returning the byte.

// epan/tvbuff.c (simplified)
static const uint8_t *
fast_ensure_contiguous(tvbuff_t *tvb, unsigned offset, unsigned length)
{
    unsigned end_offset;

    if (ckd_add(&end_offset, offset, length))   // overflow check
        THROW(BoundsError);

    if (G_LIKELY(end_offset <= tvb->length)) {
        return tvb->real_data + offset;          // happy path
    } else if (end_offset <= tvb->contained_length) {
        THROW(BoundsError);
    } else if (tvb->flags & TVBUFF_FRAGMENT) {
        THROW(FragmentBoundsError);
    } else if (end_offset <= tvb->reported_length) {
        THROW(ContainedBoundsError);
    } else {
        THROW(ReportedBoundsError);
    }
}
The bouncer at every byte access in every dissector.

Two pieces of cleverness make this both safe and fast. The ckd_add macro catches the case where offset + length overflows a 32-bit integer — an attacker's first move against any bounds check. And the four different exceptions don't crash the process; they jump out of the dissector via Wireshark's own setjmp-based exception system, get caught a few frames up, and the packet ends up labelled "Malformed Packet" in the GUI. The capture continues. The next packet gets a fresh tvbuff and starts over.

This is the unglamorous machinery that lets Wireshark run, with its thousands of protocol parsers written by hundreds of contributors over twenty-five years, against actual hostile traffic without becoming a remote code execution vector.


7. Memory in pools

Dissecting a single packet allocates a lot of small things: tree nodes, string copies, intermediate buffers. Multiply that by a million packets and a naive malloc/free for each one would crush performance, fragment the heap, and almost certainly leak somewhere in those thousands of dissectors.

Wireshark's answer is a custom allocator called wmem. The trick is scope: every allocation belongs to a pool (called an allocator), and the pool itself can be freed in one shot when the scope ends.

// wsutil/wmem/wmem_core.c
void *
wmem_alloc(wmem_allocator_t *allocator, const size_t size)
{
    if (allocator == NULL)
        return g_malloc(size);

    ws_assert(allocator->in_scope);
    if (size == 0) return NULL;

    return allocator->walloc(allocator->private_data, size);
}

void
wmem_free_all(wmem_allocator_t *allocator)
{
    wmem_free_all_real(allocator, false);
}
Allocate into a pool. Free the whole pool at once when you're done.

Two scopes do most of the work. The packet scope lives only as long as one packet's dissection — the moment that packet is done, wmem_free_all is called and every string copy, tree node, and temporary buffer the dissectors allocated vanishes in one constant-time operation. The file scope lasts as long as the capture file is open and holds the per-packet metadata the GUI needs to keep showing rows.

Dissector authors don't ever call free. They allocate into the right scope and walk away. The allocator does the cleanup, and the discipline keeps the code base small enough that humans can audit it.


8. The whole journey, one diagram

That's the whole machine. Let's run it back from the top in one diagram, with every actor talking at once:

sequenceDiagram participant U as You participant W as Wireshark GUI participant D as dumpcap (root) participant P as libpcap participant K as Kernel + NIC participant E as Epan + tvbuff + wmem U->>W: click Start on en0 W->>D: fork + exec D->>P: pcap_create / pcap_activate P->>K: open BPF / AF_PACKET socket loop per batch K-->>P: packets in kernel ring buffer P-->>D: pcap_dispatch delivers batch D->>D: wrap as pcapng block D-->>W: write to pipe end loop per packet W->>E: hand bytes to dissectors E->>E: dissect_frame → ethernet → ip → tcp → tls E-->>W: protocol tree W-->>U: one new row in the packet list end

Microseconds per packet. Six processes and one pipe doing the work.

Two processes, one pipe, and a chain of small functions that don't know about each other. The kernel feeds dumpcap. dumpcap feeds Wireshark. Wireshark feeds Epan. Epan walks the dissection chain, and each link uses tvbuff to read safely and wmem to allocate cheaply. When the last dissector returns, a row lands in the packet list and the whole thing starts over for the next frame.


9. Why this matters now

You can read network packets without Wireshark. tcpdump will print them. ngrep will grep through them. Your editor of choice can probably show you a hex dump. What Wireshark gives you that none of those do is a structured understanding of every byte — every protocol layer named, every field labelled, every bit traceable back to the RFC that defines it. That's the dissector chain doing its job, packet after packet, all the way down.

It's worth knowing, the next time your AI agent fires a hundred concurrent requests at an OpenAI endpoint and one of them stalls mysteriously, that the tool that's going to tell you what actually went over the wire is the same machine we just walked. A separately-privileged dumpcap. A pcap_dispatch loop. A registry of dissectors keyed by port. A tvbuff that bounds-checks every read. And a wmem pool that throws away the per-packet state in one constant-time operation when the dissector returns.

If you want to see the rest of what Wireshark does — the file I/O via Wiretap, the display filter engine, Sharkd's JSON-RPC interface, the thirty-year arc of dissector design — the full interactive breakdown of the Wireshark codebase is on Revibe.

Want to go deeper into Wireshark?

This post walked one live capture from NIC to screen. The Wireshark source covers a lot more — Wiretap's file format adapters, the dfilter compiler, two thousand dissectors, Sharkd's remote-analysis daemon, and the GUI's Qt-based packet list rendering. The complete interactive analysis lives on Revibe: modules, flows, system design Q&A, all explorable.