CVEReports
CVEReports

Automated vulnerability intelligence platform. Comprehensive reports for high-severity CVEs generated by AI.

Product

  • Home
  • Sitemap
  • RSS Feed

Company

  • About
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 CVEReports. All rights reserved.

Made with love by Amit Schendel & Alon Barad



CVE-2026-23986
7.10.04%

Copier's Xerox Glitch: Symlinking Your Way to RCE

Amit Schendel
Amit Schendel
Senior Security Researcher

Feb 15, 2026·5 min read·5 visits

PoC Available

Executive Summary (TL;DR)

Copier versions < 9.11.2 contain a directory traversal vulnerability when `_preserve_symlinks` is enabled. A malicious template can use directory symlinks to write files outside the intended project directory, leading to arbitrary file overwrite or RCE.

Template engines are supposed to be dumb pipes: text goes in, formatted text comes out. But when you mix the desire for 'convenience' with the chaos of filesystem symlinks, things get messy fast. Copier, a popular Python-based project scaffolder, decided that allowing templates to preserve symbolic links was a feature worth having. Unfortunately, they forgot that a symlink is effectively a portal to anywhere on your disk. This vulnerability (CVE-2026-23986) allows a malicious template author to break out of the destination sandbox and write files anywhere the user has write permissions. It turns a simple `copier update` command into a potential Remote Code Execution (RCE) vector, especially devastating in CI/CD pipelines running with elevated privileges.

The Hook: Trusting the Blueprint

We trust our tools too much. You run copier copy gh:author/cool-template . and expect it to just create a folder structure. You don't expect it to overwrite your ~/.ssh/authorized_keys. But that's exactly what's on the menu today.

Copier is designed to render project templates. It takes a source (local or git), asks you some questions, and spits out a project. The dangerous feature here is _preserve_symlinks: true. This setting tells Copier: 'If you see a symlink in the template, recreate it exactly as is in the destination.'

In a sane world, a template engine would sandbox its output. It would say, 'I shall only write to ./my-new-project/'. But because of how os.scandir works and a lack of canonical path validation, Copier inadvertently allowed templates to reach out and touch files they had no business touching.

The Flaw: The Symlink Shuffle

The vulnerability is a classic race against file system logic, compounded by Python's non-deterministic directory traversal. When Copier renders a template, it iterates through the template's file list. If _preserve_symlinks is on, it blindly copies links.

Here is the logic bomb: Imagine a template with a directory symlink named innocent_dir that points to /tmp/. Inside the template structure, there is also a file technically located at innocent_dir/payload.sh.

If Copier processes the symlink first, it creates ./destination/innocent_dir -> /tmp/. When it subsequently processes innocent_dir/payload.sh, it tries to write to ./destination/innocent_dir/payload.sh. The OS resolves the path, follows the link, and writes the file to /tmp/payload.sh.

Because os.scandir yields results in arbitrary order, this vulnerability is technically non-deterministic, but a clever attacker can structure the template to maximize the probability of the link being created before the file is written.

The Code: Diff or Die

The fix reveals the crime. The developers had to implement strict path canonicalization to stop this. They moved from trusting the path string to verifying the resolved physical path.

Here is the conceptual breakdown of the fix found in commit 41cb45c9649782150c01886bc52bee5eddb655b9:

The Vulnerable Logic (Pseudocode):

# Blindly trusting the path structure
if _preserve_symlinks and source_path.is_symlink():
    # Just copy the link, no questions asked
    shutil.copy(source_path, dest_path, follow_symlinks=False)

The Fix (Canonicalization):

# Resolve the ABSOLUTE path, stripping away all symlink tricks
resolved_dest = dest_path.resolve()
 
# The Check: Is the resolved path still inside our sandbox?
if not resolved_dest.is_relative_to(destination_root.resolve()):
    raise UnsafeTemplateError("Nice try, hacker.")

By forcing resolve(), Python chases down every symlink component in the path. If dest_path contains a symlink pointing to /etc/, resolve() will return /etc/..., and is_relative_to(destination_root) will return False, triggering the exception.

The Exploit: Escaping the Cage

Let's build a weaponized template. We want to write a file to /tmp/pwned to prove we can escape the project directory. We assume the user has configured Copier (or the template defaults) to _preserve_symlinks: true.

Step 1: The Setup We create a git repository with a malicious structure. We can't commit a symlink pointing to root easily on all platforms, but we can commit a relative symlink that traverses up.

mkdir malicious-template
cd malicious-template
# Create the bait config
echo '_preserve_symlinks: true' > copier.yaml
 
# Create a link pointing outside
ln -s ../../../../../tmp/ target_link
 
# Create the payload "inside" the link
# (We create the dir structure physically first to trick git, then swap it)
mkdir -p target_link
echo "#!/bin/bash\necho 'I own this box'" > target_link/pwn.sh

Step 2: The Execution When the victim runs: copier copy malicious-template ./my-project

Copier reads copier.yaml. It sees the symlink request. It creates my-project/target_link pointing to /tmp. It then processes the file target_link/pwn.sh. It writes to my-project/target_link/pwn.sh, which the OS translates to /tmp/pwn.sh.

Re-exploitation Notes: The patch relies heavily on pathlib.Path.resolve(). A researcher looking to bypass this would look for TOCTOU (Time-of-Check Time-of-Use) vulnerabilities. If you can swap a directory for a symlink after the check but before the write, you win. Given Python's GIL and file IO speed, this is hard but theoretically possible in high-concurrency environments.

The Impact: CI/CD Assassination

Why is a template engine vulnerability rated High (CVSS 7.1)? Context.

Developers don't run Copier in a vacuum. They run it on their workstations (access to SSH keys, cloud credentials) and, more critically, in CI/CD pipelines.

Imagine a scenario where a company uses a shared template for all microservices. An attacker compromises the template repository (or submits a PR to an open-source template). Every time a developer initializes a new service, or a CI job runs copier update to fetch the latest standards, the exploit triggers.

If the CI runner is executing as root (which, sadly, many do inside containers), the attacker could overwrite /usr/bin/git or inject into /etc/cron.d/. This isn't just file creation; it's persistence and lateral movement waiting to happen.

Official Patches

CopierGitHub Security Advisory GHSA-4fqp-r85r-hxqh

Fix Analysis (2)

Technical Appendix

CVSS Score
7.1/ 10
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H
EPSS Probability
0.04%
Top 89% most exploited

Affected Systems

Copier CLI < 9.11.2Python Applications embedding Copier

Affected Versions Detail

Product
Affected Versions
Fixed Version
Copier
Copier-org
< 9.11.29.11.2
AttributeDetail
CWE IDCWE-61
Attack VectorLocal (User-Assisted)
CVSS v3.17.1 (High)
CVSS VectorCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H
Exploit StatusPoC Available
EPSS Score0.00039

MITRE ATT&CK Mapping

T1222File and Directory Permissions Modification
Defense Evasion
T1059Command and Scripting Interpreter
Execution
CWE-61
Unix Symbolic Link (Symlink) Following

Known Exploits & Detection

GitHub Security AdvisoryOfficial advisory containing the PoC details

Vulnerability Timeline

Fix committed to main branch
2026-01-13
Version 9.11.2 Released
2026-01-20
Public Disclosure
2026-01-21

References & Sources

  • [1]Copier GitHub Repository
  • [2]CWE-61: UNIX Symbolic Link (Symlink) Following

Attack Flow Diagram

Press enter or space to select a node. You can then use the arrow keys to move the node around. Press delete to remove it and escape to cancel.
Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.