Reading network packets with netdump

A packet capture turns networking from an abstract diagram into bytes you can inspect. This tutorial develops netdump, a small C program that reads Ethernet frames and explains the IPv4, ARP, TCP, UDP, and ICMP headers inside them.

Contents: Layers · Ethernet and ARP · IPv4 · TCP, UDP, and ICMP · Capture limits · Build and run · Complete code · Walkthrough

A packet is a set of nested headers

Think of the layers as matryoshka dolls. An Ethernet frame contains an IP packet, which contains a transport segment, which carries application bytes. Each outer layer adds its own header around what it carries.

Nested Ethernet, IPv4, TCP, and application layers, with header sizes in bytes

This diagram shows a typical TCP connection over IPv4 and Ethernet. The nested boxes show what contains what, not the relative number of bytes. Ethernet padding and its frame check sequence are omitted.

Each layer answers a different question:

Layer Main question Relevant fields
Ethernet Where does this frame go on this link? Source and destination MAC addresses, EtherType
IPv4 Which hosts are communicating across networks? Source and destination IP addresses, protocol, total length, TTL
TCP or UDP Which transport endpoints are communicating? Source and destination ports
Application What do those endpoints mean to each other? DNS messages, HTTP requests, encrypted records, or another format

Encapsulation means one layer carries another layer’s bytes. It does not mean every packet contains every layer. ARP travels directly inside an Ethernet frame, and ICMP is carried by IP without TCP or UDP.

Multi-byte integer fields in these protocols use network byte order, with the most significant byte first. Bytes 00 50 therefore represent port 80. The implementation reads fields byte by byte instead of casting packet memory to C structs. That avoids alignment assumptions, compiler-dependent bitfield layouts, and host-endianness mistakes.

Ethernet and ARP

An Ethernet II header starts with a six-byte destination MAC address, followed by a six-byte source address and a two-byte EtherType. A destination of ff:ff:ff:ff:ff:ff is a broadcast. Checking the source address instead would count the wrong thing.

EtherType 0x0800 means IPv4, 0x0806 means ARP, and 0x86dd means IPv6. VLAN tags add bytes before the network-layer payload. The decoder recognizes the 0x8100 and 0x88a8 tag types and advances over each complete tag instead of assuming IP always starts at byte 14.

ARP resolves an IPv4 address to a link-layer address on the local network. A request asks which device has an address, and a reply supplies the mapping. For a destination outside the local subnet, the host normally resolves its next-hop router, not the remote Internet server’s MAC address.

The code decodes the common Ethernet/IPv4 ARP format. It checks the hardware type, protocol type, and address lengths before reading the sender and target addresses. Other ARP formats are reported as unsupported.

IPv4: Addresses, lengths, and fragments

The first IPv4 byte contains a four-bit version and a four-bit Internet Header Length, or IHL. IHL counts 32-bit words, so a value of 5 means a 20-byte header. Options can make that header longer.

The total length includes the IPv4 header and its payload. It does not include the Ethernet header or Ethernet padding. The protocol field identifies what follows, such as ICMP (1), TCP (6), or UDP (17). Time to live, or TTL, limits how many routing hops a packet can survive.

IPv4 can fragment a datagram. Later fragments do not start with a TCP or UDP header. Even the first fragment may contain only part of a transport message. This decoder reports fragmentation and skips transport decoding for every fragmented datagram rather than pretending to reassemble it.

The two capture lengths also matter. libpcap supplies caplen, the number of bytes actually available, and len, the original frame length reported by the capture source. A parser must never read beyond caplen. A short capture is not permission to trust the packet’s internal length fields.

TCP, UDP, and ICMP

TCP provides a reliable, ordered byte stream. Sequence numbers identify positions in that stream, acknowledgements report progress, and flags such as SYN, ACK, and FIN control the connection. The header’s data-offset field, like IPv4 IHL, is measured in 32-bit words. TCP options mean the payload does not always begin after 20 bytes.

One TCP segment is not necessarily one application message. An HTTP request can span segments, and one segment can contain bytes from multiple application writes. Packet-by-packet printing is not stream reassembly.

UDP carries individual datagrams. Its eight-byte header contains the two ports, a length covering the UDP header and data, and a checksum. UDP itself does not promise delivery, ordering, or retries. Applications can implement additional behavior above it.

ICMP carries control and diagnostic messages. Echo request and echo reply are familiar from ping, but other types report conditions such as an unreachable destination or an expired TTL. Only echo messages are decoded here as having an echo identifier and sequence number.

A port is a clue, not proof of an application protocol. Port 53 commonly carries DNS, 80 HTTP, and 443 TLS over TCP or QUIC over UDP. The program labels these as port hints. It does not claim to validate application messages or decrypt HTTPS.

What a capture can and cannot tell you

Capture only traffic on systems and networks where you have permission. Packet payloads can contain private data and credentials. Use synthetic examples or a controlled lab, and do not publish captures without reviewing them.

On a switched network, a normal capture does not reveal every other device’s unicast traffic. Promiscuous mode is not a way around the switch’s forwarding behavior. This program leaves promiscuous mode off.

The program requires Ethernet captures. Linux’s any interface often produces a cooked-capture link type instead, which this implementation rejects explicitly. Raw Wi-Fi, loopback-specific formats, and IPv6 need different decoders.

Checksums are printed but not verified. Hardware offloading can also make a host-side capture differ from the frames ultimately placed on the wire. Fragment reassembly, TCP stream reconstruction, retransmission analysis, and TLS decryption are outside this tutorial. Use Wireshark or tcpdump for broader protocol support.

Build and run

The implementation targets Linux with a C11 compiler and libpcap. Save the complete code below as netdump.c.

On Ubuntu or Debian:

sudo apt-get update
sudo apt-get install gcc libc6-dev libpcap-dev
cc -std=c11 -Wall -Wextra -Werror netdump.c -lpcap -o netdump

Start with an Ethernet-format capture file that you are authorized to inspect. Offline analysis does not require capture privileges:

./netdump -r lab.pcap
./netdump -r lab.pcap 'tcp port 80'

For a live capture, select a real interface from your own machine. eth0 is only an example:

ip link
sudo ./netdump -i eth0 'arp or ip'

Press Ctrl+C to stop and print the summary. The signal handler only requests that libpcap stop. Printing, statistics, and cleanup happen after the capture loop returns.

The optional quoted argument is a libpcap capture filter, not a Wireshark display filter. Filters involving VLAN traffic may need an explicit vlan expression. Windows users can build and read saved captures in WSL, but WSL’s visible interfaces are not necessarily the Windows host’s physical interfaces.

Complete code

The parser checks each header before reading its fields. Unsupported protocols and malformed or truncated data get an explicit message. Payload output is bounded by the validated protocol length, with non-printable bytes replaced by dots in the ASCII column.

#define _DEFAULT_SOURCE
#include <inttypes.h>
#include <pcap/pcap.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>

struct counters {
    unsigned long long frames, broadcast, arp, ipv4, tcp, udp, icmp, skipped;
};

static pcap_t *capture;

static unsigned be16(const unsigned char *p)
{
    return ((unsigned)p[0] << 8) | p[1];
}

static uint32_t be32(const unsigned char *p)
{
    return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16)
        | ((uint32_t)p[2] << 8) | p[3];
}

static void address(const unsigned char *p, size_t count, int mac)
{
    for (size_t i = 0; i < count; i++) {
        if (i != 0)
            putchar(mac ? ':' : '.');
        printf(mac ? "%02x" : "%u", (unsigned)p[i]);
    }
}

static void skip(struct counters *counts, const char *reason)
{
    counts->skipped++;
    printf("  Skipped: %s\n", reason);
}

static void payload(const unsigned char *p, size_t length)
{
    for (size_t offset = 0; offset < length; offset += 16) {
        size_t width = length - offset < 16 ? length - offset : 16;
        printf("    %04zx  ", offset);
        for (size_t i = 0; i < 16; i++) {
            if (i < width)
                printf("%02x ", (unsigned)p[offset + i]);
            else
                printf("   ");
        }
        printf(" |");
        for (size_t i = 0; i < width; i++) {
            unsigned char c = p[offset + i];
            putchar(c >= 32 && c <= 126 ? c : '.');
        }
        puts("|");
    }
}

static void port_hint(unsigned source, unsigned destination, int tcp)
{
    const char *hint = NULL;
    if (source == 53 || destination == 53)
        hint = "DNS";
    else if (source == 443 || destination == 443)
        hint = tcp ? "TLS" : "QUIC";
    else if (tcp && (source == 80 || destination == 80))
        hint = "HTTP";
    else if (tcp && (source == 25 || destination == 25))
        hint = "SMTP";
    else if (tcp && (source == 110 || destination == 110))
        hint = "POP3";
    else if (tcp && (source == 143 || destination == 143))
        hint = "IMAP";
    if (hint != NULL)
        printf("  Port hint: %s, not application validation\n", hint);
}

static void decode_arp(const unsigned char *p, size_t length,
                       struct counters *counts)
{
    if (length < 8) {
        skip(counts, "truncated ARP header");
        return;
    }
    if (be16(p) != 1 || be16(p + 2) != 0x0800 || p[4] != 6 || p[5] != 4) {
        skip(counts, "unsupported ARP address format");
        return;
    }
    if (length < 28) {
        skip(counts, "truncated Ethernet/IPv4 ARP message");
        return;
    }
    counts->arp++;
    printf("  ARP operation=%u sender=", be16(p + 6));
    address(p + 14, 4, 0);
    printf(" (");
    address(p + 8, 6, 1);
    printf(") target=");
    address(p + 24, 4, 0);
    printf(" (");
    address(p + 18, 6, 1);
    puts(")");
}

static void decode_ipv4(const unsigned char *p, size_t length,
                        struct counters *counts)
{
    if (length < 20 || p[0] >> 4 != 4) {
        skip(counts, "truncated or invalid IPv4 header");
        return;
    }
    unsigned header_length = (p[0] & 15) * 4;
    unsigned total_length = be16(p + 2);
    if (header_length < 20 || header_length > length ||
        total_length < header_length || total_length > length) {
        skip(counts, "invalid or truncated IPv4 lengths");
        return;
    }
    counts->ipv4++;
    printf("  IPv4 ");
    address(p + 12, 4, 0);
    printf(" -> ");
    address(p + 16, 4, 0);
    printf(" ttl=%u header=%u total=%u checksum=0x%04x\n",
           (unsigned)p[8], header_length, total_length, be16(p + 10));
    if (be16(p + 6) & 0x3fff) {
        skip(counts, "fragmented IPv4, no reassembly or transport decoding");
        return;
    }

    unsigned protocol = p[9];
    p += header_length;
    length = total_length - header_length;
    if (protocol == 6) {
        if (length < 20) {
            skip(counts, "truncated TCP header");
            return;
        }
        unsigned tcp_length = (p[12] >> 4) * 4;
        if (tcp_length < 20 || tcp_length > length) {
            skip(counts, "invalid or truncated TCP header length");
            return;
        }
        counts->tcp++;
        unsigned source = be16(p), destination = be16(p + 2);
        printf("  TCP %u -> %u seq=%" PRIu32 " ack=%" PRIu32
               " flags=0x%02x header=%u window=%u checksum=0x%04x payload=%zu\n",
               source, destination, be32(p + 4), be32(p + 8),
               (unsigned)p[13], tcp_length, be16(p + 14), be16(p + 16),
               length - tcp_length);
        port_hint(source, destination, 1);
        payload(p + tcp_length, length - tcp_length);
    } else if (protocol == 17) {
        if (length < 8) {
            skip(counts, "truncated UDP header");
            return;
        }
        unsigned udp_length = be16(p + 4);
        if (udp_length < 8 || udp_length > length) {
            skip(counts, "invalid or truncated UDP length");
            return;
        }
        counts->udp++;
        unsigned source = be16(p), destination = be16(p + 2);
        printf("  UDP %u -> %u length=%u checksum=0x%04x payload=%u\n",
               source, destination, udp_length, be16(p + 6), udp_length - 8);
        port_hint(source, destination, 0);
        payload(p + 8, udp_length - 8);
    } else if (protocol == 1) {
        if (length < 4) {
            skip(counts, "truncated ICMP header");
            return;
        }
        if ((p[0] == 0 || p[0] == 8) && length < 8) {
            skip(counts, "truncated ICMP echo header");
            return;
        }
        counts->icmp++;
        printf("  ICMP type=%u code=%u checksum=0x%04x",
               (unsigned)p[0], (unsigned)p[1], be16(p + 2));
        if (p[0] == 0 || p[0] == 8)
            printf(" echo-id=%u sequence=%u", be16(p + 4), be16(p + 6));
        putchar('\n');
    } else {
        skip(counts, "unsupported IPv4 protocol");
    }
}

static void packet(unsigned char *user, const struct pcap_pkthdr *header,
                   const unsigned char *p)
{
    struct counters *counts = (struct counters *)user;
    size_t length = header->caplen;
    counts->frames++;
    printf("\nFrame %llu captured=%u original=%u\n",
           counts->frames, header->caplen, header->len);
    if (length < 14) {
        skip(counts, "truncated Ethernet header");
        return;
    }
    printf("  Ethernet ");
    address(p + 6, 6, 1);
    printf(" -> ");
    address(p, 6, 1);
    putchar('\n');
    static const unsigned char broadcast[6] = {255, 255, 255, 255, 255, 255};
    if (memcmp(p, broadcast, 6) == 0)
        counts->broadcast++;

    unsigned type = be16(p + 12);
    size_t offset = 14;
    while (type == 0x8100 || type == 0x88a8) {
        if (length - offset < 4) {
            skip(counts, "truncated VLAN tag");
            return;
        }
        printf("  VLAN id=%u\n", be16(p + offset) & 0x0fff);
        type = be16(p + offset + 2);
        offset += 4;
    }
    printf("  EtherType=0x%04x\n", type);
    if (type == 0x0800)
        decode_ipv4(p + offset, length - offset, counts);
    else if (type == 0x0806)
        decode_arp(p + offset, length - offset, counts);
    else
        skip(counts, "unsupported EtherType or IEEE 802.3 length-format frame");
}

static void stop_capture(int signum)
{
    (void)signum;
    pcap_breakloop(capture);
}

int main(int argc, char **argv)
{
    if ((argc != 3 && argc != 4) ||
        (strcmp(argv[1], "-r") != 0 && strcmp(argv[1], "-i") != 0)) {
        fprintf(stderr, "Usage: %s {-r FILE | -i INTERFACE} [FILTER]\n", argv[0]);
        return 1;
    }
    int live = strcmp(argv[1], "-i") == 0;
    char error[PCAP_ERRBUF_SIZE] = {0};
    capture = live ? pcap_open_live(argv[2], 65535, 0, 1000, error)
                   : pcap_open_offline(argv[2], error);
    if (capture == NULL) {
        fprintf(stderr, "Capture error: %s\n", error);
        return 1;
    }
    if (error[0] != '\0')
        fprintf(stderr, "Capture warning: %s\n", error);
    if (pcap_datalink(capture) != DLT_EN10MB) {
        fprintf(stderr, "Unsupported link type %d, Ethernet is required.\n",
                pcap_datalink(capture));
        pcap_close(capture);
        return 1;
    }
    struct bpf_program filter;
    if (pcap_compile(capture, &filter, argc == 4 ? argv[3] : "",
                     1, PCAP_NETMASK_UNKNOWN) < 0) {
        fprintf(stderr, "Filter error: %s\n", pcap_geterr(capture));
        pcap_close(capture);
        return 1;
    }
    int filter_result = pcap_setfilter(capture, &filter);
    pcap_freecode(&filter);
    if (filter_result < 0) {
        fprintf(stderr, "Filter error: %s\n", pcap_geterr(capture));
        pcap_close(capture);
        return 1;
    }
    struct sigaction action = {0};
    action.sa_handler = stop_capture;
    sigemptyset(&action.sa_mask);
    if (sigaction(SIGINT, &action, NULL) < 0 ||
        sigaction(SIGTERM, &action, NULL) < 0) {
        perror("sigaction");
        pcap_close(capture);
        return 1;
    }
    struct counters counts = {0};
    int result = pcap_loop(capture, -1, packet, (unsigned char *)&counts);
    if (result == PCAP_ERROR)
        fprintf(stderr, "Read error: %s\n", pcap_geterr(capture));
    if (live) {
        struct pcap_stat stats;
        if (pcap_stats(capture, &stats) == 0)
            fprintf(stderr, "Capture stats: received=%u dropped=%u\n",
                    stats.ps_recv, stats.ps_drop);
        else
            fprintf(stderr, "Statistics error: %s\n", pcap_geterr(capture));
    }
    printf("\nSummary: frames=%llu broadcast=%llu ARP=%llu IPv4=%llu"
           " TCP=%llu UDP=%llu ICMP=%llu skipped=%llu\n",
           counts.frames, counts.broadcast, counts.arp, counts.ipv4,
           counts.tcp, counts.udp, counts.icmp, counts.skipped);
    action.sa_handler = SIG_IGN;
    if (sigaction(SIGINT, &action, NULL) < 0 ||
        sigaction(SIGTERM, &action, NULL) < 0) {
        perror("sigaction");
        return 1;
    }
    pcap_close(capture);
    return result == PCAP_ERROR ? 1 : 0;
}

Reading the output

Consider a synthetic frame containing a four-byte TCP payload, test, from 192.0.2.1 to 198.51.100.2. Those addresses are reserved for documentation, not observations from a real capture.

With a 20-byte IPv4 header and a 20-byte TCP header, the IPv4 total length is 20 + 20 + 4 = 44. Adding the Ethernet header gives 58 captured bytes in this synthetic example. Real Ethernet transmission has minimum-frame padding, and a capture may omit padding or the frame check sequence.

Byte layout of a 58-byte capture, with header boundaries at offsets 0, 14, 34, 54, and 58 bytes

The nesting becomes a sequence of headers in memory. Read the outer header, work out where its payload starts, then decode the next layer. In this example, TCP begins at byte 34. A VLAN tag or IPv4 options would move that offset, which is why the decoder calculates it rather than hard-coding it.

The transport line could read:

TCP 12345 -> 80 seq=1 ack=0 flags=0x18 header=20 window=4096 checksum=0x0000 payload=4
Port hint: HTTP, not application validation

Flags 0x18 combine PSH (0x08) and ACK (0x10). The destination port suggests HTTP, but the four bytes test are not a valid HTTP request. That is why the program prints a hint rather than claiming it decoded HTTP.

The zero checksum in this synthetic example is not a valid-checksum claim. To understand real traffic, separate what the parser actually checks from what the packet merely says.

The final counters overlap by design: an IPv4 TCP frame counts as a frame, IPv4, and TCP. Broadcast is a link-layer property. A valid IPv4 header can be counted even when its transport protocol is skipped. Capture-library receive and drop counters have platform-specific meanings and need not equal the program’s decoded-frame count.

For further detail, see the specifications for IPv4, ARP, UDP, and TCP, along with libpcap’s capture loop and safe loop termination documentation.