Hey all, I'm back with another article on an issue I discovered recently. If you've read my earlier post on AWS Cognito and S3, you already know that I find this service interesting. The same kind of issues keep showing up across different companies.
The target this time was an internal deployment dashboard belonging to a large media company. Let's call it deploy.redacted.com. The login page only had one button which says "Sign in with Corporate ID", which usually means the app is locked down and there's nothing to look at.
In this article I'll walk through how I noticed it and how I chained it to access the whole deployment manager.
The Target
The target was an internal deployment dashboard at deploy.redacted.com. When opening the page, all it had was a button called "Sign in with Corporate ID".

At this point, most people would turn back and look for other applications, thinking that this application is locked down. And honestly, most of the time they'd be right xD. A corporate SSO button usually means the auth is handled by Okta or Azure AD or some other IdP, and unless you have a valid corporate account, there's nothing you could do on that application.
Before moving on, I opened DevTools, went to debugger tab and searched up for "UserPoolId" and I got this:

We got the UserPoolID, AppClientID, IdentityPoolID, region, the OAuth domain, and the API Gateway endpoint. Basically everything I needed to interact with Cognito directly.
These values aren't secrets. Any app using Cognito on the client side will have these configs inside their JS bundle or some env file. What matters is how the user pool and the identity pool are configured on the backend, because that's what decides whether these IDs are useful to a random person on the internet or not.
Before clicking the SSO button, I wanted to check one thing…
Was Signup Enabled?
Cognito has a SignUp API that allows anyone to register a new user on a user pool (as long as the app client allows it). The base URL is https://cognito-idp.<region>.amazonaws.com/ and Cognito uses a single endpoint for all its user pool operations. The specific action is selected by setting the X-Amz-Target header, so for SignUp it would be AWSCognitoIdentityProviderService.SignUp.
For convenience, I used awscli for calling the Cognito APIs:
aws cognito-idp sign-up \
--client-id redactedclientid \
--username test@test.com \
--password 'Tester@123456' \
--user-attributes Name=email,Value=test@test.com \
--region eu-west-1The above command called the AWSCognitoIdentityProviderService.SignUp action with the clientId and the credentials we provided.

It responded with UserConfirmed: false and UserSub, which is the unique ID of the newly created user, along with a CodeDeliveryDetails block confirming a verification code had been sent to the email. A few seconds later, the OTP showed up in my inbox.
The account was still in an unconfirmed state, so the next step was to call ConfirmSignUp with the OTP we received:
aws cognito-idp confirm-sign-up \
--client-id redactedclientid \
--username test@test.com \
--confirmation-code 123456 \
--region eu-west-1If the server responds empty, you're in luck! Because you just created an account on the target user pool. In our case, it responded empty.
Now I had a valid account on the target user pool. But an account alone doesn't get you anywhere, what I actually needed was an AccessToken to call the API endpoints.
Getting Tokens
To get tokens out of Cognito, you have to call the InitiateAuth API and specify which auth flow to use. Cognito supports different auth flows, depending on the ones configured by the app client.
Here are the four standard ones:

If you try an auth flow that isn't allowed by the app client, Cognito throws an error stating that the specific auth flow is not supported.
USER_PASSWORD_AUTH is the most common auth flow. The app sends the username and password to Cognito, Cognito verifies them and returns the tokens.
So I went with the most common flow, USER_PASSWORD_AUTH:
aws cognito-idp initiate-auth \
--client-id redactedclientid \
--auth-flow USER_PASSWORD_AUTH \
--auth-parameters USERNAME=test@test.com,PASSWORD='Tester@123456' \
--region eu-west-1
Cognito returned InvalidParameterException: USER_PASSWORD_AUTH flow not enabled for this client. So this app client didn't allow plain password auth. Which makes sense because this app was only ever supposed to support the federated SSO flow. The team probably disabled USER_PASSWORD_AUTH on purpose so that a leaked App Client ID couldn't be used to bruteforce passwords against the pool.
The other option was CUSTOM_AUTH. This lets the developers define their own auth flow using Lambda triggers attached to the user pool, things like passwordless OTP, captchas or any custom challenge the developers wanted to build. The catch is that it only works if those Lambda triggers actually exist on the pool, and even then, you'd need to know what challenges the triggers expect. Trying it blind would just throw another error.
SRP Auth
USER_SRP_AUTH was the last realistic option to try out.
SRP stands for Secure Remote Password. It's a cryptographic protocol designed to let a client prove it knows a password without actually sending the password to the server. The idea is that even if someone intercepts the entire authentication exchange, they can't recover the password from it. AWS uses SRP6a which is a specific variant of the protocol.
Instead of one request like USER_PASSWORD_AUTH, SRP runs as a multi-step handshake. The client generates a random number, derives some values from it and the password and sends a public value to Cognito. Cognito sends back its own challenge values. The client then computes a proof that, when verified by Cognito, confirms it knows the password. If the proof checks out, Cognito issues the tokens.

Writing SRP from scratch is painful as hell. AWS Amplify, Cognito SDK, and libraries like pycognito handle the math for us. The AWS CLI doesn't though, it has the initiate-auth and respond-to-auth-challenge commands, but it expects us to compute SRP_A and the password proof ourselves before passing them in. So we'd need a script that implements the SRP auth using pycognito library.
I wrote a small Python script for it:

Running this script will perform the full SRP_AUTH flow and prints the IdToken , AccessToken and RefreshToken .

Injecting the Session
Now I had three tokens. The IdToken is the interesting one, that's the JWT that gets exchanged at the Cognito Identity Pool for temporary AWS credentials. The AccessToken is used for Cognito specific operations like updating user attributes. The RefreshToken is for getting new tokens once these expire.
I could have called the Identity Pool APIs (GetId, GetCredentialsForIdentity) myself to exchange the IdToken for AWS credentials. But there was an easier path, just let the app do it.
The application uses AWS Amplify and Amplify stores its session in localStorage under a predictable set of keys which is scoped to the App Client ID. When the page loads, Amplify reads those keys, sees a valid IdToken and does the Identity Pool exchange in the background.
So all we have to do is to inject the session tokens into the app client and just reload the page.
I wrote a quick console snippet for it:
(() => {
const C = "redactedclientid";
const sub = "<user-sub-from-idtoken>";
const k = `CognitoIdentityServiceProvider.${C}`;
localStorage.setItem(`${k}.LastAuthUser`, sub);
localStorage.setItem(`${k}.${sub}.idToken`, "<IdToken>");
localStorage.setItem(`${k}.${sub}.accessToken`, "<AccessToken>");
localStorage.setItem(`${k}.${sub}.refreshToken`, "<RefreshToken>");
localStorage.setItem(`${k}.${sub}.clockDrift`, "0");
localStorage.setItem("amplify-signin-with-hostedUI", "true");
location.reload();
})();Open the target site, paste this into DevTools console, hit enter. The page will reload and Amplify picks up the tokens, exchanges the IdToken for AWS credentials at the Identity Pool and the dashboard renders.


The dashboard listed tons of projects, each one mapped to a real engineering team and a real CI/CD pipeline. The pipelines page showed over a hundred deployments, with names that lined up with public facing services and internal tooling. The dependencies view rendered a full graph of the SSM parameters every pipeline relied on, environment configs, deploy targets, account references and more.
And it wasn't readonly. Each project had a "Deploy" button that would trigger a real pipeline run which will push whatever code that was in the project's source repo into the configured target environments. There was also an "Edit Target Accounts" option that let us change which AWS accounts a pipeline deployed into.
Impact
The IAM role attached to the Identity Pool's authenticated role had execute-api:Invoke on the entire deployment API. With that, any authenticated Cognito user could:
- Trigger real deployments on any of the projects, pushing arbitrary code into production environments.
- Change a project's target AWS accounts, redirecting a deploy to an attacker controlled account and exfiltrating the build artifact along with any secrets.
- Read the full SSM dependency graph the platform relied on, including parameter names that often hint at what's stored in them.
Conclusion
The whole chain came down to one setting on a Cognito app client. Self-service signup was left enabled on a user pool that was supposed to only be reachable through corporate SSO. Once you can register, you can authenticate. Once you can authenticate, the Identity Pool gives you AWS credentials. Once you have AWS credentials, the SSO gate doesn't matter anymore xD.
And that's the writeup!
Thanks for reading. If you enjoyed this writeup, please clap and share it.