Preface

Cutoff: 24/01/05 Revision: 24/09/26

其实分析工作主要是在此后才真正起步…不过实在找不到时间写帖子orz

对最新知识有兴趣或也想参与研究的话,还请参阅以下Repo及其Wiki:

Project SEKAI 逆向(1): 文件解密及API初步静态分析

  • 分析variant:ColorfulStage 2.4.1 (Google Play 美服)

1. 解密 metadata

  • 利用 Il2CppDumper 对apk中提取出来的global-metadata.datail2cpp.so直接分析,可见至少metadata有混淆

image-20231228181726715

  • IDA静态分析libunity.so, 定位到export的il2cpp_init;没有发现有关混淆的处理

image-20231228182008675

  • 考虑直接分析il2cpp.so,定位到global-metadata.dat有关流程

image-20231228182229242

从这里的xref可以很轻松的摸到Il2Cpp的metadata加载流程

(注:部分变量已更名)

_BYTE *__fastcall MetadataLoader::LoadMetadataFile(char *a1)
{
  unsigned __int64 v2; // x8
  char *v3; // x9
  __int64 v4; // x0
  _BYTE *v5; // x8
  unsigned __int64 v6; // x9
  const char *v7; // x0
  int v8; // w21
  int v9; // w20
  _BYTE *mapped_metadata; // x19
  __int64 v11; // x8
  __int64 v13; // [xsp+0h] [xbp-E0h] BYREF
  unsigned __int64 v14; // [xsp+8h] [xbp-D8h]
  char *v15; // [xsp+10h] [xbp-D0h]
  size_t len[2]; // [xsp+30h] [xbp-B0h]
  __int64 v17[2]; // [xsp+80h] [xbp-60h] BYREF
  char *v18; // [xsp+90h] [xbp-50h]
  char *v19; // [xsp+98h] [xbp-48h] BYREF
  __int64 v20; // [xsp+A0h] [xbp-40h]
  unsigned __int8 v21; // [xsp+A8h] [xbp-38h]
  _BYTE v22[15]; // [xsp+A9h] [xbp-37h] BYREF
  _BYTE *v23; // [xsp+B8h] [xbp-28h]

  sub_17A953C();
  v19 = "Metadata";
  v20 = 8LL;
  v2 = (unsigned __int64)(unsigned __int8)v13 >> 1;
  if ( (v13 & 1) != 0 )
    v3 = v15;
  else
    v3 = (char *)&v13 + 1;
  if ( (v13 & 1) != 0 )
    v2 = v14;
  v17[0] = (__int64)v3;
  v17[1] = v2;
  sub_173B820(v17, &v19);
  if ( (v13 & 1) != 0 )
    operator delete(v15);
  v4 = strlen(a1);
  if ( (v21 & 1) != 0 )
    v5 = v23;
  else
    v5 = v22;
  if ( (v21 & 1) != 0 )
    v6 = *(_QWORD *)&v22[7];
  else
    v6 = (unsigned __int64)v21 >> 1;
  v19 = a1;
  v20 = v4;
  v13 = (__int64)v5;
  v14 = v6;
  sub_173B820(&v13, &v19);
  if ( (v17[0] & 1) != 0 )
    v7 = v18;
  else
    v7 = (char *)v17 + 1;
  v8 = open(v7, 0);
  if ( v8 == -1 )
    goto LABEL_25;
  if ( fstat(v8, (struct stat *)&v13) == -1 )
  {
    close(v8);
    goto LABEL_25;
  }
  v9 = len[0];
  mapped_metadata = mmap(0LL, len[0], 3, 2, v8, 0LL);
  close(v8);
  if ( mapped_metadata == (_BYTE *)-1LL )
  {
LABEL_25:
    mapped_metadata = 0LL;
    goto UNENCRYPTED;
  }
  if ( v9 >= 1 )
  {
    v11 = 0LL;
    do
    {
      mapped_metadata[v11] ^= METADATA_KEY[v11 & 0x7F];
      ++v11;
    }
    while ( v9 != (_DWORD)v11 );
  }
UNENCRYPTED:
  if ( (v17[0] & 1) != 0 )
    operator delete(v18);
  if ( (v21 & 1) != 0 )
    operator delete(v23);
  return mapped_metadata;
}

对比 Unity 的默认实现 (https://github.com/mos9527/il2cpp-27/blob/main/libil2cpp/vm/MetadataLoader.cpp):

void* MetadataLoader::LoadMetadataFile(const char* fileName)
{
    std::string resourcesDirectory = utils::PathUtils::Combine(utils::Runtime::GetDataDir(), utils::StringView<char>("Metadata"));

    std::string resourceFilePath = utils::PathUtils::Combine(resourcesDirectory, utils::StringView<char>(fileName, strlen(fileName)));

    int error = 0;
    FileHandle* handle = File::Open(resourceFilePath, kFileModeOpen, kFileAccessRead, kFileShareRead, kFileOptionsNone, &error);
    if (error != 0)
        return NULL;

    void* fileBuffer = utils::MemoryMappedFile::Map(handle);

    File::Close(handle, &error);
    if (error != 0)
    {
        utils::MemoryMappedFile::Unmap(fileBuffer);
        fileBuffer = NULL;
        return NULL;
    }

    return fileBuffer;
}

可见该伪代码块涉及到混淆流程:

  if ( v9 >= 1 )
  {
    v11 = 0LL;
    do
    {
      mapped_metadata[v11] ^= METADATA_KEY[v11 & 0x7F];
      ++v11;
    }
    while ( v9 != (_DWORD)v11 );
  }

最后,解密脚本如下:

key = bytearray([
  0xC3, 0x2B, 0x75, 0xB9, 0xAF, 0x84, 0x3C, 0x1F, 0x2E, 0xFB, 
  0xBF, 0x6C, 0x63, 0x19, 0x70, 0xE4, 0xF0, 0x92, 0xA3, 0x3E, 
  0xD1, 0x5C, 0x30, 0x0A, 0xCB, 0x9B, 0x04, 0xF8, 0x16, 0xC7, 
  0x91, 0x4A, 0x8D, 0xAE, 0xFA, 0xBA, 0x7E, 0x71, 0x65, 0x53, 
  0xAF, 0x98, 0x2E, 0xC2, 0xC0, 0xC6, 0xA3, 0x81, 0x74, 0xD4, 
  0xA3, 0x2C, 0x3F, 0xC2, 0x97, 0x66, 0xFB, 0x6B, 0xEE, 0x14, 
  0x80, 0x43, 0x09, 0x67, 0x69, 0x75, 0xDE, 0xB4, 0x1F, 0xB5, 
  0x65, 0x7E, 0x2D, 0x50, 0x8E, 0x38, 0x2E, 0x6D, 0x4A, 0x05, 
  0xF7, 0x82, 0x84, 0x41, 0x23, 0x64, 0x0A, 0xCB, 0x16, 0x93, 
  0xBE, 0x13, 0x83, 0x50, 0xD2, 0x6C, 0x8F, 0xC7, 0x58, 0x4A, 
  0xE7, 0xEE, 0x62, 0xBE, 0x6F, 0x25, 0xFE, 0xEF, 0x33, 0x5E, 
  0x38, 0x8D, 0x21, 0xE8, 0x1C, 0xFE, 0xBE, 0xC7, 0x43, 0x05, 
  0x6A, 0x13, 0x9D, 0x8B, 0xF6, 0x52, 0xFA, 0xDC
])
with open('global-metadata.dat','rb') as E:
    with open('global-metadata-decrypt.dat','wb') as D:
        data = bytearray(E.read())
        for i in range(0, len(data)):
            data[i] ^= key[i & 0x7f]
        D.write(data)

处理后再次Dump, metadata已经能够顺利加载

image-20231228183207342

**注:**由 ERROR: This file may be protected. ,后续还将对il2cpp.so继续处理;请看下文

Il2CppDumper 检查流程

  private bool CheckProtection()
        {
            try
            {
                //.init_proc
                if (dynamicSection.Any(x => x.d_tag == DT_INIT))
                {
                    Console.WriteLine("WARNING: find .init_proc");
                    return true;
                }
                //JNI_OnLoad
                ulong dynstrOffset = MapVATR(dynamicSection.First(x => x.d_tag == DT_STRTAB).d_un);
                foreach (var symbol in symbolTable)
                {
                    var name = ReadStringToNull(dynstrOffset + symbol.st_name);
                    switch (name)
                    {
                        case "JNI_OnLoad":
                            Console.WriteLine("WARNING: find JNI_OnLoad");
                            return true;
                    }
                }
                if (sectionTable != null && sectionTable.Any(x => x.sh_type == SHT_LOUSER))
                {
                    Console.WriteLine("WARNING: find SHT_LOUSER section");
                    return true;
                }
            }
            catch
            {
                // ignored
            }
            return false;
        }

2.提取 libil2cpp.so

准备

手上没有Root的安卓实体机,这里就用WSA了

https://github.com/MustardChef/WSABuilds很方便地提供了集成Magisk的方案

  • Dump 运行时 libil2cpp.so

    image-20231228201100934

    从 /proc/[pid]/maps 里读到基址后可借助 GameGuardian 提取

image-20231228201144812

image-20231228201739702

  • 重复之前步骤

    image-20231228201403332

可以发现这次没有ERROR: This file may be protected.

image-20231229112048891

3. Dump的部分发现

这里的混淆器是BeeByte;虽然只是更名混淆,但要拿这些符号分析业务逻辑的话还是很头疼

image-20231229105840353

  • 貌似Class部分名称仍然完好

image-20231229110029895

4. API 解密,first look

抓包工具:Reqable,

CA验证可以通过https://github.com/NVISOsecurity/MagiskTrustUserCerts轻松绕过,这里不多说了

image-20231229113505997

  • 找到疑似解密业务的逻辑如下

image-20231229140355912

image-20231229140222478

image-20231229140251468

Bingo!

想在下一次用动态调试 (i.e. Frida) 拿Key/IV,还请见后文

SEE YOU SPACE COWBOY…

References

https://katyscode.wordpress.com/2021/02/23/il2cpp-finding-obfuscated-global-metadata/

Project SEKAI 逆向(2): Frida动态调试及取API AES KEY/IV

  • 分析variant:世界計劃 2.6.1 (Google Play 台服)

0. 版本更新

毕竟美服的数据库滞后好几个版本号…

换到台服之后版本号(2.4.1->2.6.1)有变化,同时上一篇所说的metadata加密手段也换了

  • 同样,拉取一份修复过的dump分析,可见:

image-20231230182936676

  • metadata加载部分不再进行全局加解密,观察二进制也可以发现:

image-20231230183059925

2.4.0解密后metadata

image-20231230183133793

2.6.1源metadata多出来8字节?

image-20231230183234026

image-20231230183305449

  • 加载部分出入不大,尝试删掉8字节直接分析

image-20231230183414173

  • 卡在读string这一块;到Il2CppGlobalMetadataHeader.stringOffset检查一下

image-20231230183456731

  • 果然有混淆;这部分应该是明文,而unity的例子中开始应该是mscrolib

image-20231230183601458

  • 而so中il2cpp对metadata的使用却没加多余步骤;推测这部分在加载时进行了原地解密

1. metadata 动态提取

  • 和上一篇记录的il2cpp.so提取步骤如出一辙,这里不再多说

image-20231230183727385

  • 比对apk的和dump出来的二进制:

image-20231230184527296

果然string部分明朗了;继续dump

image-20231230183345026

  • 成功!带信息进ida;而非常戏剧化的事情是:

image-20231230185256794

image-20231230185620868

混淆没了。

果然是非常pro-customer的版本更新啊(确信)


2. frida-gadget 注入

WSA上注入各种碰壁,不过实体机上即使无Root也可以通过frida-gadget修改apk注入

之前用过改dex的方式尝试过,并不成功;挑一个运行时会加载的.so下手:

注: 后面目标lib换成了libFastAES.so,截图尚未更新

image-20231229192615725

image-20231229185311898

拿apktool打包后签名即可安装到真机

image-20231229192636829

注: 我的frida跑在WSL上,拿 Windows 机器的adb做后端在 Win/Linux 机器配置分别如下

adb.exe -a -P 5555 nodaemon server
export ADB_SERVER_SOCKET=tcp:192.168.0.2:5555

image-20231230233324422

3. IL2CPP 动态调用 runtime

接下来就可以直接调用runtime的东西了

  • 回顾上文,貌似API业务逻辑只加了一套对称加密(AES128 CBC);这里同样如此

image-20231231002031783

image-20231231003122139

image-20231231003153856

image-20231231003214559

总结

  • APIManager为单例实例
  • APIManager.get_Crypt()取得Crypt,其aesAlgo即为.NET标准的AesManaged

综上,简单写一个脚本:

import "frida-il2cpp-bridge";

Il2Cpp.perform(() => {
    Il2Cpp.domain.assemblies.forEach((i)=>{
        console.log(i.name);
    })
    const game = Il2Cpp.domain.assembly("Assembly-CSharp").image;
    const apiManager = game.class("Sekai.APIManager");
    Il2Cpp.gc.choose(apiManager).forEach((instance: Il2Cpp.Object) => {
        console.log("instance found")
        const crypt = instance.method<Il2Cpp.Object>("get_Crypt").invoke();
        const aes = crypt.field<Il2Cpp.Object>("aesAlgo");
        const key = aes.value.method("get_Key").invoke();
        const iv = aes.value.method("get_IV").invoke();
        console.log(key);
        console.log(iv);
    });
});

输出如下:

image-20231231003558106

image-20231231001737613

  • 测试解密

image-20231231002825994

测试脚本如下:

from Crypto.Cipher import AES

def unpad(data):
    padding_len = data[-1]
    return data[:-padding_len]
def decrypt_aes_cbc(data, key, iv):
    cipher = AES.new(key, AES.MODE_CBC, iv)
    return unpad(cipher.decrypt(data))

payload = open('payload','rb').read()
key = b"g2fcC0ZczN9MTJ61"
iv = b"msx3IV0i9XE5uYZ1"

plaintext = decrypt_aes_cbc(payload, key, iv)
print(plaintext)

image-20231231002849347

不清楚序列化格式是什么,可能是protobuf;还是在下一篇继续调查吧

SEE YOU SPACE COWBOY…

References

https://lief-project.github.io/doc/latest/tutorials/09_frida_lief.html

https://github.com/vfsfitvnm/frida-il2cpp-bridge

Project SEKAI 逆向(3): pjsk API中间人攻击 POC

  • 分析variant:世界計劃 2.6.1 (Google Play 台服)

这里和il2cpp关系貌似不大;主要记录一下api包劫持可以用的手段

1. 工具

host使用https://github.com/mitmproxy/mitmproxy,

victim上安装https://github.com/NVISOsecurity/MagiskTrustUserCerts后,导入mitmproxy的CA,重启就能把它变成根证书

最后,mitmproxy所用脚本:https://github.com/mos9527/sssekai/blob/main/sssekai/scripts/mitmproxy_sekai_api.py

2. 分析

上一篇猜测api的封包用了protobuf;并不然,这里是MessagePack

如此,数据schema和报文在一起;不用额外挖了

用mitmproxy做个poc实时解密转json看看:

image-20231231115914176

3. MITM

没错,搞这个只是想看看MASTER谱面长什么样…


锁MASTER的字段貌似在这里;尝试直接修改

    def response(self, flow : http.HTTPFlow):
        if self.filter(flow):
            body = self.log_flow(flow.response.content, flow)
            if body:
                if 'userMusics' in body:
                    print('! Intercepted userMusics')
                    for music in body['userMusics']:
                        for stat in music['userMusicDifficultyStatuses']:
                            stat['musicDifficultyStatus'] = 'available'
                    flow.response.content = sekai_api_encrypt(packb(body))

选项可以点亮;但无果:貌似live会在服务端鉴权

image-20231231121435020

image-20231231121528777

不过live鉴权id貌似不会在开始live中使用;抓包中没有对id的二次引用

考虑可能谱面难度选择只在客户端进行,那么修改上报服务器的难度系数也许能够绕过

    def request(self,flow: http.HTTPFlow):
        print(flow.request.host_header, flow.request.url)
        if self.filter(flow):
            body = self.log_flow(flow.request.content, flow)
            if body:
                if 'musicDifficultyId' in body:
                    print('! Intercepted Live request')
                    body['musicDifficultyId'] = 4 # Expert
                flow.request.content = sekai_api_encrypt(packb(body))

再次启动:

image-20231231123020461

当然,分数上报只会按expert难度进行


意外较少,后续应该不会再玩 MITM 相关内容了

接下来…提取游戏asset?在此之前…

SEE YOU SPACE COWBOY…

References

https://msgpack.org/index.html

https://github.com/mitmproxy/mitmproxy

Project SEKAI 逆向(4): pjsk AssetBundle 反混淆 + PV 动画导入

  • 分析variant:世界計劃 2.6.1 (Google Play 台服)

1. 数据提取

pjsk资源采用热更新模式;本体运行时之外,还会有3~4G左右的资源 (**注:**不定量,见下一篇)

  • 尝试从本机提取资源

image-20231231183530650

image-20231231183558159

没有magic UnityFS,考虑ab文件有混淆

2. 加载流程分析

  • 进dnSpy直接搜assetbundle找相关Class

image-20231231183823530

  • 进ida看impl,可以很轻松的找到加载ab的嫌疑流程

image-20231231183917530

image-20231231183933304

  • 最后直接调用了unity的LoadFromStreamSekai.AssetBundleStream实现了这样的Stream:

image-20231231184111015

image-20231231184246728

可以注意到

  • 加载时根据 _isInverted flag 决定是否进行反混淆操作

  • 如果有,则先跳过4bytes,之后5bytes按位取反

  • 最后移交InvertedBytesAB继续处理

    • 注意到n00应为128,v20为读取offset
  • 这里考虑offset=0情况,那么仅前128字节需要处理

跟进InvertedBytesAB

image-20231231184647711

可见,这里即跳过4bytes后,每 8bytes,取反前5bytes

综上,解密流程分析完毕;附脚本:

import sys
import os
def decrypt(infile, outfile):
    with open(infile, 'rb') as fin:        
        magic = fin.read(4)    
        if magic == b'\x10\x00\x00\x00':
            with open(outfile,'wb') as fout:  
                for _ in range(0,128,8):
                    block = bytearray(fin.read(8))
                    for i in range(5):
                        block[i] = ~block[i] & 0xff
                    fout.write(block)
                while (block := fin.read(8)):
                    fout.write(block)    
        else:
            print('copy %s -> %s', infile, outfile)
            fin.seek(0)
            with open(outfile,'wb') as fout:  
                while (block := fin.read(8)):
                    fout.write(block)    

if len(sys.argv) == 1:
    print('usage: %s <in dir> <out dir>' % sys.argv[0])
else:
    for root, dirs, files in os.walk(sys.argv[1]):
        for fname in files:
            file = os.path.join(root,fname)
            if (os.path.isfile(file)):
                decrypt(file, os.path.join(sys.argv[2], fname))

3. 提取资源

image-20231231192311049

  • 文件处理完后,就可以靠https://github.com/Perfare/AssetStudio查看资源了:

image-20231231192416677

  • 不过版本号很好找,这里是2020.3.21f1

image-20231231192541641

  • 加载可行,如图:

image-20231231192616533

4. AssetBundleInfo?

在数据目录里发现了这个文件,同时在Sekai_AssetBundleManager__LoadClientAssetBundleInfo中:

image-20231231194342801

用的是和API一样的密钥和封包手段,解开看看

注: 工具移步 https://github.com/mos9527/sssekai;内部解密流程在文章中都有描述

python -m sssekai apidecrypt .\AssetBundleInfo .\AssetBundleInfo.json

image-20231231202455181

5. 资源使用?

  • 角色模型数很少

image-20231231203837242

  • 猜测这里的资源被热加载;在blender直接看看已经有的mesh吧:

    bind pose有问题,修正FBX导出设置可以解决;不过暂且不往这个方向深究

image-20231231204536443

  • 同时也许可以试试导入 Unity?

https://github.com/AssetRipper/AssetRipper/ 可以做到这一点,尝试如下:

image-20231231212152781

image-20231231212236240

image-20231231212822730

  • 拖进 Editor

image-20240101141156185

  • 注意shader并没有被拉出来,暂时用standard替补

image-20240101152353581

  • face/body mesh分开;需绑定face root bone(Neck)到body (Neck)
using UnityEngine;

public class BoneAttach : MonoBehaviour
{
    public GameObject src;

    public GameObject target;
    
    void Start()
    {
        Update();
    }
    void Update()
    {
        target.transform.position = src.transform.position;
        target.transform.rotation = src.transform.rotation;
        target.transform.localScale = src.transform.localScale;
    }
}

image-20240101141256456

  • 注意到blendshape/morph名字对不上

image-20240101141815895

image-20240101141909497

爬了下issue:这里的数字是名称的crc32(见 https://github.com/AssetRipper/AssetRipper/issues/954

image-20240101142406334

image-20240101142422934

  • 拿blendshape名字做个map修复后,动画key正常

image-20240101150057515

  • 加上timeline后的播放效果

Animation

不知道什么时候写之后的,暂时画几个饼:

  • 资源导入Blender + toon shader 复刻
  • 资源导入 Foundation
  • 脱离游戏解析+下载资源

SEE YOU SPACE COWBOY…

References

https://github.com/AssetRipper/AssetRipper/

https://github.com/AssetRipper/AssetRipper/issues/954

https://github.com/mos9527/Foundation

Project SEKAI 逆向(5): AssetBundle 脱机 + USM 提取

  • 分析variant:世界計劃 2.6.1 (Google Play 台服)

1. AssetBundleInfo

前文提及的AssetBundleInfo是从设备提取出来的;假设是已经加载过的所有资源的缓存的话:

  • 在刚刚完成下载的设备上提取该文件时,该文件 4MB

image-20240101204313069

  • 但是在初始化后重现抓包时发现的该文件为 13MB
curl -X GET 'https://184.26.43.87/obj/sf-game-alisg/gdl_app_5245/AssetBundle/2.6.0/Release/online/android21/AssetBundleInfo.json' -H 'Host: lf16-mkovscdn-sg.bytedgame.com' -H 'User-Agent: UnityPlayer/2020.3.32f1 (UnityWebRequest/1.0, libcurl/7.80.0-DEV)' -H 'Accept-Encoding: deflate, gzip' -H 'X-Unity-Version: 2020.3.32f1'

image-20240101204525117

  • 推测设备上文件为已缓存资源库,而这里的即为全量资源集合;尝试dump
 sssekai apidecrypt .\assetbundleinfo .\assetbundleinfo.json
  • 查身体模型数看看吧

image-20240101204751167

  • 此外,这里的数据还会多几个field

新数据库:

        "live_pv/model/character/body/21/0001/ladies_s": {
            "bundleName": "live_pv/model/character/body/21/0001/ladies_s",
            "cacheFileName": "db0ad5ee5cc11c50613e7a9a1abc4c55",
            "cacheDirectoryName": "33a2",
            "hash": "28b258e96108e44578028d36ec1a1565",
            "category": "Live_pv",
            "crc": 2544770552,
            "fileSize": 588586,
            "dependencies": [
                "android1/shader/live"
            ],
            "paths": null,
            "isBuiltin": false,
            "md5Hash": "f9ac19a16b2493fb3f6f0438ada7e269",
            "downloadPath": "android1/live_pv/model/character/body/21/0001/ladies_s"
        },

设备数据库:

        "live_pv/model/character/body/21/0001/ladies_s": {
            "bundleName": "live_pv/model/character/body/21/0001/ladies_s",
            "cacheFileName": "db0ad5ee5cc11c50613e7a9a1abc4c55",
            "cacheDirectoryName": "33a2",
            "hash": "28b258e96108e44578028d36ec1a1565",
            "category": "Live_pv",
            "crc": 2544770552,
            "fileSize": 588586,
            "dependencies": [
                "android1/shader/live"
            ],
            "paths": null,
            "isBuiltin": false,
            "md5Hash": "",
            "downloadPath": ""
        },

多出的downloadPath可以利用,继续吧…

2. CDN?

  • 启动下载后,能抓到一堆这种包:
curl -X GET 'https://184.26.43.74/obj/sf-game-alisg/gdl_app_5245/AssetBundle/2.6.0/Release/online/android1/actionset/group1?t=20240101203510' -H 'Host: lf16-mkovscdn-sg.bytedgame.com' -H 'User-Agent: UnityPlayer/2020.3.32f1 (UnityWebRequest/1.0, libcurl/7.80.0-DEV)' -H 'Accept-Encoding: deflate, gzip' -H 'X-Unity-Version: 2020.3.32f1'

downloadPath字段在这里出现了;看起来https://184.26.43.74/obj/sf-game-alisg/gdl_app_5245/AssetBundle/2.6.0/Release/online ` 是这里的AB的根路径

184.26.43.74就是cdn了,毕竟

image-20240101205501465

  • cdn的地址看起来是内嵌的;在dump出来的strings中:

image-20240101210240573

3. 热更新 Cache

考虑pjsk更新频率大,每次重新下所有数据不是很高效

做一个本地cache动机充分;细节就不在这里说了,请看 https://github.com/mos9527/sssekai/blob/main/sssekai/abcache/__init__.py

尝试拉取全部资源,貌似需要27GB

image-20240102003435800

4. 文件一览

  • 在 WinDirStat 中查看分布

image-20240102095200320

  • 动画资源:

image-20240102095331527

  • VO

image-20240102095619789

image-20240102095636123

其它的话,貌似是音视频文件居多

揭开后可以发现封包格式是CriWare中间件格式(i.e. USM视频流,HCA音频流)

5. USM 提取

动机:应该很简单orz


image-20240102112727738

  • 没有 Magic CRID

    回到IDA,看起来USM资源并不是直接从assetbundle中提取;中间有缓存到文件系统的流程

image-20240102114924491

image-20240102113153597

  • 果然,在/sdcard/Android/data/[...]/cache/movies下有这样的文件

image-20240102115142365

image-20240102115207418

  • 而且用WannaCri可以直接demux,没有额外密钥

image-20240102115303855

  • 回顾asset中USM文件

image-20240102115518322

  • 利用MovieBundleBuildData猜测可以拼接出源文件

image-20240102125531642

**脚本:**https://github.com/mos9527/sssekai/blob/main/sssekai/entrypoint/usmdemux.py

image-20240102135738112

SEE YOU SPACE COWBOY…

References

https://github.com/mos9527/sssekai

https://github.com/donmai-me/WannaCRI

Project SEKAI 逆向(6):Live2D 资源

  • 分析variant:世界計劃 2.6.1 (Google Play 台服)

1. Live2D 模型

image-20240102205059463

  • 所有live2d资源都可以在 [abcache]/live2d/下找到;包括模型及动画

首先,.moc3,.model3,.physics3资源都可以直接利用Live2D Cubism Editor直接打开

而模型材质需要额外更名;这些信息都在BuildModelData

image-20240102205701929

  • 补全后即可导入,效果如图

image-20240102205542299

2. 动画 Key 预处理

  • 可惜动画并不是.motion3格式

    封包中有的是Unity自己的Animation Clip

    在提取资源时,所有的动画key只能读到对应key string的CRC32 hash;导出/操作必须知道string-hash关系

image-20240102210045486

  • 这些string在moc3以外的文件中未知:当然,碰撞出string也不现实;猜想string和Live2D参数有关

image-20240102210113370

尝试搜索无果

image-20240102210134670

  • 幸运的是Live2D Unity SDK可以免费取得,而且附带样例

    还记得前文处理BlendShape时,可以知道AnimationClip的源.anim会有path的源string,而不是crc

image-20240102210341040

尝试加入前缀

image-20240102210356955

image-20240102210406498

可以定位;下面介绍如何构建CRC表,完成crc-string map

3. moc3 反序列化 + CRC打表

  • 每次读取都从moc3文件构造应该可行;不过考虑到有导入纯动画的需求,显然一个常量map是需要的

  • 故需要能读取moc3中所有参数名;参照https://raw.githubusercontent.com/OpenL2D/moc3ingbird/master/src/moc3.hexpat

    在 ImHex 中可见:

image-20240103090132409

  • 提取参数名脚本如下:
from typing import BinaryIO
from struct import unpack

## https://github.com/OpenL2D/moc3ingbird/blob/master/src/moc3.hexpat
class moc3:    
    Parameters : list
    Parts: list
    def __init__(self, file : BinaryIO) -> None:        
        # Header: 64 bytes
        file.seek(0)
        assert file.read(4) == b'MOC3'
        version = unpack('<c',file.read(1))[0]
        isBigEndian = unpack('<b',file.read(1))[0]
        assert not isBigEndian
                
        # TODO: Other fields
        file.seek(0x40)
        pCountInfo = unpack('<I',file.read(4))[0]
        
        file.seek(pCountInfo)
        numParts = unpack('<I',file.read(4))[0]
        file.seek(0x10, 1)
        numParameters = unpack('<I',file.read(4))[0]

        file.seek(0x4C)
        pParts = unpack('<I',file.read(4))[0]

        file.seek(0x108)
        pParameters = unpack('<I',file.read(4))[0]
        
        def read_strings(offset, count):
            for i in range(0,count):
                file.seek(offset + i * 0x40)   
                buffer = bytearray()  
                while b := file.read(1)[0]:
                    buffer.append(b)
                yield buffer.decode(encoding='utf-8')
        
        self.Parts = list(read_strings(pParts,numParts))
        self.Parameters = list(read_strings(pParameters,numParameters))
  • 之后,构造CRC表就很简单了
from io import BytesIO
from sssekai.unity.AssetBundle import load_assetbundle
from sssekai.fmt.moc3 import moc3
import sys, os
from UnityPy.enums import ClassIDType

ParameterNames = set()
PartNames = set()
tree = os.walk(sys.argv[1])
for root, dirs, files in tree:
    for fname in files:
        file = os.path.join(root,fname)
        with open(file,'rb') as f:
            env = load_assetbundle(f)
            for obj in env.objects:
                if obj.type == ClassIDType.TextAsset:
                    data = obj.read()
                    out_name : str = data.name
                    if out_name.endswith('.moc3'):
                        moc = moc3(BytesIO(data.script.tobytes()))                        
                        for name in moc.Parameters:
                            ParameterNames.add(name)
                        for name in moc.Parts:
                            PartNames.add(name)                        
from zlib import crc32
print('NAMES_CRC_TBL = {')
for name in sorted(list(PartNames)):
    fullpath = 'Parts/' + name
    print('    %d:"%s",' % (crc32(fullpath.encode('utf-8')), fullpath))
for name in sorted(list(ParameterNames)):
    fullpath = 'Parameters/' + name
    print('    %d:"%s",' % (crc32(fullpath.encode('utf-8')), fullpath))    
print('}')
  • 导出结果如下:

image-20240102225301658

4. AnimationClip 转换

Live2D有自己私有的动画格式motion3,幸运的是UnityLive2DExtractor已做了相当多的解析实现,可供参考

由于上文介绍的细节出入,对PJSK的转换并不能直接使用这个工具

索性在sssekai重现;细节非常繁琐,再次不多说;有兴趣的话还请参考源码

  • 使用例:将转化所有找到的AnimationClip.motion3.json
sssekai live2dextract c:\Users\mos9527\.sssekai\abcache\live2d\motion\21miku_motion_base .
  • 效果如图

sssekai-live2d-anim-import-demo

SEE YOU SPACE COWBOY…

References

https://github.com/AssetRipper/AssetRipper

https://github.com/OpenL2D/moc3ingbird

https://github.com/Perfare/UnityLive2DExtractor

Project SEKAI 逆向(7):3D 模型

1. 文件结构

  • 目前发现的 3D 模型基本都在 [ab cache]/live_pv/model/

image-20240105080707360

image-20240105080841622

初步观察:

  • (1) Body 即目标模型;当然,作为skinned mesh,而且带有blend shapes,处理细节会很多;后面继续讲
  • (2) 处的 MonoBehavior 就其名字猜测是碰撞盒
  • (3) 处的几个 Texture2D 则作为texture map

2. 模型收集

利用sssekai取得数据的流程在(5)中已有描述,这里不再多说

首先整理下根据mesh发现的数据需求

  • (1) Static Mesh

pjsk发现的所有mesh在相应assetbundle中会有1个或更多GameObject的ref;对于这些ref,static mesh会出现在m_MeshRenderer之中

其他细节暂且不说;因为做 Skinned Mesh 导入时都是我们要处理的东西

  • (2) Skinned Mesh

不同于static mesh,这些ref会出现在m_SkinnedMeshRenderer之中

同时,我们也会需要骨骼结构的信息;bone weight以外,也需要bone path(后面会用来反向hash)和transform

  • (3) Blend Shapes

    这些可以出现在static/skinned mesh之中;如果存在,我们也会需要blend shape名字的hash,理由和bone path一致

    加之,Unity存在aseetbundle中动画path也都是crc,blendshape不是例外

总结:

  • (1) 所以对于static mesh,搜集对应GameObject即可

  • (2) 对于skinned mesh,同时也需要构造bone hierarchy(就是个单根有向无环图啦),并且整理vertex权重;

    则需要收集的,反而只是bone的transform而已;transform有子/父节点信息,也有拥有transform的GameObject的ref

  • (3) 的数据,在(1)(2)中都会有

3. 模型导入

当然,这里就不考虑将模型转化为中间格式了(i.e. FBX,GLTF)

利用Blender Python,可以直接给这些素材写个importer

实现细节上,有几个值得注意的地方:

  • Unity读到的mesh是triangle list

  • Blender使用右手系,Unity/Direct3D使用左手系

坐标系
UnityZYX
Blender-YZ-X
  • 意味着对向量需要如下转化

    $\vec{V_{blender}}(X,Y,Z) = \vec(-V_{unity}.X,-V_{unity}.Z,V_{unity}.Y)$

  • 对四元数XYZ部分

    $\vec{Q_{blender}}(W,X,Y,Z) = \overline{\vec(V_{unity}.W,-V_{unity}.X,-V_{unity}.Z,V_{unity}.Y)}$

  • Unity存储vector类型数据可能以2,3,4或其他个数浮点数读取,而vector不会额外封包,需要从flat float array中读取

    意味着需要这样的处理

           vtxFloats = int(len(data.m_Vertices) / data.m_VertexCount)
           vert = bm.verts.new(swizzle_vector3(
                data.m_Vertices[vtx * vtxFloats], # x,y,z
                data.m_Vertices[vtx * vtxFloats + 1],
                data.m_Vertices[vtx * vtxFloats + 2]            
            ))
    

    嗯。这里的vtxFloats就有可能是$4$. 虽然$w$项并用不到

  • 对于BlendShape, blender并不支持用他们修改法线或uv;这些信息只能丢掉

  • Blender的BlendShape名字不能超过64字,否则名称会被截取

  • 对于bone,他们会以Transform的方式呈现;但在模型(和动画文件)中,他们只会以Scene中这些transform的完整路径的hash存储

  • 然后,Blender的Vertex Group(bone weight group)同样也不能有64+长名字

  • 对于vertex color,blender的vertex_colorslayer在4.0已被弃用;不过可以放在Color Attributes

**注:**Blender中对写脚本帮助很大的一个小功能

image-20240104202236513

image-20240105085540376

4. Shaders!

Texture2D和其他meta信息导入后,接下来就是做shader了

  • 手头有的纹理资源如下:
  1. tex_[...]_C

    Base Color Map,没什么好说的

image-20240105081336340

  1. tex_[...]_S

    Shadowed Color Map(乱猜

    • NPR渲染中常用的阈值Map;为节省性能(和细节质量),引擎也许并不会绘制真正的Shadow Map

    • 在很多 NPR Shader中,你会见到这样的逻辑:

    if (dot(N, L) > threshold) {
    	diffuse = Sample(ColorMap, uv);
    } else {
    	diffuse = Sample(ShadowColorMap, uv);
    }
    

即:对NdotL作阈值处理,光线亮(NdotL更大)采用原map,光线暗/无法照明(NdotL更小或为负)采用阴影map

image-20240105081322556

  1. tex_[...]_H

    Hightlight Map

    • 注意到Format出于某种原因竟然是未压缩的RGB565;同时,$R$通道恒为$0$,$B$通道恒为$132$,只有G通道有带意义的信息
    • UPD (20240907): 据指正,$R$通道定义肤色,$B$定义NPR阴影阈值,$G$通道标记了高光部分
    • UPD (20240907): 同时,在三周年(JP)更新后,$a$通道指定了光泽度

image-20240105081327608

  1. Vertex Color

image-20240105180210479

  • 这里只有RG通道有信息,猜测:

    • $R$通道决定是否接受描边

    • $G$通道决定高光强度

5. Shader 实现

  1. 阴影 / Diffuse

image-20240105180740265

注: BSDF应为Diffuse BSDF,截图暂未更新

这里实现的即为上文所述的阈值阴影,不多说了

  1. Specular

直接利用Specular BSDF的输出和前文所提到的weight,mix到输出即可

  1. Emissive

_H材质的$G$通道叠加,node如图

image-20240105183024248

至此Shader部分介绍完毕,效果如图

image-20240105183301304

image-20240105183407294

6.描边

游戏使用了经典的shell shading技术

image-20240105183827963

  • 可见$1$区域带明显描边而$2$区域没有,观察vertex color:

image-20240105183943570

image-20240105184014817

这和之前对描边做的猜测是一致的; $R$​值决定是否描边

显然在 Blender 中使用 Geometry Node 可以很轻松地实现这个效果


SEE YOU SPACE COWBOY…

References

https://github.com/mos9527/sssekai_blender_io 👈 插件在这

https://github.com/KH40-khoast40/Shadekai

https://github.com/KhronosGroup/glTF-Blender-IO

https://github.com/theturboturnip/yk_gmd_io

https://github.com/SutandoTsukai181/yakuza-gmt-blender

https://github.com/UuuNyaa/blender_mmd_tools