使用 mitmproxy + python 做攔截代理
轉自:https://blog.wolfogre.com/posts/usage-of-mitmproxy/
本文是一個較為完整的 mitmproxy 教程,側重於介紹如何開發攔截腳本,幫助讀者能夠快速得到一個自定義的代理工具。
本文假設讀者有基本的 python 知識,且已經安裝好了一個 python 3 開發環境。如果你對 nodejs 的熟悉程度大於對 python,可移步到 anyproxy,anyproxy 的功能與 mitmproxy 基本一致,但使用 js 編寫定制腳本。除此之外我就不知道有什么其他類似的工具了,如果你知道,歡迎評論告訴我。
本文基於 mitmproxy v4,當前版本號為 v4.0.1。
mitmproxy 是什么
顧名思義,mitmproxy 就是用於 MITM 的 proxy,MITM 即中間人攻擊(Man-in-the-middle attack)。用於中間人攻擊的代理首先會向正常的代理一樣轉發請求,保障服務端與客戶端的通信,其次,會適時的查、記錄其截獲的數據,或篡改數據,引發服務端或客戶端特定的行為。
不同於 fiddler 或 wireshark 等抓包工具,mitmproxy 不僅可以截獲請求幫助開發者查看、分析,更可以通過自定義腳本進行二次開發。舉例來說,利用 fiddler 可以過濾出瀏覽器對某個特定 url 的請求,並查看、分析其數據,但實現不了高度定制化的需求,類似於:“截獲對瀏覽器對該 url 的請求,將返回內容置空,並將真實的返回內容存到某個數據庫,出現異常時發出郵件通知”。而對於 mitmproxy,這樣的需求可以通過載入自定義 python 腳本輕松實現。
但 mitmproxy 並不會真的對無辜的人發起中間人攻擊,由於 mitmproxy 工作在 HTTP 層,而當前 HTTPS 的普及讓客戶端擁有了檢測並規避中間人攻擊的能力,所以要讓 mitmproxy 能夠正常工作,必須要讓客戶端(APP 或瀏覽器)主動信任 mitmproxy 的 SSL 證書,或忽略證書異常,這也就意味着 APP 或瀏覽器是屬於開發者本人的——顯而易見,這不是在做黑產,而是在做開發或測試。
那這樣的工具有什么實際意義呢?據我所知目前比較廣泛的應用是做仿真爬蟲,即利用手機模擬器、無頭瀏覽器來爬取 APP 或網站的數據,mitmpproxy 作為代理可以攔截、存儲爬蟲獲取到的數據,或修改數據調整爬蟲的行為。
事實上,以上說的僅是 mitmproxy 以正向代理模式工作的情況,通過調整配置,mitmproxy 還可以作為透明代理、反向代理、上游代理、SOCKS 代理等,但這些工作模式針對 mitmproxy 來說似乎不大常用,故本文僅討論正向代理模式。
安裝
“安裝 mitmproxy”這句話是有歧義的,既可以指“安裝 mitmproxy 工具”,也可以指“安裝 python 的 mitmproxy 包”,注意后者是包含前者的。
如果只是拿 mitmproxy 做一個替代 fiddler 的工具,沒有什么定制化的需求,那完全只需要“安裝 mitmproxy 工具”即可,去 mitmproxy 官網 上下載一個 installer 便可開箱即用,不需要提前准備好 python 開發環境。但顯然,這不是這里要討論的,我們需要的是“安裝 python 的 mitmproxy 包”。
安裝 python 的 mitmproxy 包除了會得到 mitmproxy 工具外,還會得到開發定制腳本所需要的包依賴,其安裝過程並不復雜。
首先需要安裝好 python,版本需要不低於 3.6,且安裝了附帶的包管理工具 pip。不同操作系統安裝 python 3 的方式不一,參考 python 的下載頁,這里不做展開,假設你已經准備好這樣的環境了。
安裝開始。
在 linux 中:
sudo pip3 install mitmproxy
在 windows 中,以管理員身份運行 cmd 或 power shell:
pip3 install mitmproxy
安裝結束。
完成后,系統將擁有 mitmproxy
、mitmdump
、mitmweb
三個命令,由於 mitmproxy
命令不支持在 windows 系統中運行(這沒關系,不用擔心),我們可以拿 mitmdump
測試一下安裝是否成功,執行:
mitmdump --version
應當可以看到類似於這樣的輸出:
Mitmproxy: 4.0.1 Python: 3.6.5 OpenSSL: OpenSSL 1.1.0h 27 Mar 2018 Platform: Windows-10-10.0.16299-SP0
運行
要啟動 mitmproxy 用 mitmproxy
、mitmdump
、mitmweb
這三個命令中的任意一個即可,這三個命令功能一致,且都可以加載自定義腳本,唯一的區別是交互界面的不同。
mitmproxy
命令啟動后,會提供一個命令行界面,用戶可以實時看到發生的請求,並通過命令過濾請求,查看請求數據。形如:
mitmweb
命令啟動后,會提供一個 web 界面,用戶可以實時看到發生的請求,並通過 GUI 交互來過濾請求,查看請求數據。形如:
mitmdump
命令啟動后——你應該猜到了,沒有界面,程序默默運行,所以 mitmdump 無法提供過濾請求、查看數據的功能,只能結合自定義腳本,默默工作。
由於 mitmproxy
命令的交互操作稍顯繁雜且不支持 windows 系統,而我們主要的使用方式又是載入自定義腳本,並不需要交互,所以原則上說只需要 mitmdump
即可,但考慮到有交互界面可以更方便排查錯誤,所以這里以 mitmweb
命令為例。實際使用中可以根據情況選擇任何一個命令。
啟動 mitmproxy:
mitmweb
應當看到如下輸出:
Web server listening at http://127.0.0.1:8081/
Proxy server listening at http://*:8080
mitmproxy 綁定了 *:8080
作為代理端口,並提供了一個 web 交互界面在 127.0.0.1:8081
。
現在可以測試一下代理,讓 Chrome 以 mitmproxy 為代理並忽略證書錯誤。為了不影響平時正常使用,我們不去改 Chrome 的配置,而是通過命令行帶參數起一個 Chrome。如果你不使用 Chrome 而是其他瀏覽器,也可以搜一下對應的啟動參數是什么,應該不會有什么坑。此外示例僅以 windows 系統為例,因為使用 linux 或 mac 開發的同學應該更熟悉命令行的使用才對,應當能自行推導出在各自環境中對應的操作。
由於 Chrome 要開始赴湯蹈火走代理了,為了方便繼續在 web 界面上與 mitmproxy 交互,我們委屈求全使用 Edge 或其他瀏覽器打開 127.0.0.1:8081。插一句,我用 Edge 實在是因為機器上沒其他瀏覽器了(IE 不算),Edge 有一個默認禁止訪問回環地址的狗屁設定,詳見解決方案。
接下來關閉所有 Chrome 窗口,否則命令行啟動時的附加參數將失效。打開 cmd,執行:
"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --proxy-server=127.0.0.1:8080 --ignore-certificate-errors
前面那一長串是 Chrome 的的安裝路徑,應當根據系統實際情況修改,后面兩參數設置了代理地址並強制忽略掉證書錯誤。用 Chrome 打開一個網站,可以看到:
同時在 Edge 上可以看到:
腳本
完成了上述工作,我們已經具備了操作 mitmproxy 的基本能力 了。接下來開始開發自定義腳本,這才是 mitmproxy 真正強大的地方。
腳本的編寫需要遵循 mitmproxy 規定的套路,這樣的套路有兩個。
第一個是,編寫一個 py 文件供 mitmproxy 加載,文件中定義了若干函數,這些函數實現了某些 mitmproxy 提供的事件,mitmproxy 會在某個事件發生時調用對應的函數,形如:
import mitmproxy.http from mitmproxy import ctx num = 0 def request(flow: mitmproxy.http.HTTPFlow): global num num = num + 1 ctx.log.info("We've seen %d flows" % num)
第二個是,編寫一個 py 文件供 mitmproxy 加載,文件定義了變量 addons,addons 是個數組,每個元素是一個類實例,這些類有若干方法,這些方法實現了某些 mitmproxy 提供的事件,mitmproxy 會在某個事件發生時調用對應的方法。這些類,稱為一個個 addon
,比如一個叫 Counter 的 addon:
import mitmproxy.http from mitmproxy import ctx class Counter: def __init__(self): self.num = 0 def request(self, flow: mitmproxy.http.HTTPFlow): self.num = self.num + 1 ctx.log.info("We've seen %d flows" % self.num) addons = [ Counter() ]
這里強烈建議使用第二種套路,直覺上就會感覺第二種套路更為先進,使用會更方便也更容易管理和拓展。況且這也是官方內置的一些 addon 的實現方式。
我們將上面第二種套路的示例代碼存為 addons.py,再重新啟動 mitmproxy:
mitmweb -s addons.py
當瀏覽器使用代理進行訪問時,就應該能看到控制台里有類似這樣的日志:
Web server listening at http://127.0.0.1:8081/ Loading script addons.py Proxy server listening at http://*:8080 We've seen 1 flows …… …… We've seen 2 flows …… We've seen 3 flows …… We've seen 4 flows …… …… We've seen 5 flows ……
這就說明自定義腳本生效了。
事件
上述的腳本估計不用我解釋相信大家也看明白了,就是當 request 發生時,計數器加一,並打印日志。這里對應的是 request 事件,那攏共有哪些事件呢?不多,也不少,這里詳細介紹一下。
事件針對不同生命周期分為 5 類。“生命周期”這里指在哪一個層面看待事件,舉例來說,同樣是一次 web 請求,我可以理解為“HTTP 請求 -> HTTP 響應”的過程,也可以理解為“TCP 連接 -> TCP 通信 -> TCP 斷開”的過程。那么,如果我想拒絕來個某個 IP 的客戶端請求,應當注冊函數到針對 TCP 生命周期 的 tcp_start
事件,又或者,我想阻斷對某個特定域名的請求時,則應當注冊函數到針對 HTTP 聲明周期的 http_connect
事件。其他情況同理。
下面一段估計會又臭又長,如果你沒有耐心看完,那至少看掉針對 HTTP 生命周期的事件,然后跳到示例。
1. 針對 HTTP 生命周期
def http_connect(self, flow: mitmproxy.http.HTTPFlow):
(Called when) 收到了來自客戶端的 HTTP CONNECT 請求。在 flow 上設置非 2xx 響應將返回該響應並斷開連接。CONNECT 不是常用的 HTTP 請求方法,目的是與服務器建立代理連接,僅是 client 與 proxy 的之間的交流,所以 CONNECT 請求不會觸發 request、response 等其他常規的 HTTP 事件。
def requestheaders(self, flow: mitmproxy.http.HTTPFlow):
(Called when) 來自客戶端的 HTTP 請求的頭部被成功讀取。此時 flow 中的 request 的 body 是空的。
def request(self, flow: mitmproxy.http.HTTPFlow):
(Called when) 來自客戶端的 HTTP 請求被成功完整讀取。
def responseheaders(self, flow: mitmproxy.http.HTTPFlow):
(Called when) 來自服務端的 HTTP 響應的頭部被成功讀取。此時 flow 中的 response 的 body 是空的。
def response(self, flow: mitmproxy.http.HTTPFlow):
(Called when) 來自服務端端的 HTTP 響應被成功完整讀取。
def error(self, flow: mitmproxy.http.HTTPFlow):
(Called when) 發生了一個 HTTP 錯誤。比如無效的服務端響應、連接斷開等。注意與“有效的 HTTP 錯誤返回”不是一回事,后者是一個正確的服務端響應,只是 HTTP code 表示錯誤而已。
2. 針對 TCP 生命周期
def tcp_start(self, flow: mitmproxy.tcp.TCPFlow):
(Called when) 建立了一個 TCP 連接。
def tcp_message(self, flow: mitmproxy.tcp.TCPFlow):
(Called when) TCP 連接收到了一條消息,最近一條消息存於 flow.messages[-1]。消息是可修改的。
def tcp_error(self, flow: mitmproxy.tcp.TCPFlow):
(Called when) 發生了 TCP 錯誤。
def tcp_end(self, flow: mitmproxy.tcp.TCPFlow):
(Called when) TCP 連接關閉。
3. 針對 Websocket 生命周期
def websocket_handshake(self, flow: mitmproxy.http.HTTPFlow):
(Called when) 客戶端試圖建立一個 websocket 連接。可以通過控制 HTTP 頭部中針對 websocket 的條目來改變握手行為。flow 的 request 屬性保證是非空的的。
def websocket_start(self, flow: mitmproxy.websocket.WebSocketFlow):
(Called when) 建立了一個 websocket 連接。
def websocket_message(self, flow: mitmproxy.websocket.WebSocketFlow):
(Called when) 收到一條來自客戶端或服務端的 websocket 消息。最近一條消息存於 flow.messages[-1]。消息是可修改的。目前有兩種消息類型,對應 BINARY 類型的 frame 或 TEXT 類型的 frame。
def websocket_error(self, flow: mitmproxy.websocket.WebSocketFlow):
(Called when) 發生了 websocket 錯誤。
def websocket_end(self, flow: mitmproxy.websocket.WebSocketFlow):
(Called when) websocket 連接關閉。
4. 針對網絡連接生命周期
def clientconnect(self, layer: mitmproxy.proxy.protocol.Layer):
(Called when) 客戶端連接到了 mitmproxy。注意一條連接可能對應多個 HTTP 請求。
def clientdisconnect(self, layer: mitmproxy.proxy.protocol.Layer):
(Called when) 客戶端斷開了和 mitmproxy 的連接。
def serverconnect(self, conn: mitmproxy.connections.ServerConnection):
(Called when) mitmproxy 連接到了服務端。注意一條連接可能對應多個 HTTP 請求。
def serverdisconnect(self, conn: mitmproxy.connections.ServerConnection):
(Called when) mitmproxy 斷開了和服務端的連接。
def next_layer(self, layer: mitmproxy.proxy.protocol.Layer):
(Called when) 網絡 layer 發生切換。你可以通過返回一個新的 layer 對象來改變將被使用的 layer。詳見 layer 的定義。
5. 通用生命周期
def configure(self, updated: typing.Set[str]):
(Called when) 配置發生變化。updated 參數是一個類似集合的對象,包含了所有變化了的選項。在 mitmproxy 啟動時,該事件也會觸發,且 updated 包含所有選項。
def done(self):
(Called when) addon 關閉或被移除,又或者 mitmproxy 本身關閉。由於會先等事件循環終止后再觸發該事件,所以這是一個 addon 可以看見的最后一個事件。由於此時 log 也已經關閉,所以此時調用 log 函數沒有任何輸出。
def load(self, entry: mitmproxy.addonmanager.Loader):
(Called when) addon 第一次加載時。entry 參數是一個 Loader 對象,包含有添加選項、命令的方法。這里是 addon 配置它自己的地方。
def log(self, entry: mitmproxy.log.LogEntry):
(Called when) 通過 mitmproxy.ctx.log 產生了一條新日志。小心不要在這個事件內打日志,否則會造成死循環。
def running(self):
(Called when) mitmproxy 完全啟動並開始運行。此時,mitmproxy 已經綁定了端口,所有的 addon 都被加載了。
def update(self, flows: typing.Sequence[mitmproxy.flow.Flow]):
(Called when) 一個或多個 flow 對象被修改了,通常是來自一個不同的 addon。
示例
估計看了那么多的事件你已經暈了,正常,鬼才會記得那么多事件。事實上考慮到 mitmproxy 的實際使用場景,大多數情況下我們只會用到針對 HTTP 生命周期的幾個事件。再精簡一點,甚至只需要用到 http_connect
、request
、response
三個事件就能完成大多數需求了。
這里以一個稍微有點黑色幽默的例子,覆蓋這三個事件,展示如果利用 mitmproxy 工作。
需求是這樣的:
- 因為百度搜索是不靠譜的,所有當客戶端發起百度搜索時,記錄下用戶的搜索詞,再修改請求,將搜索詞改為“360 搜索”;
- 因為 360 搜索還是不靠譜的,所有當客戶端訪問 360 搜索時,將頁面中所有“搜索”字樣改為“請使用谷歌”。
- 因為谷歌是個不存在的網站,所有就不要浪費時間去嘗試連接服務端了,所有當發現客戶端試圖訪問谷歌時,直接斷開連接。
- 將上述功能組裝成名為 Joker 的 addon,並保留之前展示名為 Counter 的 addon,都加載進 mitmproxy。
第一個需求需要篡改客戶端請求,所以實現一個 request
事件:
def request(self, flow: mitmproxy.http.HTTPFlow): # 忽略非百度搜索地址 if flow.request.host != "www.baidu.com" or not flow.request.path.startswith("/s"): return # 確認請求參數中有搜索詞 if "wd" not in flow.request.query.keys(): ctx.log.warn("can not get search word from %s" % flow.request.pretty_url) return # 輸出原始的搜索詞 ctx.log.info("catch search word: %s" % flow.request.query.get("wd")) # 替換搜索詞為“360搜索” flow.request.query.set_all("wd", ["360搜索"])
第二個需求需要篡改服務端響應,所以實現一個 response
事件:
def response(self, flow: mitmproxy.http.HTTPFlow): # 忽略非 360 搜索地址 if flow.request.host != "www.so.com": return # 將響應中所有“搜索”替換為“請使用谷歌” text = flow.response.get_text() text = text.replace("搜索", "請使用谷歌") flow.response.set_text(text)
第三個需求需要拒絕客戶端請求,所以實現一個 http_connect
事件:
def http_connect(self, flow: mitmproxy.http.HTTPFlow): # 確認客戶端是想訪問 www.google.com if flow.request.host == "www.google.com": # 返回一個非 2xx 響應斷開連接 flow.response = http.HTTPResponse.make(404)
為了實現第四個需求,我們需要將代碼整理一下,即易於管理也易於查看。
創建一個 joker.py
文件,內容為:
import mitmproxy.http from mitmproxy import ctx, http class Joker: def request(self, flow: mitmproxy.http.HTTPFlow): if flow.request.host != "www.baidu.com" or not flow.request.path.startswith("/s"): return if "wd" not in flow.request.query.keys(): ctx.log.warn("can not get search word from %s" % flow.request.pretty_url) return ctx.log.info("catch search word: %s" % flow.request.query.get("wd")) flow.request.query.set_all("wd", ["360搜索"]) def response(self, flow: mitmproxy.http.HTTPFlow): if flow.request.host != "www.so.com": return text = flow.response.get_text() text = text.replace("搜索", "請使用谷歌") flow.response.set_text(text) def http_connect(self, flow: mitmproxy.http.HTTPFlow): if flow.request.host == "www.google.com": flow.response = http.HTTPResponse.make(404)
創建一個 counter.py
文件,內容為:
import mitmproxy.http from mitmproxy import ctx class Counter: def __init__(self): self.num = 0 def request(self, flow: mitmproxy.http.HTTPFlow): self.num = self.num + 1 ctx.log.info("We've seen %d flows" % self.num)
創建一個 addons.py
文件,內容為:
import counter import joker addons = [ counter.Counter(), joker.Joker(), ]
將三個文件放在相同的文件夾,在該文件夾內啟動命令行,運行:
mitmweb -s addons.py
老規矩,關閉所有 Chrome 窗口,從命令行中啟動 Chrome 並指定代理且忽略證書錯誤。
測試一下運行效果:
最后
以上便是全部內容。Have fun and good luck!