Skip to content

security: manifest path fields allow path traversal / arbitrary file write on the host running dincli #227

Description

@Abidoyesimze

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

  1. 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.
  2. 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>"
      }
    }
  3. A validator runs the documented command: dincli client train-lms <attacker_model_id>.
  4. client_service_path = model_base_dir / Path(manifest["path"]) resolves to /home/victim/.ssh/authorized_keys, discarding the cache base dir.
  5. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions