利用nodejs搭建 https 代理伺服器並實現中間人攻擊

木亖發表於2019-05-07

先寫一段廢話熱身

雖然提到了中間人攻擊,但這不是一篇安全類文章,要通過中間人修改https內容,必須客戶端信任中間人提供的證照。

我做這麼一個工作,最原始的需求,是為了解決公司內網環境下 npm 包安裝的問題,簡單點講,就是切換倉庫和依賴映象源。常用的 cnpm 也提供映象功能,也能解決包依賴的硬編碼地址問題,但是不支援 lockfile, 也不支援 URLs as Dependencies 方式定義的package。最後決定採用代理的方式,用內網資源去響應外網請求。

整個過程,真的充分感受到了修改 https 請求的不易,畢竟 https 的誕生就是為了防止內容盜取、篡改的。

http請求的代理實現沒那麼多么蛾子,就先略了...

效果演示

我在本地啟動了一個代理伺服器,並注入了一些配置,將對 www.google.com.hk 的訪問重定向到了我在本機執行的一個 https 伺服器。

這裡的演示,用的url替換的方式,這部分屬於具體的業務邏輯,後文的最終實現為簡化版,雖然效果一樣~

利用nodejs搭建 https 代理伺服器並實現中間人攻擊

利用nodejs搭建 https 代理伺服器並實現中間人攻擊

背景知識

在實現代理服務之前,可以簡單瞭解一下 https 服務的證照認證過程以及代理是怎麼工作的。

證照:CA證照 與 域名證照

一個正常 https 伺服器的搭建時,我們需要去證照機構申請一個域名證照,這裡機構必須是可信的。證照的信任過程是基於信任鏈的,如果電腦信任了CA, 也就信任了有CA證照籤發的域名證照。

所以涉及兩個認證:

  1. 機構認證,對應的就是 CA 證照,在系統裡預置了
  2. 域名證照,用CA證照給域名證照籤名,得到一個域名證照

你可以自己生成一個CA證照,用來籤各種域名,也就是自簽名證照。自簽名證照是不能通過驗證的,你需要讓客戶端信任你的CA。

Proxy 與 直接訪問

http(s) 的 代理與普通請求有什麼區別?客戶端是如何告知代理目標伺服器的地址的?我從別人文章裡截了個圖:

利用nodejs搭建 https 代理伺服器並實現中間人攻擊
原文地址:Http 請求頭中的 Proxy-Connection

功能實現

一個簡單的隧道代理

圖片版

利用nodejs搭建 https 代理伺服器並實現中間人攻擊

文字版

  1. 建立 https 伺服器作為代理伺服器
  2. 監聽 connect 事件,獲取目標伺服器地址、埠、ClientSocket
  3. 與目標伺服器建立連線, 得到TargetSocket,並通知客戶端連線建立成功
  4. 將 ClientSocket 與 TargetSocket 的資料流互相轉發

程式碼版

/** 僅摘取部分核心程式碼,無法直接執行 **/

const https = require('https');
const fs = require('fs');
const forge = require('node-forge');
const net = require('net');

function connect(clientRequest, clientSocket, head) {
    const protocol = clientRequest.connection?.encrypted ? 'https:' : 'http:';
    const { port = 443, hostname } = url.parse(`${protocol}//${clientRequest.url}`);

    // 連線目標伺服器
    const targetSocket = net.connect(port, targetUrl, () => {
        // 通知客戶端已經建立連線
        clientSocket.write(
            'HTTP/1.1 200 Connection Established\r\n'
                + 'Proxy-agent: MITM-proxy\r\n'
                + '\r\n',
        );

        // 建立通訊隧道,轉發資料
        targetSocket.write(head);
        clientSocket.pipe(targetSocket).pipe(clientSocket);
    });
}

// 建立域名證照, 啟動https服務作為代理伺服器
const serverCrt = createServerCertificate('localhost');
https.createServer({
        key: forge.pki.privateKeyToPem(serverCrt.key),
        cert: forge.pki.certificateToPem(serverCrt.cert),
    })
    .on('connection', console.log)
    .on('connect', connect) // 建立通訊隧道
    .listen(6666, () => {
        console.log('代理伺服器已啟動, 代理地址:https://localhost:6666');
    });
複製程式碼

問題:https代理模式下的證照認證過程是怎樣的?

上面的程式碼實現,看起來可能沒什麼營養,不過可以幫助理解代理模式下的證照認證過程。上面過程涉及兩次認證:

  1. 代理伺服器是 https 服務,客戶端與代理伺服器之間的連線需要認證
  2. 客戶端需要校驗目標伺服器的證照,生成會話的加密資料,用於後續通訊

嗯,問題是,

  1. 這兩次認證分別發生在什麼時候?
  2. 程式碼裡面 connection、connect 事件分別在什麼情況下觸發?

可以嘗試將代理地址設定成 https://127.0.0.1:6666,(注意代理域名換了,代理服務的證照驗證會成問題) 然後你將看到 connection 事件被觸發,然後告訴你客戶端主動斷開了連線...然後...就沒有然後了。

如果代理伺服器的證照認證通過,將會先後看到 connection、connect 事件被觸發。

至於客戶端需要校驗的目標伺服器的證照,是在代理服務與目標伺服器建立連線之後,通過 pipe 傳給客戶端的。

問題:如果代理伺服器建立的連線不是到目標伺服器的,而是另一個伺服器,會發生什麼?

這個答案也簡單,上面兩個認證中的第二個,也就是客戶端對目標伺服器的證照認證是沒法通過的,於是連線被斷開。

那,如果我們讓 “另一個伺服器” 響應正確的證照,或者說“偽造目標伺服器”,是否就能正確建立連線,然後...為所欲為了?

偽造目標伺服器

證照問題:如果提供任意域名的證照?

我們在搭建一個 https 伺服器的時候,通常需要申請一個域名證照,找一個客戶端信任的CA給你籤。

所以,其實讓證照可用條件還算簡單,用客戶端信任的CA證照籤一個域名證照,就可以了。

在我的目標場景下,客戶端是由我自己控制的,所以,造一個 CA證照讓客戶端信任是可行的,既然CA都被信任了,那域名證照也就隨便籤了。

偽造一個https服務,處理多個域名的請求

因為代理的目標地址不確定,可能是 a.com, 也可能是 b.cn, 所以我期望造一個https服務,處理不同域名的請求。

這裡有一個叫 “SNI”的東西,也就是“伺服器名稱指示”,用於實現服務與域名的一對多關係。

/** 僅摘取部分核心程式碼,無法直接執行 **/

/** 建立支援多域名的 https 服務 **/
function createFakeHttpsServer() {
    return new https.Server({
        SNICallback: (hostname, callback) => {
            const { key, cert } = createServerCertificate(hostname);
            callback(
                null,
                tls.createSecureContext({
                    key: forge.pki.privateKeyToPem(key),
                    cert: forge.pki.certificateToPem(cert),
                }),
            );
        },
    });
}

const fakeServer = createFakeHttpsServer();

/** 這裡是具體的業務,給客戶端返回想要提供的內容 **/
fakeServer.on('request', (req, res) => {
    // do something
    // 到這裡,證照部分已經通過了,正常響應請求就可以
    res.writeHead(200);
    res.end('hello world\n');
}).listen(0);
複製程式碼

利用代理伺服器替換https站點的內容

綜合一下上面的步驟:

  1. 建立偽造的伺服器 fakeServer
  2. 建立代理伺服器 proxyServer
  3. proxyServer 監聽客戶端的連線請求
  4. proxyServer 建立到 fakeServer 的連線
  5. proxyServer 建立客戶端請求到 fakeServer 之間的通訊隧道
  6. fakeServer 根據業務需要處理客戶端請求
/** createServerCertificate 的實現,程式碼比較長,先忽略了 **/

const https = require('https');
const fs = require('fs');
const forge = require('node-forge');
const net = require('net');
const tls = require('tls');
const url = require('url');
const createServerCertificate = require('./cert');

function connect(clientRequest, clientSocket, head) {
    // 連線目標伺服器
    const targetSocket = net.connect(this.fakeServerPort, '127.0.0.1', () => {
        // 通知客戶端已經建立連線
        clientSocket.write(
            'HTTP/1.1 200 Connection Established\r\n'
                + 'Proxy-agent: MITM-proxy\r\n'
                + '\r\n',
        );

        // 建立通訊隧道,轉發資料
        targetSocket.write(head);
        clientSocket.pipe(targetSocket).pipe(clientSocket);
    });
}

/** 建立支援多域名的 https 服務 **/
function createFakeHttpsServer(fakeServerPort = 0) {
    return new Promise((resolve, reject) => {
        const fakeServer = new https.Server({
            SNICallback: (hostname, callback) => {
                const { key, cert } = createServerCertificate(hostname);
                callback(
                    null,
                    tls.createSecureContext({
                        key: forge.pki.privateKeyToPem(key),
                        cert: forge.pki.certificateToPem(cert),
                    }),
                );
            },
        })
        fakeServer
            .on('error', reject)
            .listen(fakeServerPort, () => {
                resolve(fakeServer);
            });
    });
}

function createProxyServer(proxyPort) {
    return new Promise((resolve, reject) => {
        const serverCrt = createServerCertificate('localhost');
        const proxyServer = https.createServer({
            key: forge.pki.privateKeyToPem(serverCrt.key),
            cert: forge.pki.certificateToPem(serverCrt.cert),
        })
        .on('error', reject)
        .listen(proxyPort, () => {
            const proxyUrl = `https://localhost:${proxyPort}`;
            console.log('啟動代理成功,代理地址:', proxyUrl);
            resolve(proxyServer);
        });
    });
}

// 業務邏輯
function requestHandle(req, res) {
    res.writeHead(200);
    res.end('hello world\n');
}

// 這裡就是入口了
function main(proxyPort) {
    return Promise.all([
        createProxyServer(proxyPort),
        createFakeHttpsServer(), //隨機埠
    ]).then(([proxyServer, fakeServer]) => {
        // 建立客戶端到偽服務端的通訊隧道
        proxyServer.on('connect', connect.bind({
            fakeServerPort: fakeServer.address().port,
        }));
        // 偽服務端處理,可以響應自定義內容
        fakeServer.on('request', requestHandle);
    }).then(() => {
        console.log('everything is ok');
    });
}

// 監聽異常,避免意外退出
process.on('uncaughtException', (err) => {
    console.error(err);
});

main(6666);
複製程式碼

附完整程式碼

原始碼地址

執行 demo

  1. 啟動代理伺服器
cd demo/proxy
npm i
npm run test
複製程式碼
  1. 將 demo/proxy/cert/cacert.pem 匯入系統並信任
  2. 設定瀏覽器代理為 http://localhost:6666
  3. 訪問任意 https 站點

不用了記得刪除證照~~

參考文件

  1. HTTPS為什麼安全 &分析 HTTPS 連線建立全過程
  2. Http 請求頭中的 Proxy-Connection
  3. nodejs文件-tls
  4. HTTP 代理原理及實現(一)
  5. HTTP 代理原理及實現(二)
  6. 建立CA證照

相關文章