0x00 前言
在前几天的DiceCTF里,遇到了一道关于WebAssembly的题目。解题时利用Unicode字符的转换问题,使前端js脚本与WebAssembly进行交互时出现异常,以达到XSS的效果。
解题时也了解了一些关于WebAssembly
的知识,算是有所收获
0x01 WebAssembly介绍
在看题目之前,我们先来了解一下WebAssembly是什么
按照WebAssembly官网的介绍:
WebAssembly/wasm WebAssembly 或者 wasm 是一个可移植、体积小、加载快并且兼容 Web 的全新格式
WebAssembly 有一套完整的语义,实际上 wasm 是体积小且加载快的二进制格式, 其目标就是充分发挥硬件能力以达到原生执行效率
WebAssembly 运行在一个沙箱化的执行环境中,甚至可以在现有的 JavaScript 虚拟机中实现。在web环境中,WebAssembly将会严格遵守同源策略以及浏览器安全策略。
WebAssembly 设计了一个非常规整的文本格式用来、调试、测试、实验、优化、学习、教学或者编写程序。可以以这种文本格式在web页面上查看wasm模块的源码。
WebAssembly 在 web 中被设计成无版本、特性可测试、向后兼容的。WebAssembly 可以被 JavaScript 调用,进入 JavaScript 上下文,也可以像 Web API 一样调用浏览器的功能。当然,WebAssembly 不仅可以运行在浏览器上,也可以运行在非web环境下。
因为官方介绍说的不太人话,人类比较难读懂,所以再贴一段别的地方找的介绍:
WebAssembly(缩写为 wasm)是一种使用非 JavaScript 代码,并使其在浏览器中运行的方法。这些代码可以是 C、C++ 或 Rust 等。它们会被编译进你的浏览器,在你的 CPU 上以接近原生的速度运行。这些代码的形式是二进制文件,你可以直接在 JavaScript 中将它们当作模块来用。
概括一下重点:WebAssembly与js一样可以在浏览器上运行,而且由于其更“底层”,所以会有运行速度上的优势。
速度优势也是WebAssembly的主要卖点。
0x02 解题
[DiceCTF2022]blazingfast
题目主页是一个WebAssembly实现的大小写转换器,可以把输入的句子转换成大小写交替的形式
还给出了一个adminbot页面,可以让带有bot访问url,不过必须符合/^https:\/\/blazingfast\.mc\.ax\//
的正则,也就是只能访问上面说的那个大小写转换器
可以从给出的源码里看到flag就在bot的localStorage中
所以本题的思路很清晰:通过xss,让bot向我们发送flag就行了。
业务逻辑
本题中直接给出了WebAssembly的c语言源码
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] == '&' || buf[i] == '"') {
return 1;
}
}
ptr = 0;
return 0;
}
同时通过网页中的js代码可以看到与WebAssembly的交互过程
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);
}
})
大致的功能实现逻辑如下:
前端js获取用户输入的字符串,以该字符串的长度初始化wasm程序的length值
-->js将用户输入转换成大写后写入wasm的buf数组
-->wasm程序将buf数组中的字符转成大小写交替的形式
-->js读取wasm的buf数组,将字符串输出在界面中
有三个过滤点:
字符串长度不能超过1000
字符的unicode编码值不能超过128
不能含有<
>
&
"
其中主要是第三个过滤点防止了我们进行XSS,后续考虑怎么绕过这个过滤
代码审计
我们细看wasm源码中的mock函数部分
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] == '&' || buf[i] == '"') {
return 1;
}
}
ptr = 0;
return 0;
}
对字符串的处理进行到length
处就停止了,但是js代码在读取的时候
while(buf != 0) {
mocking += String.fromCharCode(buf);
buf = blazingfast.read();
}
却是一直读取到\0为止。
也就是说,如果我们能构造一个特殊字符串,使length
的值比传给wasm的字符串的真实长度要小,就能使length
之后的字符不被检查
for (let c of str.toUpperCase()) {
if (c.charCodeAt(0) > 128) return 'Nice try.';
blazingfast.write(c.charCodeAt(0));
}
虽然这里限制了字符的unicode值不能大于128,但是多亏了这个toUpperCase
方法,我们能够利用一些奇技淫巧来进行下一步操作
神奇的Unicode大小写转换
这篇文章提到:某些unicode字符会在大小写转换时被转成英文字母。
更神奇的是,甚至有的unicode字符,会在转换后变成两个甚至三个英文字母!
'ß'.toLowerCase() // 'ss'
'ß'.toLowerCase() === 'SS'.toLowerCase() // true
XSS
如果我们在输入的字符串中添加这些特殊字符,js获取并告知wasm的长度就会比转换成大写后字符串的真实长度要小,从而绕过过滤!
虽然payload会全部被转成大写,不过我们可以通过实体编码的方式解决
最后使用一个观感极佳(?)的payload成功获取flag
0x03 总结
比赛时临时学了一下wasm,不过好在对wasm的考察程度并不深,最后靠一点运气和灵感做出来了。比赛时也找到一些其他的关于wasm安全的资料,提到了一些关于wasm的pwn技巧,这里就不作过多延展了
可能是国际比赛的原因,还吃了网络的亏……一开始在自己的服务器上监听,笑死,根本打不通,还以为是自己payload写的有问题,改了半天。换了好几个平台试,最后用一开始的payload打通了……
这道题从本质上来说,还是接口调用时的处理没有做好才导致问题出现
最后,再默念一遍——开发和安全缺一不可!