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.
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.
| Partition | Start in GPP | Size in bytes |
|---|---|---|
| PRODINFO | 0x00004400 | 4,176,896 |
| PRODINFOF | 0x00400000 | 4,194,304 |
| BCPKG2, six partitions | 0x00800000 | 6 × 8,388,608 |
| SAFE | 0x03800000 | 67,108,864 |
| SYSTEM | 0x07800000 | 2,684,354,560 |
| USER | 0xA7800000 | 27,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.
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.
| Field | Offset in PRODINFO | Before | After |
|---|---|---|---|
| Certificate label | 0x2B56, 16 bytes | ID_DONOR | Compatible with ID_HW |
| Record CRC16 | 0x2CDE, 2 bytes | 0xA9CD | 0xD3DB |
| Body SHA-256 | 0x20, 32 bytes | Previous hash | Recalculated hash |
| Header CRC16 | 0x1E, 2 bytes | 0x0E71 | 0x0E71 |
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.
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.
| Field | Offset / size | Newly prepared | Later copy |
|---|---|---|---|
| Base time, seconds | 0x00 / 8 | 0 | 81051 |
| Clock source | 0x08 / 16 | All zero | Nonzero; value omitted |
| Last update, seconds | 0x18 / 8 | 0 | 997 |
| Fraction, units of 10⁻⁷ s | 0x20 / 4 | 0 | 8509400 |
| Sequence | 0x24 / 2 | Random, 14 bits | Changed; still 14 bits |
| Padding | 0x26 / 2 | 0 | 0 |
| Local node | 0x28 / 8 | Random, 48 bits | Unchanged |
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.
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.