NEPCTF2025-pwn
| 0 | 前言
今年的nep
和往年比简单不少,但对我来说还是很有挑战的 QwQ
之前的半个月因为学iot
和kernel
导致我对二进制安全有点提不起兴趣,但是nep一下把我的状态打满了 :)
本次的成绩为4/5,差一点ak了
| 1 | 题目解析
0x00.Time
1 | unsigned __int64 input_my_name() |
1 | __int64 input_filename() |
向全局变量出入一个100字节的name
,然后列出根目录下的文件
检查输入的文件名,如果是flag
就要重新输入(注意此时flag
被输入了filename
)
然后开启一个子线程运行work
函数
1 | unsigned __int64 __fastcall work(void *a1) |
先计算filename
的md5
,然后如果文件存在,就把文件读入内存,并打印name
很明显这里存在着非栈上格式化字符串漏洞,通过%p/%x
可以将读入内存的文件内容dump
出来
现在我们需要的是打开flag
文件,可是input_filename
有过滤
线程相关
线程与进程关系
进程(Process) 是资源的集合(有自己独立的内存空间、文件描述符、栈等)
线程(Thread) 是进程中的一个执行流(共享内存、文件描述符,但有自己栈、寄存器等)。
线程是”轻量级”的进程,多个线程共享一个进程的大部分资源。
线程竞争
线程竞争(race condition)是指多个线程同时访问共享资源,并试图对其进行读写操作时,由于访问时序不确定,导致程序执行结果不一致或错误的现象
通常产生于
- 多线程环境中共享资源未加锁(或锁不当)
- 写操作与写操作、写操作与读操作同时发生
- 时序不可控,调度由内核或调度器决定
本题中,work
线程和main
进程同时访问了filename
,分别为读入与打开
同时由于work
进行了一次md5
计算,打开时刻会靠后
我们只需要在输入一个一个文件名后立马输入第二个文件名flag
就有可能实现在open
前替换filename
为flag
然后通过格式化字符串将flag
打印
EXP
1 | from pwn import * |
解码flag
1 | def hex_to_string(hex_list): |
0x01smallbox
程序逻辑很简单
1 | int __fastcall main(int argc, const char **argv, const char **envp) |
先fork
,子进程进入死循环,父进程执行沙箱后任意代码执行
其中沙箱
1 | line CODE JT JF K |
只允许父进程进行ptrace
调用
ptrace介绍
1 |
|
参数 | 含义 |
---|---|
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 模式) |
我们使用的gdb
就是依赖于ptrace
调用,我们在gdb
中可以对被调试的进程进行内存写入
,内存查看
,寄存器修改
等等操作,几乎可以控制子进程的所有行为
回到题目,我们看到子进程在fork
后立马进入了while(1)
,并没有开启沙箱,所以思路很简单:父进程附加到子进程上,将shellcode
注入子进程,后修改子进程的RIP
修改为0xDEADC0DE000
,最后脱离子进程,子进程便开始执行父进程注入的shellcode
EXP
1 | from pwn import * |
我们可以从栈上获得子进程的进程号,然后attach
上去,注意attach
需要相对比较长的时间
通常情况下可以使用wait
调用等到附加成功,但本题只能调用ptrace
,我们可以写一个loop
强行等待到attach
成功
然后就是常规的将shellcode
写入子进程,最后PTRACE_DETACH
脱离子进程
0x03ASTRAY
自定义的菜单题,实现了一个可以以usr
和master
身份管理一个记事本的系统
1 | int __fastcall __noreturn main(int argc, const char **argv, const char **envp) |
其中init
中实现了对内容堆块的申请,初始化,并为每个堆块标记了权限
其中operate_chunk
的结构为
1 | 00000000 usr_chunk struc ; (sizeof=0x18, mappedto_8) |
1 | __int64 init() |
chunk[0]
被标记为0x10
,chunk[1~9]
为3
,其余为2
1 | ssize_t user_operation() |
在usr
操作中,可以进行的子操作有usr_write
和usr_read
其中检查函数
1 | __int64 __fastcall check(unsigned int Id, const char *s, int a3) |
对usr_read
无检查,usr_write
会通过&操作
检查权限是否符合3&1=1/2&1=0
所以usr_read
只能写chunk[1~9]
然后根据操作字符串,映射对应的操作码
1 | int __fastcall permission_confirm(__int64 a1, const char *a2) |
将造作字符串转化为对应的操作码
1 | ssize_t __fastcall manager_operation(__int64 a1) |
manager_operation
中提供了两类操作,分别是manager_write/read
与manager_write_usr/manager_read_usr
check
复用了usr
中的check
,仍然是对read
操作无检查
对manager_write
进行了堆块权限的检查
对manager_operate_usr
是根据进行的最后一次usr
操作的chunk
确定,还会额外调用checkvisit
进行检查
1 | _BOOL8 __fastcall checkvisit(int a1) |
要求进行操作的堆块进行的最后一次usr
操作的操作码不能是4
(正常来说,必须是能在usr
态编辑的chunk
才能保证操作码不为4
)
由于这个系统对read
的检查不够严格,我们可以随便read
,其中最特殊的chunk
便是chunk[0]
,拥有最特殊的权限0x10
,
chunk[0]中也包含着堆地址
与PIE偏移
我们先把exp.py
的自定义函数写好
1 | from pwn import * |
先是manager_read(0)
,获得两个地址
1 | man_read(0) |
在manager_write_usr
中,操作的堆地址便是从*((void* )(chunk[0])+1)
中获得
如果我们可以控制chunk[0]
,便可以对任意地址进行单次0xff
大小的写入
但是想要操作chunk[0]
,使用usr_write
或manager_write
是不行的,因为会有严格的权限检查
使用manager_write_usr
,虽然不会进行权限检查,但是要求被操作的堆块在usr
态被标记了非read
操作
但是我们又无法使用usr_write
为chunk[0]
打上write
操作码1
check
检查最大的问题就是,完全没有考虑以usr
态输入manager
相关操作字符串会怎么样?
首先是可以通过check
检查,因为如果以usr态
进入且操作字符串不为usr_write
就会返回1
在permission_confirm
中,并不会检查身份,即使以usr身份
进行manager操作
,也会返回合法的操作码
也就是如果我们以usr身份
对chunk[0]
进行manager操作
,不光不会报错,还可以为chunk[0]
添加一个非4
的操作码,这样我们就可以用manager_write_usr
操作chunk[0]
了
1 | def get_right(): |
1 | usr_write(10,b'a'*0x50) |
然后我们可以对任意地址进行写,但是我们只有一次机会,因为把chunk[0]
修改后便无法再次直接使用manager_write_usr
操作chunk[0]
我们无法使用manager_write
操作chunk[0]
是因为chunk[0]
的权限太特殊了,是0x10
如果我们把chunk[0]
的权限修改为0xf
,那么manager_write
便可以直接操作chunk[0]
:配合manager_write_usr
可以实现任意地址写;配合manager_read
可以实现任意地址读
1 | man_write_usr(0,p64(0)+p64(heap+0x22d0)+p64(head+0x18)+p64(head+0x20)+p64(head+0x28)+p64(pie+0x4068)) |
对got
表进行读,获得libc_base
,从而获得environ
地址
1 | man_write(0,p64(0)+p64(heap+0x22d0)+p64(head+0x18)+p64(head+0x20)+p64(head+0x28)+p64(pie+0x4020)) |
对environ
进行读,获得stack
地址
1 | stack=base+libc.sym.environ |
对manager_operat
的返回地址进行写,通过ROP
进行getshell
1 | man_write(0,p64(0)+p64(heap+0x22d0)+p64(head+0x18)+p64(head+0x20)+p64(head+0x28)+p64(stack-0x150)) |
实际操作中,不知道为什么远程的got表中地址和本地不一致,无法计算libc
,转而使用IO指针
,才得到libc_base
EXP
1 | from pwn import * |
0x04canutrytry
依旧菜单题
1 | void __fastcall __noreturn main(void *a1, char **a2, char **a3) |
init
中把flag
读入bss
中,并开启沙箱,白名单留下open,read,write
1 | void init() |
IDA
的反编译没有把异常处理部分展示出
menu
处的异常处理为
1 | .text:0000000000401F19 ; catch(char const*) // owned by 401ED4 |
visit
与leave
的异常处理部分
1 | .text:0000000000401F7B ; catch(char const*) // owned by 401EEA |
其中visit
用于创造堆块,编辑堆块,但是create
把输入大小与malloc
分为两个单独的步骤
且size
会检查整形溢出,edit
不会检查下标
1 | unsigned __int64 visit() |
在leave
中,允许我们将一个chunk
的内容复制到leave
的栈中,并通过size
检测是否栈溢出并抛出异常
c++异常处理
c++ 异常处理在抛出(throw
)异常后,执行两个主要步骤:
1.栈展开(stack unwinding)
- 一旦抛出异常,程序会开始逐级退栈,调用每个函数的析构函数来清理资源(RAII)
- 栈帧会被逐个销毁,直到找到能处理异常的
catch
语句 - 如果找不到,就会调用
std::terminate()
2.查找异常处理器(catch block)
- 异常对象的类型(如
std::runtime_error
)会被用来比对每一层的异常处理表(这些表通常是编译器生成的结构体或元数据) - 一旦匹配成功,就跳转到对应的
catch
块执行,继续正常执行
在 C++ 异常处理中,通过返回地址逆向查找异常处理器:
- 每个函数的返回地址(RIP)存在于栈帧中
- 栈展开时,会查看返回地址对应的代码段
- 根据地址查询异常表
.eh_frame
,决定:- 是否有处理器
- 是否要执行析构器
- 是否要跳到下一个栈帧
也就是说,触发异常时根据返回地址确定异常抛出于哪个try
代码块中,并查看处理器是否能处理此处异常
当我们在leave
中栈溢出覆盖返回地址,但不改变返回地址,我们就可以调用leave
相关异常处理函数
leave
相关异常处理函数会提供给我们一个libc
地址
同时我们覆盖rbp
,也可以实现栈迁移,由于leave
中
1 | if ( size_list[v5] > 16 ) |
中的exception
为栈上变量,由rbp
确定位置,我们还可以通过覆盖rbp
为bss
地址,在bss
上写入0x4031D7
(“stack overflow”的地址)
回到visit
的edit
部分,根据chunk_list
进行负向溢出,可以写bss
上存在的一个指针,同时要求对应的size_list
必须存在
但是size_list
为int数组
;chunk_list
为指针数组,二者单个元素的大小不同
如果我们能直接修改bss
上的stdout
指针,便可以在调用menu
时触发IO
将flag
打印出来
但是我发现当chunk_list+idx==stdout
时,size_list[idx]
为0
,且如果我们提前通过栈迁移将0x4031D7
写入此时的size_list[idx]
,程序会因为栈过低在某一步抛出异常
既然没法修改stdout
,我就先在bss
上寻找一开始残留的可用地址
1 | 00:0000│ 0x405000 —▸ 0x404dd8 ◂— 1 |
我发现
6b:0358│ 0x405358 (std::cin+216) —▸ 0x4052d0 (std::cin+80) ◂— 0
中存放了一个更低地址的指针,如果我们写这个指针,能反过来覆盖0x405358
中的内容,再次编辑便可以实现任意地址写,恰好我们得到了libc_base
,那直接修改IO
结构体便可以leak_flag
我尝试栈迁移将0x4031D7
写入对应的size_list[idx]
中,发现不会触发上次的问题
1 | from pwn import * |
栈迁移到bss
+获得libc_base
1 | add(0x200) |
此时我们编辑-70
,便可以将_IO_2_1stdout_
写入0x405210
1 | ch(1) |
成功写入
再次编辑-70
堆块,便是编辑IO
结构体
1 | ch(1) |
此时main
循环会回到menu
,在menu
中触发IO
流,打印出flag
EXP
1 | from pwn import * |