DEV Community

Cover image for Building a Packet Sniffer in C++ with libpcap (And the Segfault That Taught Me a Lesson)
NITISH Sharma
NITISH Sharma

Posted on

Building a Packet Sniffer in C++ with libpcap (And the Segfault That Taught Me a Lesson)

I'm a CS student working toward a portfolio in AI/ML, DevOps, and cybersecurity, and one of the bigger projects on that list is NetSentinel — an ML-based intrusion detection system. You can't detect anomalies in network traffic if you can't first read network traffic, so before touching any machine learning, I went back to basics: write a packet sniffer in C++ using libpcap.

This post is the story of that sniffer — what it does, the bugs it taught me the hard way, and the refactor that mattered more than any single feature I added.

Why libpcap, and what even is a pcap_t?

libpcap is the C library behind tools like tcpdump and Wireshark's capture engine. It gives you raw access to packets flowing across a network interface, before any of your OS's normal socket machinery gets involved.

The first thing that trips people up is pcap_t — the handle you get back when you open a capture session:

pcap_t *descr = pcap_open_live(device_name, MAXBYTES2CAPTURE, 1, 512, errbuf);
Enter fullscreen mode Exit fullscreen mode

pcap_t is what's called an opaque struct — you get a pointer to it, but you never see or touch its internal fields. Every single thing you do with it goes through a function call: pcap_loop() to start capturing, pcap_setfilter() to configure it, pcap_close() to tear it down. You never write descr->something.

If that pattern feels familiar, it should — it's the exact same shape as a FILE* from <cstdio>, or a sqlite3* from SQLite's C API. Open, operate through functions, close. Once that clicked for me, a bunch of other C libraries suddenly felt less mysterious.

Letting the user pick a device

The very first version of this sniffer just auto-picked whatever device pcap_lookupdev() handed back (deprecated now, but fine for a first pass). The obvious next step was letting the user choose — laptop Wi-Fi, loopback, whatever.

pcap_findalldevs() gives you a linked list of every available interface:

pcap_if_t *alldevs;
pcap_findalldevs(&alldevs, errbuf);

for (pcap_if_t *d = alldevs; d != nullptr; d = d->next) {
    std::cout << d->name << '\n';
}
Enter fullscreen mode Exit fullscreen mode

Interesting detail: unlike pcap_t, a pcap_if_t is not opaque. d->name, d->next, d->description are plain, directly-accessible fields — no accessor functions needed. The two types sit at opposite ends of "how much the library hides from you," and it's a nice example of a library only hiding what actually needs hiding.

I wrote a small helper to walk the list by index so a user could type a number instead of a device name:

pcap_if_t* selectNodeByIndex(pcap_if_t* head, int targetIndex)
{
    if (targetIndex < 0) return nullptr;

    pcap_if_t* current = head;
    int currentIndex = 0;

    while (current != nullptr) {
        if (currentIndex == targetIndex) return current;
        currentIndex++;
        current = current->next;
    }
    return nullptr;
}
Enter fullscreen mode Exit fullscreen mode

Looked done. It was not done.

The segfault: a classic use-after-free

Here's what my main() looked like right after wiring device selection in:

std::cout << "Opening device: " << selectedDev;
pcap_freealldevs(alldevs);
descr = pcap_open_live(selectedDev->name, MAXBYTES2CAPTURE, 1, 512, errbuf);
Enter fullscreen mode Exit fullscreen mode

Run it, print the device list, type a number... segfault. Instantly.

The bug: selectedDev isn't a separate copy of anything — it's a pointer into the same linked list alldevs owns. pcap_freealldevs(alldevs) walks the whole list and frees every node in it, including the exact node selectedDev points to. The very next line dereferences selectedDev->name — memory that had just been freed.

Two variables, two different names, pointing at overlapping memory. Freeing one silently invalidates the other, and nothing about the code looks wrong — that's what makes use-after-free bugs so nasty compared to, say, a syntax error.

The fix was just reordering — finish reading from the list before freeing it:

std::cout << "Opening device: " << selectedDev->name << '\n';

descr = pcap_open_live(selectedDev->name, MAXBYTES2CAPTURE, 1, 512, errbuf);
if (descr == nullptr) {
    std::cerr << "pcap_open_live failed: " << errbuf << '\n';
    pcap_freealldevs(alldevs);
    return 1;
}

pcap_loop(descr, -1, processPackets, reinterpret_cast<u_char*>(&count));
pcap_freealldevs(alldevs);   // only free once nothing still points into it
Enter fullscreen mode Exit fullscreen mode

The lesson that stuck with me: before calling free/delete/pcap_freealldevs on anything, the real question isn't "am I done with this variable" — it's "does anything else still point into this memory."

The quiet failure: when nothing crashes, but nothing happens either

Bug #2 was sneakier because there was no crash at all. I'd pick loopback, see "Opening device: lo" print... and then the program would just exit. No error, no packets, nothing.

Turns out pcap_open_live() can succeed at opening the handle even without the right privileges — the actual failure happens later, on the first internal read inside pcap_loop(), and I wasn't checking its return value:

int result = pcap_loop(descr, -1, processPackets, reinterpret_cast<u_char*>(&count));
if (result == PCAP_ERROR) {
    std::cerr << "pcap_loop failed: " << pcap_geterr(descr) << '\n';
}
Enter fullscreen mode Exit fullscreen mode

Once I added that check, the actual problem showed up immediately: permissions. Capturing raw packets needs CAP_NET_RAW/CAP_NET_ADMIN, which a normal user doesn't have by default:

sudo setcap cap_net_raw,cap_net_admin=eip ./simplesniffer
Enter fullscreen mode Exit fullscreen mode

The real takeaway here: check return values from anything that can fail silently. A function returning early with an error code and a function succeeding can look identical if you never look at what it returned.

Actually parsing packets: peeling layers one at a time

With capture working, the fun part: turning raw bytes into something meaningful. A captured frame is layers stacked back to back in memory — Ethernet header, then IP header, then TCP/UDP/ICMP, then payload. You don't copy anything to get at each layer; you just reinterpret the same buffer at increasing offsets:

const struct ether_header* eth = reinterpret_cast<const struct ether_header*>(packet);
uint16_t etherType = ntohs(eth->ether_type);

if (etherType == ETHERTYPE_IP) {
    parseIPv4(packet + sizeof(struct ether_header),
              caplen - sizeof(struct ether_header));
}
Enter fullscreen mode Exit fullscreen mode

Two details bit me here that are worth knowing up front:

  • Byte order. Multi-byte fields like ether_type and port numbers arrive in network byte order (big-endian), and my CPU is little-endian. Skipping ntohs()/ntohl() means comparing the right bytes in the wrong order — the classic "why is this never matching" bug.
  • Variable header length. struct ip's ip_hl field is a word count, not a byte count, because IP headers can carry optional fields. Forgetting to multiply by 4 means computing the wrong offset for everything after it.

The full pipeline ended up as: parseEthernet → check EtherType → parseIPv4 → check protocol number → parseTCP/parseUDP/parseICMP. Each function only ever needs to know about its own header — no function reaches backward or forward across layers it doesn't own.

The refactor that mattered more than any feature

Here's the part I actually want other people building similar tools to take away.

My first working parser looked like this — each function found something, and immediately printf'd it:

void parseIPv4(const u_char* packet, int caplen)
{
    // ...parse the header...
    std::printf("[ip] %s -> %s | protocol %d\n", srcIp, dstIp, iph->ip_p);
}
Enter fullscreen mode Exit fullscreen mode

It worked. It was also a dead end. If I ever wanted to save packets to a file, group them into flows, or hand features to an ML model — all actual requirements for NetSentinel — I'd have had to re-parse the same raw bytes all over again, because the only "output" of parsing was text on a terminal.

The fix: stop printing inside the parser. Parse into a plain data struct instead, and let something else decide what to do with it:

struct PacketInfo
{
    uint8_t  srcMac[6] = {0};
    uint8_t  dstMac[6] = {0};
    uint16_t etherType = 0;

    bool        hasIPv4 = false;
    std::string srcIp;
    std::string dstIp;
    uint8_t     protocol = 0;
    uint8_t     ttl = 0;

    bool     hasTransport = false;
    uint16_t srcPort = 0;
    uint16_t dstPort = 0;
};
Enter fullscreen mode Exit fullscreen mode

Every parse* function now takes a PacketInfo& and fills it in instead of printing:

void parseIPv4(const u_char* packet, int caplen, PacketInfo& info)
{
    // ...same parsing logic as before...
    info.hasIPv4  = true;
    info.srcIp    = srcIp;
    info.dstIp    = dstIp;
    info.protocol = iph->ip_p;
    info.ttl      = iph->ip_ttl;
}
Enter fullscreen mode Exit fullscreen mode

Printing becomes one function, called once, after parsing is completely done:

PacketInfo info;
parseEthernet(packet, caplen, info);
printPacketInfo(info);   // the ONLY function that touches stdout now
Enter fullscreen mode Exit fullscreen mode

The satisfying confirmation that this actually worked: my parser's .cpp file no longer needed to #include <cstdio> at all. Zero output dependency, anywhere in the parsing code. Anything that wants packet data now — a flow tracker, a CSV writer, a feature extractor for a model — just becomes another reader of PacketInfo. None of them will ever need to touch a raw byte buffer.

One small design detail worth calling out: notice the bool hasIPv4 flag sitting next to the IP fields. Not every frame is IPv4 — some are ARP, some IPv6 — and without that flag, an unparsed packet's srcIp/ttl would just sit at their zero-initialized defaults with no way to tell "genuinely empty" apart from "never reached this layer." Every optional layer in the struct follows that same has-flag pattern.

What's next

The plan from here is to build outward from PacketInfo rather than back into raw bytes:

  • Group packets into flows (keyed by source/destination IP + port + protocol) and track stats per flow — packet counts, byte counts, duration
  • Export flow records so a Python pipeline can train a baseline anomaly-detection model on them
  • Eventually feed live traffic through that trained model for real-time flagging — the actual "intrusion detection" part of NetSentinel

None of that would be possible in a sane way if every consumer still had to re-parse raw Ethernet frames from scratch. That's the whole reason this refactor came before any of it.

Try it yourself

If you want to see this working without needing real suspicious traffic: capture on loopback and generate traffic yourself.

sudo ./simplesniffer     # pick the loopback interface
Enter fullscreen mode Exit fullscreen mode

In a second terminal:

ping -c 3 127.0.0.1
Enter fullscreen mode Exit fullscreen mode

You'll see the layered output for each ICMP echo request/reply. Swap ping for nc -l 127.0.0.1 9999 and echo "hello" | nc 127.0.0.1 9999 in two more terminals if you want to see the TCP path instead.

Lessons that generalize past this one project

  • Opaque handles (pcap_t, FILE*, sqlite3*) all follow the same shape: operate through functions only, never touch fields directly.
  • Use-after-free doesn't always look wrong. Two differently-named pointers can point into the same memory — freeing one silently breaks the other.
  • Check return values from anything that can fail without crashing. A silent early return and a full success can look identical from the outside.
  • Decouple "compute the answer" from "do something with the answer." The moment your parsing/logic function starts calling printf (or writing to a file, or hitting a network socket) directly, you've locked yourself into re-doing that computation for every new consumer that shows up later.

Still very much a work in progress — next post will probably be about turning PacketInfo into flow-level statistics.

Top comments (0)