Arcaea的逆向分析


Abstract

  本文使用Charles和Frida对安卓系统上的Arcaea的3.5.3和3.6.2版本进行逆向分析,能够达到搭建私服以及自动代打理论值的效果。

  本文只用于记录和交流思路,任何用于非法目的的行为后果自负。

前言

  大概去年7月入坑的Arcaea,人菜还爱玩,打了一年了全曲包加持下ptt也只有11.66,新曲包Aegleseeker直接给我Track Lost打自闭了,只能上B站康康大佬的手元过过瘾(指目害,也经常刷到一些自制铺的视频。以前玩拉拉还有邦邦也有一些自制铺的网站很方便能在线玩,粗略找了一圈暂且是没看到有Arcaea的自制铺网站。本来想自己研究一下Arcaea的自制铺导入,但是我自己又不会做铺,人家做好现成的也不愿意分享,而且质量参差不齐(指塞爆和反手,想想还是算了,好像没啥搞头,顶多也就绕过一个哈希校验的事情没啥技术含量。不过偶然间逛gayhub发现了这个项目Arcaea-server,使我回想起了之前看到过的一篇文章不修改游戏,不注入内存的修改方法

  简而言之就是自己搭了个私服来玩耍,就效果来看应该是能解锁所有的角色和歌曲,而且可以下载愚人节版本的客户端来玩愚人节限定铺面。思路很好理解,就是想办法让客户端和我们自己搭的服务器进行通信,而且服务器由我们自己掌控因此可以任意操作账号数据。唯一的难点就是如何使客户端与私服进行通信了。突然对这个项目有点兴趣了,在加上自己也没有逆向过cocos的游戏,那就拿它开刀学习一下,顺便熟悉一下如何用frida进行native层的分析。

思路

  我能想到的方法有两种:

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

  以下内容均在安卓系统的基础上讨论,iOS往后稍稍(我iPad还在保修期,不打算越狱。

  第一种方法也是简单明了,把libcocos2dcpp.so拖到IDA里搜一下服务器host的字符串然后改掉就行了(说的容易但是我自己没试过不知道是否可行。因为平时搞逆向都是尽可能的避免对apk本身进行修改的,如果改了so文件的话就得想办法重打包apk,这时候就要面对apk的签名校验问题了,不过简单试了一下Arcaea是没有签名校验的(所以说616对外宣称的加强了安全防护简直是笑话)。另外如果手机root的话就可以直接把patch过的so文件替换掉安装目录里面的so,这样就可以不用重打包。当然手机如果root了的话也可以使用hook工具动态的修改内存中服务器的地址来达到同样的效果。

  第二种方法就是抓包了。给手机设置代理将流量代理到电脑端的抓包工具上,然后由抓包工具进行流量转发,接通客户端与私服。当然代理也有很多麻烦之处比如证书校验啥的,总之相关的资料网上一搜一大把,特别是roysue大佬的r0capture项目readme下面另一位大佬总结的非常全的抓包的知识点,很受用。

  自己平时也经常抓包但是和这篇文章的目的不同,平时的话只要想办法知道客户端和服务器通信的内容并且能进行篡改就算达到目的了,所以很多app的一些防抓包策略其实没怎么在意,只要能够hook到收发数据包的函数康康参数和返回值就完事了。不过在这篇文章的场景下这个方法不适用了,我们必须老老实实的绕过客户端的证书校验才行。所以正好也来熟悉一下证书校验的绕过,估计以后有时间了也会总结一下抓包相关的知识点。

  扯远了,讲下实践部分,这边只介绍第二种方法。

证书校验绕过

  先简单交代一下环境:

  • PC操作系统:Win10
  • 手机:小米11 & 小米8SE
  • 手机系统:MIUI 12.5开发版(Android 11) & MIUI 12.0.2稳定版(Android 10)
  • 客户端版本:Arcaea 3.6.2 & Arcaea 3.5.3
  • 客户端SO架构:arm64
  • Frida版本:14.2.18
  • Charles版本:4.6.1

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

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

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

3.5.3版本

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

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

  然后注入的方法就在这个场景下貌似有点麻烦,因为现有的绕过工具都是基于java层的,而Arcaea是cocos写的,逻辑基本上都在native层,所以不奏效,具体native怎么做的我也没细看,不是有两个方法备用了吗,有这时间不如抓紧康康3.6.2吧。

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

  然而并没有什么卵用!我百思不得其解,明明都过了OpenSSL的证书校验了啊为毛还是没有效果!然后我就只能一点一点苦逼地看这个sub_6133B4,看了大半天还是没啥效果,主要还是native代码看的少啊。最后令我非常无语的是,我抱着试试看的心态来了个“中西结合”,把所有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这游戏像我这种ptt 11.66的菜鸡连个8级曲都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文件的架构要对,不是arm64的请自行分析。

3.6.2版本

  3.6.2版本算是重头戏吧。最新版本中一个鲜明的特点就是libcocos2dcpp.so里面几乎所有的与游戏逻辑有关的代码全都没有函数名了,之前我们在3.5.3版本中使用的方法是搜索函数名,现在显然不管用了。一个简单的思路就是去3.5.3版本里边找hitNote函数有哪些特征,比如字符串啥的,然后去新版本里搜索。这个方法是可行的。可能有人会觉得难道616在新版本就丝毫没有进行代码重构或者优化吗?这不禁让我想起了之前博士学长让我帮忙完成的一个User Study,也是做不同版本的函数对应的,那叫一个变态,好在616她没那么变态。以下为根据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字符串,来找到这个函数。那么在假如字符串加密了咋整?感觉分析一些商用app和做ctf题的一个很大区别就是我们可能会花很多时间在关键代码的定位上。平时分析Java层代码在遇到这种情况的话,首先会使用dumpsys activity top | grep ACTIVITY命令先康康要分析的目标activity的类名,然后使用Objection来hook整个类的所有函数,接着触发目标功能查看函数调用情况,最后在跟进深入分析。因此分析native是否可以采用相同的办法,hook整个SO文件的所有函数?我想理论上是行的,但是不知道如何操作。查了好久Frida的api文档也也没有相关的函数,而frida-trace貌似只能hook导出函数,像这种以sub_开头的无名函数它是没有办法批量hook上的。而且与游戏逻辑相关的函数全都不在导出函数的列表里,所以直接用frida-trace是无济于事的。不过我们也不是因此就无路可走了,可以看到这些sub函数是能被IDA反编译的,而且它们都是的命名格式都是’sub_ + 地址偏移’

subfunction

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

  功夫不负有心人,我啪的一下就搜到了一个现成的轮子trace_natives

这个项目十分契合我们的需求,有如雪中送炭!但是又有一个问题,我直接导出来的sub函数多达16000余个,用frida-trace hook上后无用的函数调用太多了,甚至连游戏都直接崩了,那还分析个锤子。莫慌,有的是办法。之前在参加CVVD比赛时看过一篇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 !
评论
  目录