`dvc pull` can materialize a tracked output through an intermediate symlink
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 asoutlink/payload.binwrites a fetched object outside the clone root.hookdir -> .git/hookswith a DVC output declared ashookdir/pre-commitinstalls an executable Git hook. The next ordinarygit 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_reporejects 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 seeshookdir/pre-commit, not the path after resolvinghookdir.dvc/repo/checkout.pyfilters 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/replayThe 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_RANThe 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 metadataReject 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
Source: treeverse/dvc