大杂烩
CTFZone matreshka
My friend from Russia brought me a doll, but there is something in it. Can you figure out what is this?
附件下载
运行程序提示输入flag,查看sub_6810
,有一个巨大的switch-case,那么应该是个虚拟机:
但是看每个case调用的函数,基本是一大坨,很难判断代码的作用。先找一下虚拟机指令的位置,在sub_12E0
中可以发现两段数据:unk_8040
和unk_81E0
,大小均已知。然后看看虚拟机是如何解析指令的,回到sub_6810
,逐句分析switch前的代码,发现对a1指针取值的数据类型不同,猜测是一个C++对象的指针:
1 | __int64 __fastcall sub_6810(__int64 a1) |
然后分析while (1)后面的代码,并把断点下在68A0
处验证,可以看出这些操作是从v5
这个指针指向的数据(位偏移量是v6
)中取出5个bit放入v1
。5bit可以对应32个值,而switch中有25个case,那这个v1
应该是操作码了。
然后我们分析每个case,先进入sub_55D0
,虽然说代码很多,但分析后可以看出都是些固定的内联函数,忽略它们以后其实也没那么复杂好吧,还是有点复杂的。函数接受一个虚拟机对象指针,并且从指令流中继续读取3个bit存入v4
,然后紧接着又是一个switch:
1 | char __fastcall sub_55D0(_QWORD *a1) |
3个bit对应8个case,每个case会继续从指令流中读取一些bit,先看看case 0:
1 | v38 = v4; |
看着代码多,实际就两次取指令数据,每次取4个bit,分别放到v38和v4里面。然后进行赋值,类似于x86的mov指令。然后把8个case都分析一下,虚拟机对象的数据结构和函数的功能就可以大致推测出来了:
1 | struct vector_u16 |
把它们放入local types,便于后面的分析。然后就是体力活了,就是相当纯粹的逆向,把20多个指令都逆一下,可以得到下面的指令对应表:
指令 | 操作 |
---|---|
00000 |
halt |
00001 000 x4 y4 |
mov reg:x, reg:y |
00001 001 x4 y16 |
mov reg:x, y |
00001 010 0 x4 y4 |
mov byte[reg:x], reg:y |
00001 010 1 x4 y4 |
mov [reg:x], reg:y |
00001 011 0 x4, y8 |
mov byte[reg:x], y |
00001 011 1 x4 y16 |
mov [reg:x], y |
00001 100 x4 y4 |
mov reg:x, byte[reg:y |
00001 101 x4 y4 |
mov reg:x, [reg:y |
00001 110 x4 y16 |
mov reg:x, byte[y |
00001 111 x4 y16 |
mov reg:x, [y |
00010 00 x4 y4 |
add reg:x, reg:y |
00010 01 x4 y16 |
add reg:x, y |
00010 10 1 x4 y4 |
add [reg:x], reg:y |
00010 10 0 x4 y4 |
add byte[reg:x], reg:y |
00010 11 1 x4 y16 |
add [reg:x], y |
00010 11 0 x4 y8 |
add byte[reg:x], y |
00011 00 x4 y4 |
sub reg:x, reg:y |
00011 01 x4 y16 |
sub reg:x, y |
00011 10 1 x4 y4 |
sub [reg:x], reg:y |
00011 10 0 x4 y4 |
sub byte[reg:x], reg:y |
00011 11 1 x4 y16 |
sub [reg:x], y |
00011 11 0 x4 y8 |
sub byte[reg:x], y |
00100 00 x4 y4 |
umul reg:x, reg:y |
00100 01 x4 y16 |
umul reg:x, y |
00100 10 1 x4 y4 |
umul [reg:x], reg:y |
00100 10 0 x4 y4 |
umul byte[reg:x], reg:y |
00100 11 1 x4 y16 |
umul [reg:x], y |
00100 11 0 x4 y8 |
umul byte[reg:x], y |
00101 00 x4 y4 |
udiv reg:x, reg:y |
00101 01 x4 y16 |
udiv reg:x, y |
00101 10 1 x4 y4 |
udiv [reg:x], reg:y |
00101 10 0 x4 y4 |
udiv byte[reg:x], reg:y |
00101 11 1 x4 y16 |
udiv [reg:x], y |
00101 11 0 x4 y8 |
udiv byte[reg:x], y |
00110 0 x4, y4 |
xor reg:x, reg:y |
00110 1 0 x4 y8 |
xor reg:x, y |
00110 1 1 x4 y16 |
xor reg:x, y |
00111 0 x4, y4 |
and reg:x, reg:y |
00111 1 0 x4 y8 |
and reg:x, y |
00111 1 1 x4 y16 |
and reg:x, y |
01000 0 x4, y4 |
or reg:x, reg:y |
01000 1 0 x4 y8 |
or reg:x, y |
01000 1 1 x4 y16 |
or reg:x, y |
01001 0 x4 y4 |
cmp reg:x, reg:y |
01001 1 x4, y16 |
cmp reg:x, y |
01010 0 x4 |
jmp reg:x |
01010 1 x16 |
jmp x |
01011 0 x4 |
jae reg:x |
01011 1 x16 |
jae x |
01100 0 x4 |
jbe reg:x |
01100 1 x16 |
jbe x |
01101 0 x4 |
ja reg:x |
01101 1 x16 |
ja x |
01110 0 x4 |
jb reg:x |
01110 1 x16 |
jb x |
01111 0 x4 |
push reg:x |
01111 1 x16 |
push x |
10000 x4 |
pop reg:x |
10001 |
syscall |
10010 0 x4 |
call reg:x |
10010 1 x16 |
call x |
10011 |
ret |
10100 0 x4 |
jnz reg:x |
10100 1 x16 |
jnz x |
10101 0 x4 |
jz reg:x |
10101 1 x16 |
jz x |
10110 |
enter |
10111 |
leave |
11000 |
nop |
11001 |
nop |
其中x4
代表x
是一个4位整数;reg:x
代表x号寄存器,如r0
;独立的x
代表立即数;[x]
代表地址x
处的值。syscall
看着吓人,实际就两个功能:读取输入缓冲区和写到输出缓冲区(其实还有一个分支,但没有被用到)。
在逆的过程中,一些寄存器的含义也能猜测出来:
寄存器 | 对应x86的寄存器 | 含义 |
---|---|---|
r11 |
rbp |
栈底指针 |
r12 |
rsp |
栈顶指针 |
r13 |
rip |
程序计数器 |
r14 |
rdx |
存放除法指令的余数 |
r15 |
rflags |
标志寄存器(0:ZF(op1==op2 ), 1:SF(op1<op2 )) |
我们可以写个脚本,把指令流转化成人类可读的格式:
1 | import bitstring |
输出数据简单处理一下,得到下面的:
1 | 000 jmp start |
可以看出,start
调用了sub_BA4
,而后者中有一行call r0
。经过分析发现这又是另一个虚拟机(虚拟机2),这就是题目“俄罗斯套娃”的由来吧。虚拟机1的内存0x3800
处存放了虚拟机2的jump table
;0x4000
处存放虚拟机2的寄存器;0x2000
存放虚拟机2的指令。把跳转表中的每个函数都看一遍,它们的对应功能如下:
指令 | 操作 |
---|---|
00 x8 y8 |
mov reg:x, reg:y |
01 x8 y16 |
mov reg:x, y |
02 x8 y8 |
mov byte[reg:x], reg:y |
03 x8 y8 |
mov reg:x, byte[reg:y] |
04 x8 y8 |
add reg:x, reg:y |
05 x8 y8 |
sub reg:x, reg:y (但好像没有用到) |
06 x8 y8 |
xor reg:x, reg:y |
07 x8 y8 |
cmp reg:x, reg:y |
08 x16 |
jnz x |
09 |
read r0, r1 (读取输入缓冲区到r0 指向的内存,长度为r1 ) |
0A |
write r0, r1 (输出缓冲区写出到r0 指向的内存,长度为r1 ) |
0B |
halt |
这个就不写脚本了,直接手逆吧:
1 | 2000 01 00 00 39 mov r0, 0x3900 |
把它们转换成伪代码的形式:
1 | write("Enter Flag: ") |
可以看出这是RC4加密,状态向量是v_3700
,flag的长度是87,加密后放在v_4800
,比较的内容是v_3915
。那我们写个脚本再逆回去就好了:
1 | flag = [0x07, 0x2B, 0xC4, 0x5F, 0x11, 0xB1, 0xDC, 0x15, 0x28, 0xB7, 0x62, 0x59, 0xED, 0x3F, 0xAD, 0x9E, 0x38, 0x70, 0xD8, 0x68, 0xCA, 0xD3, 0x36, 0x67, 0x09, 0xD7, 0x16, 0x94, 0x11, 0xB1, 0x14, 0xB3, 0x34, 0xF5, 0x48, 0xE9, 0x64, 0x19, 0x2D, 0xC3, 0xA7, 0xB7, 0xB4, 0x10, 0xFB, 0x5C, 0xDF, 0x55, 0x18, 0xB5, 0xF3, 0x54, 0xC7, 0x51, 0x5D, 0xB0, 0x71, 0x13, 0xFE, 0x1F, 0x68, 0x25, 0x46, 0x63, 0xFD, 0x2E, 0xAA, 0x83, 0xF9, 0x0A, 0x2B, 0x04, 0xB0, 0x42, 0x15, 0x7A, 0x8D, 0x85, 0x02, 0x91, 0xDB, 0x41, 0xF9, 0xB4, 0xA6, 0x44, 0xE9] |
附上逆向时的IDB文件:附件下载
ctfzone{7h15_15_8451c411y_p427_0f_2c4_w21773n_1n_vm_1n_vm_8u7_w17h_435_580x_h4h4_funny}
TCTF2019 Elements
附件下载
IDA载入程序,容易发现flag的格式为'flag{%012x-%012x-%012x}' % (a, b, c)
。然后$a$的值已知,要求$b$和$c$,约束条件是:
其中$T_1$,$T_2$是已知量,$d$满足:
然而我用z3写的脚本不太能解出来,于是尝试用别的办法。注意到$a$的值已知,而两个方程都是齐次的,于是可以先把$a$除掉,再来解二元方程组:
这样所有数量级就被处理掉了,常数都转化为了正常大小的小数。然后这个方程组手解可能挺麻烦的,好在我们可以用Walfram Alpha求它的解析解,进入网站后输入:
1 | solve sqrt(x^2-(1+x^2-y^2)^2/4)/(1+x+y)=a,xy/sqrt(4x^2-(1+x^2-y^2)^2)=b |
得到它的四组解析解。其中有两组复数解不管,另外两组只是$x$、$y$互换,选$x < y$的那组即可:
代入$T_1’$和$T_2’$的值,即可解出原来的$x$和$y$。把它们乘以$a$,可以求出$b$和$c$。但求出后我们不能直接得到flag,原因是程序有double数到格式串的转化过程,我们要按照它的方式生成格式串。查看关键代码:
1 | v17 = (__m128i)_mm_sub_pd( |
num
就是读入的a
,b
,c
。经分析,此过程实际上把它们转换成double
型的变量。这几句代码可能是编译器对uint64_t
到double
转换的内联代码。于是我们求出的值直接四舍五入然后转成12位十六进制数就行了:
1 | def q2d(val): |
flag{391BC2164F0A-4064E4798769-56E0DE138176}
TCTF2019 fixed_point
附件下载
程序接受一个格式为flag{长度为32的小写hex字符串}
的flag。随后程序把它换成flag{hex表示的16字节二进制数据}
,并求出它的CRC128,如果求出的值与原flag值相等,则校验通过。python代码表示为:
1 | assert flag.startswith('flag{') and flag.endswith('}') |
我一开始想到的是Z3求解,因为之前写过一个已知CRC32枚举原字符串的题,那个用Z3确实能胜任。于是我故技重施,写出了如下的代码:
1 | import z3 |
结果不太乐观,脚本挂了一天一夜也没能跑出来,那么只能尝试别的方法了。然后我又试了下面的:
1 | import z3 |
还是不行。就这么挣扎了几天,最后只能去网上找WP,找到了这个。按照上面所说的CRC的性质,构造一个CRC128矩阵,然后解128个异或方程。但是我用Z3好像还是解不出来,也不知道为啥,于是我用传统的高斯消元法弄了一下,最后得到了flag。
1 | from binascii import hexlify |
这个题我感觉还是很有用的,学到了这样一个性质(x
和y
是同长度的位向量):
1 | CRC(x ^ y) = CRC(x) ^ CRC(y) |
用这个我们就可以实现文件CRC自校验了。还有,我们利用这一性质可以生成Zip Quine,就是这篇文章中提到的,简单的来说,就是一个Zip文件,它在解压后的结果是它自身,也就是无论我们解压这个文件多少次,最终解压结果都和源文件没有区别,就像这个附件:
附件下载
flag{670344c379b7f7fa4555a50fbabaefa4}
第五空间 Universal
wherever && universal
附件下载
这个附件没有后缀,我一开始以为这是个ELF文件,但DiE扫描结果显示这是PE文件?!!我赶紧用记事本打开代码,确实是MZ头,但是——
感觉DOS头有点奇怪,而且为什么PE文件会有shell代码?继续往下翻,看到一个网址:
访问这个网址,里面介绍这是一个APE文件,即在Windows上能以PE文件运行,在Linux上能以Shell脚本文件运行。这个设计非常巧妙,它忽略了原来的DOS头,并让它被Linux解释成赋值语句,这样两种平台都能正确处理文件,运行高效的Native代码而不是脚本。用IDA载入程序,能显示函数,但IDA没有正确显示参数列表。查看对应的汇编,发现这个调用约定是Linux的,也就是通过rdi
,rsi
,rdx
,rcx
,r8
,r9
传递参数。而IDA不知道这一点,于是参数解析就乱套了。在网上找了一圈,没能找到怎么修改IDA的解析方式,那我们只能先从Linux版本入手了。
Linux中输入sh Challenge
运行这个程序,发现没有任何结果,也没有错误代码。但是我们可以按Shell脚本的方式修改这个文件,比如把它的第二句改成sleep 10
(但是要注意用十六进制编辑器改,否则数据就错位了),发现程序确实暂停了10秒。观察这个脚本,发现程序如果没有检测到ape
命令,就把自己的ape
文件提取到临时目录,然后运行这个文件,以程序自身和其余参数为参数执行。我们可以先把Shell脚本修改一下,使之能把ape
提取到我们已知的目录,最终我们得到了一个8KB的文件。文件下载
那我们直接运行这个文件,以Challenge
为参数,没有错误,但不知道是否运行成功。用IDA查看里面仅有的几个函数,没发现提取ELF的,那应该就是内存加载了。给这个程序下断点,从IDA远程运行,按F8执行程序直到rip
指向的地址发生突变(比如跳到另一个内存段),可以看到最后rip
指向0x200C9D处的retn
。随后程序就跳到了0x4023C2
处。Ctrl+S查看段名,正是我们要的Challenge。
然后我们就可以构造一个能让IDA分析的ELF了。把这些段抠出来,然后写了empty.c
:
1 |
|
这个程序不需要写main
函数,因为我们稍后手动指定程序的入口点。运行下面的命令,成功制作出可以运行的ELF文件:
1 | gcc empty.c -c -o chall.o && ld -static -e 0x4023C2 -o chall chall.o && rm chall.o |
这时比赛已经结束了,在这之前,@zsky大佬通过动态调试PE发现flag是通过命令行的方式输入的,而且输入正确会显示Good Job
,关键函数是sub_402747
。我们直接利用这一结论,查看此函数,与之前直接分析PE相比,这次IDA能正常识别函数参数了,那么ELF的制作应该是成功了!
这个文件不能替代原始文件,因为它无法感知输入命令行的参数。但是我们可以静态分析这个程序,定位到关键函数,稍微处理一下符号名:
1 | void __fastcall checkflag(int argc, const char **argv) |
然后就可以写出对应的脚本:
1 | from binascii import unhexlify |
加密分为两个阶段:首先把flag切分为4组,每组两个32位整数,把它们分别进行费斯妥加密(大概)。然后再进行两轮按字节循环加密学术名字叫什么我也不知道。然而,我在后面解密的时候发现,第二轮加密是不可逆的???好在枚举并不复杂,约束条件也比较好找,只要保证最后是ascii字符就可以了。
S3hr0din7er_3_Uni4er3al_D00rs111