Repository navigation
Description
Summary
Every dincli command that fetches a model-owner-supplied service file or artifact builds the destination path by joining a trusted local base directory with a raw path string taken directly from the model's manifest JSON, then passes it to ensure_file_exists, which creates parent directories and writes the fetched content with no containment check. Since manifest content is never validated on-chain, a malicious model owner can make any client/auditor/aggregator who interacts with their model overwrite an arbitrary file on their own machine.
Where
dincli/cli/context.py:490-540 (ensure_file_exists):
file_path.parent.mkdir(parents=True, exist_ok=True)
retrieve_from_ipfs(ipfs_cid, file_path)No path validation anywhere in this function.
Vulnerable call sites (all build base_dir / Path(manifest["path"]) or equivalent, verified against develop @ 740a613):
dincli/cli/context.py~L316 — task-contract ABI artifact path (task_contracts.<key>.artifact.path) — pure file-write, no code execution needed to triggerdincli/cli/client.py~L129-134 (train-lms)dincli/cli/aggregator.py~L336-341, ~L557-562dincli/cli/auditor.py~L450-462dincli/cli/modelownerd/{gi,aggregation,auditor_batches,model}.py— model-owner-side equivalents
The only path-side defense found is dincli/services/ipfs.py's _normalize_path:
def _normalize_path(path: str | Path) -> Path:
safe_path = Path(path).expanduser().resolve()
dangerous_roots = (Path("/etc"), Path("/boot"), Path("/dev"), Path("/proc"))
if any(str(safe_path).startswith(str(root)) for root in dangerous_roots):
logger.warning(f"Reading or writing through a sensitive system path: {safe_path}")
return safe_pathThis only logs a warning for four hardcoded system directories and never blocks the write or checks containment against the trusted base directory — everything else (~/.ssh/authorized_keys, ~/.bashrc, crontabs, any other user-writable path) passes through silently. Python's Path.__truediv__ also discards the base directory entirely if the manifest's path is an absolute string:
>>> Path('/home/victim/.cache/dincli/model_5') / Path('/home/victim/.ssh/authorized_keys')
PosixPath('/home/victim/.ssh/authorized_keys')
foundry/src/DINModelRegistry.sol's requestModelRegistration/approveModel only validate slasher status and task-contract ownership — the manifest's bytes32 CID is stored and never dereferenced/validated on-chain, so manifest content (every path field included) is entirely attacker-controlled by whoever owns that model.
This bypasses, rather than overlaps with, the one sandboxing control the codebase already has for model-owner-supplied content: dincli/cli/worker.py runs custom service code inside a Docker container. But the vulnerable ensure_file_exists downloads happen in the host CLI process, before run_worker_container is ever invoked — so this write lands on the bare host regardless of that mitigation.
Exploit
- Attacker deploys their own
DINTaskCoordinator/DINTaskAuditor, gets them authorized as slashers (the normal model-owner flow), registers and gets approved — none of this inspects manifest content. - Attacker publishes a manifest containing, e.g.:
{ "type": "custom", "train_client_model": { "path": "/home/victim/.ssh/authorized_keys", "ipfs": "<CID of attacker's SSH public key>" } } - A validator runs the documented command:
dincli client train-lms <attacker_model_id>. client_service_path = model_base_dir / Path(manifest["path"])resolves to/home/victim/.ssh/authorized_keys, discarding the cache base dir.ensure_file_existscreates~/.sshif needed, downloads, and silently overwritesauthorized_keyswith the attacker's key — persistent SSH access to the victim's machine, independent of any later Docker-sandboxed execution.
The same pattern applies via the aggregator.py/auditor.py/modelownerd/*.py call sites for their respective roles.
Recommendation
Resolve the manifest-supplied path against the trusted base directory and reject (don't silently re-anchor) anything not contained within it, e.g.:
resolved = (base_dir / manifest_path).resolve()
if not resolved.is_relative_to(base_dir.resolve()):
raise ValueError(f"manifest path escapes base directory: {manifest_path}")Apply this once, centrally, inside ensure_file_exists (or a shared path-construction helper every call site is required to go through) rather than patching each of the ~8 call sites individually.
Activity
umeradl commentedon Oct 6, 2026
Status update: confirmed; scheduled as task_061026_22 Part B
Checked against
develop@a1fcce2. The finding holds:DinContext.ensure_file_exists(dincli/cli/context.py:490) creates parent directories and writes whatever IPFS returns to the path it's given, with no containment check.<base_dir> / Path(manifest[...]["path"]):client.py:129-130aggregator.py:336-337,:557-558auditor.py:450-453modelownerd/aggregation.py:399-400,modelownerd/auditor_batches.py:144modelownerd/model.py:103-104,:219-220context.py:316(model_base_path / artifact_rel_path)pathreplaces the base directory outright, and..segments walk out of it.dincli/services/ipfs.py:81_normalize_pathonly logs a warning for four system roots, and never blocks the write.Can be done now: yes. It goes in task_061026_22 as Part B, assigned to you. Notes for the fix:
resolve_manifest_path(base_dir, manifest_path) -> Path. It rejects absolute paths and anything whoseresolve()isn't insidebase_dir.resolve()(Path.is_relative_to), raising rather than silently re-anchoring.ensure_file_existsalso checks containment, as a backstop for future call sites.monkeypatch/CliRunnerstyle covering an absolute path,../traversal, a symlink escape, and a normal relative path that still works, for at least one call site per role plus the artifact path.Coordination: task-plan-051026-2 (PR No. 229) TP-3 also edits
auditor.pyandcontext.pyfor new commands. Whichever lands second rebases. Full spec: task_061026_22 (linked from the tracking discussion once it's open).docs(tasks): add task_061026_22 — DinEmission.fundGI registry check (#…
fix(dincli): reject manifest paths that escape their base directory (#…
umeradl commentedon Oct 8, 2026
Done by PR No. 237 (task_061026_22 Part B). It was merged into
developas0be3d4a, with a review fix in920c93e. Outcome comment; the develop push CI is green. I'm closing this by hand, because a merge intodevelopdoesn't trigger closing keywords (mainis the default branch).Checked on
origin/develop@920c93e:B1, shared helper:
resolve_manifest_pathis atdincli/cli/utils.py:46.pathoutright (:58).(base_dir / path).resolve()isn'tis_relative_to(base_dir.resolve())(:62-65).ManifestPathEscapesBaseError, naming the manifest key, and never re-anchors.resolve()follows symlinks, so a symlink inside the base that points outside is rejected. That's documented in the docstring and the PR.B2, every call site: all 19 manifest-
pathjoins now go through the helper:modelownerd/aggregation ×2, auditor_batches ×1 and model ×4context.py:319), which falls back to the bundled ABI on an escapemodelownerd/gi.py×2, a site the PR found with the suggestedgit grepsweepgit grep '\["path"\]' dinclileaves only:"service_path"entries. The host already validated those same strings, and the container reads them under a:romount.system.py:1418, which reads the bundled, repo-shippedcid_services.jsonrather than a manifest.B3, backstop:
DinContext.ensure_file_existschecks that the target resolves insidebase_dirbefore any IPFS fetch (context.py:539-542)._normalize_path's warning is left in place.Deviation: the PR's
CACHE_DIRdefault forbase_dirbroke the model owner's local task flow (create-genesis-model,submit-genesis-model,distribute-mnist).920c93emakesbase_dirrequired on bothensure_file_existsandload_custom_fn, scoped to the role:get_model_base_dir(model_id)=CACHE_DIR/<net>/model_<id>for clients, auditors, aggregators and the registered model ownerget_task_dir(coordinator)=cwd/tasks/<net>/<coord>for the model owner before registrationThat's the "CACHE_DIR and the task directory" option from the task spec, made strict.
B4, tests:
tests/test_manifest_path_traversal.pyhas 16 tests:.., symlink escape, and.== basebase_diris aTypeError, no writing into another model's dirtrain_lms, aggregatoraggregate_t1, auditorevaluate_lms, model ownercreate_testdataset) plus the artifact path, each raising before any downloadThe PR description shows break-then-fix on
client.py.Deliverables:
pytest -m "not integration"gives 468 passed and 0 failed, with green CI on920c93e.Documentation/public/manifest.md'spathrow says the path must be relative and stay inside the model's cache or task directory.No out-of-scope follow-ups were raised.
fix(dincli): reject manifest paths that escape their base directory (I…