Protokół oznacza “oficjalny standard postępowania w danych sytuacjach”. Jako programiści embedded słysząc słowo “protokół” myślimy najczęściej o protokole komunikacyjnym, czyli zestawie reguł, na podstawie których urządzenia komunikują się ze sobą. Protokoły komunikacyjne są wszechobecne, od tych niskopoziomowych w obrębie PCB (I2C, SPI, UART), przez protokoły sieciowe (TCP, UDP), po wysokopoziomowe (JSON-RPC). W tym artykule pokażemy jak dodać do Wireshark obsługę naszego własnego protokołu - napiszemy plugin w Lua, który posłuży do dekodowania ramek wiadomości. Do dzieła!
W pracy programisty embedded nie raz tworzymy własne protokoły, następnie implementujemy je, żeby potem spędzić wiele godzin szukając dlaczego komunikacja nie działa. Debugowanie komunikacji między urządzeniami jest stałą częścią naszej pracy, dlatego warto korzystać z narzędzi które tą pracę ułatwią.
O ile na najniższym poziomie często wystarczy analizator stanów logicznych, o tyle w wyższych wartstwach i przy bardziej skomplikowanej komunikacji warto sięgnąć po inne narzędzia. Jednym z najlepszych narzędzi do analizy protokołów sieciowych jest Wireshark. Zwykle kojarzy się on nam z analizą ruchu sieciowego TCP/IP, jednak w praktyce możemy wykorzystać jego zaawansowany interfejs również do analizy innych protokołów, w tym naszych własnych protokołów.
W tym artykule pokażemy jak dodać do Wireshark obsługę naszego własnego protokołu - napiszemy plugin w Lua, który posłuży do dekodowania ramek wiadomości. Do dzieła!
Przykładowy protokół
// excom proto - EXample COMmunication Protocol
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdbool.h>
#define __packed __attribute__((__packed__))
enum RequestType {
REQ_DISPLAY = 1,
REQ_LED = 2,
};
typedef struct {
uint32_t text_length;
char text[];
} __packed display_request_t;
typedef struct {
uint16_t id;
bool state;
} __packed led_request_t;
typedef union {
display_request_t display;
led_request_t led;
} __packed request_data_t;
typedef struct {
uint32_t id;
uint8_t type;
request_data_t data;
} __packed request_t;
typedef struct {
uint32_t id;
bool status;
} __packed response_t;
#define REQUEST_BASE_SIZE (sizeof(request_t) - sizeof(request_data_t))
Przyjmijmy że komunikacja przebiega pomiędzy dwoma urządzeniami:
- klient - wysyła request_t i oczekuje response_t;
- serwer - oczekuje request_t, przetwarza zapytanie i odsyła odpowiedź response_t;
- display_request_t - wyświetlenie tekstu na wyświetlaczu;
- led_request_t - zmiana stanu diody;
Aplikacja testowa
O ile moglibyśmy zaimplementować nasz protokół na rzeczywistych urządzeniach, to na potrzeby tego artykułu zasymulujemy to na PC. W tym artykule wykorzystamy protokół TCP jako warstwę transportową (model OSI) dla naszego protokołu excom (warstwa aplikacji). Uprości to na początku znacząco testowanie naszego protokołu w Wireshark. W kolejnym artykule pokażemy jak łatwo zmodyfikować nasze rozwiązanie tak, aby pominąć TCP lub wykorzystać inny protokół niższej warstwy.
- excom_client.c
- wysyła kolejne display_request_t z tekstem snprintf(..., "Hello world %zu!", i) do momentu otrzymania response_t ze statusem true
- potem wysyła serię led_request_t dla id w zakresie [0, 42)
- excom_server.c
- w pętli odczytuje request_t i wysyła response_t, response_t.status ustawia na true jedynie w co 42 pakiecie
Link do pełnego kodu dostępny w źródłach [1].
# Terminal 1
socat -x TCP-LISTEN:9000,reuseaddr,fork EXEC:./excom_server.elf
# Terminal 2
socat -x TCP:localhost:9000 EXEC:./excom_client.elf
$ socat -x TCP-LISTEN:9000,reuseaddr,fork EXEC:./excom_server.elf
> 2024/04/22 11:09:37.000217666 length=23 from=0 to=22
01 00 00 00 01 0e 00 00 00 48 65 6c 6c 6f 20 77 6f 72 6c 64 20 30 21
< 2024/04/22 11:09:37.000217760 length=5 from=0 to=4
01 00 00 00 00
> 2024/04/22 11:09:37.000217877 length=23 from=23 to=45
02 00 00 00 01 0e 00 00 00 48 65 6c 6c 6f 20 77 6f 72 6c 64 20 31 21
< 2024/04/22 11:09:37.000217934 length=5 from=5 to=9
02 00 00 00 00
Pierwsze kroki z Wireshark
Mając przygotowane środowisko, możemy wystartować Wireshark żeby podsłuchać komunikację między excom_server.c
a excom_client.c
.
Dissector protokołów
Wireshark korzysta z tzw. dissectorów do analizowania otrzymanych pakietów. Dissector parsuje dane zgodnie z danym protokołem, po czym może przekazać część danych do dissectorów innych protokołów. O ile Wireshark posiada wbudowane wsparcie dla większości popularnych protokołów, o tyle o naszym na pewno jeszcze nie wie. Na szczęście istnieje możliwość dodawania własnych dissectorów jako “pluginów” do Wiresharka. Plugin taki możemy zdefiniować w języku skryptowym Lua i załadować dynamicznie przy starcie Wireshark.
Lua to lekki język skryptowy stworzony z myślą o wbudowywaniu go w inne programy. Sam język daje duże możliwości, pomimo prostej składni, jedynie 8 typów danych i kilku dziwactw (indeksowanie od 1!). Jeśli nie mieliście z nim dotąd styczności, to do naszych celów wystarczy zaznajomić się z Learn Lua in Y minutes [3] bez sekcji 3.1 i 4. Dokładniejsze informacje można znaleźć na oficjalnej stronie [4].
wireshark -X lua_script:excom_protocol.lua -k -i lo -f 'port 9000'
Na początek zdefiniujmy wstępną implementację dissectora:
-- Our protocol object
excom_proto = Proto('excom-proto', 'EXample COMmunication protocol')
-- Helper function for ProtoField names
local function field(field_name)
return string.format('%s.%s', excom_proto.name, field_name)
end
-- RequestType enum
local request_type = {
REQ_DISPLAY = 1,
REQ_LED = 2,
}
-- Mapping of RequestType value to name
local request_type_names = {}
for name, value in pairs(request_type) do
request_type_names[value] = name
end
-- Define field types available in our protocol, as a table to easily reference them later
local fields = {
id = ProtoField.uint32(field('id'), 'Request ID', base.DEC),
-- request_t
type = ProtoField.uint8(field('type'), 'Request type', base.HEX, request_type_names),
-- response_t
status = ProtoField.bool(field('status'), 'Response status'),
}
-- Add all the types to Proto.fields list
for _, proto_field in pairs(fields) do
table.insert(excom_proto.fields, proto_field)
end
-- Dissector callback, called for each packet
excom_proto.dissector = function(buf, pinfo, root)
-- arguments:
-- buf: packet's buffer (https://www.wireshark.org/docs/wsdg_html_chunked/lua_module_Tvb.html#lua_class_Tvb)
-- pinfo: packet information (https://www.wireshark.org/docs/wsdg_html_chunked/lua_module_Pinfo.html#lua_class_Pinfo)
-- root: node of packet details tree (https://www.wireshark.org/docs/wsdg_html_chunked/lua_module_Tree.html#lua_class_TreeItem)
-- Set name of the protocol
pinfo.cols.protocol:set(excom_proto.name)
-- Add new tree node for our protocol details
local tree = root:add(excom_proto, buf())
-- Extract message ID, this is the same for request_t and response_t
-- `id` is of type uint32_t, so get a sub-slice: buf(offset=0, length=4)
local id_buf = buf(0, 4)
tree:add_le(fields.id, id_buf)
-- request_t
local type_data = buf(4, 1)
tree:add_le(fields.type, type_data)
end
-- Register our protocol to be automatically used for traffic on port 9000
local tcp_port = DissectorTable.get('tcp.port')
tcp_port:add(9000, excom_proto)
Wireshark przed wczytaniem naszego pliku ładuje definicje API które będą dostępne jako zmienne globalne. Opis dostępnego API można znaleźć na oficjalnej stronie Wireshark’s Lua API Reference Manual [5].
# Terminal 1
wireshark -X lua_script:excom_protocol.lua -k -i lo -f 'port 9000'
# Terminal 2
socat -x TCP-LISTEN:9000,reuseaddr,fork EXEC:./excom_server.elf
# Terminal 3
socat -x TCP:localhost:9000 EXEC:./excom_client.elf
Sukces! Potrafimy już zdekodować pola naszego protokołu, teraz czas dodać resztę logiki.
Pełen kod dostępny poniżej w źródłach [6].
Dekodowanie odpowiedzi
local server_port = 9000
excom_proto.dissector = function(buf, pinfo, root)
local tree = root:add(excom_proto, buf())
local id_buf = buf(0, 4)
tree:add_le(fields.id, id_buf)
if pinfo.dst_port == 9000 then
-- request_t
local type_data = buf(4, 1)
tree:add_le(fields.type, type_data)
else
-- response_t
tree:add_le(fields.status, buf(4, 1))
end
end
local fields = {
id = ProtoField.uint32(field('id'), 'Request ID', base.DEC),
-- request_t
type = ProtoField.uint8(field('type'), 'Request type', base.HEX, request_type_names),
-- response_t
status = ProtoField.bool(field('status'), 'Response status'),
-- display_request_t
display_text_length = ProtoField.uint32(field('display.text_length'), 'Text length', base.DEC),
display_text = ProtoField.string(field('display.text'), 'Text', base.ASCII),
-- led_request_t
led_id = ProtoField.uint16(field('led.id'), 'LED ID', base.DEC),
led_state = ProtoField.bool(field('led.state'), 'LED state'),
}
excom_proto.dissector = function(buf, pinfo, root)
local tree = root:add(excom_proto, buf())
local id_buf = buf(0, 4)
tree:add_le(fields.id, id_buf)
if pinfo.dst_port == server_port then
-- request_t
local type_data = buf(4, 1)
tree:add_le(fields.type, type_data)
-- request_data_t depending on the `type` field
local type = type_data:le_uint()
if type == request_type.REQ_DISPLAY then
-- display_request_t
local len_buf = buf(5, 4)
tree:add_le(fields.display_text_length, len_buf)
tree:add_le(fields.display_text, buf(9, len_buf:le_uint()))
elseif type == request_type.REQ_LED then
-- led_request_t
tree:add_le(fields.led_id, buf(5, 2))
tree:add_le(fields.led_state, buf(7, 1))
end
else
-- response_t
tree:add_le(fields.status, buf(4, 1))
end
end
Pełen kod dostępny poniżej w źródłach [7].
Parowanie request-response
local fields = {
-- (...)
-- special fields to provide information about matching request/response
request = ProtoField.framenum(field('request'), 'Request', base.NONE, frametype.REQUEST),
response = ProtoField.framenum(field('response'), 'Response', base.NONE, frametype.RESPONSE),
}
-- Mappings of request/response ID to frame numbers
local id2frame = {
request = {}, -- request id -> request frame number
response = {}, -- response id -> response frame number
}
excom_proto.dissector = function(buf, pinfo, root)
local tree = root:add(excom_proto, buf())
local id_buf = buf(0, 4)
tree:add_le(fields.id, id_buf)
local id = id_buf:uint()
if pinfo.dst_port == server_port then
--- (...)
-- On first dissection run (pinfo.visited=false) store mapping from request id to frame number
if not pinfo.visited then
id2frame.request[id_buf:uint()] = pinfo.number
end
-- If possible add information about matching response
if id2frame.response[id] then
tree:add_le(fields.response, id2frame.response[id])
end
else
--- (...)
if not pinfo.visited then
id2frame.response[id_buf:uint()] = pinfo.number
end
if id2frame.request[id] then
tree:add_le(fields.request, id2frame.request[id])
end
end
end
Pełen kod dostępny poniżej w źródłach [8].
Składanie paczek TCP
# Terminal 1
wireshark -X lua_script:excom_protocol.lua -k -i lo -f 'port 9000'
# Terminal 2
socat -x TCP-LISTEN:9000,reuseaddr,fork EXEC:./excom_server.elf
# Terminal 3
socat -x TCP:localhost:9000 EXEC:./excom_spam_client.elf
Poprawna disekcja przy wykorzystaniu TCP wymaga od nas wykonania tak zwanego “TCP Reassembly”. Szczegóły na co należy zwrócić uwagę możemy znaleźć na Wireshark wiki [9].
-- Helper function for taking message data from buffer and configuring pinfo in case we need more data
local function msg_consumer(buf, pinfo)
local obj = {
msg_offset = 0, -- offset in buf to start of the current message
msg_taken = 0, -- number of bytes consumed from current message
not_enough = false,
}
obj.next_msg = function()
obj.msg_offset = obj.msg_offset + obj.msg_taken
obj.msg_taken = 0
end
obj.take_next = function(n)
if obj.not_enough then -- subsequent calls
return
end
-- If not enough data in the buffer then wait for next packet with correct offset
if buf:len() - (obj.msg_offset + obj.msg_taken) < n then
pinfo.desegment_offset = obj.msg_offset
pinfo.desegment_len = DESEGMENT_ONE_MORE_SEGMENT
obj.not_enough = true
return
end
local data = buf:range(obj.msg_offset + obj.msg_taken, n)
obj.msg_taken = obj.msg_taken + n
return data
end
obj.current_msg_buf = function()
return buf:range(obj.msg_offset, obj.msg_taken)
end
return obj
end
Teraz możemy zmodyfikować dissector tak, żeby parsował wszystkie znalezione w buforze wiadomości, a przy braku danych przerywał i oczekiwał na więcej. Dodatkowo opóźniamy dodawanie do drzewa węzłów naszego protokołu, aby nie dodawać “częściowych” wiadomości. Zmodyfikowany kod dissectora wygląda tak:
excom_proto.dissector = function(buf, pinfo, root)
-- Construct TCP reassembly helper
local consumer = msg_consumer(buf, pinfo)
-- TCP reasasembly - loop through all messages in the packet
while true do
consumer.next_msg()
-- Deferred adding of tree fields
local tree_add = {}
-- Extract request/response ID
local id_buf = consumer.take_next(4)
if not id_buf then
return -- not enough data, take_next has configured pinfo to request more data
end
table.insert(tree_add, {fields.id, id_buf})
local id = id_buf:uint()
-- Distinguish request/response
if pinfo.dst_port == server_port then
-- request_t
local type_buf = consumer.take_next(1)
if not type_buf then
return
end
table.insert(tree_add, {fields.type, type_buf})
-- request_data_t depending on the `type` field
local type = type_buf:le_uint()
if type == request_type.REQ_DISPLAY then
-- display_request_t
local len_buf = consumer.take_next(4)
local text_buf = len_buf and consumer.take_next(len_buf:le_uint())
if not text_buf then
return
end
table.insert(tree_add, {fields.display_text_length, len_buf})
table.insert(tree_add, {fields.display_text, text_buf})
elseif type == request_type.REQ_LED then
-- led_request_t
local id_buf = consumer.take_next(2)
local state_buf = consumer.take_next(1)
if not state_buf then
return
end
table.insert(tree_add, {fields.led_id, id_buf})
table.insert(tree_add, {fields.led_state, state_buf})
end
-- On first dissection run (pinfo.visited=false) store mapping from request id to frame number
if not pinfo.visited then
id2frame.request[id_buf:uint()] = pinfo.number
end
-- If possible add information about matching response
if id2frame.response[id] then
table.insert(tree_add, {fields.response, id2frame.response[id]})
end
else
-- response_t
local status_buf = consumer.take_next(1)
table.insert(tree_add, {fields.status, status_buf})
if not pinfo.visited then
id2frame.response[id_buf:uint()] = pinfo.number
end
if id2frame.request[id] then
table.insert(tree_add, {fields.request, id2frame.request[id]})
end
end
-- Add tree node for this message only if we reached this place
local tree = root:add(excom_proto, consumer.current_msg_buf())
for _, to_add in ipairs(tree_add) do
tree:add_le(to_add[1], to_add[2])
end
end
end
Po użyciu go do podsłuchania wiadomości wysyłanych przez excom_spam_client.c
powinniśmy zobaczyć:
Podsumowanie
W tym artykule pokazaliśmy jak dodać obsługę własnego protokołu do programu Wireshark - jak widać nie jest to trudne zadanie. Choć zrobiliśmy to na przykładowym prościutkim protokole, to sama idea pozostaje taka sama przy znacznie bardziej skomplikowanych protokołach. W dalszej częsci zobaczymy jak wykorzystać nasze rozwiązanie z pominięciem TCP, jak parsować zagnieżdżone protokoły oraz jak wykorzystać wbudowany dekoder Google’s Protobuf w Wiresharku.
Źródła
[1] GoodByte Github - proto dissector vol1: https://github.com/goodbyte-software/wireshark-custom-proto-dissector
[2] WIreshark, instalacja: https://www.stationx.net/how-to-install-wireshark/
[3] Learn Lua in Y minutes: https://learnxinyminutes.com/docs/lua/
[4] Lua oficjalna strona: https://www.lua.org/pil/contents.html
[5] Wireshark's Lua API Manual: https://www.wireshark.org/docs/wsdg_html_chunked/wsluarm_modules.html
[6] GoodByte Github - proto dissector vol2: https://github.com/goodbyte-software/wireshark-custom-proto-dissector/tree/v2
[7] GoodByte Github - proto dissector vo3: https://github.com/goodbyte-software/wireshark-custom-proto-dissector/tree/v3
[8] GoodByte Github - proto dissector vo4: https://github.com/goodbyte-software/wireshark-custom-proto-dissector/tree/v4
[9] Wireshark wiki: https://wiki.wireshark.org/Lua/Dissectors
[10] GoodByte Github - proto dissector vol5: https://github.com/goodbyte-software/wireshark-custom-proto-dissector/tree/v5