HTTP被動掃描代理的那些事

  • 2019 年 10 月 4 日
  • 筆記

HTTP 代理這個名詞對於安全從業人員應該都是熟知的,我們常用的抓包工具 burp 就是通過配置 HTTP 代理來實現請求的截獲修改等。然而中國對這一功能的原理類文章很少,有的甚至有錯誤。

筆者在做 xray 被動代理時研究了一下這部分內容,並整理成了這篇文章,這篇文章我們從小白的角度粗略的聊聊 HTTP 代理到底是如何工作的,在實現被動掃描功能時有哪些細節需要注意以及如何科學的處理這些細節。

開始之前我先來一波靈魂6問,讀者可以先自行思考下,這些問題將是本文的關鍵點,並將在文章中一一解答:

1.http_proxy 和 https_proxy 有什麼區別? 2.為什麼需要信任證書才能掃描 HTTPS 的站點? 3.代理 HTTPS 的站點一定需要信任證書嗎? 4.代理的隧道模式下如何區分是不是 TLS 的流量? 5.代理應如何處理 Websocket 和 HTTP2 的流量? 6.是否應該復用連接以及如何復用連接?

知識儲備

我們在本地做開發時,有時會需要啟動一個 HTTPS 的服務,通常使用 OpenSSL 自行簽發證書並在系統中信任該證書,然後就可以正常使用這個 TLS 服務了。如果沒有信任,瀏覽器就會提示證書不信任而無法訪問,簡言之,我們需要手動信任自行簽發的證書才可以正常訪問配置了該證書的網站。那麼問題來了,為什麼平日訪問的那些網站都不需要信任證書呢?打開 baidu.com 查看其證書發現這裡其實是一個證書鏈:

最頂層的 Global Sign RootCA 是一個根證書,第二個是一個中間證書,最後一個才是 baidu 的頒發證書,這三種證書的效力是:

RootCA >  Intermediates CA > End-User Cert

而且只要信任了 RootCA 由 RootCA 簽發的包括其下級簽發的證書都會被信任。而 Global Sign RootCA等是一些默認安裝在系統和瀏覽器中的根證書。這些證書由一些權威機構來維護,可以確保證書的安全和有效性。而內置的這些根證書就允許我們訪問一些公共的網站而無需手動信任證書了。

再來說下與 HTTP 代理相關的兩個環境變數: HTTP_PROXY 和 HTTPS_PROXY,有的程式使用的是小寫的,比如 curl。對於這兩個變數,約定俗稱的規則如下:

1.如果目標是 HTTP 的,則使用 HTTP_PROXY 中的地址 2.如果目標是 HTTPS 的,則使用 HTTPS_PROXY 中的地址 3.如果對應的環境變數為空,則不使用代理

這兩個環境變數的值是一個 URI,常見的有如下三種形式:

http://127.0.0.1:7777https://127.0.0.1:7777socks5://127.0.0.1:7777

拋開與主題無關的 socks 不管,這裡又有一個 http 和 https,別暈,這裡的 http 和 https 指的是代理伺服器的類型,類似 http://baidu.com 和 https://baidu.com 一個是裸的 HTTP 服務,一個套了一層 TLS 而已。那麼組合一下就有 4 種情況了:

1.http_proxy=http://127.0.0.1:7777 2.https_proxy=http://127.0.0.1:7777 3.http_proxy=https://127.0.0.1:7777 4.https_proxy=https://127.0.0.1:7777

這四種情況都是合法的,也是代理實現時應該考慮的。但是如上面所說,這只是約定俗稱的,沒有哪個 RFC 規定必須這樣做,導致上面四種情況在常見的工具中被實現的五花八門,為了避免把大家繞暈,我直接說結論:很多工具對後面兩種不支援,比如 wget, python requests, 也就是說 https://還是被當成了 http://,因此我們這裡只討論前兩種情況的實現。

代理中的 MITM

HTTP 代理的協議基於 HTTP,因此 HTTP 代理本身就是一個 HTTP 的服務,而其工作原理本質上就是中間人(MITM) ,即讀取當前客戶端的 HTTP 請求,從代理髮送出去並獲得響應,然後將響應返回給客戶端。其過程類似下面的流程:

為了更直觀的感受下,可以用 nc 監聽 127.0.0.1:7777 然後使用

http_proxy=http://127.0.0.1:7777 curl http://example.com

會發現 nc 的數據包為:

GET http://example.com/ HTTP/1.1Host: example.comProxy-Connection: keep-aliveUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_4)  Accept: text/htmlAccept-Encoding: gzip, deflateAccept-Language: zh-CN,zh;q=0.9,en;q=0.8

看起來和 HTTP 的請求非常像,唯一的區別就是 GET 後的是一個完整的 URI,而不是 path,這主要是方便代理得到客戶端的原始請求,如果不用完整的 URI,請求的 Scheme 將無從得知,埠號有時也可能是不知道的。

在 Go 中我們可以用幾行簡單的程式碼實現這種場景下的代理。

package mainimport (     "bufio"      "log"      "net"      "net/http")var client = http.Client{}func main() {     listener, err := net.Listen("tcp", "127.0.0.1:7777")   if err != nil {        log.Fatal(err)     }   for {        conn, err := listener.Accept()      if err != nil {           log.Fatal(err)        }      go handleConn(conn)     }  }  func handleConn(conn net.Conn) {   // 讀取代理中的請求   req, err := http.ReadRequest(bufio.NewReader(conn))     if err != nil {        log.Println(err)      return   }     req.RequestURI = ""   // 發送請求獲取響應   resp, err := client.Do(req)     if err != nil {        log.Println(err)      return   }   // 將響應返還給客戶端   _ = resp.Write(conn)     _ = conn.Close()  }

編譯運行這段程式碼,然後使用 curl 測試下:

http_proxy=http://127.0.0.1:7777  curl -v http://example.com

代理看起來工作正常,我們使用不到 40 行程式碼就實現了一個簡易的 HTTP 代理!程式碼中的 req 就是做被動代理掃描需要用到的請求,把請求複製一份扔給掃描器就可以了。這也就是上面說的第一種情況, 即http_proxy=http://。那麼如果直接使用上述實現訪問 https 的站點會發生什麼呢?

TLS 與隧道代理

https_proxy=http://127.0.0.1:7777 curl -v https://baidu.com

使用上面的方式訪問 baidu 時,出現了比較奇怪的事情——通過代理讀到的客戶端請求不是原來的請求,而是一個 CONNECT 請求:

CONNECT baidu.com:443 HTTP/1.1Host: baidu.com:443User-Agent: curl/7.54.0Proxy-Connection: Keep-Alive

這是 HTTP 代理的另一種形式,稱為隧道代理。隧道代理的過程如下:

隧道代理的出現是為了能在 HTTP 協議基礎上傳輸非 HTTP 的內容。如果你用過 websocket,一定對 Connection: Upgrade 這個頭不陌生。這個頭是用來告訴 server,客戶端想把當前的 HTTP 的連接轉為 Websocket 協議通訊的連接。類似的,這裡的 CONNECT是一種協議轉換的請求,但這種轉換更像是一種 Degrade,因為握手完成後,這個鏈接將退化為原始的 Socket Connection,可以在其中傳輸任意數據。用文字描述下整個過程如下:

1. 客戶端想通過代理訪問https://baidu.com,向代理髮送 Connect 請求。 2. 代理嘗試連接 baidu.com:443,如果連接成功返回一個 200 響應,連接控制權轉交個客戶端;如果連接失敗返回一個 502,連接中止。 3. 客戶端收到 200 後,在這個連接中進行 TLS 握手,握手成功後進行正常的 HTTP 傳輸。

有個點需要注意下,轉換後的連接是可以傳輸任意數據的,並非只是 HTTPS 流量,可以是普通的 HTTP流量,也可以是其他的應用層的協議流量。那麼我們回到被動代理掃描這個話題,如何獲取隧道代理中的請求並用來掃描?

這是一個比較棘手的問題,正是由於隧道中的流量可以是任意應用層協議的數據,我們無法確切知道隧道中流量用的哪種協議,所以只能猜一下。查看 TLS 的 RFC 可以發現,TLS 協議開始於一個位元組 0x16,這個位元組在協議中被稱為 ClientHello,那麼我們其實就可以根據這第一個位元組將協議簡單區分為 TLS 流量和非 TLS 流量。對於被動掃描器而言,為了簡單起見,我們認為 TLS 的流量就是 HTTPS 流量,非 TLS 流量就是 HTTP 流量。後者和普通代理下的 MITM 一致,可以直接復用程式碼,而 HTTPS 的情況需要多一個 TLS 握手的過程。用偽程式碼表示就是:

b = conn.Read(1)if b == "0x16" {    tlsHandShake(conn)  }  req = readRequest(conn)  handleReq(conn, req)

這裡有個細節是讀出的這一個位元組不要忘記「塞回去」,因為少了一個位元組,後面的會操作會失敗。

這裡我們需要重點關注下 TLS 握手過程。在 TLS 握手過程中會進行證書校驗,如果客戶端訪問的是 baidu.com,server 需要有 baidu.com 這個域的公鑰和私鑰才能完成握手,可是我們手裡哪能有 baidu.com的證書(私鑰),那個在文件在 baidu 的伺服器上呢!

解決辦法就是文章最開始說到的信任根證書。信任根證書後,我們可以在 TLS 握手之前直接簽發一個對應域的證書來進行 TLS 握手,這就是包括 burp 在內的所有需要截獲 HTTPS 數據包的軟體都需要信任一個根證書的原因!有了被系統信任的根證書,我們就可以簽出任意的被客戶系統信任的具體域的證書,然後就可以剝開 TLS 拿到被動掃描需要的請求了。這裡還有一個小問題是簽發的證書的域該使用哪個,簡單起見我們可以直接使用 CONNECT 過程中的地址,更科學的方法我們後面說。簽完證書就可以完成 TLS 握手,然後就又和第一節的情況類似了。

有個點需要提一下,如果不需要進行中間人獲取客戶端請求,是不需要信任證書的,因為這種情況下的是真正的隧道,像是客戶端與伺服器的直接通訊,代理伺服器僅僅在做二進位的數據轉發。

至此,被動代理的核心實現已經完成了,接下來是一些瑣碎的細節,這些細節同樣值得注意。

代理的認證

一個公網的代理如果沒有加認證是比較危險的,因為代理本身就相當於開放了某個網路的使用許可權,而且由於隧道模式的存在,代理的支援的協議理論上拓寬到了任何基於 TCP 的協議,如果可以和傳統的 redis 未授權,SSRF DNS rebinding 等結合一下就是一個簡單的 CTF 題。所以給代理加上鑒權是很有必要的。

代理的認證和正常的 HTTP Basic Auth 很像,只是相關頭加了一個 Proxy- 的前綴,可以參考 《HTTP 權威指南》中的一個圖學習一下:

點對點的修正

根據 RFC,HTTP 中的下列頭被稱為單跳頭(Hop-By-Hop header),這些 Header 應該只作用於單個 TCP 連接的兩端,HTTP 代理在請求中如果遇到了,應當刪掉這些頭。

"Proxy-Authenticate","Proxy-Authorization","Connection","Keep-Alive","Proxy-Connection",  "Te","Trailer","Transfer-Encoding","Upgrade",

至於這些頭要刪掉的原因,這裡按我的理解簡單說下。前兩個是和認證相關的,每個代理的認證是獨立的,所以認證成功應該刪掉當前代理的認證資訊。

中間的三個是用於控制連接狀態的,TCP 連接是端到端的,連接狀態的維護也應該是針對兩端的,即客戶端與代理伺服器, 代理伺服器與目的伺服器應該是分別維護各自狀態的。Proxy-Connection 類似 Connection,是用來指定客戶端和代理之間的連接是不是 KeepAlive 的,代理實現時應該兼顧這個要求。對於連接的狀態管理,我認為比較科學的方式是分拆而後串聯。分拆是說 client->proxy 和 proxy -> server 這兩個過程分開處理, client->proxy 的過程每次開啟新的 TCP 連接,不做連接復用;而 proxy->server 的過程本質上就是一個普通的 http 請求,所以可以套一個連接池,藉助連接池可以復用 TCP 連接。兩部分的連接都撥通後,可以將其串聯起來,最終效果上就是在遵循 Proxy-Connection 的前提下連接的狀態最終與代理無關,而是由 client 和 server 共同控制。串聯過程在 Go 中可以用兩行程式碼簡單搞定:

go io.Copy(conn1, conn2)io.Copy(conn2, conn1)

TE Trailer Transfer-Encoding和請求傳輸的方式有關。代理在讀取客戶端請求時應該確保正確處理了 chunked 的傳輸方式後再刪除這幾個頭,由代理自行決定在發往目的伺服器時要不要使用分塊傳輸。類似的還有 Content-Encoding,這個決定的是請求的壓縮方式,也應該在代理端被科學的處理掉。好在傳輸方式這幾個頭在 Go 的標準庫中都有實現,對開發者基本都是透明的,開發者可以直接使用而無需關心具體的邏輯。

Websocket 與 HTTP2

前面提到過 Upgrade,這裡再簡單說說。這個頭常用於從 HTTP 轉換到 Websocket 或 HTTP2 協議。對於 Websocket,被動掃描時可以不關注,所以可以直接放行。這裡放行的意思是不再去解析,而是類似 Tunnel 那種,單純的進行數據轉發。對於 HTTP2 ,我們可以拒絕這一轉換,使得數據協議始終用 HTTP,也算是一個偷懶的捷徑。

當然,如果想要做的完善些,就需要套用一下這兩種協議的解析,偽裝成 Websocket server 或 HTTP2 server,然後做中間人去獲取傳輸數據,有興趣可以看一下 Python 的 MitmProxy 的實現。

離完美的差距

回顧剛才說的一些要點,這裡的被動代理實現其實並不完美,主要有這兩點:

第一點是隧道模式下,我們強行判定了以 0x16 開頭的就是 TLS 流量,協議千千萬,這種可能有誤判的。其次我們認為 TLS 層下的應用協議一定是 HTTP,這也是不妥的,但對於被動掃描這種場景是足夠了。

另一點是隧道模式下證書的簽發流程不夠完美。如果你用過虛擬主機,或者嘗試過在同一地址同一埠上運行多個 HTTP 服務,那一定知道 nginx 中的 server_name 或是 apache 的 VirtualHost。伺服器收到 HTTP 請求後會去查看請求的 Host 欄位,以此決定使用哪個服務。TLS 模式下有所不同,因為 TLS 握手時伺服器沒法讀取請求,為此 TLS 有個叫 SNI(Server Name Indication)的拓展解決了這個問題,即在 TLS 握手時發送客戶端請求的域給伺服器,使得在同一 ip 同一埠上運行多個 TLS 服務成為了可能。回到被動代理這,之前我們簽證書用的域是從 CONNECT 的 HOST 中獲取的,其實更好的辦法是從 TLS 的握手中讀取,這樣就需要自行實現 TLS 的握手過程了,具體可以參考下 MitmProxy 的實現。

https://docs.mitmproxy.org/stable/concepts-howmitmproxyworks/

後話

零零散散說了好多,一個看似簡單的 HTTP 代理實則暗藏各種玄機。在所有我見過的被動代理中,Python 的 MitmProxy 是實現的最全面最科學的,如果你想使用二而不關心其中的細節,推薦大家使用這個庫。截止到這篇文章發布,在 Go 中暫時還沒有類似 MitmProxy 那般完善的實現,於是我們在寫 xray 被動掃描代理的時候參考了幾個開源的項目並調整了一下,達到了我認為能用的狀態。如果我有時間,一定要整一個Go 版的 MitmProxy! (咕咕咕

有一些程式碼層面的細節沒法寫到,凡事都要身體力行才能得到一些獨到的理解,大家有時間可以親自嘗試下,相信會有不一樣的收穫。一家之談,難免有疏漏和謬誤,如果發現有問題,可以在評論處指正,或者和我微信交流下: emVtYWw2NjY=。

參考

RFC How mitmproxy works https://github.com/google/martian

*本文作者:koalrx,轉載請註明來自FreeBuf.COM