7月某天兴起玩了一下googlectf,发现很有意思。不过最后一个也没做出来,都是赛后重新学的。这里把wp放出来和大家分享一哈。
_PS:整个wp太长了,所以就分成两部分了
2019 GoogleCTF part1
Rev
Malvertising
这个题目本意是模拟了恶意广告(那种覆盖页面,点击后会跳转的广告),然后顺着混淆的js
脚本找到源头,所以和Reverse
相关,但是全部都是发生在Web段。。。
Stage1
题目是一个urlhttps://malvertising.web.ctfcompetition.com
随便点击之后就会跳转到google.com
上。对当前界面的代码进行审计,可以看到如下关键代码:
1 | <script> |
这个地方尝试加载了一个js脚本,于是去资源中找到这个脚本,不过基本上是看不懂的。。。用Chrome自带的格式化工具处理一下后,找到关键代码:
1 | // 省略前面一大段 |
结合之前的逻辑分析,会发现函数b
被反复的调用。并且动态调试后注意到,这个函数会对一些加密的内容进行解密,于是动态跟踪程序,在如下的位置下断点:
可以发现,这个地方解开了一段加载js的代码。然后在如下的位置:
1 | if (Number(/\x61\x6e\x64\x72\x6f\x69\x64/i[b('0x1b', 'JQ&l')](navigator[b('0x1c', 'IfD@')]))) { |
对加载的逻辑进行了判断。这个地方利用了js的各种特性,编码的部分扔到console
里面可以得到如下结果:
1 | '/\x61\x6e\x64\x72\x6f\x69\x64/i' |
直译过来就是
1 | if (Number(/android/i["test"](navigator["userAgent"]))) |
正则后面跟着["test"]
,就是利用js的特性,其实本质上是调用了/android/i.test
的正则匹配。这里的意思相当于说:读出当前浏览器的userAgent
,并且检测其中是否包含Android
字样。为了解决这个问题,可以利用Chrome的Toggle Device Bar
模拟切换浏览设备,如下:
这样就会进入第二个js文件
Stage2
第二个js文件的内容如下:
1 | // 省略一些不必要的内容 |
这个地方会检测一大堆的属性,并且检测我们当前的浏览器是否符合这些属性,然后用这个属性组合的密钥对一段密文进行解密。这个地方比较头疼,需要一个爆破逻辑。。。不过有几个地方可以限制一下爆破的范围:
- 前文我们是通过修改成
Android
的操作系统,那么这个时候platform
就是Linux
- 测试发现,有几位数是不生效的
然而因为编码问题,以为是上述思维有错,最后写了一个完全爆破脚本。。。
1 | function main_func(){ |
但是最后还是爆不出答案…..结果知道比赛结束都没能做出来。之后仔细检查了逻辑,发现其实是比赛给的btoa
实现有问题!https://github.com/EmpireCTF/empirectf/tree/master/writeups/2019-06-22-Google-CTF-Quals#140-reversing–malvertising。我重新用npm
下载了一个数据包,然后再次爆破,终于能够找到可见字符:
1 | and now the answer is : |
于是赶紧去找到了这个文件
Stage3
这个文件里面也使用了和Stage1中类似的算法,并且这一次上了反调试,如果使用调试器的话,会陷入一个死循环中出不来,而直接运行脚本,又会有如下的错误(我看网上的wp似乎没遇到这个问题。。)
考虑到隐藏的内容可能包含了答案,于是简单逆向,找到了加密算法_0x5877
,并且写了一个小jio本把里面所有调用的函数都捞了出来:
1 | _0x5877("0x0", "fYVo") !== "csEBi") |
一个个试了一下。。最后找到了关键的解密逻辑:
1 | _0x5877("0x18", "L]47") |
于是访问https://malvertising.web.ctfcompetition.com/ads/src/WFmJWvYBQmZnedwpdQBU.js,找到flag为
1 | alert("CTF{I-LOVE-MALVERTISING-wkJsuw}") |
Flappybird
许久不做Android,找一个题目来找一下手感。
这个题目是一个游戏题,内容如下:
操作起来及其难受。
然后拖到工具里面查看,内容包大致如下:
其中有部分内容我简单逆向了一下。里面很多的类和对象都被混淆成了abcd,简单逆向之后,大致的功能分为三类
- 用于渲染游戏主体的部分
- 用于加载游戏和控制游戏的类
- 奇怪的Checker
检查加密
唯一的Checker
没有被处理,并且可以在其中找到一个native方法:
1 |
|
这个checker中调用的checkAndDecrypt
方法最后会被一个如下的方法调用:
1 | void ShowSecret() { // [import]check |
简单来说,就是解密后的数据会被传入CallMessageToGenerateMap
,当成地图程序处理,然后会被渲染到当前的程序中。但是下载游戏后发现,并没有可以进行输入的地方(至少前两关是没有的)
寻找交互点
这就很奇怪了,一般来说rev不会没有交互就直接退出的(不然的话就不是reverse了)。不过有一个地方说明了程序是存在一个可以与用户交互的地方的:
1 | byte[] message = new byte[0x20]; |
这里可以发现,解密用的message是由程序自己生成的,也就是说程序还是给了一个可以交互的地方,让用户通过与程序交互,使得egg_place
与egg_lists
里面的数据能够相等。这里的egg_lists
是一开始就确定了的:
1 |
|
所以,我们可以关注一下egg_place
的发生赋值的位置。
1 | // 首先是初始化的位置 |
可以看到,初始化的时候会根据egg_number
的数量进行初始化。也就是说只有当前地图中存在egg_holder,才至少有一个egg会被初始化
然后还有一个赋值函数:
1 | // 然后是官方钦定的赋值函数 |
赋值函数GetEgg
中,会将当前存储了egg类型的egg_lists
中对应的egg放到egg_places
中,并且从存放了最近操作的egg类型的recently_egg_place
中移除。其中的两个变量主要含义为:
egg_places_index
记录了最近放了egg的egg_holder的下标egg_places
记录了每一个egg_holder中放入的egg的种类
整个函数的实际作用就是:虽然存在32个egg_places
,但是只有最先操作的16个egg_places
能够存放非Egg_0
种类的egg。
理清上面两个点之后,就能够知道让当前的程序吐出解密后地图的逻辑为:
- 进入带有
egg_holder
的关卡 - 发生交互,让
egg_places
中存放的egg与egg_lists
中相等 - 发生解密
这里的egg_lists
中存放的egg是在一开始被初始化的egg_0~egg_15
。
资源读取
讲到这里,就需要提一下游戏的sprite
的初始化过程了。整个程序用了一个被我称为ResourceLoader
的类进行初始化处理的。对于简单的游戏来说,一般需要一些图片将简单的元素放在一起,这些元素会在程序执行的之后被切割下来作为tile
,对应在某些由游戏本身定义好的object
上面。这个程序也有这样的tile
,将当前的APK解包之后,在assets目录下会发现几张图片,例如:
仔细看会发现,这里面基本上每一个元素的大小距离都是等距的,这样就能够方便程序对每一个元素进行快速的切割。切割下来的tile
一般会作为某一些操作元素的外壳,这个外壳就被称为Sprite
。
一般来说游戏的开发中,都会有一个将Sprite
和游戏对象的physical object
以及部分可以操作的controller
进行绑定的过程,在这个程序中的逻辑如下:
1 | PhysicalControl MainControl = this; |
其中UI.png
如下:
可以看到,这段逻辑将一个名为UI.png
的图片进行了等距切割,并且将切割好的图片对应到一个二维的int数组中,将每一个按钮的Sprite
用数组的方式进行了存储,从而实现对按钮初始化的过程。
根据这段逻辑,我们可以找到调用了tileset.png
初始化的位置
1 | g_map(h arg3) { |
这边同样将这个图片进行了切割,并且进行了映射。在网上找到了一个解释的很好的图:网上的解释图
而显示地图则是通过如下的逻辑:
- 读取
Level.bin
文件 - 将文件解压缩
- 将其中的数字(ascii)翻译成对应的Sprite
这段逻辑就是之前演示过的CallMessageToGenerateMap
1 | private void CallMessageToGenerateMap(byte[] message) { |
解题逻辑
到这儿其实解题的思路差不多就有了:通过正确的调用GetEgg
,有选择的让egg_places
与指定的egg_list
相等,从而让下面这段逻辑能够通过:
1 | void ShowSecret() { // [import]check |
从而让秘密地图显示出来。
那么解开这个题的关键就在于这个nativeChecker
中,找到这个nativeChecker
的生成逻辑,我们就能够通过修改关卡或者直接生成flag图片的方式来获得题解。
反向归并排序
1 | if ( right > 0 ) |
感觉算是一个小小的算法,问了一下大佬同学,可以按照归并排序的顺序运算,然后在原先进行right/left_index ++
的场合,记录下此时元素与元素之间的大小关系。大佬的思想是有了,不过具体的实现好像蛮头疼的,怎么样才能记录这个大小关系呢?这里我想到的办法是用权重,将每一个元素初始状态假设为0
,每次发生比较,较大的元素权重就要发生+1
这里大致定义一下整个归并排序的过程
- 将数列拆封成两部分
- 得到有序后的两部分数组
- 用两个下标分别标识当前数组元素,并且对其进行比较,较小(根据排序需求)者放入目标数组,并且下标自增
- 若其中一个数组被遍历完成,则将剩余的另一个元素数组依次拷贝到目标数组中
- 目标数组即为排序完成的数组
我最初的想法是,在第三步的时候对当前元素的权重进行设置。但是单纯只在比较的场合记录权重,可能会有如下问题:
1 | 数列:4123 |
如果只是单纯的在每次比较的时候增加权重,此时有些元素的权重无法得到及时的更新,导致算法出错。考虑到归并排序后,每一个小数列必然是有序的,那么数列a[n]中,weight(a[n-1])<weight(a[n])**,因此在每一次比较完成后,不应该只是更新当前的元素,而是从当前下标开始的所有当前数组中的元素**
1 | 数列:1423 |
于是就可以得到解题脚本
1 | g_cmp = [0, 0, 0, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 0] |
于是可以得到排列为9, 8, 7, 2, 11, 15, 13, 10, 6, 5, 14, 4, 3, 0, 12, 1
。
最终拿flag
在用数列解密之前,我们先确认一下当前的地图模式是怎么样的。反向推到加载level
的逻辑可以知道,程序将关卡都压缩过,这里我们用python重新解压缩任意一关之后,得到如下的数据:
然后源程序中,有一个翻译的逻辑如下:
1 | for(i = 0; i < this.clo; ++i) { |
里面记录了不同的ascii对应的含义是什么。同时还有一个:
1 | Graph2Object GetTileSprite(Enum_Res resource) { |
这里则是将对应的数据翻译成Sprite对象。
我们可以选择将这些算法翻译出来来得到整个图像。
通过逆向整个check算法,可以知道key实际上有32字节,要通过爆破一个sha256得到。不过幸好根据逻辑我们可以知道,真正的key只是我们得到的16字节的key中穿插塞入0而已。于是可以写一个爆破脚本:
1 | def burst_key(): |
于是得到真正的密码为:
1 | [9, 0, 0, 8, 0, 7, 2, 0, 0, 11, 0, 15, 13, 0, 10, 0, 6, 0, 0, 5, 14, 0, 0, 4, 0, 3, 0, 0, 12, 0, 1, 0] |
最后用这个密码对程序进行解密,得到加密的关卡:
1 | import zlib |