前后端安全通信,WebAssembly直呼666
WebAssembly 这个名词出来也挺长时间了,老页面仔除了日常搬砖,也没机会一睹芳容,正好最近所谓的安全团队挑事,给了一些机会近距离接触下。
注:本文来自一位好友F的亲身经历,后文为方便叙述,使用第一人称来展开。
本文大纲:
- 一个公司的故事,关于前后端通信安全的大讨论
- ChatGPT:
WebAssembly介绍 - 基于
WebAssembly的前后端 ”安全“ 通信方案
故事的开头
公司有专门的所谓安全团队,会不定时的来挑一些系统扫描,看看有没有什么漏洞,比如前后端通信有没有额外加密(是的,额外加密,就是让秋儿在应用层实现一个简单的 https 机制)、你的webpack 的 source-map 有没有正常关闭(说的就是测试环境,想不到吧,就找你测试环境来挑刺,坚决不能让你在测试环境方便测试&定位bug)、JS代码有没有混淆(是的,只要他们看不懂,那就是棒棒哒)。
可能最近帝都的天气不太好,影响到了该团队心情,决定挑几个系统,搞点 所谓 的安全漏洞祭天。嗯,刚好我负责的系统中奖了。
想来去年也中奖过几次了,这次应该没有什么问题,被他们拿来说事了吧。事实证明,还是低估了他们。所谓安全团队的大佬,直接在自己浏览器里,打开代码调试界面,打上断点,哟西,居然打断点能看到前端的加密秘钥!马上立刻整改!
看着是AES秘钥,但其实我们是模仿了 https 的那套加解密流程:
- 前端代码保存后端提供的
RSA公钥A - 前端JS在页面加载时,会生成随机的
16字节长度的字符串,作为AES秘钥B - 前后端通信的消息体,都使用
AES秘钥B来加解密 - 前端在请求后端接口时,会在请求
header里带上(使用RSA对AES秘钥B进行加密之后的)秘钥C - 后端使用
RSA私钥解密C,得到AES的秘钥B
这套流程里,其实 AES 秘钥不管能不能 在JavaScript里打断点看到 ,都对系统安全不会有任何影响。(严格来说,这套机制也是没个卵用,对系统安全的惟一影响就是,增加了前后端联调、定位问题的复杂度,棒棒哒)。
下面就是我和所谓安全大佬(下面简称“安全”)的对话:
安全:你这AES秘钥我打断点能看到啊,秘钥泄漏了,很危险,属于高危漏洞,必须整改
我:我们其实是用RSA来加密了AES秘钥的,而且这个秘钥每个人不一样,即使攻击者看到自己的,也没什么用
安全:那不行,打断点能看到,就会被攻击者利用
我:那我秘钥总得有个地方存吧,会JavaScript的都能打断点看到
安全:你能不能不用变量来存储密钥吗?
我:感觉有点难度(不用变量,我存你手机上么,每次要的时候,给你打电话?)
安全:试试 web worker 或者 web crypto api ,直接在浏览器内部处理,不暴露给JavaScript。或者试试禁止调试
我:web worker 也只是一个JavaScript文件,也可以打断点看到。web crypto api 就是一套加密的API,也需要地方存储秘钥啊(这里我说错了,后来尝试了下这个 web crypto api ,确实可以不暴露秘钥,但是由于我们还需要把AES传给后端,所以最终还是不能采用)。
我:其实这些安全措施都没用,我在自己浏览器上,都能拿到你的JS代码了,什么不能做呢。即使你用禁止调试,我也一样可以用本地代理,把你的JS内容换一下,去掉禁止调试的逻辑。而且禁止调试这种 伤敌800自损1000 的做法,实在没必要。你看看阿里腾讯百度,他们的接口消息体都是明文的,根本没有额外加密。
安全:明文是肯定不行的,在我们这儿肯定过不了。
我:那你给我一些,通过你们扫描的系统,我看看他们怎么做的。
然后安全给我发了一个系统,大概2分钟,JS打个断点,就找到了他们的秘钥。然后安全又给我发了其他系统,时间关系就不去找了,要找也是很轻松的找到。最后,安全给的指示是,不管怎么做,只要他们找不到我们秘钥就行。嗯,围绕这个目标,想到了今天的主角: WebAssembly 。
WebAssembly
🚀 什么是 WebAssembly?
WebAssembly(缩写:Wasm) 是一种运行在现代浏览器中的二进制指令格式,它设计用于:
- 提供接近原生性能的执行速度
- 与 JavaScript 共存,共享内存、调用函数
- 支持多语言编译(如 Rust、C、C++、Go 等)
WebAssembly 并不是要替代 JavaScript,而是作为它的 高性能补充,用于处理计算密集型、性能敏感、需要安全沙箱隔离的任务(如加密、图像处理、音视频编解码、游戏、模拟器等)。
🧠 通俗解释
如果把浏览器比作一台计算机,那么:
- JavaScript 就像浏览器的“脚本语言”,灵活但速度较慢;
- WebAssembly 就像浏览器的“汇编语言”,执行效率极高;
- 你可以用 Rust、C++ 等高级语言 写核心算法,然后编译成 WebAssembly,直接在浏览器中运行,而无需服务器参与。
WebAssembly的消息加解密方案
再次 reminding,我们的目标,并不是提供一个安全的系统,而是为了让 所谓安全团队,他们找不到我们的秘钥。
基于这个目标,我的方案是,把 RSA 加密、AES加解密的逻辑,都放到 WebAssembly 内实现,以我对他们的了解,他们应该打断点也找不到我的秘钥了。
不会 rust 语言?没关系,作为尊为的 cursor pro 会员,这些小事都让cursor去解决吧。
整天实现方案如下:
RSA的公钥,放在JavaScript侧来维护,方便后续各个系统对接这套wasm加解密方案- 在
WebAssembly内部,维护一份RSA公钥(也就是上面的JS里维护的,会暴露函数给JS调用传进来)、AES随机秘钥 WebAssembly暴露函数:- setRsaPublicKey:设置RSA公钥
- getSecureAESKey:获取经过RSA加密之后的AES秘钥,用来传给后端
- encrypt:使用AES加密
- decrypt:使用AES解密
逻辑简单,没几行代码。但是在开发过程中,遇到了如下2个恶心的问题:
- 之前RSA公钥的格式不对,原来JavaScript代码里的RSA公钥,没有适当的换行,导致
rust里解析RSA公钥报错。经过 cursor 和 gemini 反复修改,终于找到这个问题了 - 刚开始cursor给我的AES秘钥,是真的随机生成的,导致java端在解密的时候,遇到了很多问题。
- 首先是AES秘钥长度不对,我在
rust里生成的是 16 字节,但是在java端,经过一系列骚操作之后拿到的,变成了26个字节(某一次遇到的数据是这样,不是每次都是这些长度)。原来,java端在解密出来AES秘钥之后,又转换成了 UTF-8 的 string,然后后在使用的时候,又从这个string获取对应的字节数组。在这两次抓换中,导致了字节数据失真!根据cursor的解释,原始随机字节里,又的不是合法的UTF-8字符,在转换成UTF-8字符时,会被别的默认占位符替代,再从UTF-8字符转换字节时,默认的占位字符会变成3个字节,导致出问题。那为什么以前JavaScript版本的加密,没这个问题呢?翻了翻JavaScript的秘钥生成代码,原来是从固定的 [a-zA-Z0-9] 这个集合里随机出来的16个字符,这些都是合法的UTF-8字符,难怪java骚操作之后还是没问题。 - 第二个问题,AES加解密的模式对不上,这个只能扣了扣java代码,发现用的是
ECB_MODE这个模式,改了改rust代码,终于跑起来了! - 刚高兴了一半,又遇到一个问题。有的请求没有参数,传的是
undefined,之前JavaScript版本的加密使用的CryptoJS看来能处理这种问题。在我的版本,JSON.stringify(undefined)之后得到的还是undefined(我还以为会是字符串的"undefined"……),导致在传递给rust时获取字符串长度报错,只能继续加上默认值了
- 首先是AES秘钥长度不对,我在
最后,感谢LD、感谢CCAV、感谢懂王,当然,更要感谢舍得买cursor pro的我,终于解决了这个问题,使得我们的系统安全得到了极大提升,足以应对任何内部外部的安全挑战!
哦,差点忘了,最应该感谢的是公司的安全团队,鞭策着我们不断进步。您辛苦了!鞠躬!