flaws2.cloud — Attacker Path — Level 2¶
Vulnerability: Public ECR image with secrets baked into Docker layer history → plaintext credentials visible via docker history
Level 1 Recap — Three Lessons¶
Before jumping in, the official level 1 lessons are worth embedding properly:
1. Lambda credentials come from environment variables, not the metadata service.
EC2 uses 169.254.169.254. Lambda uses injected env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN). Dumping process.env in an error handler exposes them to anyone who can trigger the error.
2. Least Privilege — for real.
The Lambda role had s3:ListBucket on a bucket it never needed to access. Permissions that aren't actively used for the function's purpose are attack surface. Use AWS Access Advisor or CloudTrail to identify unused permissions and strip them.
3. Never assume validation happened upstream. Serverless architectures chain many components: client → API Gateway → Lambda → downstream services. Each piece may assume something earlier in the chain already validated the input. Don't. Validate at every layer that cares about correctness.
The Setup¶
The target is a containerised web app running at http://container.target.flaws2.cloud/. It's protected by HTTP basic auth. The hint says the ECR repo is named level2.
The attack: use the level 1 credentials to pull the container image from ECR, inspect it, and extract the credentials baked into the image.
Core Concepts First¶
What is Docker?¶
Docker is a containerisation platform. A container is a lightweight, isolated process that packages an application and all its dependencies (libraries, runtime, config files) into a single portable unit. Containers run the same way regardless of what machine they're on.
Container vs VM:
| VM | Container | |
|---|---|---|
| Isolation | Full OS per VM | Shared OS kernel, isolated process |
| Size | GBs | MBs |
| Startup time | Minutes | Seconds |
| What it contains | Full OS + app | App + libraries only |
Think of it this way: a VM is like a full apartment (its own electricity, plumbing, walls). A container is like a room in a shared apartment — its own space but shares the building's infrastructure.
Docker image — a read-only template. Like a blueprint or a snapshot. Immutable. Can be stored in a registry (ECR, Docker Hub).
Docker container — a running instance of an image. You run an image, you get a container. Like a process vs a program.
Docker layers — images are built in layers. Each RUN, COPY, ADD instruction in a Dockerfile creates a new layer. Layers are stacked. This matters for security because every command ever run to build the image is preserved in the layer history.
What is ECR?¶
ECR = Elastic Container Registry. AWS's managed private Docker image registry. Think of it as Docker Hub but inside your AWS account — authenticated, private, integrated with IAM.
ECR structure:
- One registry per AWS account, identified by account ID
- Multiple repositories within the registry (one per app/service)
- Multiple images within each repository, identified by tag (latest, v1.2, etc.)
ECR URL format — always:
For this level:
You had all the pieces:
| Piece | Value | Source |
|---|---|---|
| Account ID | 653711331788 | sts get-caller-identity from level 1 |
| Region | us-east-1 | Known throughout |
| Repo name | level2 | Given in the hint |
| Tag | latest | Standard default, always try first |
ECR registry ID = AWS account ID. Every AWS account has exactly one ECR registry. Its identifier is the account's 12-digit ID. This is why --registry-id 653711331788 in the describe command means "look in this account's ECR".
Step 1 — Enumerate ECR Repositories¶
aws ecr describe-repositories --region us-east-1 --profile flaws2-level1
# Or explicitly target the account:
aws ecr describe-repositories --registry-id 653711331788 --region us-east-1 --profile flaws2-level1
Breakdown:¶
| Part | What it does |
|---|---|
aws ecr describe-repositories |
List all repositories in the registry |
--registry-id 653711331788 |
Explicitly specify which account's registry to query. Without this it defaults to your own account's registry — which is wrong here since you're using stolen creds for someone else's account |
--region us-east-1 |
ECR is regional — must specify the correct region |
--profile flaws2-level1 |
Use the stolen Lambda credentials |
Returns the level2 repo with its URI: 653711331788.dkr.ecr.us-east-1.amazonaws.com/level2.
Step 2 — Authenticate Docker to ECR¶
ECR is a private registry. Before you can pull images, Docker needs to authenticate against it. AWS doesn't use static passwords for ECR — it exchanges your IAM credentials for a short-lived Docker registry token.
aws ecr get-login-password --region us-east-1 --profile flaws2-level1 \
| docker login --username AWS --password-stdin 653711331788.dkr.ecr.us-east-1.amazonaws.com
Breaking this down piece by piece:¶
aws ecr get-login-password:
- Calls the ECR API using your IAM credentials
- Returns a temporary Docker authentication token (valid for 12 hours)
- Outputs it as plain text to stdout
| (pipe):
- Takes the stdout of the left command and feeds it as stdin to the right command
- So the token becomes the input for docker login
docker login --username AWS --password-stdin <registry-url>:
| Part | What it does |
|---|---|
docker login |
Authenticate Docker to a registry so subsequent pull/push commands work |
--username AWS |
The username for ECR authentication. Always literally AWS — hardcoded by AWS, not your IAM username |
--password-stdin |
Read the password from stdin instead of prompting interactively. Avoids the password showing in shell history |
653711331788.dkr.ecr.us-east-1.amazonaws.com |
The registry URL — which registry you're logging into |
The full flow:
Your IAM credentials (in AWS profile)
↓
aws ecr get-login-password → generates short-lived Docker token
↓ (piped via |)
docker login reads token from stdin as the password
↓
Docker stores auth token in ~/.docker/config.json
↓
Subsequent docker pull/push commands against this registry are authenticated
Why a temporary token instead of a password? Consistent with AWS's "everything is temporary" security model. The same principle as Lambda's AWS_SESSION_TOKEN and EC2's metadata-issued credentials. Your IAM identity is the source of truth — everything else derives from it with a time limit.
Step 3 — Pull the Image¶
Breakdown:¶
| Part | What it does |
|---|---|
docker pull |
Download an image from a registry to your local machine |
653711331788.dkr.ecr.us-east-1.amazonaws.com/level2:latest |
Full image reference: registry/repo:tag |
Docker downloads all the image layers and stores them locally. Now you have the image and can inspect it without network access.
Step 4 — Inspect the Image History¶
What docker history shows:¶
Every command that was run to build the image, in reverse order (newest first). Each line is a layer. This is the build history — every RUN, COPY, ENV, ADD from the Dockerfile.
Output (truncated):
IMAGE CREATED CREATED BY SIZE
<layer> 7 years ago /bin/sh -c htpasswd -b -c /etc/nginx/.htpass… 0B
...
Notice the truncation (…) — the command is cut off. This is the default behaviour.
--no-trunc — show the full command without truncation. Full output:
There it is: plaintext credentials in the image build history.
- Username: flaws2
- Password: secret_password
Why Docker History Exposes Secrets¶
What is htpasswd?¶
htpasswd is a utility for managing HTTP basic auth password files (used by nginx, Apache). It hashes passwords and stores them in a file (.htpasswd).
Contents look like:
Format: username:hashed_password
The -b flag is the problem:¶
| Flag | What it does |
|---|---|
-b |
"Batch mode" — take the password directly from the command line argument, not interactively |
-c |
Create the file (overwrite if exists) |
/etc/nginx/.htpasswd |
Output file |
flaws2 |
Username |
secret_password |
The plaintext password, on the command line |
Why -b is dangerous: The password is on the command line. Command line arguments are:
- Visible in ps aux output while the process runs
- Stored in shell history (~/.bash_history)
- Permanently stored in docker history as part of the layer metadata
Even though the .htpasswd file only stores the hash, the RUN command that created it is stored forever in the image. Every layer's creation command is baked into the image manifest. You can't remove it without rebuilding the image from scratch.
The hash (for reference):¶
$apr1$ = Apache's MD5-based crypt hash (APR1-MD5). Format:
This hash is crackable, but in this case you don't need to crack it because the plaintext is already in the image history.
Password Cracking (for when history doesn't help)¶
If the history had been clean and you only had the hash, this is how you'd crack it.
John the Ripper¶
# Save the hash to a file
echo '$apr1$jJh7fsij$wJV.a0WR6hAZ51/r11myl/' > hash.txt
# Crack with a wordlist
john --wordlist=~/rockyou.txt hash.txt
John the Ripper is a password cracking tool. It takes a hash file, tries every word in the wordlist, hashes each one, and checks if the result matches. When it finds a match, the plaintext is the password.
Install on Mac:
john-jumbo is the community-enhanced version with support for hundreds of hash formats (standard john only handles a subset).
hashcat¶
| Part | What it does |
|---|---|
hashcat |
GPU-accelerated password cracking tool. Much faster than John on large wordlists |
-m 1600 |
Hash type: Apache $apr1$ MD5 format. Every hash format has a number |
'$apr1$...' |
The hash to crack (in single quotes to prevent shell interpretation of $) |
~/rockyou.txt |
The wordlist |
Install on Mac:
Common hashcat mode numbers:¶
| Mode | Hash type |
|---|---|
0 |
MD5 |
100 |
SHA1 |
1000 |
NTLM (Windows) |
1600 |
Apache $apr1$ MD5 |
1800 |
SHA-512 Unix ($6$) |
3200 |
bcrypt ($2*$) |
5600 |
NetNTLMv2 |
13100 |
Kerberos TGS-REP (AS-REP roasting) |
rockyou.txt — What it is¶
rockyou.txt is a wordlist of ~14 million real passwords leaked in the 2009 RockYou data breach. It's the standard starting wordlist for password cracking because:
- Real passwords people actually chose — not random strings
- Covers the overwhelming majority of weak/common passwords
- Small enough to run quickly (134MB)
Download it:
curl -L https://github.com/brannondorsey/naive-hashcat/releases/download/data/rockyou.txt -o ~/rockyou.txt
Or on Kali/Parrot Linux it's at /usr/share/wordlists/rockyou.txt.
Why secret_password wouldn't be in rockyou¶
Wordlists contain actual leaked passwords. secret_password with an underscore is a constructed, artificial password — not the kind of thing a real user chooses. Real breach passwords look like iloveyou, password123, dragons, qwerty, 123456789.
A rules-based attack might find it — rules can append numbers, substitute characters, combine words with separators:
Rules like "take a word, add_password" might generate secret_password. But straight dictionary won't hit it.
Step 5 — Explore the Container Interactively¶
Breakdown:¶
| Part | What it does |
|---|---|
docker run |
Create and start a container from an image |
-i |
Interactive — keep stdin open |
-t |
Allocate a pseudo-TTY (terminal). Makes it behave like a normal shell session |
-it |
Always combine these for interactive shells |
level2:latest |
The image to run |
/bin/sh |
The command to run inside the container instead of the default. Gives you a shell |
This drops you into a shell inside the container. You can poke around the filesystem, check config files, read source code, look for more credentials. LAMBDA_TASK_ROOT: /var/task for Lambda containers. For nginx containers it's typically:
The Full Attack Chain¶
Level 1 stolen Lambda credentials (flaws2-level1 profile)
↓
aws ecr describe-repositories --registry-id 653711331788
→ finds repo: level2
↓
aws ecr get-login-password | docker login
→ authenticates Docker to ECR using IAM credentials
↓
docker pull 653711331788.dkr.ecr.us-east-1.amazonaws.com/level2:latest
→ downloads image locally
↓
docker history --no-trunc <image>
→ shows every build command in full
→ /bin/sh -c htpasswd -b -c /etc/nginx/.htpasswd flaws2 secret_password
↓
Browse to http://container.target.flaws2.cloud/
→ basic auth prompt
→ login: flaws2 / secret_password
→ level complete
Vulnerability Summary¶
The flaw: Credentials passed on the command line during Docker image build. docker history preserves every build command permanently — including plaintext secrets.
Why this is common: Developers building Docker images need to configure things (set passwords, write config files, install credentials). The path of least resistance is to pass values as command-line arguments or environment variables in RUN commands. Both are permanently visible in the image history.
The additional flaw: The ECR image was readable by the stolen Lambda role. The Lambda role had ecr:GetAuthorizationToken, ecr:BatchGetImage, and ecr:GetDownloadUrlForLayer permissions — more than it needed.
The fix:
- Never pass secrets as command-line arguments in RUN commands — they end up in layer history forever
- Use Docker BuildKit's secret mount feature: RUN --mount=type=secret — secrets are available during build but not stored in layers
- Alternatively: don't bake credentials into the image at all. Pass them at runtime via environment variables, AWS Secrets Manager, or a secrets manager
- Scope ECR permissions tightly — a Lambda function shouldn't have permission to pull arbitrary images from ECR unless it specifically needs to
- Audit images with docker history --no-trunc before pushing to any registry
What docker history reveals — always check:
- RUN htpasswd -b ... — plaintext passwords
- RUN curl ... -u user:pass ... — credentials in curl commands
- ENV SECRET_KEY=... — hardcoded environment variables
- RUN echo "password" > /etc/... — secrets written to files via echo
- ADD credentials /root/.aws/ — credentials files copied in (the file content won't show but you can extract the layer)