前言
有幸参加了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步中使用到的图片该有点二次元要素,结果是个风景照片。二次元浓度太少了!