Safe Penetration Testing Labs · intermediate · ~15 min

Parse a pcap per-packet record in C

- Read the 24-byte pcap global header and detect the file's byte order from its magic number. - Decode a 16-byte per-packet record header (timestamp, captured length, original length) field by field. - Assemble 32-bit integers from raw bytes using shifts, in both little-endian and big-endian order, without unaligned pointer casts. - Walk a `.pcap` file record by record using `incl_len` to find the next record. - Bounds-check every read so a malformed or attacker-crafted capture cannot make your parser read or write out of bounds. - Log what your parser accepted, rejected, and why, so a suspicious capture leaves an audit trail.

Overview

Security objective. The asset you protect is your own analysis machine and the integrity of your forensic results. The threat is a malformed or maliciously crafted .pcap file — one whose length fields lie about how much data follows. Historically, capture parsers (including code paths in Wireshark and libpcap) have been crashed or exploited by hand-edited traces. In this lesson the learner builds a parser that detects the lie (a length field pointing past the end of the file) and refuses to trust it, instead of reading past the buffer.

A .pcap file is the on-disk format that tcpdump and Wireshark write when they save captured network traffic. It begins with one global header (24 bytes) describing the whole file, followed by a stream of records, one per captured packet. Each record has a 16-byte record header (when the packet was captured and how many bytes were saved) followed by the raw packet bytes.

This lesson builds directly on your prerequisites. From pointers you take walking a const unsigned char * cursor through a byte buffer. From structs you take grouping the parsed fields (ts_sec, ts_usec, incl_len, orig_len) into one pcap_rec_hdr_t. From endianness and byte order you take the central skill: the same four bytes mean different numbers depending on whether the file was written little-endian or big-endian, and the magic number tells you which. Getting that wrong turns 40 into 671088640 — and a wrong length is exactly what makes a parser read off the end of the buffer.

The emphasis throughout is defensive: read only what you have proven is present, treat every length as untrusted until checked, and never let file-supplied numbers drive an unbounded read.

Why it matters

In authorized, professional work, reading a pcap by hand is a core skill for several roles:

  • Digital forensics and incident response (DFIR). When you receive a packet capture from a compromised host, you often cannot fully trust the tool that produced it, and you may need to carve records out of a truncated or partially corrupted file that Wireshark refuses to open. A small hand-written parser recovers what a GUI tool gives up on.
  • Detection engineering. Custom parsers feed intrusion-detection pipelines that process millions of records per second, where you strip out just the fields you need rather than loading a whole capture into a heavyweight tool.
  • Secure software development. File-format parsers are one of the most common places real vulnerabilities live, because they process untrusted attacker-controlled input. Learning to bounds-check a length field here is the same discipline that prevents CVEs in production parsers.
  • Tool validation. Knowing the byte layout lets you confirm that a capture tool wrote what it claimed, and to spot tampering (for example, a record whose stored length disagrees with the file size).

Every one of these is done on data you are authorized to handle. The engineering habit you build — never trust a length from the file — transfers to every binary format you will ever parse.

Core concepts

1. The pcap file layout

Definition. A classic (libpcap) .pcap file is a 24-byte global header followed by zero or more packet records. Each record is a 16-byte record header immediately followed by incl_len bytes of captured packet data.

Plain explanation. Think of it as a shipping manifest: one cover sheet (global header), then a stack of parcels. Each parcel has a small label (record header) that says when it arrived and how big it is, then the parcel contents (the packet bytes).

How it works. To reach parcel N+1 you must read parcel N's label to learn its size, then skip that many bytes. There is no length-prefix table and no index — the only way to find the next record is to trust (after checking) the current record's incl_len.

When / when not. This is the classic pcap format. The newer pcapng format (Wireshark's modern default) is block-structured and different; do not assume this layout for a .pcapng file. Check the magic number first.

Pitfall. Assuming records are a fixed size. They are not — each record's total size is 16 + incl_len, and incl_len varies per packet.

2. The global header and the magic number

Definition. The first 4 bytes are a magic number: 0xA1B2C3D4. If you read those 4 bytes and get 0xA1B2C3D4, the file's byte order matches how you read it; if you get the byte-reversed 0xD4C3B2A1, the file was written in the opposite byte order and every multi-byte field must be swapped.

Plain explanation. The magic number is both a "this really is a pcap" marker and a compass telling you which way to read the bytes.

How it works. You read 4 bytes as big-endian into a variable. If it equals 0xA1B2C3D4, use big-endian for the rest. If it equals 0xD4C3B2A1, use little-endian. Anything else: reject the file.

When / when not. Magic values 0xA1B23C4D / 0x4D3CB2A1 indicate nanosecond timestamps — same layout, finer time units. Do not silently accept unknown magics.

Pitfall. Deciding byte order once and forgetting it. The record headers use the same byte order as the global header — carry that decision forward to every field.

3. The per-packet record header (the focus of this lesson)

Definition. A 16-byte structure:

offset size field meaning
0 4 ts_sec capture time, whole seconds (Unix epoch)
4 4 ts_usec fractional part (microseconds, or nanoseconds if magic says so)
8 4 incl_len captured length: bytes of this packet actually saved
12 4 orig_len original length: bytes the packet had on the wire

Plain explanation. incl_len is how much is in the file for this packet; orig_len is how big the packet really was. If the capture used a snap length (snaplen) smaller than a packet, incl_len < orig_len — the tail was truncated at capture time.

How it works. After the 16-byte header come exactly incl_len bytes of packet data. The next record header starts at current_offset + 16 + incl_len.

When / when not. Use incl_len (not orig_len) to advance through the file and to bound reads of the payload. orig_len is informational only.

Pitfall — the security-critical one. Trusting incl_len without checking it against the bytes you actually have. A crafted file can set incl_len to 0xFFFFFFFF; if you memcpy or advance by that value, you read far past the buffer. Always verify incl_len <= bytes_remaining before using it, and reject absurd values (a legitimate incl_len never exceeds snaplen, and incl_len <= orig_len always holds for well-formed captures).

4. Byte-order-aware integer assembly

Definition. Building a uint32_t from 4 bytes by shifting each into place, choosing the order based on the file's endianness.

Plain explanation. Little-endian: least-significant byte first (b[0] | b[1]<<8 | b[2]<<16 | b[3]<<24). Big-endian: most-significant byte first (b[0]<<24 | b[1]<<16 | b[2]<<8 | b[3]).

How it works. Shifting bytes is portable and alignment-safe: it works regardless of your CPU's native endianness and regardless of whether the buffer pointer is 4-byte aligned.

When / when not. Prefer byte-by-byte shifts over *(uint32_t*)p. The cast is undefined behavior when p is misaligned and gives the wrong answer when the file's endianness differs from the CPU's.

Pitfall. Shifting an unsigned char (promoted to int) by 24 can touch the sign bit. Assemble into a uint32_t and cast each byte to uint32_t (or unsigned) before shifting.

Threat model

  UNTRUSTED INPUT                TRUST BOUNDARY            YOUR ANALYSIS HOST
  (attacker-controlled)          (validation layer)        (protected asset)

  +---------------------+        +-------------------+      +------------------+
  |  suspect.pcap       |        |  read_pcap_record |      |  DFIR pipeline / |
  |  - magic (4B)       | =====> |  - length checks  | ===> |  analyst report  |
  |  - record headers   | entry  |  - magic check    | ok   |  parsed structs  |
  |  - incl_len (LIES?) | point  |  - incl_len bound |      |                  |
  |  - payload bytes    |        |  - reject on fail |      |                  |
  +---------------------+        +-------------------+      +------------------+
         file on disk                 bounds-checked            in-memory data
                                      byte-by-byte reads

  Entry point : bytes of the .pcap file (fully attacker-controlled)
  Trust bdry  : your parser's length + magic validation, BEFORE any read/copy
  Asset       : the analysis host's memory safety + the integrity of results
  Key risk    : a length field (incl_len) that points past end-of-file

Knowledge check.

  1. What asset is protected here, and what single field is most dangerous if trusted blindly?
  2. Where exactly is the trust boundary — before or after you copy the payload?
  3. What insecure assumption ("the file tells the truth about its own sizes") causes an out-of-bounds read, and how does checking incl_len <= remaining remove it?

Syntax notes

The core building block is byte-by-byte, endian-aware integer assembly. Keep a cursor and the number of bytes remaining side by side, and never read without checking remaining first.

#include <stdint.h>
#include <stddef.h>

/* Assemble a little-endian uint32 from 4 bytes. Alignment-safe. */
static uint32_t read_le32(const unsigned char *b) {
    return  (uint32_t)b[0]        |   /* least-significant byte first */
            (uint32_t)b[1] << 8   |
            (uint32_t)b[2] << 16  |
            (uint32_t)b[3] << 24;     /* most-significant byte last  */
}

/* Assemble a big-endian uint32 from 4 bytes. */
static uint32_t read_be32(const unsigned char *b) {
    return  (uint32_t)b[0] << 24  |
            (uint32_t)b[1] << 16  |
            (uint32_t)b[2] << 8   |
            (uint32_t)b[3];
}
  • Cast each byte to uint32_t before shifting — the shift by 24 must happen in an unsigned 32-bit type, not in a promoted int.
  • read_le32/read_be32 never dereference beyond b[3], so the caller must have already proven that 4 bytes are available at b.
  • The record header struct groups the four parsed fields:
typedef struct {
    uint32_t ts_sec;    /* offset 0  */
    uint32_t ts_usec;   /* offset 4  */
    uint32_t incl_len;  /* offset 8  — captured bytes (bound your reads by this) */
    uint32_t orig_len;  /* offset 12 — original on-wire length (informational)   */
} pcap_rec_hdr_t;

Lesson

Why this matters

Wireshark and tcpdump both write their captures in the same on-disk format: libpcap.

Knowing how to walk a .pcap file by hand is what separates using a tool from reading what it produced.

This exercise is the first step. You will:

  • Read the 24-byte file header.
  • Confirm the magic number.
  • Pull out the snap length and link type.

What the bytes look like

The pcap global header is exactly 24 bytes. Each field sits at a fixed offset:

offset  size  field
0       4     magic     (0xa1b2c3d4 host-order, 0xd4c3b2a1 swapped)
4       2     version_major (usually 2)
6       2     version_minor (usually 4)
8       4     thiszone  (signed)
12      4     sigfigs
16      4     snaplen   (max packet size captured)
20      4     linktype  (1 = Ethernet)

The magic number is a fixed marker that identifies the file format. It also tells you the byte order the file was written in.

The snap length (snaplen) is the maximum number of bytes captured per packet. The link type identifies the data-link protocol (for example, 1 means Ethernet).

Your job

Implement this function:

int read_pcap_header(const uint8_t *buf, size_t n, pcap_hdr_t *out);

Read the bytes in host order. We assume the capture came from a machine with the same endianness as ours. (Endian swapping is a follow-up exercise.)

Return value:

  • Return 0 if the magic matches 0xa1b2c3d4.
  • Return -1 otherwise, or if n < 24.

Common mistakes

  • Reading past the buffer when n < 24. Always check the length first.
  • Treating the input as a string. It is a byte array. There is no terminating NUL byte.
  • Casting the pointer to read magic as an int. This is alignment-unsafe on some platforms. Build the integer byte by byte with bit shifts instead.

What this is NOT

  • Not a full pcap walker. Per-record headers and packet payloads are out of scope here.
  • Not a live capture tool. We only read pre-captured files.

Code examples

The example is a small, self-contained pcap record walker. It shows the insecure version first (do not use it), then the secure fix, then a verify step proving the fix rejects a lie and accepts a valid file.

/*
 * WARNING: intentionally vulnerable — use only in a local, isolated,
 * authorized lab. Do not deploy.
 *
 * This walk_bad() trusts incl_len from the file. A crafted record with a
 * huge incl_len makes the cursor jump far past the buffer, so the NEXT
 * read_le32() reads out of bounds (crash or info leak).
 */
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>

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

void walk_bad(const unsigned char *buf, size_t n) {
    size_t off = 24;                 /* skip the global header */
    while (off + 16 <= n) {
        uint32_t incl = read_le32(buf + off + 8);
        printf("record at %zu, incl_len=%u\n", off, incl);
        off += 16 + incl;            /* BUG: incl is never bounds-checked */
        /* if incl is bogus, `off` overflows/leaps past n and the loop
           condition can still pass after wraparound -> OOB read */
    }
}
/*
 * SECURE version: validate byte order once, then bounds-check every read
 * and every advance. No file-supplied length is ever trusted before it is
 * compared against the bytes actually present.
 */
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>

typedef struct {
    uint32_t ts_sec, ts_usec, incl_len, orig_len;
} pcap_rec_hdr_t;

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

#define PCAP_MAGIC_BE 0xA1B2C3D4u   /* file is big-endian    */
#define PCAP_MAGIC_LE 0xD4C3B2A1u   /* file is little-endian */
#define PCAP_SANE_MAX 262144u       /* reject incl_len larger than 256 KiB */

/* Returns 1 on success (byte order decided), 0 if not a pcap we accept. */
static int pcap_byte_order(const unsigned char *buf, size_t n, int *little) {
    if (n < 24) return 0;
    uint32_t m = rd_be32(buf);          /* read magic as big-endian */
    if (m == PCAP_MAGIC_BE) { *little = 0; return 1; }
    if (m == PCAP_MAGIC_LE) { *little = 1; return 1; }
    return 0;                            /* unknown magic: reject */
}

/* Parse ONE record header at buf+off into *out.
 * Returns 1 on success; 0 if the record does not fully fit or is insane. */
static int read_pcap_record(const unsigned char *buf, size_t n, size_t off,
                            int little, pcap_rec_hdr_t *out) {
    if (off > n || n - off < 16) return 0;      /* 16-byte header must fit */
    const unsigned char *p = buf + off;
    uint32_t (*rd)(const unsigned char *) = little ? rd_le32 : rd_be32;
    out->ts_sec   = rd(p + 0);
    out->ts_usec  = rd(p + 4);
    out->incl_len = rd(p + 8);
    out->orig_len = rd(p + 12);

    if (out->incl_len > PCAP_SANE_MAX) return 0;         /* absurd length */
    if (out->incl_len > out->orig_len) return 0;         /* impossible     */
    if (n - off - 16 < out->incl_len) return 0;          /* payload truncated */
    return 1;
}

int walk_good(const unsigned char *buf, size_t n) {
    int little;
    if (!pcap_byte_order(buf, n, &little)) {
        fprintf(stderr, "reject: not a classic pcap (bad magic or short)\n");
        return -1;
    }
    size_t off = 24;
    int count = 0;
    while (off + 16 <= n) {
        pcap_rec_hdr_t r;
        if (!read_pcap_record(buf, n, off, little, &r)) {
            fprintf(stderr, "reject: bad record at offset %zu\n", off);
            return -1;                                   /* stop, do not guess */
        }
        printf("rec %d @%zu ts=%u.%06u incl=%u orig=%u\n",
               count, off, r.ts_sec, r.ts_usec, r.incl_len, r.orig_len);
        off += 16 + r.incl_len;                          /* now provably safe */
        count++;
    }
    return count;
}
/*
 * VERIFY: prove the secure parser REJECTS a lying record and ACCEPTS a good
 * one. This is a self-contained test with hand-built byte buffers.
 */
#include <assert.h>
#include <string.h>

static void put_le32(unsigned char *b, uint32_t v) {
    b[0]=v; b[1]=v>>8; b[2]=v>>16; b[3]=v>>24;
}

int main(void) {
    unsigned char buf[64];
    memset(buf, 0, sizeof buf);

    /* global header: an on-disk little-endian file stores the magic as the
       byte sequence D4 C3 B2 A1, which pcap_byte_order (reading big-endian)
       sees as PCAP_MAGIC_LE. Write those bytes directly. Rest zero is fine. */
    buf[0] = 0xD4; buf[1] = 0xC3; buf[2] = 0xB2; buf[3] = 0xA1;

    /* GOOD record at offset 24: 4-byte payload that actually fits */
    put_le32(buf + 24 + 0, 1700000000u); /* ts_sec  */
    put_le32(buf + 24 + 4, 500u);        /* ts_usec */
    put_le32(buf + 24 + 8, 4u);          /* incl_len = 4 */
    put_le32(buf + 24 + 12, 4u);         /* orig_len = 4 */
    /* payload bytes 24+16 .. 24+19 are the 4 captured bytes (zeros) */

    /* Accept case: 24 + 16 + 4 = 44 bytes used, buffer is 64 -> valid */
    int rc = walk_good(buf, 44);
    assert(rc == 1);                     /* exactly one record parsed */

    /* Reject case: same header but claim incl_len = 0xFFFFFFFF (a lie) */
    put_le32(buf + 24 + 8, 0xFFFFFFFFu);
    put_le32(buf + 24 + 12, 0xFFFFFFFFu);
    rc = walk_good(buf, 44);
    assert(rc == -1);                    /* parser refuses, no OOB read */

    printf("VERIFY OK: accepts valid record, rejects lying incl_len\n");
    return 0;
}

Expected output (from the verify main): one line rec 0 @24 ts=1700000000.000500 incl=4 orig=4, then a reject: bad record at offset 24 line on stderr, then VERIFY OK: accepts valid record, rejects lying incl_len. Compile with cc -std=c11 -Wall -Wextra -fsanitize=address,undefined pcap.c -o pcap so ASan/UBSan would catch any out-of-bounds read if the bounds checks were removed.

Line by line

Walking the secure parser, the security-critical path:

  1. pcap_byte_order(buf, n, &little) first checks n < 24 — with fewer than 24 bytes there is no global header, so it rejects immediately. This is the first bounds check and it happens before any field read.
  2. It reads the magic as big-endian (rd_be32). If the file was written big-endian the bytes come back as 0xA1B2C3D4; if little-endian, the same physical bytes read big-endian give the reversed 0xD4C3B2A1. That comparison is the byte-order detection — no CPU-specific code needed.
  3. Unknown magic returns 0, so walk_good prints a reject and stops. A .pcapng file (different magic) is refused rather than misparsed.
  4. The loop guard off + 16 <= n guarantees a full 16-byte record header is present before read_pcap_record touches it.
  5. Inside read_pcap_record, if (off > n || n - off < 16) return 0; re-checks locally (defense in depth) using subtraction n - off rather than addition off + 16, which cannot overflow because off <= n is established first.
  6. The four fields are read with the function pointer rd selected once from little, so every field uses the same byte order the magic dictated.
  7. The three validation lines are the heart of the fix:
    • incl_len > PCAP_SANE_MAX rejects absurd lengths early.
    • incl_len > orig_len rejects the physically impossible (you cannot capture more than the packet contained).
    • n - off - 16 < incl_len is the decisive check: is the claimed payload actually present in the bytes we have? Written as subtraction so it never overflows.
  8. Only after all three pass does walk_good do off += 16 + r.incl_len. Because incl_len <= n - off - 16, the new off is <= n — provably in bounds. The next loop iteration is safe.

Trace of the reject case (incl_len = 0xFFFFFFFF, n = 44, off = 24):

step expression value result
header fits? n - off = 44 - 24 20 ≥ 16 pass
sane max? 0xFFFFFFFF > 262144 true reject

The parser returns before computing any new off, so the buffer is never over-read. Contrast walk_bad, which would compute off = 24 + 16 + 0xFFFFFFFF, wrap around size_t, and then read read_le32 at a wild address.

Common mistakes

Mistake 1 — Trusting incl_len to advance the cursor.

  • WRONG: off += 16 + incl_len; with no check.
  • WHY: a crafted incl_len sends off past the buffer (or wraps size_t), and the next read is out of bounds — a real, exploitable class of bug.
  • CORRECTED: verify n - off - 16 >= incl_len (subtraction form, no overflow) before advancing.
  • RECOGNISE/PREVENT: compile with -fsanitize=address,undefined; feed a fuzzed/hand-edited pcap; ASan flags the over-read instantly.

Mistake 2 — off + 16 <= n overflow.

  • WRONG: checking off + payload <= n when off is near SIZE_MAX.
  • WHY: off + payload can wrap and pass a check it should fail.
  • CORRECTED: always compare with subtraction: off <= n && n - off >= 16.
  • RECOGNISE/PREVENT: make it a rule — bound with subtraction, never addition, on untrusted offsets.

Mistake 3 — Casting the pointer to read an integer.

  • WRONG: uint32_t incl = *(uint32_t*)(buf + off + 8);
  • WHY: undefined behavior if buf + off + 8 is not 4-byte aligned, and it silently produces the wrong number when file endianness differs from the CPU.
  • CORRECTED: read_le32/read_be32 byte assembly.
  • RECOGNISE/PREVENT: UBSan reports the misaligned load; a cross-endian test file (magic 0xA1B2C3D4 vs 0xD4C3B2A1) exposes the wrong-number bug.

Mistake 4 — Deciding byte order for the global header but reading records natively.

  • WRONG: swap for snaplen but read incl_len with the host's default order.
  • WHY: record fields use the same byte order as the file; mixing them corrupts every length.
  • CORRECTED: carry the little flag into read_pcap_record and use it for all fields.
  • RECOGNISE/PREVENT: parse a big-endian capture on a little-endian box; record lengths will be nonsense if you forgot.

Mistake 5 — Treating the buffer as a C string.

  • WRONG: strlen, strcpy, or stopping at a NUL byte.
  • WHY: packet data is binary and contains NULs everywhere; string functions read the wrong length.
  • CORRECTED: always carry an explicit length (n) and use memcpy with a checked count.

Debugging tips

  • Symptom: garbage lengths / millions of bytes. Likely wrong byte order. Print the magic in hex (printf("%08x", m)); a1b2c3d4 means read big-endian, d4c3b2a1 means little-endian. If your parsed incl_len looks byte-reversed (e.g. 0x28000000 instead of 0x28), you picked the wrong rd_* function.
  • Symptom: crash / ASan heap-buffer-overflow. A length check is missing or uses addition. Re-derive every bound as subtraction against n. Run under -fsanitize=address,undefined — ASan prints the exact offset and the read size, which usually points straight at the unbounded advance.
  • Symptom: parser stops after the first record on a real capture. Compare your computed off += 16 + incl_len against Wireshark's per-record offsets (Wireshark shows frame offsets). If they diverge, your incl_len read is off by an endianness or an offset (is it at +8, not +12?).
  • Symptom: n < 24 rejects a file you believe is valid. Confirm you actually read the whole file into buf (check the fread return value against the file size); a short read produces a small n.
  • Questions to ask when it fails: Did I check length before every read? Is byte order decided once and reused for all fields? Am I bounding with subtraction? Is off still <= n after the advance? Did I confuse incl_len (bound reads by this) with orig_len (informational)?
  • Useful cross-checks: xxd -l 24 file.pcap shows the raw global header bytes; tcpdump -r file.pcap -c 1 -X shows the first record decoded, so you can confirm your parsed timestamp and length against a trusted tool.

Memory safety

Memory/UB safety for this parser.

  • Every read is preceded by a bound proven with subtraction against n; read_le32/read_be32 touch only b[0..3], and callers guarantee those 4 bytes exist.
  • Cast each byte to uint32_t before shifting so the << 24 happens in an unsigned 32-bit type — no signed-overflow UB, no sign-bit surprises.
  • No *(uint32_t*) casts, so no unaligned-access UB regardless of buffer alignment.
  • Advancing the cursor only after the payload-fits check keeps off <= n as a loop invariant; the arithmetic cannot wrap.
  • Treat the buffer as bytes with an explicit length; never a NUL-terminated string.

Security & safety — detection and logging. A parser that processes untrusted captures should leave an audit trail so a hostile file is noticed, not just silently rejected.

  • Log on each rejection: timestamp, the source (file name / hash / case ID), the resource (which record offset), the result (rejected), the security decision (which rule failed: bad magic, incl_len > snaplen, payload truncated), and a correlation id tying it to the analysis session. Example line: 2026-07-08T12:00:00Z case=IR-2231 file=sha256:ab… off=24 decision=REJECT rule=incl_len_exceeds_remaining incl=4294967295 remaining=20.
  • Also log accepted-file summaries: record count, total bytes consumed vs file size (a mismatch — trailing bytes after the last record — can indicate tampering or appended data).
  • Never log: raw packet payloads that may contain credentials, session cookies, tokens, private keys, or personal data; full capture contents; or secrets from the environment. Log field lengths and offsets, not payload bytes.
  • Events that signal abuse: many records rejected for insane incl_len, a file whose declared records do not reach the file's true size, magic that is valid pcap but with snaplen far larger than any real link MTU, or a burst of malformed captures from one submitter.
  • False positives arise from legitimately truncated captures (transfer cut off mid-record), captures from tools using nanosecond magic, or pcapng files mistaken for classic pcap. Distinguish "malformed" from "malicious" — log both, escalate patterns, and let a human decide.

Real-world uses

Authorized use case. In an incident-response engagement you own or are contracted to investigate, an analyst receives suspect.pcap from a monitored segment. Wireshark refuses to open it because the file was truncated during transfer. A small hand-written walker like walk_good reads records one at a time, prints each record's offset/timestamp/length, and recovers every intact record up to the point of corruption — then stops safely instead of crashing on the damaged tail.

Authorization checklist (before touching any capture in a lab):

  1. Written scope/authorization covers this data and this host.
  2. Captures are handled on an isolated analysis VM or container, not production.
  3. Sample/lab pcaps come from systems you own, a CTF, or an intentionally-vulnerable range.
  4. No third-party live traffic is captured or replayed.
  5. Findings and logs are stored per the engagement's handling rules.

Lab cleanup / reset: delete generated test pcaps and rebuilt binaries (rm pcap pcap.o test_*.pcap), clear the scratch directory, and revert the analysis VM to a clean snapshot so no capture data lingers.

Best-practice habits:

habit beginner advanced
input validation check n >= 24, bound every read fuzz the parser (AFL++/libFuzzer), enforce incl_len <= snaplen
least privilege run the tool as a normal user run in a sandbox/container with no network, read-only mount
secure defaults reject unknown magic, stop on first bad record configurable strict vs recovery mode, both logged
logging print which rule rejected a record structured logs with file hash + correlation id
error handling return -1 and a clear message never abort() on bad input; degrade gracefully

Misconceptions to keep straight: parsing a capture without errors does not prove the traffic is benign — you have only read the container, not judged the contents. A parser passing an automated scanner does not prove it is safe against crafted input; only bounds checks + fuzzing give confidence. And nothing here makes a parser "completely secure" — it makes this class of over-read impossible, which is a specific, verifiable claim.

Practice tasks

Beginner 1 — Read one 32-bit length.

  • Objective: implement uint32_t read_le32(const unsigned char *b) and uint32_t read_be32(const unsigned char *b).
  • Requirements: byte-by-byte shifts, cast each byte to uint32_t before shifting.
  • Input/output: bytes 28 00 00 00read_le32 returns 40; read_be32 returns 0x28000000.
  • Constraints: no pointer casts, no library byte-swap builtins.
  • Hints: least-significant byte first for little-endian. Concepts: endianness, bit shifts.

Beginner 2 — Detect byte order from the magic.

  • Objective: int pcap_byte_order(const unsigned char *buf, size_t n, int *little) returning 1/0.
  • Requirements: reject n < 24; accept 0xA1B2C3D4 (big) and 0xD4C3B2A1 (little); reject anything else.
  • Output: sets *little correctly.
  • Constraints: read the magic as big-endian once.
  • Hints: unknown magic must be refused, not guessed. Concepts: magic numbers, validation.

Intermediate 1 — Parse one record header safely.

  • Objective: int read_pcap_record(const unsigned char *buf, size_t n, size_t off, int little, pcap_rec_hdr_t *out).
  • Requirements: verify the 16-byte header fits (subtraction bound), fill all four fields in the file's byte order, reject incl_len > orig_len, incl_len > snaplen, and a payload that does not fit.
  • Input/output: return 1 and a filled struct for a valid record; 0 for any failure.
  • Constraints: no addition-based bounds checks.
  • Hints: compute remaining = n - off - 16 only after proving n - off >= 16. Concepts: structs, bounds checking.

Intermediate 2 — Walk and count records.

  • Objective: int walk(const unsigned char *buf, size_t n) returning the record count, or -1 on the first malformed record.
  • Requirements: use the byte order from the global header; advance off += 16 + incl_len only after validation; stop safely at end or on error.
  • Output: prints one summary line per record; returns the count.
  • Constraints: never read past n; log which rule rejected a bad record.
  • Hints: keep off <= n as a loop invariant. Concepts: cursor walking, invariants.

Challenge — Malformed-capture hardening (lab only).

  • Objective: in a local, isolated, authorized lab, build a few hand-crafted .pcap byte buffers that try to break the parser (huge incl_len, incl_len > orig_len, header straddling end-of-file, trailing bytes after the last record) and prove your parser survives.
  • Requirements: a test harness that feeds each malformed buffer to walk, asserts it returns -1 (or the correct count) and never over-reads; run the whole thing under -fsanitize=address,undefined.
  • Constraints: all inputs are synthetic buffers you build in code — no third-party or live captures. Emit a structured log line for each rejection (offset + rule), logging lengths and offsets only, never payload bytes.
  • Defensive conclusion: for every crafted attack, state the vulnerable assumption, the bounds check that neutralizes it, and how ASan/UBSan verifies no over-read occurred. Cleanup: delete the generated buffers/binaries and reset the lab.
  • Hints: put_le32 helpers make building test bytes easy; assert both the accept and reject paths. Concepts: fuzz-style testing, mitigation verification, detection logging.

Summary

  • Layout: a .pcap is a 24-byte global header then a stream of records; each record is a 16-byte header (ts_sec, ts_usec, incl_len, orig_len) followed by incl_len payload bytes. The next record is at off + 16 + incl_len.
  • Byte order: the magic (0xA1B2C3D4 big-endian, 0xD4C3B2A1 little-endian) decides the byte order for every field. Assemble integers with read_le32/read_be32 shifts — never *(uint32_t*)p.
  • The security rule: incl_len is attacker-controlled. Before using it, prove incl_len <= orig_len, incl_len <= snaplen, and (with subtraction, never addition) that the payload fits in the bytes you have. Advance the cursor only after those checks, keeping off <= n.
  • Common mistakes: trusting incl_len, addition-based bounds that overflow, unaligned pointer casts, forgetting the file's byte order, treating binary as a string.
  • Verify + detect: test that the parser rejects a lying incl_len and accepts a valid record; run under ASan/UBSan; log every rejection (timestamp, source hash, offset, failed rule, correlation id) but never log payload bytes. Remember: parsing cleanly does not prove the traffic is safe, and no parser is ever "completely secure" — but this over-read class can be made provably impossible.

Practice with these exercises