记一次 Unity IL2CPP 游戏逆向

本文由 Coxxs 原创,转载须注明原文链接:https://coxxs.me/1282

Instant Apps 真是个可怕的东西。

闲着没事就点开了个愤怒的小鸟出品的 Dream Blast 试玩,玩了几分钟觉着有意思,就下了 70MB 的完整版游戏。于是乎,几个小时就玩没了…

手游嘛,开头吸引人,后期总是氪氪氪。虽然我支持正版,但我对内购,尤其是手游内购一向厌恶,这游戏又有内购联网验证。思前想后,不如直接改存档吧。几经尝试,找到了位于 sdcard/Android/data/com.ro**o.dream/files/users/[userid]/prefs.json 的游戏存档文件。打开一看,好好的一个 .json 文件,怎么里面全是乱码呢。好吧,找加密算法去吧。

内购再见!

还原 IL2CPP

解包游戏大致看了看,classes.dex 里都是些无关紧要的东西,游戏逻辑估计是写 Unity 代码里了。找了找并没有 Unity 的 .NET 二进制文件 assets/bin/Data/Managed/Assembly-CSharp.dll,原来是用了 IL2CPP 来编译。这样一来,游戏用到的字符串等被放到了 assets/bin/Data/Managed/Metadata/global-metadata.dat,而游戏二进制文件则位于 lib/armeabi-v7a/libil2cpp.so

要分析 IL2CPP 后的文件原本并不容易,即使没有经过混淆,但由于字符串、函数名等基本上都被拿到了单独的文件,很难找到需要找的代码段。好在 Perfare 大神写了个 Il2CppDumper,可以分析这两个文件,并将字符串、函数名通过 IDA 显示在二进制文件的对应位置。

Input Unity version:
2018.3
Initializing metadata...
Select Mode: 1.Manual 2.Auto 3.Auto(Plus) 4.Auto(Symbol)
3
Initializing il2cpp file...
Applying relocations...
Searching...
CodeRegistration : 16e534c
MetadataRegistration : 16e5384
Dumping...
Done !
Create DummyDll...
Done !
Press any key to exit...

Il2CppDumper 处理完毕后,可获得 DummyDll、dump.cs 以及 script.py 几个文件。前两个文件包含了分析出的各种类定义,script.py 则是辅助 IDA 分析 .so 文件的。

打开 dump.cs,搜索字符串 prefs.json,很快找到了该字符串所在的类,同时发现了一个疑似密钥的字符串 EK(版权原因,密钥已隐去)。

EK 长度为 20 字节;而如果看作 Base64,解码后则是 15 字节。无论哪种情况,都不符合 AES、DES、3DES 的密钥长度。将它作为 RC4 的密钥解密存档文件 prefs.json 呢?果然——不行。

分析 il2cpp.so

乖乖分析代码逻辑吧。用 IDA 打开 libil2cpp.so,Alt+F7 运行刚才生成的 script.py,稍等片刻,定位到对应函数 F5 反编译,就获得了一份有可读性的代码。

这里从 UserPrefs 类的 Init 函数入手,发现在该函数内,构造了一个用于加解密的类。密钥为 “Local-” + Guid + EK。其中的 “Local-” + Guid 部分,恰好就是游戏存档文件所在的文件夹名。

完整的密钥找到了,不过还是不确定加密算法,于是继续分析 CryptoUtility::ctor() 构造函数。

这里的 AesManaged 和 Rfc2898DeriveBytes 两个类都是 .NET 提供的,这样一来就很清楚了:

  1. 构造 Rfc2898DeriveBytes 类,将刚才传入的 存档文件夹名 + EK 的密钥作为 password,将另一个字串 “0xa…..s” 作为 salt
  2. AesManaged 默认使用 AES-128-CBC,因此用 Rfc2898DeriveBytes 类生成出一组 IV (16 Bytes) 及 Key (16 Bytes)。
  3. 对存档进行加解密。

加解密程序

参考了前人在 PHP 中对 .NET 的 Rfc2898DeriveBytes 类的实现用最好的语言实现了加解密程序:

<?php

class SymmetricEncryption {
    private $cipher;
    public function __construct($cipher = 'aes-128-cbc') {
        $this->cipher = $cipher;
    }
    private function getKeySize() {
        if (preg_match("/([0-9]+)/i", $this->cipher, $matches)) {
            return $matches[1] >> 3;
        }
        return 0;
    }
    function derived($password, $salt) {
        $AESKeyLength = $this->getKeySize();
        $AESIVLength = openssl_cipher_iv_length($this->cipher);
        $pbkdf2 = hash_pbkdf2("SHA1", $password, $salt, 1000, $AESKeyLength + $AESIVLength, TRUE);
        $key = substr($pbkdf2, 0, $AESKeyLength);
        $iv =  substr($pbkdf2, $AESKeyLength, $AESIVLength);
        $derived = new stdClass();
        $derived->key = $key;
        $derived->iv = $iv;
        return $derived;
    }
    function encrypt($message, $password, $salt) {
        $derived = $this->derived($password, $salt);
        $enc = openssl_encrypt($message, $this->cipher, $derived->key, OPENSSL_RAW_DATA, $derived->iv);
        return $enc;
    }
    function decrypt($message, $password, $salt) {
        $derived = $this->derived($password, $salt);
        $dec = openssl_decrypt($message, $this->cipher, $derived->key, OPENSSL_RAW_DATA, $derived->iv);
        return $dec;
    }
}

$name = 'Local-db23521a-d1ea-2197-8640-f112c7f9ced9';
$password = $name.'8C................2l'; // 版权原因,隐去一部分
$salt = '0x.........s';

// decrypt
$file = file_get_contents('prefs.json.encrypted');
$file = substr($file, 2); // 去除文件头
$se = new SymmetricEncryption();
$decrypted = $se->decrypt($file, $password, $salt);

// encrypt
$file = file_get_contents('prefs.json');
$file = $se->encrypt($file, $password, $salt);
$encrypted = 'EN'.$file; // 加上文件头
解密后的存档文件

尾声与思考

这游戏的变态之处绝不仅是用 Rfc2898DeriveBytes 和 AES 给存档加密.. 改完存档浪了几分钟,发现金币又归零了。

再次分析,发现这游戏把所有获得金币、经验、道具的事件都加了一个 ID,每次获得物品都会把对应的事件 ID 放到队列里,并择机上报给服务器;同理,消耗物品也会上报。猜测服务器其实也维护了一份物品数据,如果游戏发现与服务器数据差别过大就会加载服务器的数据。于是只好禁止游戏联网玩了。

或许比起修改存档,把消耗金币的代码取消掉才是更正确的选择。不过修改 .so 与打包游戏又是另一个故事了。

Coxxs

《记一次 Unity IL2CPP 游戏逆向》上有1条评论

发表评论

电子邮件地址不会被公开。 必填项已用*标注