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

先寫一段廢話熱身

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

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

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

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

效果演示

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

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

背景知識

在實現代理服務以前,能夠簡單瞭解一下 https 服務的證書認證過程以及代理是怎麼工做的。github

證書:CA證書 與 域名證書

一個正常 https 服務器的搭建時,咱們須要去證書機構申請一個域名證書,這裏機構必須是可信的。證書的信任過程是基於信任鏈的,若是電腦信任了CA, 也就信任了有CA證書籤發的域名證書。web

因此涉及兩個認證:npm

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

你能夠本身生成一個CA證書,用來籤各類域名,也就是自簽名證書。自簽名證書是不能經過驗證的,你須要讓客戶端信任你的CA。api

Proxy 與 直接訪問

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

原文地址: Http 請求頭中的 Proxy-Connection

功能實現

一個簡單的隧道代理

圖片版

文字版

  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證書
相關文章
相關標籤/搜索