
The alert covered what CloudTrail could see. One guest identity, 23 AWS calls, 22 of them denied. The rest of the story wasn’t in CloudTrail at all. Seven more guest identities, issued the same way, were walking a customer’s user directory through an API that returned any user’s record without checking whether it belonged to the caller.
Executive summary
The attacker made a public-facing API serve profiles for 172 of a customer’s users, using anonymous guest credentials that the application handed to anyone who asked. The data served included the legacy session values for 60 of those users. No password was stolen and no account was taken over, although the same API would have let a guest change any user’s password. No exploit was involved; the application’s configuration did the work.
The anatomy was short and mechanical:
- Anonymous entry. A Cognito identity pool issued temporary AWS credentials to guests with no login, which is by design. The gap was the guest role those credentials mapped to, which was allowed to call the profile API.
- An API that trusted the caller. The API returned any user’s profile for any ID it was given, without checking whether the caller had a claim to it. This is Broken Object Level Authorization, the flaw once known as IDOR.
- Seeding. The attacker started from a set of valid user IDs. A lookup turned a couple of emails into IDs; the origin of the rest is not established.
- The enumeration. User IDs were sequential, so the attacker stepped through the ranges around their seeds one ID at a time, 401 user IDs in all, across eight guest identities.
- Collection. The API served 172 profiles, 60 of them carrying the session values the app used to keep users signed in.
- Attempted reuse. The attacker replayed session values for four users and requested a password-reset email for one of them. None of it produced access.
Cognito user pools and identity pools
This incident touched both halves of AWS Cognito, and they do different jobs.
A user pool is the account directory. It holds user accounts, handles sign-up and sign-in, stores profile attributes, and issues tokens that prove who someone is. When the attacker registered seven accounts, those went into the user pool.
An identity pool does something else. It hands out temporary AWS credentials so an application can call AWS services on a user's behalf. It issues them through AWS STS, using an action called AssumeRoleWithWebIdentity, and maps whoever is asking to an IAM role. An identity pool can also be set up to issue credentials to anyone at all, with no sign-in. Those are guest, or unauthenticated, identities. Anyone who can reach the pool gets temporary credentials mapped to a single unauthenticated role, and whatever that role can do, the anonymous caller can do.
Here, the unauthenticated role was allowed to call the customer's AppSync profile API. So anyone on the internet, with no account and no password, could request guest credentials and use them against that API. Each request produced a fresh temporary identity with its own short-lived access key. The attacker collected eight of them.
The permission amounted to a statement like this:
{
"Effect": "Allow",
"Action": "appsync:GraphQL",
"Resource": "arn:aws:appsync:us-east-1:5821xxxxxxxx:apis/q7m2v9-xxxx/types/*/fields/*"
}An anonymous guest could call the profile API, and nothing after that checked whose record it was reading.
The role also allowed execute-api:Invoke on every stage and method of a separate API Gateway. The customer described it as unused, but it had no logging, so that can’t be confirmed.
The API used IAM authorization. AppSync supports several modes, including API keys, Cognito user pools, OIDC, and IAM, and IAM mode asks one question: does the calling principal have a policy that allows appsync:GraphQL on this API? It does not ask whose data the caller should see. That check, record by record, is the resolver's job, and here the resolvers never did it.
Guest access is a deliberate feature: it lets visitors use an app without logging in. It also means there is no standing user behind a guest identity to baseline. Each set is new and anonymous, used for one burst, then dropped, so abuse of the role leaves very little for identity-based detection to catch.
From anonymous credentials to a bulk read of 172 profiles.
How the enumeration worked
The profile API returned any user's record by ID, with no object-level authorization. Its resolver took a userId as an argument and returned that user's full profile without checking whether the caller was that user or had any relationship to the account. A valid guest credential plus any userId returned that user's data. This is a textbook insecure direct object reference (IDOR): the object identifier in the request is the only access control, and nothing validates it against the caller. OWASP catalogs the same flaw as Broken Object Level Authorization, which it ranks first in its API Security Top 10.
The one real obstacle was knowing which IDs to ask for. The attacker started from nine seed IDs. A checkUsername lookup by email returned four of them, but the attacker had already written two of those four into their own account attributes more than fifteen minutes before the lookups ran, so at most two IDs were genuinely learned this way.
The lookup was a query along these lines:
query {
checkUsername(email: "admin@customer.example") {
userId
hasLogin
}
}It returned the internal user ID for the address and a flag for whether the account had a login. That is how an email turned into a starting ID.
From there the attacker needed no more lookups. User IDs were sequential, so once they held a real one, the neighbors were easy guesses. They walked the ID ranges around their seeds one step at a time, and that is what turned a handful of lookups into a bulk extraction.
The attacker requested 401 real user IDs, and 384 of them fall in 13 contiguous ranges around the seeds. Most consecutive requests stepped by exactly one. The whole run took about half an hour, spread across eight guest credential sets, 604 requests in all, though one set alone accounted for 360 of them. Most came from a Python-urllib client, the rest from a browser user agent copied from the attacker's own sign-ups.
What the API served
The API served a profile for 172 of those IDs, and returned nothing for the other 229. Each served profile is tied to the attacker through the request and execution records, so the counts below are what the resolver actually returned, field by field.
A few other fields, including job discipline and job level, were served for fewer than ten users each.
Password hashes, sign-up IP addresses, and internal tracking fields were not served. The GraphQL schema and the resolver's response mapping leave them out, so they never left the backend. The most sensitive thing that did leave was the pair of legacy session values returned for 60 users, the values the application used to keep those users signed in.
What the attacker did with the data
The attacker did not stop at pulling the data down. They tried to use it. For four of the users whose session values they had collected, they replayed those values against the application's auth endpoint to resume their sessions. For one of the four, they also used the served email address to request a password-reset email.
None of it produced access. One user's values no longer matched the session cache. For two others there was no cached session to resume. For the fourth the values were accepted, but the token refresh behind them failed, so nothing was issued. The password reset did send a reset email to the real user, but it had not been completed by the end of that day. No session came back, and no account was taken over.
Other activity
Alongside the enumeration, the attacker did a few other things worth noting. They self-registered seven accounts in the Cognito user pool, five of which were confirmed and signed in. On one of those accounts they set profile attributes pointing at internal admin user IDs, apparently testing whether the application would treat the account as that admin. At the sign-in endpoint, it did not. Whether a downstream application that trusts those attributes would have been fooled was not assessed.
The guest identity that drew the first alert also ran AWS reconnaissance: 23 calls against services including DynamoDB, S3, IAM, Secrets Manager, and Cognito. Twenty-two were denied. The one that succeeded was GetCallerIdentity, which needs no permission and only confirms which identity a set of credentials maps to.
The whole operation carried clear penetration-test markers: addresses on a third-party domain, the label "Pentest Eval", a Kali Linux user agent, and methodical enumeration. No authorization for the testing was on record at the time of analysis, so the activity was treated as unauthorized until the customer could confirm otherwise.
Root cause
None of this needed a stolen credential or a software exploit. It worked because of how the application was configured, and four choices stand out:
- Guest credentials to anyone. Unauthenticated identities were enabled on the pool, and the guest role they mapped to was allowed to call the profile API. That alone put the API within reach of the entire internet.
- An API that trusted the caller’s input. The resolvers returned a profile for whatever userId a request named, and never checked whether the caller had any right to it. The authorization question was “can you call this API,” not “is this your data.” For a guest-reachable API, those are not the same question. The same gap covered writes: an updateUserProfile mutation, reachable with the same guest credentials, would edit any user’s profile, password included, with no reset email involved. The attacker never called it, and no one had in the 90 days of available logs.
- Open self sign-up. Anyone could create accounts in the user pool, which gave the attacker accounts to work from and a place to test attribute tampering.
- Session values in profile responses. The API handed back the application's legacy session values as ordinary profile fields. Session material should never ride along in a profile response, and here it did, for 60 users.
What security teams should take away
Back least privilege with a guardrail the configuration can't drift past. This is prevention, not detection: the goal is a guest role that never reaches a user-data API in the first place. Any unauthenticated role is reachable by the whole internet, so if a guest role can call an API that returns user data, assume anyone can. Scope the guest role to the fields guest features actually use. The grant here allowed every type and field on the API; scoped down to the form-configuration lookup, it looks like this:
{
"Effect": "Allow",
"Action": "appsync:GraphQL",
"Resource": "arn:aws:appsync:us-east-1:5821xxxxxxxx:apis/q7m2v9-xxxx/types/Query/fields/getTrxProfile"
}Then put a service control policy above the account, where no one in it can raise the ceiling. Tag the unauthenticated role exposure=public when it's created, deny public roles any data-plane action outside a short allowlist, and protect the tag itself:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyPublicRoleDataApisOutsideAllowlist",
"Effect": "Deny",
"Action": [
"appsync:GraphQL",
"execute-api:Invoke"
],
"NotResource": [
"arn:aws:appsync:us-east-1:5821xxxxxxxx:apis/q7m2v9-xxxx/types/Query/fields/getTrxProfile"
],
"Condition": {
"StringEquals": {
"aws:PrincipalTag/exposure": "public"
}
}
},
{
"Sid": "ProtectExposureTag",
"Effect": "Deny",
"Action": [
"iam:TagRole",
"iam:UntagRole"
],
"Resource": "*",
"Condition": {
"ForAnyValue:StringEquals": {
"aws:TagKeys": [
"exposure"
]
},
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/<platform-pipeline-role>"
}
}
}
]
}The ceiling is set once, at the organization, and a later mistake in a single account can't lift it (SCPs don't apply to the management account itself). Pair it with the AWS Config managed rule cognito-identity-pool-unauth-access-check (Security Hub control Cognito.2), which flags any identity pool with unauthenticated access enabled, and the misconfiguration can't come back quietly. Detection still matters. It just becomes the second line instead of the only one.
Authorize on the data, not just the endpoint. The guardrail keeps guests off the API; it doesn't fix the API. An API that can be reached without logging in has to confirm the caller owns a record before returning or changing it. Caller-scoped authorization would have made the userId in each request worthless to an attacker, however many they tried.
Keep session material out of API responses. The legacy session values served here were never meant to be a profile field. Returning them is what moved this from data exposure toward attempted session theft.
Sequential IDs requested in bulk are a scraping signature. One source stepping through user IDs a step at a time, especially on guest credentials, is worth an alert on its own, even when each single request looks normal.
What the Exabots detected
The whole case started from the one part of it CloudTrail could see. A Cognito guest identity assumed the unauthenticated role, and within seconds the same credentials were probing DynamoDB, S3, IAM, and Secrets Manager from a hosting provider, with a Boto3 client running on Kali Linux (Boto3/1.43.103 … os/linux#6.18.12+kali-cloud-amd64). A new guest identity on its own means nothing; unauthenticated pools mint one for every visitor, so a rule keyed on novelty either fires constantly or never. The Exabots set that aside as expected and weighed what came next: credentials validated and used for broad reconnaissance within nine seconds, from rented infrastructure. They pulled in the context without being asked, including the hosting provider, the address’s prior abuse report, and two open GuardDuty findings already flagging the same identity for Kali Linux pen-test tooling, and concluded with high confidence that it needed investigation.
That alert covered one guest identity out of eight. The other seven never called the AWS APIs that land in CloudTrail; they went straight at the AppSync profile API, and in CloudTrail those identities appear only as AssumeRoleWithWebIdentity events from Cognito, with nothing about what they did next. Seeing the rest meant reading logs CloudTrail does not carry.
When anyone asks the identity pool for guest credentials, Cognito creates an anonymous identity and calls AWS STS on the caller’s behalf. CloudTrail logs that role assumption from Cognito’s own address, not the caller’s, and that event is the record of when each credential set was issued. AppSync records what those credentials then asked for, but only when request logging is turned on. Neither is enough on its own, and seeing guest-credential abuse end to end takes four sources:
- CloudTrail: Cognito identity-pool and STS events, which show when each credential set was issued, plus user-pool events such as SignUp and InitiateAuth.
- AppSync request logging in CloudWatch, with field-level logging set to at least ERROR: each request made with those credentials, its source address, and its user agent.
- Resolver Lambda logs: what each request resolved to.
- Lambda invocation data events in CloudTrail, and API Gateway access logs: the other paths guest and logged-in roles can reach. Both were missing here.
Joined by issuance time and by each request’s execution window, those sources turned eight anonymous role assumptions into 604 attributed requests and 172 served profiles.
The Exabots detected it on the assume-role anomaly: guest credentials acting like an attacker seconds after they were issued, recognized as such in a place where new identities are normally noise. That alert was the start. Reading the Cognito records and the AppSync logs together showed what the other seven credential sets did and what the API handed back.





