treeverse / treeverse/dvc

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

Open
#11,101 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
15.9k
Forks
1.3k
PR merge metrics
No merged PRs in 30d

Description

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:

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with dvc/output.py, especially Output.is_in_repo and output-path validation, then trace dvc/repo/checkout.py into the linked dvc-data checkout and dvc-objects local filesystem write paths. Run attachments/reproduce.sh via the documented replay command; done means materialization rejects physical paths outside the project or inside protected .git metadata, with regression coverage for the listed symlink cases and clean control.

Written by the indexing model from the issue text.

Assessment

Tech stack
git, python
Domain
devtools, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
50/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.