Skip to content

Fix cross-tenant session hijack via caller-supplied AgentCore session ids (H6) - #39

Merged
odinwang merged 2 commits into
aws-samples:mainfrom
zzkamzn:sec-patch/h6-session-binding
Sep 19, 2026
Merged

odinwang merged 2 commits into
aws-samples:mainfrom
zzkamzn:sec-patch/h6-session-binding

Conversation

@zzkamzn

@zzkamzn zzkamzn commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

The finding

An AgentCore runtimeSessionId is not a label. Reusing one routes to the same warm microVM and the same kernel process, so two callers that share a session id share /tmp/agent-work, the process secret cache and any in-process model-gateway grant.

Both invoke routes took the caller's session_id verbatim out of the request body and handed it to the runtime with no ownership check. That made another tenant's process state reachable with nothing more than an ordinary authenticated account — no admin, no IAM change.

The target id did not even have to be guessed:

  • Echoed. It comes back in InvokeResponse.runtime_session_id and is recorded on invocation-ledger entries.
  • Computable. The channel path derived it as an unkeyed sha256(channel_id + ":" + conversation_id). The channel id is public — it sits in the webhook URL handed to the third party — so any conversation's session id could be computed offline.

Impact was cross-tenant read of leftover artifacts and cached secrets, reuse of another tenant's still-valid gateway grant, and a one-request targeted denial of service: mint wrote the grant item unconditionally, so overwriting the token digest on a victim's live session left the victim's kernel holding a token that no longer verified.

What makes this a clear miss rather than a debatable one: the neighbouring argument on the same call was already recognised as an authorization boundary. resolve_memory_actor namespaces the request-supplied memory actor per caller precisely because whoever picks it picks whose records get read. The session id is the equivalent key and was passed through with no protection at all.

The fix

Same shape as the control that was already there, applied to the key that was missed.

  • backend/app/services/session_binding.py (new). resolve_session_id namespaces a request-supplied id under the authenticated caller via HMAC. Two tenants submitting the same id can never land on one microVM, and an id lifted from someone else's response re-namespaces under whoever replays it. The mapping is idempotent, which matters because the resolved id is echoed to the client and the Debug console resends it as the next session_id: a caller's own id round-trips unchanged, so conversation continuity is preserved.
  • Applied at both client entry points, the kernel invoke route and the published-agent invoke route, mirroring where resolve_memory_actor is applied. Internal callers still reach invoke unmodified, since they derive their session server-side from values a caller cannot forge.
  • derive_channel_session_id keys the channel digest with the platform binding secret, so it is no longer derivable from the public channel id plus a guessed conversation id.
  • llm_credentials_service.mint now writes with ConditionExpression="attribute_not_exists(PK) OR #u = :user" — create, or re-mint by the same owner. Ids are caller-bound upstream now, so a collision should be unreachable; this makes overwriting another principal's live grant impossible even if that ever breaks.

New setting PLATFORM_SESSION_BINDING_SECRET keys the binding. Unset, it falls back to deployment identifiers that appear in no public webhook URL; set it explicitly in production. It only needs to be stable across replicas and restarts, not rotated.

Tests

Both suites pass on this branch.

  • scripts/check_session_binding_authz.py — no third-party dependencies and no AWS calls, so it is safe in CI. Covers cross-tenant collision, replay, idempotency, AgentCore id shape, channel-digest keying, and that the digest is no longer the precomputable unkeyed one. It also asserts the routes actually apply the mapping, since a correct function is worthless if a call site passes the request value through verbatim — the regression this guards against. Mirrors the existing check_memory_actor_authz.py.
  • scripts/e2e_session_isolation.py — drives the real FastAPI routes through the real invocation, kernel and credential services against mocked DynamoDB, with a recording fake kernel standing in for the runtime. Asserts two tenants submitting the same id reach different runtime sessions, that replaying another tenant's echoed id does not land on it, that each tenant keeps continuity on its own id, and that the grant-overwrite denial of service is refused while the owner can still re-mint.

Only authentication and the undeployed runtime are stubbed; the authorization path under test is the real one.

Review notes

  • Scoped to this finding only, branched off main rather than onto Fix three High security findings: agent takeover, unsandboxed workflow scripts, llm-edge open proxy #36, so that PR keeps its stated scope. Both touch config.py and api/agents.py in different places, so whichever merges second may need a trivial context resolution.
  • Behaviour change worth a look: the resolved id is opaque and caller-bound, so one logical conversation can no longer be addressed across two different user identities. That was never a supported flow, but it is the one thing here a client could notice.
  • terraform/.terraform.lock.hcl was modified in the working tree beforehand and is deliberately left out of this branch.

🤖 Generated with Claude Code

An AgentCore runtimeSessionId is not a label: reusing one routes to the same
warm microVM and the same kernel process, so two callers that share a session
id share /tmp/agent-work, the process secret cache and any in-process
model-gateway grant. Both invoke routes took the caller's session_id straight
from the request body, so that shared state was reachable across tenants with
nothing more than an ordinary authenticated account.

The id did not even have to be guessed. It is echoed back in
InvokeResponse.runtime_session_id and recorded in the invocation ledger, and
the channel path derived it as an unkeyed sha256 of channel_id +
conversation_id — the channel id being public, since it sits in the webhook
URL handed to the third party. So a target session id was either observable or
computable offline.

The same file already treats the neighbouring memory actor id as an
authorization boundary and namespaces it per caller (resolve_memory_actor).
The session id is the equivalent key and now gets the equivalent treatment:

- session_binding.resolve_session_id namespaces a request-supplied id under
  the authenticated caller (HMAC), so two tenants submitting the same id can
  never land on one microVM, and an id lifted from another tenant re-namespaces
  under whoever replays it. The mapping is idempotent because the resolved id
  is echoed to the client and resent for conversation continuity: a caller's
  own id round-trips unchanged.
- session_binding.derive_channel_session_id keys the channel digest with the
  platform binding secret, so it is no longer computable from the public
  channel id plus a guessed conversation id.
- llm_credentials_service.mint writes conditionally (create, or re-mint by the
  same owner) instead of unconditionally overwriting. Ids are caller-bound
  upstream now, so a collision should be unreachable; this makes overwriting a
  live grant held by another principal — and the targeted denial of service it
  buys, since the victim's kernel token stops matching the stored digest —
  impossible even if that breaks.

PLATFORM_SESSION_BINDING_SECRET keys the binding. Unset, it falls back to
deployment identifiers that do not appear in any public webhook URL; set it
explicitly in production. It only has to be stable across replicas and
restarts.

Tests: scripts/check_session_binding_authz.py covers the boundary with no
dependencies or AWS calls and asserts the routes actually apply the mapping,
since a correct function is worthless if a call site passes the request value
through verbatim. scripts/e2e_session_isolation.py drives the real routes and
services against mocked AWS with a recording fake kernel, asserting both the
cross-tenant landing and the grant-overwrite denial of service are blocked
while continuity and owner re-mint still work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zzkamzn

zzkamzn commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Heads-up on a latent conflict with #36 — not an issue with this PR, purely a merge-ordering note.

Both PRs insert a new field into Settings at the same location in backend/app/config.py, immediately after portal_admin_secret:

Each is individually clean against main, so GitHub reports both as MERGEABLE — but whichever merges second will flip to CONFLICTING. Verified with an in-memory three-way merge of the two heads (git merge-tree --write-tree): backend/app/config.py is the only conflicting path. Everything else auto-merges cleanly, including backend/app/api/agents.py and the four docs files both branches touch.

The two fields are semantically unrelated, so the resolution is to keep both, in either order. Happy to rebase whichever one lands second.

# Conflicts:
#	backend/app/config.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants