Safe Penetration Testing Labs · beginner · ~12 min
## What you will learn - Recognise **magic numbers**: the fixed prefix bytes that identify a file or firmware format. - Write a `detect_firmware_type()` function in C11 that **bounds-checks before every comparison** so it never reads past the end of a buffer. - Compare raw bytes correctly, including a big-endian magic (U-Boot uImage) versus how it looks on disk. - Build a small, table-driven signature matcher instead of a tangle of `if` statements. - Treat every input file as **untrusted attacker-controlled data** and reason about what happens when it is truncated, empty, or NULL. - Log a classification decision safely for a forensic audit trail — and know what must never be logged.
Security objective. In an authorized firmware-analysis lab you are handed an unknown binary blob — a router image, an IoT update file, a dump pulled from a chip you are allowed to examine. The asset you protect is your own analysis workstation and the integrity of your investigation. The threat is a malformed or hostile file crafted to make your parser read out of bounds, crash, or mislead you. Before you mount, decompress, or unpack anything, you first answer one small question safely: what kind of file is this? You will learn to detect that from the first few bytes without trusting the file's length, contents, or claims.
Every format leaves a fingerprint at its very start. A magic number is a fixed sequence of bytes at offset 0 that identifies the format. binwalk, file, unblob, and the Linux kernel all begin the same way: read a handful of bytes, compare them to a table of known signatures, and branch. You are going to build the tiny, careful core of that step.
This lesson builds directly on your two prerequisites. From pointers you use const uint8_t *buf as a read-only view into bytes you do not own, and you never dereference it without first proving it is safe. From endianness and byte order you use the key insight that a multi-byte magic is just bytes in a specific order on disk — you compare byte-for-byte and do not let the CPU's native order confuse you. The uImage magic 27 05 19 56 is stored big-endian, and getting that order right is the difference between a correct classifier and a silent bug.
Firmware analysis is a core skill in embedded security, vulnerability research, and incident response. When a security team is handed a router update to review (with the vendor's authorization) or an investigator pulls an image off a seized-but-in-scope device, the first pipeline stage is always identification. Get it wrong and everything downstream — the wrong extractor, the wrong offset, a mounted filesystem that isn't what you thought — is wrong too.
The reason this is a security lesson and not just a parsing exercise is that the file is hostile input. A production parser that reads 4 bytes without checking the length is a classic out-of-bounds read: on a truncated or crafted file it can crash the tool, leak adjacent memory into a log, or be the first domino in a larger exploit against your own tooling. Professionals treat the identifier as the first line of defense: it is small, it is fully bounds-checked, and it fails safe. Learning to write that — careful, defensive, auditable — is the habit the whole track is built on.
Definition. A magic number is a short, fixed byte sequence at a known offset (almost always offset 0) that identifies a file format.
Plain explanation. File extensions lie and can be renamed; the bytes inside are harder to fake by accident. So tools identify a format by content, not by name. ELF files literally start with 7F 45 4C 46 — the last three bytes are the ASCII letters E, L, F.
How it works. You read the first k bytes into a buffer and memcmp them against each known signature. First match wins.
When / when not. Great as a fast first classifier. It is not proof: a file can begin with valid magic and be garbage (or malicious) after byte 4. Never treat a magic match as a security guarantee.
Pitfall. Some formats share short prefixes, and a 2-byte magic collides far more often than an 8-byte one. Order your checks and prefer longer signatures where you can.
| Magic (hex) | Meaning | Bytes on disk |
|---|---|---|
4D 5A |
PE / DOS executable (MZ) |
4D 5A |
7F 45 4C 46 |
ELF | 7F 'E' 'L' 'F' |
27 05 19 56 |
U-Boot uImage (big-endian) | 27 05 19 56 |
68 73 71 73 |
Squashfs (hsqs) |
68 73 71 73 |
19 85 |
JFFS2 | 19 85 |
Definition. Confirming the buffer actually holds at least as many bytes as you are about to compare, before you touch them.
Plain explanation. n is the number of valid bytes. If n is 3 and you memcmp 4 bytes, the 4th read is undefined behaviour — an out-of-bounds read.
How it works. Before comparing a k-byte magic, require n >= k. If the buffer is too short for that magic, skip it and try the next (shorter) one.
When / when not. Always. There is no situation where skipping the length check is acceptable on untrusted input.
Pitfall. Checking the length once at the top (if (n < 4) return 0;) breaks the 2-byte magics — a valid 2-byte JFFS2 file with n == 2 would be wrongly rejected. Check per-signature.
Definition. A multi-byte magic is defined as an exact ordered byte string on disk, independent of your CPU.
Plain explanation. The uImage header field is a big-endian 32-bit value 0x27051956. Stored big-endian, the bytes in the file are 27 05 19 56. If you loaded it into a uint32_t on a little-endian machine and compared to 0x27051956, you would be wrong. Comparing byte-by-byte with memcmp against {0x27,0x05,0x19,0x56} sidesteps the whole problem.
Pitfall. Reading *(uint32_t*)buf is both an alignment hazard and an endianness trap. Prefer memcmp on byte arrays.
UNTRUSTED INPUT TRUST BOUNDARY YOUR TOOLING (asset)
+-----------------------------+ | +---------------------------------+
| unknown_firmware.bin | | detect_firmware_type() | analysis workstation |
| - may be truncated (n<k) | ---> | * NULL check | forensic log / case notes |
| - may be empty (n==0) | | * per-magic length check| downstream extractor (binwalk) |
| - crafted / hostile bytes | | * memcmp, no *(u32*)cast| |
+-----------------------------+ | RETURNS a tag, no I/O +---------------------------------+
entry point: file read | fails safe -> 0 or -1
The firmware file is entry point and untrusted. The classifier sits on the trust boundary: it must survive any bytes and produce a tag without ever reading out of bounds.
Knowledge check.
memcmp(buf, sig, 4) into an out-of-bounds read?The key building block is a length check followed by a byte-exact memcmp. memcmp returns 0 when the regions are equal.
#include <stdint.h> /* uint8_t */
#include <string.h> /* memcmp */
#include <stddef.h> /* size_t */
/* Compare k bytes ONLY if the buffer really has k bytes. */
static int magic_at_start(const uint8_t *buf, size_t n,
const uint8_t *sig, size_t k) {
if (n < k) return 0; /* too short -> not a match, no read */
return memcmp(buf, sig, k) == 0; /* byte-exact, endianness-proof */
}
Notes:
const uint8_t * says "read-only bytes I do not own." Keep it const.n < k guard runs before memcmp, so memcmp never reads past buf[n-1].{0x27,0x05,0x19,0x56} encode the on-disk order directly, so big-endian magics need no byte-swap.Before you can mount or extract a firmware image, you need to know what it is.
Tools like binwalk and file start with the same simple trick: they read the first 4-8 bytes of a file and compare them against a table of known signatures.
These signatures are called magic numbers (or magic bytes): a fixed sequence of bytes at the start of a file that identifies its format.
Each format begins with its own distinctive byte sequence:
| Magic | Meaning |
|---|---|
4D 5A |
PE / DOS executable |
7F 45 4C 46 |
ELF |
27 05 19 56 |
U-Boot uImage (big-endian) |
68 73 71 73 |
Squashfs (hsqs) |
19 85 |
JFFS2 |
Implement this function:
int detect_firmware_type(const uint8_t *buf, size_t n);
Return one of these values:
1 = PE2 = ELF3 = uImage4 = Squashfs5 = JFFS20 = unknown, but the pointer is valid-1 = buf is NULLRules:
n is too short to check a given magic, skip it and try the next one.0.27 05 19 56.n < 4. This reads past the end of the buffer. Always check the length first.binwalk replacement. We classify the file; we do not extract its contents.Below: an insecure version to study, the secure fix, and a verification harness.
/* INSECURE: no length checks, endianness bug. For study only. */
#include <stdint.h>
#include <string.h>
#include <stddef.h>
int detect_bad(const uint8_t *buf, size_t n) {
(void)n; /* length ignored -> OOB read */
if (buf[0]==0x4D && buf[1]==0x5A) return 1; /* PE */
if (memcmp(buf, "\x7F" "ELF", 4) == 0) return 2; /* ELF */
/* BUG: treats uImage magic as a native uint32 -> wrong on little-endian */
if (*(const uint32_t *)buf == 0x27051956u) return 3; /* uImage */
return 0;
}
Why it is dangerous: on a 1-byte or empty buffer, buf[1] and the 4-byte reads run off the end (undefined behaviour, possible crash or memory disclosure). The *(const uint32_t*)buf cast is also an alignment hazard and gives the wrong answer on a little-endian CPU.
#include <stdint.h>
#include <string.h>
#include <stddef.h>
enum { FW_NULL = -1, FW_UNKNOWN = 0, FW_PE = 1, FW_ELF = 2,
FW_UIMAGE = 3, FW_SQUASHFS = 4, FW_JFFS2 = 5 };
static int magic_at_start(const uint8_t *buf, size_t n,
const uint8_t *sig, size_t k) {
if (n < k) return 0;
return memcmp(buf, sig, k) == 0;
}
int detect_firmware_type(const uint8_t *buf, size_t n) {
if (buf == NULL) return FW_NULL;
static const uint8_t pe[] = {0x4D, 0x5A};
static const uint8_t elf[] = {0x7F, 0x45, 0x4C, 0x46};
static const uint8_t uimg[] = {0x27, 0x05, 0x19, 0x56}; /* big-endian on disk */
static const uint8_t sqsh[] = {0x68, 0x73, 0x71, 0x73}; /* "hsqs" */
static const uint8_t jffs2[] = {0x19, 0x85};
/* Longer / more specific magics first. */
if (magic_at_start(buf, n, elf, sizeof elf)) return FW_ELF;
if (magic_at_start(buf, n, uimg, sizeof uimg)) return FW_UIMAGE;
if (magic_at_start(buf, n, sqsh, sizeof sqsh)) return FW_SQUASHFS;
if (magic_at_start(buf, n, pe, sizeof pe)) return FW_PE;
if (magic_at_start(buf, n, jffs2, sizeof jffs2)) return FW_JFFS2;
return FW_UNKNOWN;
}
#include <stdio.h>
#include <assert.h>
int main(void) {
/* Good inputs are ACCEPTED. */
const uint8_t elf[] = {0x7F,'E','L','F', 0,0,0,0};
const uint8_t uimg[] = {0x27,0x05,0x19,0x56, 0xAA};
const uint8_t sqsh[] = {'h','s','q','s'};
const uint8_t pe[] = {0x4D,0x5A,0x90};
const uint8_t jf[] = {0x19,0x85};
assert(detect_firmware_type(elf, sizeof elf) == FW_ELF);
assert(detect_firmware_type(uimg, sizeof uimg) == FW_UIMAGE);
assert(detect_firmware_type(sqsh, sizeof sqsh) == FW_SQUASHFS);
assert(detect_firmware_type(pe, sizeof pe) == FW_PE);
assert(detect_firmware_type(jf, sizeof jf) == FW_JFFS2);
/* Bad / hostile inputs are REJECTED safely, no OOB read. */
assert(detect_firmware_type(NULL, 0) == FW_NULL);
const uint8_t empty[1] = {0};
assert(detect_firmware_type(empty, 0) == FW_UNKNOWN); /* n==0 */
const uint8_t truncated[] = {0x7F}; /* ELF cut to 1 byte */
assert(detect_firmware_type(truncated, 1) == FW_UNKNOWN);
const uint8_t junk[] = {0x00,0x11,0x22,0x33};
assert(detect_firmware_type(junk, sizeof junk) == FW_UNKNOWN);
puts("all classification checks passed");
return 0;
}
Build and run (lab):
cc -std=c11 -Wall -Wextra -fsanitize=address,undefined detect.c -o detect
./detect
Expected output: all classification checks passed. ASan/UBSan report nothing — the truncated and empty cases prove no read runs past buf[n-1].
Walking the secure detect_firmware_type:
if (buf == NULL) return FW_NULL; — the first and cheapest defense. A NULL pointer is a caller error or a failed file read; we report -1 instead of crashing on buf[0].static const signature arrays store each magic as its on-disk bytes. uimg[] = {0x27,0x05,0x19,0x56} is the big-endian value written out; we never build it as an integer, so no byte-swap is needed.magic_at_start(...) call first tests n < k. If the buffer is shorter than that magic, it returns 0 without calling memcmp — this is where the out-of-bounds read is prevented.n >= k, memcmp(buf, sig, k) == 0 does a byte-exact comparison. Equality means the format matches.return FW_UNKNOWN; (0) — a valid pointer, just an unrecognised format.Trace for truncated = {0x7F}, n = 1:
| Step | Check | n < k? |
memcmp run? |
Result |
|---|---|---|---|---|
| ELF | k=4 | 1 < 4 yes | no | skip |
| uImage | k=4 | 1 < 4 yes | no | skip |
| Squashfs | k=4 | 1 < 4 yes | no | skip |
| PE | k=2 | 1 < 2 yes | no | skip |
| JFFS2 | k=2 | 1 < 2 yes | no | skip |
| end | — | — | — | return 0 |
Every comparison is skipped by the length guard, so the lone 0x7F byte is never over-read and the function returns FW_UNKNOWN safely.
| WRONG approach | WHY it is wrong | CORRECTED | How to recognise / prevent |
|---|---|---|---|
One length check at the top: if (n < 4) return 0; |
Rejects valid 2-byte magics (JFFS2, PE) when n is 2 or 3, and still risks reads if you later add a longer magic |
Check n >= k per signature inside the helper |
Unit test with n == 2; it must classify a 2-byte magic |
*(const uint32_t *)buf == 0x27051956u |
Endianness-dependent (wrong on little-endian) and an unaligned-read UB hazard | memcmp(buf, (uint8_t[]){0x27,0x05,0x19,0x56}, 4) |
Run under UBSan; it flags misaligned/OOB access |
Ignoring the n parameter entirely |
Out-of-bounds read on truncated/empty files — a real memory-safety bug | Pass n into every comparison and guard on it |
ASan crashes on the truncated test case |
Returning 0 for a NULL pointer |
Hides a caller bug and dereferences NULL right after | Return FW_NULL (-1) before touching buf |
Add assert(detect(NULL,0) == -1) |
| Testing 2-byte magics before 4-byte ones | Short magics collide and shadow more specific formats | Order checks specific-first | Feed an ELF and confirm it isn't misread as something shorter |
| Treating a magic match as "this file is safe" | Bytes after the magic can be hostile; magic proves format, not safety | Classify, then still parse defensively downstream | Never let a match skip later validation |
printf("%02X %02X %02X %02X\n", buf[0],buf[1],buf[2],buf[3]); (only after confirming n >= 4). Compare against your signature table.-fsanitize=address,undefined. ASan pinpoints the exact out-of-bounds read; UBSan catches misaligned integer casts.memcmp on the byte array {0x27,0x05,0x19,0x56}.fopen(path,"rb")); text mode can mangle bytes on some platforms.n before every read? Is my signature in on-disk byte order? Am I returning the right enum constant? Did I read the real first bytes, or a copy that got truncated on load?xxd -l 16 file.bin or file file.bin in the lab to confirm the true magic before blaming your code.Memory safety (C). The only pointer you dereference is buf, and only after (a) proving it is non-NULL and (b) proving n >= k for the exact number of bytes you read. memcmp with a guarded k never walks off the end. Keep buf const so you cannot accidentally write through it. Avoid *(uint32_t*)buf casts entirely — they are both alignment UB and endianness traps; byte-wise memcmp is safe and portable. Compile the lab with -fsanitize=address,undefined so any stray read is caught immediately.
Security & safety — detection & logging. Because you are classifying attacker-controlled files, keep a forensic trail.
classified / unknown / rejected-null). Example line: 2026-07-08T10:15Z case=IOT-42 sha256=<hash> bytes=8 result=FW_SQUASHFS.FW_UNKNOWN on files that claim a firmware extension; truncated files (n far smaller than expected); or the same hash reappearing across cases.MZ or hsqs by coincidence, and a 2-byte magic like JFFS2 collides easily. Treat a match as a lead, confirm with full parsing before acting, and never claim a file is safe just because it classified.Authorized real-world use. A security engineer reviewing a vendor's router firmware (under a signed engagement) runs an intake step that hashes each blob, classifies it by magic, and routes it to the right extractor — squashfs to an unpacker, uImage to a header parser, gzip to a decompressor. The classifier itself does no I/O and cannot be exploited by the file's contents, so it is safe to run first on unknown input.
Best-practice habits.
n and buf as untrusted.FW_UNKNOWN, never a guess that triggers an unpacker.-1 NULL) rather than crashing.| Level | Focus |
|---|---|
| Beginner | Correct magic table, per-magic length checks, right enum values, binary-mode reads |
| Advanced | Confidence scoring across multiple offsets, entropy checks to spot encryption/compression, integrating with binwalk/unblob, quarantining and cleanly resetting the lab between samples |
Authorization checklist (before touching any firmware): written scope naming the device/image; a copy (never the only original); an isolated offline lab (container/VM/CTF box); an agreed reset/cleanup procedure; and a rule that findings stay within the engagement.
All tasks are lab-only: work on files you created or are explicitly authorized to analyse, inside an isolated offline environment. Each ends by remediating and verifying.
Beginner 1 — gzip detector.
int is_gzip(const uint8_t *b, size_t n) returning 1 if the first two bytes are the gzip magic 1F 8B, else 0, and 0 if n < 2.n < 2.memcmp.Beginner 2 — NULL and empty hardening.
detect_firmware_type to return -1 for NULL and 0 for n == 0, then prove it.NULL, n==0, and a 1-byte truncated ELF.-fsanitize=address,undefined; zero sanitizer reports.Intermediate 1 — table-driven matcher.
if chain with a struct { const uint8_t *sig; size_t len; int tag; } array and a loop.73 71 73 68 (sqsh) as a second entry mapping to the same tag.magic_at_start helper.Intermediate 2 — safe file intake + logging.
FW_UNKNOWN and exit cleanly.fopen(path,"rb"), fread, then reuse the classifier.Challenge — confidence + quarantine.
quarantine/ folder without unpacking.FW_UNKNOWN; a defensive conclusion: nothing is unpacked automatically.quarantine/, and restore the lab to a clean snapshot before the next sample.n >= k), per signature — not once at the top. This is the memory-safety heart of the lesson.memcmp on on-disk byte order; the uImage magic 27 05 19 56 is big-endian, so never load it as a native integer.-1 for NULL, 0 for unknown; longer/more-specific magics checked first.n, a single top-level length check, the *(uint32_t*) endianness/alignment trap, and trusting a match as proof of safety.