Safe Penetration Testing Labs · intermediate · ~15 min
- 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.
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).
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.
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.
0xE5 deletion markerDefinition. 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.
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.
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.
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.
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.
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.
Implement:
int recover_8_3(const uint8_t *entry, char *out);
Requirements:
entry must be non-NULL. out must be non-NULL.8 + 1 + 3 + 1 = 13 bytes, including the trailing NUL (the dot and the NUL account for the two extra bytes).entry[0] == 0xE5, write '_' as the first character. The original byte is gone, so the leading _ is just our display convention.-1 on a NULL argument.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.
#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
#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
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 TXT → pos=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.
| 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 |
pos and each field length; confirm out[pos]=='\0'.entry when you have confirmed at least 11 bytes remain in the buffer/sector.out[0] with _ after copying the 0xE5. Check the deletion branch runs after the base copy.copy_trimmed — verify it trims trailing spaces only (src[n-1]), not leading.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 / UB safety (C).
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.8 + 1 + 3 + 1. Keep out sized FAT_OUT_MAX and terminate exactly once.unsigned char/uint8_t for raw bytes so 0xE5 compares correctly and there is no sign-extension surprise.-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.
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):
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.
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.
entry[0] becomes 0xE5; everything else survives. That is why FAT undelete works — and why "I deleted it" is a false sense of security.. only if the extension is non-empty, mark a deleted first byte with _, and terminate the string yourself.cc -std=c11 -fsanitize=address,undefined, hexdump/xxd to inspect the entry, sha256sum to prove read-only handling.strcpy on non-terminated bytes (overflow), trimming across the field boundary, always adding a dot, guessing the lost byte, or overstating what recovery proves.