Extracting Switch Saves from an emuMMC Image: Non-Standard AES-XTS, FAT32 and DISF

For a while the only thing left of my hacked Switch was its 512GB SD card. The goal was narrow: rescue the game saves I had accumulated and move them into Eden, a yuzu fork on Android, so the games could continue.

I plugged the card into a USB adapter and opened Nintendo/save/ first. Two saves were there, 8000000000000000 and 8000000000000124, both NAX0-encrypted system saves with no relation to game progress. I walked every visible directory and found nothing resembling a game save. That was counterintuitive: the console had produced those saves, so why were they not on the card.

Nintendo’s own documentation is blunt. Game saves live only in the console’s internal eMMC and cannot be copied to a microSD; the card holds game titles, updates, DLC, screenshots and system saves. For a hacked console, the saves live in the emuMMC.

The saves are not on the card’s surface, they are inside an emuMMC image

Hacking a Switch usually means building an emuMMC, a virtual system that copies the console’s entire eMMC to the SD card and boots from that copy. So the files 00 through 07 under emuMMC/SD00/eMMC/ are not eight separate things — they are a single internal-storage image split into chunks because FAT caps any one file at 4GB. BOOT0 and BOOT1 sit beside them as boot partitions. The card hides a complete Switch eMMC, around 30GB, and the game saves are inside its USER partition.

That fixes the whole route:

Each link in that chain corresponds to one non-obvious fact.

Three parts, none optional

The image lives at emuMMC/SD00/eMMC/00..07. The key lives at atmosphere/automatic_backups/<serial>_BISKEYS.bin, with the console certificate *_PRODINFO.bin in the same folder. The NAND partitions are AES-XTS encrypted; without the BIS keys they never open. And the NAND is encrypted under keys unique to that console, so a different console or a different card changes nothing.

That fact sets the stakes. With the console gone, this card and its BISKEYS are the only copy that can restore the data. Until everything wanted is exported, the card cannot be reformatted and should not take other writes. Every intermediate artifact I produced got saved twice for exactly this reason.

Concatenate the NAND, then read the GPT: the partition table is plaintext

Concatenating 00..07 in order yields the full NAND. I wrote a small read routine that treats the eight files as one disk with arbitrary-offset reads; everything downstream goes through it:

def rd(self, off, n):
    out = bytearray()
    for f, st, sz in zip(self.files, self.starts, self.sizes):
        if off < st + sz and len(out) < n:
            with open(os.path.join(self.dir, f), "rb") as fh:
                local = max(0, off - st)
                fh.seek(local)
                want = min(n - len(out), sz - local)
                out += fh.read(want)
            off += min(n - len(out), sz - local)
    return bytes(out)

I read the GPT first because decryption is done per partition and the partition positions live in the GPT. There is a useful detail here: the GPT partition table is plaintext, because the bootloader must read it before any decryption happens. So this step has no friction, and it hands over the partition list: PRODINFO, PRODINFOF, BCPKG2-1..6, SAFE, SYSTEM, USER.

SYSTEM is about 2.5GB and holds system saves (system-titles), not what I needed. USER is about 26GB and the game saves sit in its /save/. On this card the offsets were SYSTEM at 0x7800000 and USER at 0xA7800000, so once offset = first_lba * 512 is known, only USER needs decrypting.

The hard part: Nintendo’s AES-XTS is not standard

AES-XTS is best read as AES with a per-unit tweak. Given ciphertext C and unit tweak T, decryption is:

plaintext = AES_dec(key1, C XOR T) XOR T
T advances between 16-byte blocks by multiplication by x in GF(2^128)
the first tweak T0 = AES_enc(key2, unit index)

I started from the usual assumptions: 512-byte data units and a little-endian unit index. After writing that, one result made no sense — sector 0 of the partition decoded perfectly, everything after it was noise.

Unit 0 has index 0, and little-endian versus big-endian, 512 versus 16K all collapse to the same bytes when the index is 0, so it happened to be right. From unit 1 onward both assumptions were wrong at once. Reading switchbrew’s Flash Filesystem page confirmed it: Nintendo uses a 0x4000 (16KB) data unit rather than 512 bytes, and a big-endian sector index rather than little-endian.

After switching to 16KB units and big-endian indices, USER immediately produced a valid FAT table (f8 ff ff 0f ...) and a root directory listing PRF2SAFE.RCV, save, saveMeta and Contents.

Check against known plaintext, not intuition

Decryption work cannot run on intuition. I assembled known values to compare against: a FAT32 boot sector must end in 55 AA with FAT32 at offset 0x52, the FSInfo sector starts with RRaA, the FAT table starts with F8 FF FF 0F, and the root directory should show readable names.

At first none of them matched, which meant the 512-byte-unit and little-endian-index assumptions were both wrong. Swap hypotheses one at a time until known plaintext appears — that is the general method in reverse engineering, eliminating with verifiable known values rather than guessing.

Vectorized decryption: pycryptodome for AES, numpy for the tweak

Naive Python doing XOR per 16 bytes would need billions of loop iterations for 26GB, far too slow. So I split it: the AES itself goes to pycryptodome’s ECB in bulk, at C speed, and the tweak recurrence runs in numpy across whole arrays.

Multiplying by x in GF(2^128) vectorizes cleanly: shift each 16-byte tweak left by one bit, carry each byte’s high bit into the next byte’s low bit, and XOR the reduction polynomial 0x87 when the top bit overflows.

def _mul_alpha(T):                       # T: (N,16) uint8
    top = (T[:, 15] >> 7).astype(np.uint8)        # high bit = carry
    out = ((T << 1) & 0xFF).astype(np.uint8)
    out[:, 1:] |= (T[:, :-1] >> 7)                 # carry low bytes into high
    out[:, 0] ^= (top * 0x87).astype(np.uint8)     # overflow → XOR 0x87
    return out

The unit’s first tweak must be big-endian:

iv = np.zeros((n, 16), np.uint8)
for k in range(8):                       # pack big-endian into the last 8 bytes
    iv[:, 15 - k] = (units >> np.uint64(8 * k)) & np.uint64(0xFF)
base = AES_ecb(key2).encrypt(iv)         # T0 = AES_enc(key2, big-endian(unit index))

Then the 1024 per-block tweaks for every unit are generated at once and the formula applied in bulk. One aside: the cryptography library’s modes.XTS is a poor fit here — it treats the whole input as a single data unit, errors out past 16MB, and its streaming update() does not keep state, so a hand-rolled implementation is both simpler and faster.

USER is FAT32, and the cluster size equals one XTS unit

Decrypted, USER is a standard FAT32 volume; Nintendo uses FAT internally. Its boot sector gives the parameters: 512 bytes per sector, 32 sectors per cluster, 32 reserved sectors, 2 FATs, 13312 sectors per FAT, root cluster 2. That makes each cluster 16384 bytes, with the data region starting at (rsvd + nfats * fatsz) * bps.

There is a deliberate alignment here: a 16384-byte cluster equals the 0x4000 XTS unit, and Nintendo laid it out that way. It buys a useful property — decryption can happen per cluster, because cluster N starts exactly on a unit boundary, so reading one cluster is decrypting one unit:

def cluster_unit(n):
    return (DATA_START + (n - 2) * CLUSTER) // UNIT   # divides exactly
def read_cluster(n):
    u = cluster_unit(n)
    return xts.decrypt_units(rd(POFF + u * UNIT, UNIT), u)

FAT32 traversal is otherwise standard: read the FAT (decrypted per unit and concatenated), each entry 4 bytes with the low 28 bits pointing to the next cluster; a directory is the byte string formed by a cluster chain, 32 bytes per entry; names require VFAT long-file-name handling, where an entry with attribute 0x0F stores 13 UTF-16 characters and several entries combine. One performance detail is easy to miss: consecutive clusters must be merged into a single large read, otherwise a 1GB save issues thousands of small I/Os and becomes unusably slow.

In the end USER:/save/ held 95 save containers, named by the hex of their 8-byte saveID.

The save container: a small filesystem inside a file

Every /save/<id> is not a bare file but an entire miniature filesystem. Its header starts at offset 0x100, with a CMAC before that. The header lines up a series of modules: DISF for the overall layout, DPFS for double buffering, IVFC for the integrity hash tree, JNGL for the journal, SAVE for the save filesystem, RMAP for remapping, and Extra data carrying the TitleID and timestamp.

Reading these containers is layered mapping rather than direct file-offset reads:

Nintendo built it this complicated because a save must not be lost, and the design stacks three safeguards. Duplex writes data twice as A and B, marks which side is current with a bitmap, and flips the bitmap on commit so a power loss mid-write causes no corruption. Journal works like a database WAL, writing the log before touching the primary data to keep the commit atomic. IVFC is a layered hash used to detect and localize corruption.

I simplified one step during implementation. The IVFC layer only verifies, and it carries a sparse optimization where an all-zero hash means an empty block; but an allocated file block is always real data with a non-zero hash. Since verification does not change what is read, the whole hash tree can be skipped and reads can come straight from the data layer, saving the work of porting an entire hash tree. IVFC is absent from the tool not because it was overlooked but because it does not affect correctness.

The canonical route remains hactool -t save -k prod.keys <container> --outdir out; this Python implementation translates the remap, duplex, journal and SAVE FS logic from hactool’s save.c. Both produce the same result.

Importing into Eden on Android: three traps

Eden’s save directory on Android is /sdcard/Android/data/dev.eden.eden_emulator/files/nand/user/save/0000000000000000/<container ID>/<TITLEID>/, and unpacked files just go into <TITLEID>/. In practice three traps showed up, each caused by an Android system behavior.

  1. adb push into a subdirectory of Android/data fails with remote secure_mkdirs failed: Operation not permitted. The directory is protected and adb’s secure_mkdirs is rejected beneath it. The workaround is to tar locally, push to an ordinary directory, then extract on-device with tar xf.
  2. Files created by adb push/tar are owned by shell, while Eden opens saves read-write (O_RDWR). Mode 0644 leaves only read permission for “others”, so the write is denied and the game fails to load the save or hangs. The fix is to open permissions: find <save dir> -mindepth 1 -exec chmod 777 {} +.
  3. macOS tar adds AppleDouble ._* junk. Pack with COPYFILE_DISABLE=1, or delete afterwards with find … -name '._*' -delete.

There is also a backup step: Eden may already hold saves made later, so adb pull a copy before overwriting. After importing, I compare md5 per file and only call it done when local and device match — the biggest risk in disk migration is a silent few-byte difference.

Folding the four layers into one tool

I merged the four layers into a single script, switch_save_tool.py, depending only on numpy and pycryptodome:

# show partitions
python switch_save_tool.py info    --emmc "<SD>/emuMMC/SD00/eMMC"
# list every save (TitleID, time, size)
python switch_save_tool.py list    --emmc <eMMC dir> --titledb titledb.json
# export all save containers
python switch_save_tool.py extract --emmc <eMMC dir> --out ./containers
# unpack into game save files
python switch_save_tool.py unpack-all --containers ./containers --out ./saves

The key can be auto-detected: the tool tries each atmosphere/automatic_backups/*BISKEYS.bin candidate and decides the right one by whether it decrypts SYSTEM’s sector 0 into a FAT boot sector. The code maps to the four layers: Nand concatenates the NAND and reads the GPT, Xts implements the non-standard XTS, UserFs walks FAT32 and exports containers, and SaveContainer unpacks them.

Sector 0 decoding correctly was the most dangerous result

What stuck with me afterward was not the code but the moment sector 0 decoded correctly. It looked like success, and it was the most deceptive failure in the whole project: index 0 is the point where every assumption collapses to the same bytes, big-endian or little-endian, 512 or 16K. Since then, whenever a decryption looks partly successful, I first find known plaintext to tell whether it is right or merely lucky.

The BIS keys and PRODINFO on that card are bound to the console’s identity, so they are omitted here. Beyond saves, the card also holds album screenshots, user profile data, playtime records and a full NAND snapshot; the game titles themselves are console-encrypted and useless on another machine. The personal data is what is worth rescuing, and the sooner it is exported the better.

Article Link:

https://time-friend.com/en/archive/switch-emummc-save-extraction/

# Related Articles