序
我一朋友最近在玩国内某三四线厂商的不知名手游,跟我说他已经充了不少钱了,但最近有个充值活动看起来很诱人,不知道要不要参加。我带着轻蔑的语气回复他道:“这种破游戏有什么好充钱的,我分分钟给你破解掉!”。于是就开启了这段hack之旅。(打脸之旅)
人生赢家
安装之后发现这游戏实时性要求不是特别高,类似于几年前流行的卡牌养成类游戏。目测网络请求用的是http,于是打开了Charles试着抓包看一下。果不其然,用的甚至都不是https。
看到结尾的aspx我猜这家公司很早以前应该是做网站的,后来转型做的游戏。在我个人的认知里,现在好像很少看到有公司用asp写后台以及用windows做服务器了。
接着看这些接口都返回了什么。
在这里先介绍几种常见的加密方式:des、aes、rsa、base64、md5、sha。其中des和aes是对称加密,所谓对称加密是指加密的密钥和解密的密钥是同一个。rsa为非对称加密,加密的密钥和解密的密钥并不相同,https就使用了rsa。base64则是一种编码方式,更多的是帮助二进制文件通过http传输。md5和sha应该算是信息摘要,得到的结果一般是不可逆的。
上面这串东西末尾有两个等号,显然是用base64编码过了。找个在线网站直接解码一下,我们得到了这么个东西。
看来他们还对数据做了一层加密,我第一反应觉得可能只是将数据对一串密钥进行了异或运算(异或加密),因为这样实现起来最简单。不管怎样,这时我们都需要取到加密的密钥才解密了。
既然网络请求返回的都是密文,客户端要解密,本地一定存有一份密钥。所以接下来我决定对客户端进行反编译。因为本身是android开发,对android也比较熟悉,就去他们官网下载了android客户端。因为apk本质上就是一个压缩包,直接把.apk改成.zip。解开后找到后缀为.dex的文件。android应用最后是运行在Dalvik虚拟机上的,他与jvm类似,而dex文件就相当于jar包。dex文件阅读源码并不方便,所以在这里我用了dex2jar把dex转换成jar包,然后用jd-gui来预览源码。
整个过程比我想象的要顺利的多,反编译之后所有代码一览无遗,这家公司甚至连混淆都没做。不做混淆的结果就是我一眼就注意到了AesUtil.class这个类。原来他们采用的是aes加密。
可以看到密钥写在了Constants这个类里。到此为止,我就成功解开了他们对网络请求的所有加密,这时我的内心是这样的:
陷入困境
接下来把抓包得到的密文解密后看到了他们请求的数据格式,伪造一份重新加密后发送过去,但返回给我的却是失败。看了下请求头User-Agent那边设置了他们app的名字。设置好UA后重新发送请求,这次终于拿到了正确的数据。其实我觉得用UA做防御好像没什么用,都抓你包来请求接口了,肯定也会看到请求头啊。但后来仔细想了想他们应该不是用来防御的,是用来区分应用的🙄️。
本来是想在代码里直接找到他们所有的网络接口的,但发现他们用了一个叫做corona的引擎,大概就是用lua写android和ios游戏,然后他们的网络请求都是用lua写的,虽然最后肯定还是会被编译成c的二进制文件或者是java的class文件,但我一时半会儿没找到在哪儿,而且找到了可读性可能也很差,所以还是决定通过抓包来测试他们的接口。
游戏第一天签到会赠送道具,使用后可以增加10万金币,我们先从这个接口入手,看看能不能让我们一夜奔小康。
根据字段的名称可以知道cmd是指令,num是使用的数量,propID是道具的ID,guid是用户id,cTime是时间戳,但是位数好像不太对,测试后发现是减去了2016/01/01/0:00的时间戳,hmVer比较重要,可以看到respose会返回一个hmVer且等于请求加一,这个是他们服务器做的验证,下次请求的hmVer需是上一次Response中的hmVer,但是经过测试发现-999是一个特殊的hmVer可以无视这个规则,这应该是他们为了方便留的一个后门吧。
很遗憾他们对数据做了一些判断,随后我试了下num = 0,num = -1以及别的一些异常数据,也试了别的几个接口,他们的后端对于一些边界条件和异常数据都做了处理还是比较细心的,看来接口这条路是走不通了,这时我的内心是这样的:
出现转机
当然有了所有的网络接口已经可以写个自动挂机的脚本了,但只是自动挂机的话,我要怎么做天下第一???回去继续看源码,寻找别的突破口,这时看到了这么一段代码。
看方法名就知道这是用来测试支付的,显然我手上这个包不会执行到这段代码,我们得通过一些手段来逆天改命。
用dex2jar反编译的包是不能修改代码的,也不能重新编译回去,所以这时候我们需要另一个工具:apktool
他能帮你把apk反编译成Dalvik运行时所需的字节码。我们通过修改字节码来改变程序运行的流程。Dalvik运行的字节码是.smali格式的,相当于jvm的.class文件。在修改之前我们先补充一点smali的知识。
Dalvik 是基于寄存器的,而寄存器是cpu的一部分,提供高速且有限的存储,用于暂存指令,数据和地址。在smali中p表示参数寄存器,v表示本地寄存器。其中对于非静态方法,p0是这个类的引用指针(这和java是一样的,这也是为什么在非静态方法中能直接操作这个类,因为持有了他的指针啊喂,你写java时感知不到是因为编译器帮你传了这个东西)
下面这张表格展示了java的基本类型在smali中对应的表示:
java | small |
---|---|
boolean | Z |
byte | B |
short | S |
char | C |
int | I |
long | J |
float | F |
double | D |
void | V |
class | L |
array | [ |
smali调用方法的几种方式:
- invoke-static 显而易见,调用静态方法
- invoke-interface 显而易见,调用接口
- invoke-direct 看名字直接调用,其实是指private方法和construct方法
- invoke-virtual 调用虚函数,什么鬼?其实每一个对象都关联了一张虚函数表里面有他可以调用的一些public、protected、default(package作用域)方法
- invoke-super 这个是从他父类关联的虚函数表里面去找方法
反编译后找到支付方法所在的类,并找到支付这个方法(注意下我加的注释)
.method public pay(Lxxx/PayParams;)V #参数包名xxx/PayParams,返回void
.locals 5 #申请了5个本地寄存器
.param p1, "data" #非静态方法p0存着这个类的引用,p1是第一个参数,这里标注了下,第一个参数名字为data
.prologue #标记了程序起始位置
....
return-void #方法都是要有返回值的,在java中void不用写return,是因为编译器帮你添加了
.end method```
我们在程序开始处直接调用测试支付的方法并且让程序直接返回
```java
.method public pay(Lxxx/PayParams;)V
.locals 5 #这行可以去掉,我们并不需要本地寄存器来存东西
.param p1, "data" # Lxxx/PayParams;
.prologue
#调用私有方法checkPayResultTest
invoke-direct {p0, p1}, Lxxx/Pay;->checkPayResultTest(Lxxx/PayParams;)V
return-void #直接返回了,后面代码不执行
....
.end method```
好了,现在使用apktool重新build代码,并重新对apk进行签名就能安装了,安装好之后进商城点充值然后boom:
![](http://upload-images.jianshu.io/upload_images/2782970-e9a2d6ceaaf0ad9e.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
报了一个错,不慌,这是因为没有在ui线程操作ui组件造成的。我们修改字节码让其在ui线程运行就可以了。但是这里我犯了一个错误,我一开始没仔细看pay方法下面的字节码,其实下面是有调用到这个方法的。
```java
runOnUiThread(new Runnable() {
@Override
public void run() {
checkPayResultTest();
}
});
因为我们new的Runnable是一个匿名内部类,所以编译器会自动在pay下帮你生成一个静态方法,以及这个内部类,这和jvm是一样的,都是从0开始,依次递增,并且这个静态方法和内部类的数字是对应的。
.method static synthetic access$1(Lxxx/CKPay;Lxxx/PayParams;)V
.locals 0
.prologue
.line 292
invoke-direct {p0, p1}, Lxxx/Pay;->checkPayResultTest(Lxxx/PayParams;)V
return-void
.end method```
所以对应的我们找到Pay$1.smali,这个命名规则与java是一样的,类名 + $ + 内部类名称。
打开后看到run方法非常长,大概有300多行,所有的支付逻辑都在这里面了。本着帮他们优化一下程序性能的想法,我把300多行的代码优化成了3行:
virtual methods
.method public run()V
.locals 2 #这边两个本地寄存器就够了
.prologue
#获取到Pay的引用并且赋值到v0
iget-object v0, p0, Lxxx/Pay$1;->this$0:Lxxx/Pay;
#获取到外部传进来的参数并赋值到v1
iget-object v1, p0, Lxxx/Pay$1;->val$tempData:Lxxx/PayParams;
#调用编译器帮你生成的那个静态方法
invoke-static {v0, v1}, Lxxx/Pay;->access$1(Lxxx/Pay;Lxxx/PayParams;)V
return-void
好了现在看着干净多了,支付这一块的性能和内存占用也得到了极大的提升, 我们重新构建apk并且签名。可能有的同学不会用命令行来签名,我们先执行以下代码来生成一个签名文件:
```shell
keytool -genkey -v -keystore 签名文件名称.keystore -keyalg RSA -keysize 2048 -validity 10000 -alias 别名
然后执行以下代码对apk进行签名:
jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore 签名文件名称.keystore APK名称.apk 别名
签名完成后我们重新安装一下,记得把之前安装的先删掉,包名相同签名不同是装不进去的,adb install -r都不行。
选择成功后弹出,请稍后,待订单验证成功后重新登录,然后就没有然后了。
尾声
当然故事到了这里并没有结束,在阅读他们源码的时候发现他们的客户端程序猿还做了一件我所不能理解的事情——把他们的运营后台网址写进了代码里,于是我又试着从他们运营后台找突破口。先试了下sql注入,并不起效果,然后打开了Chrome的控制台,刷新了下页面。
并没有返回他们服务器的信息,但可以看到cookie里有设置一个open.session.id应该是通过这个id来判断是否登录的,是一串md5值,然而并没有什么卵用。
然后我又点开了Chrome控制台的source标签看了下都有些什么文件,根据目录结构我感觉服务器是nodejs或者python的可能性比较大,然后看了下js基本都是些jquery和bootstrap的库,直到common下两个js文件引起了我的注意。
注释里写着所有权归这家公司所有,还有作者的昵称。然后我去搜索了一下作者的昵称,搜到了一个博客。翻阅了一下最早的一篇文章是2008年的,也就是说这个作者至少是工作了8年的程序员。其中有一篇文章是作者的一个开源项目,是基于多个别的开源项目封装的快速开发平台,在github还有着4000+的star。然后在文档里巨细无遗的写了从后端到前端各个层级具体使用了什么技术。而且我还看到了和我反编译的apk所相似的包名前缀,所以我可以肯定这位程序员是那家公司的员工,而且前端技术选型里所提到的几个框架还有库和他们运营后台所用到的一摸一样,所以他应该是把他们运营后台给开源了吧🙄️。顺带提一下,common下的两个js都是些工具类,而且在登陆页只是引用了这两个文件,我并没有找到使用他们的地方。
这次hack之旅到这里就告一段落了,如果还要继续下去的话,可能我会去看一下他开源的代码。哦,对了,在他项目的文档里还给了个测试的账号密码。我试了下并没有登上去,但我相信账号肯定是对的。
总结
这个故事告诉我们这么几个道理:
- 打包时一定要对代码进行混淆,必要的话最好再加上壳
- 密钥什么的不要写死在代码里,可以选择放在native的so包里,或者存本地数据库也行
- 单元测试还是有必要的,并且尽量把测试用例补全
- 不要为了方便而留后门
- 用不到的代码都删掉或者注释掉
- 一些重要的逻辑都放到服务端去处理
- 不要把运营后台地址写进代码里,而且运营后台不要和app共用一个地址,最好切断外网访问运营后台的途径。
- 不要随随便便立flag
把运营后台地址写到代码里真的是很危险的事,如果登进去了我不仅能对他们游戏数据做一些修改,还有可能拿到他们整个数据库的数据。一般情况下数据库里前几的账号都是他们内部人员的账号,而这些账号很有可能与他们的百度网盘、支付宝、qq之类是同一个,我甚至能拿到他们公司比较机密的数据。
最后说两句
其实在和朋友打赌之前,我完全没有做过这种尝试,我只是用自己这两年储备的知识和经验作出对应的分析和思考。大概过程是这个样子的:利用网络来改自己数据->抓包->有=号用base64解解看->解不出,还有一层加密->客户端也要解->去反编译客户端->得到密钥->测试他们接口->不可行->还有源码,去源码找找线索->找到测试支付代码->需要改字节码,不太会->查语法,自己先写个demo试一下->动手改,重新安装->不可行->接着去源码找线索->找到运营后台->去运营后台找线索->找到作者博客->找到后台所用技术
整个过程还是挺流畅的,我想说的其实是遇到问题不要慌,去找对应的解决方法就好了,如果找不到就换一个角度,那句话怎么说来着,条条大路通罗马。其实也不是特别难,有点工作经验的程序员都能做到攻破一个做的不是很好的网站或者app,但这不是这篇文章的目的。我希望大家引以为戒,养成良好的编码习惯,不要留给别人可趁之机。