為什麼從重放開始#
理想上,為了做安全測試而實作用戶端或伺服器,應該只做「最低限度必要的工作」。降低成本最直接的一招,就是先擷取一段真實的協定流量,再把它重放(replay)給真正的用戶端或伺服器。本節看三種做法:
- 用 Netcat 送出原始二進位資料。
- 用 Python 送出 UDP 封包。
- 把第 5 章的分析程式碼改造成網路用戶端與伺服器。
用 Netcat 重放 TCP 流量#
Netcat 是實作網路用戶端/伺服器最簡單的工具,幾乎所有平台都有(有時叫 nc 或 netcat),只是不同版本的命令列選項略有差異;這裡使用 macOS 與多數 Linux 預設的 BSD 版本。
第一步:擷取要重放的流量#
用 Wireshark 的命令列版本 Tshark 擷取 SuperFunkyChat 的流量。為了只抓到 ChatServer(TCP 12345 埠)相關的封包,使用 BPF(Berkeley Packet Filter)運算式來限制擷取範圍。
tshark -i INTNAME -w capture.pcap tcp port 12345把 INTNAME 換成要擷取的介面名稱(例如 eth0)。接著建立一次用戶端連線產生流量,再按 CTRL+C 停止擷取。
BPF 運算式與 Wireshark 的顯示過濾器(display filter)不同:BPF 限制的是「擷取下來的封包」,顯示過濾器只是從一大包已擷取的封包中挑出要顯示的部分。
第二步:匯出單一方向的原始位元組#
先用 -r 讀回檔案確認抓對了東西,-z conv,tcp 會列出連線對話摘要,從中可以看到用戶端使用的來源埠(例如 26082)與雙向的封包數與位元組數。有了來源埠,就能把「用戶端送往伺服器」這個方向的資料單獨匯出成十六進位字串:
tshark -r capture.pcap -T fields -e data 'tcp.srcport==26082' > outbound.txt這個指令會把每個封包的資料原樣輸出,不會過濾掉重複或亂序的封包。因此只該用在可靠的網路上擷取的資料(例如 localhost 或區域網路連線),否則輸出裡會混進錯誤的封包。
另外,data 欄位只有在該協定沒有被 dissector 解析時才存在。TCP 擷取沒有這個問題,但等一下處理 UDP 時就必須手動停用 dissector。
第三步:轉成二進位並用 Netcat 送出#
用類 Unix 系統上普遍內建的 xxd 把十六進位轉回原始位元組(-p 表示處理的是純 hex dump,而非 xxd 預設的編號格式),再交給 Netcat:
xxd -p -r outbound.txt > outbound.bin
netcat HOSTNAME 12345 < outbound.bin > inbound.bin伺服器回送的流量會被存進 inbound.bin。你可以用十六進位編輯器直接修改 outbound.bin 來改變重放的內容;也可以反過來扮演伺服器,把 inbound.bin 送回給真實的用戶端:
netcat -l 12345 < inbound.bin > new_outbound.bin各步驟的實際輸出樣貌
驗證擷取結果(-z conv,tcp 會在原始封包列表後附上對話摘要):
$ tshark -r capture.pcap -z conv,tcp
1 0 192.168.56.1 → 192.168.56.100 TCP 66 26082 → 12345 [SYN]
2 0.000037695 192.168.56.100 → 192.168.56.1 TCP 66 12345 → 26082 [SYN, ACK]
3 0.000239814 192.168.56.1 → 192.168.56.100 TCP 60 26082 → 12345 [ACK]
4 0.007160883 192.168.56.1 → 192.168.56.100 TCP 60 26082 → 12345 [PSH, ACK]
5 0.007225155 192.168.56.100 → 192.168.56.1 TCP 54 12345 → 26082 [ACK]
--snip--
================================================================================
TCP Conversations
Filter:<No Filter>
| <- | | -> |
| Frames Bytes | | Frames Bytes |
192.168.56.1:26082 <-> 192.168.56.100:12345 17 1020 28 1733
================================================================================摘要顯示連線由 192.168.56.1:26082 連往 192.168.56.100:12345,用戶端收到 17 個 frame(1020 位元組),伺服器收到 28 個 frame(1733 位元組)。
匯出後的十六進位資料:
$ cat outbound.txt
42494e58
0000000d
00000347
00
057573657231044f4e595800
--snip--轉成二進位後可以看出協定的樣子(開頭的 BINX 魔術值、長度欄位,以及使用者名稱 user1):
$ xxd -p -r outbound.txt > outbound.bin
$ xxd outbound.bin
00000000: 4249 4e58 0000 000d 0000 0347 0005 7573 BINX.......G..us
00000010: 6572 3104 4f4e 5958 0000 0000 1c00 0009 er1.ONYX........
00000020: 7b03 0575 7365 7231 1462 6164 6765 7220 {..user1.badger
--snip--用 Python 重放 UDP 流量#
Netcat 的限制在於:重放 TCP 這類串流協定很容易,重放 UDP 卻不然。UDP 必須維持封包邊界(正如第 5 章分析聊天協定時看到的),而 Netcat 從檔案或 shell pipeline 讀資料時,只會盡可能一次送出越多越好。
解法是寫一支很小的 Python 腳本。先用 ChatClient 的 --udp 參數產生 UDP 流量並擷取:
tshark -i INTNAME -w udp_capture.pcap udp port 12345再把用戶端送往伺服器的封包轉成十六進位字串:
tshark -T fields -e data -r udp_capture.pcap --disable-protocol gvsp \
"udp.dstport==12345" > udp_outbound.txt這裡多了
--disable-protocol gvsp。Tshark 會自動嘗試把這段流量當成 GVSP 協定解析,一旦被 dissector 接手,data欄位就不存在了。停用該 dissector 才能得到正確輸出。
接著讓腳本逐行讀入 hex、還原成位元組送出,並印出收到的回應:
python udp_client.py HOSTNAME 12345 < udp_outbound.txtudp_client.py 完整程式碼
import sys
import binascii
from socket import socket, AF_INET, SOCK_DGRAM
if len(sys.argv) < 3:
print("Specify destination host and port")
exit(1)
# Create a UDP socket with a 1sec receive timeout
sock = socket(AF_INET, SOCK_DGRAM)
sock.settimeout(1)
addr = (sys.argv[1], int(sys.argv[2]))
for line in sys.stdin:
msg = binascii.a2b_hex(line.strip())
sock.sendto(msg, addr)
try:
data, server = sock.recvfrom(1024)
print(binascii.b2a_hex(data))
except:
pass這支腳本在 Python 2 與 3 下都能運作。伺服器會收到這些封包,用戶端收到的回應則以二進位字串印在主控台上。
改造第 5 章的分析代理程式#
第 5 章我們替 SuperFunkyChat 實作了一個簡單的代理程式(proxy),會擷取流量並做基本解析。既然解析器(parser)已經寫好了,就不必為了換框架或換語言重寫一遍——直接拿它來實作用戶端與伺服器,重放並修改流量。
先擷取範例流量#
實作之前要有素材。以第 5 章的 parser.csx 為基礎寫一支擷取用代理程式:在本機 4444 埠監聽,把新連線轉送到 127.0.0.1:12345,同時擷取流量。
兩個關鍵細節:
- 仍要把
Parser加為 layer,這樣擷取到的封包才是純資料部分,不含長度與檢查碼欄位。 - 寫出的
packets.bin會同時包含出向與入向封包,之後要用方向標籤過濾。
讓一個用戶端連線跑過這個代理程式並多操作幾下,關閉連線後按 ENTER 結束,封包就會寫進 packets.bin(這個檔案後面的用戶端與伺服器都會用到,記得留著)。
實作簡單的網路用戶端#
用 NetClientTemplate 建立連線,它會給你一個 Data Adapter,讓你以「已解析的封包」為單位讀寫網路。流程是:讀入擷取檔 → 過濾出 Out 方向的封包並寫進連線 → 設定讀取逾時,把伺服器回應印出來直到連線關閉。
有一個容易漏掉的地方:因為初始的網路流量是在 Parser 類別內部處理掉的,擷取檔裡不含用戶端一開始送出的魔術值。所以必須替 template 補上 InitialData:
template.InitialData = new byte[] { 0x42, 0x49, 0x4E, 0x58 };光是原封不動重放其實用處有限,真正有價值的是修改流量來測試協定功能。既然有了自己的用戶端,只要在送出迴圈裡動手腳即可,例如把所有封包裡的使用者名稱從 user1 換成 bobsmith:
string data = packet.Frame.ToDataString();
data = data.Replace("\u0005user1", "\u0008bobsmith");
adapter.Write(data.ToDataFrame());擷取代理程式與用戶端的完整程式碼
擷取用代理程式 chapter8_capture_proxy.csx:
#load "parser.csx"
using static System.Console;
using static CANAPE.Cli.ConsoleUtils;
var template = new FixedProxyTemplate();
// Local port of 4444, destination 127.0.0.1:12345
template.LocalPort = 4444;
template.Host = "127.0.0.1";
template.Port = 12345;
template.AddLayer<Parser>();
var service = template.Create();
service.Start();
WriteLine("Created {0}", service);
WriteLine("Press Enter to exit...");
ReadLine();
service.Stop();
WriteLine("Writing Outbound Packets to packets.bin");
service.Packets.WriteToFile("packets.bin", "Out");網路用戶端 chapter8_client.csx:
#load "parser.csx"
using static System.Console;
using static CANAPE.Cli.ConsoleUtils;
if (args.Length < 1) {
WriteLine("Please Specify a Capture File");
return;
}
var template = new NetClientTemplate();
template.Port = 12345;
template.Host = "127.0.0.1";
template.AddLayer<Parser>();
template.InitialData = new byte[] { 0x42, 0x49, 0x4E, 0x58 };
var packets = LogPacketCollection.ReadFromFile(args[0]);
using(var adapter = template.Connect()) {
WriteLine("Connected");
// Write packets to adapter
foreach(var packet in packets.GetPacketsForTag("Out")) {
adapter.Write(packet.Frame);
}
// Set a 1000ms timeout on read so we disconnect
adapter.ReadTimeout = 1000;
DataFrame frame = adapter.Read();
while(frame != null) {
WritePacket(frame);
frame = adapter.Read();
}
}每支腳本都能透過 args 變數取得命令列參數,所以不必改程式就能換不同的擷取檔。執行時把擷取檔路徑傳進去,它就會連上伺服器並重放你原本的工作階段——原始擷取中送出的訊息會被重新送一次。
實作簡單的伺服器#
安全問題可能出在用戶端,也可能出在伺服器,所以同樣要能扮演伺服器。做法分兩層:
- 一個伺服器類別:每有新連線就建立一個實例,
Run()方法拿到的 Data Adapter 與用戶端那邊用的完全相同。它先等用戶端送來第一個封包,收到後就把整份封包清單一次送回去。 - 一個template:指定伺服器類別與設定類別的型別、監聽埠、要掛的
Parserlayer,並把過濾成In方向的封包塞進設定裡,最後建立並啟動 service。
伺服器類別本身完全不知道 SuperFunkyChat 協定的存在——它不過濾封包、也不指定任何解析器。協定相關的行為全部由 template 上的 layer 決定,這讓同一個伺服器類別可以套用到別的協定上。
用真正的 ChatClient 連上這個伺服器,擷取的流量就會被送回用戶端;資料送完後伺服器會自動關閉連線。只要確認訊息有被重送,ChatClient 輸出裡出現錯誤訊息不必在意。
伺服器類別與 template 的完整程式碼
伺服器類別 chat_server.csx:
using CANAPE.Nodes;
using CANAPE.DataAdapters;
using CANAPE.Net.Templates;
class ChatServerConfig {
public LogPacketCollection Packets { get; private set; }
public ChatServerConfig() {
Packets = new LogPacketCollection();
}
}
class ChatServer : BaseDataEndpoint<ChatServerConfig> {
public override void Run(IDataAdapter adapter, ChatServerConfig config) {
Console.WriteLine("New Connection");
DataFrame frame = adapter.Read();
// Wait for the client to send us the first packet
if (frame != null) {
// Write all packets to client
foreach(var packet in config.Packets) {
adapter.Write(packet.Frame);
}
}
frame = adapter.Read();
}
}設定類別其實可以直接用 LogPacketCollection 當設定型別,這裡刻意獨立成一個類別,是為了示範日後要加自己的設定項目時該怎麼擺。
啟動用的 chapter8_example_server.csx:
#load "chat_server.csx"
#load "parser.csx"
using static System.Console;
if (args.Length < 1) {
WriteLine("Please Specify a Capture File");
return;
}
var template = new NetServerTemplate<ChatServer, ChatServerConfig>();
template.LocalPort = 12345;
template.AddLayer<Parser>();
var packets = LogPacketCollection.ReadFromFile(args[0])
.GetPacketsForTag("In");
template.ServerFactoryConfig.Packets.AddRange(packets);
var service = template.Create();
service.Start();
WriteLine("Created {0}", service);
WriteLine("Press Enter to exit...");
ReadLine();
service.Stop();結構與第 2 章的 DNS 伺服器腳本非常相似:建立 template、載入並過濾封包、建立 service 並啟動——就像啟動代理程式一樣。當然,你也可以在此基礎上加功能,例如修改流量或產生全新的封包。