Cuando conseguí que la Switch arrancara, me quedé con ganas de mirar la reparación de otra manera. Había pasado de una pantalla negra a HOME, pero entre medias había cambiado una imagen de unos 31 GB, dos identificadores dentro de paquetes cifrados, un campo de un certificado y un archivo de 48 bytes. Quería saber cuánto de aquello había cambiado de verdad.
En la primera entrada conté las pruebas que fui haciendo. Aquí he vuelto a los dumps y a los archivos que guardé con Codex durante el 2 y el 3 de noviembre. He comparado los estados intermedios, descifrado las copias y leído el save que conservé después de los arranques. No he vuelto a escribir en la consola para preparar este artículo.
Los valores de «después» corresponden a esas copias, anteriores a la restauración manual en sysNAND. Después comprobé el arranque en la memoria interna, tanto con CFW como con stock desde Hekate. No tengo un volcado completo de la RAM de esos arranques. Donde hablo de emulación o de bytes esperados en RAM, lo indico.
He dejado las mediciones en un JSON. Las claves, los certificados completos y los identificadores de cada consola se quedan fuera. Para seguir las comparaciones llamaré ID_HW al identificador del hardware y ID_DONANTE al que venía en el material donante.
Los diagramas se pueden abrir a tamaño completo. Las tablas anchas se desplazan lateralmente.
Primero tuve que situar cada dirección
La copia de RAWNAND ocupaba 31.268.536.320 bytes y estaba dividida en quince archivos. BOOT0 y BOOT1 ocupaban otros 4.194.304 bytes cada uno. Son áreas distintas de la eMMC: el byte cero de BOOT0 no es el byte cero de RAWNAND, ni hay que sumarle 8 MiB a todos los offsets de esta última.
Este es el mapa que leí de la GPT de mi copia. Los offsets de la tabla empiezan en el byte cero de GPP / RAWNAND; los tamaños no incluyen BOOT0 ni BOOT1.
| Partición | Inicio en GPP | Tamaño en bytes |
|---|---|---|
| PRODINFO | 0x00004400 | 4.176.896 |
| PRODINFOF | 0x00400000 | 4.194.304 |
| BCPKG2, seis particiones | 0x00800000 | 6 × 8.388.608 |
| SAFE | 0x03800000 | 67.108.864 |
| SYSTEM | 0x07800000 | 2.684.354.560 |
| USER | 0xA7800000 | 27.917.287.424 |
Había once entradas, porque cada BCPKG2 tiene la suya. USER tampoco llegaba hasta el final de la imagen: terminaba en 0x727800000, dejando un tramo sin particiones antes de la GPT de respaldo. Es fácil dibujar USER ocupando «todo lo demás» y perder ese detalle.
En BOOT0 estaban las BCT, package1, los keyblobs y el estado de NAND Patrol. Para reconstruirlo integré los componentes nuevos en una imagen completa, conservando las zonas originales necesarias. No bastaba con pegar el pequeño archivo generado por EmmcHaccGen y darlo por un BOOT0 de 4 MiB. El mapa de la eMMC de Switchbrew me sirvió para identificar esas zonas.
También había tres clases de dirección que aparecían mezcladas en los registros:
0x00006F56 byte dentro de GPP
0x00002B56 byte dentro de PRODINFO
base del proceso + ... dirección virtual en RAM
0x4400 + 0x2B56 = 0x6F56
Las dos primeras señalan el mismo campo en el almacenamiento. La tercera necesita conocer el proceso y su dirección de carga. No puedo ir al offset de una dirección de RAM dentro de RAWNAND.bin y esperar encontrar allí una instrucción.
Lo que veía al descifrar antes y después
Al principio, el mensaje CAL0 is corrupt or wrong keys dejaba abiertas las dos posibilidades. Volví a extraer las claves por RCM directo y comprobé el procedimiento con otras copias conocidas. Las claves disponibles seguían sin sacar estructuras válidas de esta NAND.
He repetido una comparación pequeña: los cuatro primeros bytes de PRODINFO, primero tal como estaban almacenados y después de aplicar las claves BIS del hardware actual.
Original Reconstrucción
Almacenado, cifrado dc f9 fd ea cb 5e f0 e0
Descifrado, BIS actual 39 df b5 a6 43 41 4c 30
ASCII no es CAL0 C A L 0
43 41 4c 30 es la cabecera esperada. Leída como entero de 32 bits little-endian vale 0x304C4143; no hay que darle la vuelta a los bytes del archivo para escribir «CAL0».
En SYSTEM había otra comprobación independiente. Los bytes 0x1FE–0x1FF de su primer sector, una vez descifrado con la clave actual, pasaron de 10 0c a 55 aa. En la reconstrucción también leí 512 bytes por sector, 32 sectores por clúster y el campo FAT32 . Cada clúster ocupaba 16 KiB.
Esto no demuestra que 39 df b5 a6 fuera el contenido en claro original de la consola. Es el resultado de intentar descifrar los datos antiguos con la clave que tenía. Si esa clave no les corresponde, AES puede devolver bytes sin que aparezca ninguna excepción. Sigo sin saber si alguien había puesto allí una imagen ajena o si se habían dañado los datos.
La capa BIS y sus unidades de 16 KiB
En los scripts utilicé AES-XTS con unidades de datos de 0x4000 bytes. PRODINFO y PRODINFOF iban con BIS 00, SAFE con BIS 01 y SYSTEM y USER con BIS 02. Cada clave XTS empleada reunía dos claves AES de 128 bits.
Para localizar una unidad dentro de mi copia, la cuenta era esta:
UNIT = 0x4000
partition_start = 0x4400 # PRODINFO en esta GPP
field_offset = 0x2B56 # relativo a 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
# Así preparaba el script la entrada tweak para XTS:
tweak_input = unit_index.to_bytes(16, "big")
El índice empezaba de nuevo en cada partición. Usar el LBA global de GPP en su lugar habría producido otro descifrado. La elección de endian del ejemplo describe el código que utilicé; no es una invitación a cambiar la de otra biblioteca que ya encapsule esa conversión.
XTS tampoco autentica los datos. Por eso comprobaba después la cabecera, los CRC, los hashes y las estructuras del sistema de archivos. Que la llamada a AES terminara era solo el principio de la comprobación.
Los eFuses siguieron valiendo lo mismo
El desfase de versión estaba en otro sitio. Había leído 24 fusibles anti-downgrade quemados; el valor reportado para ODM7 era 0x00FFFFFF. Sin embargo, los 14 caracteres ASCII en BOOT0 + 0x100010 eran 20240207110330.
Antes Reconstruido
package1: fecha de compilación 20240207110330 20260730135423
Familia de firmware 18.0.0–18.1.0 23.0.0–23.0.1
Fusibles esperados por la familia 19 24
Fusibles observados en hardware 24 24
Contrasté las fechas con System Versions y el recuento con Fuses. Reconstruí con 23.0.1 para que los componentes de arranque fueran coherentes con ese estado. No «reparé» ni rebajé los eFuses: rehacer un archivo no desquema un fusible físico.
Eso explicaba un problema del arranque, pero no todo el caso. CFW también había fallado por un recorrido que podía saltarse la comprobación anti-downgrade. Los bytes inválidos al descifrar las particiones seguían siendo un problema aparte.
Un CAL0 correcto podía seguir perteneciendo a otra consola
El PRODINFO donante sí se descifraba y tenía controles de integridad válidos. En los tres estados que conservé —donante, paquetes adaptados y certificado adaptado— leí estos campos iguales:
PRODINFO + 0x0000 : 43 41 4c 30 magic CAL0
PRODINFO + 0x0004 : 07 00 00 00 versión 7
PRODINFO + 0x0008 : c0 7f 00 00 cuerpo de 0x7FC0 bytes
PRODINFO + 0x001E : 71 0e CRC de cabecera = 0x0E71
Cuerpo usado para SHA-256: [0x40, 0x8000)
Hash guardado: [0x20, 0x40)
Los offsets corresponden a CalibrationBinary; los valores los he leído de mis archivos. El cuerpo y sus registros podían pasar las comprobaciones y conservar, aun así, el identificador del donante. Me encontré con eso dos veces.
Los identificadores dentro de los paquetes cifrados
El primer fallo concreto fue 2026-0002 en es. Dentro de CAL0 había otro nivel de cifrado: los paquetes extendidos de eTicket y SSL. El de eTicket empezaba en +0x3890 y medía 0x240 bytes; el de SSL empezaba en +0x3AE0 y medía 0x130.
El paquete de eTicket que analicé tenía esta distribución:
Paquete: 576 bytes
┌──────────┬────────────────────────────────────┬──────────┐
│ IV: 16 B │ texto cifrado con AES-CTR: 544 B │ GMAC:16 B│
└──────────┴────────────────────────────────────┴──────────┘
Payload de 544 bytes una vez descifrado:
d:256 | n:256 | e:4 | padding:20 | device_id:8
Ese último identificador de 8 bytes estaba en big-endian. El módulo RSA seguía teniendo 2048 bits y el exponente público era 65537 (0x10001). No cambié d, n ni e: adapté el vínculo al dispositivo. El paquete SSL tenía un payload de 272 bytes y la misma clase de campo final.
En la envoltura que usé, la etiqueta GMAC autentica el payload en claro como datos asociados de GCM. Tras modificar el campo había que rehacer la envoltura con su IV, la etiqueta, el CRC del registro y el hash del cuerpo de CAL0; después venía el cifrado BIS exterior. Cambiar ocho bytes directamente en el ciphertext habría dejado incoherentes esas capas.
La comparación de mis tres archivos quedó así:
Estado GMAC ID en paquetes ID en certificado
Donante OK ID_DONANTE ID_DONANTE
Paquetes adaptados OK ID_HW ID_DONANTE
Certificado adaptado OK ID_HW ID_HW
La primera adaptación permitió avanzar. No había arreglado todavía el campo que iba a consultar Settings.
La cadena que seguía apuntando al donante
Después apareció 2105-0584. Seguí la llamada hasta una rutina de Settings del firmware que estaba utilizando, en el offset de módulo 0x8920. Leía el identificador del certificado RSA de eTicket y lo comparaba con el hardware. Al emular esa rutina, el estado intermedio devolvía W0 = 0x49069; con el campo corregido devolvía W0 = 0.
La cadena empezaba en PRODINFO + 0x2B56: eran 16 caracteres ASCII hexadecimales dentro del certificado que comienza en +0x2A90. Tenía que coincidir la parte baja de 56 bits, conservando el byte superior usado por el modelo. La operación, escrita con nombres simbólicos, era:
mask56 = (1 << 56) - 1
adapted = (old_id & ~mask56) | (ID_HW & mask56)
# Después se representa como 16 caracteres hexadecimales.
# ID_HW no es el nodo local de idgen.
No era un descubrimiento nuevo. prodinfo_gen 1.1.0 ya recogía en 2022 el cambio de Horizon 14 a esa comparación, y en la incidencia 24 había pruebas de la corrección. Ojalá lo hubiera leído antes de dejar la adaptación a medias.
El diff de aquella corrección cabía en cinco bloques AES
Para esta comparación tomé el PRODINFO con los paquetes ya adaptados y el que guardé después de corregir el certificado. No estoy comparándolo con la NAND averiada original.
| Campo | Offset en PRODINFO | Antes | Después |
|---|---|---|---|
| Etiqueta del certificado | 0x2B56, 16 bytes | ID_DONANTE | Compatible con ID_HW |
| CRC16 del registro | 0x2CDE, 2 bytes | 0xA9CD | 0xD3DB |
| SHA-256 del cuerpo | 0x20, 32 bytes | Hash previo | Hash recalculado |
| CRC16 de cabecera | 0x1E, 2 bytes | 0x0E71 | 0x0E71 |
Conté 43 bytes distintos en claro: diez caracteres de la etiqueta, dos bytes de CRC y 31 bytes del hash. El campo de la etiqueta mide 16 y el hash mide 32, pero algunos bytes coincidían entre ambos estados. Medir el tamaño de los campos y contar los bytes distintos da resultados diferentes.
El CRC de cabecera no cambió porque cubre [0x00, 0x1E). El hash empieza en 0x20, fuera de ese rango. Para comprobar el CRC del registro utilicé esta implementación equivalente a la del script de recuperación:
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 es una copia ya descifrada, abierta solo para lectura.
stored = int.from_bytes(cal0[0x2CDE:0x2CE0], "little")
assert crc16_cal0(cal0[0x2A90:0x2CDE]) == stored
En los archivos cifrados conté 80 bytes diferentes, repartidos en cinco bloques AES de 16 bytes:
Offset relativo a PRODINFO Contenido afectado
0x0020 primera mitad del hash
0x0030 segunda mitad del hash
0x2B50 comienzo de la etiqueta
0x2B60 final de la etiqueta
0x2CD0 CRC del registro
La etiqueta comienza seis bytes después del inicio de un bloque, así que cruza dos. Una unidad XTS de 16 KiB contiene muchos bloques AES: modificar una parte no obliga a cambiar sus 16 KiB completos de ciphertext. En este caso se guardó, escribió y volvió a leer PRODINFO entero, 4.176.896 bytes, aunque solo 80 bytes cifrados diferían entre esos dos estados.
El CRC correcto tampoco repara la firma del emisor del certificado. Adapté una comprobación local y recalculé integridad; no recuperé la identidad Nintendo de fábrica ni obtuve un certificado nuevo firmado por Nintendo.
El save existía; lo que faltaba era context.bin
Entre las dos correcciones de CAL0 había tenido el error 2162-0002, en account. El contenedor SYSTEM:/save/8000000000000011 ya existía, ocupaba 589.824 bytes y tenía cabeceras válidas. Su sistema de archivos interno estaba vacío.
account lo monta como idgen:/. Lo que necesitaba era /context.bin, de 48 bytes. La documentación de ese save de sistema me dio el formato; el binario del firmware me permitió comprobar el recorrido que fallaba.
En la función estudiada, situada en account + 0xBA770, no encontrar el contexto llevaba a enumerar interfaces de red. En aquella prueba no devolvía ninguna utilizable y acababa abortando. Tener un contexto válido evitaba esa rama. En pseudocódigo, con nombres puestos por mí:
si existe un contexto válido:
leer estado guardado
comprobar origen del reloj
generar el siguiente identificador local
si no:
enumerar interfaces de red
si no hay una utilizable: abortar
crear estado inicial
Emulé esa rutina del módulo exacto con Unicorn y servicios externos simulados. El control sin contexto hacía una llamada de red y abortaba; con el contexto preparado no hacía esa llamada, retornaba y producía identificadores diferentes en dos invocaciones. También probé el cambio del origen del reloj. Aquello no emulaba toda la consola ni demostraba una avería del Wi-Fi.
Qué puse en aquellos 48 bytes y qué apareció después
Conservé tres estados: el contenedor vacío creado durante la reconstrucción, la copia con el contexto inicializado y el save posterior a los arranques. Este último me permite enseñar algo más que los ceros que escribí yo.
| Campo | Offset / tamaño | Recién preparado | Copia posterior |
|---|---|---|---|
| Tiempo base, segundos | 0x00 / 8 | 0 | 81051 |
| Origen del reloj | 0x08 / 16 | Todo cero | No nulo; valor omitido |
| Última actualización, segundos | 0x18 / 8 | 0 | 997 |
| Fracción, unidades de 10⁻⁷ s | 0x20 / 4 | 0 | 8509400 |
| Secuencia | 0x24 / 2 | Aleatoria, 14 bits | Cambió; sigue en 14 bits |
| Padding | 0x26 / 2 | 0 | 0 |
| Nodo local | 0x28 / 8 | Aleatorio, 48 bits | El mismo |
Los enteros de este archivo son little-endian. El tiempo base incluye el origen del reloj; 81051 no es una fecha Unix y no lo he convertido en una fecha de calendario. La fracción posterior representa 0,8509400 segundos.
Para preparar el nodo utilicé aleatoriedad criptográfica, dejé sus 16 bits superiores a cero y marqué su bit inferior a uno. La secuencia inicial quedó por debajo de 16384. Ese nodo sirve para generar identificadores locales: no es el identificador de hardware que comparaba Settings. Tampoco reproduje la derivación normal completa a partir del número de serie y la MAC, que era precisamente el recorrido que no conseguía completar.
Los cambios posteriores en reloj y secuencia, conservando el nodo, son compatibles con el manejo del contexto que comprobé en la rutina. Los valores los he vuelto a leer del save preservado; no son una predicción de la emulación.
Por qué 48 bytes acabaron en una escritura de 160 KiB
No inserté los bytes en cualquier hueco del contenedor. Un programa pequeño basado en LibHac abrió una copia del save, creó /context.bin y confirmó la transacción, rehaciendo su estructura e integridad. Verifiqué las cabeceras y sus MAC con las claves de la consola antes y después.
Luego comparé SYSTEM cifrado. Había diez unidades BIS de 16 KiB que sustituir, 163.840 bytes en total. Dentro de esos rangos solo 511 bytes de ciphertext resultaron distintos, en 32 bloques AES. Que sean 511 y no 512 se debe a un byte que coincidía.
Archivo nuevo dentro del save 48 bytes
Contenedor que lo alberga 589.824 bytes
Rangos BIS escritos 10 × 16.384 = 163.840 bytes
Bytes cifrados distintos 511
Ejemplo de ubicación del primer rango:
SYSTEM + unidad 25525 × 0x4000
= 125829120 + 418201600
= offset GPP 544030720
El tamaño del archivo lógico, el contenedor, la unidad de escritura y el número de bytes diferentes describen cosas distintas. El ejecutable de account siguió intacto: lo que persistió fue un estado que antes no existía.
Lo que cambiaba en RAM al arrancar CFW
Después de llegar a HOME apareció 2123-0011 en nim al probar la conexión. La entrada de recuperación utilizaba cal0blank=1. Esa opción hace que determinados datos de calibración se presenten mediante una vista alternativa durante el arranque con Atmosphère.
En las copias de PRODINFO, el tamaño del certificado SSL en +0xAD0 seguía siendo 1513 (0x5E9), y sus bytes eran iguales entre el donante y la reconstrucción final. El código de Atmosphère 1.12.1 permite seguir cómo prepara una copia en memoria y pone a cero, entre otros campos, el tamaño y los datos SSL. La capa de lectura devuelve esa vista cuando está activada.
Por tanto, 1513 en el almacenamiento y 0 en la vista en blanco pueden coexistir. El cero describe el comportamiento que deriva de ese código y la configuración, no una lectura que yo haya hecho de toda la RAM viva. No borré el certificado almacenado al poner esa opción.
Primero evité el cierre usando DNS.mitm para redirigir los dominios seleccionados a 127.0.0.1. Su registro confirmó que cargaba el archivo de REC1. Más tarde añadí sys-patch. Cambiar una resolución DNS y modificar una instrucción cargada son intervenciones distintas; el primer resultado sin crash fue anterior a sys-patch.
Con este último sí puedo poner un ejemplo de bytes de código. El log guardado tras restaurar sysMMC decía:
[nim]
blankcal0crashfix_17.0.0+=Patched (sys-patch) (0x19ECD4)
[stats]
fw_version=23.0.1
is_emummc=0
En el ejecutable extraído de nim, +0x19ECD4 contenía e2 14 00 10. Al desensamblarlo obtuve adr x2, #0x19ef70. El código de sys-patch 1.6.3.0 selecciona para ese ajuste e2 03 1f aa, que corresponde a mov x2, xzr:
; Offsets de módulo, con base cero en el análisis.
; Ejecutable extraído:
0x19ECD4: e2 14 00 10 adr x2, #0x19ef70
; Sustitución esperada al aplicar sys-patch:
0x19ECD4: e2 03 1f aa mov x2, xzr
Antes se calculaba una dirección relativa al PC y se dejaba en X2; después se coloca cero en X2. No estoy mostrando un diff de dos volcados de RAM: he cruzado el binario original, el código del parche y el log que dice que se aplicó. El destino absoluto de adr en un proceso real depende de dónde se cargue el módulo.
La modificación afecta al código cargado durante CFW. No convertí la NCA de nim almacenada en otra versión ni necesité parchear account para que leyera el contexto.
El último backup ya tenía cosas que yo no había escrito
La reconstrucción del PC había dejado de ser una copia exacta de REC1 tras los arranques. Horizon creaba saves, actualizaba estado y dejaba cambios en SYSTEM y USER. Antes de restaurar la memoria interna guardé esas diferencias.
La base ya contenía la corrección del certificado. Frente a esa base reconstruida, encontré 127 bloques cambiados de 1 MiB: 122 en SYSTEM y cinco en USER. No eran «127 bloques distintos de la NAND original averiada».
El archivo de diferencias tenía registros con este formato propio:
import struct
# Cabecera de cada registro, seguida de length bytes cifrados.
HEADER = struct.Struct("<QI") # offset GPP u64 + longitud u32
assert HEADER.size == 12
blocks = 127
block_size = 1 << 20
assert blocks * (HEADER.size + block_size) == 133_170_676
Para leer el estado final, el lector tomaba los bytes de la base y superponía los rangos presentes en el delta. Reconstruyó por lectura los 31.268.536.320 bytes y calculó su SHA-256. Coincidía con el de REC1. El backup era la pareja base + delta, junto con sus BOOT y los archivos de SD conservados, no el pequeño delta por sí solo.
En BOOT0 también había un cambio que al principio llamaba la atención. Entre dos snapshots ya funcionales, 33 bytes diferían en NAND Patrol:
Campo BOOT0 Snapshot anterior Snapshot posterior
+0x184020 / raw u32 0x019B7000 0x019BB400
+0x184024 / contador 3 3
+0x184000 / HMAC cambió al actualizarse el registro
El contador no había aumentado en esa comparación. Cambiaron un byte del campo de posición y los 32 bytes de su HMAC. Las BCT, package1 y los keyblobs eran iguales entre esas dos copias; BOOT1 también. Eso me permitió distinguir una actualización del estado de mantenimiento de otro cambio de firmware.
Lo que quedó al pasarla a sysNAND
Restauré BOOT0, BOOT1 y GPP después de verificar las partes preparadas para Hekate. La memoria interna recibió la reconstrucción que había probado, con sus cambios persistentes de CAL0 y sus saves. REC1 quedó en la SD.
Al arrancar CFW seguían interviniendo Atmosphère, el archivo hosts/sysmmc.txt y sys-patch. En stock desde Hekate no estaban esas funciones de Atmosphère. Aun así llegó al sistema y completó la prueba de conexión. Por eso el arranque stock fue una comprobación necesaria: no podía atribuir su éxito a un parche de RAM de sys-patch.
Puedo decir que reconstruí una NAND que vuelve a arrancar en esta consola y que conservé las copias para seguir los cambios. No puedo decir qué contenía en claro la NAND averiada antes del fallo, ni que haya recuperado su identidad de fábrica, ni que una prueba de conexión valide cuentas Nintendo o eShop. Son las preguntas que siguen abiertas después de mirar todos estos bytes.