`dvc pull` can materialize a tracked output through an intermediate symlink
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:
outlink -> ../outsidewith a DVC output declared as
outlink/payload.binwrites a fetched object outside the clone root.hookdir -> .git/hookswith a DVC output declared as
hookdir/pre-commitinstalls an executable Git hook. The next ordinary
git commitexecutes 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:
dvc/output.py,Output.is_in_repo
rejects absolute declared paths and checksfs.isin, but does not establish
that the canonical filesystem destination stays inside the project.dvc/output.py, output-path validation
checks the declared spelling against DVC ignore rules. It sees
hookdir/pre-commit, not the path after resolvinghookdir.dvc/repo/checkout.py
filters outputs without.is_in_repoand then calls the data-index checkout
application.dvc-datacheckout
constructs the destination from the relative entry key and passes it to the
transfer layer.dvc-objectslocal filesystem write
creates a temporary file in the destination parent and usesos.replace.
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
.dvcdirectory; 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
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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