DEV Community

Bala Paranj
Bala Paranj

Posted on

Four CLI Commands From Anonymous to Admin: Your Cognito Configuration Is the Vulnerability

✓ Human-authored analysis; AI used for formatting and proofreading.

In June 2023 Serj Novoselov published a Medium walkthrough demonstrating an attack against a Cognito-protected application using nothing but the AWS CLI. The application has a login page and has no signup button. The developer assumed that hiding the signup button was the registration control.

The walkthrough's attacker registers an account anyway. Because the API endpoint behind the missing button is wide open. Then they self-promote to admin by changing their own user attributes. Then they trade their Cognito session for AWS credentials from an identity pool. Then they use those credentials to read every S3 bucket in the account.

aws cognito-idp sign-up \
  --client-id abc123def456 \
  --username attacker@example.com \
  --password 'P@ssw0rd!'

aws cognito-idp update-user-attributes \
  --access-token $TOKEN \
  --user-attributes Name=custom:role,Value=admin

aws cognito-identity get-credentials-for-identity \
  --identity-id $IDENTITY_ID \
  --logins ...

aws s3 ls
Enter fullscreen mode Exit fullscreen mode

Four commands. Zero starting credentials. Full S3 access.

This article runs the writeup's configuration through a Z3 SAT solver and proves the chain end-to-end. Then it runs a choke-point analysis. A Z3 pattern that asks "which single configuration change collapses the chain?". It finds that three of five candidate fixes each break the chain on their own. The cheapest is one boolean in the Cognito user-pool config.

The four-stage chain

Each stage exploits a different Cognito component:

[Stage 1] cognito-idp:SignUp
   ├ user pool's allow_admin_create_user_only = false
   ├ app client has no secret (public, client_id in page source)
   └ no pre-sign-up Lambda validates the request
        → account created

[Stage 2] cognito-idp:UpdateUserAttributes
   ├ app client's write_attributes includes custom:role
   ├ no pre-token-generation Lambda validates the change
   └ attribute set to "admin"
        → account is now privileged

[Stage 3] cognito-identity:GetCredentialsForIdentity
   ├ identity pool issues credentials to authenticated identities
   └ authenticated_role has s3:* on *
        → temporary AWS credentials with admin S3 access

[Stage 4] aws s3 ls / aws s3 cp / aws s3api put-bucket-policy
        → AWS API calls authenticated by the temporary creds
Enter fullscreen mode Exit fullscreen mode

Each stage is a real Cognito feature. Each stage is individually documented as a configuration option operators are expected to choose. The bug is not "Cognito has a vulnerability". Cognito does what it's told to do at every step. The bug is in the combination of choices: the developer who hid the signup button, the developer who added custom:role to writable attributes, the developer who enabled identity-pool credentials for authenticated callers, and the developer who scoped the authenticated role to s3:*.

Four developers, four reasonable choices, one chain.

What pattern-matching tools see

A scanner that checks Cognito user pool settings reports "allow_admin_create_user_only is false." Action item: maybe enable it.

A scanner that checks app client settings reports "custom:role is in write_attributes." Action item: maybe scope it.

A scanner that checks identity-pool settings reports "allow_unauthenticated_identities is true." Action item: maybe disable it.

A scanner that checks IAM roles reports "the authenticated role has s3:* on *." Action item: scope it.

Four findings. Each labelled "medium severity." Each ticketed for some future sprint. The team triages by priority and works through the list. The compound chain stays open until all four findings close.

The Z3 prover composes the four findings into one verdict: the chain is reachable from zero credentials to admin. That's not a "medium severity" configuration-checklist item. It's an active path to the most sensitive resources in the account.

The five Z3 queries

The prover runs five queries against the writeup configuration:

Finding 1 — Self-registration possible

self_reg_restricted:        false
app_client_has_secret:      false   (public client: true)
pre_signup_lambda_present:  false
verdict:                    SAT — anyone with the public client_id
                            can call cognito-idp:SignUp; no Lambda
                            validates the request before account creation
Enter fullscreen mode Exit fullscreen mode

Three booleans. The account-creation gate is open at all three layers.

Finding 2 — Sensitive attribute writable

writable attributes:         [email custom:role name custom:is_premium]
sensitive (writable):        3
  email                  → account_takeover
  custom:role            → privilege_escalation
  custom:is_premium      → privilege_escalation
pre_token_lambda_validates:  false
verdict:                     SAT — at least one sensitive attribute is
                             writable AND no Lambda validates the change
Enter fullscreen mode Exit fullscreen mode

The Z3 prover ships a registry of well-known privilege-bearing Cognito attributes. The intersection of the app client's write_attributes with that registry tells the prover whether self-promotion is possible. Three attributes match.

Finding 3 — Credentials reach sensitive resources

Path A — unauthenticated:
  allow_unauthenticated:    true
  unauth role:              Cognito_appUnauth_Role
  has sensitive access:     true   (DynamoDB read on app-data table)

Path B — self-registered + authenticated:
  auth role:                Cognito_appAuth_Role
  has sensitive access:     true   (s3:* on *)
verdict:                    SAT — at least one credential path reaches sensitive AWS
Enter fullscreen mode Exit fullscreen mode

Two independent paths. Path A is the textbook unauthenticated case (Capital One's pattern). Path B the self-registered, self-promoted attacker uses. Both reach sensitive resources because the Cognito IAM roles are over-broad. The standard mistake of granting s3:* to a user-facing identity-pool role because "the team needs to read user data."

Finding 4 — Compound chain

stage 1 (self-register):       true
stage 2 (self-promote attr):   true
stage 3 (creds → sensitive):   true
verdict:                       SAT — 4 CLI commands from anonymous to AWS:
                               cognito-idp:SignUp →
                               cognito-idp:UpdateUserAttributes →
                               cognito-identity:GetCredentialsForIdentity →
                               (use auth role's permissions on AWS resources)
Enter fullscreen mode Exit fullscreen mode

The conjunction. Four CLI commands from zero to admin.

Finding 5 — Choke-point analysis

The prover takes the writeup state, toggles each candidate fix one at a time, and reports which ones flip the chain from SAT to UNSAT.

question: which single configuration change flips the chain to UNSAT?
testing 5 candidate fixes...

[CLOSED] set allow_admin_create_user_only=true
         stage 1 closed — no self-registration
[CLOSED] remove sensitive attrs from app client write_attributes
         stage 2 closed — no self-promotion
[CLOSED] configure pre-token-generation Lambda validator
         stage 2 closed — attribute changes validated
[OPEN  ] set allow_unauthenticated_identities=false
         path A of stage 3 closed (path B remains)
[OPEN  ] scope authenticated role to non-sensitive resources
         path B of stage 3 closed

3 single-change fixes break the chain:
  • set allow_admin_create_user_only=true
  • remove sensitive attrs from app client write_attributes
  • configure pre-token-generation Lambda validator
the cheapest is the first listed (one boolean flip in user-pool config).
Enter fullscreen mode Exit fullscreen mode

Three of five fixes collapse the chain on their own. The remaining two are partial fixes. Stage 3 has two independent paths to credentials (unauthenticated, and authenticated-via-self-register), so closing one path leaves the other open. Those two fixes are together a stage-3 close, but neither alone is a chain-breaking choke point.

The cheapest is row 1: one boolean in the user-pool config. One Terraform attribute change. No IAM rework, Lambda authoring or role rescoping. The chain dies at stage 1.

What pattern-matching tools cannot say

A scanner reporting four findings says fix all four. A reviewer asks "which one matters most?" and the scanner has no answer. It can't compose findings, so it can't tell you which one is structurally foundational.

The choke-point analysis can. Of these five candidate fixes, three are individually sufficient. The remaining two are partial fixes that need pairing. The order of work is therefore obvious. Pick the cheapest of the three sufficient ones, ship that, the chain is closed. Optionally schedule the other fixes as defense in depth, but the exposure window is over the moment the cheapest fix lands.

This matters because real teams ship in priority order and have finite review bandwidth. "Fix four medium-sev findings" reads as a sprint of work. "Flip allow_admin_create_user_only to true" reads as a single PR. The math says they have the same effect on the active exposure.

The architectural rule: UI is not a security boundary

The deeper bug is upstream of any Cognito setting. The developer who shipped the application built a login page, designed it for known users, and didn't include a signup form. They concluded the application was "closed to registration."

The signup form the developer interacts with. The signup API an attacker interacts with. The two are different surfaces. The application exposes the API to anyone with the client ID, and the client ID is in the page source.

This is the same pattern as:

  • "We removed the admin link from the navbar" (the admin endpoint is still routable).
  • "We hid the bucket from the listing" (the bucket ARN is still resolvable).
  • "We removed the field from the form" (the field is still accepted by the API).

Whenever the only restriction is at the UI layer, the attacker wins by skipping the UI. Cognito's configuration is the boundary. The application's frontend is not the boundary.

The remediation

 # Cognito user pool
 admin_create_user_config:
-  allow_admin_create_user_only: false
+  allow_admin_create_user_only: true     # ← THE choke point
 mfa_configuration: OFF
+ mfa_configuration: ON
 lambda_config:
+  pre_sign_up: arn:aws:lambda:...:function:validate-signup
+  pre_token_generation: arn:aws:lambda:...:function:validate-claims

 # Cognito app client
- write_attributes: [email, custom:role, name, custom:is_premium]
+ write_attributes: [name]

 # Cognito identity pool
- allow_unauthenticated_identities: true
+ allow_unauthenticated_identities: false

 # IAM authenticated role
- Action: s3:*
- Resource: *
+ Action: s3:GetObject
+ Resource: arn:aws:s3:::app-user-data/${cognito-identity.amazonaws.com:sub}/*
Enter fullscreen mode Exit fullscreen mode

The choke-point analysis says any of the three flagged fixes (rows 1, 4, or the new pre-token Lambda) is sufficient on its own. The full diff defense-in-depth looks like; the choke point the smallest diff looks like.

Three rules

Cognito's settings are the security boundary, not the UI. If the API endpoint allows an action, assume an attacker will call it. Build the restriction into the configuration, not the frontend.

App client write_attributes is a privilege boundary. Treat it like an IAM resource policy. Every attribute on the list is something a user can change post-registration. If the attribute affects permissions, business logic, or trust, it must not be on the list.

Identity-pool authenticated roles are public-facing. Anyone who registers reaches that role's permissions. Scope the role like you'd scope a public-internet-facing identity, not like an internal-employee identity.

Checklist

  • allow_admin_create_user_only=true on every user pool that doesn't have a public-facing signup feature
  • App client write_attributes excludes custom:role, custom:admin, custom:is_premium, email, email_verified, and any custom attribute that affects authorization
  • Pre-sign-up and pre-token-generation Lambda triggers validate request content; neither field is empty in the user-pool config
  • Identity-pool allow_unauthenticated_identities is false unless an unauthenticated public-facing asset path genuinely requires it
  • Authenticated identity-pool roles use ABAC conditions (${cognito-identity.amazonaws.com:sub}) to scope per-user resource access; no s3:* on *
  • CI runs the chain-and-choke-point analysis against post-deploy observations; the example shipped with this article is the template

The four CLI commands work because four configuration choices line up. Closing any one of three of them collapses the chain. The math says one fix is enough; the math also says which fix is cheapest. Ship that one first.


The example cognito-self-register-to-aws-creds shows two binaries side by side: a CEL evaluation via pkg/stave.Apply (uses the existing CTL.COGNITO.SELFREG.001 per-technique control; fires on writeup, silent on remediated) and a Z3 SAT prover that runs the five queries from this article including the choke-point analysis. The Z3 binary lives in a sibling Go module so its libz3 link stays out of Stave's main vendored tree. Stave detects this pattern and 31 other H1-grounded scenarios from local AWS configuration snapshots, with no cloud credentials.

Top comments (0)