#11101·dvc

`dvc pull` can materialize a tracked output through an intermediate symlink

Author: N0zoM1z0Created Sep 12, 2026Updated Sep 12, 2026

Bug Report

Summary

DVC 3.67.1 accepts a relative output path as being inside the repository, but does not enforce the physical destination at the materialization boundary. If the Git checkout contains an intermediate symlink, dvc pull follows it while writing the fetched object.

Two end-to-end effects are reproducible:

  1. outlink -> ../outside with a DVC output declared as outlink/payload.bin writes a fetched object outside the clone root.
  2. hookdir -> .git/hooks with a DVC output declared as hookdir/pre-commit installs an executable Git hook. The next ordinary git commit executes it.

The Git tree, DVC descriptor, and remote object are all valid. No --force option or pre-existing target file is required.

Affected source

The primary validation target is DVC 3.67.1, source commit 356dfa03278058b02df42124f243c2c345329dae. The same three traces were also replayed against DVC main commit 56e59829512ff134aa269099a2099587b810b4dd, but the stable release is the report basis (main source tree at that check).

The relevant source path is:

The operating system resolves the intermediate symlink for the parent directory, temporary file, and final rename. No physical-root or protected .git check is made at that point.

Reproduction

From the directory containing this report:

bash
python -m pip install 'dvc==3.67.1'
DVC_BIN=dvc attachments/reproduce.sh attachments/replay

The script uses only local Git repositories and local DVC remotes, so no network service or credentials are required. It refuses to overwrite an existing output directory.

See attachments/evidence.md and attachments/run.log.

Expected behavior

Before DVC writes an output, it should verify the canonical destination and reject any output whose physical path escapes the project root or enters protected metadata such as .git. The same invariant must hold for temporary files and the final rename, not only for the descriptor's relative spelling.

Observed behavior

The clean control materializes a regular file inside the clone. The attack traces produce:

control=clean
escape=symlink target=clone/outlink/payload.bin
resolved=outside/payload.bin
outside_content=DVC-EXTERNAL-WRITE
attack=symlink target=clone/hookdir/pre-commit
resolved=clone/.git/hooks/pre-commit
mode=-rwxr--r-- 744
marker=DVC_RELEASE_HOOK_RAN

The hook marker is absent before the commit and present afterward. This is an independent downstream oracle: it does not rely on DVC claiming that the destination was outside the project.

Security impact and prerequisites

An attacker who controls a Git repository and its DVC metadata can cause a user or CI job that runs dvc pull to write into a path selected by the repository. The hook variant turns that write into code execution in the user or CI context when a later Git command invokes the hook. The external variant demonstrates a direct project-root confinement failure.

The workflow requires an explicit DVC materialization step. git clone alone does not execute the hook, and this report does not claim host compromise. The impact is strongest where CI pulls data from an untrusted repository and then runs normal Git lifecycle commands in the same clone.

Related history and novelty

The closest DVC records are related but do not describe this complete chain:

  • #10055 discusses pulling through a symlink path failing;
  • #8345 discusses pulling to a symlink outside the workspace;
  • #6149 discusses a symlinked .dvc directory; and
  • #3920 discusses external workspaces and isolation.

I did not find a report of a Git-tracked intermediate symlink combined with a relative DVC output, a fetched cache object, and a write into .git/hooks or an external physical root. The DVC security page also has no matching published advisory at the time of writing: treeverse/dvc/security.

Suggested remediation

At the final checkout/materialization boundary, resolve the destination using the host filesystem and enforce both of these conditions:

canonical_destination is inside canonical_project_root
canonical_destination is not inside protected metadata

Reject symlinked intermediate components where the check cannot be made reliably. Apply the same policy to temporary files and os.replace, and add regression tests for an external sibling, .git/hooks, existing symlink targets, and clean-control materialization.

Attachments

attachments.zip