一道有趣的CTF赛题-unicode引发的WebAssembly与js交互问题

一万年前投在tttang上的一篇文章,转载到自己博客上

重看一遍发现有很多地方说的不够严谨深入……但是懒得改了

主要是想重新经营下自己的博客了,先拿这篇凑数吧

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语言源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

同时通过网页中的js代码可以看到与WebAssembly的交互过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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函数部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

对字符串的处理进行到length处就停止了,但是js代码在读取的时候

1
2
3
4
while(buf != 0) {
mocking += String.fromCharCode(buf);
buf = blazingfast.read();
}

却是一直读取到\0为止。

也就是说,如果我们能构造一个特殊字符串,使length的值比传给wasm的字符串的真实长度要小,就能使length之后的字符不被检查

1
2
3
4
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字符,会在转换后变成两个甚至三个英文字母!

1
2
'ß'.toLowerCase() // 'ss'
'ß'.toLowerCase() === 'SS'.toLowerCase() // true

XSS

如果我们在输入的字符串中添加这些特殊字符,js获取并告知wasm的长度就会比转换成大写后字符串的真实长度要小,从而绕过过滤!

虽然payload会全部被转成大写,不过我们可以通过实体编码的方式解决

alert(1)

最后使用一个观感极佳(?)的payload成功获取flag


0x03 总结

比赛时临时学了一下wasm,不过好在对wasm的考察程度并不深,最后靠一点运气和灵感做出来了。比赛时也找到一些其他的关于wasm安全的资料,提到了一些关于wasm的pwn技巧,这里就不作过多延展了

可能是国际比赛的原因,还吃了网络的亏……一开始在自己的服务器上监听,笑死,根本打不通,还以为是自己payload写的有问题,改了半天。换了好几个平台试,最后用一开始的payload打通了……

这道题从本质上来说,还是接口调用时的处理没有做好才导致问题出现

最后,再默念一遍——开发和安全缺一不可!