CVE-2026-30950 Allows Chat Session Hijacking In AutoGPT

ZeroPath Research discovered an authenticated IDOR in AutoGPT (CVE-2026-30950) that lets any logged-in user hijack any other user's chat session with a single PATCH request, exposing the full conversation history and locking out the original owner. Affects autogpt-platform-backend >= 0.6.36, < 0.6.51; patched in 0.6.51.

Research

7 min read

John Walker
John Walker

2026-05-20

CVE-2026-30950 Allows Chat Session Hijacking In AutoGPT

Summary

ZeroPath Research discovered an authenticated IDOR vulnerability in AutoGPT that allows any logged-in user to hijack any other user's chat session with a single PATCH request. The attacker needs only the target session_id — there's no requirement to share an organization, agent, or prior access with the victim.

After the hijack, the attacker reads the full conversation history of the session, and the original owner is locked out of their own data. Chat sessions in AutoGPT carry conversation history with the agent, credentials metadata, agent execution context, and any files or sensitive content the user pasted into the chat.

The issue is tracked as CVE-2026-30950 and GHSA-q58p-v9r9-7gqj, and was patched in autogpt-platform-backend version 0.6.51. MITRE assigned a CVSS 3.1 base score of 7.1 (high).

Impacted Software

Vulnerable VersionsPatched Versions
>= 0.6.36, < 0.6.51>= 0.6.51

Timeline

  • 2026-03-06 — Issue reported to AutoGPT maintainers (private security advisory GHSA-q58p-v9r9-7gqj created)
  • 2026-03-08 — Fix committed as eca7b5e793 and shipped in autogpt-platform-beta-v0.6.51
  • 2026-05-11 — GHSA-q58p-v9r9-7gqj published by the AutoGPT maintainers
  • 2026-05-18 — CVE-2026-30950 published

AutoGPT

Background

AutoGPT is a workflow automation platform for building, deploying, and managing continuous AI agents. The platform's backend (autogpt-platform-backend) is a FastAPI service that brokers chat sessions between end users and agents — each session carries the message history, tool invocations, and credentials context for one conversation.

Chat sessions and ownership

Each chat session is a database object with a session_id (UUIDv4) and a user_id representing the owner. Sessions are cached in Redis (12-hour TTL) and read with a get_chat_session(session_id, user_id) helper that performs ownership validation when user_id is supplied. The helper has a dual mode: when called with user_id=None, the docstring explicitly notes this is "admin/system access" and the ownership check is skipped — meant for internal lookups that aren't tied to a particular caller.

The platform also exposes an assign-user endpoint, designed to let a freshly-logged-in user claim an anonymous session they created before authenticating. That endpoint is where the bypass lives.

CVE-2026-30950: Bypassing ownership by passing None to the data layer

Overview

The PATCH /api/chat/sessions/{session_id}/assign-user endpoint authenticates the caller but performs no check that the caller currently owns the target session. The service function it calls deliberately passes user_id=None to the data accessor, which the accessor treats as a privileged system lookup and returns the session regardless of who owns it. The service then overwrites session.user_id with the caller's ID and persists the change to Redis and the database.

Preconditions

  • An authenticated account on the target AutoGPT instance (any standard signed-up user works).
  • Knowledge of the victim's session_id. Session IDs are UUIDv4, so they're not brute-forceable, but they appear in URLs, referer headers, server logs, shared links, support tickets, and screen shares.

The three layers of the bypass

The vulnerability is established once at each of three layers, and each layer's behavior on its own looks defensible — it's the composition that breaks.

Layer 1 — the route handler. In autogpt_platform/backend/backend/api/features/chat/routes.py, around lines 753–776, the endpoint requires a valid JWT but does not check that the caller owns the session referenced in the URL:

@router.patch( "/sessions/{session_id}/assign-user", dependencies=[Security(auth.requires_user)], status_code=200, ) async def session_assign_user( session_id: str, user_id: Annotated[str, Security(auth.get_user_id)], ) -> dict: await chat_service.assign_user_to_session(session_id, user_id) # ZP: no ownership check on session_id return {"status": "ok"}

session_id is taken directly from the URL path and is attacker-controlled. user_id comes from the caller's JWT and is the attacker's own ID. Both flow into the service layer with no further gating.

Layer 2 — the service function. In autogpt_platform/backend/backend/copilot/service.py, around lines 291–303, assign_user_to_session fetches the session by ID with user_id=None, then overwrites the owner:

async def assign_user_to_session(session_id: str, user_id: str) -> ChatSessionInfo: session = await get_chat_session(session_id, None) # ZP: None disables ownership filter in the data layer if not session: raise NotFoundError(f"Session {session_id} not found") session.user_id = user_id session = await upsert_chat_session(session) return session

Other endpoints in the same file (GET /sessions/{session_id}, DELETE /sessions/{session_id}) correctly forward the authenticated user_id to get_chat_session, so the ownership check fires. This endpoint is the outlier.

Layer 3 — the data accessor. In autogpt_platform/backend/backend/copilot/model.py, around lines 355–366, the ownership check short-circuits when user_id is None:

session = await _get_session_from_cache(session_id) if session: if user_id is not None and session.user_id != user_id: # ZP: bypassed when user_id is None logger.warning(f"Session {session_id} user id mismatch") return None return session

The same check exists on the database fallback path a few lines below. The docstring on get_chat_session documents user_id=None as "admin/system access," so from this function's perspective the behavior is by design — it just trusts that callers pass None only when they have already authorized the request themselves.

Persistence and lockout

After the service overwrites session.user_id, upsert_chat_session writes the modified session to both the database and Redis. The Redis write (cache_chat_session in model.py) serializes the full session object including the attacker's user_id and stores it with a 12-hour TTL. From that point on:

  • The attacker calling GET /api/chat/sessions/{session_id} passes the now-cached ownership check and receives the full message history.
  • The victim calling the same endpoint fails the ownership check (the cached session.user_id no longer matches their JWT subject) and receives 404 — the same lockout symptom you'd see if the session had been deleted.

A note on the DB layer: db.update_chat_session does not update the userId column directly, so initially the database row retains the original owner while the Redis cache is poisoned. However, any subsequent upsert of the session (for example, when the attacker sends a follow-up message and the session is re-saved end-to-end) writes a session record carrying the attacker's user_id, making the change durable beyond the 12-hour cache TTL.

POC

Full working POC with a Docker Compose setup that boots AutoGPT at the last vulnerable tag (autogpt-platform-beta-v0.6.50), provisions an attacker and a victim account, and runs the single-PATCH hijack end-to-end. The POC verifies the attacker has no pre-exploit access, fires the request, then confirms ownership has transferred and the victim is locked out.

Mitigation

  • Upgrade autogpt-platform-backend to 0.6.51 or later. The fix is in commit eca7b5e7.
  • If you cannot upgrade immediately, treat PATCH /api/chat/sessions/{session_id}/assign-user as an authenticated-but-unauthorized endpoint — block it at your gateway, or accept that any authenticated user can hijack any session whose ID they can obtain.

Takeaways

The bug here isn't in any single function — it's in how three functions agree on responsibility. The route handler trusts the service to authorize. The service passes None and trusts the data layer not to expose anything dangerous. The data layer treats None as a privileged sentinel and trusts callers to only use it from system code. None of those individual decisions is obviously wrong if you only look at one file at a time, and that's exactly why this kind of finding survives review.

What makes it interesting from a tooling angle is that it's a pure semantic bug — there's no taint flowing into a dangerous sink, no string concatenation, no missing escape. Every value at every step is the "right" type. The flaw is that an authorization sentinel (user_id=None meaning "skip the check") was reachable from a code path that hadn't done the check yet. That's the kind of cross-function policy violation that's well-suited to LLM-driven analysis of how authorization decisions actually compose across a codebase, rather than pattern matching on individual sinks.

Detect & fix
what others miss

Security magnifying glass visualization