← Community
bugopen

/api/vault/save merges a module's reserved-key credential_spec into its own allowlist — a module can overwrite the signing seed, and a resta

ShwetaShweta#19d ago · 37 views
affected: station-v1.3.1

Reproduction steps:

  1. On a keyring-less install (seed_store unavailable — the documented offline

fallback), the Ed25519 signing seed lives as hex in keys.local.json under the
reserved key "_railcall_signing_seed" (railcall_signing.py:80-81, "redacted/
filtered everywhere").

  1. Install any module (trust default "any", so a self-signed bundle installs)

whose manifest declares that reserved key as its OWN credential_spec provider:
{"credential_spec": {"provider": "_railcall_signing_seed",
"required": ["api_key"], "shape": "string", ...}}

  1. Any authenticated session (an ordinary operator session; also reachable via

CSRF on the loopback server per the platform's established pattern) POSTs to
the REAL /api/vault/save handler:
POST /api/vault/save {"provider":"_railcall_signing_seed",
"fields":{"api_key":"<attacker 32-byte hex>"}}

  1. Run repro_vault_save_reserved_seed_hijack_v131.py against a clean v1.3.1

extraction. It drives the REAL routes.vault._handle_vault_save,
routes.modules._module_credential_specs, and railcall_signing end to end.

Expected:
The signing seed is documented as "redacted/filtered everywhere" and never
surfaced as a user-configurable BYOK integration (railcall_signing.
is_reserved_vault_key). No module, however it declares its provider, should be
able to make the operator's own "allowlisted providers only" credential-save
endpoint accept a write to that slot.

Actual:
_module_credential_specs() keys after loading the malicious module: ['_railcall_signing_seed']
/api/vault/save -> 200 {'ok': True, 'provider': '_railcall_signing_seed', 'status': 'key_present'}
keys.local.json['_railcall_signing_seed'] -> attacker's chosen hex (CONFIRMED overwritten)
next sign_block() uses the attacker's seed
station restart (ensure_keypair(), which runs on every boot) republishes
signing_pubkey.json to match the ATTACKER's seed
verify_against_install() on the forged signature, post-restart -> signed_and_verified
routes/vault.py's _handle_vault_save builds its allowlist as
dict(_VAULT_ALLOWLIST_BUILTIN) merged with _module_credential_specs() — every
loaded module's declared credential_spec.provider, verbatim, unless it collides
with _known_provider_ids() (the ~110 real built-in/catalogue integration ids)
or an earlier-loaded module's provider, in which case it gets auto-namespaced to
<slug>::<provider>. "_railcall_signing_seed" is not a real integration, so it
is never in _known_provider_ids() — it collides with nothing and is merged in
UNDER ITS OWN RESERVED NAME. The ONE place this platform filters the reserved
key (signing.is_reserved_vault_key) is the vault LISTING at
studio_server.py:4352 (names only) — already the sole guard noted in the v1.2-
era report's finding on module vault_get reads (#23 in bugs_found_v0.99.txt).
_handle_vault_save never calls it, so the allowlist the endpoint trusts
("Whitelisted providers only" per its own docstring) silently includes a
reserved slot the moment any installed module's manifest names it. This is
strictly worse than a read: writing the seed, followed by an ordinary station
restart (which unconditionally republishes the pinned public key to match
whatever seed is currently in the vault), hands the attacker permanent,
verifying control of the station's entire signing identity — every future
receipt, mesh envelope, and team manifest signature they produce passes
verify_against_install() against the install's own pinned key.

Suggested fix:
Filter RESERVED_VAULT_KEYS on every write path that can reach keys.local.json
via a provider name, not just the listing:
1. In routes/vault.py._handle_vault_save, reject the request before building
VAULT_ALLOWLIST:
from railcall_signing import is_reserved_vault_key
if is_reserved_vault_key(provider):
return handler._send(200, {"ok": False, "error": "reserved provider"})
2. In routes/modules._module_credential_specs, drop (or refuse to load) any
module manifest whose credential_spec.provider is a reserved vault key —
the entry should never reach the merged allowlist in the first place, so
every other caller of _module_credential_specs() (workflow_requirements,
the Integrations Configure form) is protected too, not just this one sink.
3. Apply the same reserved-key check to _module_declared_providers /
_make_module_vault_get's collision path (a module could otherwise still
collide-namespace AROUND a check that only looks at the bare name).
A single is_reserved_vault_key guard, applied at both the module-manifest
ingestion point and the vault-write sink, closes this the same way it already
protects the listing.

0 replies

Sign in to reply.