為什麼從重放開始#

理想上,為了做安全測試而實作用戶端或伺服器,應該只做「最低限度必要的工作」。降低成本最直接的一招,就是先擷取一段真實的協定流量,再把它重放(replay)給真正的用戶端或伺服器。本節看三種做法:

  • 用 Netcat 送出原始二進位資料。
  • 用 Python 送出 UDP 封包。
  • 把第 5 章的分析程式碼改造成網路用戶端與伺服器。

用 Netcat 重放 TCP 流量#

Netcat 是實作網路用戶端/伺服器最簡單的工具,幾乎所有平台都有(有時叫 ncnetcat),只是不同版本的命令列選項略有差異;這裡使用 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.txt
udp_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:指定伺服器類別與設定類別的型別、監聽埠、要掛的 Parser layer,並把過濾成 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 並啟動——就像啟動代理程式一樣。當然,你也可以在此基礎上加功能,例如修改流量或產生全新的封包。