DEV Community

Artemii Amelin
Artemii Amelin

Posted on

Unit 42 Read an Agent's Master Token Out of /proc/1/mem. shell.online's New Password Requests Never Give the Service a Copy

On September 18, Unit 42's Niv Rabin published a write-up on Amazon Bedrock AgentCore that is worth reading slowly. AgentCore Identity does what a credential vault should do: secrets are encrypted at rest and in transit, protected by KMS keys and IAM, and an operator references them by ARN in an authorization header instead of pasting the token into config. The design is sound right up to the moment the agent needs to make a request. At that point the harness resolves the ARN into a plaintext token inside its own process memory, and Unit 42 found that the shell tool runs as root in the same harness, so a script it executes can read /proc/1/mem.

The delivery vehicle was a support ticket with a hidden HTML comment telling the agent to curl a recon script and pipe it into python3. The script scanned the heap for two patterns, a JWT and a downstream MCP server URL, and posted both to a webhook. The token was 1,034 bytes, belonged to a service account named mcp-service, and replayed fine from a laptop with no AWS credentials at all, straight into customer PII. AWS closed the report as informative under its shared responsibility model, pointing at allowedTools scoping and egress filtering as customer-side controls.

A vault only protects a secret while it stays inside the vault. The interesting question is who gets a plaintext copy, for how long, and whether any of those parties is also running attacker-supplied code.

A password request where the middle party never holds the password

We merged a feature to shell.online today that has a clean answer to that question. It is PR #247, sits in the Unreleased section of the changelog, and is not in the v0.23.0 release that went out this week.

The situation it handles: a teammate opens an encrypted session they are responsible for and has no password for it in their vault. The unlock prompt now has a button that asks the owner for it. The owner sees a count of waiting requests on the Share button, and accepts or declines from the session page.

What the service stores when someone asks is only the ask. Migration 024_password_requests.sql creates a session_password_requests table with a session reference, the requester, and a status constrained to pending, approved or declined. The row is keyed to the session with ON DELETE CASCADE, so a deleted session takes its requests with it. Asking again after a decline reopens the same row as pending, and a request that is already pending keeps its original timestamp, so re-asking cannot push you to the top of the owner's list.

The route in app/server/app.ts refuses the ask in three cases before it writes anything: the session is not encrypted, the asker already owns it, or the asker has no vault. That last one returns a 409 with the reason spelled out, "set up your vault first, so the password has somewhere to go."

Accept means seal in the browser, then store the ciphertext with the answer

When the owner clicks Accept, the browser does the work, not the server. The component in app/src/components/PasswordRequests.tsx first checks that the owner's vault is unlocked in this browser, because the password has to be readable here to be re-sealed. Then it calls the vault's sealTo, which in app/src/lib/vault-crypto.ts generates an ephemeral ECDH P-256 key, derives a shared key against the asker's account public key, and encrypts the password with AES-GCM under a context bound to the session id and the recipient's uid. The ephemeral public key travels with the ciphertext so the asker can derive the same key.

That sealed blob goes up in the PUT to /api/sessions/:id/password-requests/:uid together with the decision. The Postgres store method resolvePasswordRequest opens a transaction, updates the request row only where the status is still pending, and inserts the sealed copy into session_key_shares in the same transaction before committing. If the update matches no row, the transaction rolls back and the route returns 409, "that request has already been answered." An approve without a share is refused before the transaction starts. The invariant is spelled out in a comment on the route: a request never reads as shared without the password having gone with it.

The service sees who asked, who answered, and a ciphertext it cannot open. The two places a plaintext copy exists are the owner's unlocked browser and, after the asker opens it, the asker's. Both already needed it. Every answer lands in the audit trail as a handoff event, "shared the password with" or "declined a password request from" the teammate's email, and the audit page filter accepts handoff as a kind.

One more guard is inherited from the sharing code rather than new here. If the asker's account key has changed since the owner's browser last sealed to it, Accept waits until the owner has seen that and confirmed. A service that quietly swapped a member's public key for its own would otherwise be the perfect man in the middle for exactly this flow.

What this design does and does not buy

It does not make the secret disappear. A session password still has to be plaintext on the host machine that encrypts the terminal, and in whichever browser is decrypting it, and if either of those runs attacker-supplied code with the vault unlocked, the AgentCore lesson applies there too. What it removes is the third copy: the one that would live on a server whose job is to route and store, and which is the copy nobody at the endpoints can audit.

That is the same shape we chose for Pilot Protocol, where the relay used for NAT traversal carries tunnel ciphertext it cannot open, and the keys live only at the two agents. The Pilot source and the shell.online source differ in almost everything else, but they agree on which party is not allowed a plaintext copy.

The transaction is resolvePasswordRequest in app/server/lib/store-postgres.ts, about forty lines. The memory store enforces the same one-answer rule, and the store conformance tests run against both.

Top comments (0)