前面實現的代理對于 HTTPS 流量是進行盲轉的,也就是說直接在 TCP 連接上傳輸 TLS 流量,但是我們無法查看或者修改它的內容。當然了,通常來說這也是不必要的。不過對于某些場景下還是有必要的,例如使用 Fiddler 進行抓包或者監控其它電腦的流量傳輸等,這就需要用到中間人代理了。所以說,學習一下這一塊的內容,對于更好的使用 Fiddler Charles 或者 mitmProxy 這類軟件是很有幫助的。遙想起來,好幾年前我還在大學的時候,學會使用 Fiddler 去抓包,當時就覺得很神奇。我當時特別喜歡使用它來學習 HTTP 協議的結構,或者開安卓模擬器來抓 APP 的包,不過我當時對于代理的概念幾乎是一無所知。現在,也很少再玩這些東西了,反而對代理的了解更進一步了。
在繼續閱讀之前,最好還是對一下概念有一個簡單的了解:
- 非對稱加密和對稱加密
- 公鑰和私鑰
- 簽名
- 證書
一、中間人代理的概念
一圖勝千言,我們先來看幾張圖片吧。
這是前面介紹的通過 TCP 隧道的透明代理的一個簡單示意圖,數據是通過 TCP 上發送的二進制 TLS 流量,代理無法解密這些數據,它只是負責對這些數據進行盲轉發,所以說它是透明代理。所以即使經過了代理服務器,客戶端實際上也是和服務器進行的 TLS 握手,然后通信的,這種情況下通信是安全的。
這是我們接下來要介紹的中間人代理了,它同樣是一個代理,但是這次它會假裝自己是客戶端需要訪問的服務器,然后客戶端實際上是同中間人進行通信,代理再同服務器進行通信(甚至沒有這一步!),這樣就可以查看、修改客戶端和服務器通信的內容了(這種情況下通信是不安全的)。
注:中間人,Man In The Middle,簡稱 MITM。
網絡是分層的,這就是 HTTPS 協議的示意圖。所以知乎上有一個博主說了一句話,我認為挺有道理的,大致意思是:沒有 HTTPS 協議,只有 HTTP 協議。
所以對于我們接下來的內容,即實現一個中間人代理的關鍵是解決客戶端和中間人代理之間的 TLS 握手。我們先來使用命令行看一下訪問 https://www.baidu.com
的過程(大致了解一下,我也只是理解一點),首先是連接到目標主機的 443 端口,這是 TCP 連接。然后開始建立 TLS 連接,可以看到會進行協議握手(handshake)(握手就是進行協商一個雙方共同接受的條件,協議版本或者密碼套件之類的)。
使用瀏覽器訪問百度,查看它的證書,這樣比在命令行里面更方便一些。這里我們主要關注這個 CN 字段,其它的我也不了解。
前面這些圖應該可以有一個大致的概念了。我們要做的就是代替真正的服務器和客戶端進行握手(相當于是欺騙它我就是你需要訪問的服務器),但是我們不可能搞到真正服務器的證書(私鑰)的。不過,任何人都可以很方便的生成一個自己公鑰和私鑰,它和那些由CA簽發的證書來說,區別只是它不是受信任的。
節選自 《HTTP權威指南》第 14 章節 安全的 HTTP
在發送已加密的 HTTP 報文之前,客戶端需要和服務器進行一次 SSL 握手,在這個握手過程中,它們要完成以下工作:
- 交換協議版本號
- 選擇一個兩端都了解的密碼
- 對兩端的身份進行驗證
- 生成臨時的會話密鑰,以便加密信道。
這里的第三點,對兩端身份進行驗證,其實通常是沒有的。我們一般只對服務器的身份進行驗證,即它確實是我們要訪問的服務器(服務器一般不會要求驗證客戶端的身份)。當然也有要求客戶端進行驗證的,例如說 Fiddler 也抓不了支付寶的數據包。據說是因為它使用了雙端驗證,即使你可以假扮服務器,但是沒有假扮客戶端去和真正的服務器進行交互(我聽說支付寶的客戶端證書是內置的,所以中間人代理是沒法抓到它的數據包的)。所以中間人代理實際上也就是鉆了這個漏洞,否則大部分的 HTTPS 流量都難以解密了。
二、實現細節
所以我們要做的工作也就大致清晰了,下面是一個簡單的示意圖,圓角矩形表示這中間的協議變化。
首先客戶端通過 HTTP 協議和代理服務器建立 TCP 連接(在 Go 中,是通過 Hijack 獲取底層 TCP 連接的)。之后客戶端會在這個 TCP 連接上發送 TLS 的內容。所以我們把這個連接轉成 TLS 連接(就是說和客戶端進行握手),如果成功之后我們就會被認為是真的服務器了(實際上是假的),因為現在就是單純的 HTTP 通信了,所以可以自由的查看和修改報文的內容了。
生成本地的自簽名證書,這里的 CN 字段,指定為 baidu.com
。
然后我們來看一下,關于中間人代理這塊的代碼:
這里的變化也就是多加了這一個函數,也就是在劫持獲得 TCP 連接后,把它作為參數傳入這個函數。這個函數的功能主要就是加載公鑰和私鑰,然后把 TCP 連接變成 TLS 連接,接著就可以進行 HTTP 的請求和響應了。
func mitm(conn net.Conn) error {tlsCert, err := tls.LoadX509KeyPair(certFile, keyFile)if err != nil {return err}// 再把底層連接轉換成 tls 連接,然后再封裝成 http requesttlsConn := tls.Server(conn, &tls.Config{ // 這里有一個概念叫 客戶端連接和服務端連接PreferServerCipherSuites: true,MaxVersion: tls.VersionTLS13,Certificates: []tls.Certificate{tlsCert}, // 這里最關鍵的是這個證書的配置})defer tlsConn.Close()// 封裝成 http requestvar req *http.Requestif req, err = http.ReadRequest(bufio.NewReader(tlsConn)); err != nil {return err}req.RequestURI = "" // 這里創建一個新的請求或者把這個設置為 空,不然會報錯req.URL.Scheme = "https" // 中途轉換的 Request 丟失了一些信息,居然沒有 scheme 了req.URL.Host = req.Hostvar resp *http.Responseif resp, err = proxyHttpClient.Do(req); err == nil {err = resp.Write(tlsConn)}return err
}
我之前一直沒有想明白這一塊,我也看了很多其它人的實現,但是就是這一點沒有想明白。然后昨天看了一個外國的博文,發現居然就是這樣的,還是對這個網絡這一塊的了解太少了,哈哈。我剛開始想到了把它轉成 TLS 連接,但是我在想那怎么再變成 HTTP 連接呢?但是,仔細一想,HTTP 是一個文本協議,實際上我可以直接對這個 TLS 連接上讀取或者返回符合 HTTP 報文格式的數據,所以最后寫入響應數據就是 resp.Write(tlsConn)
,雖然這只是簡短的一行,卻困惑了我好久!!!
Talk is cheap, show me your code
我就是看的這篇博客然后才了解了最后困擾我的那部分內容,這個作者寫的真的不錯!
Go and Proxy Servers: Part 2 - HTTPS Proxies
注:這里使用的是 tls.Server
,這個包下還有一個 tls.Client
的方法。所以它這里是為了區分客戶端連接和服務端連接。我的理解是這樣的,如果你打算發起一個請求,你肯定需要的是客戶端連接。反過來,如果你已經得到了一個請求,你打算處理它,它就是服務端連接。
三、完整代碼和測試
package mainimport ("bufio""crypto/tls""io""log""net""net/http""strings""github.com/gin-gonic/gin"
)const (RPOXY_SERVER = "CrazyDragonHttpProxy" // it is just a kidding, but Only HTTP!TUNNEL_PACKET = "HTTP/1.1 200 Connection Established\r\nProxy-agent: CrazyDragonHttpProxy\r\n\r\n"certFile = "./server.crt"keyFile = "./server.key"
)var proxyHttpClient = http.DefaultClientfunc main() {r := gin.Default()r.Use(recordReq) // 使用 gin 的中間件簡單打印請求的信息r.NoRoute(routeProxy) // NO Route is every Routes!!!r.Run(":8888")
}// 這樣就可以處理所有的請求了,在這里區分是 http 還是 https,https 會通過 CONNECT 來建立隧道,所以就通過它來區分。
func routeProxy(c *gin.Context) {req := c.Requestif req.Method == http.MethodConnect {httpsProxy(c, req) // 處理 HTTPS 請求(HTTPS => TCP <-> TLS <-> HTTP )} else {httpProxy(c, req) // 處理 HTTP 請求}
}func httpsProxy(c *gin.Context, req *http.Request) {c.Status(200) // 不加也沒有問題,只是說記錄的日志狀態碼都會變成 404.// 這里接管這個 http 連接,然后將它劫持為一個 TCP 連接hj, ok := c.Writer.(http.Hijacker)if !ok {http.Error(c.Writer, "webserver doesn't support hijacking", http.StatusInternalServerError)return}clientConn, _, err := hj.Hijack()if err != nil {http.Error(c.Writer, err.Error(), http.StatusInternalServerError)return}// 建立隧道之后,發送一個連接建立的響應(其實,只要狀態碼是 200 就可以了)if _, err = clientConn.Write([]byte(TUNNEL_PACKET)); err != nil {log.Printf("Response Failed: %v", err.Error())} else {log.Println("Response Success.")}// 因為我們知道了客戶端需要訪問的 HOST+PORT,所以現在我們需要來模擬一個假的服務器(即中間人)來和它進行對話。// 所以這里的關鍵是這個模擬的服務需要有證書,不然無法和客戶端進行握手。err = mitm(clientConn)if err != nil {log.Println(err)return}
}func httpProxy(c *gin.Context, req *http.Request) {req.RequestURI = "" // 這里創建一個新的請求或者把這個設置為 空,不然會報錯resp, err := proxyHttpClient.Do(req)if err != nil {log.Println(err)return}defer resp.Body.Close()c.Status(resp.StatusCode) // 修改狀態碼,因為默認的是 404(我是在 NoRoute 里面處理的)。for k, v := range resp.Header {c.Header(k, strings.Join(v, ",")) // 寫入頭部(全部寫入嚴格來說是不對)。}c.Header("Server", RPOXY_SERVER) // haha, it just a kidding!!!io.Copy(c.Writer, resp.Body) // 響應數據給客戶端
}func recordReq(ctx *gin.Context) {log.Printf("Method: %s, Host: %s, URL: %s, Version: %s\n", ctx.Request.Method, ctx.Request.Host, ctx.Request.URL.Path, ctx.Request.Proto)
}func mitm(conn net.Conn) error {tlsCert, err := tls.LoadX509KeyPair(certFile, keyFile)if err != nil {return err}// 再把底層連接轉換成 tls 連接,然后再封裝成 http requesttlsConn := tls.Server(conn, &tls.Config{ // 這里有一個概念叫 客戶端連接和服務端連接PreferServerCipherSuites: true,MaxVersion: tls.VersionTLS13,Certificates: []tls.Certificate{tlsCert}, // 這里最關鍵的是這個證書的配置})defer tlsConn.Close()// 封裝成 http requestvar req *http.Requestif req, err = http.ReadRequest(bufio.NewReader(tlsConn)); err != nil {return err}req.RequestURI = "" // 這里創建一個新的請求或者把這個設置為 空,不然會報錯req.URL.Scheme = "https" // 中途轉換的 Request 丟失了一些信息,居然沒有 scheme 了req.URL.Host = req.Hostvar resp *http.Responseif resp, err = proxyHttpClient.Do(req); err == nil {err = resp.Write(tlsConn)}return err
}
啟動代碼后,使用 curl 訪問 https://www.baidu.com
,這里要加 -k 參數跳過證書驗證(我這個自簽名證書是不受信任的,默認是關閉連接的)。
如果不加 -k 的話,證書驗證過不去,連接會被終止。
啟動代碼后,配置好代理的配置(可以看前面的博客的配置,注意端口號這里我換了,因為 8888 被占用了),使用瀏覽器訪問 https://www.baidu.com
。
遇到問題了,使用不受信任的證書,瀏覽器禁止我訪問百度了,因為它采用了更嚴格的安全策略 HSTS。因為通常情況下,即使證書不安全,我也可以堅持訪問的。不過現在這樣還就是麻煩了呢。
HSTS(HTTP Strcit-Transport-Securit)即 HTTP 嚴格傳輸安全, 是一種 Web 安全策略機制,可保護網站免受協議降級攻擊和 cookie 劫持、中間人攻擊。它允許 Web 服務器聲明瀏覽器(或其他符合要求的用戶代理)應用使用安全的 HTTPS 連接與交互,而不是通過不安全的 HTTP 協議。
HSTS 詳解,讓 HTTPS 更安全
不過幸好,這個是可以關閉的。我用的是 edge 瀏覽器,輸入下面的網址進入這個頁面,在下面輸入百度的域名,刪除動態的 HSTS。不過它也提示了預加載的是沒有用的。這個應該是臨時的,最好還是開啟它,因為這是涉及安全的問題!
好了,現在終于可以訪問了 www.baidu.com
了,這里只是打開了首頁截了幾張圖片。
四、信任證書和證書鏈
證書通常是由某些很權威的機構來頒發的,一般稱為 CA。這里的頒發,指的是用 CA 的私鑰來對證書進行簽名(這樣別人就無法偽造證書了,因為沒有辦法簽名)。驗證證書就是用 CA 的公鑰對證書進行解密并驗證。 并且邏輯上證書是一條鏈的結構,首先是根證書,然后是中間證書,最后是服務端證書。
根證書 --> 中間證書 --> 服務端證書
這樣是從頒發證書的角度看的,實際上驗證證書的時候是反過來的,從服務端證書開始,一直到根證書,都驗證通過了才是通過。
現在這個代理服務器,還是有很多問題的,首先它只能訪問 https://www.baidu.com
,其次它會報紅色警告。第一個是因為我只是生成了一個假的 baidu.com 的證書,所以對于其它的網址直接是過不去檢驗的(會檢查域名和CN的匹配關系,然后是校驗證書的有效性)。如果域名和證書不匹配,那么直接就是錯誤了,如下圖。
這個紅色警告是因為這個證書是自簽名證書,天然就是不被信任的,當然也有解決辦法了。我們來看看 Fiddler 是怎么解決的,它是把自己的證書安裝到了系統的根證書(根證書是默認信任的)中,然后用這個來給我們訪問的域名簽發假的證書(可以看到全部是假的證書,而且它的名字叫 不要信任 Fiddler 根證書)。這里就沒有中間證書了,證書校驗就是服務端證書和根證書了。
我這里只是用了一個自簽名證書,因為我只演示了百度這一個域名,所以就不需要再弄一個根證書了。而且,我也不想安裝把這個安裝到系統的根證書,因為這樣很危險。所以,如果想要訪問各種不同的網站,需要自己來為每一個訪問的域名簽發證書,這就需要一個 CA 證書來操作了,這樣只需要把一個證書加入系統根證書就好了。如果想要擴展的話,應該會用到下面這個方法來動態的生成證書,不過這個主題的探索對于我來說已經差不多要結束了,因為關于證書這一塊還是挺多不熟悉的。如果你也閱讀到這里,相信你對于 Fiddler、Charles 和 MitmProxy 的也會有更多的理解,對于我們使用這些工具是很有幫助的。
這一塊的內容還是挺多的,我也只是了解一點,如果想要看更多的內容的話,可以看下面的這兩個技術博客,寫得很好。
證書鏈簡介
HTTPS 精讀之 TLS 證書校驗
五、總結
這篇博客和上一篇博客之間已經隔了好久了。因為理解這個中間人代理的過程遇到了困難,再加上時間不是很充足,也就沒有繼續寫這個主題的內容。最近剛好又有了時間了,所以就集中時間看了很多內容,測試代碼(因為 TSL 發生了錯誤基本上看不懂什么意思,感覺自己掌握的知識和工具還是太少了,很多錯誤只能束手無策了),也算是對這個東西有了一個新的理解。我其實還是更喜歡盲轉發的代理,因為那樣實現起來更簡單,可以做一些上網行為統計的小工具玩一玩。對于這種中間人代理,因為已經有了很成熟的工具了,所以就當做是了解怎么樣更好的使用這些工具了。