Edgar F. Diéguez · Blog

Inside the NAND: what I changed to recover the Switch

The CAL0 bytes, idgen context, and code in RAM I examined while rebuilding a Switch, with offsets, diagrams, and measured before-and-after values.

tagged: nintendo-switch nand reverse-engineering homebrew recovery

After getting the Switch to boot, I wanted to look at the repair from a different angle. I had gone from a black screen to HOME, but along the way I had changed a roughly 31 GB image, two identifiers inside encrypted packets, a certificate field, and a 48-byte file. I wanted to know how much had actually changed.

In the first post, I described the tests I ran. Here I have gone back to the dumps and files I kept while working with Codex on November 2 and 3. I compared intermediate states, decrypted the preserved copies, and read the save I backed up after the console had booted. I made no further writes to the console for this article.

The “after” values refer to those copies, made before the manual restoration to sysNAND. I subsequently confirmed that internal storage booted with both CFW and stock through Hekate. I do not have a complete live RAM dump from those boots. Where I discuss emulation or expected RAM bytes, I say so.

I have included the measurements as JSON. Keys, complete certificates, and console-specific identifiers are excluded. I use ID_HW for the hardware identifier and ID_DONOR for the one in the donor material.

The diagrams open at full size. Wide tables scroll horizontally.

First, I had to place each address

The RAWNAND copy contained 31,268,536,320 bytes, split across fifteen files. BOOT0 and BOOT1 each contained another 4,194,304 bytes. These are separate eMMC hardware areas: byte zero of BOOT0 is not byte zero of RAWNAND, and RAWNAND offsets do not need an extra 8 MiB added to them.

Schematic map of BOOT0, BOOT1, and GPP, with the main partition offsets. The diagram is not to scale.

This is the map I read from the GPT in my copy. All offsets below start at byte zero of GPP / RAWNAND. The sizes exclude BOOT0 and BOOT1.

PartitionStart in GPPSize in bytes
PRODINFO0x000044004,176,896
PRODINFOF0x004000004,194,304
BCPKG2, six partitions0x008000006 × 8,388,608
SAFE0x0380000067,108,864
SYSTEM0x078000002,684,354,560
USER0xA780000027,917,287,424

There were eleven entries, since each BCPKG2 has its own. USER did not run to the end of the image either: it ended at 0x727800000, leaving an unpartitioned tail before the backup GPT. Drawing USER as “everything else” would have hidden that detail.

BOOT0 held the BCTs, package1, keyblobs, and NAND Patrol state. I rebuilt it by integrating the new components into a full image while preserving the necessary original regions. The smaller output from EmmcHaccGen was not, by itself, a complete 4 MiB BOOT0. Switchbrew’s eMMC map helped me identify those regions.

Three different kinds of address also appeared in the records:

0x00006F56              byte within GPP
0x00002B56              byte within PRODINFO
process base + ...      virtual address in RAM

0x4400 + 0x2B56 = 0x6F56

The first two point to the same field in storage. The third requires the process and its load address. Seeking to a RAM address inside RAWNAND.bin will not find the corresponding instruction.

What decryption showed before and after

Initially, CAL0 is corrupt or wrong keys left both explanations open. I extracted the keys again through direct RCM and checked the reading procedure against other known backups. The available keys still did not produce valid structures from this NAND.

I repeated a small comparison: the first four bytes of PRODINFO as stored, followed by their decryption using the BIS keys for the current hardware.

                         Original       Reconstruction
Stored, encrypted        dc f9 fd ea     cb 5e f0 e0
Current-key decryption   39 df b5 a6     43 41 4c 30
ASCII                    not CAL0       C  A  L  0

43 41 4c 30 is the expected header. As a little-endian 32-bit integer it is 0x304C4143; the bytes in the file do not need reversing to spell “CAL0.”

SYSTEM gave me an independent check. Bytes 0x1FE–0x1FF of its first sector, after decryption with the current key, changed from 10 0c to 55 aa. The rebuilt sector also reported 512 bytes per sector, 32 sectors per cluster, and the field FAT32 . Each cluster was 16 KiB.

This does not establish that 39 df b5 a6 was the console’s original plaintext. It is what I obtained by attempting decryption with the key I had. If that key does not belong to those bytes, AES can return output without raising an exception. I still cannot establish whether someone had put another console’s image there or whether the stored data had been damaged.

The BIS layer and its 16 KiB units

My scripts used AES-XTS with 0x4000-byte data units. PRODINFO and PRODINFOF used BIS 00, SAFE used BIS 01, and SYSTEM and USER used BIS 02. Each XTS key used here combined two 128-bit AES keys.

To locate a unit in my copy, I used this calculation:

UNIT = 0x4000
partition_start = 0x4400      # PRODINFO in this GPP
field_offset = 0x2B56        # relative to PRODINFO

unit_index, within_unit = divmod(field_offset, UNIT)
gpp_unit_offset = partition_start + unit_index * UNIT

assert unit_index == 0
assert gpp_unit_offset + within_unit == 0x6F56

# The script prepared the XTS tweak input this way:
tweak_input = unit_index.to_bytes(16, "big")

The index restarted at each partition. Using the global GPP LBA would produce a different decryption. The byte order shown here describes the code I used; another library may already handle that conversion internally.

Layers from the GPP offset to the CAL0 field, showing the partition-relative XTS index and the two AES blocks crossed by the label.

XTS does not authenticate the data either. That is why I checked headers, CRCs, hashes, and filesystem structures after decryption. A successful AES call was only the start of validation.

The eFuses kept the same value

The version mismatch was elsewhere. I had read 24 burnt anti-downgrade fuses, with ODM7 reported as 0x00FFFFFF. However, the 14 ASCII characters at BOOT0 + 0x100010 were 20240207110330.

                                   Before             Rebuilt
package1 build timestamp           20240207110330     20260730135423
Firmware family                    18.0.0–18.1.0      23.0.0–23.0.1
Expected fuse count for family     19                 24
Observed hardware fuse count       24                 24

I checked the timestamps against System Versions and the count against Fuses. I rebuilt with 23.0.1 so the boot components matched that state. I did not repair or downgrade the eFuses: rebuilding a file does not unburn a physical fuse.

That explained one boot problem, but not the whole case. CFW had also failed on a path that could bypass the anti-downgrade check. The invalid partition decryption was a separate problem.

A valid CAL0 could still belong to a different console

The donor PRODINFO decrypted correctly and had valid integrity checks. Across the three states I preserved—donor, rebound packets, and adapted certificate—these fields stayed the same:

PRODINFO + 0x0000 : 43 41 4c 30       CAL0 magic
PRODINFO + 0x0004 : 07 00 00 00       version 7
PRODINFO + 0x0008 : c0 7f 00 00       body size 0x7FC0
PRODINFO + 0x001E : 71 0e             header CRC = 0x0E71

Body covered by SHA-256: [0x40, 0x8000)
Stored hash:            [0x20, 0x40)

The offsets follow CalibrationBinary; I read the values from my own files. The body and its records could pass integrity checks while retaining the donor’s identifier. I ran into this twice.

Identifiers inside encrypted packets

The first specific failure was 2026-0002 in es. CAL0 contained another encryption layer: the extended eTicket and SSL packets. The eTicket packet started at +0x3890 and was 0x240 bytes long; SSL started at +0x3AE0 and was 0x130 bytes long.

The eTicket packet I examined had this layout:

Packet: 576 bytes
┌──────────┬────────────────────────────────────┬──────────┐
│ IV: 16 B │ AES-CTR ciphertext: 544 B          │ GMAC:16 B│
└──────────┴────────────────────────────────────┴──────────┘

Decrypted 544-byte payload:
d:256 | n:256 | e:4 | padding:20 | device_id:8

The final 8-byte identifier was big-endian. The RSA modulus remained 2048 bits, and the public exponent was 65537 (0x10001). I did not change d, n, or e: I changed the device binding. The SSL packet had a 272-byte plaintext payload with the same kind of final field.

In the wrapping scheme I used, GMAC authenticates the plaintext payload as GCM associated data. Changing the field required rebuilding the wrapper with its IV and tag, the record CRC, and the CAL0 body hash, before applying the outer BIS encryption. Simply changing eight ciphertext bytes would have left those layers inconsistent.

The comparison between my three files looked like this:

State                  GMAC   ID inside packets   ID in certificate
Donor                  OK     ID_DONOR           ID_DONOR
Packets rebound        OK     ID_HW              ID_DONOR
Certificate adapted    OK     ID_HW              ID_HW

The first adaptation let the boot progress. I had not yet fixed the field Settings would read.

The string that still pointed to the donor

Next came 2105-0584. I followed the call to a Settings routine in the firmware I was using, at module offset 0x8920. It read the RSA eTicket certificate identifier and compared it with the hardware. Emulating that routine with the intermediate data returned W0 = 0x49069; with the corrected field it returned W0 = 0.

The string began at PRODINFO + 0x2B56: 16 ASCII hexadecimal characters inside the certificate starting at +0x2A90. The low 56 bits had to match while preserving the upper byte used for the model. With symbolic names, the operation was:

mask56 = (1 << 56) - 1
adapted = (old_id & ~mask56) | (ID_HW & mask56)
# It is then represented as 16 hexadecimal characters.
# ID_HW is not idgen's local node.

This was already known. prodinfo_gen 1.1.0 documented Horizon 14 switching to this comparison back in 2022, with test reports in issue 24. I wish I had read that before stopping halfway through the adaptation.

That correction fitted into five AES blocks

For this comparison, I used the PRODINFO with the packets already rebound and the one saved after correcting the certificate. This is not a comparison with the original broken NAND.

The certificate label changes its record CRC and the CAL0 body hash, while the header CRC stays unchanged.

FieldOffset in PRODINFOBeforeAfter
Certificate label0x2B56, 16 bytesID_DONORCompatible with ID_HW
Record CRC160x2CDE, 2 bytes0xA9CD0xD3DB
Body SHA-2560x20, 32 bytesPrevious hashRecalculated hash
Header CRC160x1E, 2 bytes0x0E710x0E71

I counted 43 different plaintext bytes: ten characters in the label, two CRC bytes, and 31 hash bytes. The label field is 16 bytes and the hash is 32, but some bytes happened to match across the two states. Field sizes and actual changed-byte counts are different measurements.

The header CRC did not change because it covers [0x00, 0x1E). The hash starts at 0x20, outside that range. I checked the record CRC using this implementation, equivalent to the recovery script’s:

def crc16_cal0(data):
    crc = 0x55AA
    for byte in data:
        crc ^= byte
        for _ in range(8):
            crc = (crc >> 1) ^ (0xA001 if crc & 1 else 0)
    return crc & 0xFFFF

# cal0 is an already-decrypted copy, opened read-only.
stored = int.from_bytes(cal0[0x2CDE:0x2CE0], "little")
assert crc16_cal0(cal0[0x2A90:0x2CDE]) == stored

In the encrypted files I counted 80 different bytes, spread across five 16-byte AES blocks:

Offset within PRODINFO    Affected content
0x0020                   first half of body hash
0x0030                   second half of body hash
0x2B50                   beginning of label
0x2B60                   end of label
0x2CD0                   record CRC

The label starts six bytes into a block, so it crosses two. A 16 KiB XTS unit contains many AES blocks: editing part of it does not require all 16 KiB of ciphertext to change. In this case I saved, wrote, and read back the entire 4,176,896-byte PRODINFO, although only 80 ciphertext bytes differed between those two states.

A correct CRC does not repair the certificate issuer’s signature. I adapted a local comparison and recalculated integrity checks; I did not recover the factory Nintendo identity or obtain a newly signed Nintendo certificate.

The save existed; context.bin was missing

Between those CAL0 corrections, I had encountered 2162-0002 in account. The container SYSTEM:/save/8000000000000011 already existed, occupied 589,824 bytes, and had valid headers. Its internal filesystem was empty.

account mounts it as idgen:/. What it needed was the 48-byte /context.bin. The system save documentation supplied its layout; the firmware binary let me examine the failing path.

Two account paths: without context, network enumeration leads to the observed abort; with context, local ID generation proceeds.

In the function I examined, at account + 0xBA770, a missing context led to network interface enumeration. In that test it returned no usable interface, and the process aborted. A valid existing context avoided the branch. In pseudocode, using names I assigned:

if a valid context exists:
    read saved state
    check the clock source
    generate the next local identifier
else:
    enumerate network interfaces
    if none is usable: abort
    create initial state

I emulated that routine from the exact module with Unicorn and simulated external services. The missing-context control made one network call and aborted; the prepared-context case made none, returned, and generated different identifiers in two calls. I also tested a clock-source change. That was neither whole-console emulation nor proof of a broken Wi-Fi chip.

What I put in those 48 bytes, and what appeared later

I kept three states: the empty container created during reconstruction, the copy with an initialized context, and the later save from the running console. The last one lets me show more than the zeroes I initially wrote.

FieldOffset / sizeNewly preparedLater copy
Base time, seconds0x00 / 8081051
Clock source0x08 / 16All zeroNonzero; value omitted
Last update, seconds0x18 / 80997
Fraction, units of 10⁻⁷ s0x20 / 408509400
Sequence0x24 / 2Random, 14 bitsChanged; still 14 bits
Padding0x26 / 200
Local node0x28 / 8Random, 48 bitsUnchanged

The integers in this file are little-endian. The base time includes the clock source; 81051 is not a Unix timestamp, and I have not converted it into a calendar date. The later fractional value represents 0.8509400 seconds.

I prepared the node using cryptographic randomness, cleared its upper 16 bits, and set its lowest bit. The initial sequence stayed below 16384. This node is used to generate local identifiers: it is not the hardware identifier Settings compared. I also did not reproduce the complete normal derivation from serial number and MAC address, which was the path I could not get through.

The later clock and sequence changes, with the node preserved, are consistent with the context handling I examined. I read those values back from the preserved save; they are not predictions from emulation.

Why 48 bytes became a 160 KiB write

I did not place the bytes in an arbitrary gap in the container. A small LibHac-based program opened a copy of the save, created /context.bin, and committed the transaction, updating its structure and integrity data. I verified its headers and MACs with the console’s keys before and after.

I then compared encrypted SYSTEM. Ten 16 KiB BIS units needed replacing, 163,840 bytes in total. Within those ranges, only 511 ciphertext bytes differed, across 32 AES blocks. It is 511 rather than 512 because one byte happened to match.

New file inside the save           48 bytes
Containing save                    589,824 bytes
BIS ranges written                 10 × 16,384 = 163,840 bytes
Different ciphertext bytes         511

Location of the first range:
SYSTEM + unit 25525 × 0x4000
= 125829120 + 418201600
= GPP offset 544030720

The logical file size, container size, write unit, and number of changed bytes describe different things. The account executable stayed intact. What persisted was state that had previously been missing.

What changed in RAM when booting CFW

After reaching HOME, I got 2123-0011 in nim while testing the connection. The recovery boot entry used cal0blank=1. With Atmosphère, this presents selected calibration fields through an alternate view.

In the preserved PRODINFO copies, the SSL certificate size at +0xAD0 was still 1513 (0x5E9), and its certificate bytes were unchanged between donor and final reconstruction. Atmosphère 1.12.1’s implementation shows it preparing an in-memory copy and clearing, among other fields, the SSL size and data. The storage read layer returns that view when enabled.

So 1513 in storage and 0 in the blanked view can coexist. The zero is the behavior implied by that code and configuration, not a measurement from a complete live RAM dump. Enabling the option did not erase the stored certificate.

Persistent data, the CFW CAL0 view, patches to loaded instructions, and the separate stock path.

First, I avoided the crash with DNS.mitm, redirecting selected domains to 127.0.0.1. Its log confirmed that it loaded REC1’s file. I added sys-patch later. Changing DNS resolution and changing a loaded instruction are separate interventions; the first crash-free result came before sys-patch.

For the latter, I can show a concrete code-byte example. The log saved after restoring sysMMC contained:

[nim]
blankcal0crashfix_17.0.0+=Patched (sys-patch) (0x19ECD4)
[stats]
fw_version=23.0.1
is_emummc=0

In the extracted nim executable, +0x19ECD4 contained e2 14 00 10, which disassembled as adr x2, #0x19ef70. The sys-patch 1.6.3.0 source selects e2 03 1f aa for this adjustment: mov x2, xzr.

; Module offsets, with a zero base for analysis.
; Extracted executable:
0x19ECD4:  e2 14 00 10    adr x2, #0x19ef70

; Expected replacement when sys-patch is applied:
0x19ECD4:  e2 03 1f aa    mov x2, xzr

The original instruction calculated a PC-relative address into X2; the replacement puts zero in X2. This is not a diff between two RAM captures. I cross-checked the original binary, the patch source, and the log reporting its application. The absolute adr destination in a real process depends on the module’s load address.

The change affects loaded code during CFW. I did not replace the stored nim NCA with a modified version, and I did not need to patch account to make it read the context.

The final backup contained changes I had not written myself

After booting, the PC reconstruction was no longer an exact copy of REC1. Horizon created saves, updated state, and left changes in SYSTEM and USER. I saved those differences before restoring internal storage.

The base already contained the certificate fix. Compared with that reconstructed base, there were 127 changed 1 MiB blocks: 122 in SYSTEM and five in USER. This was not a claim that only 127 blocks differed from the original broken NAND.

The delta used a format I had defined for the backup:

import struct

# Each header is followed by length encrypted bytes.
HEADER = struct.Struct("<QI")  # GPP offset u64 + length u32
assert HEADER.size == 12

blocks = 127
block_size = 1 << 20
assert blocks * (HEADER.size + block_size) == 133_170_676

To read the final state, the reader took bytes from the base and overlaid ranges present in the delta. It reconstructed all 31,268,536,320 bytes by reading and calculated their SHA-256. That matched REC1. The backup was base + delta, alongside its BOOT copies and preserved SD files, rather than the small delta alone.

BOOT0 also had a change that initially stood out. Between two already-working snapshots, 33 bytes differed in NAND Patrol:

BOOT0 field               Earlier snapshot    Later snapshot
+0x184020 / raw u32       0x019B7000          0x019BB400
+0x184024 / count         3                   3
+0x184000 / HMAC          changed with the updated record

The count had not increased in this comparison. One byte changed in the position field, along with all 32 HMAC bytes. The BCTs, package1, and keyblobs matched between those two copies; BOOT1 matched too. That let me distinguish a maintenance-state update from another firmware change.

What remained after the sysNAND restoration

I restored BOOT0, BOOT1, and GPP after verifying the parts prepared for Hekate. Internal storage received the tested reconstruction, with its persistent CAL0 changes and saves. REC1 stayed on the SD.

CFW still used Atmosphère, hosts/sysmmc.txt, and sys-patch. Stock through Hekate did not use those Atmosphère features. Nevertheless, it reached the system and completed the connection test. Trying stock mattered: I could not attribute its successful boot to a sys-patch RAM modification.

I can say that I rebuilt a NAND that boots on this console and kept the copies needed to follow the changes. I cannot say what plaintext the broken NAND held before the failure, that I recovered its factory identity, or that a connection test validates Nintendo accounts or eShop access. Those questions remain open after examining all these bytes.

Other posts