Ingeniería inversa de Hololive Dreams - Archivo de notas

Preface

Cutoff: 26/08/23 Revision: 26/08/23

Registro de la ingeniería inversa del sistema de recursos de un juego móvil en Unity 6. Estado actual: assets, animaciones, cara, pelo, cámara y Live2D están todos listos para entregar; la física de huesos de resorte es lo único que sigue sin resolver.

Las herramientas y el registro completo, capítulo por capítulo, están en https://git.siao.ai/siao/hohohololive (sin el descifrado, el motivo está en (8)).

Los fragmentos de código de este artículo se incrustan directamente desde SiaoHub en tiempo de build, no se pegan a mano, así que nunca quedan desincronizados del código fuente; la página del archivo en SiaoHub también muestra, en sentido inverso, que «este artículo lo referencia».

El nombre es un homenaje a sssekai —ese proyecto y su archivo de notas fueron la referencia más útil en todo este camino de ingeniería inversa.

Objetivo del análisis: game.qualiarts.hololive.dreams.com 1.0.0 (iOS, IPA ya descifrado), Unity 6000.3.0b1, IL2CPP metadata v39.


(1) Conexión del dispositivo y IL2CPP metadata v39

1. Transporte: primero mira el techo

Dentro del contenedor de la app, Library/octo/ es la caché de descarga de recursos, 3.4GB. El subdirectorio v1/ tiene 178 archivos .awb, 1240 .acb y 176 .usm, todos en formatos de audio/vídeo CRIWARE.

Con SSH por WiFi, scp tardó 11 minutos en transferir 12MB. La primera reacción fue cambiar el cipher (el que viene por defecto no tiene aceleración por hardware); tras el cambio pareció volverse instantáneamente más rápido —era la ilusión del buffer de disco local, con archivos grandes se cae la máscara.

Pero lo más importante es que, aunque el cipher realmente ayudara, no tendría sentido: 3.4GB por WiFi, se ajuste lo que se ajuste, sigue siendo del orden de decenas de minutos. Cambié a USB:

iproxy 2222:22 &         # SSH
iproxy 27042:27042 &     # Frida
Ruta Medido Tiempo estimado para 3.4GB
WiFi SSH (cipher por defecto) ~18 KB/s ~2 días
WiFi SSH (gcm) Archivos pequeños parecen instantáneos, los grandes siguen lentos
USB (iproxy) 40–70 MB/s ~1 minuto

Antes de tocar nada, traje todo el tar completo a la máquina local. Más adelante hubo varias veces en que necesité borrar la caché del lado del dispositivo para observar el momento de la descarga; sin una copia de seguridad, cada borrado habría sido irreversible.

2. El metadata está cifrado, el binario no

$ xxd -l 8 global-metadata.dat
00000000: 8f2b 0d1c ...       # esperado AF 1B B1 FA

El magic no coincide. Pero el UnityFramework en disco no está cifrado —hay que distinguir estas dos cosas, algo que al principio no distinguí (ver el Apéndice, escollos).

El metadata necesariamente está descifrado en tiempo de ejecución, así que escaneé la memoria buscando el magic:

import frida
 
MAGIC = b"\xaf\x1b\xb1\xfa"
 
def on_message(msg, data):
    if msg["payload"].get("event") == "metadata":
        open("global-metadata-decrypted.dat", "wb").write(data)
 
dev = frida.get_usb_device()
pid = dev.spawn(["game.qualiarts.hololive.dreams.com"])
ses = dev.attach(pid)
scr = ses.create_script(open("dump.js").read())
scr.on("message", on_message)
scr.load()
dev.resume(pid)

Process.enumerateRanges("r--") escaneando segmento por segmento, un único hit de dirección:

magic   = AF 1B B1 FA
version = 39

version = 39 es un número de versión válido de IL2CPP, lo que confirma que es la forma real descifrada y no una coincidencia.

3. El patrón de fallo al falsificar la versión es la respuesta

La última versión de Il2CppDumper solo soporta hasta la 31. El método estándar es cambiar el número de versión para engañarlo, porque el formato a menudo no cambia, solo salta de número.

Disfrazada de Resultado
27 Falla — conflicto de key
29 Falla — mismo conflicto de key
31 Falla — mismo conflicto de key

Las tres veces falló en el mismo punto. Si solo fuera un salto de número, cambiar a distintas versiones antiguas debería romperse en lugares distintos; que las tres fueran idénticas indica que en ese punto la estructura que el parser está leyendo ya diverge de lo esperado —es un formato realmente actualizado.

Lo valioso de esta conclusión es que cierra toda una rama de una vez. «Probar un número de versión más» cuesta solo treinta segundos cada vez, así que es muy fácil seguir intentándolo indefinidamente.

Cambié a Il2CppInspectorRedux (el fork de LukeFZ), que generó un mapa completo de 560.000 nombres de métodos a direcciones virtuales.

4. Entorno de decompilación: evitar la JVM

Ghidra necesita la JVM, y el módulo del kernel AppleSystemPolicy de esta máquina bloquea el java sin firmar —no es una restricción del sandbox de la herramienta, es el propio sistema; ejecutarlo directamente en la propia terminal da el mismo Kill: 9.

No hay que pelear contra el sistema. rz-ghidra extrae el núcleo en C++ del motor de decompilación de Ghidra (SLEIGH + decompiler) y lo compila como un plugin de rizin, que en tiempo de ejecución no necesita la JVM:

git clone --recurse-submodules https://github.com/rizinorg/rz-ghidra.git
cd rz-ghidra && mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release .. && make -j$(sysctl -n hw.ncpu)
cp *.dylib /opt/homebrew/lib/rizin/plugins/

Escollo uno: copiar solo el .dylib no basta; también hay que devolver el .sla generado por el build (la especificación de lenguaje sleigh ya compilada) al árbol de código fuente, al mismo nivel que el .slaspec, y configurar SLEIGHHOME. El mensaje de error no menciona que falta el archivo de datos.

Escollo dos: si el af de rizin -q -c "s <addr>; af; pdg" cae, en cierta dirección, dentro del rango de otra función anterior y más grande, reutiliza los límites existentes y decompila contenido de función que no tiene nada que ver. Por esto llegué a malinterpretar cierta dirección como la lógica de búsqueda de un Dictionary genérico compartido por IL2CPP, y por un tiempo creí que cierto valor clave venía de una búsqueda en tabla en lugar de calcularse.

Estado: completo.

References


(2) Localizando la capa de protección: el cifrado oficial nunca estuvo activado

Este juego usa el middleware de audio CRIWARE, y CRIWARE tiene cifrado oficial. En el mapa de métodos efectivamente aparece:

Vision.Sound.CriWareDecrypter.Initialize(string key, bool enableAtom, bool enableMana)

Parece ser eso. Hice hook para ver los parámetros reales de la llamada:

[+] CriWareDecrypter.Initialize
    key         = "..." (no vacío)
    enableAtom  = false
    enableMana  = false

Los dos flags están en false: el cifrado oficial nunca se activó —la key se pasa pero nadie la usa. Lo que realmente protege los recursos es otra capa hecha a medida por el juego (Vision.Octo.ResourceDecrypter).

Antes de esto pasé varias horas estudiando la documentación de CRIWARE. Hacer un hook para ver el valor real tomó menos de diez minutos.

Corrección (en el momento del análisis): al principio, con solo mirar la superficie (el bundle no tiene el prefijo en texto plano del grupo de audio, y su inicio tiene alta entropía) concluí que audio y bundle eran dos mecanismos distintos, y di una vuelta enorme (probé LZ4, AES, hooks de GPU, captura de Metal). El verdadero avance fue volver a lo básico y hacer un ataque de texto plano conocido —en realidad ambos comparten el mismo esquema, solo difieren el prefijo y el punto de inicio. Lección: una diferencia superficial no equivale a una diferencia de mecanismo, y «parece distinto» se convierte fácilmente en una excusa para dejar de verificar.

Estado: completo.


(3) Catálogo de recursos y cadena de extracción sin jailbreak

1. El argumento de la escala: por qué no usar hooks pasivos

Descifrar requiere el nombre de archivo original de cada archivo (address). El nombre no está en el archivo cifrado, está en el catálogo de recursos del juego.

Enfoque pasivo: hacer hook a la función de descifrado, jugar el juego, y registrar lo que se vaya cargando. Va a funcionar seguro, técnicamente no tiene ningún riesgo.

El problema es la escala:

Total de archivos cifrados      1467
Cobertura del hook pasivo       depende de hasta dónde juegues
Horas estimadas                 cientos de horas
Garantía de cobertura           ninguna (los recursos de eventos limitados podrían no activarse nunca)

Cuando el costo de un camino es «tiempo × suerte» y no hay garantía de cobertura, no es un camino, es una pendiente resbaladiza.

2. Primero medir, luego decidir si vale la pena

octo/pdb/5/100001/octocacheevai, 4.4MB. Primero calculé la entropía:

from collections import Counter
import math
 
d = open(path, "rb").read()
c = Counter(d)
H = -sum((n / len(d)) * math.log2(n / len(d)) for n in c.values())
# -> 8.0 bits/byte

Puntaje máximo 8.0: confirma que es cifrado real y no solo un formato comprimido o serializado —vale la pena el esfuerzo, y también significa que no hay posibilidad de un análisis estático.

3. Localizar la forma ya descifrada

El índice necesariamente se descifra para usarse en tiempo de ejecución, así que su forma descifrada tiene que estar en la memoria del proceso. No hace falta romper su cifrado, solo encontrar su forma ya descifrada.

La primera versión intentó capturar el «descifrado in situ»: hacer hook a read(), anotar la dirección del buffer, y volver a leer ese mismo bloque unos segundos después. Todo mal —el buffer nativo se recicla y se reutiliza para otra cosa poco después de que la función retorna (ver el Apéndice).

Cambié a esto: esperar 60 segundos a que el índice cargue por completo, y luego escanear toda la memoria del proceso buscando una cadena de nombre de archivo que se sabe que aparece ahí.

Bloque encontrado         16 MB
Número de coincidencias   3563

Ese bloque es el catálogo de recursos completo ya descifrado, en formato protobuf.

4. Estructura de las entradas

1a <len>              # entrada (submensaje delimitado por longitud)
  08 <varint>         # 1  id          -> nombre de directorio en caché = ("A"|"R") + id, codificado en hex
  12 <len> <bytes>    # 2  name        -> address (nombre de archivo original)
  18 <varint>         # 3  size        -> bytes en texto plano
  2a 20 <32 bytes>    # 5  md5         -> nombre de archivo en caché
  3a <len> <bytes>    # 7  objectName  -> clave de objeto en el CDN, cadena aleatoria de 6 caracteres

Ejemplo (VisionProject.acf, longitud total del registro 0x42 = 66 bytes, la suma campo por campo coincide exactamente):

1a 42  08 01  12 11 "VisionProject.acf"  18 89 77  2a 20 "bd19...8afb"  3a 06 "VQHQAP"
       id=1        name(17)              size=15241   md5(32)            objectName(6)

5. Verificación: cuadre de cuentas, no «parece correcto»

El nombre de directorio de la caché de octo es ASCII codificado en hex —413138363439 decodifica a A18649. Así que ("A"|"R") + id se puede cuadrar uno por uno contra los 4942 archivos de caché locales:

id coincide           4929
id no coincide           13
no está en catalog        0
                     -> 99.74%

Los 13 casos que no coinciden son todos truncamiento en el límite del bloque de memoria: al inicio de address se cuelan caracteres de ruido como * o # (ejemplo: *vo_live_cmn_chr_0). Eso es un problema del límite del dump, no de la lógica de parseo; basta con filtrar exigiendo que el primer carácter de address sea alfanumérico.

Este paso es el más importante de toda la sección. Cuando un parser produce «una cadena que parece un nombre de archivo», puede que solo esté leyendo ruido. Hace falta una fuente independiente con la cual cuadrar cuentas —aquí, los nombres de directorio de la caché local.

El resultado final es una tabla completa de 16823 entradas de hash a address, con 99.93% de cobertura por lote: 1595 archivos, 1.5GB.

6. objectName → CDN

Antes, al invertir OctoAPI.DecryptAes, ya había descifrado una plantilla de CDN:

https://asset.game-hololive-dreams.com/{o}

En ese momento no sabía qué era {o}, lo tomé por una pista falsa. Solo al terminar de resolver la estructura de las entradas supe que {o} es exactamente el objectName del field 7.

GET https://asset.game-hololive-dreams.com/UFfHjj
    User-Agent: UnityPlayer/6000.3.0b1
-> 1462529 bytes, md5 = 94bb0bfa4f2a82415c87fff62763ef6a

El md5 del catalog se calcula sobre el texto cifrado, así que la integridad se puede verificar directamente después de descargar, sin necesidad de descifrar primero.

Esto significa que el jailbreak queda reducido a un único uso: obtener el catalog una vez.

DEFAULT_URL_FORMAT = "https://asset.game-hololive-dreams.com/{o}"
USER_AGENT = "UnityPlayer/6000.3.0b1"
 
 
def url_for(entry: dict, url_format: str = DEFAULT_URL_FORMAT) -> str:
    return url_format.replace("{o}", entry["object_name"])

在 SiaoHub 檢視 siao/hohohololive/hohohololive/cdn.py L21-26

7. Parseo por avance de campos: una suposición causó un 94% de omisiones

La primera versión del parser asumía que objectName (field 7) venía justo después de md5 (field 5). El resultado: de 608 entradas mdl_chr, solo se capturaron 2.

En medio se interponía un field 6 repetido:

2a 20 <md5>  30 ca8402  30 e19402  30 809502 ...  3a 06 "SswxO0"  42 23 <address>
             \____ 6 = id de recurso dependiente (repeated varint) ____/  \_ 7 = objectName

Cada modelo 3D depende de texturas y materiales, así que todos tienen field 6, y todos se saltaban. Cambié a un parseo por avance de campos apropiado —avanzando campo por campo según el wire type: tag → longitud → valor:

        while p < n:
            tag = blob[p]
            field, wire = tag >> 3, tag & 7
            p += 1
            if wire == 0:                       # varint
                v = shift = 0
                while p < n:
                    b = blob[p]
                    v |= (b & 0x7F) << shift
                    p += 1
                    if not (b & 0x80):
                        break
                    shift += 7
                if field == 6:
                    deps.append(v)
                else:
                    break
            elif wire == 2:                     # length-delimited
                if p >= n:
                    break
                ln2 = blob[p]
                p += 1
                if ln2 & 0x80:                  # 兩位元組長度, 已超出本筆範圍
                    break
                val = blob[p:p + ln2]
                p += ln2
                if field == 7 and len(val) == ln2 and all(0x20 <= c < 0x7F for c in val):
                    obj = val.decode("ascii")
                break
            else:
                break
        out.append({"id": oid, "address": address, "size": size,
                    "md5": md5.decode(), "object_name": obj,
                    "dependencies": deps})

在 SiaoHub 檢視 siao/hohohololive/hohohololive/catalog.py L112-145

La condición para field == 7 además exige «que la longitud coincida y que todo sea ASCII imprimible», porque el truncamiento en el límite del dump de memoria hace que la última entrada lea medio campo. Después de la corrección:

Antes de la corrección    33799 / 36119 entradas tienen objectName
Después de la corrección  36119 / 36119

De paso también quedó resuelta la lista de dependencias (field 6), lo que facilitó mucho «capturar junto con las dependencias» más adelante.

Protobuf no garantiza el orden de los campos, y los campos repeated encima tienen longitud arbitraria. Localizar campos por «desplazamiento» es apostar a los detalles de implementación del serializador, y cuando se pierde esa apuesta no lanza un error, solo captura de menos —una proporción como 2/608 es obvia, pero 33799/36119 no necesariamente se habría notado.

8. Prueba real

Por descargar   944 entradas (3D + Live2D faltante + mot_define), 1.01 GB
Exitosos        944
Omitidos          0
Fallidos          0

Cadena offline completa: descarga del CDN → verificación → descifrado → extracción.

Estado: completo.

References


(4) Modelos 3D: glTF con esqueleto vinculado

1. Diseño de los streams de vértices

stride(s) = Σ dimension × sizeof(format)
offset(0) = 0
offset(s) = align16(offset(s-1) + vertexCount × stride(s-1))

Medido en el body de un personaje:

stream0 stride 40  ch0 Position(3f)  ch1 Normal(3f)  ch2 Tangent(4f)
stream1 stride 16  ch3 Color(4×UNorm8) ch4 UV0(2×half) ch7 UV3(2f)
stream2 stride 32  ch12 BlendWeight(4f) ch13 BlendIndices(4×uint32)

Método de verificación: para cada mesh, calculé «longitud calculada vs. m_DataSize real», y todo coincidió byte por byte.

2. Dos bugs reales

(1) El número de huesos que influyen no es siempre 4. Al principio asumí que ch12/ch13 siempre tenían dim4, y usé [:, :4] directamente:

Mesh ch12 ch13 Real
Geo_Body_LOD0 dim4 dim4 mezcla de 4 huesos
Geo_Eye_LOD0 dim2 dim2 mezcla de 2 huesos
Geo_Brow_LOD0 / Geo_Iris_LOD0 ninguno dim1 hueso único rígido, peso siempre 1

Para los meshes con dim2, [:, :4] solo obtiene un array de ancho 2 pero lo declara como VEC4 → la suma de pesos termina entre −2.37 y 3.02, con índices de joint de 63471. Las cejas y el iris, por «no tener ch12», se saltaban por completo como si no tuvieran binding. Corrección: siempre rellenar con ceros hasta ancho 4; cuando falta ch12, tratarlo como binding rígido y poner el peso del slot 0 en 1.0.

(2) Los datos de vértices se guardan de dos formas. Los modelos de personajes van embebidos en m_VertexData.m_DataSize, pero las piezas de escenario en su mayoría están en un archivo de stream externo (.resS), indicado por mesh.m_StreamData con path/offset/size. Sin manejar esto, fbx_mdl_env_* extraía 0 meshes en todo el lote. Lo resolví usando UnityPy.helpers.ResourceReader.get_resource_data() para recuperarlos.

3. Conversión de sistema de coordenadas

Unity (sistema zurdo) → glTF (sistema diestro), reflejando el eje X con M = diag(-1,1,1):

Objeto Conversión
Posición / normal / tangente (x,y,z) → (-x,y,z)
Cuaternión de rotación (x,y,z,w) → (x,-y,-z,w)
Matriz de bind inverso M' = S·M·S, S = diag(-1,1,1,1)
UV v → 1-v (Unity tiene el origen abajo-izquierda, glTF arriba-izquierda)
Winding del triángulo invertido (cambia el signo del determinante)

La derivación de la fila del cuaternión: R' = M R M, donde M es una transformación impropia (det = −1), que convierte «rotar θ alrededor del eje a» en «rotar θ alrededor de (aₓ, -a_y, -a_z)»; sustituyendo en q = (sin(θ/2)·a, cos(θ/2)) se obtiene el resultado.

Estas reglas están escritas en el docstring de la implementación, no solo en el registro, porque son premisas que hay que reconfirmar cada vez que se modifica este archivo:

## 座標系轉換
 
Unity 是左手系 (Y-up, Z-forward),glTF 是右手系 (Y-up, Z-back)。
以鏡射 X 軸 M = diag(-1,1,1) 轉換:
 
    位置/法線/切線   (x,y,z) -> (-x, y, z)
    旋轉四元數       (x,y,z,w) -> (x, -y, -z, w)
        推導: R' = M R M, M 為非正常變換 (det=-1),
        使繞軸 a 轉 θ 變成繞 (ax,-ay,-az) 轉 θ
    逆綁定矩陣       M' = S · M · S     (S = diag(-1,1,1,1))
    三角形環繞順序   必須反轉 (行列式變號)

在 SiaoHub 檢視 siao/hohohololive/hohohololive/gltf.py L27-37

4. Un lugar donde la inferencia falló dos veces: la carcasa de contorno

Geo_Body_LOD0 tiene 3 submeshes, con 13932 / 240 / 13932 caras —el 0 y el 2 son idénticos. El buffer de índices 41796 + 720 + 41796 = 84312 llena exactamente 168624 bytes (uint16), así que no es un error de parseo, son datos reales.

Primera inferencia (incorrecta): estos submeshes duplicados no tenían material correspondiente en renderer.m_Materials, así que concluí que «por eso Unity no lo renderiza», y usé «sin material» como criterio de descarte.

La verdad: el material siempre estuvo ahí, solo que era un material compartido guardado en un bundle dependiente, y el PPtr no se puede resolver sin cargar la dependencia. En cuanto agregué load_bundle_with_deps(), los nombres salieron a la superficie directamente:

Materiales: ['m_eye', 'm_bdy', 'm_bdyco', 'SubMeshOutlineMaterial', 'm_fef']
  Geo_Body_LOD0 sub0 caras=13932 material=m_bdy
  Geo_Body_LOD0 sub1 caras=  240 material=m_bdyco
  Geo_Body_LOD0 sub2 caras=13932 material=SubMeshOutlineMaterial   <- carcasa de contorno

Es el contorno inverted-hull típico de toon rendering: la misma geometría, extruida hacia afuera por la normal y con las caras invertidas para dibujar solo el reverso.

«Esto no tiene X, así que el motor no lo usa» —cuando X es algo que se resuelve a través de otro bundle, la premisa de esa frase puede ser simplemente que no cargaste la dependencia.

5. BlendShape → glTF morph target

Las expresiones faciales no dependen del esqueleto, van por blendshape, y solo en los meshes de la cara:

Mesh N.º de canales
Geo_Eye_LOD0 16
Geo_Brow_LOD0 14
Geo_Iris_LOD0 2
Geo_Body_LOD0/1 0 (la deformación del cuerpo depende solo del esqueleto)

Total 32 —coincide exactamente con el número de curvas typeID 137 dentro del clip de animación, cada uno corrobora al otro.

Unity lo guarda de forma dispersa: channels[] apunta a un segmento de shapes[], y shapes[i] a su vez apunta a un segmento de vertices[], donde cada vértice trae su propio índice del mesh original. El morph target de glTF necesita un array de desplazamientos denso, del mismo largo que el mesh, así que lo expandí de vuelta uno por uno (el desplazamiento también necesita el reflejo en X).

channel.frameCount > 1 indica una deformación progresiva; un target de glTF solo puede representar una forma, así que tomé el último frame. En este juego, medido, todos tienen frameCount = 1.

Estado: completo.


(5) Animación 3D: búsqueda inversa por CRC32 y mot_define

1. La brecha fue el MonoBehaviour, no el AnimationClip

El genericBindings de AnimationClip solo guarda hashes:

{'path': 1182008026, 'attribute': 1661978518, 'typeID': 137}

Al principio calculé CRC32 sobre las rutas de huesos del skeleton.json del personaje para compararlas —608 esqueletos × todas las formas de ruta posibles, 0 coincidencias.

La respuesta real estaba en el MonoBehaviour del mismo bundle (VisionActorMotionDefine): su baseAnimation.bindings lista cada binding en texto plano:

{"name": "Geo_Eye_LOD0", "path": "Root_Body/Geo_Eye_LOD0",
 "type": "UnityEngine.SkinnedMeshRenderer",
 "properties": ["blendShape.b_eye.eye_001", "..."]}

El nodo raíz del Animator se llama Root_Body, no el nombre del bundle —por eso no coincidían.

Se confirmó que la función de hash es CRC32(texto plano):

Cadena CRC32 Aparece en el clip
b_eye.eye_001 1661978518 primera entrada de blendshape (sin el prefijo blendShape.)
m_FadeFactor 682354173 6 veces = 6 DecalProjector
bakeAnimationWeight 3202011236 44 veces = 44 huesos de swing

Recopilé los bindings de los 637 bundles en una tabla de búsqueda inversa global (el diccionario de un solo bundle solo alcanza para resolver sus propias entradas), obteniendo 520 rutas / 198 nombres de propiedad.

Este es el punto más reutilizable de la sección: cuando un hash no coincide, sospecha primero de la forma de la cadena de entrada, no de la función de hash. El tiempo que pasé en «¿será en realidad otro hash?» superó por mucho al que pasé en «¿será que el prefijo de la ruta es distinto?», y la respuesta era lo segundo.

Estado: completo.


(6) Live2D: atacar la capa nativa, no la capa superior

1. Tres caminos sin salida

Probé en orden, todo falló: suponer que era compresión LZ4; buscar Octo.dll/Octo/Loader/OctoAPI.DecryptAes (un éxito engañoso —lo que descifra son paquetes de API, no recursos); hacer hook a la API del Cubism SDK en la capa de IL2CPP.

La forma en que falló el tercer camino señaló la causa raíz: Cubism Core es una biblioteca C nativa, enlazada estáticamente y directamente dentro de UnityFramework, sin un .framework independiente, así que en la capa de IL2CPP simplemente no hay nada que hacer hook.

2. Cambiar de objetivo a los símbolos nativos exportados

Module.enumerateExports() lista todos los exports nativos que empiezan con csm (44 en total):

csmGetVersion
csmGetMocVersion / csmGetLatestMocVersion
csmHasMocConsistency
csmReviveMocInPlace   ← función clave
csmInitializeModelInPlace
csmUpdateModel
familia csmGetDrawable*

csmReviveMocInPlace(void* address, unsigned int mocSize) recibe dos parámetros: la dirección de memoria donde están los datos moc3 ya descifrados y parseables, más el tamaño. No hace falta entender si la capa superior es C# o IL2CPP, ni tocar el algoritmo de cifrado en sí —basta con que se llame a esta función nativa: en ese momento, los datos en memoria son moc3 en texto plano, 100% correctos y usables.

const target = Module.findExportByName("UnityFramework", "csmReviveMocInPlace");
Interceptor.attach(target, {
  onEnter(args) {
    const addr = args[0], size = args[1].toInt32();
    send({event: "csmReviveMocInPlace", size}, addr.readByteArray(size));
  }
});

Usar Module.findExportByName en lugar de un offset de dirección fijo lo hace inmune por diseño a ASLR y a los desplazamientos de dirección tras reiniciar.

3. La forma genérica

Vale la pena escribir por separado la forma reutilizable de esta sección: encuentra la función nativa que «recibe el texto plano como parámetro» y espera ahí. Sin importar cuántas capas de cifrado, ofuscación o runtime gestionado envuelvan la capa superior, los datos tarde o temprano tienen que entregarse al motor en una forma que este entienda. Ese punto de entrega es la posición de menor costo, y por naturaleza no cambia aunque el esquema de cifrado se actualice.

Una vez obtenidos, .moc3 / .model3 / .physics3 se pueden abrir directamente en Cubism Editor; los materiales hay que renombrarlos según la información de BuildModelData.

Estado: completo (el atlas de texturas es aparte, ver abajo).

4. Lo que no se resolvió: el atlas de texturas

El texture atlas exclusivo del moc3 no se ha conseguido hasta ahora. Probé siete caminos en orden, todos fallaron (todos eran hooks de Frida + activación manual jugando), y al final, usando solo localización estática de la ruta de carga, encontré la fuente real de los datos. En el medio, en un momento tomé set_MainTexture como el punto de enganche correcto, y luego lo descarté al seguir decompilando.

Corrección: la suposición sobre set_MainTexture resultó ser incorrecta. En su momento «parecía ser eso», y al hacer hook realmente había algo ahí —solo que no era lo que buscaba. Que el hook capture algo no significa que esté en el lugar correcto.

Estado: incompleto. La opción que queda es Xcode Metal Frame Capture.

References


(7) Física de huesos de resorte (incompleto)

El vuelo de la falda, las cintas y el pelo no vienen incluidos en la animación horneada —el bakeo solo cubre 51 huesos humanoid, mientras que el modelo tiene en realidad 126; los 75 que faltan (31 de falda, 10 de mejillas, 8 de cintas…) los calcula en tiempo de ejecución el sistema Swing/Quartz del juego.

Pero los parámetros sí vienen incluidos, embebidos en el MonoBehaviour de cada bundle de modelo:

ActorSwingDynamicBone   en huesos _sim         hueso que se simula
ActorSwingStaticBone    en huesos del cuerpo    colisionador
ActorSwingChain         en hips                 estructura de cadena
QuartzDriverSkirtBone   en huesos _ast          hueso auxiliar procedural

(No se pueden encontrar estos buscando por address en el catalog porque address no incluye el nombre de clase del MonoBehaviour —es el mismo error que cometí al principio buscando el Avatar.)

El integrador para la reproducción offline:

step    = min(dt, 1/60) × 40
inertia = (pos - prevPos) × (1 - damping)²
force   = CalcStiffnessPendulum(...) + childSpeed × spring
newPos  = pos + inertia + force × step
newPos.y -= mass × 0.01            gravedad
                                   después: restricción rígida de longitud de hueso, luego se convierte a rotación del hueso padre

CalcStiffnessPendulum (dynamicType == 0, cubre 6900/6940 huesos):

delta = rotate(parent.worldRot, boneAxis)      dirección de reposo del hueso
cos   = |dot(cur, rest)| / (|cur|·|rest|)      ángulo entre los dos vectores de posición
p     = max(0, cos - (1 - range)) / range × pendulum
return delta × (stiffness - p) × 0.01

Las coordenadas están en animator root space, no en espacio mundial, así que basta con calcular directamente usando la jerarquía del esqueleto del GLB.

 
            for _ in range(n_sub + warm):
                cur, pv = pos[ni], prev[ni]
                inertia = (cur - pv) * (1.0 - damping) ** 2
                # cos 是 prevPos 與 pos 的夾角 (呼叫端 childTx=-0xf0=prevPos,
                # childDefaultTx=(s13,s11,s12)=pos)。0x02793a04 把 -0xf0
                # 寫回 child.selfTx.translation, 證實它就是子骨的位置。
                if pen <= 1e-5 or rng <= 1e-5:
                    p_term = 0.0
                else:
                    na, nb = np.linalg.norm(pv), np.linalg.norm(cur)
                    cosv = abs(float(np.dot(pv, cur))) / max(na * nb, 1e-9)
                    p_term = max(0.0, cosv - (1.0 - rng)) / rng * pen
                # delta = rotate(**當前**的 selfTx.rotation, boneAxis) —— §35 釘死:
                # selfTx 是迴圈攜帶狀態, 每個子步讀到的是上一子步的模擬結果,
                # 不是動畫給的靜止姿勢。骨的當前方向就是 (pos - 骨位置) 正規化。
                # 用動畫的 rest_dir 等於憑空多給一個遊戲裡沒有的角度回復力。
                if args.delta_current:
                    cd = cur - anchor      # §39: 原本用 WT[ni], 與骨長約束的基準不一致
                    cn = float(np.linalg.norm(cd))
                    dvec = cd / cn if cn > 1e-9 else rest_dir
                else:
                    dvec = rest_dir
                force = dvec * (stiff - p_term) * 0.01 + csp
                if args.vel == "off":
                    new = cur + inertia + force * step
                else:
                    # §36: 0x02793408/0x02793410 讀寫 [x20+0x54] 這個累加器 ——
                    #   fmul s1, s0, s5             力 × step
                    #   fmul v13.2s, v0.2s, v2.s[0] × swingPowerWeight (實測 1.0)
                    #   fadd v4.2s, v13.2s, v0.2s   累加進狀態
                    # 力不是加到位置, 是加進狀態; 狀態才改位置。
                    vel[ni] = vel[ni] * (1.0 - args.vel_damp) + force * step
                    if args.vel == "add":
                        new = cur + inertia + vel[ni] * step
                    else:
                        new = cur + vel[ni] * step
                new[1] -= mass * 0.01 * args.gravity_scale  # 重力 (每個子步)

在 SiaoHub 檢視 siao/hohohololive/sim_swing.py L705-742

Estado actual

El método de verificación es comparar frame a frame contra el ground truth del juego —bake_swing_truth.py graba, desde el juego, la rotación local de los huesos _sim del mismo clip, y calcula el ángulo frame por frame:

ángulo = 2 · arccos(|dot(q_sim, q_truth)|)

El signo del cuaternión no afecta la pose, así que se toma el valor absoluto.

error / amplitud del movimiento real   110%      (100% = el hueso de resorte no se mueve en absoluto)

Sigue siendo ligeramente peor que «no hacer nada». El síntoma converge en un solo factor: sobre-oscilación de 1.50 veces. Cuatro elementos ya implementados pero desactivados por defecto (cada uno penalizado porque a la etapa siguiente todavía le falta amortiguación):

--quartz          QuartzDriverSkirtBone maneja la raíz de la cadena _ast   142%
--delta-current   delta usa la dirección actual en vez de la de reposo     153%
--collision       esfera vs cápsula cónica                                 202%
--vel             la fuerza se acumula en el estado de velocidad           147%

Todavía sin implementar: el viento (CalcWindPower, medido con windPower = 0.7 activado), la cuarta pasada de suavizado de la cadena de huesos, y el driver _ast de múltiples variantes.

Corrección: en su momento decidí dejar de lado el viento con el juicio de que «aumenta la oscilación, en la dirección equivocada» —ese juicio se hizo sobre un modelo que todavía tenía cuatro bugs, hay que volver a probarlo. Un descarte hecho sobre una línea base incorrecta no cuenta como descarte.

Estado: incompleto. Es lo único que sigue abierto en todo el proyecto.


(8) Alcance de la publicación

El algoritmo de descifrado quedó resuelto, y también se escribió como una especificación matemática completa y una herramienta ejecutable. Esa parte no está aquí, ni en el repo público.

El motivo es legal. El artículo 80-2 de la Ley de Derechos de Autor de Taiwán prohíbe proveer «dispositivos, equipos, componentes, técnicas o información cuyo propósito principal sea eludir medidas de protección contra copia»; la palabra «información» abarca no solo código, sino también un documento de especificación lo bastante claro como para que, con solo leerlo, cualquiera pueda implementarlo por su cuenta. El tercer párrafo del mismo artículo tiene una excepción para «ingeniería inversa realizada con el fin de lograr interoperabilidad entre programas», pero la interpretación general es que cubre hacer ingeniería inversa, no publicar el método para eludir la protección. Es una zona gris, y no soy abogado.

(Por eso este artículo difiere en este punto del archivo de notas de sssekai —allí sí se publica la key table completa junto con la key/iv de AES, aquí no. Es mi decisión conservadora respecto a mi propia jurisdicción, no un juicio sobre cómo lo hacen otros.)

Las herramientas se publican divididas: la mitad de parseo de formato (extracción de AssetBundle, mesh/esqueleto, conversión a glTF, decodificación de animación 3D y Live2D) es trabajo de interoperabilidad y está pública en https://git.siao.ai/siao/hohohololive; la mitad del descifrado no.

El propio criterio de división tiene un giro que vale la pena anotar. Originalmente pensaba hacer lo típico de «quitar la key, dejar el algoritmo», pero eso no funciona —la key se deriva de address, y el procedimiento de derivación es en sí mismo la key: no hay un secreto independiente que se pueda quitar. Y la única constante mágica en la implementación es un solo byte; el texto plano conocido está escrito en la cabecera del archivo, así que las 256 posibilidades son una fuerza bruta de microsegundos. Ocultar solo esa constante reduce el esfuerzo requerido a cero, pero produciría algo que «parece censurado pero en realidad no lo está». Así que se quitó toda la capa junto.

Los assets no se publicaron en absoluto; los derechos de autor de los recursos del juego pertenecen a la distribuidora, ni un solo byte está en ninguno de mis lugares de publicación.


(Apéndice) Escollos

Medir la velocidad de transferencia con un archivo pequeño. Después de cambiar el cipher probé con un archivo pequeño, y «terminó de transferirse» justo en el instante en que se escribía en la caché de disco. Ver una mejora después de un cambio no significa que ese cambio la haya causado.

Volqué 234MB de memoria innecesaria. El UnityFramework en disco nunca estuvo cifrado. Peor aún, la versión en memoria resultó inutilizable:

Disco     __TEXT empaquetado de forma compacta, file offset == vaddr - base
Memoria   __TEXT alineado a página, la diferencia entre ambos es el padding de alineación de cada segmento

Las direcciones no coincidían y la herramienta de parseo simplemente se rompía. Lo que estaba cifrado era el metadata, no el binario; al no distinguirlos, apliqué el mismo tratamiento contra el cifrado a ambos.

Leer el buffer nativo con retraso. El buffer de read() nativo se recicla y se reutiliza poco después de retornar; lo que se lee con retraso son solo residuos que no tienen relación —por un tiempo lo confundí con lógica de búsqueda en tabla, cadenas UTF16, bplist. Hay que volcarlo de forma síncrona justo en onLeave:

Interceptor.attach(Module.findExportByName(null, "read"), {
  onEnter(args) { this.buf = args[1]; },
  onLeave(ret) {
    const n = ret.toInt32();
    if (n > 0) send({tag: "read", n}, this.buf.readByteArray(n));  // en el momento, no se puede retrasar
  }
});

La reutilización de fd contamina la tabla de seguimiento. Hice hook a open() para anotar los fd de interés pero no los limpiaba en close(). Cuando el sistema reasigna ese mismo número de fd a otro archivo, termina tomando contenido sin relación (cabeceras UnityFS, bplist00) como si fuera el contenido del archivo objetivo:

Interceptor.attach(Module.findExportByName(null, "close"), {
  onEnter(args) { tracked.delete(args[0].toInt32()); }
});

Los dos últimos son los que más vale la pena recordar, porque no son «me equivoqué al inferir», sino que el propio método de observación estaba generando datos falsos. Los datos falsos se veían idénticos a los reales, y hasta construí varias explicaciones para ellos.


Herramientas

Propósito Herramienta
Reenvío de puerto USB libimobiledevice / iproxy
Instrumentación dinámica Frida (API de Python, no CLI)
Parseo de IL2CPP Il2CppInspectorRedux (fork de LukeFZ)
Decompilación rizin + rz-ghidra
Assets de Unity UnityPy (FALLBACK_UNITY_VERSION = "6000.3.0b1")
  • Usa la API de Python de Frida, no la CLI. La CLI tiene problemas de timeout al hacer attach; device.spawn() → attach() → resume() es mucho más estable.
  • La CLI de Il2CppInspectorRedux puede quedarse falsamente colgada. Su servicio web SignalR integrado a veces bloquea todo el proceso, con todos los hilos en idle en __psynch_cvwait. Con sample <pid> se ve que no está ocupado, está esperando. Cambiar a una opción de salida más liviana evita el problema.

References