Slack-space hiding

What it is

Filesystems allocate storage in fixed-size units. NTFS calls them clusters. ext4 and APFS call them blocks. All three default to 4 KiB.

A file almost never ends exactly on a unit boundary, so its last unit is only partly used. The filesystem still counts the whole unit as the file’s. The leftover bytes at the tail belong to nobody, and nothing reads them. That is slack, and you can write a payload there.

Here is the arithmetic for a 10,000-byte file on 4096-byte blocks:

ceil(10000 / 4096) = 3 blocks allocated  = 12288 bytes reserved
file content                             = 10000 bytes used
slack                                    =  2288 bytes  <-- hiding spot

The file’s reported size stays 10,000. Reading it returns 10,000 bytes. du reports 12 KiB, as it always did, because that is just the allocation. Writing those 2288 bytes changes nothing you can see.

Kinds of slack

one 4096-byte block:
+-----------------------------+---------------------+
|   file's last 1808 bytes    |       slack         |
|   (10000 - 2*4096)          |   payload goes here  |
+-----------------------------+---------------------+
                              ^ end of file content

Why it is fragile

Contributors get this wrong more than anything else about the technique. Any of these destroys a slack payload:

Treat slack as volatile storage. If the payload has to persist, use a different technique.

Privilege

Reading and writing slack needs raw device access. No normal file API on any OS exposes bytes past end-of-file.

Per Modes, live hide, detect, and clear with --technique slack-space must notice the missing privilege and fail with a clear error. They must not quietly degrade. named-stream and timestomp have no such requirement, because they use ordinary syscalls.

macOS (APFS)

The code is in internal/filesystem/apfs/live_darwin.go.

See APFS live mode.

Linux (ext4)

Explicit read-only live detection and extraction work through verified mount and device discovery. They need device read permission and kernel mount-ID support.

Mounted hide and clear slack writes are refused. Raw reads can see unflushed or concurrent changes, so use an offline image when you need a consistent view. Native xattrs and timestamps still use ordinary file permissions.

nemo’s payload frame

Raw slack holds whatever the last file to own that block left behind. An arbitrary payload looks the same as that noise. detect would have nothing to match on, and clear would not know how many bytes it wrote.

So nemo writes each new slack payload behind a 28-byte versioned header (internal/technique/slackframe.go):

offset  size  field
  0      4    magic    ASCII "NEM2"
  4      4    length   payload length, uint32 little-endian
  8      4    crc32    CRC-32 (IEEE polynomial) of the payload
 12     16    nonce    random identity for this hide operation
 28    length payload  the hidden bytes

detect reports a slack finding only when the magic matches and the CRC validates (decodeFrame and readFrame in technique.go).

This is a deliberate trade-off. The string NEM2 (or the older NEMO) followed by a valid CRC is easy to spot. In return, hide, detect, and clear stay reliable and reversible. Nemo is a forensic and CTF tool, not a stealth implant. Hiding from a determined examiner is a non-goal.

The length field caps a payload at math.MaxUint32, about 4 GiB. In practice a region is one block’s slack, a few KiB.

Reversibility: the manifest

Before overwriting slack bytes, slackSpaceTechnique.Hide calls Request.Backup with the original residual bytes.

The command layer saves that as one JSON Lines record per line in nemo-manifest.jsonl. Use --manifest to move it. Original is base64-encoded. The code is in internal/technique/manifest.go (AppendManifest, LoadManifest, LatestImageBackup). If the manifest write fails, hide aborts before touching the disk.

Identity checks

New records include two extra fields.

Symlink and hardlink aliases resolve to the same identity, and so do moves within a filesystem. Copied, resized, or replaced images cannot reuse these backups automatically. Non-file images have process-local identities.

An in-place ext4 replacement with a different UUID also fails the match. Re-hiding an identical payload produces a new nonce, which fails stale-frame validation.

Clearing

clear checks the image identity, target, and offset range. Then it verifies the current frame digest before restoring. A stale frame is an error before any write.

A Request.Restore slice that does not match the frame length is rejected, not zero-filled (slackSpaceTechnique.Clear). Later manifest records win, so re-hiding and then clearing restores the most recent hide’s bytes.

The older NEMO format has a 12-byte header. detect, extract, and clear can still read it. New frames carry a random nonce, so repeated hides of the same payload cannot reuse a stale digest.

Detection by a third party

Traces left behind

Implementation Notes

On-disk mechanics

The technique layer writes slack the same way on every filesystem. A filesystem’s Entry implements filesystem.SlackSpaceCapable and returns []filesystem.SlackRegion{ {Offset, Length} }. Those are absolute byte offsets into the image.

The technique picks the first region big enough, reads the bytes it is about to overwrite so it can back them up, and writes the frame. What differs per filesystem is how the regions are computed.

NTFS

ext4

APFS

In nemo

filesystem.SlackSpaceCapable:

SlackRegions() ([]filesystem.SlackRegion, error)  // {Offset, Length} into the image

slackSpaceTechnique (internal/technique/technique.go) requires Request.Image, which is image-backed storage. It frames the payload and writes into the first region that fits.

Further reading