Safe Penetration Testing Labs · intermediate · ~15 min

Recover an 8.3 filename from a FAT directory entry

- Read the fixed 8.3 short-name layout out of a raw FAT directory entry (8 base bytes + 3 extension bytes). - Detect a deleted entry from its first byte (`0xE5`) and render it with a safe display convention. - Trim FAT's space padding and join the base and extension so you produce a clean, printable name. - Write the reconstruction as C11 that validates its inputs and never writes past a fixed 13-byte output buffer. - Explain, as a forensic examiner would, what recovery does and does NOT prove — and why this only runs on an image you are authorized to examine.

Overview

Security objective. The asset you protect here is evidence integrity on a storage image. The threat is a file that was deleted — perhaps to hide activity — on a FAT12/FAT16/FAT32 volume. Your goal as a defender/examiner is to detect and reconstruct the name of that deleted file from a read-only disk image, so it can be documented in a forensic report, without altering the original evidence.

On FAT, deleting a file does not wipe its name. The file's directory entry — the small fixed-size record that holds the name, size, and starting cluster — is left almost intact. Only the very first byte of the name is overwritten with 0xE5 to mark the slot as free. Every other name byte survives on disk until that slot is reused. That single fact is the entire basis of FAT undelete tooling.

This lesson builds directly on your prereqs. From C strings you already know that C text is a byte array terminated by a NUL ('\0'), and that FAT names are not NUL-terminated — they are space-padded to a fixed width, so you must trim manually. From pointers you know how to walk a const uint8_t * across a buffer with offsets instead of copying. Here you combine both: index into an 11-byte region of the entry, copy the printable bytes, and terminate the result yourself.

What you will build is recover_8_3(): given the raw bytes of one directory entry, it returns a printable 8.3 name such as REPORT.TXT, or _EPORT.TXT when the entry was deleted (the leading _ marks the lost first byte).

Why it matters

In authorized digital forensics and incident response (DFIR), reconstructing deleted filenames is routine and legally consequential. When an examiner works a case — an internal investigation, an authorized breach response, an e-discovery request — the names of deleted files are often the first lead: they hint at what existed, when, and whether someone tried to remove it.

FAT is not a museum piece. It is the default on SD cards, USB thumb drives, camera storage, many embedded and IoT devices, and EFI system partitions. Those are exactly the small removable media that show up in real cases. Knowing the on-disk layout by hand — rather than trusting a black-box tool — lets you validate what a tool reports, explain your findings under scrutiny, and recognise when an entry has been tampered with.

Doing this in C matters because forensic parsers must be exact about bytes and offsets, must never mutate the evidence, and must survive hostile or corrupt input without crashing. Sloppy parsing that reads out of bounds is not just a bug here — it can invalidate evidence or become an attack surface when the "file" you parse was crafted by an adversary.

Core concepts

1. The FAT directory entry

Definition. A directory entry is a fixed 32-byte record describing one file or subdirectory. The first 11 bytes are the 8.3 short name.

How it works. The name field is split into two space-padded parts: 8 bytes of base name, then 3 bytes of extension. There is no stored dot — the . is implied between the two fields. Bytes are ASCII (uppercase by convention).

When / when not. This applies to the classic short-name entries. Long filenames (LFN) are stored in separate preceding entries with attribute 0x0F; this lesson does not parse those.

Pitfall. The name is not NUL-terminated. If you printf("%s") the raw 11 bytes as if they were a C string, you print garbage past the field.

2. The 0xE5 deletion marker

Definition. When a file is deleted, the filesystem overwrites entry[0] — only that one byte — with 0xE5.

Plain explanation. The slot is now "free" for reuse, but nothing else changed. The remaining 10 name bytes, the size, and the starting cluster are all still readable until the slot is reallocated.

Insecure assumption it exposes. "Deleting a file destroys it." It does not — on FAT, deletion is a one-byte edit. Anyone assuming deleted means gone is wrong.

Pitfall / edge case. A legitimately present file whose first name character is the Japanese 0xE5 (Kanji) collides with the marker. Real FAT stores such a first byte as 0x05 on disk to avoid the clash. For this beginner lesson we treat 0xE5 at entry[0] as "deleted" and simply cannot recover that lost first character.

3. Space padding and trimming

Definition. Unused positions in both fields are filled with ASCII space (0x20).

How it works. HI (base) + C (ext) means base HI, extension C, joined as HI.C. You must scan from the right and drop trailing spaces on each part independently.

When not. Do not trim interior spaces — those are (rarely) legal. Trim trailing only.

4. Read-only, non-destructive access

Definition. Forensic parsing must never write to the evidence. You open the image read-only (or work on a verified copy) and treat every byte as input.

Why it matters. Altering evidence — even a timestamp — can make it inadmissible and destroys the integrity you are trying to protect.

THREAT MODEL — recovering a deleted FAT filename

  ASSET: integrity + recoverability of a deleted file's metadata
         (its name), on a storage image under examination

  ENTRY POINTS
    - the raw bytes of a 32-byte directory entry (attacker- or
      case-supplied; may be corrupt, crafted, or truncated)

  TRUST BOUNDARY
    +-------------------- untrusted disk image --------------------+
    |  sector bytes  ->  directory region  ->  one 32-byte entry   |
    +--------------------------|-----------------------------------+
                               v   (crosses into your parser)
    +----------------- your C recovery program -------------------+
    |  recover_8_3(entry, out):                                   |
    |    - assumes entry has >= 11 readable bytes  <-- must verify |
    |    - copies base[0..8), ext[0..3)                            |
    |    - never writes > 13 bytes to out                         |
    |    - read-only w.r.t. the image                             |
    +------------------------------------------------------------ +
                               |
                               v
         printable 8.3 name  ->  forensic report / case log

  INSECURE ASSUMPTION IF IGNORED:
    "the entry is well-formed and at least 11 bytes"  ->  OOB read
    "deleted == unrecoverable"                        ->  missed evidence

Knowledge check.

  1. What asset are you protecting when you parse a FAT entry read-only instead of mounting the disk read-write?
  2. Where is the trust boundary in the diagram, and what must your parser verify before it crosses it?
  3. Which insecure assumption makes "I deleted the file" a false sense of security on FAT?

Syntax notes

The key structure is: a const uint8_t * you index (never copy blindly), plus manual space-trimming, plus manual NUL termination.

#include <stdint.h>   /* uint8_t              */
#include <stddef.h>   /* size_t, NULL         */

#define FAT_DELETED 0xE5u   /* entry[0] marker for a deleted slot */

/* Copy a fixed-width, space-padded FAT field into dst, dropping
 * trailing spaces. Returns the trimmed length. Reads exactly
 * `width` bytes from src; writes at most `width` bytes to dst. */
static size_t copy_trimmed(char *dst, const uint8_t *src, size_t width)
{
    size_t n = width;
    while (n > 0 && src[n - 1] == ' ')   /* trim trailing spaces */
        n--;
    for (size_t i = 0; i < n; i++)
        dst[i] = (char)src[i];
    return n;                            /* caller terminates/joins */
}

Note what is not here: no strcpy, no strlen on the raw field (it is not NUL-terminated), and every read is bounded by the field width you were promised.

Lesson

Why this matters

On FAT12, FAT16, and FAT32, a file is not erased when you delete it.

Deletion happens by rewriting the first byte of the file's directory entry to 0xE5. (A directory entry is the small fixed-size record that stores a file's name, size, and location on disk.)

The rest of the name bytes stay on disk untouched. That is exactly why forensic tools can list deleted files: most of the original name is still there.

What the entry looks like

The relevant part of a directory entry has this fixed layout:

offset  size  field
0       8     name (space-padded, ASCII; byte 0 == 0xE5 -> deleted)
8       3     extension (space-padded, ASCII)
11      1     attributes
...

So the 8.3 short name is reconstructed from bytes [0..11]: 8 bytes of base name followed by 3 bytes of extension.

Your job

Implement:

int recover_8_3(const uint8_t *entry, char *out);

Requirements:

  • entry must be non-NULL. out must be non-NULL.
  • The output is at most 8 + 1 + 3 + 1 = 13 bytes, including the trailing NUL (the dot and the NUL account for the two extra bytes).
  • If entry[0] == 0xE5, write '_' as the first character. The original byte is gone, so the leading _ is just our display convention.
  • Trim trailing spaces from both the base name and the extension.
  • If the extension is all spaces, omit the dot.
  • Return the number of bytes written (excluding the NUL), or -1 on a NULL argument.

What this is NOT

  • This is not an LFN (long filename) parser. LFN entries are a separate exercise, if and when we add one.
  • This is not a cluster walker. We only read the single directory entry itself, not the file's data.

Code examples

Below: an intentionally-vulnerable parser (to see the failure), the secure fix, and a small test that proves the fix.

/* WARNING: intentionally vulnerable — use only in a local, isolated,
 * authorized lab. Do not deploy.
 *
 * BUG: treats the 11 name bytes as a NUL-terminated C string and
 * copies with strcpy. FAT names are space-padded, NOT terminated,
 * so this reads past the field and can overflow `out`. */
#include <string.h>
int recover_8_3_BAD(const unsigned char *entry, char *out)
{
    strcpy(out, (const char *)entry);   /* no bound, no NUL guarantee */
    return (int)strlen(out);            /* length of whatever it ran into */
}

Why it is dangerous: entry is untrusted bytes from a disk image. There is no '\0' at byte 11, so strcpy keeps reading — through the extension, attributes, timestamps, and beyond — until it happens on a zero byte, writing all of it into a caller buffer sized for 13 bytes. Classic out-of-bounds read and buffer overflow.

SECURE version

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

#define FAT_DELETED 0xE5u
#define FAT_NAME_LEN 8u
#define FAT_EXT_LEN  3u
#define FAT_OUT_MAX  13u   /* 8 + '.' + 3 + '\0' */

static size_t copy_trimmed(char *dst, const uint8_t *src, size_t width)
{
    size_t n = width;
    while (n > 0 && src[n - 1] == ' ')
        n--;
    for (size_t i = 0; i < n; i++)
        dst[i] = (char)src[i];
    return n;
}

/* Reconstruct a printable 8.3 name from one directory entry.
 * `entry` must point to at least 11 readable bytes.
 * `out` must have room for FAT_OUT_MAX (13) bytes.
 * Returns bytes written (excluding NUL), or -1 on NULL argument. */
int recover_8_3(const uint8_t *entry, char *out)
{
    if (entry == NULL || out == NULL)
        return -1;

    char base[FAT_NAME_LEN];
    char ext[FAT_EXT_LEN];

    size_t bn = copy_trimmed(base, entry, FAT_NAME_LEN);
    size_t en = copy_trimmed(ext, entry + FAT_NAME_LEN, FAT_EXT_LEN);

    size_t pos = 0;
    for (size_t i = 0; i < bn; i++)
        out[pos++] = base[i];

    /* Deleted marker: first byte was overwritten with 0xE5.
     * The original char is gone; show '_' as our convention. */
    if (entry[0] == FAT_DELETED) {
        if (bn == 0) out[pos++] = '_';   /* whole base was spaces */
        else out[0] = '_';               /* replace the copied first char */
    }

    if (en > 0) {
        out[pos++] = '.';
        for (size_t i = 0; i < en; i++)
            out[pos++] = ext[i];
    }

    out[pos] = '\0';                      /* we terminate; fits in 13 */
    return (int)pos;
}

int main(void)
{
    /* Three lab entries. Bytes 0..10 are the name; the rest omitted. */
    const uint8_t live[11]    = {'R','E','P','O','R','T',' ',' ','T','X','T'};
    const uint8_t deleted[11] = {0xE5,'E','P','O','R','T',' ',' ','T','X','T'};
    const uint8_t noext[11]   = {'R','E','A','D','M','E',' ',' ',' ',' ',' '};

    char out[FAT_OUT_MAX];

    if (recover_8_3(live, out) < 0) { fprintf(stderr, "bad arg\n"); return 1; }
    printf("live:    %s\n", out);

    if (recover_8_3(deleted, out) < 0) { fprintf(stderr, "bad arg\n"); return 1; }
    printf("deleted: %s\n", out);

    if (recover_8_3(noext, out) < 0) { fprintf(stderr, "bad arg\n"); return 1; }
    printf("noext:   %s\n", out);

    return 0;
}

Expected output:

live:    REPORT.TXT
deleted: _EPORT.TXT
noext:   README

VERIFY — prove the fix rejects bad input and accepts good input

#include <assert.h>
#include <string.h>
/* ... include the secure recover_8_3 above ... */

static void run_checks(void)
{
    char out[FAT_OUT_MAX];

    /* ACCEPT good input: correct reconstruction */
    const uint8_t live[11] = {'R','E','P','O','R','T',' ',' ','T','X','T'};
    assert(recover_8_3(live, out) == 10);
    assert(strcmp(out, "REPORT.TXT") == 0);

    /* Deleted entry -> leading underscore, rest intact */
    const uint8_t del[11] = {0xE5,'E','P','O','R','T',' ',' ','T','X','T'};
    assert(recover_8_3(del, out) == 10);
    assert(strcmp(out, "_EPORT.TXT") == 0);

    /* No extension -> no dot */
    const uint8_t ne[11] = {'R','E','A','D','M','E',' ',' ',' ',' ',' '};
    assert(recover_8_3(ne, out) == 6);
    assert(strcmp(out, "README") == 0);

    /* REJECT bad input: NULL args return -1, never write/crash */
    assert(recover_8_3(NULL, out) == -1);
    assert(recover_8_3(live, NULL) == -1);

    /* Output never exceeds 12 chars + NUL */
    const uint8_t full[11] = {'A','B','C','D','E','F','G','H','X','Y','Z'};
    assert(recover_8_3(full, out) == 12);   /* ABCDEFGH.XYZ */
    assert(strlen(out) == 12);
}

Build and run with sanitizers so any stray out-of-bounds read is caught:

cc -std=c11 -Wall -Wextra -fsanitize=address,undefined -g fatname.c -o fatname
./fatname

Line by line

Trace of recover_8_3(deleted, out) where deleted = {0xE5,'E','P','O','R','T',' ',' ','T','X','T'}:

Step Code State / effect
1 if (entry==NULL ...) both non-NULL, continue
2 copy_trimmed(base, entry, 8) reads bytes 0..7: E5 'E' 'P' 'O' 'R' 'T' ' ' ' '; trims 2 trailing spaces → n=6; base = \xE5EPORT, returns 6
3 copy_trimmed(ext, entry+8, 3) reads bytes 8..10: 'T' 'X' 'T'; no trailing space → n=3; ext = TXT, returns 3
4 copy base loop out = "\xE5EPORT", pos=6
5 entry[0]==0xE5 true, bn!=0 out[0]='_'out = "_EPORT"
6 en>0 append '.'pos=7; append TXTpos=10, out="_EPORT.TXT"
7 out[pos]='\0' terminate at index 10 (within 13)
8 return 10 10 bytes written, matches the assert

Key insight in step 5: we copy the raw first byte first (0xE5), then overwrite the display slot with _. We never pretend to know the original character — it is genuinely lost, and the report should say so. The extension TXT survived deletion untouched, which is the whole point of FAT undelete.

Common mistakes

WRONG approach WHY it is wrong CORRECTED How to recognise / prevent
strcpy(out, entry) on the 11 raw bytes FAT names are space-padded, not NUL-terminated → reads past the field, overflows out Copy a bounded width and terminate yourself ASan reports a heap/stack overflow; code review flags str* on non-terminated data
Trimming spaces from the whole 11-byte blob at once Merges base and extension, loses the field boundary Trim base and extension separately Output like REPORTTXT with no dot
Always appending . Files with no extension get a trailing dot (README.) Append . only when trimmed ext length > 0 Names ending in a bare .
Claiming you "recovered the file" You recovered the name/metadata, not the data clusters Report it as a deleted-entry name reconstruction; data may be overwritten Report language that overstates certainty
Guessing the lost first byte as a real letter The original byte is destroyed; a guess fabricates evidence Use a neutral marker (_) and note the byte is unknown Any code that hardcodes a "probably A" first char
Opening the evidence read-write to "look" Mounting rw changes timestamps / free lists → tainted evidence Work on a read-only image or verified copy Hash mismatch before/after examination

Debugging tips

  • Output has trailing garbage. You likely printed the field without a NUL, or trimmed the wrong count. Print pos and each field length; confirm out[pos]=='\0'.
  • AddressSanitizer flags a read overflow. Your parser assumed more than 11 readable bytes. Guard the caller: only pass entry when you have confirmed at least 11 bytes remain in the buffer/sector.
  • Deleted name shows a stray high byte. You forgot to overwrite out[0] with _ after copying the 0xE5. Check the deletion branch runs after the base copy.
  • Extension dropped or duplicated. Off-by-one in copy_trimmed — verify it trims trailing spaces only (src[n-1]), not leading.
  • Compare against ground truth. In a lab, create a known file on a FAT image, delete it, then run your parser and confirm the surviving bytes match what you wrote. Use xxd/hexdump on the directory region to eyeball the 32-byte entry.

Questions to ask when it fails: How many bytes am I actually allowed to read here? Is this a short-name entry or an LFN entry (attribute 0x0F)? Did I terminate the string? Am I modifying the image by accident?

Memory safety

Memory / UB safety (C).

  • Every read is bounded: copy_trimmed reads exactly width bytes and the caller must guarantee ≥ 11 bytes exist at entry. Never infer length from a NUL — FAT fields are not terminated.
  • Output is a fixed 13-byte buffer; the code writes at most 8 + 1 + 3 + 1. Keep out sized FAT_OUT_MAX and terminate exactly once.
  • Use unsigned char/uint8_t for raw bytes so 0xE5 compares correctly and there is no sign-extension surprise.
  • Build with -fsanitize=address,undefined during development so any OOB read on crafted input is caught before it reaches a real case.

Security & safety — detection & logging (forensics). When this parser runs inside a case-processing tool, log for each recovered entry: a UTC timestamp, the evidence/image identifier and its hash, the byte offset of the entry, the reconstructed name, whether it was deleted (0xE5), and a case/correlation id tying it to the examination. Log the decision ("entry at offset X treated as deleted") so the reasoning is auditable.

Never log: the raw file contents, any credentials or secrets that might live in file data, personal data beyond what the case authorizes, or full copies of user documents in a general log. Names/metadata are usually in scope; bulk content is not.

Signals of abuse / tampering: a directory region full of 0xE5 slots (mass deletion), entries whose names survive but whose clusters are already reallocated (attempted destruction), or timestamps that contradict the surviving name. False positives arise from normal churn: temp files are constantly created and deleted, so a deleted entry is not by itself evidence of wrongdoing — it is a lead to corroborate, and severity depends on context, not on the mere fact of deletion.

Real-world uses

Authorized real-world use. An examiner receives a seized USB drive under a signed authorization / warrant. They image it read-only, verify the image hash, then walk the FAT directory regions to enumerate both live and deleted entries. Reconstructed deleted names (e.g. _ONFIG.OLD) become leads: which files existed, when, and whether they were removed near a key event. The finding goes into a report with the offset, the surviving bytes, and an explicit note that the first character is unknown.

Authorization checklist (lab & real):

  • Written authorization to examine this specific media/image.
  • Work only on a read-only image or a verified working copy.
  • Record and re-verify the image hash before and after.
  • Scope: metadata recovery is in scope; content review only if authorized.
  • All processing on your own lab host / container — never a third-party live system.

Best-practice habits.

Beginner Advanced
Validation Check NULL, assume 11 bytes given Validate entry count vs. cluster size; reject LFN/volume-label entries
Least privilege Open the image read-only Mount via read-only loop device / write-blocker; drop privileges
Secure defaults _ marker for lost byte Emit "unknown-first-char" flag in structured output, not a guess
Logging Print name + deleted flag Structured, hashed, correlation-id'd audit log
Error handling Return -1 on NULL Distinguish corrupt vs. empty vs. LFN, never crash on crafted bytes

Misconception to correct: recovering a FAT name does not mean the file's data is intact — the clusters may already be overwritten. And no examination tool makes a system "completely secure" or a recovery "complete"; you report what the bytes support, no more.

Practice tasks

All tasks are lab-only: build FAT images on your own machine or in a container. Each ends by remediating and verifying.

Beginner 1 — Detect deletion. Objective: write int is_deleted(uint8_t first) returning 1 iff first == 0xE5. Requirements: no branches deeper than one line; handle any byte value. I/O: 0xE5 → 1, 'R' → 0. Hints: compare against the FAT_DELETED macro. Concepts: byte comparison, uint8_t.

Beginner 2 — Trim one field. Objective: implement copy_trimmed yourself from the signature only. Requirements: read exactly width bytes; drop trailing spaces; return trimmed length; write no NUL. I/O: "HI " (8) → "HI", returns 2. Constraints: no str* functions. Hints: scan from src[width-1] down while it is ' '. Concepts: bounded copy, pointers.

Intermediate 1 — Full reconstruction with a twist. Objective: extend recover_8_3 to also reject volume-label entries (attribute byte at offset 11 has bit 0x08 set) by returning -2. Requirements: read the attribute byte safely; keep all existing behaviour; add asserts. Constraints: caller must guarantee ≥ 12 bytes now. Hints: entry[11] & 0x08. Concepts: bitmask, input validation. Remediate + verify: add a test with a labelled entry and assert it returns -2 without writing to out.

Intermediate 2 — Directory scanner. Objective: given a buffer of N consecutive 32-byte entries, print each live and deleted short name with its offset. Requirements: stop at an entry whose first byte is 0x00 (end-of-directory marker); skip LFN entries (attr 0x0F); never read past the buffer. I/O: prints e.g. 0x20 deleted _EPORT.TXT. Constraints: bound every read by N*32. Hints: step by 32; check entry[0] for 0x00/0xE5. Concepts: iteration, boundaries, logging. Remediate + verify: run under ASan on a crafted truncated buffer and confirm no overflow.

Challenge — Lab-only undelete report. Objective: on a FAT image you create, delete a known file, then produce a small forensic-style report line for its recovered entry. Requirements: image is opened read-only; record entry offset, reconstructed name, deleted flag, and a note that the first char is unknown; compute and print the image SHA-256 before and after to prove you did not modify it. Constraints: everything on localhost/container; no third-party media; include the authorization checklist as comments. Defensive conclusion: state remediation (the drive's owner should assume deleted ≠ gone and use secure-erase/full-disk-encryption) and verify your tool read-only by matching the before/after hashes. Hints: mkfs.fat on a loopback file in a lab VM; sha256sum before and after. Concepts: read-only evidence handling, hashing, reporting. No full solution is provided — design the report fields yourself.

Summary

  • Layout: a FAT directory entry's first 11 bytes are an 8.3 short name — 8 base bytes + 3 extension bytes, space-padded, no stored dot, not NUL-terminated.
  • Deletion is one byte: entry[0] becomes 0xE5; everything else survives. That is why FAT undelete works — and why "I deleted it" is a false sense of security.
  • Reconstruction: copy each field bounded, trim trailing spaces per field, join with . only if the extension is non-empty, mark a deleted first byte with _, and terminate the string yourself.
  • Key commands: cc -std=c11 -fsanitize=address,undefined, hexdump/xxd to inspect the entry, sha256sum to prove read-only handling.
  • Common mistakes: strcpy on non-terminated bytes (overflow), trimming across the field boundary, always adding a dot, guessing the lost byte, or overstating what recovery proves.
  • Remember: recover names read-only, log the decision (never the content), corroborate before concluding, and only ever on media you are authorized to examine.

Practice with these exercises