Safe Penetration Testing Labs · intermediate · ~15 min
- 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.
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.
In authorized, professional work, reading a pcap by hand is a core skill for several roles:
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.
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.
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.
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).
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.
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.
incl_len <= remaining remove it?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];
}
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.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;
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:
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).
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:
0 if the magic matches 0xa1b2c3d4.-1 otherwise, or if n < 24.n < 24. Always check the length first.magic as an int. This is alignment-unsafe on some platforms. Build the integer byte by byte with bit shifts instead.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.
Walking the secure parser, the security-critical path:
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.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.walk_good prints a reject and stops. A .pcapng file (different magic) is refused rather than misparsed.off + 16 <= n guarantees a full 16-byte record header is present before read_pcap_record touches it.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.rd selected once from little, so every field uses the same byte order the magic dictated.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.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.
Mistake 1 — Trusting incl_len to advance the cursor.
off += 16 + incl_len; with no check.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.n - off - 16 >= incl_len (subtraction form, no overflow) before advancing.-fsanitize=address,undefined; feed a fuzzed/hand-edited pcap; ASan flags the over-read instantly.Mistake 2 — off + 16 <= n overflow.
off + payload <= n when off is near SIZE_MAX.off + payload can wrap and pass a check it should fail.off <= n && n - off >= 16.Mistake 3 — Casting the pointer to read an integer.
uint32_t incl = *(uint32_t*)(buf + off + 8);buf + off + 8 is not 4-byte aligned, and it silently produces the wrong number when file endianness differs from the CPU.read_le32/read_be32 byte assembly.0xA1B2C3D4 vs 0xD4C3B2A1) exposes the wrong-number bug.Mistake 4 — Deciding byte order for the global header but reading records natively.
snaplen but read incl_len with the host's default order.little flag into read_pcap_record and use it for all fields.Mistake 5 — Treating the buffer as a C string.
strlen, strcpy, or stopping at a NUL byte.n) and use memcpy with a checked count.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.n. Run under -fsanitize=address,undefined — ASan prints the exact offset and the read size, which usually points straight at the unbounded advance.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?).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.off still <= n after the advance? Did I confuse incl_len (bound reads by this) with orig_len (informational)?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/UB safety for this parser.
n; read_le32/read_be32 touch only b[0..3], and callers guarantee those 4 bytes exist.uint32_t before shifting so the << 24 happens in an unsigned 32-bit type — no signed-overflow UB, no sign-bit surprises.*(uint32_t*) casts, so no unaligned-access UB regardless of buffer alignment.off <= n as a loop invariant; the arithmetic cannot wrap.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.
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.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.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):
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.
Beginner 1 — Read one 32-bit length.
uint32_t read_le32(const unsigned char *b) and uint32_t read_be32(const unsigned char *b).uint32_t before shifting.28 00 00 00 → read_le32 returns 40; read_be32 returns 0x28000000.Beginner 2 — Detect byte order from the magic.
int pcap_byte_order(const unsigned char *buf, size_t n, int *little) returning 1/0.n < 24; accept 0xA1B2C3D4 (big) and 0xD4C3B2A1 (little); reject anything else.*little correctly.Intermediate 1 — Parse one record header safely.
int read_pcap_record(const unsigned char *buf, size_t n, size_t off, int little, pcap_rec_hdr_t *out).incl_len > orig_len, incl_len > snaplen, and a payload that does not fit.remaining = n - off - 16 only after proving n - off >= 16. Concepts: structs, bounds checking.Intermediate 2 — Walk and count records.
int walk(const unsigned char *buf, size_t n) returning the record count, or -1 on the first malformed record.off += 16 + incl_len only after validation; stop safely at end or on error.n; log which rule rejected a bad record.off <= n as a loop invariant. Concepts: cursor walking, invariants.Challenge — Malformed-capture hardening (lab only).
.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.walk, asserts it returns -1 (or the correct count) and never over-reads; run the whole thing under -fsanitize=address,undefined.put_le32 helpers make building test bytes easy; assert both the accept and reject paths. Concepts: fuzz-style testing, mitigation verification, detection logging..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.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.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.incl_len, addition-based bounds that overflow, unaligned pointer casts, forgetting the file's byte order, treating binary as a string.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.