安全地在先後端之間傳輸數據 - 「3」真的安全嗎?

前置閱讀:git


「2」註冊和登陸示例中,咱們經過非對稱加密算法實現了瀏覽器和 Web 服務器之間的安全傳輸。看起來一切都很美好,可是危險就在哪裏,有些人發現了,有些人嗅到了,更多人卻渾然不知。就像是給門上了把好鎖,還派了我的盯着,卻沒發現壞人已經從窗戶潛進去了。算法

廢話少說,先公佈答案:不安全!npm

若是想要安全,目前最優解仍然是使用 HTTPSjson

爲何不安全

不過爲何不安全呢?請思考一個問題:數據加密是基於服務器送過來的公鑰,可是這個公鑰確實是服務器發出來的那一個嗎?segmentfault

基於 HTTP 的傳輸是明文的,並且瀏覽器和服務器之間要通過若干網絡節點(路由等),誰知道公鑰在傳輸的過程當中沒有被掉包!後端

若是公鑰被掉包了,服務器知道嗎,它還能用原來的私鑰把數據解出來嗎?api

帶着這些個疑問,來看一張圖:數組

image.png

在瀏覽器和服務器的傳輸過程當中,黑客能夠劫持服務器發放的公鑰,並用本身產生的假公鑰替換之,狸貓換太子。然後加密數據的傳輸過程當中,黑客能夠用本身的私鑰解密(由於是用他發的假公鑰加的密),並用正確的公鑰加密送給服務器。這樣就在瀏覽器和服務器都感知不到的狀況下,把數據給偷走了。這種行爲,稱爲中間人劫持攻擊,上圖的黑客就是那個中間人。瀏覽器

模擬中間人劫持

真實的中間人劫持過程也不是很簡單的事情,不過咱們想研究這個過程的話,能夠模擬。安全

若是有兩臺計算機,能夠用一臺部署服務,另外一臺部署模擬的中間人。而後假設 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,至少知道一個相對安全的傳輸方法,並且明白其面臨的風險。

源碼下載

相關文章
相關標籤/搜索