先寫一段廢話熱身
雖然提到了中間人攻擊,但這不是一篇安全類文章,要通過中間人修改https內容,必須客戶端信任中間人提供的證照。
我做這麼一個工作,最原始的需求,是為了解決公司內網環境下 npm 包安裝的問題,簡單點講,就是切換倉庫和依賴映象源。常用的 cnpm 也提供映象功能,也能解決包依賴的硬編碼地址問題,但是不支援 lockfile, 也不支援 URLs as Dependencies 方式定義的package。最後決定採用代理的方式,用內網資源去響應外網請求。
整個過程,真的充分感受到了修改 https 請求的不易,畢竟 https 的誕生就是為了防止內容盜取、篡改的。
http請求的代理實現沒那麼多么蛾子,就先略了...
效果演示
我在本地啟動了一個代理伺服器,並注入了一些配置,將對 www.google.com.hk 的訪問重定向到了我在本機執行的一個 https 伺服器。
這裡的演示,用的url替換的方式,這部分屬於具體的業務邏輯,後文的最終實現為簡化版,雖然效果一樣~
背景知識
在實現代理服務之前,可以簡單瞭解一下 https 服務的證照認證過程以及代理是怎麼工作的。
證照:CA證照 與 域名證照
一個正常 https 伺服器的搭建時,我們需要去證照機構申請一個域名證照,這裡機構必須是可信的。證照的信任過程是基於信任鏈的,如果電腦信任了CA, 也就信任了有CA證照籤發的域名證照。
所以涉及兩個認證:
- 機構認證,對應的就是 CA 證照,在系統裡預置了
- 域名證照,用CA證照給域名證照籤名,得到一個域名證照
你可以自己生成一個CA證照,用來籤各種域名,也就是自簽名證照。自簽名證照是不能通過驗證的,你需要讓客戶端信任你的CA。
Proxy 與 直接訪問
http(s) 的 代理與普通請求有什麼區別?客戶端是如何告知代理目標伺服器的地址的?我從別人文章裡截了個圖:
原文地址:Http 請求頭中的 Proxy-Connection功能實現
一個簡單的隧道代理
圖片版
文字版
- 建立 https 伺服器作為代理伺服器
- 監聽 connect 事件,獲取目標伺服器地址、埠、ClientSocket
- 與目標伺服器建立連線, 得到TargetSocket,並通知客戶端連線建立成功
- 將 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代理模式下的證照認證過程是怎樣的?
上面的程式碼實現,看起來可能沒什麼營養,不過可以幫助理解代理模式下的證照認證過程。上面過程涉及兩次認證:
- 代理伺服器是 https 服務,客戶端與代理伺服器之間的連線需要認證
- 客戶端需要校驗目標伺服器的證照,生成會話的加密資料,用於後續通訊
嗯,問題是,
- 這兩次認證分別發生在什麼時候?
- 程式碼裡面 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站點的內容
綜合一下上面的步驟:
- 建立偽造的伺服器 fakeServer
- 建立代理伺服器 proxyServer
- proxyServer 監聽客戶端的連線請求
- proxyServer 建立到 fakeServer 的連線
- proxyServer 建立客戶端請求到 fakeServer 之間的通訊隧道
- 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
- 啟動代理伺服器
cd demo/proxy
npm i
npm run test
複製程式碼
- 將 demo/proxy/cert/cacert.pem 匯入系統並信任
- 設定瀏覽器代理為 http://localhost:6666
- 訪問任意 https 站點
不用了記得刪除證照~~