Safe Penetration Testing Labs · intermediate · ~15 min

Pull the filename out of a mock MFT record

By the end of this lesson you will be able to: - Read a fixed binary layout by offset: a 4-byte magic signature, then two little-endian `u16` fields. - Validate a file-format signature with `memcmp` instead of string functions, and explain why that matters for untrusted binary data. - Bounds-check attacker-influenced offset and length fields **before** touching memory, using overflow-safe arithmetic. - Perform a bounded copy into a caller-supplied buffer and NUL-terminate the result. - Design a parser as a *defensive boundary* that treats input as hostile and fails closed with a clear error code. - Trace how a single missing bounds check turns a metadata parser into an out-of-bounds read.

Overview

Security objective. The asset you are protecting is the memory of the process doing the parsing — and, indirectly, the integrity of any forensic report built from it. The threat is a malformed or maliciously crafted file-system record: bytes that came off a disk image you did not create, where an offset or length field points outside the buffer. The skill you are building is the one that detects that bad input and refuses it, instead of reading past the end of the record and leaking or crashing.

NTFS (the default Windows file system) stores its metadata in the Master File Table (MFT). Every file on the volume has at least one MFT record describing it — its name, size, timestamps, and where its data lives on disk. Forensic and incident-response tools walk the MFT to reconstruct what files existed, when they changed, and what was deleted.

Real MFT records are dense: attribute lists, fixup arrays, resident and non-resident runlists. Parsing the true format means implementing a large part of the NTFS specification. That is too much surface area to learn one skill on. So this lesson uses a simplified mock record with a tiny fixed layout. The format is fake; the discipline — check the signature, bounds-check every offset, copy within limits — is exactly what the real format demands, and exactly what separates a safe parser from an exploitable one.

This builds directly on your prerequisites. From pointers you already know how to walk a const uint8_t * buffer and read individual bytes. From bounded-copy you know that every copy needs a destination capacity and a check that the source fits. Here we put those together against untrusted data, where the length telling you how much to copy is itself part of the input and cannot be trusted.

Why it matters

In authorized professional work — digital forensics, incident response, malware triage, EDR development — you routinely parse binary structures that originated on a machine you do not control. A ransomware sample, a disk image from a compromised host, a memory dump: all of it is adversarial input. The record you are reading may have been deliberately corrupted to break the tools examining it, a technique called anti-forensics.

A parser that trusts an in-band length field is a classic vulnerability class. If name_length says 60000 but the record is 512 bytes long, an unchecked memcpy reads ~59 KB past the buffer. Best case your tool crashes mid-investigation and you lose the analysis session. Worse case, in a service that parses uploaded images, that out-of-bounds read leaks adjacent heap memory into output an attacker can see — an information-disclosure bug. Real CVEs in file-carving and forensic libraries have exactly this shape: a length or offset from the file was used without validation.

Learning the defensive shape on a mock record means that when you move to The Sleuth Kit, a custom EDR agent, or a CTF forensics challenge, bounds-checking untrusted offsets is already a reflex, not an afterthought.

Core concepts

1. The MFT record as a trust boundary

Definition. A trust boundary is the line where data crosses from a source you control into your program's logic. Everything on the far side is untrusted until validated.

Plain explanation. Your function receives rec (the raw record bytes) and n (how many bytes are actually valid). Those bytes came from a disk image — an attacker or a corrupted volume could have written anything there. The moment you interpret a field as an offset or length and act on it, you have crossed the boundary. Validation is the gate on that crossing.

How it works. You never dereference an offset from the record until you have proven, with arithmetic on n, that the range it names lies entirely inside [0, n).

When / when not. Always bounds-check fields that come from the file. You do not need to bounds-check cap against itself — that is a value the caller (trusted code) supplied. But you do still verify the copy fits in cap, because the untrusted name_length decides how many bytes you want to write.

Pitfall. Assuming that because n bytes exist, an offset < n is safe. An offset of 6 is inside the buffer, but a name starting at 6 with length 4000 is not.

2. Signature validation with memcmp

Definition. A magic signature is a fixed byte pattern at a known offset that identifies the format. A valid mock record starts with 'F','I','L','E'; a corrupt one NTFS itself marks as 'B','A','A','D'.

Plain explanation. Before trusting any other field, confirm the record is the type you expect. If the first four bytes are not FILE, stop — everything after is meaningless.

How it works. memcmp(rec, "FILE", 4) == 0 compares exactly four bytes. It does not stop at a NUL byte and does not need the buffer to be a C string.

When / when not. Use memcmp for fixed-length binary comparisons. Do not use strncmp/strcmp on raw record bytes: those stop at the first \0, and binary records contain \0 everywhere, so the comparison can succeed on a buffer that only partially matches.

Pitfall. Writing rec == "FILE" compares pointers, not contents — it is always false. And checking the signature after reading offsets is backwards: validate first, read second.

3. Little-endian u16 fields

Definition. A 16-bit unsigned integer stored little-endian puts the least-significant byte first. The two bytes 0x10, 0x00 mean 0x0010 = 16.

Plain explanation. You cannot cast (uint16_t*)(rec+4) portably — that risks an unaligned read and depends on the host's byte order. Instead, assemble the value byte by byte so it works the same everywhere.

How it works. value = rec[4] | (rec[5] << 8). The low byte goes in as-is; the high byte is shifted up 8 bits.

When / when not. Do this for every multi-byte field read from a fixed on-disk format. When the format is big-endian instead, swap the shift ((rec[4] << 8) | rec[5]).

Pitfall. Reading bytes 4 and 5 requires n >= 6; reading through byte 7 requires n >= 8. Read the header fields only after confirming the header is fully present.

4. Overflow-safe bounds arithmetic

Definition. Integer overflow is when a sum exceeds the type's maximum and wraps around to a small value.

Plain explanation. The check off + len > n looks safe, but if off and len are uint16_t, their sum is computed in int here (fine), yet the same pattern in narrower types can wrap: 60000 + 60000 in a uint16_t wraps to a small number and passes a naive check. Then the copy runs off the end.

How it works. Promote to a wider type before adding — size_t total = (size_t)off + (size_t)len; — then compare total > n. size_t is wide enough that two u16 values cannot overflow it.

When / when not. Any time you add two attacker-controlled quantities and compare the result to a limit, do it in a type that cannot wrap. Equivalently, rearrange to avoid the sum: if (len > n - off) — but only after proving off <= n, or n - off itself underflows.

Pitfall. off + len in uint16_t wraps silently with no warning. This is the single most common way a bounds check gets bypassed.

Threat model

            UNTRUSTED (disk image / uploaded file)
  +-----------------------------------------------------+
  |  MFT record bytes: signature | name_off | name_len  |
  |                    | ... | filename bytes | ...      |
  +-----------------------------------------------------+
                        |
                        | entry point: read_mft_name(rec, n, out, cap)
   =====================|===================  TRUST BOUNDARY
                        v
  +-----------------------------------------------------+
  |  VALIDATION GATE (fail closed -> return -1)          |
  |   - pointers non-NULL, cap != 0                      |
  |   - n >= 8 (header present)                          |
  |   - memcmp(rec,"FILE",4) == 0                        |
  |   - (size_t)off + (size_t)len <= n                   |
  |   - len < cap (room for NUL)                         |
  +-----------------------------------------------------+
                        |
                        v  (only validated data reaches here)
  +-----------------------------------------------------+
  |  TRUSTED: bounded memcpy into caller's out buffer    |
  |  ASSET PROTECTED: process memory + report integrity  |
  +-----------------------------------------------------+

Knowledge check.

  1. What asset is protected by the bounds check on name_offset + name_length? (The parsing process's memory — an unchecked value causes an out-of-bounds read of adjacent memory.)
  2. Where is the trust boundary in this function? (At the read_mft_name entry point: rec/n are untrusted; out/cap come from trusted caller code.)
  3. What insecure assumption would cause an out-of-bounds read here? (Assuming an in-record length field is honest and copying that many bytes without checking it against n and cap.)

Syntax notes

The core moves, annotated. This is lab-safe: it reads a buffer you own and never writes outside it.

#include <stdint.h>   /* uint8_t, uint16_t          */
#include <stddef.h>   /* size_t                     */
#include <string.h>   /* memcmp, memcpy             */

/* 1. Signature check: exactly 4 bytes, binary-safe. */
if (memcmp(rec, "FILE", 4) != 0) return -1;

/* 2. Little-endian u16 read (low byte first). */
uint16_t off = (uint16_t)(rec[4] | (rec[5] << 8));
uint16_t len = (uint16_t)(rec[6] | (rec[7] << 8));

/* 3. Overflow-safe range check: promote to size_t before adding. */
size_t end = (size_t)off + (size_t)len;
if (end > n) return -1;            /* name runs past the record   */

/* 4. Destination capacity check: room for len bytes + a NUL. */
if (len >= cap) return -1;         /* would overflow caller buffer */

/* 5. Bounded copy, then terminate. */
memcpy(out, rec + off, len);
out[len] = '\0';

Note the order: presence of the header, then signature, then read fields, then range check, then capacity check, then copy. Each step assumes the previous ones passed. Skipping or reordering them is how bugs get in.

Lesson

Why this matters

NTFS stores its metadata in the Master File Table (MFT). Each file on the volume has at least one MFT record.

Real MFT records are dense. They contain attribute lists, fixup arrays, and runlists. Parsing a real record means understanding all of that structure.

In this exercise we use a simplified mock record instead. That lets us focus on one skill: careful bounds-checking. We do not need the full NTFS specification to practise it.

What the mock record looks like

The mock layout is fixed and small:

offset  size  field
0       4     signature "FILE"  (literal)
4       2     name_offset       (u16 LE — where the filename starts)
6       2     name_length       (u16 LE — number of ASCII bytes)
8       ...   (other mock fields, ignored)

Two notes on the format:

  • u16 LE means an unsigned 16-bit integer stored little-endian (least significant byte first).
  • The filename is plain ASCII here. In real NTFS it is UTF-16LE; we are simplifying.

Your job

Implement this function:

int read_mft_name(const uint8_t *rec, size_t n, char *out, size_t cap)

The steps are:

  1. Validate the signature.
  2. Read name_offset and name_length.
  3. Bounds-check the values.
  4. Copy the name.
  5. NUL-terminate the output.
  6. Return the number of bytes written.

Return -1 if any of these are true:

  • Any pointer is NULL, or cap == 0.
  • n < 8 (the header is too small to hold).
  • The signature is not exactly 'F','I','L','E'.
  • name_offset + name_length > n (the name runs past the record).
  • The name would overflow cap.

Common mistakes

  • Reading the signature with strncmp on a buffer that is not NUL-terminated. Use memcmp instead.
  • Computing name_offset + name_length in a uint16_t and overflowing it. Add in a wider type.
  • Forgetting to NUL-terminate the output.

What this is NOT

  • A real NTFS parser. For actual case work, use The Sleuth Kit.
  • A recovery tool. This module only reads — it never writes.

Code examples

Below: an intentionally vulnerable version, the secure fix, and a test harness that proves the fix rejects bad input and accepts good input.

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

/* vuln_mft.c
 * WARNING: intentionally vulnerable - use only in a local, isolated,
 * authorized lab. Do not deploy.
 *
 * The bug: name_offset and name_length come from the record but are
 * never bounds-checked. A crafted len makes memcpy read past `rec`
 * and overflow `out`.
 */
#include <stdint.h>
#include <stddef.h>
#include <string.h>

int read_mft_name_vuln(const uint8_t *rec, size_t n,
                       char *out, size_t cap) {
    (void)n; (void)cap;                 /* ignored - that is the bug */
    uint16_t off = (uint16_t)(rec[4] | (rec[5] << 8));
    uint16_t len = (uint16_t)(rec[6] | (rec[7] << 8));
    memcpy(out, rec + off, len);        /* out-of-bounds read AND write */
    out[len] = '\0';
    return (int)len;
}

(2) The secure fix

/* safe_mft.c - validated mock MFT filename reader. */
#include <stdint.h>
#include <stddef.h>
#include <string.h>

/*
 * Read the filename from a mock MFT record.
 * Returns bytes written (name length) on success, or -1 on any
 * validation failure. On -1, `out` is not guaranteed usable.
 */
int read_mft_name(const uint8_t *rec, size_t n, char *out, size_t cap) {
    /* Gate 0: trusted-caller sanity + untrusted-input sanity. */
    if (rec == NULL || out == NULL || cap == 0) return -1;
    if (n < 8) return -1;                    /* header must be present */

    /* Gate 1: signature - binary-safe, exactly 4 bytes. */
    if (memcmp(rec, "FILE", 4) != 0) return -1;

    /* Gate 2: read the two little-endian u16 header fields. */
    uint16_t off = (uint16_t)(rec[4] | (rec[5] << 8));
    uint16_t len = (uint16_t)(rec[6] | (rec[7] << 8));

    /* Gate 3: range check in size_t so u16 + u16 cannot wrap. */
    size_t end = (size_t)off + (size_t)len;
    if (end > n) return -1;                  /* name runs past record */

    /* Gate 4: destination must hold len bytes plus a NUL. */
    if (len >= cap) return -1;               /* would overflow out */

    /* Validated: copy within proven bounds, then terminate. */
    memcpy(out, rec + off, len);
    out[len] = '\0';
    return (int)len;
}

(3) VERIFY: a test that proves rejection of bad input and acceptance of good input

/* test_mft.c - build: cc -std=c11 -Wall -Wextra -fsanitize=address \
 *                        test_mft.c safe_mft.c -o test_mft && ./test_mft
 */
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include <stdio.h>

int read_mft_name(const uint8_t *rec, size_t n, char *out, size_t cap);

/* Build a mock record: "FILE" + off(LE) + len(LE) + name at off. */
static size_t build(uint8_t *buf, size_t bufcap,
                    uint16_t off, uint16_t len, const char *name) {
    memset(buf, 0, bufcap);
    memcpy(buf, "FILE", 4);
    buf[4] = (uint8_t)(off & 0xFF); buf[5] = (uint8_t)(off >> 8);
    buf[6] = (uint8_t)(len & 0xFF); buf[7] = (uint8_t)(len >> 8);
    if (name && (size_t)off + len <= bufcap)
        memcpy(buf + off, name, len);
    return bufcap;
}

int main(void) {
    uint8_t rec[64];
    char out[32];
    int fails = 0;

    /* ACCEPT: valid record, name "report.txt" (10 bytes) at offset 8. */
    size_t n = build(rec, sizeof rec, 8, 10, "report.txt");
    int r = read_mft_name(rec, n, out, sizeof out);
    if (r != 10 || strcmp(out, "report.txt") != 0) {
        printf("FAIL accept: r=%d out=%s\n", r, out); fails++;
    } else {
        printf("ok   accept: read %d bytes -> \"%s\"\n", r, out);
    }

    /* REJECT: length runs past the record (off 8 + len 5000 > 64). */
    n = build(rec, sizeof rec, 8, 5000, NULL);
    r = read_mft_name(rec, n, out, sizeof out);
    if (r != -1) { printf("FAIL reject-overrun: r=%d\n", r); fails++; }
    else printf("ok   reject: over-long name -> -1\n");

    /* REJECT: bad signature "BAAD". */
    n = build(rec, sizeof rec, 8, 4, "data");
    memcpy(rec, "BAAD", 4);
    r = read_mft_name(rec, n, out, sizeof out);
    if (r != -1) { printf("FAIL reject-sig: r=%d\n", r); fails++; }
    else printf("ok   reject: bad signature -> -1\n");

    /* REJECT: name longer than caller buffer (len 40 >= cap 32). */
    n = build(rec, sizeof rec, 8, 40, NULL);
    r = read_mft_name(rec, n, out, sizeof out);
    if (r != -1) { printf("FAIL reject-cap: r=%d\n", r); fails++; }
    else printf("ok   reject: name exceeds cap -> -1\n");

    /* REJECT: truncated header (n < 8). */
    r = read_mft_name(rec, 4, out, sizeof out);
    if (r != -1) { printf("FAIL reject-short: r=%d\n", r); fails++; }
    else printf("ok   reject: short header -> -1\n");

    printf(fails ? "\n%d TEST(S) FAILED\n" : "\nALL TESTS PASSED\n", fails);
    return fails ? 1 : 0;
}

Expected output (all validation gates behave):

ok   accept: read 10 bytes -> "report.txt"
ok   reject: over-long name -> -1
ok   reject: bad signature -> -1
ok   reject: name exceeds cap -> -1
ok   reject: short header -> -1

ALL TESTS PASSED

Building with -fsanitize=address is the point: if you swap in read_mft_name_vuln and feed it the over-long-length case, AddressSanitizer aborts with a heap/stack overflow report — that is the vulnerability being detected by tooling, exactly what you want the secure version to make impossible.

Line by line

Walking the secure read_mft_name:

  1. if (rec == NULL || out == NULL || cap == 0) return -1; — Reject the caller's mistakes first. A NULL buffer or zero capacity means there is nowhere safe to read from or write to.
  2. if (n < 8) return -1; — The header is signature (4) + offset (2) + length (2) = 8 bytes. If fewer than 8 bytes are valid, reading rec[4..7] would itself be out of bounds. This check must come before any field read.
  3. if (memcmp(rec, "FILE", 4) != 0) return -1; — Confirm the format. memcmp compares 4 raw bytes and ignores NULs. If this is a BAAD (corrupt) record or unrelated data, stop now.
  4. uint16_t off = rec[4] | (rec[5] << 8); — Assemble the little-endian offset. Safe because step 2 proved bytes 4–5 exist.
  5. uint16_t len = rec[6] | (rec[7] << 8); — Same for the length. Bytes 6–7 also guaranteed by step 2.
  6. size_t end = (size_t)off + (size_t)len; — Promote before adding so two u16 values cannot wrap. end is where the name would stop.
  7. if (end > n) return -1; — The critical bounds check: does the name fit inside the record? If end exceeds n, the name points past valid data — reject.
  8. if (len >= cap) return -1; — Does the name plus a NUL fit in the caller's buffer? >= (not >) because out[len] needs one more byte for the terminator.
  9. memcpy(out, rec + off, len); — Only now, with source and destination bounds proven, copy.
  10. out[len] = '\0'; — Terminate so out is a valid C string.
  11. return (int)len; — Report bytes written.

Trace on the accept case: record "FILE", offset field 08 00, length field 0A 00, name report.txt at byte 8, n = 64, cap = 32.

Step Expression Value Decision
2 n < 8 64 < 8 = false continue
3 memcmp 0 signature ok
4 off 0x0008 = 8
5 len 0x000A = 10
6 end 8 + 10 = 18
7 end > n 18 > 64 = false in range
8 len >= cap 10 >= 32 = false fits
9–11 copy 10, NUL, return out="report.txt", ret 10 success

Now the malicious case with len = 5000: step 6 gives end = 5008, step 7 sees 5008 > 64 and returns -1 before the copy. The out-of-bounds read never happens.

Common mistakes

Mistake 1 — Trusting the in-record length. WRONG: memcpy(out, rec + off, len); using len straight from the record. WHY: len is attacker-controlled; a large value reads past rec and overflows out. CORRECTED: check (size_t)off + (size_t)len <= n and len < cap first. RECOGNISE/PREVENT: any memcpy/memmove whose size comes from parsed input is a red flag — trace that size back and confirm it was bounds-checked. AddressSanitizer catches the overflow at runtime.

Mistake 2 — strncmp on binary bytes. WRONG: if (strncmp((char*)rec, "FILE", 4) == 0). WHY: string functions stop at \0. A record like FI\0X could compare equal to FILE up to the NUL, and binary data is full of NULs. CORRECTED: memcmp(rec, "FILE", 4) == 0. RECOGNISE/PREVENT: never use str* functions on raw binary buffers; reserve them for known C strings.

Mistake 3 — Overflow in the bounds check. WRONG: uint16_t end = off + len; if (end > n) .... WHY: if the sum exceeds 65535 it wraps to a small number and the check passes. CORRECTED: compute end in size_t. RECOGNISE/PREVENT: when adding two untrusted quantities, ask "can this sum overflow its type?" If yes, widen first.

Mistake 4 — Reading fields before checking n. WRONG: reading rec[4..7] and then checking n >= 8. WHY: the read itself is out of bounds when n < 8. CORRECTED: check header presence first, read second. RECOGNISE/PREVENT: order gates from cheapest/most-fundamental (does the data even exist?) to most specific.

Mistake 5 — Off-by-one on the terminator. WRONG: if (len > cap) return -1; then out[len] = '\0';. WHY: when len == cap, out[len] writes one byte past the buffer. CORRECTED: if (len >= cap) return -1; reserving room for the NUL. RECOGNISE/PREVENT: whenever you NUL-terminate, budget the extra byte in the capacity check.

Debugging tips

  • Symptom: crash / ASan abort inside memcpy. You copied an unvalidated length. Rebuild with -fsanitize=address -g and read the report: it names the overflowed object and the line. Trace the size argument back to its bounds check.
  • Symptom: signature check always fails. Print the first four bytes in hex (printf("%02x %02x %02x %02x\n", rec[0], rec[1], rec[2], rec[3])). Confirm they are 46 49 4c 45 (FILE). If you see them swapped or offset, your record layout or offset math is wrong.
  • Symptom: offset/length read as huge or swapped values. You likely assembled the u16 big-endian. rec[4] | (rec[5] << 8) is little-endian; swapping the two indices flips it.
  • Symptom: garbage tail on the output string. You forgot out[len] = '\0', or you copied more than len bytes. Verify the return value equals the expected name length.
  • Symptom: valid record rejected. Print off, len, end, n, cap right before each gate and see which comparison fired. A common cause is n being the buffer capacity rather than the count of valid bytes.
  • Questions to ask when it fails: Did I check n >= 8 before reading the header? Is every size in the copy proven <= n and < cap? Am I using memcmp, not strncmp? Did I promote to size_t before adding? Is n the valid-byte count or the allocation size?
  • Fuzz it. Feed random bytes and random n values in a loop under ASan; a correct parser only ever returns >= 0 or -1, and never overruns. Any ASan hit is a real bug.

Memory safety

Memory safety (C). Every read from rec must be inside [0, n) and every write to out inside [0, cap). The two dangerous operations are the header reads (rec[4..7], guarded by n >= 8), the name read (rec[off .. off+len), guarded by end <= n), and the writes (out[0..len], guarded by len < cap which leaves room for out[len]). memcmp/memcpy never touch NULs specially, so binary data is fine; string functions would be undefined behavior here. Keep rec const so the parser cannot accidentally modify the evidence — a read-only parser is easier to trust. Compile with -Wall -Wextra -fsanitize=address,undefined during development; UBSan catches the u16 shift/overflow mistakes and ASan catches any out-of-bounds access.

Security & safety — detection and logging. Even a mock parser teaches the logging habits real forensic pipelines need.

What to log on each parse: a timestamp, the source (image name / offset of the record within the volume), the resource (record index or file reference), the result (accepted or rejected), the specific security decision that fired (bad-signature, name-runs-past-record, name-exceeds-buffer, short-header), and a correlation id tying it to the analysis session or case number. Example line: 2026-07-08T12:00:00Z case=IR-2231 img=disk1.raw rec=44 result=REJECT reason=name-runs-past-record off=8 len=5000 n=64.

What to NEVER log: the raw file contents wholesale (may contain secrets or unneeded PII), any credentials or tokens found in carved data, full personal identifiers beyond what the case requires, or private keys. Log the decision and metadata, not the payload.

Which events signal abuse / anti-forensics: a burst of bad-signature or name-runs-past-record rejections on one image often means the records were deliberately corrupted to break tooling — worth flagging to the analyst, not silently skipping.

How false positives arise: genuinely damaged (not malicious) media, an unexpected-but-legal format variant, or a bug in your offset math. Because rejections are logged with the reason and the field values, an analyst can tell a real anti-forensics pattern from a benign corruption or a parser bug — instead of guessing.

Ethics and authorization. Parse only images from systems you own or are explicitly authorized to examine (a signed engagement scope or a lab you built). Do this in an isolated lab — localhost, a container, or an intentionally-corrupt test image you generated. Never claim a parser is "completely secure"; claim it is bounds-checked against the failure modes you tested and fuzzed. Passing these five tests is evidence, not proof of total safety.

Real-world uses

Authorized use case. During an incident response engagement (with written authorization and a defined scope), an analyst images a suspect Windows workstation and walks its MFT to build a timeline of file creation and deletion. Records may be partially overwritten or deliberately corrupted; the parser must reject bad records gracefully, log why, and keep going — never crash mid-timeline. The same bounds discipline appears in EDR agents that parse file-system structures on live hosts and in CTF forensics challenges built around crafted images.

Best-practice habits.

  • Input validation. Treat every field from the image as hostile; bounds-check before use.
  • Least privilege. Open images read-only; the parser needs no write access to evidence.
  • Secure defaults. Fail closed — unknown or malformed record means reject and log, never "assume valid and continue."
  • Logging. Record the decision, reason, and metadata for every parse; keep a correlation id per case.
  • Error handling. One clear failure signal (-1) with a logged reason beats silent truncation.

Beginner vs advanced.

Beginner (this lesson) Advanced (production)
Format Fixed mock layout Full NTFS: attributes, fixups, runlists
Names ASCII UTF-16LE, normalization, Unicode edge cases
Validation Signature + two bounds checks Fixup-array verification, per-attribute bounds, cross-record consistency
Tooling Hand-rolled + ASan The Sleuth Kit / libyal, differential testing, continuous fuzzing
Output One filename Timelines, hash sets, chain-of-custody records

Practice tasks

Beginner 1 — Signature validator. Objective: write int is_mft_record(const uint8_t *b, size_t n) returning 1 if n >= 4 and the first four bytes are FILE, else 0. Requirements: use memcmp, not string functions; check n >= 4 first. I/O: FILE... -> 1; BAAD... -> 0; a 3-byte buffer -> 0. Constraints: no writes, b stays const. Hints: guard the length before comparing. Concepts: signature validation, memcmp, presence check.

Beginner 2 — Little-endian reader. Objective: write long read_u16le(const uint8_t *b, size_t n, size_t at) returning the little-endian u16 at at, or -1 if at + 2 > n. Requirements: assemble with b[at] | (b[at+1] << 8); bounds-check in size_t. I/O: bytes 10 00 at at -> 16; reading at the last byte -> -1. Constraints: no out-of-bounds read even when at is near n. Hints: compute at + 2 in size_t. Concepts: endianness, overflow-safe bounds.

Intermediate 1 — Full mock reader with reason codes. Objective: extend read_mft_name to also fill an enum out-param naming why it failed (OK, ERR_NULL, ERR_SHORT, ERR_SIG, ERR_RANGE, ERR_CAP). Requirements: keep the -1/byte-count return; set the reason for every path. I/O: over-long name -> return -1, reason ERR_RANGE. Constraints: set exactly one reason per call. Hints: set the reason immediately before each return -1. Concepts: structured error reporting, the logging habit.

Intermediate 2 — Rejection-logging wrapper. Objective: wrap the reader in parse_and_log that, on rejection, writes one log line with timestamp, record index, reason, and the field values (off, len, n) to a caller-supplied FILE *. Requirements: log the decision and metadata only — never the record's raw byte payload. I/O: rejected record index 44 -> a line naming reason and off/len/n. Constraints: lab-only; no real case data. Hints: format the reason enum to a short string. Concepts: detection/logging, what to never log.

Challenge — Fuzz harness + hardening. Objective: build a loop that generates random rec contents, random n <= sizeof(rec), and random cap, calls your reader thousands of times under -fsanitize=address,undefined, and asserts the return is always >= 0 or -1 with no sanitizer abort. Requirements: seed the RNG; count accepts vs rejects; treat any ASan/UBSan hit as a failing test. Constraints: runs only on localhost against buffers you allocate — never real media. Include a note describing the DEFENSIVE conclusion: which bounds check each class of malformed input exercised, and how the fuzz run verifies the fix. Then RESET the lab by discarding the generated buffers (they live only in memory; nothing to clean on disk). Hints: if a run aborts, print the seed so you can replay it. Concepts: fuzzing, mitigation verification, fail-closed design.

Summary

Main concepts. An MFT record parser sits on a trust boundary: rec/n are untrusted bytes from a disk image, out/cap come from trusted caller code. Safety comes from validating in order — header present (n >= 8), signature (memcmp with FILE), read the two little-endian u16 fields, range-check (size_t)off + len <= n, capacity-check len < cap — and only then copying.

Key syntax/commands. memcmp(rec, "FILE", 4) for binary signatures; rec[i] | (rec[i+1] << 8) for little-endian u16; promote to size_t before adding untrusted values; memcpy + out[len] = '\0' for a bounded, terminated copy. Build with cc -std=c11 -Wall -Wextra -fsanitize=address,undefined.

Common mistakes. Trusting an in-record length, using strncmp on binary data, overflowing a uint16_t sum, reading fields before checking n, and off-by-one on the terminator (> vs >=).

What to remember. Every offset and length from a file is hostile until proven in-range. Fail closed with a clear code, log the decision and metadata (never the raw payload or secrets), and verify your fix by testing that it rejects bad input and accepts good input — under a sanitizer and a fuzzer. This mock is a training ground, not a real NTFS parser or recovery tool; for real work use The Sleuth Kit, only on media you are authorized to examine.

Practice with these exercises