Arcaea的逆向分析


前言

  封号后果自负

  Insight来源:Arcaea-server,以及之前看到过的一篇文章不修改游戏,不注入内存的修改方法

  简而言之就是自己搭了个私服来玩耍,就效果来看应该是能解锁所有的角色和歌曲,而且可以下载愚人节版本的客户端来玩愚人节限定铺面。思路很好理解,就是想办法让客户端和我们自己搭的服务器进行通信,而且服务器由我们自己掌控因此可以任意操作账号数据。唯一的难点就是如何使客户端与私服进行通信了。

思路

  1. 修改客户端的服务器地址
  2. 使用代理中间人攻击转发流量

  以下内容均在安卓系统的基础上讨论,iOS往后稍稍

  第一种方法也是简单明了,把libcocos2dcpp.so patch一下就好了

  第二种方法就是抓包。给手机设置代理将流量代理到电脑端的抓包工具上,然后由抓包工具进行流量转发,接通客户端与私服。关于抓包,在r0capture项目的readme下面有一位大佬总结的比较全的知识点。

  这边只对第二种方法进行了实践。

证书校验绕过

  616加强安全防护貌似是在3.6.0以后的版本,就结果来看客户端对证书的校验策略确实是有所加强。在这篇文章中有大佬对目前证书校验的安全级别以及破解方法进行了划分。如图:

客户端证书处理逻辑安全等级分类

  从3.5.3到.3.6.2版本发生的变化之一,就是客户端的上述安全等级从二级上升到了三级,因此我们分开讨论两个版本。

3.5.3版本

  3.5.3版本的客户端处于安全等级二,根据图片中的描述在安全等级二,直接给手机安装证书是无法正常抓到客户端的数据包的。破解方法文章中也有提到,那就是注入或者将证书安装到系统根目录。另一种思路是将apk的manifest中targetSdkVersion的属性值改到安卓7以下,我自己试过把值改成23即可愉快玩耍,缺点是需要重打包。

  在尝试将证书安装到根目录的实践过程中遇到了一些坑。想要这么做首先要将system分区进行解锁,但是我的小米8SE虽然刷了magisk root但是不知道为啥就是没权限修改system分区,不过好在我刷了第三方twrp可以通过recover模式修改system分区。而我的小米11使用的是系统自带的root,能开启adb root,所以就按照网上的教程还有这篇,对system进行remount以后把证书搬过去就行了。值得注意的是adb disable-verity这个命令有点危险,改完system以后记得使用配套命令adb enable-verity启用校验,否则手机无法使用OTA进行系统升级,只能线刷。

  事后发现magisk有个模块可以直接将证书搬运过去,就是这个->Move_Certificates-v1.9

3.6.2版本

  3.6.2版本在安全等级三,SSL部分使用的是OpenSSL,也就是说客户端会在apk里自己带一个证书然后自己验证自己的证书,完全无视其他证书,旧版本的办法是完全失效了,还是得深入进去看它的校验逻辑。根据分析结果可知,这个证书后来会放到这个目录:

客户端证书目录

  分析过程就是在IDA里搜SSL,幸运的是相比于旧版本(3.5.3),虽然游戏相关的函数都被去掉了函数名,但是OpenSSL并没有被混淆,所以搜索了一下OpenSSL的关键函数,查一下OpenSSL的api用法,然后用frida打印一下参数就行了。我试过直接把Charles证书加到这个证书的顶部,确实能够通过一部分OpenSSL的证书校验,但是Charles还是抓不到包。

  后来又去搜索了一下如何绕过OpenSSL的校验,找到了这篇文章Universal interception. How to bypass SSL Pinning and monitor traffic of any application。可以说是非常详细了,其实完成文章中的Technique #1就可以了,看了一下它下文的内容好像并不是我们遇到的场景。关键代码如下:

//X509_verify_cert
Interceptor.attach(Module.findExportByName("libcocos2dcpp.so","X509_verify_cert"),{
    onLeave:function(ret){
        ret.replace(0x1)
    }
})

//SSL_CTX_set_cert_verify_callback
Interceptor.attach(Module.findExportByName("libcocos2dcpp.so","SSL_CTX_set_cert_verify_callback").add(0x60d5a4), {
    onEnter: function(args){ args[1] = ptr("0x0") }
});

  虽然和文章中的写法不同但是效果相同。然而事情并没有那么简单,做到这一步的话,效果和上边说的修改证书是一样的,虽然OpenSSL的校验看起来好像是通过了,但是客户端依然认为没有通过,肯定是还有其他的校验部分。

  通过跟踪OpenSSL证书校验函数的调用栈我来到了这个函数sub_6133B4

sub_6133B4

  可以看到它调用了大量OpenSSL的库函数,代码逻辑上和网上能找到的一些使用OpenSSL进行Socket通信的Demo非常接近,可以推测这个函数就是616自己写的用于进行签名校验的关键函数。我hook并打印了这个函数的返回值,发现校验通过的情况下返回值为0,否则不为零。于是我使用Frida修改这个返回值

//sub_6133B4
Interceptor.attach(Module.findBaseAddress("libcocos2dcpp.so").add(0x6133B4),{
    onLeave:(ret)=>{
        ret.replace(0x0)
    }
})

  然而并没有什么卵用,有点纳闷,我抱着试试看的心态把所有hook代码都拼一块,结果莫名其妙的成功了。完整代码:

// SSL pinning Bypass for 3.6.2
Java.perform(()=>{
    console.log("Bypass SSL Pinning")
    
    //X509_verify_cert
    Interceptor.attach(Module.findExportByName("libcocos2dcpp.so","X509_verify_cert"),{
        onLeave:function(ret){
            ret.replace(0x1)
        }
    })

    //SSL_CTX_set_cert_verify_callback
    Interceptor.attach(Module.findExportByName("libcocos2dcpp.so","SSL_CTX_set_cert_verify_callback").add(0x60d5a4), {
        onEnter: function(args){ args[1] = ptr("0x0") }
    });


    //sub_6133B4
    Interceptor.attach(Module.findBaseAddress("libcocos2dcpp.so").add(0x6133B4),{
        onLeave:(ret)=>{
            ret.replace(0x0)
        }
    })

    
})

效果图

  爷青结。

  目的达到了,但是我们来继续分析Arcaea,榨干它的利用价值。目前还想做的是想办法能自动帮我打个理论值,最终效果是不用动手就使每个note都是大Pure,这样相比直接改封包也许会安全点,当然依旧有可能封号,不过无所谓,我们只用小号进行离线测试。接下来介绍一下自动理论值的分析过程。

自动理论值

  自动代打理论值的思路就是找到客户端判断miss和判断hit的函数,然后把所有的miss都换成hit即可,根据hit的参数不同还有大Pure小Pure和far的区别,总之全换成大Pure就行了。我们要解决的问题就是如何找到这两个函数,由于新版本拿掉了所有函数名,所以难度会高很多,因此同样的,我们分版本进行讨论。

3.5.3版本

  这个版本函数名都还在,直接上IDA搜索一下note,看了一下

搜索note

一共200多个函数有点多啊,粗略看了一下确实有类似noteHit的函数,直接搜notehit

搜索notehit

  搜索到的结果少很多,然后用frida-trace hook上这几个函数康康调用吧,输入frida-trace -UF -i "*notehit*" -i "*Notehit*" -i "*noteHit*" 来进行批量hook,当然也可以自己写代码用frida一个一个的hook。最后定位到了地址偏移为0x67AC5C的这个函数。并且根据它的调用栈我们找到了一个叫checknote的函数显然这个函数是用来检测每一个note的类型(如普通的tap或者是长条还有蛇)并且判断是miss了还是被hit了,顺着这个函数我们又找到了一个叫missNote的函数,他与hitNote并列。代码就不贴了,太长。到这里基本上齐活了,简单的hook这两个函数发现miss的时候会调用missNote,hit的时候会调用hitNote。这是hitNote函数的签名:
__int64 __fastcall ScoreState::hitNote(__int64 a1, _DWORD *a2, int a3, int a4, unsigned int a5)
前两个参数是固定的,应该是某个游戏管理类的结构体的指针,不需要管它,有两个int类型的参数会根据hit的准度不同而变化,我们只需要知道大Pure时两个int都为0即可,最后一个参数应该是每个note的序号或者类似的用于区分note的玩意,也不需要管。我们只需要将两个int参数修改为0即可,关键代码如下:

Interceptor.attach(libcocos2dcppSo.base.add(0x67AC5C),{
    onEnter(args){
        args[2] = new NativePointer(0x0)
        args[3] = new NativePointer(0x0)
    }
})

测试一下果然随手打的也全是大Pure,只要我们能Full Combo就必然是理论值。然而Arcaea这游戏想要Full Combo也是非常难的,想要没有miss就得想办法让每个note都进到hitNote的逻辑里来。一个思路是分析checkNote函数,对它进行patch,让他每次都自动进到hit的分支,我们可以使用强无敌的Frida,它提供了一个牛逼的api能够直接把函数的实现进行替换,我们用这个方法直接把对missNote的实现改成调用hitNote即可。这里忘了说,missNote的参数和hitNote不同,因为没有hit到note所以也就没有用于表示精确度的两个int参数,其他的三个参数还是有的。因此我们的hook代码只需要手动的将参数用0(大Pure)补全这两个参数,其他参数直接复用missNote的参数即可。关键代码如下:

var hitNote = new NativeFunction(libcocos2dcppSo.base.add(0x67AC5C), 'int', ['pointer','pointer', 'int', 'int', 'int'])

Interceptor.replace(libcocos2dcppSo.base.add(0x67AB8C),new NativeCallback((arg1,arg2,arg3)=>{
    return hitNote(arg1,arg2,0,0,arg3)
},"int",['pointer','pointer', 'int']))

  这样就完成了,同时使用两部分的代码就能达到每次都是理论值的效果,甚至不用动手!附上完整代码:

// 3.5.3版本
Java.perform(function(){
    console.log("attach")

    var libcocos2dcppSo = undefined

    var process_Obj_Module_Arr = Process.enumerateModules();
    for(var i in process_Obj_Module_Arr) {
        if(process_Obj_Module_Arr[i].path.indexOf("libcocos2dcpp.so")!=-1)
        {
            libcocos2dcppSo = process_Obj_Module_Arr[i]
           
        }
    }

    Interceptor.attach(libcocos2dcppSo.base.add(0x67AC5C),{
        onEnter(args){
            args[2] = new NativePointer(0x0)
            args[3] = new NativePointer(0x0)
        }
    })

    var hitNote = new NativeFunction(libcocos2dcppSo.base.add(0x67AC5C), 'int', ['pointer','pointer', 'int', 'int', 'int'])
    Interceptor.replace(libcocos2dcppSo.base.add(0x67AB8C),new NativeCallback((arg1,arg2,arg3)=>{
        return hitNote(arg1,arg2,0,0,arg3)
    },"int",['pointer','pointer', 'int']))
    
})

  hook代码都是基于地址偏移进行的,前提是SO文件的架构要对,否则脚本将失效。

3.6.2版本

  3.6.2版本算会稍微难一些。最新版本中一个鲜明的特点就是libcocos2dcpp.so里面几乎所有的与游戏逻辑有关的代码全都没有函数名了,之前我们在3.5.3版本中使用的方法是搜索函数名,现在显然不管用了。一个简单的思路就是去3.5.3版本里边找hitNote函数有哪些特征,比如字符串啥的,然后去新版本里搜索。这个方法是可行的。以下为根据3.5.3版本的字符串特征找到3.6.2版本的对应的函数对比:

353searchstring

362searchstring

这两个函数不能说十分相似,只能说一模一样。在简单的hook验证一下很快就实锤了,然后看这个函数的调用栈很容易就找到了3.6.2版本中与3.5.3版本的checkNote对应的函数了,同样的也是一模一样的结构,然后顺其自然的找到了missNote的对应函数,当然这些函数在3.6.2版本中都是以sub_ + 地址偏移的格式命名的,不过参数和实现丝毫未变,参照3.5.3版本进行hook代码编写就完事了。

  我们加点限制,考虑这样一种情况,假如Arcaea的第一个版本就是3.6.2,我们没有可以参考的旧版本怎么办?虽然函数名没了不过我们应该可以搜索note字符串,来找到这个函数。那么在假如字符串加密了咋整?平时分析Java层代码在遇到这种情况的话,首先会使用dumpsys activity top | grep ACTIVITY命令先康康要分析的目标activity的类名,然后使用Objection来hook整个类的所有函数,接着触发目标功能查看函数调用情况,最后在跟进深入分析。因此分析native是否可以采用相同的办法,hook整个SO文件的所有函数?我想理论上是行的,但是不知道如何操作。查了好久Frida的api文档也也没有相关的函数,而frida-trace貌似只能hook导出函数,像这种以sub_开头的无名函数它是没有办法批量hook上的。而且与游戏逻辑相关的函数全都不在导出函数的列表里,所以直接用frida-trace是无济于事的。不过我们也不是因此就无路可走了,可以看到这些sub函数是能被IDA识别为函数的,只不过名字被抹去了,IDA只知道他们的地址,所以它们都是的命名格式都是’sub_ + 地址偏移’

subfunction

只要我们能得到这个地址偏移的列表就能一次性批量的hook上,所以要想办法把这个列表导出来。

  我啪的一下就搜到了一个现成的轮子trace_natives

这个项目十分契合我们的需求,但是又有一个问题,我直接导出来的sub函数多达16000余个,用frida-trace hook上后无用的函数调用太多了,甚至连游戏都直接崩了,那还分析个锤子。之前在看过一篇CAN总线协议分析的文章,里边提到了一种统计法,先重复某个操作n次,然后分别统计这个过程中所有指令的执行次数,找到所有执行次数为n的指令,其中某一条或者多条就对应着刚刚执行过的操作了,然后多次重复筛选就行,过程就和用CE修改器改内存一样。

  我们可以用frida-trace进行迭代式的hook,首先我们要排除掉一些噪声,先使用-o 参数将log保存到本地,等待hook上以后手动的触发一些无关功能,中间可能会闪退但是问题不大。log中所有被调用的函数都是不相关的函数,我们将它们从trace_natives生成的hook列表中剔除。具体如何剔除就是自己写个简单的脚本操作一下就行了,考虑到以后应该也会用到最好还是认真写个轮子,以便复用。这边我写的太烂了,用java写的,怕丢人就不贴代码了。这样多次剔除以后,终于能进游戏了,一些按钮的点击也不会闪退了,噪声清的差不多了。

  接下来就是统计了。同样用-o 参数保存log,方便统计,然后随便进一首歌,随便点几个note,当然要记住自己点了几个,然后暂停,结束hook。统计一下所有与点击的note数相同的函数调用,然后单独的拿出来继续用frida-trace hook,没几轮就只剩下14个函数了

subfunction

点几个note康康调用情况

subfunction

分析最靠左边的函数就行了,很快就能找到noteHit函数了然后照上面的思路打印调用栈接着往下分析就行了。

  然后有一个问题就是挂机的时候一直Lost,虽然Lost了也加分但是影响美观,推测应该是有一个和hitNote类似的函数处理UI上的hit or miss,然后参考上边的思路很快解决了这个问题。最后有一点美中不足的就是note和长条还是越过会判定线,因为我们没去点击它们,不过这个问题解决起来好像有点麻烦,就不搞它了,这篇文章就到处为止了。最后附上完整代码和成绩图。

//3.6.2版本
Java.perform(function(){
        
    var libcocos2dcppSo = undefined

    var process_Obj_Module_Arr = Process.enumerateModules();
    for(var i in process_Obj_Module_Arr) {
        if(process_Obj_Module_Arr[i].path.indexOf("libcocos2dcpp.so")!=-1)
        {
            libcocos2dcppSo = process_Obj_Module_Arr[i]
           
        }
    }

    //hitNote 函数
    Interceptor.attach(libcocos2dcppSo.base.add(0xc75278),{
        onEnter(args){
            args[2] = new NativePointer(0x0)
            args[3] = new NativePointer(0x0)
        }
    })
    
    var hitNote = new NativeFunction(libcocos2dcppSo.base.add(0xc75278), 'int', ['pointer','pointer', 'int', 'int', 'int'])

    //替换miss
    Interceptor.replace(libcocos2dcppSo.base.add(0xD39FE0),new NativeCallback((arg1,arg2,arg3)=>{
        return hitNote(arg1,arg2,0,0,arg3)
    },"int",['pointer','pointer', 'int']))

    //替换lost为大pure
    Interceptor.attach(libcocos2dcppSo.base.add(0x8F2324),{
        onEnter(args){
            args[3] = new NativePointer(0x0)
            args[4] = new NativePointer(0x0)
        }
    })    

})

score

结语

  封号后果自负


文章作者: 大A
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 大A !
评论
  目录