时隔两年又玩了一把googlectf,这次看了一整天都没看出一题来,事后决定点时间学习一波,这一学就学了一大堆的东西。,,总共花了3周才整理了5题的wp,这个比赛实在是过于硬核。。。
GoogleCTF 2019
Rev
Malvertising (140)
这个题目本意是模拟了恶意广告(那种覆盖页面,点击后会跳转的广告),然后顺着混淆的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 | var T = {}; |
这个地方会检测一大堆的属性,并且检测我们当前的浏览器是否符合这些属性,然后用这个属性组合的密钥对一段密文进行解密。这个地方比较头疼,需要一个爆破逻辑。。。不过有几个地方可以限制一下爆破的范围:
- 前文我们是通过修改成
Android
的操作系统,那么这个时候platform
就是Linux
- 测试发现,有几位数是不生效的
然而因为编码问题,以为是上述思维有错,最后写了一个完全爆破脚本。。。
1 | function main_func(){ |
但是最后还是爆不出答案…结果知道比赛结束都没能做出来。之后仔细检查了逻辑,发现其实是比赛给的btoa
实现有问题!这里的哥们也提到了这个问题。我重新用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 |
这里因为懒得再翻译程序中对Sprite的翻译过程,想到另一个办法:将apk中的level1.bin
替换成我们解密得到的文件,即可让apk帮忙加载地图。
最后的签名
APK本身是签了名的,再修改了level1.bin
之后,首先要将apk中的META_INF
文件删除,然后进行签名。这里使用的是jarsign进行签名。
首先我们要生成属于我们自己的密钥对:
1 | >keytool -genkeypair -alias "test" -keyalg "RSA" -keystore "test.keystore" |
-alias
后面跟着的是当前密钥对的名字,之后会用到-keyalg
表示当前使用的签名算法-keystore
表示当前生成的签名最后的存放位置
之后还会要求我们输入输入密钥库口令以及一些基本信息,输入完成后就会生成一个90天有效的签名了
得到了签名之后,就能够使用jarsign
签名了:
1 | jarsigner -verbose -keystore test.keystore -signedjar ./flaggybirdflag-signed.apk ./flaggybirdflag.apk test |
-verbose
显示详细信息-keystore
当前密钥文件的地址-signedjar
签名后的文件名
最后跟上jar-file
以及我们刚刚对签名起的别名,即可完成签名。替换后的APK打开即可得到flag:
Crypto
Reverse a cellular automata (80)
本质上为一个算法题(大雾)
题目描述
题目描述是说,当前使用了一种叫做胞元自动机cellular automata的东西(就是题目里面那个东西)的算法。这个东西解释起来有、、复杂,不过现实中很多地方都见过。胞元自动机相当于是定义了一种规则。假设我们定义了如下的3*3
的方块:
1 | = = = |
假定如下的规则:
=
表示活着的细胞+
表示死了的细胞- 定义一个规则:如果细胞四周(上下左右)有>=3个细胞存在,由于细胞养料不足,当前的细胞会死亡;否则,因为养料充足,细胞会重新活过来
胞元自动机是以状态为概念的。也就是说每一个细胞只考虑当前状态下周围的养料情况,不考虑下一个状态。那么根据规则,下一个状态的方块中的细胞会变成:
1 | = + = |
可以注意到,此时**“死去”**了很多个细胞。虽然根据资源分配的话,四周的细胞死亡后,正中间的细胞本来是不必死去的,但是根据状态,它也会进入这个状态。然后再下一个状态就是:
1 | = = = |
恢复成最初的形状。这种就叫做胞元自动机的变化过程。
有很多的胞元自动机规则,分别就是规定了这种变化过程。题目中给出的Wolfram rule 126
也是一种变化的规则:
第一排为当前状态。也就是说仅仅在当前状态以及相邻两个状态均相等的时候,当前状态转变成0,否则转变成1
题解
题目中给出了一个密文,并且给出了一个64bit下的胞元自动机生成的数字,推测出上一个状态,对应的数字作为密文的密钥即可解开答案:
1 | Flag (base64) |
这里观察Rule 126
,可以发现一些规律。这里推荐网站https://www.wolframalpha.com/input/?i=Rule+126,里面已经帮我们总结了规律。
这种有规律,求解的问题都可以用z3
来解决。这里参考https://blog.julianjm.com/Google-CTF-2019/#Automata照着写了一个:
1 | from z3 import * |
基本上是把人家的搬运了一下。。实在是不会写z3
这个输出非常多,可以用bash脚本之类的主动调用题目中给出的解题指令,即可得到答案。
Pwn
SecureBoot
1 | Your task is very simple: just boot this machine. We tried before but we always get ‘Security Violation’. For extra fancyness use socat -,raw,echo=0 tcp:$IP:$PORT'. |
这个题目是一个pwn题,从名字中可以知道应该是和一个叫做Secure Boot的特性相关的一个题目。这个特性其实是关于UEFI(Unified Extensible Firmware Interface)的一个子特性。
用binwalk
查看之后,发现是一个UEFI文件,然后用UEFITool
查看之后发现里面内容如下:
官方给出的run.py
脚本如下:
1 | #!/usr/bin/python3 |
其中qemu
那段启动的内容意思为:
-monitor /dev/null
:将监视器重定向到主机的空白设备。-m 128M
: 设置启动时候的RAM大小为128M-drive if=pflash,format=raw,file=%s
: 设置一个驱动,同时可以设置相关的设备类型。这里设置的设备接口为pflash
(闪存,相当于是连接了bios的那个东东),磁盘格式为raw
,意味着不需要检测格式头,然后定义了当前的OVMF.fd
作为当前的操作镜像。这句话相当于是模拟了一个写有UEFI的闪存挂载到操作系统上的一个过程-drive file=fat:rw:contents,format=raw
: 同理,这句话设置了一个驱动,不过这里是将contents
目录作为硬盘格式挂载到这上面(标注为raw之后就不需要关注是不是MBR/GPT的磁盘了)-net none
: 不支持网络通信2 > /dev/null
: 重定向错误流
尝试运行这段内容后,程序会输出:
1 | UEFI Interactive Shell v2.2 |
根据输出,我们知道当前的OVMF.fd
中的UEFI开启了SecureBoot
的特性,而当前内核并没有签名,导致了内核没有能够加载。
企图绕过
在UEFI加载的时候,有四个阶段
1 | SEC(安全检测,完成从flat mode 到 real mode)--PEI(EFI前期初始化,初始化各个模块,CPU/IO等等)--DXE(初始化各类驱动)--BDS(初始化键鼠驱动,VGA等等) |
但在这四个阶段之后,还会有一个叫做TSL(操作系统加载前期)
的阶段,这个阶段中就是在最初的UEFI加载完成之后(但是还没有尝试开始运行的时候),会从主板上(CMOS处)加载关于UEFI的配置(也就是通常说的进入BIOS)。在这之后才会正式进入RT(Runtime)
加载操作系统等等。
所以想到,我们可以通过手动修改BIOS的配置来关闭SecureBoot
。但是该题没有告诉我们到底怎么进入BIOS,此时只能一通乱按,终于发现是在按下了f12
的时候可以打开这个界面:
1 | BdsDxe: loading Boot0000 "UiApp" from Fv(7CB8BDC9-F8EB-4F34-AAEA-3EE4AF6516A1)/FvFile(462CAA21-7614-4503-836E-8AB6F4662331) |
这里会发现,程序在DXE
阶段加载了一个好像叫做UiAPP
的文件,并且要求我们输入密码。于是这里想到说,这个UiAPP
可能是实现了输入密码功能的一个自定义的EFI文件。此时我们注意到7CB8BDC9-F8EB-4F34-AAEA-3EE4AF6516A1
这个值,这个值我们在UEFITool
的截图中看到过。
于是想到,能不能将这个文件导出来看一下。这里使用工具uefi-firmware-parser
进行导出:
1 | uefi-firmware-parser -ecO ./OVMF.fd |
输出中可以开到如下的内容:
1 | File 38: 462caa21-7614-4503-836e-8ab6f4662331 type 0x09, attr 0x00, state 0x07, size 0x1beae (114350 bytes), (application) |
这段就是我们需要找到的内容(目录有点深,得搜索出来才行)
1 | SecureBoot\OVMF.fd_output\volume-0\file-9e21fd93-9c72-4c15-8c4b-e77f1db2d792\section0\section3\volume-ee4e5898-3914-4259-9d6e-dc7bd79403cf\file-462caa21-7614-4503-836e-8ab6f4662331 |
漏洞点
首先利用全局搜索找到字符串L"Password",发现其相关函数如下:
1 | __int64 checkPassword() |
程序要求我们输入128个字节,然后将这128个字节进行sha256,结果要为四个DEADBEEFDEADBEEF
的时候,就会返回1表示密码输入正确。
这里会发现一个很显眼的漏洞点:程序允许我们读入140字节,但是申请的空间却只有128个字节那么大。总共可以多溢出12个字节。由于这里是位于EFI程序中,此时地址并不是特别高位,所以可以粗略的认为我们此时可以修改v6和v7处的变量。
从定义上可以发现,v7是一个地址,而我们栈溢出又正好可以溢出到v7的地址处,于是一个自然的想法就是,通过爆破sha256,得到一个粗略任意地址写的一个漏洞(因为必须要将buffer进行sha256,所以这个写入也不是那么受控制,不过如果控制了v7相当于是可以往部分位置写数据了)
那么要往哪里写呢?这个倒是一个大问题,毕竟这个题目不是在通常的运行环境下,没有libc
等一系列东西,所以得考虑用别的办法。不过其实考虑到,分页保护这个过程发生在操作系统加载之后,那么应该会意识到,此时的EFI程序应该是不存在页保护的。
动态调试
修改题目中给出的run.py
1 | #!/usr/bin/python3 |
之后启动gdb,然后输入
1 | target remote 127.0.0.1:1234 |
即可连接调试。
链接进去后用手速进入输入password的界面,然后断下来,检查内部执行情况可以发现,此时的程序段的确都是可执行的:
此时我们需要找到此时的uiapp
被映射的位置,这里似乎没有什么好办法,只能说翻一下栈,看看有没有哪个数字的最后三个字节和IDA中的某个函数调用返回值的后三字节相同。通过逆向分析,可以知道如下的汇编:
1 | .text:000000000000FF1E |
猜想说此处的rax
调用的正是读取数据的函数,所以我们此时需要查找栈中地址尾部为F5E
的地址,最后成功找到:
1 | 24:0120│ 0x7ec17c8 —▸ 0x67daf5e ◂— jmp 0x67daede /* 0x44b70fffffff7be9 */ |
于是可以计算出此时段的基地址为67daf5e - ff5e = 67CB000
。
利用思路
代码段本身居然能被修改,而且没有开启ASLR
,这意味这我们不需要leak就可以修改任意内容,我们需要巧妙的利用这一点。这里比较容易想到的一点就是修改发生比较前后的代码。将发生跳转的
1 | .text:000000000000FFBE cmp rdx, rax |
这个位置修改成jmp $+b3
,那么就可以直接跳转到
1 | .text:0000000000010074 mov eax, 1; <------arrive--here----- |
不过查看反汇编,会发现jmp +$0xb3
需要的字节码有、长,为\xe9\xae\x00\x00\x00
,因为这个地方有一个无符号数0xb3
,而爆破这么长的字节显然不合适,但是反向跳跃不需要那么多字节:
1 | .text:0000000000010074 mov eax, 1 ;<---------arrive here |
如果从指定的位置进行jmp的话,那么此时只需要0x1007b - 0x10074=0x7
,也就是说此时只需要跳转jmp $f9(\xeb\xf7)
即可:
那么接下来要做的事情就很简单了:
- 溢出修改v7的值为`0x67db07b
- 计算sha256,让开头两字节为
\xeb\xf7
说到调试问题,可以让程序先运行起来,然后本地使用gdb远程链接过去(此时注意qemu的命令行中不要加入-S
防止被挂起)
在调试期间发现一个漏掉的问题点:
1 | v6 = (*(__int64 (__fastcall **)(_QWORD, char *))(*(_QWORD *)(qword_1BC68 + 48) + 8i64))( |
这个地方的v6
在栈上位于v7
之前,通过测试发现在读入我们的输入的时候会被置为00000000
,所以在计算sha256的时候需要把这段默认为0,但是发送数据的时候这一段需要设置为任意可见字符防止被截断。
由于此时需要利用pwntool和远程通信,所以需要发送f12
,所有特殊按键在UEFI加载的时候通过\x1b
来激活,表示这之后的字符串是特殊按键。这些对应关系可以查询这里
1 | # -*- codingP:utf-8 -*- |
如果只是本地测试的话,可以使用
1 | socat -,raw,echo=0 SYSTEM:"python exploit.py" |
来进行启动,会得到如下的画面:
然后设置一下Secure Boot
就能够让其正常启动。修改成remote
模式之后即可到达最终答案
CTF{pl4y1ng_with_v1rt_3F1_just_4fun}