flaws2.cloud — Attacker Path — Level 1¶
Vulnerability: Client-side-only input validation on a form → API callable directly with arbitrary input → Lambda crashes and dumps all environment variables including live AWS credentials
The Vulnerability Explained¶
The web page has a form with a JavaScript validation function that checks the input is a number before submitting. The developer thought: "I've validated the input." But JavaScript validation only runs in the user's browser — it has zero effect on what you send to the server directly.
The actual API endpoint is just a URL. Nothing stops you from hitting it with any input you want, bypassing the browser entirely.
Step 1 — The JavaScript "Validation"¶
function validateForm() {
var code = document.forms["myForm"]["code"].value;
if (!(!isNaN(parseFloat(code)) && isFinite(code))) {
alert("Code must be a number");
return false;
}
}
What this does:¶
| Part | What it means |
|---|---|
document.forms["myForm"]["code"].value |
Get the value from the form field named "code" |
parseFloat(code) |
Try to parse it as a decimal number |
isNaN(...) |
Is it "Not a Number"? |
isFinite(code) |
Is it a finite number (not Infinity)? |
return false |
If validation fails, prevent form submission |
Why this is not security:¶
Client-side validation runs in your browser. You can disable JavaScript entirely, open the browser dev tools and delete the function, or just ignore the form and call the API URL directly. The server never enforces this — it just receives whatever you send.
Rule: Client-side validation = UX convenience only. Never security. Any validation that matters must happen on the server.
Step 2 — The API Is Just a URL¶
This is an API Gateway URL (same pattern as flaws.cloud level 6):
The form submits to this URL with ?code=1234 as a query parameter. Since it's a GET request, you can just type this URL directly in a browser or use curl.
What GET 301 means: A 301 Moved Permanently redirect — the server is telling you the resource has moved to another URL and you should follow the redirect. The browser follows it automatically. In this context it's just the API routing — not an error.
Bypass: send non-numeric input directly¶
The JavaScript would have blocked undefined in the browser. The API has no such check — it passes undefined to the Lambda function's code. The Lambda code tries to process it, fails, crashes, and returns an error message containing its full environment state.
Step 3 — The Error Response Leaks Everything¶
When the Lambda crashes with malformed input, it returns:
Error, malformed input
{"AWS_REGION":"us-east-1","AWS_SESSION_TOKEN":"IQoJb3Jp...","AWS_ACCESS_KEY_ID":"<REDACTED_ACCESS_KEY_ID>","AWS_SECRET_ACCESS_KEY":"<REDACTED_SECRET_KEY>", ...}
Why does Lambda have AWS credentials in environment variables?¶
When a Lambda function runs, AWS automatically injects the credentials for the function's attached IAM role directly as environment variables. The function uses these to make AWS API calls (access S3, call other services, etc.). The developer doesn't explicitly set them — AWS puts them there automatically every time the function is invoked.
The full set of auto-injected credential env vars:
- AWS_ACCESS_KEY_ID — the key ID (starts with ASIA = temporary/role credential)
- AWS_SECRET_ACCESS_KEY — the secret key
- AWS_SESSION_TOKEN — the session token (required alongside the above two for temporary creds)
The flaw: The error handler printed the entire process.env (all environment variables) in the error response. The developer wrote a debug error handler that was never removed from production. Credentials that were only supposed to be used internally by the Lambda are now fully visible to anyone.
Other interesting environment variables in the dump:¶
| Variable | Value / What it reveals |
|---|---|
AWS_REGION |
us-east-1 — the region |
AWS_EXECUTION_ENV |
AWS_Lambda_nodejs8.10 — Node.js 8.10 runtime (EOL) |
AWS_LAMBDA_FUNCTION_NAME |
level1 — the function name |
AWS_LAMBDA_FUNCTION_VERSION |
$LATEST — not a pinned version |
AWS_LAMBDA_FUNCTION_MEMORY_SIZE |
128 — 128MB RAM |
AWS_LAMBDA_LOG_GROUP_NAME |
/aws/lambda/level1 — CloudWatch log group |
AWS_XRAY_DAEMON_ADDRESS |
169.254.79.129:2000 — X-Ray tracing daemon (link-local again) |
LAMBDA_TASK_ROOT |
/var/task — where the function code lives on the container |
_HANDLER |
index.handler — entry point: file index.js, function handler |
NODE_PATH |
Runtime module paths |
LAMBDA_TASK_ROOT: /var/task is worth noting — if you ever get code execution on a Lambda, that's where the source code is.
Step 4 — Configure the Stolen Credentials¶
What each command does and why¶
aws configure --profile flaws2-level1
# AccessKeyId: <REDACTED_ACCESS_KEY_ID>
# SecretAccessKey: <REDACTED_SECRET_KEY>
# region: us-east-1
aws configure is an interactive wizard that writes credentials to ~/.aws/credentials and config to ~/.aws/config under the profile name you specify. Without --profile, it writes to the [default] block. With --profile flaws2-level1, it creates a named block [flaws2-level1].
What it writes to ~/.aws/credentials:
[flaws2-level1]
aws_access_key_id = <REDACTED_ACCESS_KEY_ID>
aws_secret_access_key = <REDACTED_SECRET_KEY>
Why a separate command? aws configure only asks for 4 things: Access Key ID, Secret Access Key, region, and output format. It has no field for a session token. So you add it separately with configure set.
What is a session token and why does it exist?¶
Session tokens are the third piece of temporary credentials. They exist because of how AWS's temporary credential system works:
Permanent IAM user credentials (AKIA...):
- Just two pieces: Access Key ID + Secret Access Key
- Never expire (until manually deleted)
- Long-lived — the access key IS the identity
Temporary credentials (ASIA...):
- Three pieces: Access Key ID + Secret Access Key + Session Token
- Always expire (minutes to hours)
- The session token is a cryptographically signed assertion from STS that says: "these temporary keys are valid until this expiry time, for this role"
- AWS validates the token server-side on every API call
Why use temporary credentials at all? They're more secure than permanent ones because they expire. Even if an attacker steals them (like we just did), they stop working after the Expiration time. Permanent keys work until someone manually rotates or deletes them.
The session token is essentially the proof. Without it, the Access Key ID and Secret Key are useless — AWS sees ASIA... and knows it must be a temporary credential, so it requires the token to validate.
What is STS?¶
STS = Security Token Service. The AWS service that issues temporary credentials. When a Lambda function starts, it calls STS behind the scenes to get temporary credentials for its IAM role. Those credentials come back with an expiry time. That's what's in the Lambda's environment variables.
STS is also how you explicitly assume roles, federate with external identity providers, and implement cross-account access.
What it does: Asks AWS "who do these credentials belong to?" Returns a guaranteed-accurate answer from AWS itself — not cached, not guessed.
Output:
{
"UserId": "AROAIBATWWYQXZTTALNCE:level1",
"Account": "653711331788",
"Arn": "arn:aws:sts::653711331788:assumed-role/level1/level1"
}
| Field | Value | What it means |
|---|---|---|
UserId |
AROAIBATWWYQXZTTALNCE:level1 |
Role ID : session name. The session name is set when the role is assumed — here it's level1 (the Lambda set it) |
Account |
653711331788 |
The AWS account ID these credentials belong to |
Arn |
arn:aws:sts::653711331788:assumed-role/level1/level1 |
Full identity ARN |
How to Read an ARN to Determine Credential Type¶
The ARN structure tells you everything:
arn:aws:sts::653711331788:assumed-role/level1/level1
↑↑↑ ↑↑↑↑↑↑↑↑↑↑↑
STS service assumed-role = temporary, from a role
Compare to a permanent IAM user from flaws.cloud level 6:
| ARN contains | Means |
|---|---|
iam:...:user/ |
Permanent IAM user — two-piece credentials, no session token needed |
sts:...:assumed-role/ |
Temporary credentials assumed by a service/role — three-piece credentials, session token required |
sts:...:federated-user/ |
Temporary credentials issued via web identity federation |
How you know it's Lambda specifically:
Lambda functions don't have permanent credentials. They always assume an IAM role at runtime. So assumed-role in the ARN = almost certainly a service (Lambda, EC2, ECS task, etc.) rather than a human. Combined with the environment variables (AWS_EXECUTION_ENV: AWS_Lambda_nodejs8.10, AWS_LAMBDA_FUNCTION_NAME: level1), it's confirmed.
Step 5 — Enumerate What This Role Can Do¶
Result: AccessDenied — s3:ListAllMyBuckets not allowed. This role can't enumerate all S3 buckets.
Lesson: Just because you have credentials doesn't mean you have full access. Always enumerate permissions first.
Check the role's permissions¶
aws iam get-role --role-name level1 --profile flaws2-level1
aws iam list-attached-role-policies --role-name level1 --profile flaws2-level1
get-role — returns the role's trust policy (which services/identities are allowed to assume this role) and metadata.
list-attached-role-policies — returns which IAM policies are attached to the role. The policies define what the role can actually do.
What is IAM?¶
IAM = Identity and Access Management. The AWS service that controls who can do what. Everything in AWS goes through IAM.
| IAM Concept | What it is |
|---|---|
| User | A permanent human identity with long-lived credentials |
| Role | An identity meant to be assumed temporarily — by services, cross-account users, or other roles. No permanent credentials |
| Policy | A JSON document listing allowed/denied actions on resources |
| Attached policy | A managed policy linked to a user/role/group |
| Inline policy | A policy embedded directly in a user/role (not reusable) |
| Trust policy | Controls who can assume a role (e.g., lambda.amazonaws.com can assume this role) |
| Permission policy | Controls what the role can do once assumed |
get-role vs list-attached-role-policies:
- get-role → who is allowed to assume this role (the trust policy)
- list-attached-role-policies → what the role can do (permission policies)
You need both to understand a role fully.
Enumerate other services¶
aws lambda list-functions --profile flaws2-level1 --region us-east-1
aws sns list-topics --profile flaws2-level1 --region us-east-1
aws sqs list-queues --profile flaws2-level1 --region us-east-1
Broad enumeration — try everything the SecurityAudit-style approach suggests. See what errors you get (AccessDenied) vs what returns data.
What is ECR?¶
ECR = Elastic Container Registry. AWS's private Docker image registry — like Docker Hub but hosted in your AWS account. If a team is running containerised workloads (ECS, Fargate, EKS), their Docker images live in ECR.
Why check ECR? Docker images often contain: - Hardcoded credentials left in the image layers - Application source code - Config files with secrets - A buildable version of the app you can inspect
If the role has ecr:GetAuthorizationToken and ecr:BatchGetImage, you can pull images and inspect every layer.
Step 6 — Access the Level S3 Bucket¶
Even though the role can't list all buckets (s3:ListAllMyBuckets), it can list a specific bucket (s3:ListBucket) if the policy allows it — or if the bucket is publicly readable.
The distinction:
- s3:ListAllMyBuckets — list every bucket in the account. This is what aws s3 ls (no bucket name) does.
- s3:ListBucket — list the contents of one specific bucket. This is what aws s3 ls s3://bucketname does.
These are separate permissions. A role can have one without the other.
Result: bucket listing succeeds → you see secret-ppxVFdwV4DDtZm8vbQRvhxL8mE6wxNco.html → that's the next level.
The Full AWS Credential Flow for Lambda (Mental Model)¶
Lambda function is invoked
↓
AWS Lambda service calls STS:AssumeRole for the function's attached IAM role
↓
STS issues temporary credentials (AccessKeyId + SecretAccessKey + SessionToken)
with an expiry time (typically 12 hours for Lambda)
↓
AWS injects all three into the Lambda container as environment variables:
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
AWS_SESSION_TOKEN
↓
Lambda code can now make AWS API calls — automatically authenticated
↓
When the function crashes and dumps process.env... you have all three
Vulnerability Summary¶
The flaws (two separate issues combined):
-
Client-side-only validation: Input filtering runs in JavaScript in the browser. Easily bypassed by calling the API directly with any value.
-
Unhandled errors expose environment variables: The Lambda's error handler printed
process.env— all environment variables — in the HTTP response. Environment variables in Lambda always contain the function's IAM role credentials.
The attack chain:
1. Observe the form — it submits ?code=<number> to an API Gateway URL
2. Call the API directly with non-numeric input (?code=undefined)
3. Lambda crashes → error response dumps all environment variables
4. Extract AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN
5. aws configure --profile flaws2-level1 → enter the keys
6. aws configure set aws_session_token <token> --profile flaws2-level1
7. aws sts get-caller-identity → confirm identity (Lambda's IAM role)
8. Enumerate what the role can access → find s3://level1.flaws2.cloud
9. aws s3 ls s3://level1.flaws2.cloud → find the secret HTML file → level 2
The fix: - Validate input on the server, not (only) the client - Never print environment variables in error responses — use structured error messages that reveal nothing about the server environment - Scope Lambda IAM roles to the minimum permissions needed - In production: use structured exception handling with a generic error response; log details to CloudWatch only - Enable Lambda error alerting — a crash that returns credentials in the body should trigger an immediate incident