Hololive Dreams 리버스 엔지니어링 - 노트 아카이브
Preface
Cutoff: 26/08/23 Revision: 26/08/23
Unity 6 기반 모바일 게임 리소스 시스템을 리버스 엔지니어링한 기록이다. 현재 상태: 에셋, 애니메이션, 얼굴, 머리카락, 카메라, Live2D는 전부 완성됐고, 스프링 본 물리만 아직 풀지 못했다.
도구와 챕터별 전체 로그는 https://git.siao.ai/siao/hohohololive에 있다 (복호화 부분은 제외, 이유는 (8) 참고).
본문의 코드 스니펫은 빌드 시점에 SiaoHub에서 직접 임베드된 것이지 손으로 붙여넣은 게 아니어서, 원본 코드와 어긋날 일이 없다. SiaoHub 쪽 파일 페이지에도 "이 글에서 인용됨"이 역으로 표시된다.
이름은 sssekai에 대한 오마주다 —— 그 프로젝트와 노트 아카이브는 이번 리버스 엔지니어링 과정에서 가장 유용했던 참고 자료였다.
분석 대상: game.qualiarts.hololive.dreams.com 1.0.0 (iOS, 복호화된 IPA), Unity 6000.3.0b1, IL2CPP metadata v39.
(1) 기기 연결과 IL2CPP metadata v39
1. 전송: 일단 한계부터 본다
앱 컨테이너 아래 Library/octo/는 리소스 다운로드 캐시로 3.4GB다. 하위 디렉터리 v1/에는 .awb 178개, .acb 1240개, .usm 176개가 있는데 전부 CRIWARE 오디오/비디오 포맷이다.
WiFi로 연결한 SSH에서 scp로 12MB를 옮기는 데 11분이 걸렸다. 처음 든 생각은 cipher를 바꾸는 것(기본값은 하드웨어 가속이 없다)이었고, 바꾸자 순간적으로 빨라진 것처럼 보였다 —— 그건 로컬 디스크 버퍼가 만든 착시였고, 큰 파일에서는 바로 본색이 드러났다.
더 중요한 건 cipher가 정말 효과가 있었다 해도 의미가 없었다는 점이다: 3.4GB는 WiFi로는 아무리 손봐도 수십 분 단위다. USB로 바꿨다:
iproxy 2222:22 & # SSH
iproxy 27042:27042 & # Frida| 경로 | 실측 | 3.4GB 예상 시간 |
|---|---|---|
| WiFi SSH (기본 cipher) | ~18 KB/s | 약 2일 |
| WiFi SSH (gcm) | 작은 파일은 아주 빨라 보이지만 큰 파일은 여전히 느림 | — |
USB (iproxy) |
40–70 MB/s | 약 1분 |
무엇을 건드리기 전에 전체를 tar로 묶어 로컬로 먼저 가져왔다. 이후 디바이스 쪽 캐시를 지우고 다운로드 시점을 관찰해야 하는 일이 여러 번 있었는데, 백업이 없었다면 지울 때마다 되돌릴 수 없었을 것이다.
2. Metadata는 암호화됐지만 binary는 아니다
$ xxd -l 8 global-metadata.dat
00000000: 8f2b 0d1c ... # 예상값 AF 1B B1 FA
magic이 맞지 않는다. 하지만 디스크 상의 UnityFramework는 암호화되어 있지 않다 —— 이 둘은 구분해야 하는데, 처음엔 구분하지 못했다 (부록의 함정 참고).
metadata는 실행 중에는 반드시 풀려 있을 수밖에 없으니, 메모리를 스캔해서 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--")로 구간을 하나씩 스캔했더니 단일 주소에서 히트했다:
magic = AF 1B B1 FA
version = 39
version = 39는 유효한 IL2CPP 버전 번호이므로, 이건 우연히 걸린 게 아니라 복호화된 진짜 본체라는 뜻이다.
3. 버전 위장이 실패하는 방식이 곧 답이다
Il2CppDumper 최신 버전은 31까지만 지원한다. 표준적인 방법은 버전 번호를 바꿔서 속이는 것인데, 포맷 자체는 그대로인 채 번호만 건너뛴 경우가 흔하기 때문이다.
| 위장한 버전 | 결과 |
|---|---|
| 27 | 실패 —— key 충돌 |
| 29 | 실패 —— 동일한 key 충돌 |
| 31 | 실패 —— 동일한 key 충돌 |
세 번 다 같은 지점에서 걸렸다. 단순히 번호만 건너뛴 거라면 서로 다른 예전 버전으로 바꿨을 때 각기 다른 곳에서 깨져야 정상이다. 세 번 다 똑같다는 건 파서가 읽는 구조가 그 지점에서 이미 예상과 어긋났다는 뜻 —— 즉 실제로 갱신된 포맷이라는 것이다.
이 판단이 값진 이유는 한 번에 그 분기 전체를 닫아버렸다는 데 있다. "버전 번호 하나 더 시도해보기"는 매번 30초밖에 안 걸리니, 계속 시도하게 되기 쉽다.
Il2CppInspectorRedux(LukeFZ fork)로 바꿔서, 메서드 이름과 가상 주소를 매핑한 56만 개짜리 완전한 map을 뽑아냈다.
4. 디컴파일 환경: JVM 우회하기
Ghidra는 JVM이 필요한데, 이 기기의 AppleSystemPolicy 커널 모듈이 서명되지 않은 java를 막는다 —— 도구 샌드박스의 제약이 아니라 시스템 자체의 문제라서, 내 터미널에서 직접 실행해도 똑같이 Kill: 9가 뜬다.
시스템과 싸우지 않기로 했다. rz-ghidra는 Ghidra 디컴파일 엔진의 C++ 핵심(SLEIGH + decompiler)만 뽑아내 rizin 플러그인으로 빌드한 것이라, 실행 시 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/함정 1: .dylib만 복사해서는 부족하다. 빌드 결과물인 .sla(컴파일된 sleigh 언어 명세)를 소스 트리에서 .slaspec과 같은 위치에 되돌려놓고 SLEIGHHOME을 설정해야 한다. 에러 메시지에는 데이터 파일이 빠졌다는 언급이 전혀 없다.
함정 2: rizin -q -c "s <addr>; af; pdg"의 af가 어떤 주소에서 그보다 앞서 시작하고 범위가 더 큰 함수 안으로 떨어지면, 기존 경계를 그대로 써서 엉뚱한 함수 내용을 디컴파일해 버린다. 이 때문에 한 번은 어떤 주소를 IL2CPP가 공유하는 제네릭 Dictionary 조회 로직으로 오판해서, 어떤 핵심 값이 계산된 게 아니라 테이블 조회로 나온 거라고 한동안 착각했다.
상태: 완료.
References
(2) 보호 계층 특정: 공식 암호화는 애초에 켜져 있지 않았다
이 게임은 CRIWARE 오디오 미들웨어를 쓰는데, CRIWARE에는 공식 암호화 기능이 있다. method map에도 실제로 있었다:
Vision.Sound.CriWareDecrypter.Initialize(string key, bool enableAtom, bool enableMana)
딱 이거다 싶었다. hook을 걸어 실제 호출 인자를 확인했다:
[+] CriWareDecrypter.Initialize
key = "..." (비어있지 않음)
enableAtom = false
enableMana = false
두 스위치 모두 false다. 공식 암호화는 애초에 켜져 있지 않았다 —— key는 넘어가지만 아무도 쓰지 않는다. 실제로 리소스를 보호하는 건 게임이 자체 구현한 다른 계층(Vision.Octo.ResourceDecrypter)이다.
이걸 알기 전까지 CRIWARE 문서를 몇 시간이나 붙들고 있었다. hook 한 번 걸어 실제 값을 보는 데는 10분도 안 걸렸다.
정정 (분석 당시): 처음엔 겉모습만 보고(bundle에는 오디오 쪽의 평문 prefix가 없고 시작 부분의 엔트로피가 높다는 이유로) 오디오와 bundle이 서로 다른 두 메커니즘이라고 단정하고 크게 돌아갔다(LZ4, AES, GPU hook, Metal capture까지 시도했다). 진짜 돌파구는 기본으로 돌아가 known-plaintext attack을 하는 것이었다 —— 사실 둘은 같은 방식을 공유하고, prefix와 시작 지점만 달랐다. 교훈: 겉으로 다르다고 메커니즘이 다른 건 아니며, "달라 보인다"는 검증을 멈추는 이유가 되기 쉽다.
상태: 완료.
(3) 리소스 카탈로그와 탈옥 없는 오프라인 체인
1. 규모 논증: 왜 수동 hook을 쓰지 않는가
복호화하려면 파일마다 원래 파일명(address)이 필요하다. 파일명은 암호화된 파일 안에 있는 게 아니라 게임의 리소스 카탈로그 안에 있다.
수동적인 방법: 복호화 함수에 hook을 걸어놓고 게임을 플레이하면서 로드되는 걸 그때그때 기록한다. 반드시 성공하고 기술적으로는 리스크가 전혀 없다.
문제는 규모다:
암호화된 파일 총수 1467
수동 hook 커버리지 어디까지 플레이하느냐에 달림
예상 소요 시간 수백 시간
커버리지 보장 없음 (기간 한정 이벤트 리소스는 영영 트리거 안 될 수도 있음)
어떤 길의 비용이 "시간 × 운"이고 커버리지 보장도 없다면, 그건 길이 아니라 미끄럼틀이다.
2. 먼저 측정하고, 그다음 할 가치가 있는지 판단한다
octo/pdb/5/100001/octocacheevai, 4.4MB. 먼저 엔트로피를 계산했다:
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만점 8.0. 진짜 암호화이지 단순 압축이나 직렬화 포맷이 아니라는 게 확인됐다 —— 공을 들일 가치가 있다는 뜻이면서, 동시에 정적 분석으로 풀 가능성은 없다는 뜻이기도 하다.
3. 복호화된 형태를 찾는다
인덱스는 실행 중에 반드시 풀려서 쓰이니, 풀린 형태는 반드시 프로세스 메모리 안에 있다. 암호화를 풀 필요 없이, 풀린 뒤의 모습만 찾으면 된다.
첫 시도는 "제자리 복호화"를 잡는 것이었다: read()에 hook을 걸어 buffer 주소를 기록해두고, 몇 초 지연 후 같은 영역을 다시 읽었다. 완전히 틀렸다 —— native buffer는 함수가 반환된 직후 금세 회수되어 다른 용도로 재사용된다 (부록 참고).
방법을 바꿨다: 60초를 기다려 인덱스가 완전히 로드되게 한 다음, 안에 반드시 나타날 걸 아는 파일명 문자열로 프로세스 메모리 전체를 스캔했다.
히트 블록 16 MB
히트 횟수 3563
그 블록이 바로 풀린 상태의 완전한 리소스 카탈로그였고, protobuf 포맷이었다.
4. 엔트리 구조
1a <len> # 엔트리 (length-delimited submessage)
08 <varint> # 1 id -> 캐시 디렉터리명 = ("A"|"R") + id, 그 다음 hex 인코딩
12 <len> <bytes> # 2 name -> address (원본 파일명)
18 <varint> # 3 size -> 평문 바이트 수
2a 20 <32 bytes> # 5 md5 -> 캐시 파일명
3a <len> <bytes> # 7 objectName -> CDN 오브젝트 키, 6자리 랜덤 문자열
예시 (VisionProject.acf, 전체 길이 0x42 = 66 bytes, 필드별로 더하면 정확히 맞아떨어진다):
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. 검증: "맞는 것 같다"가 아니라 대조
octo 캐시의 디렉터리명은 hex로 인코딩된 ASCII다 —— 413138363439를 풀면 A18649가 된다. 그래서 ("A"|"R") + id를 로컬에 있는 4942개 캐시 파일과 하나씩 대조할 수 있었다:
id 일치 4929
id 불일치 13
catalog에 없음 0
-> 99.74%
불일치한 13건은 전부 메모리 블록 경계에서 잘린 것이었다: address 앞에 *, # 같은 잡음 문자가 섞여 있었다(예: *vo_live_cmn_chr_0). 이건 dump의 경계 문제지 파싱 로직의 문제가 아니라서, "address 첫 글자는 영숫자여야 한다"는 조건으로 걸러내면 된다.
이 단계가 이 절에서 가장 중요하다. 파서가 "파일명처럼 보이는 문자열"을 뱉어낼 때, 그건 그냥 잡음을 읽고 있는 것일 수도 있다. 대조할 수 있는 독립된 출처가 있어야 한다 —— 여기서는 로컬 캐시의 디렉터리명이 그 역할을 했다.
최종적으로 hash와 address를 대응시킨 16823건짜리 완전한 대조표를 얻었다. 배치 커버리지 99.93%, 1595개 파일, 1.5GB.
6. objectName → CDN
앞서 OctoAPI.DecryptAes를 리버싱할 때 CDN 템플릿을 하나 풀어낸 적이 있었다:
https://asset.game-hololive-dreams.com/{o}
그때는 {o}가 뭔지 몰라서 그냥 red herring 취급했다. 엔트리 구조를 다 풀고 나서야 알았다: {o}가 바로 field 7의 objectName이었다.
GET https://asset.game-hololive-dreams.com/UFfHjj
User-Agent: UnityPlayer/6000.3.0b1
-> 1462529 bytes, md5 = 94bb0bfa4f2a82415c87fff62763ef6a
catalog 안의 md5는 암호문 기준으로 계산된 것이라, 다운로드한 뒤 바로 무결성을 검증할 수 있고 먼저 복호화할 필요가 없다.
즉 탈옥이 필요한 용도는 "catalog를 한 번 확보하는 것" 하나로 줄어든다.
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. 필드 순차 파싱: 가정 하나가 94%를 놓쳤다
첫 버전 파서는 objectName(field 7)이 md5(field 5) 바로 뒤에 온다고 가정했다. 결과적으로 mdl_chr 608건 중 단 2건만 잡혔다.
중간에 반복되는 field 6이 끼어 있었다:
2a 20 <md5> 30 ca8402 30 e19402 30 809502 ... 3a 06 "SswxO0" 42 23 <address>
\____ 6 = 종속 에셋 id (repeated varint) ____/ \_ 7 = objectName
3D 모델은 전부 텍스처와 머티리얼에 의존하기 때문에 전부 field 6을 갖고 있었고, 전부 건너뛰어졌던 것이다. 정석대로 필드를 순차적으로 파싱하도록 바꿨다 —— wire type에 따라 tag → 길이 → 값 순으로 필드를 하나씩 진행시키는 방식이다:
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
field == 7쪽 조건에는 "길이가 맞고 전부 출력 가능한 ASCII여야 한다"는 조건을 추가로 걸었다. 메모리 dump의 경계 절단 때문에 마지막 엔트리가 필드 절반만 읽히는 경우가 있었기 때문이다. 수정 후:
수정 전 33799 / 36119건에 objectName 있음
수정 후 36119 / 36119
겸사겸사 종속 목록(field 6)도 같이 풀어냈고, 나중에 "종속까지 한꺼번에 가져오기"를 할 때 편리했다.
protobuf는 필드 순서를 보장하지 않고, repeated 필드는 길이가 임의적이다. "오프셋"으로 필드 위치를 잡는 건 직렬화기의 구현 디테일에 베팅하는 것이고, 그 베팅에서 지면 에러를 내지 않고 그냥 덜 잡아낼 뿐이다 —— 2/608처럼 눈에 띄는 비율이면 몰라도, 33799/36119 같은 건 알아채지 못할 수도 있다.
8. 실측
다운로드 대상 944건 (3D + 부족한 Live2D + mot_define), 1.01 GB
성공 944
건너뜀 0
실패 0
완전한 오프라인 체인: CDN 다운로드 → 검증 → 복호화 → 추출.
상태: 완료.
References
(4) 3D 모델: 스켈레톤 바인딩이 있는 glTF
1. 정점 스트림 레이아웃
stride(s) = Σ dimension × sizeof(format)
offset(0) = 0
offset(s) = align16(offset(s-1) + vertexCount × stride(s-1))
캐릭터 body 실측:
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)
검증 방법: 메시마다 "계산한 총 길이 vs 실제 m_DataSize"를 비교했고, 전부 바이트 단위까지 일치했다.
2. 진짜 버그 두 개
(1) 본 영향 개수는 4로 고정돼 있지 않다. 처음엔 ch12/ch13이 항상 dim4라고 가정하고 그냥 [:, :4]로 잘랐다:
| 메시 | ch12 | ch13 | 실제 |
|---|---|---|---|
Geo_Body_LOD0 |
dim4 | dim4 | 4 본 블렌딩 |
Geo_Eye_LOD0 |
dim2 | dim2 | 2 본 블렌딩 |
Geo_Brow_LOD0 / Geo_Iris_LOD0 |
없음 | dim1 | 강체 단일 본, 가중치 항상 1 |
dim2 메시의 경우 [:, :4]로는 폭 2짜리 배열만 얻으면서 VEC4로 선언해버려 → 가중치 합이 −2.37 ~ 3.02, joint 인덱스가 63471까지 나왔다. 눈썹과 홍채는 "ch12가 없다"는 이유로 아예 바인딩이 없는 것으로 취급되어 통째로 건너뛰어졌다. 수정: 무조건 폭 4로 0을 채워 넣고, ch12가 없으면 강체 바인딩으로 간주해 0번 슬롯 가중치를 1.0으로 설정했다.
(2) 정점 데이터 저장 방식이 두 가지다. 캐릭터 모델은 m_VertexData.m_DataSize에 내장돼 있지만, 씬 오브젝트는 대부분 외부 스트림 파일(.resS)에 저장돼 있고 mesh.m_StreamData가 path/offset/size를 가리킨다. 처리하지 않으면 fbx_mdl_env_*는 통째로 메시 0개로 풀렸다. UnityPy.helpers.ResourceReader.get_resource_data()로 바꿔서 가져왔다.
3. 좌표계 변환
Unity 왼손 좌표계 → glTF 오른손 좌표계, X축을 미러링하는 M = diag(-1,1,1)로 변환한다:
| 대상 | 변환 |
|---|---|
| 위치 / 법선 / 탄젠트 | (x,y,z) → (-x,y,z) |
| 회전 쿼터니언 | (x,y,z,w) → (x,-y,-z,w) |
| 역바인드 행렬 | M' = S·M·S, S = diag(-1,1,1,1) |
| UV | v → 1-v (Unity 원점은 좌하단, glTF는 좌상단) |
| 삼각형 와인딩 | 반전 (행렬식 부호 변화) |
쿼터니언 쪽의 유도: R' = M R M, M은 improper transformation(det = −1)이라서 "축 a를 중심으로 θ만큼 회전"이 "축 (aₓ, -a_y, -a_z)를 중심으로 θ만큼 회전"으로 바뀐다. q = (sin(θ/2)·a, cos(θ/2))에 대입하면 바로 나온다.
이 규칙들은 로그에만 적어두지 않고 구현의 docstring에도 써놨다. 이 파일을 고칠 때마다 매번 다시 확인해야 하는 전제이기 때문이다:
## 座標系轉換
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. 두 번 틀린 추론: 아웃라인 쉘
Geo_Body_LOD0에는 서브메시가 3개 있는데, 면 수가 13932 / 240 / 13932다 —— 0번과 2번이 완전히 동일하다. 인덱스 버퍼 41796 + 720 + 41796 = 84312가 168624 bytes(uint16)를 정확히 채우므로, 이건 파싱 오류가 아니라 실제 데이터다.
첫 번째 추론 (틀림): 이 중복된 서브메시들이 renderer.m_Materials에 대응하는 머티리얼이 없길래, "그러니 Unity가 렌더링하지 않을 것"이라고 판단해서 "머티리얼 없음"을 배제 조건으로 썼다.
진실: 머티리얼은 처음부터 계속 있었다. 다만 의존 bundle 안에 있는 공유 머티리얼이라서, 의존성을 로드하지 않으면 PPtr이 풀리지 않았을 뿐이다. load_bundle_with_deps()를 추가하자 이름이 바로 드러났다:
머티리얼: ['m_eye', 'm_bdy', 'm_bdyco', 'SubMeshOutlineMaterial', 'm_fef']
Geo_Body_LOD0 sub0 면=13932 머티리얼=m_bdy
Geo_Body_LOD0 sub1 면= 240 머티리얼=m_bdyco
Geo_Body_LOD0 sub2 면=13932 머티리얼=SubMeshOutlineMaterial <- 아웃라인 쉘
카툰 렌더링의 inverted-hull 아웃라인 기법이었다: 같은 지오메트리를 법선 방향으로 밀어낸 다음 뒤집어서 뒷면만 그리는 방식.
"이건 X가 없으니 엔진이 안 쓰는 것"이라는 말 —— X가 bundle을 넘나들며 풀어야 하는 것일 때, 이 말의 전제는 그냥 의존성을 로드하지 않았다는 것일 수도 있다.
5. BlendShape → glTF morph target
얼굴 표정은 스켈레톤이 아니라 blendshape로 처리되며, 얼굴 메시에서만 쓰인다:
| 메시 | 채널 수 |
|---|---|
Geo_Eye_LOD0 |
16 |
Geo_Brow_LOD0 |
14 |
Geo_Iris_LOD0 |
2 |
Geo_Body_LOD0/1 |
0 (몸의 변형은 전부 스켈레톤이 담당) |
합계 32 —— 애니메이션 clip 안의 typeID 137 커브 수와 정확히 일치하며, 서로가 서로를 증명해준다.
Unity는 sparse한 방식으로 저장한다: channels[]가 shapes[]의 한 구간을 가리키고, shapes[i]는 다시 vertices[]의 한 구간을 가리키며, 각 정점은 원래 메시의 인덱스를 함께 갖고 있다. glTF의 morph target은 메시와 길이가 같은 dense한 변위 배열이 필요하므로, 하나씩 다시 펼쳐줬다(변위에도 X 미러링을 똑같이 적용해야 한다).
channel.frameCount > 1은 점진적인 형태 변화를 뜻하는데, glTF의 target 하나는 형태 하나만 표현할 수 있어서 마지막 프레임을 취했다. 실측 결과 이 작품에서는 전부 frameCount = 1이었다.
상태: 완료.
(5) 3D 애니메이션: CRC32 역조회와 mot_define
1. 돌파구는 MonoBehaviour였다, AnimationClip이 아니라
AnimationClip의 genericBindings는 해시만 저장한다:
{'path': 1182008026, 'attribute': 1661978518, 'typeID': 137}처음엔 캐릭터 skeleton.json의 본 경로로 CRC32를 계산해 대조했는데, 608개 스켈레톤 × 모든 경로 형태를 다 시도해도 히트 0이었다.
진짜 답은 같은 bundle 안의 MonoBehaviour(VisionActorMotionDefine)에 있었다: 그 안의 baseAnimation.bindings가 각 바인딩을 평문으로 나열하고 있었다:
{"name": "Geo_Eye_LOD0", "path": "Root_Body/Geo_Eye_LOD0",
"type": "UnityEngine.SkinnedMeshRenderer",
"properties": ["blendShape.b_eye.eye_001", "..."]}Animator 루트 노드의 이름은 **Root_Body**였다, bundle 이름이 아니라 —— 이게 안 맞았던 이유였다.
해시 함수는 CRC32(평문)로 확인됐다:
| 문자열 | CRC32 | clip 내 등장 |
|---|---|---|
b_eye.eye_001 |
1661978518 | blendshape 첫 항목 (blendShape. 접두사 없음) |
m_FadeFactor |
682354173 | 6회 = DecalProjector 6개 |
bakeAnimationWeight |
3202011236 | 44회 = swing bone 44개 |
637개 bundle의 bindings를 전부 모아 전역 역조회 테이블을 만들었다(bundle 하나짜리 사전으로는 자기 것 몇 줄밖에 못 푼다). 결과로 520개 경로 / 198개 속성명을 얻었다.
이 절에서 가장 재사용 가능한 교훈은 이거다: hash가 안 맞을 때는 먼저 입력 문자열의 형태를 의심하라, hash 함수부터 의심하지 말라. "혹시 다른 hash 아닐까"에 쓴 시간이 "경로 접두사가 다른 거 아닐까"에 쓴 시간보다 훨씬 많았는데, 답은 후자였다.
상태: 완료.
(6) Live2D: 상위 계층이 아니라 네이티브 계층을 공략한다
1. 막다른 길 세 개
순서대로 시도했고 전부 실패했다: LZ4 압축이라고 추측; Octo.dll/Octo/Loader/OctoAPI.DecryptAes를 찾아봄(오해를 부르는 성공 —— 이게 푸는 건 API 패킷이지 리소스가 아니었다); IL2CPP 계층의 Cubism SDK API에 hook.
세 번째 시도가 실패한 방식이 근본 원인을 알려줬다: Cubism Core는 네이티브 C 라이브러리로, UnityFramework에 직접 정적 링크돼 있고 독립된 .framework가 없다. 그러니 IL2CPP 계층에는 애초에 hook할 만한 게 없었다.
2. 네이티브 export 심볼 공략으로 전환
Module.enumerateExports()로 csm으로 시작하는 네이티브 export를 전부 나열했다 (총 44개):
csmGetVersion
csmGetMocVersion / csmGetLatestMocVersion
csmHasMocConsistency
csmReviveMocInPlace ← 핵심 함수
csmInitializeModelInPlace
csmUpdateModel
csmGetDrawable* 계열
csmReviveMocInPlace(void* address, unsigned int mocSize)는 "이미 복호화되어 파싱 가능한 moc3 데이터가 있는 메모리 주소 + 크기" 두 인자를 받는다. 상위 계층이 C#인지 IL2CPP인지 이해할 필요도 없고, 암호화 알고리즘 자체를 건드릴 필요도 없다 —— 이 네이티브 함수가 호출되는 순간, 그 메모리에 있는 데이터는 100% 올바르고 바로 쓸 수 있는 평문 moc3다.
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));
}
});주소 offset을 하드코딩하는 대신 Module.findExportByName을 썼기 때문에, ASLR과 재시작할 때마다 바뀌는 주소 오프셋에 원천적으로 영향받지 않는다.
3. 일반화된 패턴
이 절의 재사용 가능한 패턴은 따로 적어둘 가치가 있다: "평문을 인자로 받는" 네이티브 함수를 찾아 그 위에서 기다려라. 상위 계층이 암호화, 난독화, 매니지드 런타임을 몇 겹으로 감싸고 있든, 데이터는 결국 엔진이 이해할 수 있는 형태로 엔진에 넘겨져야 한다. 그 인계 지점이야말로 가장 비용이 적게 드는 자리이고, 암호화 방식이 바뀌어도 자연히 영향을 받지 않는다.
.moc3 / .model3 / .physics3를 확보하면 Cubism Editor로 바로 열 수 있고, 머티리얼은 BuildModelData의 정보에 맞춰 이름을 다시 지어야 한다.
상태: 완료 (텍스처 atlas는 별개, 아래 참고).
4. 아직 못 푼 것: 텍스처 atlas
moc3 전용 texture atlas는 아직까지 확보하지 못했다. 순서대로 일곱 가지 경로를 시도했고 전부 실패했다(전부 Frida hook + 플레이어가 직접 트리거하는 방식이었다). 결국 순수한 정적 분석으로 로딩 경로를 추적해서야 진짜 데이터 출처를 찾았다. 중간에 한 번은 set_MainTexture를 올바른 후크 지점으로 착각했다가 디컴파일을 더 진행한 뒤 뒤집혔다.
정정:
set_MainTexture라는 가정은 틀린 것으로 확인됐다. 당시엔 "딱 그거처럼 보였고", hook을 걸었더니 실제로 뭔가 나오기까지 했다 —— 다만 그게 내가 원하던 게 아니었을 뿐이다. 뭔가 hook됐다고 해서 제대로 된 곳을 hook한 건 아니다.
상태: 미완성. 남은 선택지는 Xcode Metal Frame Capture다.
References
- https://github.com/OpenL2D/moc3ingbird (moc3용 ImHex pattern)
(7) 스프링 본 물리 (미완성)
치맛자락, 리본, 머리카락은 애니메이션에 구워져 나오지 않는다 —— 베이킹은 humanoid 본 51개만 커버하는데, 모델에는 실제로 126개가 있어서 나머지 75개(치맛자락 31, 볼 10, 리본 8…)는 게임의 Swing/Quartz가 런타임에 계산한다.
하지만 파라미터는 출시본에 들어있고, 각 모델 bundle의 MonoBehaviour 안에 내장돼 있다:
ActorSwingDynamicBone _sim 본에 부착 시뮬레이션 대상 본
ActorSwingStaticBone 몸통 본에 부착 콜라이더
ActorSwingChain hips에 부착 체인 구조
QuartzDriverSkirtBone _ast 본에 부착 프로시저럴 보조 본
(catalog의 address로는 이것들을 검색할 수 없다. address에 MonoBehaviour 클래스명이 들어있지 않기 때문인데, 처음 Avatar를 찾을 때 저질렀던 것과 같은 실수다.)
오프라인으로 재현한 적분기:
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 중력
이후: 본 길이 하드 제약, 그 다음 부모 본의 회전으로 변환
CalcStiffnessPendulum(dynamicType == 0, 6900/6940개 본을 차지):
delta = rotate(parent.worldRot, boneAxis) 본의 정지 방향
cos = |dot(cur, rest)| / (|cur|·|rest|) 두 위치 벡터 사이의 각도
p = max(0, cos - (1 - range)) / range × pendulum
return delta × (stiffness - p) × 0.01
좌표는 animator root space이지 월드 좌표가 아니라서, 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
현황
검증은 게임의 실제 값과 프레임 단위로 비교하는 방식이다 —— bake_swing_truth.py가 게임에서 같은 clip의 _sim 본 local rotation을 녹화하고, 프레임마다 각도 차이를 계산한다:
각도 = 2 · arccos(|dot(q_sim, q_truth)|)
쿼터니언의 부호는 자세에 영향을 주지 않으므로 절댓값을 취한다.
오차 / 실제 동작 폭 110% (100% = 스프링 본이 전혀 움직이지 않음)
여전히 "아예 안 하는 것"보다 살짝 나쁘다. 증상은 단일 항목으로 수렴한다: 과도하게 흔들리는 정도가 1.50배. 이미 구현했지만 기본값으로는 꺼놓은 네 가지 항목(각각 하위 단계에 아직 damping이 빠져 있어서 불이익을 받는다)이 있다:
--quartz QuartzDriverSkirtBone이 _ast 체인 루트를 구동 142%
--delta-current delta에 정지 방향 대신 현재 방향을 사용 153%
--collision 구 vs 원뿔 캡슐 202%
--vel 힘을 속도 상태에 누적 147%
아직 구현하지 못한 것: 바람(CalcWindPower, 실측 결과 windPower = 0.7로 켜져 있음), 체인 본 스무딩의 4번째 패스, 여러 변형의 _ast 구동.
정정: 처음엔 "바람이 흔들림을 키우고 방향도 안 맞는다"고 판단해 보류했었다 —— 그건 아직 버그 네 개가 남아있는 모델을 기준으로 내린 판단이었으니, 다시 테스트해야 한다. 잘못된 baseline 위에서 한 배제는 배제라고 할 수 없다.
상태: 미완성. 이 프로젝트 전체에서 유일하게 아직 열려 있는 부분이다.
(8) 공개 범위
복호화 알고리즘은 다 풀었고, 완전한 수학적 스펙과 실행 가능한 도구로도 정리해뒀다. 다만 그 부분은 여기에도, 공개 repo에도 넣지 않는다.
이유는 법률이다. 대만 저작권법(著作權法) 제80조의2는 "복제 방지 조치를 우회하는 데 주로 쓰이는 장비, 기기, 부품, 기술 또는 정보"의 제공을 금지한다 —— "정보"라는 단어는 코드만이 아니라, 충분히 명료해서 읽으면 직접 구현할 수 있는 명세 문서까지 포괄한다. 같은 조 제3항에는 "정보 간의 상호운용성을 달성하기 위해 하는 리버스 엔지니어링"이라는 예외가 있지만, 일반적인 해석으로는 리버스 엔지니어링을 하는 것을 포괄하는 것이지, 우회 방법을 공개 배포하는 것을 포괄하지는 않는다. 회색지대이고, 나는 변호사가 아니다.
(그래서 이 글은 이 점에서 sssekai의 노트 아카이브와 다르다 —— 거기서는 완전한 key table과 AES key/iv를 그대로 공개하지만, 여기서는 하지 않는다. 이건 내가 속한 법역에 대해 내린 보수적인 판단일 뿐, 다른 사람의 방식을 평가하는 게 아니다.)
도구는 나눠서 공개했다: 포맷 파싱 쪽 절반(AssetBundle 추출, mesh/스켈레톤, glTF 변환, Live2D와 3D 애니메이션 디코딩)은 상호운용성 작업이므로 https://git.siao.ai/siao/hohohololive에 공개했고, 복호화 쪽 절반은 공개하지 않았다.
이 나누는 방식 자체에 기록해둘 만한 반전이 있다. 원래는 흔히 하는 방식대로 "키만 빼고 알고리즘은 남긴다"로 하려 했는데, 그게 성립하지 않았다 —— 키는 address로부터 유도되고, 그 유도 과정 자체가 곧 키라서, 따로 뗄 수 있는 독립된 비밀이 없었다. 그리고 구현에 있는 유일한 매직 상수는 바이트 하나뿐이고, known-plaintext는 파일 헤더에 그대로 있어서 256가지 경우의 수는 마이크로초 단위로 전수조사할 수 있는 수준이다. 그 상수 하나만 가린다고 해서 작업량이 조금이라도 줄어드는 게 아니라, "가린 것처럼 보이지만 실은 안 가려진" 물건을 만드는 꼴이 될 뿐이다. 그래서 그 계층 전체를 통째로 뺐다.
게임 소재는 전혀 공개하지 않았다. 게임 리소스의 저작권은 퍼블리셔에게 있고, 단 1바이트도 내가 공개한 어떤 곳에도 없다.
(부록) 빠졌던 함정들
작은 파일로 전송 속도를 측정한 것. cipher를 바꾼 뒤 작은 파일 하나로 테스트했는데, 디스크 캐시에 쓰이는 순간 "전송 완료"가 됐다. 어떤 변경 뒤에 개선이 보인다고 해서 그 변경이 원인이라는 뜻은 아니다.
필요 없는 메모리 234MB를 dump한 것. 디스크 상의 UnityFramework는 애초에 암호화돼 있지 않았다. 더 나쁜 건 메모리 버전이 오히려 못 쓴다는 것이었다:
디스크 __TEXT가 빽빽하게 배치, file offset == vaddr - base
메모리 __TEXT가 page 정렬, 두 값의 차이는 각 세그먼트의 정렬 padding
주소가 안 맞으니 파싱 도구가 그대로 망가졌다. 암호화된 건 metadata지 binary가 아닌데, 구분하지 못하고 둘 다에 암호화 대응 수법을 썼다.
native buffer 읽기를 지연시킨 것. native read()의 버퍼는 반환된 직후 금세 회수되어 다른 용도로 재사용되므로, 지연 후 읽으면 전부 무관한 잔여물이다 —— 한 번은 조회 로직, UTF16 문자열, bplist로 오판하기도 했다. 반드시 onLeave 시점에 동기적으로 dump해야 한다:
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)); // 지금 당장, 지연하면 안 됨
}
});fd 재사용이 추적 테이블을 오염시킨 것. open()에 hook을 걸어 관심 있는 fd를 기록해뒀지만 close()에서 지우지 않았다. 시스템이 같은 fd 번호를 다른 파일에 재할당하면, 무관한 내용(UnityFS 헤더, bplist00)을 대상 파일의 내용으로 착각하게 된다:
Interceptor.attach(Module.findExportByName(null, "close"), {
onEnter(args) { tracked.delete(args[0].toInt32()); }
});뒤의 두 가지가 가장 기록할 가치가 있다. "내가 추론을 잘못했다"가 아니라 관측 방법 자체가 가짜 데이터를 만들어내고 있었기 때문이다. 가짜 데이터는 진짜 데이터와 똑같아 보였고, 나는 그걸 위해 그럴싸한 설명을 여러 개나 만들어냈다.
툴체인
| 용도 | 도구 |
|---|---|
| USB 포트 포워딩 | libimobiledevice / iproxy |
| 동적 instrumentation | Frida (Python API, CLI 아님) |
| IL2CPP 파싱 | Il2CppInspectorRedux (LukeFZ fork) |
| 디컴파일 | rizin + rz-ghidra |
| Unity 에셋 | UnityPy (FALLBACK_UNITY_VERSION = "6000.3.0b1") |
- Frida는 Python API를 쓰고 CLI는 쓰지 말 것. CLI는 attach timeout 문제가 있고,
device.spawn() → attach() → resume()이 훨씬 안정적이다. Il2CppInspectorRedux의 CLI는 먹통이 될 수 있다. 내장된 SignalR web 서비스 때문에 프로세스 전체가 멈출 수 있고, 모든 스레드가__psynch_cvwait에서 idle 상태가 된다.sample <pid>로 보면 바쁜 게 아니라 기다리고 있다는 걸 알 수 있다. 더 가벼운 출력 옵션으로 바꾸면 우회할 수 있다.