zer0pts 2022 Flag-Checker Write Up
前言
有幸参加了zer0ptsCTF2022,有幸在一开始就抽到了本次比赛最折磨的题目,经过两天的鏖战(坐牢),终于在比赛最后4分钟拿到该题一血,成功出狱。
题目结构
概览
本题目由3个 exe 文件组成,彼此间使用命名管道\\.\pipe\anime
通信,用进程的父子关系模拟二叉树,以用户输入的 flag 字符串构造满二叉树,最后层序遍历二叉树验证用户输入是否正确。
3个 exe 如下:
task.exe
: 所有进程的父进程,进程二叉树的父节点。负责用户交互、启动anime.exe
和subprocess.exe
。使用autoit
语言编写;anime.exe
: 控制进程,负责构造进程二叉树和验证二叉树。使用C++
编写;subprocess.exe
: 子孙进程,进程二叉树的非父节点。使用Go
语言编写。
autoit
逆向
aotuit
是个解释型语言,提供了编译为 exe 的功能。通过替换资源就可以确认,它生成的 exe 实际上就是把脚本扔进 exe 的资源里。
这个语言可以用这个工具简单反编译。不过这个工具不支持 x64 的 exe ,这题提供的 exe 刚好是 x64(出题人的恶意*1)。
反编译出来的代码完美还原了符号信息,还原了出题人的恶意*2:令人误解的符号。代码里充满了像nullptr
、inullptr
、$unidentified_flying_object
之类的变量名,名叫$runtime_newproc
、实际上是GetExitCode Process
的函数,以及getanimelist
、getanimedescription
、findbestanimefrommyanimelist
这种让我真的怀疑这程序是不是真的访问了MyAnimeList.net(我甚至真的去 MyAnimeList看了下bestanime。
通讯协议
为了下边解释方便,这里直接把进程间的通讯协议贴上来。
下文的整理以anime.exe
为主视角,其中signal0
表示anime.exe
接收到的信息类型 0,respond0
表示anime.exe
对signal0
的回应消息。各字段默认 4 字节,非 4 字节的会用@size
标注。
1 | signal0 { |
工作流程
整体的流程可以以如下类 Python 代码概括:
1 | flag = input() |
1. 准备
首先task
两次弹窗,获取用户输入。这两次弹窗点“No/Cancel”的话,会弹Youtube,具体是啥可以自己去看看(出题人的恶意*3。
然后task
会将用户输入转换为字节数组,并将用户输入随机填充到长度为 110为止(所以如果运气足够好的话,你什么都不输,程序也会弹窗正确)。
启动anime
子进程。anime
子进程会将父进程也就是task
的pid
保存,用于后续读取task
的内存。
伪代码抄写:
1 | task.input = input('Please enter the flag').ljust(110, random) |
2. 发送Flag
signal1
: task
将用户输入字符串的一个字节发送给anime
,anime
将其保存到全局变量string input
。
伪代码抄写:
1 | idx_input = 0 |
3. 查找资源地址
signal3
: anime
查找task
的资源subprocess
的地址和大小。
当然,这里的地址属于task
的内存空间。
task.exe
有个名为RCData/Scr1pt
的资源(resource),这个资源以某种数据结构保存了三个加密的exe
。下面是dump资源并解密的脚本。
1 | from Crypto.Cipher import AES |
Scr1pt
中实际保存了3个exe,用到的只有id
为0xfff01870
一个,我们把它称为subproc.exe
。
那么其他两个是什么呢?一个是同样使用autoit
编写的假flag(出题人的恶意*4:
1 | Func compute() |
另一个只执行system('cmd')
。
伪代码抄写:
1 | anime.subproc.addr = addr |
4. 启动子进程
task
启动两个子进程,启动时设置了CREATE_SUSPENDED
,进程创建后处于挂起(suspended)的状态,在调用ResumeThread
之前子进程都不会真正执行代码。
狡猾的出题人在源码里写的并不是CREATE_SUSPENDED
,而是直接写了魔数4
。
1 | $runtime_newgoroutine("", "C:\Windows\System32\" & getanimepath(), 0, 0, 0, 4, 0, 0, $tstartup, $tprocess) |
5. 替换子进程
两次signal4
,依次将两个子进程的pid
和tid
、子进程的父进程的pid
和tid
(这里子进程的父进程就是task
)、第3步中查询到的subproc.exe
的地址address
和大小size
、以及最重要的data
发送给anime
,初次两个子进程的data
固定为0
和3
,之后的data
如何确定会在之后说明。
anime
会通过调用ReadProcessMemory
从task
读取加密的subprocess.exe
,解密后映射到子进程的内存空间(解密过程的抄写见上方),实现对进程主模块的替换。
最后是对重要的data
的处理。anime
会把data
保存下来,用于后面创建二叉树。然后在data
中加入flag
信息后写入子进程的PEB->BeingDebugged
(出题人的恶意,调试器的反反调试插件会把PEB->BeingDebugged
覆盖为0
)。
为了避免混淆,把保存在anime
中的data
称为data_anime
,处理后保存在subProcess
中的称为data_subproc
。处理过程和易懂的抄写如下。
其中depth
是子进程到task
进程的深度。显然,data
和data_anime
的低两位是完全等价的。
伪代码抄写:
1 | data_anime = 0b{data.byte1, data.byte0} = data |
6. Resume
task
调用ResumeThread
,不过此时子进程主模块已经被替换成subproc.exe
了。
task
等待两个子进程退出。
6.5 subproc
里的假flag
subproc
首先会向stdout
写假flag。subproc
并没有terminal
,正常运行的时候并不会看到这个假flag。
7. 子进程深度判断
signal2
: subproc
发送signal2
,anime
会判断该进程与task
进程之间的父子关系深度是否大于8。
如果深度已经达到8,则subproc
会跳过下面的启动子进程过程,直接跳到第9步
1 | if subproc.depth > 8: |
8. 子进程的子进程
subproc
再来一次步骤[3](#3. 查找资源地址)、[4](#4. 启动子进程)、[5](#5. 替换子进程)、[6](#6. Resume),区别在于行为主体从task
变成了subproc
,而且第5步中的data
不再是固定的0
和3
,而是由以下逻辑决定:
显然这段代码是个switch
结构,四个主要分支的最明显的区别是esi
和r8d
的值不同(忽略其他几个寄存器,动态调试调起来的话就明白其他寄存器的两个取值无关紧要),没错,esi
和r8d
就是subproc
的两个子进程的data
。
1 | switch subproc.data_subproc >> 1: |
现在已经知道data
,那么subproc
的两个子进程subsubproc
对应的data_anime
和data_subproc
是多少呢,请自行推算。
于是每个子进程都会启动两个孙进程,直到进程深度达到8为止,于是该题实现了在程序父子关系上模拟二叉树。
9. 树的构造
当子进程查询到自己的深度达到8,或者子进程的两个子进程都退出时,subproc
会发送signal6
,然后退出。
anime
接收到signal6
后,会将该进程到task
进程父子关系间的所有进程插入二叉树。如果data_anime
低位为0,则插入左子树,否则右子树。
1 | while curr_proc.pid != task.pid: |
10. 树的check
待到task
的两个子进程都退出,也就是说所有的subproc
都退出,task
发送signal7
,anime
重要开始对树进行check。
首先anime
会对树进行遍历:
显然,我看不出来这是什么遍历,构造数据测试后发现这段是树的层级遍历。遍历时将节点的data_anime
处理后转换为字符追加到字符串from_user_input
末尾,易知这里的计算是取data_anime
的低第二位,因为只有 1 byte,所以转换后的字符串完全由0
和1
组成。
最后丢弃字符串from_user_input
的首位,也就是辈份最高的进程task
对应的节点。
然后anime
读取task
内存空间中的一个图片资源,然后进行一系列无需深究的解码后得到数组from_png_data
。
最后终于到了激动人心的 check 环节:
sub_7FF78DFF4A20
在干什么并不重要(检查是否为素数),这段代码在筛选from_png_data
中的元素(注意此处的idx是全局变量),取其第位与from_user_input
的元素异或'0'
后进行比较。最后取决于比较是否都相同,anime
会向task
返回一个int
值,相同则是0
;不同则是rand() % 1336 +1
,总是是个非0值。
1 | from_user_input = '' |
11. continue
复杂吗,这样复杂的过程还要再来 109 遍,task
从 2 开始,向anime
传送用户输入字符串的第 2 位,展开第二个二叉树……
task
会把每次anime
在第10步返回回来的result
累加,最终结果是 0 则校验正确。
校验逻辑的理解与逆
了解了程序整体流程后,我们发现解题的关键在于from_user_input
,也就是说关键在于data_anime.byte1
以及树的结构。下面我们回顾第5步、第8步、第9步和第10步,首先把这三步伪代码都贴过来:
1 | # step 5 |
第8步似乎还能优化:
1 | # step 8 |
加上第5步的信息,我们知道data_subproc.byte1
就是anime.input[-1].byte(depth)
,于是
1 | d = subproc.data_subproc |
加上第9步的信息,我们知道data_anime.byte1
决定了节点是左子树或右子树:
1 | d = subproc.data_subproc |
消去第10步中的data_anime
:
1 | from_user_input = '' |
综上,最后整理两个子节点最后对应的from_user_data
子串:
1 | c0 = anime.input[-1].byte(depth) |
不得了,把他的逻辑逆过来就是:
1 | if from_user_data_substr_childs == '01': |
Solve.py
1 | with open('./MEM_00000254F356D000_022E3000.mem', 'rb') as f: |
其中MEM_00000254F356D000_022E3000.mem
是 dump 下来的from_png_data
运行脚本就可以得到flag
:
1 | zer0pts{me$s4g3_p4$$1ng_thru|p1pez_4r3_gr347!83157e16e25aa73f8a1fa1af06ac897a0f47d5be53ebb601953a43b6b3b4ca49} |
总结
这题主要的难度在于调试。task
使用autoit
编写,这个语言并没有设计调试器,只能用MsgBox
实现断点;anime.exe
是无调试符号的C++
程序,一些对象的类型和操作靠动态调试观察比较方便,而且用户代码中的所有Winapi
都使用CallObfuscator
包装,静态分析时看不到调用了什么api
;subproc.exe
是go
语言编写,我个人感觉go
语言动态调试更容易理解。
然而本题目严重依赖进程关系,可以调试器直接启动的只有task.exe
,后续进程只能靠attach
。调试task
和anime
的通信需要 4个调试器(两个用来调试subproc
),最后构造树、观察树的遍历时更是用到了 6个调试器。
最后吐槽一下这题的二次元浓度:二次元浓度太少了,看到findbestanimefrommyanimelist
,我本以为后续会出现什么奇怪的日本动画片,结果没有;我本以为flag
里会有点二次元要素,结果没有;我以为第10步中使用到的图片该有点二次元要素,结果是个风景照片。二次元浓度太少了!