Safe Penetration Testing Labs · beginner · ~12 min

Classify a firmware blob from its magic bytes

## 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.

Overview

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.

Why it matters

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.

Core concepts

1. Magic numbers (file signatures)

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

2. Bounds-checking before every read

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.

3. Byte order in a magic

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.

Threat model

            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.

  1. What asset is protected by bounds-checking here — the firmware file, or your own analysis tool?
  2. Where exactly is the trust boundary in the diagram above?
  3. What insecure assumption turns memcmp(buf, sig, 4) into an out-of-bounds read?
  4. Why does classifying a blob only prove its format, never that it is safe to unpack?
  5. Why must you only test unknown firmware in an isolated, authorized lab rather than on a live production device?

Syntax notes

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.
  • The n < k guard runs before memcmp, so memcmp never reads past buf[n-1].
  • Byte arrays like {0x27,0x05,0x19,0x56} encode the on-disk order directly, so big-endian magics need no byte-swap.

Lesson

Why this matters

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.

What the magics look like

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

Your job

Implement this function:

int detect_firmware_type(const uint8_t *buf, size_t n);

Return one of these values:

  • 1 = PE
  • 2 = ELF
  • 3 = uImage
  • 4 = Squashfs
  • 5 = JFFS2
  • 0 = unknown, but the pointer is valid
  • -1 = buf is NULL

Rules:

  • Check the buffer length before each comparison.
  • If n is too short to check a given magic, skip it and try the next one.
  • If no magic matches, return 0.

Common mistakes

  • Treating uImage's magic as little-endian. It is stored big-endian on disk, so the bytes appear in the order 27 05 19 56.
  • Reading 4 bytes when n < 4. This reads past the end of the buffer. Always check the length first.
  • Returning the wrong constant for a matched format.

What this is NOT

  • Not a binwalk replacement. We classify the file; we do not extract its contents.
  • Not a virus scanner.

Code examples

Below: an insecure version to study, the secure fix, and a verification harness.

1. WARNING: intentionally vulnerable — use only in a local, isolated, authorized lab. Do not deploy.

/* 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.

2. SECURE fix

#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;
}

3. VERIFY — proves the fix rejects bad input and accepts good input

#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].

Line by line

Walking the secure detect_firmware_type:

  1. 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].
  2. The 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.
  3. Each 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.
  4. When n >= k, memcmp(buf, sig, k) == 0 does a byte-exact comparison. Equality means the format matches.
  5. Order matters: we test the 4-byte magics before 2-byte ones so a longer, more specific signature wins and we reduce accidental collisions.
  6. If nothing 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.

Common mistakes

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

Debugging tips

  • Wrong tag returned? Print the first bytes: printf("%02X %02X %02X %02X\n", buf[0],buf[1],buf[2],buf[3]); (only after confirming n >= 4). Compare against your signature table.
  • Crash or silent corruption on short files? Build with sanitizers: -fsanitize=address,undefined. ASan pinpoints the exact out-of-bounds read; UBSan catches misaligned integer casts.
  • uImage never matches? You almost certainly compared it as a native integer. Switch to memcmp on the byte array {0x27,0x05,0x19,0x56}.
  • Everything returns 0? Confirm you are reading the file in binary mode (fopen(path,"rb")); text mode can mangle bytes on some platforms.
  • Questions to ask when it fails: Did I check 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?
  • Cross-check with xxd -l 16 file.bin or file file.bin in the lab to confirm the true magic before blaming your code.

Memory safety

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.

  • Log: timestamp; a case/correlation id; the file's SHA-256 (identity without storing contents); the detected tag; and the decision (classified / unknown / rejected-null). Example line: 2026-07-08T10:15Z case=IOT-42 sha256=<hash> bytes=8 result=FW_SQUASHFS.
  • Never log: the raw firmware bytes wholesale, embedded credentials or keys that firmware often contains, private keys, or any extracted PII. A hash and a tag are enough to correlate later.
  • Signals of abuse / interest: a spike of FW_UNKNOWN on files that claim a firmware extension; truncated files (n far smaller than expected); or the same hash reappearing across cases.
  • False positives arise because magic matching is only a prefix test: a benign file can start with 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.

Real-world uses

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.

  • Input validation: bounds-check before every read; treat n and buf as untrusted.
  • Least privilege: run analysis in a container/VM with no network and a non-root user; the classifier needs only the bytes, not the network.
  • Secure defaults: unknown or ambiguous → FW_UNKNOWN, never a guess that triggers an unpacker.
  • Logging & error handling: record hash + tag + decision; return typed error codes (-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.

Practice tasks

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.

  • Objective: Write 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.
  • I/O: input a byte buffer + length; output 0/1.
  • Constraints: no reads when n < 2.
  • Hints: one length guard, one memcmp.
  • Concepts: magic numbers, bounds-checking.

Beginner 2 — NULL and empty hardening.

  • Objective: Extend detect_firmware_type to return -1 for NULL and 0 for n == 0, then prove it.
  • Requirements: assertions for NULL, n==0, and a 1-byte truncated ELF.
  • Constraints: build with -fsanitize=address,undefined; zero sanitizer reports.
  • Hints: NULL check first, before any dereference.
  • Concepts: fail-safe defaults, memory safety.

Intermediate 1 — table-driven matcher.

  • Objective: Replace the if chain with a struct { const uint8_t *sig; size_t len; int tag; } array and a loop.
  • Requirements: same return values; add Squashfs little-endian variant 73 71 73 68 (sqsh) as a second entry mapping to the same tag.
  • Constraints: order entries longest/most-specific first.
  • Hints: iterate, call your magic_at_start helper.
  • Concepts: data-driven design, collision handling.

Intermediate 2 — safe file intake + logging.

  • Objective: Read a file in binary mode, classify it, and print a log line with SHA-256, byte count, and tag.
  • Requirements: handle short reads; never print raw file bytes.
  • Constraints: if the file is 0 bytes, log FW_UNKNOWN and exit cleanly.
  • Hints: fopen(path,"rb"), fread, then reuse the classifier.
  • Concepts: untrusted I/O, forensic logging, what-not-to-log.

Challenge — confidence + quarantine.

  • Objective: For a directory of lab samples, classify each, flag files whose magic matches but whose length is implausibly small (e.g. a 4-byte "squashfs"), and route suspicious ones to a quarantine/ folder without unpacking.
  • Requirements: a summary count per tag and per FW_UNKNOWN; a defensive conclusion: nothing is unpacked automatically.
  • Constraints: read-only on the originals; all work on copies; offline lab.
  • Hints: combine the table matcher, length sanity thresholds, and logging.
  • Concepts: defense in depth, detection heuristics, false positives.
  • Remediate + verify: after the run, confirm every quarantined file is intact and unopened, then cleanup/reset: delete extracted copies, wipe quarantine/, and restore the lab to a clean snapshot before the next sample.

Summary

Key takeaways

  • A magic number is a fixed prefix that identifies a format; classify by content, not by extension.
  • Bounds-check before every comparison (n >= k), per signature — not once at the top. This is the memory-safety heart of the lesson.
  • Compare magics with memcmp on on-disk byte order; the uImage magic 27 05 19 56 is big-endian, so never load it as a native integer.
  • Fail safe: -1 for NULL, 0 for unknown; longer/more-specific magics checked first.
  • A magic match proves format, not safety — still parse defensively downstream, and log a hash + tag + decision while never logging raw bytes, keys, or PII.
  • Common mistakes: ignoring n, a single top-level length check, the *(uint32_t*) endianness/alignment trap, and trusting a match as proof of safety.
  • Remember: classification is the first, fully-bounded stage of a firmware pipeline — small, careful, auditable, and lab-only on authorized samples.

Practice with these exercises