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 trigger
dincli/cli/client.py ~L129-134 (train-lms)
dincli/cli/aggregator.py ~L336-341, ~L557-562
dincli/cli/auditor.py ~L450-462
dincli/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_path
This 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_exists creates ~/.ssh if needed, downloads, and silently overwrites authorized_keys with 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.
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
pathstring taken directly from the model's manifest JSON, then passes it toensure_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):No path validation anywhere in this function.
Vulnerable call sites (all build
base_dir / Path(manifest["path"])or equivalent, verified againstdevelop@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 equivalentsThe only path-side defense found is
dincli/services/ipfs.py's_normalize_path:This 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'sPath.__truediv__also discards the base directory entirely if the manifest'spathis an absolute string:foundry/src/DINModelRegistry.sol'srequestModelRegistration/approveModelonly validate slasher status and task-contract ownership — the manifest'sbytes32CID is stored and never dereferenced/validated on-chain, so manifest content (everypathfield 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.pyruns custom service code inside a Docker container. But the vulnerableensure_file_existsdownloads happen in the host CLI process, beforerun_worker_containeris ever invoked — so this write lands on the bare host regardless of that mitigation.Exploit
DINTaskCoordinator/DINTaskAuditor, gets them authorized as slashers (the normal model-owner flow), registers and gets approved — none of this inspects manifest content.{ "type": "custom", "train_client_model": { "path": "/home/victim/.ssh/authorized_keys", "ipfs": "<CID of attacker's SSH public key>" } }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/*.pycall sites for their respective roles.Recommendation
Resolve the manifest-supplied
pathagainst the trusted base directory and reject (don't silently re-anchor) anything not contained within it, e.g.: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.