الهندسة العكسية لـ Hololive Dreams - أرشيف الملاحظات

مقدمة

تاريخ القطع: 26/08/23 المراجعة: 26/08/23

سجلّ للهندسة العكسية لنظام موارد لعبة موبايل مبنية على Unity 6. الوضع الحالي: الأصول (assets)، والحركات (animations)، والوجه، والشعر، والكاميرا، وLive2D، كلها جاهزة للتسليم؛ فيزياء العظام النابضة هي الشيء الوحيد الذي لم يُحل بعد.

الأدوات والسجل الكامل فصلاً بفصل موجودان على https://git.siao.ai/siao/hohohololive (لا يشمل فك التشفير، والسبب موضح في (8)).

مقاطع الشيفرة البرمجية في هذه المقالة مُضمَّنة مباشرة من SiaoHub وقت البناء (build)، وليست ملصقة يدوياً، لذلك لا تنفصل عن المصدر الأصلي أبداً؛ وصفحة الملف على 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.4 غيغابايت. المجلد الفرعي v1/ يحتوي على 178 ملف .awb، و1240 ملف .acb، و176 ملف .usm، وكلها بصيغ صوت/فيديو من CRIWARE.

عبر SSH على شبكة WiFi، استغرق نقل 12 ميغابايت بواسطة scp 11 دقيقة. رد الفعل الأول كان تغيير الـ cipher (المجموعة الافتراضية لا تستفيد من تسريع العتاد)، وبعد التغيير بدا الأمر أسرع فوراً — لكن ذلك كان وهماً ناتجاً عن التخزين المؤقت على القرص محلياً؛ الملفات الكبيرة تكشف الحقيقة.

لكن الأهم أنه حتى لو كان تغيير الـ cipher فعّالاً بالفعل، فلا معنى له: 3.4 غيغابايت عبر WiFi ستبقى في نطاق عشرات الدقائق مهما ضبطت. التحوّل إلى USB:

iproxy 2222:22 &         # SSH
iproxy 27042:27042 &     # Frida
المسار القياس الفعلي الوقت المقدَّر لـ 3.4 غيغابايت
WiFi SSH (cipher الافتراضي) ~18 KB/s حوالي يومين
WiFi SSH (gcm) الملفات الصغيرة تبدو سريعة جداً، الكبيرة لا تزال بطيئة
USB (iproxy) 40–70 MB/s حوالي دقيقة واحدة

سحبتُ الحزمة كاملة بصيغة 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

تعطّل ثلاث مرات في نفس الموضع بالضبط. لو كان الأمر مجرد قفزة في الترقيم، لكان تغيير الإصدار إلى قيم قديمة مختلفة يُفشل التحليل في أماكن مختلفة؛ لكن التطابق التام ثلاث مرات يعني أن البنية التي يقرأها المحلِّل تنحرف عن المتوقَّع عند تلك النقطة بالذات—هذه صيغة تغيّرت فعلياً.

قيمة هذا الاستنتاج أنه يُغلق فرعاً كاملاً من الاحتمالات دفعة واحدة. تجربة رقم إصدار آخر تكلّف ثلاثين ثانية فقط في كل مرة، لذا من السهل جداً الاستمرار في التجربة إلى ما لا نهاية.

التحوّل إلى Il2CppInspectorRedux (نسخة LukeFZ المتفرّعة) أنتج خريطة كاملة تربط 560 ألف اسم دالة بعناوينها الافتراضية.

4. بيئة إعادة التصريف: الالتفاف حول JVM

يحتاج Ghidra إلى JVM، ووحدة النواة AppleSystemPolicy على هذا الجهاز تمنع تشغيل java غير الموقَّع—وهذا ليس قيداً من صندوق عزل الأداة، بل من النظام نفسه؛ حتى التشغيل المباشر من الطرفية الخاصة بي انتهى بنفس النتيجة Kill: 9.

بدل مصارعة النظام، ينتزع rz-ghidra نواة C++ الخاصة بمحرك إعادة التصريف في Ghidra (SLEIGH + decompiler) ويُصرِّفها كإضافة (plugin) لـ 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/

المطب الأول: نسخ ملف .dylib وحده لا يكفي؛ يجب أيضاً وضع ملف .sla الناتج عن البناء (مواصفة لغة sleigh المُصرَّفة) في شجرة الشيفرة المصدرية بجانب .slaspec، وضبط متغيّر SLEIGHHOME. رسالة الخطأ لا تذكر أبداً أن ملف البيانات مفقود.

المطب الثاني: إن وقع أمر af في rizin -q -c "s <addr>; af; pdg" داخل نطاق دالة أخرى أسبق وأكبر عند عنوان معيّن، فسيتبع حدودها الموجودة ويُصرِّف محتوى دالة لا علاقة له بالمطلوب. أخطأتُ بسبب هذا في الحكم على عنوان معيّن باعتباره منطق بحث في Dictionary عام مشترك في IL2CPP، وظننتُ لفترة أن قيمة أساسية جاءت من بحث في جدول وليست محسوبة.

الحالة: مكتمل.

References


(2) تحديد موقع طبقة الحماية: التشفير الرسمي لم يُفعَّل إطلاقاً

تستخدم هذه اللعبة وسيط CRIWARE الصوتي، ولدى CRIWARE تشفير رسمي. وبالفعل توجد في خريطة الدوال:

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 واحد لرؤية القيم الفعلية استغرق أقل من عشر دقائق.

تصحيح (وقت التحليل): في البداية، بمجرد النظر إلى المظهر السطحي (الـ bundle لا يحمل نفس بادئة النص الصريح لمجموعة الصوت، وبدايته عالية الإنتروبيا) استنتجتُ أن الصوت والـ bundle آليتان مختلفتان تماماً، ودرتُ دورة كبيرة (جربتُ LZ4، وAES، وhook على الـ GPU، والتقاط Metal). الاختراق الحقيقي كان العودة إلى الأساسيات وتنفيذ هجوم النص الصريح المعروف (known-plaintext attack)—فالاثنان يشتركان فعلياً في نفس الآلية، والفرق فقط في البادئة ونقطة البداية. الدرس: الاختلاف السطحي لا يعني اختلاف الآلية، و«يبدو مختلفاً» يتحوّل بسهولة إلى عذر للتوقف عن التحقق.

الحالة: مكتمل.


(3) فهرس الموارد وسلسلة العمل بدون jailbreak

1. حجة الحجم: لماذا لم أعتمد على hook سلبي

يتطلّب فك التشفير اسم الملف الأصلي لكل ملف (address). اسم الملف ليس داخل الملف المشفَّر، بل في فهرس موارد اللعبة.

الأسلوب السلبي: عمل hook على دالة فك التشفير، ثم لعب اللعبة، وتسجيل كل ما يُحمَّل. سينجح هذا بالتأكيد، ولا مخاطرة تقنية فيه إطلاقاً.

لكن المشكلة هي الحجم:

إجمالي الملفات المشفَّرة         1467
تغطية الـ hook السلبي           تعتمد على مدى تقدّمي في اللعب
الساعات المقدَّرة                مئات الساعات
ضمان التغطية                    لا يوجد (موارد الفعاليات المحدودة قد لا تُستدعى أبداً)

عندما تكون تكلفة مسار ما هي «الوقت × الحظ» بلا أي ضمان للتغطية، فهو ليس مساراً، بل منحدر.

2. القياس أولاً، ثم الحكم على الجدوى

octo/pdb/5/100001/octocacheevai، بحجم 4.4 ميغابايت. حسبتُ الإنتروبيا أولاً:

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، ما يؤكّد أنه تشفير حقيقي وليس مجرد ضغط أو صيغة تسلسل (serialization)—يستحق الجهد، ويعني أيضاً استحالة التحليل الساكن (static).

3. تحديد الشكل بعد فك التشفير

لا بد أن الفهرس يُفَكّ وقت التشغيل لاستخدامه، لذا فإن شكله بعد الفك موجود حتماً في ذاكرة العملية (process). لا حاجة لفك تشفيره فعلياً، يكفي إيجاد شكله بعد الفك.

في المحاولة الأولى أردتُ التقاط «فك التشفير في مكانه»: عمل hook على read()، وتسجيل عنوان الـ buffer، ثم العودة لقراءة نفس الكتلة بعد تأخير ثوانٍ. كان هذا خاطئاً تماماً—الـ buffer الأصلي (native) يُعاد استخدامه سريعاً بعد عودة الدالة (انظر الملحق).

بدلاً من ذلك: انتظرتُ 60 ثانية حتى يكتمل تحميل الفهرس، ثم مسحتُ ذاكرة العملية بأكملها بحثاً عن سلسلة اسم ملف معروف أنها موجودة بداخله.

حجم الكتلة المطابقة   16 ميغابايت
عدد مرات التطابق      3563

تلك الكتلة هي فهرس الموارد الكامل بعد فك التشفير، بصيغة protobuf.

4. بنية الإدخال

1a <len>              # الإدخال (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 بايت، ومجموع الحقول يتطابق تماماً):

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
غير موجود في الفهرس    0
                     -> 99.74%

كل الحالات الثلاث عشرة غير المتطابقة سببها اقتطاع عند حدود كتلة الذاكرة: بداية address اختلطت بها أحرف تشويش مثل * و# (مثال: *vo_live_cmn_chr_0). هذه مشكلة في حدود الـ dump وليست في منطق التحليل، ويكفي تصفيتها بشرط «أول حرف في address يجب أن يكون حرفاً أو رقماً».

هذه الخطوة هي الأهم في القسم بأكمله. عندما يُخرج محلِّل «سلسلة تبدو كاسم ملف»، فقد يكون ذلك مجرد قراءة تشويش. لا بد من مصدر مستقل للمطابقة معه—وهنا كان اسم مجلد الذاكرة المؤقتة المحلية.

النتيجة النهائية: جدول مطابقة كامل من 16823 إدخالاً يربط الـ hash بـ address، بتغطية دفعة قدرها 99.93%، أي 1595 ملفاً بحجم 1.5 غيغابايت.

6. من objectName إلى CDN

عند إجراء الهندسة العكسية لـ OctoAPI.DecryptAes سابقاً، فككتُ قالب عنوان CDN:

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

في حينها لم أكن أعرف ماهية {o}، واعتبرته مجرد أثر خادع (red herring). بعد فك بنية الإدخال عرفتُ: {o} هو نفسه objectName في field 7.

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

الـ md5 المخزَّن في الفهرس مُحتسَب على النص المشفَّر، لذا يمكن التحقق من سلامة الملف بعد التنزيل مباشرة، دون الحاجة لفك تشفيره أولاً.

وهذا يعني أن الـ jailbreak أصبح مطلوباً لغرض واحد فقط: الحصول على الفهرس (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). والنتيجة: من أصل 608 إدخالاً لـ mdl_chr، لم يُلتقَط سوى 2 فقط.

يفصل بينهما field 6 مكرَّر:

2a 20 <md5>  30 ca8402  30 e19402  30 809502 ...  3a 06 "SswxO0"  42 23 <address>
             \____ 6 = id الأصول المُعتمَد عليها (varint مكرَّر) ____/  \_ 7 = objectName

كل نموذج 3D يعتمد على خرائط الملمس (textures) والمواد (materials)، فكل الإدخالات تحتوي 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) يمكن أن تكون بأي طول. تحديد موقع حقل بالـ«إزاحة» (offset) مقامرة على تفاصيل تنفيذ أداة التسلسل، وعندما تخسر هذه المقامرة لا تحصل على خطأ، بل على التقاط ناقص فقط—ونسبة مثل 2/608 واضحة بما يكفي لتُلاحَظ، لكن 33799/36119 قد لا تُكتشَف أبداً.

8. اختبار عملي

قيد التنزيل   944 إدخالاً (3D + Live2D الناقص + mot_define)، 1.01 غيغابايت
نجح           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))

القياس الفعلي لجسد الشخصية:

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_DataSize الفعلي» لكل شبكة (mesh)، وتطابق الجميع بايتاً ببايت.

2. علّتان (bugs) حقيقيتان

(1) عدد العظام المؤثِّرة ليس ثابتاً عند 4. افترضتُ في البداية أن ch12/ch13 دائماً dim4، واستخدمتُ [:, :4] مباشرة:

الشبكة ch12 ch13 الواقع
Geo_Body_LOD0 dim4 dim4 مزج 4 عظام
Geo_Eye_LOD0 dim2 dim2 مزج عظمتين
Geo_Brow_LOD0 / Geo_Iris_LOD0 لا يوجد dim1 ربط جامد بعظمة واحدة، الوزن دائماً 1

بالنسبة للشبكات ذات dim2، فإن [:, :4] يحصل على مصفوفة عرضها 2 فقط لكنه يُعلَن كـ VEC4 ← فيصبح مجموع الأوزان بين −2.37 و3.02، ومؤشر joint يصل إلى 63471. أما الحاجبان والقزحية فقد جرى تخطّيهما بالكامل باعتبارهما «بلا ربط» بسبب غياب ch12. الإصلاح: إكمال العرض إلى 4 بالأصفار دائماً؛ وعند غياب ch12، تُعامَل كربط جامد بوضع وزن 1.0 في الخانة 0.

(2) بيانات الرؤوس تُخزَّن بطريقتين. نماذج الشخصيات مُضمَّنة داخل m_VertexData.m_DataSize، لكن مُعظم أجزاء المشاهد (scene parts) تُخزَّن في ملف تدفّق خارجي (.resS)، ويشير mesh.m_StreamData إلى الـ path/offset/size. دون معالجة ذلك، خرجت دفعة fbx_mdl_env_* بأكملها بصفر شبكات. الحل: استرجاعها عبر UnityPy.helpers.ResourceReader.get_resource_data().

3. تحويل نظام الإحداثيات

من نظام Unity اليساري إلى نظام glTF اليميني، بعكس محور X: M = diag(-1,1,1):

العنصر التحويل
الموضع / الاتجاه العمودي (normal) / المماس (tangent) (x,y,z) → (-x,y,z)
رباعي الدوران (quaternion) (x,y,z,w) → (x,-y,-z,w)
مصفوفة الربط العكسية (inverse bind matrix) M' = S·M·S، S = diag(-1,1,1,1)
UV v → 1-v (نقطة الأصل في Unity أسفل اليسار، وفي glTF أعلى اليسار)
اتجاه لفّ المثلث (winding) معكوس (تغيّر إشارة المحدِّد determinant)

اشتقاق سطر رباعي الدوران: R' = M R M، حيث M تحويل غير سوي (improper) بـ 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 متطابقتان تماماً. مخزن الفهارس (index buffer) بحجم 41796 + 720 + 41796 = 84312 يملأ بالضبط 168624 بايت (uint16)، لذا هذه ليست خطأ في التحليل، بل بيانات حقيقية.

الاستنتاج الأول (الخاطئ): هذه الشبكات الفرعية المكرَّرة لا تملك مادة (material) مقابلة في renderer.m_Materials، فحكمتُ بأن «Unity لا يرسمها لهذا السبب»، واستخدمتُ «انعدام المادة» كشرط استبعاد.

الحقيقة: المادة كانت موجودة طوال الوقت، لكنها مادة مشتركة موضوعة في bundle تابع (dependency)، ولا يمكن حلّ 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 للحدود الخارجية في التظليل الكرتوني (toon shading): نفس الهندسة (geometry)، لكن بعد دفع الاتجاهات العمودية (normals) للخارج وقلب الوجوه، لا تُرسَم سوى الأوجه الخلفية.

«هذا الشيء لا يملك X لذا المحرّك لا يستخدمه»—عندما يكون X شيئاً يُحلّ عبر bundles متعددة، فإن مقدّمة هذه الجملة قد تكون ببساطة أنك لم تُحمِّل الاعتماديات.

5. من BlendShape إلى glTF morph target

تعابير الوجه لا تعتمد على الهيكل العظمي، بل على blendshape، وفقط على شبكات الوجه:

الشبكة عدد القنوات
Geo_Eye_LOD0 16
Geo_Brow_LOD0 14
Geo_Iris_LOD0 2
Geo_Body_LOD0/1 0 (تشوّه الجسد بالكامل يعتمد على الهيكل العظمي)

المجموع 32—مطابق تماماً لعدد المنحنيات ذات typeID 137 داخل clip الحركة، ما يؤكّد كلٌّ منهما الآخر.

يُخزِّن Unity البيانات بشكل متناثر (sparse): channels[] يشير إلى جزء من shapes[]، وshapes[i] بدوره يشير إلى جزء من vertices[]، وكل رأس (vertex) يحمل مؤشر الشبكة الأصلية معه. يحتاج morph target في glTF مصفوفة إزاحات كثيفة (dense) بنفس طول الشبكة، لذا جرى فكّها رأساً برأس رجوعاً (والإزاحات أيضاً يجب أن تخضع لعكس محور X).

channel.frameCount > 1 يعني تشوّهاً تدريجياً، لكن target الواحد في glTF لا يمكنه تمثيل سوى شكل واحد، فأخذتُ الإطار الأخير. القياس الفعلي أظهر أن كل هذه اللعبة لديها frameCount = 1.

الحالة: مكتمل.


(5) حركات 3D: البحث العكسي عبر CRC32 و mot_define

1. نقطة الاختراق: MonoBehaviour لا AnimationClip

يخزِّن genericBindings في AnimationClip قيم hash فقط:

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

في البداية أخذتُ مسارات العظام من skeleton.json الخاص بالشخصية وحسبتُ CRC32 للمقارنة—608 هيكلاً عظمياً × كل أشكال المسارات الممكنة، 0 تطابق.

الإجابة الحقيقية كانت في MonoBehaviour (باسم VisionActorMotionDefine) الموجود في نفس الـ bundle: يسرد 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 مرات = 6 عناصر DecalProjector
bakeAnimationWeight 3202011236 44 مرة = 44 عظمة نابضة (swing bone)

جمعتُ كل روابط bindings من 637 bundle في جدول بحث عكسي عام (global) (قاموس bundle واحد يكفي فقط لحل رموزه الخاصة)، فحصلتُ على 520 مساراً / 198 اسم خاصية.

هذه أكثر نقطة قابلة لإعادة الاستخدام في هذا القسم: عندما لا يتطابق الـ hash، شكِّك أولاً في شكل سلسلة الإدخال، لا في دالة الـ hash نفسها. الوقت الذي أنفقتُه في «هل قد تكون دالة hash مختلفة فعلاً؟» كان أكبر بكثير من الوقت الذي أنفقتُه في «هل بادئة المسار مختلفة؟»، والإجابة كانت الثانية.

الحالة: مكتمل.


(6) Live2D: مهاجمة الطبقة الأصلية، لا الطبقة العليا

1. ثلاثة مسارات مسدودة

جرَّبتُها بالترتيب، وفشلت جميعها: تخمين أنه ضغط LZ4؛ البحث عن Octo.dll/Octo/Loader/OctoAPI.DecryptAes (نجاح مضلِّل—فهو يفكّ حزم API لا الموارد)؛ عمل hook على واجهة Cubism SDK API في طبقة IL2CPP.

طريقة فشل المسار الثالث أشارت إلى السبب الجذري: Cubism Core مكتبة C أصلية (native)، مربوطة استاتيكياً مباشرة داخل UnityFramework، بلا .framework مستقل، لذا لا يوجد في طبقة IL2CPP أصلاً ما يمكن عمل hook عليه.

2. التحوّل إلى الرموز الأصلية المُصدَّرة

أظهر Module.enumerateExports() كل الرموز الأصلية المُصدَّرة التي تبدأ بـ csm (44 رمزاً بالمجموع):

csmGetVersion
csmGetMocVersion / csmGetLatestMocVersion
csmHasMocConsistency
csmReviveMocInPlace   ← الدالة المفتاحية
csmInitializeModelInPlace
csmUpdateModel
سلسلة csmGetDrawable*

تأخذ csmReviveMocInPlace(void* address, unsigned int mocSize) معاملين: عنوان الذاكرة الذي تقع فيه بيانات moc3 مفكوكة التشفير وقابلة للتحليل، وحجمها. لا حاجة لفهم ما إذا كانت الطبقة العليا C# أو IL2CPP، ولا حتى لمس خوارزمية التشفير نفسها—بمجرد استدعاء هذه الدالة الأصلية، تكون البيانات في الذاكرة في تلك اللحظة نصاً صريحاً لـ moc3 صحيحاً 100% وقابلاً للاستخدام.

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));
  }
});

استخدام Module.findExportByName بدل ترميز إزاحة (offset) العنوان يدوياً يجعل الأسلوب محصَّناً بطبيعته ضد ASLR وانزياح العناوين بعد إعادة التشغيل.

3. الشكل العام

الشكل القابل لإعادة الاستخدام في هذا القسم يستحق أن يُكتب على حدة: جِد تلك الدالة الأصلية التي «تأخذ النص الصريح كمعامل»، وانتظر عندها. بغض النظر عن عدد طبقات التشفير أو التشويش أو بيئة التشغيل المُدارة (managed runtime) التي تُغلِّف الطبقة العليا، لا بد أن تُسلَّم البيانات في النهاية إلى المحرّك بصيغة يفهمها. تلك نقطة التسليم هي الموضع الأقل تكلفة، وهي بطبيعتها لا تتغيّر مع تحديثات مخطط التشفير.

بعد الحصول على .moc3 / .model3 / .physics3 يمكن فتحها مباشرة في Cubism Editor، مع إعادة تسمية المواد وفق معلومات BuildModelData.

الحالة: مكتمل (أطلس الملمس (texture atlas) منفصل، انظر أدناه).

4. غير المكتمل: أطلس الملمس

لم أحصل حتى الآن على أطلس الملمس الخاص بـ moc3. جرَّبتُ سبعة مسارات بالترتيب وفشلت جميعها (كلها اعتمدت على hook عبر Frida + تشغيل من قِبَل اللاعب)، وأخيراً بالتحوّل إلى تحديد ساكن بحت لمسار التحميل وجدتُ مصدر البيانات الحقيقي. في منتصف الطريق اعتبرتُ set_MainTexture نقطة الربط الصحيحة لفترة، ثم نقضتُ هذا الافتراض بعد مزيد من إعادة التصريف.

تصحيح: ثبت خطأ افتراض set_MainTexture. في حينها «بدا هذا هو المطلوب بالضبط»، وعند عمل hook عليه ظهر فعلاً شيء ما—لكنه لم يكن ما أبحث عنه. الحصول على شيء من الـ hook لا يعني أن الـ hook في المكان الصحيح.

الحالة: غير مكتمل. الخيار المتبقّي هو Xcode Metal Frame Capture.

References


(7) فيزياء العظام النابضة (spring bones) (غير مكتمل)

تنانير الفساتين والأشرطة والشعر لا تأتي مع الحركة (animation) المُخبَّزة—فالخَبْز (baking) يغطّي 51 عظمة humanoid فقط، بينما يملك النموذج فعلياً 126 عظمة؛ العظام الـ75 الناقصة (31 للتنورة، 10 للخدّين، 8 للأشرطة...) تُحسَب وقت التشغيل بواسطة نظام Swing/Quartz الخاص باللعبة.

لكن المعاملات (parameters) موجودة في الإصدار، مُضمَّنة داخل MonoBehaviour في كل bundle نموذج:

ActorSwingDynamicBone   مُثبَّتة على عظام _sim     العظام المُحاكاة
ActorSwingStaticBone    مُثبَّتة على عظام الجسد     أجسام التصادم (colliders)
ActorSwingChain         مُثبَّتة على hips           بنية السلسلة
QuartzDriverSkirtBone   مُثبَّتة على عظام _ast       عظام مساعدة إجرائية (procedural)

(لا يمكن البحث عن هذه في address الخاص بالفهرس، لأن address لا يتضمّن اسم صنف MonoBehaviour—نفس الخطأ الذي وقعتُ فيه عند البحث عن Avatar في البداية.)

المُكامِل (integrator) لإعادة الإنتاج دون اتصال:

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 (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 دوران _sim bones المحلي (local rotation) لنفس الـ clip من اللعبة، ويحسب زاوية الفرق إطاراً بإطار:

الزاوية = 2 · arccos(|dot(q_sim, q_truth)|)

إشارة رباعي الدوران (موجب/سالب) لا تؤثر على الوضعية (pose)، لذا أُخذت القيمة المطلقة.

الخطأ / سعة الحركة الحقيقية   110%      (100% = العظام النابضة لا تتحرك إطلاقاً)

لا يزال أسوأ قليلاً من «عدم فعل أي شيء إطلاقاً». تتقارب الأعراض في بندٍ واحد: الإفراط في الأرجحة (overswing) بمقدار 1.50 ضعف. أربعة بنود منفَّذة لكنها معطَّلة افتراضياً (كل منها يُعاقَب لأن مراحل لاحقة لا تزال تفتقر إلى التخميد damping):

--quartz          QuartzDriverSkirtBone يقود جذر سلسلة _ast   142%
--delta-current   delta يستخدم الاتجاه الحالي لا اتجاه السكون  153%
--collision       كرة مقابل كبسولة مخروطية                     202%
--vel             القوة تتراكم في حالة السرعة                  147%

لم يُنفَّذ بعد: الرياح (CalcWindPower، القياس الفعلي يُظهر أن windPower = 0.7 مُفعَّلة)، والتمريرة الرابعة لتنعيم سلسلة العظام، وقيادة _ast متعددة المتغيّرات.

تصحيح: أجَّلتُ الرياح في البداية بحكم أن «الرياح تزيد الأرجحة والاتجاه خاطئ»—لكن ذلك الحكم صدر على نموذج لا يزال يحمل أربع علل، ويستحق إعادة الاختبار. الاستبعاد المبني على خط أساس خاطئ ليس استبعاداً حقيقياً.

الحالة: غير مكتمل. هذا هو الشيء الوحيد المتبقّي مفتوحاً في المشروع بأكمله.


(8) نطاق النشر العلني

خوارزمية فك التشفير جرى حلّها بالكامل، وكُتبت كمواصفة رياضية كاملة وأداة قابلة للتنفيذ. ذلك الجزء غير موجود هنا، ولا في الـ repo العلني.

السبب قانوني. المادة 80-2 من قانون حقوق النشر التايواني تحظر تقديم «الأجهزة أو المعدات أو القطع أو التقنية أو المعلومات المُستخدَمة أساساً للتحايل على تدابير الحماية من النسخ»—وكلمة «المعلومات» لا تشمل الشيفرة البرمجية فقط، بل تشمل أيضاً وثيقة مواصفات مكتوبة بوضوح كافٍ بحيث يستطيع أي شخص تنفيذها بمجرد قراءتها. تتضمّن الفقرة الثالثة من نفس المادة استثناءً لـ«الهندسة العكسية الهادفة لتحقيق قابلية التشغيل البيني (interoperability) بين المعلومات»، لكن الفهم السائد هو أن هذا الاستثناء يغطّي إجراء الهندسة العكسية، لا نشر أساليب التحايل علناً. هذه منطقة رمادية، ولستُ محامياً.

(لذلك تختلف هذه المقالة عن أرشيف ملاحظات sssekai في هذه النقطة—فذاك ينشر جدول المفاتيح الكامل ومفتاح/iv الخاص بـ AES، بينما هذه المقالة لا تفعل. هذا حكم متحفّظ مني بشأن الولاية القضائية التي أنتمي إليها، وليس تقييماً لأسلوب الآخرين.)

نشرتُ الأداة مقسومةً إلى جزأين: النصف الخاص بتحليل الصيغ (استخراج AssetBundle، وmesh/الهيكل العظمي، وتحويل glTF، وفك ترميز Live2D وحركات 3D) هو عمل قابلية تشغيل بيني، ومنشور علناً على https://git.siao.ai/siao/hohohololive؛ أما النصف الخاص بفك التشفير فلا.

طريقة التقسيم نفسها فيها منعطف يستحق التسجيل. كانت نيتي الأصلية اتّباع الأسلوب الشائع «إزالة المفتاح، وترك الخوارزمية»، لكن هذا لم يصمد—فالمفتاح مُشتقّ من address، وإجراء الاشتقاق نفسه هو المفتاح، فلا يوجد سرّ مستقل يمكن إزالته. والثابت السحري (magic constant) الوحيد في التنفيذ هو بايت واحد فقط، والنص الصريح المعروف مكتوب في رأس الملف نفسه، و256 احتمالاً هو استنفاد شامل (brute force) بمقياس الميكروثانية. حجب ذلك الثابت وحده لا يقلّل الجهد المطلوب بشيء، لكنه سيُنتج شيئاً «يبدو محجوباً لكنه ليس كذلك فعلياً». لذلك أزلتُ الطبقة بأكملها معاً.

المواد (assets) لم تُنشَر إطلاقاً؛ حقوق نشر موارد اللعبة مملوكة للناشر، ولا يوجد ولا بايت واحد منها في أي مكان نشرتُه.


ملحق: العقبات التي واجهتها

اختبار سرعة النقل بملف صغير. بعد تغيير الـ cipher، اختبرتُ ملفاً صغيراً، و«انتهى النقل» بمجرد كتابته في الذاكرة المؤقتة على القرص. رؤية تحسّن بعد تعديل ما لا يعني أن ذلك التعديل هو السبب.

عمل dump لـ 234 ميغابايت من الذاكرة لم تكن ضرورية. UnityFramework على القرص لم يكن مشفَّراً أصلاً. والأسوأ أن النسخة من الذاكرة كانت غير صالحة للاستخدام:

القرص     __TEXT مُرصوصة بإحكام، file offset == vaddr - base
الذاكرة   __TEXT محاذاة بالصفحة (page)، والفرق بينهما padding محاذاة كل قسم

العناوين لا تتطابق، وأداة التحليل تعطَّلت مباشرة. المشفَّر هو الـ metadata لا الملف الثنائي، ولأنني لم أميّز بينهما، طبَّقتُ أسلوب التعامل مع التشفير على كليهما.

تأخير قراءة الـ buffer الأصلي (native). يُعاد استخدام buffer دالة read() الأصلية سريعاً بعد عودتها، فما يُقرأ بعد تأخير هو مجرد بقايا غير ذات صلة—وقد أخطأتُ لفترة في تفسيرها كمنطق بحث في جدول، أو سلاسل UTF16، أو bplist. يجب عمل dump متزامن في لحظة 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));  // في اللحظة نفسها، لا يجوز التأخير
  }
});

إعادة استخدام fd يلوِّث جدول التتبّع. عملتُ hook على open() لتسجيل الـ fd المهمة، لكن دون تنظيفها في close(). عندما يُعيد النظام تخصيص نفس رقم fd لملف آخر، يُعامَل محتوى غير ذي صلة (رأس ملف UnityFS، أو bplist00) كأنه محتوى الملف المستهدَف:

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

آخر بندين هما الأجدر بالتسجيل، لأنهما ليسا «استنتاجاً خاطئاً مني»، بل طريقة الرصد نفسها كانت تُنتج بيانات مزيَّفة. البيانات المزيَّفة بدت مطابقة تماماً للبيانات الحقيقية، وقد بنيتُ لها عدة تفسيرات.


الأدوات

الاستخدام الأداة
تحويل منفذ USB libimobiledevice / iproxy
الأدوات الديناميكية (dynamic instrumentation) Frida (واجهة برمجة Python، لا CLI)
تحليل IL2CPP Il2CppInspectorRedux (نسخة LukeFZ)
إعادة التصريف (decompile) rizin + rz-ghidra
أصول Unity UnityPy (FALLBACK_UNITY_VERSION = "6000.3.0b1")
  • استخدم واجهة Frida عبر Python، لا الـ CLI. الـ CLI لديه مشكلة انتهاء مهلة الـ attach، بينما device.spawn() → attach() → resume() أكثر استقراراً بكثير.
  • الـ CLI الخاص بـ Il2CppInspectorRedux قد يتجمّد ظاهرياً. خدمة الويب المدمجة SignalR قد تُعلِّق العملية (process) بأكملها، وتبقى كل الخيوط (threads) خاملة عند __psynch_cvwait. استخدام sample <pid> يكشف أنها ليست مشغولة، بل في حالة انتظار. التحوّل إلى خيارات إخراج أخف يتجاوز هذه المشكلة.

References