Appearance
Appearance
Thrift 内容通常会由默认 Thrift 解析器自动检测并解析,从而开箱即用地进行快速分析。
不过在某些情况下,尤其是某个端点使用未缓冲实现时,启发式解析器可能会因为可供查看的数据不足而无法检测到 Thrift header。
在这种情况下,可以在 Thrift 解析器首选项中设置已知端口号(支持 TCP 和 UDP)。
无论是否使用子解析器,Thrift 解析器都允许对用户体验进行一些自定义。
将 binary 显示为 bytes 或 strings:由于原版 Wireshark 捆绑的通用 Thrift 解析器不知道 T_BINARY 字段是二进制 blob 还是字符串(如果是字符串,也不知道其编码),此设置允许用户选择 Wireshark 必须用于所有 T_BINARY 字段的编码。
UTF-8 if printable:解析器使用基本启发式方法检查每个字段的内容是否为可打印的 UTF-8 字符串。
如果认为它可打印,Wireshark 会将其显示为字符串(同时也让过滤请求更容易)。
如果并非如此,显示会回退到二进制字段的十六进制表示。
两种 Thrift 协议都规定“Strings are first encoded to UTF-8, and then send as binary”,因此此设置在大多数情况下应该有效。
Thrift Binary protocol encoding
Thrift Compact protocol encoding
Binary(hexadecimal string):将所有 T_BINARY 字段视为二进制 blob。
如果你发现启发式方法试图把某些不应显示为字符串的二进制数据显示为字符串,可以考虑使用此设置。
也请考虑提交报告,以帮助改进启发式方法。
ASCII 和各种 Unicode 编码:如果你知道你的协议对所有字符串使用(或应使用)某种特定编码,那么可以显式设置它。
Thrift TLS port:选择用于 TLS 加密通信的 TCP 端口。
在解析树中显示内部 Thrift 字段:此设置仅适用于子解析器,因为通用解析器始终显示这些内部字段。
默认情况下,子解析不会显示内部 Thrift 字段(field type 和 field id),因为子解析器显示的字段名通常更易用。
不过,在某些情况下你可能希望显示这些字段,以便更好地理解数据如何被解释,包括在开发子解析器时。
如果子解析器失败则回退到通用 Thrift 解析器:如果子解析器返回错误码,例如缺少必填字段或字段类型不是预期类型,那么如果 PDU 格式良好,通用解析器可以尝试解析该 PDU。
这有助于理解子解析器失败的原因,并且仍然能够看到 PDU 的结尾。
Thrift nested types depth:表示 Thrift 类型树的最大深度,以防止超过系统调用限制。关于用法和子解析器覆盖,请参阅“为 emitBatch 嵌套类型添加最大深度”。
Reassemble Framed Thrift messages spannig multiple TCP segments:告知辅助函数 tcp_dissect_pdus,当应用程序使用 Framed Transport 时,对拆分到多个 TCP 包中的 Thrift PDU 进行重组。
如果不存在 Framed Transport,则重组由全局 TCP 参数 Allow subdissector to reassemble TCP streams 控制。
虽然 Thrift 解析器支持 UDP 和 USB bulk,但这些传输流不支持重组。
Thrift TCP port 和 Thrift UDP port:如上一节所述,启发式 Thrift 解析器在大多数情况下会正确检测并解析 Thrift PDU,因此通常不需要选择端口。
不过,有少数情况下启发式方法无法检测到正在使用 Thrift:
在这些情况下,端口设置表示所选端口确实是 Thrift 数据。这将确保 Thrift 解析器在不依赖启发式方法的情况下被调用,并且 Thrift 解析器会正确处理有效的 Thrift 数据,确保重组或解析旧 header。
请注意,在这种情况下,Thrift 解析器会非常努力地寻找匹配格式,并可能产生更多误报,导致 Thrift 解析以 [TCP segment of a reassembled PDU] 结束,而正确的解析器没有被调用。因此,如果你在同一个端口上有多个协议,建议在不再需要时尽快将 TCP 端口设置回 0。
由于 Thrift 协议是自描述的,在一个屏幕上用 Wireshark 分析 Thrift PDU、另一个屏幕上查看你的协议文档相对容易;但当你的协议包含大量不同类型和深层子结构时,这会变得很麻烦。
编写基于 Thrift 的子解析器可以消除对你的基于 Thrift 协议文档的依赖,并让在大型抓包中查找特定 PDU 更容易。
感谢 Kalied,现在可以从你的 .thrift 文件自动生成基于 Thrift 协议的代码。
Kalied 支持生成匹配 Wireshark 3.6 到 4.4 之间任意版本的代码,以方便开发。
它已成功通过以下所有示例以及一些包含一百多个命令的内部协议测试,没有出现任何问题。
由于通常适合进一步自定义解析,我建议将生成的代码作为基础,然后在其上应用包含自定义内容的 patch。Kalied 的生成方式确保代码生成具有足够一致性,从而在添加新命令或结构时便于维护 patch(内部也是以这种方式使用)。
本节描述基于 Thrift 创建解析器的常见步骤。
Thrift 自定义子解析器的工作方式与任何解析器相同:创建 epan/dissectors/packet-tcustom.c 文件,并相应更新 epan/dissectors/CMakeLists.txt。
初始 packet-tcustom.c 文件如下:
#include<epan/packet.h>#include"packet-thrift.h"voidproto_register_tcustom(void);voidproto_reg_handoff_tcustom(void);/* Return codes or assimilated. */#define NOT_AN_EXPECTED_PDU (0)// Common helper definitions but not always needed (see containers and structures)// Warning: Remove the ", NULL" at the end if using Wireshark 4.2 or earlier!#define TMUTF8 NULL, { .encoding = ENC_UTF_8 }, NULL#define TMRAW NULL, { .encoding = ENC_NA }, NULLstaticintproto_tcustom;// Here will go all hf id declarations//static int hf_tcustom_<where>_<what>staticintett_tcustom;// Any "ett tree" addition (for containers and structures) will happen here firstvoidproto_register_tcustom(void){statichf_register_infohf[]={// This location will be referred to as the "hf_register_info section"};/* setup protocol subtree arrays */staticgint*ett[]={&ett_tcustom,// Any "ett tree" addition will happen here second};/* Register protocol name and description */proto_tcustom=proto_register_protocol("Thrift Custom Protocol","TCustom","tcustom");/* register field array */proto_register_field_array(proto_tcustom,hf,array_length(hf));/* register subtree array */proto_register_subtree_array(ett,array_length(ett));}voidproto_reg_handoff_tcustom(void){// Any supported command will be registered in this function.}作为一个“Hello World!”级别的示例,考虑以下 Thrift 定义:
serviceHelloWorld{onewayvoidinitialize(1:binaryinit_vector);onewayvoidregistration(1:boolunregister,2:stringserver_name,3:i16port);onewayvoidgreetings(1:binaryuser_name_utf32le);onewayvoidgood_bye();}要处理此协议,我们需要创建并注册 3 个函数,每个函数负责一个命令。每个函数按以下模板创建:
// Here, the <command_name> will be one of registration, initialize, and greetingsstaticintdissect_tcustom_<command_name>(tvbuff_t*tvb,packet_info*pinfo,proto_tree*tree,void*data){// We get this data from the generic dissector and need to pass it back to the helper functions.thrift_option_data_t*thrift_opt=(thrift_option_data_t*)data;// Start dissection from the beginning of the tvbuff_t.intoffset=0;// Create the tree right now, using unspecified length (-1)proto_item*tcustom_pi=proto_tree_add_item(tree,proto_tcustom,tvb,offset,-1,ENC_NA);;proto_tree*tcustom_tree=proto_item_add_subtree(tcustom_pi,ett_tcustom);/********************************//* Dissection will happen here! *//********************************/// Thrift commands /always/ ends with T_STOP, so keep it in the templateoffset=dissect_thrift_t_stop(tvb,pinfo,tcustom_tree,offset);// The current value of offset is either an error code or the end of the dissected data.// T_STOP takes 1 byte so offset cannot be 0.if(offset>0){// Set the end of the main treeproto_item_set_end(tcustom_pi,tvb,offset);}returnoffset;}注意:上面的模板可用于处理不带任何参数的函数(例如本例中的 good_bye())。与通用解析器相比,唯一的改进是将命令识别为来自我们的自定义协议,包括过滤能力,这也是实现此类命令有意义的原因。在这种情况下,移除第一行的 thrift_opt 定义,并在 data 参数后添加 U,因为它不会被使用。
注册发生在 proto_reg_handoff_tcustom() 中:
voidproto_reg_handoff_tcustom(void){dissector_add_string("thrift.method_names","initialize",create_dissector_handle(dissect_tcustom_initialize,proto_tcustom));dissector_add_string("thrift.method_names","registration",create_dissector_handle(dissect_tcustom_register,proto_tcustom));dissector_add_string("thrift.method_names","greetings",create_dissector_handle(dissect_tcustom_greetings,proto_tcustom));dissector_add_string("thrift.method_names","good_bye",create_dissector_handle(dissect_tcustom_good_bye,proto_tcustom));}对于任何需要解析的字段,第一步是在 hf_register_info section 中定义它,以便正确显示:
对于 initialize(binary init_vector),我们通过其 hf id 定义唯一参数,名称为 hf_tcustom_<command_name>_<param_name>(如果某个给定名称的参数或结构字段始终与任何其他同名参数或结构字段具有相同类型,则可以省略命令名):
// Associated with the declaration of hf_tcustom_initialize_init_vector at the beginning{&hf_tcustom_initialize_init_vector,{"Initialization Vector","tcustom.initialize.init_vector",FT_BYTES,BASE_NONE,NULL,0x0,NULL,HFILL}},之后,我们可以在 dissect_tcustom_initialize 函数中使用匹配的 dissect_thrift_t_<type> 辅助函数来使用 hf 信息:
offset=dissect_thrift_t_binary(tvb,pinfo,tcustom_tree,offset,thrift_opt,TRUE,1,hf_tcustom_initialize_init_vector);为了进一步改进解析,我们可以解析 init_vector 的内容,可以将委托的子解析器定义为标准解析函数:
staticintdissect_tcustom_init_vector(tvbuff_t*tvb_U_,packet_info*pinfo_U_,proto_tree*tree_U_,void*data){thrift_option_data_t*thrift_opt=(thrift_option_data_t*)data;// TODO: Write the dissector.if(TRUE){// If for any reason we are unable to dissect it, let’s fallback to the basic dissection.thrift_opt->use_std_dissector=TRUE;}returntvb_reported_length(tvb);// Consume the entire binary.}然后,可以使用 dissect_thrift_t_raw_data 而不是 dissect_thrift_t_binary 来设置它:
// Give the basic type to ensure consistency and keep a fallback path with use_std_dissector = TRUE,// then provide the dissection function.offset=dissect_thrift_t_raw_data(tvb,pinfo,tcustom_tree,offset,thrift_opt,TRUE,1,hf_tcustom_initialize_init_vector,DE_THRIFT_T_BINARY,dissect_tcustom_init_vector);对于 registration(bool unregister, string server_name, i16 port),我们定义 3 个参数:
{&hf_tcustom_registration_unregister,{"Unregister","tcustom.registration.unregister",FT_BOOLEAN,BASE_NONE,NULL,0x0,NULL,HFILL}},{&hf_tcustom_registration_server_name,{"Server Host Name","tcustom.registration.server_name",FT_STRING,BASE_NONE,NULL,0x0,NULL,HFILL}},// Please note that all Thrift integers are signed.// This particular application seems to only support ports up to 32767.{&hf_tcustom_registration_port,{"Port Number","tcustom.registration.port",FT_INT16,BASE_DEC,NULL,0x0,NULL,HFILL}},然后在 dissect_tcustom_registration 中放入 3 个连续调用:
offset=dissect_thrift_t_bool(tvb,pinfo,tcustom_tree,offset,thrift_opt,TRUE,1,hf_tcustom_registration_unregister);// When using string type in the .thrift definition, data is serialized as an UTF-8 string.offset=dissect_thrift_t_string(tvb,pinfo,tcustom_tree,offset,thrift_opt,TRUE,2,hf_tcustom_registration_server_name);offset=dissect_thrift_t_i16(tvb,pinfo,tcustom_tree,offset,thrift_opt,TRUE,3,hf_tcustom_registration_port);从 Wireshark 4.4 开始,我们可以澄清 unregister 布尔值以避免混淆。
首先,我们定义一个 true_false_string,用它通过自定义解析函数以更明确的方式解析布尔值,然后将新创建的函数用作 raw data dissector:
#include<epan/tfs.h>// …staticconsttrue_false_stringptfs_unregister_register={"Unregister","Register"};// TRUE = "Unregister"// …staticintdissect_tcustom_register_unregister(tvbuff_t*tvb,packet_info*pinfo,proto_tree*tree,void*data){// Read the byte and use the true_false_string to display the proper signification.guint8value=tvb_get_guint8(tvb,0);proto_tree_add_boolean(tree,hf_tcustom_registration_unregister_tfs,tvb,0,1,value);return1;}// …//offset = dissect_thrift_t_bool(tvb, pinfo, tcustom_tree, offset, thrift_opt, TRUE, 1, hf_tcustom_registration_unregister);offset=dissect_thrift_t_raw_data(tvb,pinfo,tcustom_tree,offset,thrift_opt,TRUE,1,hf_tcustom_registration_unregister,DE_THRIFT_T_BOOL,dissect_tcustom_register_unregister);// …{&hf_tcustom_registration_unregister_tfs,{"Unregister","tcustom.registration.unregister",// and change hf_tcustom_registration_unregister for "tcustom.registration.unregister.basic" to avoid conflict.FT_BOOLEAN,8,TFS(&ptfs_unregister_register),0x01,NULL,HFILL}},// …对于 greetings(binary user_name_utf32le),从 Thrift 的角度看其内容只是 binary,但我们恰好知道它实际上是一个以 little-endian 编码的 UTF-32 字符串(由于某些历史原因,这在真实项目中往往会发生),因此我们将其定义为字符串:
{&hf_tcustom_greetings_user_name,{"User Name","tcustom.greetings.user_name",FT_STRING,BASE_NONE,NULL,0x0,NULL,HFILL}},在这种情况下,我们需要使用 dissect_thrift_t_string_enc,它允许我们指定字符串编码:
offset=dissect_thrift_t_string_enc(tvb,pinfo,tcustom_tree,offset,thrift_opt,TRUE,1,hf_tcustom_greetings_user_name,ENC_UCS_4|ENC_LITTLE_ENDIAN);Thrift 枚举的处理类似于 Wireshark 中的任何枚举,唯一约束是要将它们与 i32 整数关联。
示例将使用以下定义:
enumnearly_boolean{True,False,Maybe,}serviceEnumeration{onewayvoidconfigure(1:nearly_booleanactive);}在这种情况下,我们需要像往常一样为每个 enum 值定义字符串转换:
staticconstvalue_stringtcustom_nearly_boolean_vals[]={{0,"Very True"},// Like in C, Thrift enums start at 0{1,"Absolutely False"},{2,"It’s not impossible"},{0,NULL},};然后,在 hf_field_info section 中定义参数,并关联到正确的枚举:
{&hf_tcustom_configure_active,{"Active","tcustom.configure.active",FT_INT32,BASE_DEC,VALS(tcustom_nearly_boolean_vals),0x0,NULL,HFILL}},注意:我选择保留与具体用法关联的 hf_id,而不是与类型本身关联,以便更容易搜索。这个选择带来的结果是,每次使用该类型时都需要定义新的 hf_id。你可能希望采用其他方式来限制 hf_id 的数量。
然后,像往常一样使用 dissect_thrift_t_i32 完成解析:
offset=dissect_thrift_t_i32(tvb,pinfo,tcustom_tree,offset,thrift_opt,TRUE,1,hf_tcustom_configure_active);Thrift 暴露 3 种容器:
示例将使用以下定义:
serviceContainers{onewayvoidset_keys(1:map<string,i32>registry);}为了正确解析这些容器,我们需要描述容器本身以及元素(对于 map,是 key 和 value)。
我们首先像处理任何 string 或 i32 一样描述 key 和 value:
{&hf_tcustom_set_keys_registry_key,{"Registry Key","tcustom.set_keys.registry.key",FT_STRING,BASE_NONE,NULL,0x0,NULL,HFILL}},{&hf_tcustom_set_keys_registry_value,{"Registry Value","tcustom.set_keys.registry.value",FT_INT32,BASE_DEC,NULL,0x0,NULL,HFILL}},但我们还需要将它封装到一个 thrift_member_t 结构中,容器辅助函数会使用该结构:
staticconstthrift_member_ttcustom_set_keys_registry_key={&hf_tcustom_set_keys_registry_key,0,FALSE,DE_THRIFT_T_BINARY,TMUTF8};staticconstthrift_member_ttcustom_set_keys_registry_value={&hf_tcustom_set_keys_registry_value,0,FALSE,DE_THRIFT_T_BINARY,TMFILL};该结构的字段依次为:
描述数据的 hf id。
field id。(仅用于结构,将其设置为 0)
字段是否可选?(同样用于结构,设置为 FALSE)
字段/元素/key/value 的预期类型,用于确保我们正确解码数据。请记住,Thrift 在 IDL 中暴露的类型比网络上的类型更多:
string 和 binary 以 DE_THRIFT_T_BINARY 传输(只有编码不同)
struct、union 和 exception 以 DE_THRIFT_T_STRUCT 传输。
内部元素的“ett tree”(对于结构列表,它将是该结构的 ett tree),除容器和结构外,所有类型都保持为 NULL。
如果此元素需要一些额外参数:
binary 和 string 需要编码。
list 和 set 需要元素类型信息。
map 需要 key 和 value 类型信息。
struct 等需要字段信息(我们将在下一章看到)。
由于在大多数情况下不需要 ett tree 和额外参数,packet-thrift.h header 提供了方便的 TMFILL 定义(类似于 hf id 的 HFILL)。
如果你的解析器经常使用字符串和/或二进制,可以使用本示例中给出的 TMUTF8 和 TMRAW 定义。
为内部元素或 key 与 value 类型定义 thrift_member_t 后,内容描述就完成了,接下来需要描述容器本身。
添加匹配的“ett tree”:
在 ett_custom(我们树的主干)声明旁添加 static int ett_tcustom_set_keys_registry;。
在 proto_register_tcustom 的初始化列表中添加 ett_tcustom_set_keys_registry。
添加 hf id 定义,这很直接:
{&hf_tcustom_set_keys_registry,{"Registry Configuration Keys","tcustom.set_keys.registry",FT_NONE,BASE_NONE,NULL,// We don’t want to display the data in the interface0x0,NULL,HFILL}},然后你可以使用额外参数调用 dissect_thrift_t_map(或任何其他容器辅助函数)。
offset=dissect_thrift_t_map(tvb,pinfo,tcustom_tree,offset,thrift_opt,TRUE,1,hf_tcustom_set_keys_registry,ett_tcustom_set_keys_registry,&tcustom_set_keys_registry_key,&tcustom_set_keys_registry_value);Thrift 暴露的最后几类对象是 struct 和 union 类型。
struct 包含任意数量的字段,每个字段由一个特定数值索引(在网络上可见)。此外,每个字段可以是:
required,表示当父 struct 用于通信时它必须存在;
或 optional,表示通信期间它可以存在,也可以不存在。
在 Wireshark 的语境中,缺少限定符必须视为等同于 optional。
union 类似于 struct(并在序列化数据中以 struct 发送),但有以下限制:
所有字段都是 optional。
在一次通信中,恰好一个(1)字段被填充。
为了演示这一点,我们将使用以下定义:
unionbig_integer{1:i64small;2:binaryefficient;3:list<bool>inefficient;}structplacement{0:requiredi32position;32767:optionali8occurrences;}serviceStructures{onewayvoidinsert(1:big_integerbigint,2:placementwhere);}像往常一样,我们从 leaf 的定义开始(具体定义留作练习 📝):
// hf id for all leaf elementsstaticinthf_tcustom_big_integer_small;staticinthf_tcustom_big_integer_efficient;staticinthf_tcustom_big_integer_inefficient_bit;// For the elements of the liststaticinthf_tcustom_big_integer_inefficient;// The list itselfstaticinthf_tcustom_placement_position;staticinthf_tcustom_placement_occurrences;// ett tree for the liststaticintett_tcustom_big_integer_inefficient;// description of the list for deep dissectionstaticconstthrift_member_ttcustom_big_integer_inefficient={&hf_tcustom_big_integer_inefficient_bit,0,FALSE,DE_THRIFT_T_BOOL,TMFILL};注意,在这种情况下,leaf 是结构类型的子项,因此命名方案现在遵循 hf_tcustom_<type_name>_<field_name> 模式(可以安全地假设类型名和命令名之间不会发生冲突,但你可以根据需要调整)。
现在我们需要编写必要的 ett tree(ett_tcustom_insert_bigint 和 ett_tcustom_insert_where)以及结构的 hf id:
{&hf_tcustom_insert_bigint,{"Big Integer","tcustom.insert.bigint",FT_NONE,BASE_NONE,NULL,0x0,NULL,HFILL}},{&hf_tcustom_insert_where,{"Where","tcustom.insert.where",FT_NONE,BASE_NONE,NULL,0x0,NULL,HFILL}},现在,我们需要结构解析的描述,它看起来与 list 和 set 使用的类型相同。主要区别是,容器需要 1 或 2 个指向 thrift_member_t 的指针,而结构需要一系列元素;因此在这种情况下,我们需要一个元素数组,在示例中描述如下:
staticconstthrift_member_ttcustom_big_integer[]={{&hf_tcustom_big_integer_small,1,TRUE,DE_THRIFT_T_I64,TMFILL},{&hf_tcustom_big_integer_efficient,2,TRUE,DE_THRIFT_T_BINARY,TMRAW},{&hf_tcustom_big_integer_inefficient,3,TRUE,DE_THRIFT_T_LIST,&ett_tcustom_big_integer_inefficient,{.element=&tcustom_big_integer_inefficient}},{NULL,0,FALSE,DE_THRIFT_T_STOP,TMFILL}};staticconstthrift_member_ttcustom_placement[]={{&hf_tcustom_placement_position,0,FALSE,DE_THRIFT_T_I32,TMFILL},{&hf_tcustom_placement_occurrences,32767,TRUE,DE_THRIFT_T_I8,TMFILL},{NULL,0,FALSE,DE_THRIFT_T_STOP,TMFILL}};这一次,我们看到 thrift_member_t 结构的第二和第三个字段真正被使用:
最后一个参数(在目标元素的 ett tree 之后)在这里也更明显(根据元素类型,它也可以用于容器):
大多数时候不使用,TMFILL 提供默认初始化。
结尾的 , NULL 是在 Wireshark 4.4 引入的,在 Wireshark 4.2 及以前版本中不要使用。
对于 binary 字段,我们使用 { .encoding = ENC_SOMETHING }, NULL 提供预期编码。
当 binary 只是二进制对象时,我们可以使用前面定义的 TMRAW 辅助宏。
当它是标准 UTF-8 字符串(根据 Thrift 规范)时,我们可以使用 TMUTF8 辅助宏。
对于 list 和 set,我们使用 { .element = &tcustom_<type_name>_<field_name> }, NULL 提供指向元素描述的指针。
对于 map,我们使用 { .m.key = &tcustom_<type_name><field_name>key, .m.value = &tcustom<type_name><field_name>_value }, NULL 同时提供 key 和 value 描述。
对于子结构,我们使用 { .s.members = tcustom_<subtype_name>, .s.expert_info = NULL }, NULL 提供成员列表。
对于 Wireshark 4.0 及以前版本,它只是 { .members = tcustom_<subtype_name> }
对于 Wireshark 4.2,它是 { .s.members = tcustom_<subtype_name>, .s.expert_info = NULL }
最后,所有结构和 union 在序列化时都以 T_STOP 字段结束,这在描述中通过固定内容 { NULL, 0, FALSE, DE_THRIFT_T_STOP, TMFILL } 表示,同时也标记数组结束。
到此为止,我们现在可以像往常一样调用辅助函数:
offset=dissect_thrift_t_struct(tvb,pinfo,tcustom_tree,offset,thrift_opt,TRUE,1,hf_tcustom_insert_bigint,ett_tcustom_insert_bigint,tcustom_big_integer);offset=dissect_thrift_t_struct(tvb,pinfo,tcustom_tree,offset,thrift_opt,TRUE,2,hf_tcustom_insert_where,ett_tcustom_insert_where,tcustom_placement);鉴于 union 始终只包含一个字段,你可能希望省略只包含一个元素的子树(尤其是当你需要扫描很长的这类对象列表时)。
这很容易实现:不提供匹配的 hf id 和 ett tree 元素,而是将它们替换为 -1,这是未初始化元素的默认值。
为了明确这种行为,我们可以定义常量:
staticconstintDISABLE_SUBTREE=-1;然后我们可以将第一个调用替换为:
offset=dissect_thrift_t_struct(tvb,pinfo,tcustom_tree,offset,thrift_opt,TRUE,1,DISABLE_SUBTREE,DISABLE_SUBTREE,tcustom_big_integer);类似地,当作为结构字段或容器内部元素使用时,匹配的 thrift_member_t 定义将是:
{DISABLE_SUBTREE,1,TRUE,DE_THRIFT_T_STRUCT,DISABLE_SUBTREE,{.s.members=tcustom_big_integer,.s.expert_info=NULL},NULL},直到 Wireshark 4.0,使用以下定义:
{DISABLE_SUBTREE,1,TRUE,DE_THRIFT_T_STRUCT,DISABLE_SUBTREE,{.members=tcustom_big_integer}},⚠️ 如果你选择省略树,界面中显示的标签将来自可用字段(这里将是 small、efficient 或 inefficient 字段的定义),而不是 union 本身的定义(事实上,后者可以连同匹配的 ett tree 一起移除)。
手动编写解析器时,为了在开发期间更快获得结果,让通用解析器处理部分工作可能很有价值,尤其是在我们有大量字段和/或深层子结构时。
虽然无法从子解析器直接触发通用解析器,但可以省略某些字段的定义,struct handler 会将未指定的 field id 重新路由到通用解析器并处理它们。
给定如下定义:
structresource{// Very big structure we don’t want/need to handle right now}structdata{1:requiredi64id;2:requiredstringname;3:requiredresourcecontent;}完整定义方式会使用以下结构定义:
staticconstthrift_member_ttcustom_data[]={{&hf_tcustom_data_id,1,TRUE,DE_THRIFT_T_I64,TMFILL},{&hf_tcustom_data_name,2,TRUE,DE_THRIFT_T_BINARY,TMUTF8},{&hf_tcustom_data_content,3,TRUE,DE_THRIFT_T_STRUCT,&ett_tcustom_resource,{.s.members=tcustom_resource,.s.expert_info=NULL},NULL},// Adapt depending on Wireshark version.{NULL,0,FALSE,DE_THRIFT_T_STOP,TMFILL}};为了加快开发(至少能获得一些结果,并能对 data 结构的 id 和 name 字段进行过滤),我们可以按如下方式处理:
staticconstthrift_member_ttcustom_data[]={{&hf_tcustom_data_id,1,TRUE,DE_THRIFT_T_I64,TMFILL},{&hf_tcustom_data_name,2,TRUE,DE_THRIFT_T_BINARY,TMUTF8},{NULL,3,TRUE,DE_THRIFT_T_GENERIC,TMFILL},{NULL,0,FALSE,DE_THRIFT_T_STOP,TMFILL}};这样我们无需在可以使用 data 结构之前定义整个 resource 结构。
此功能可用于任意数量的字段,无论其类型或在父结构中的位置如何。
在这种情况下,我们也可以为类似结果定义与以下定义关联的整个 data 结构:
staticconstthrift_member_ttcustom_resource[]={{NULL,1,TRUE,DE_THRIFT_T_GENERIC,TMFILL},{NULL,2,FALSE,DE_THRIFT_T_GENERIC,TMFILL},{NULL,3,TRUE,DE_THRIFT_T_GENERIC,TMFILL},// ... Specify all fields and only indicate whether they are optional or not...// ... or just always put TRUE (optional) as long as all possible field ids are covered.{NULL,0,FALSE,DE_THRIFT_T_STOP,TMFILL}};在这种情况下,content 字段会被正确显示(避免数据展示树中的不规则性),但这是唯一差异,其子树内容仍然由通用解析器解析。
到目前为止,我们只解析了 oneway 命令,它们不期望任何响应,因此当我们在网络上看到它们时,只需解析参数。
不过,Thrift IDL 允许定义返回值或可能返回异常的命令:
exceptionout_of_memory_exception{1:interror_code;2:stringmessage;};serviceEchoChamber{binaryping(1:binarypayload)throws(1:out_of_memory_exceptionoom_exc/* 2: some_other_exception so_exc, … */);}使用带结果的命令时,我们需要回答 2 个问题:
对于第一个问题,我们需要查看 Thrift 通用解析器提供的数据,我们已经将其转换为 thrift_option_data_t 指针:
switch(thrift_opt->mtype){caseME_THRIFT_T_CALL:// Dissect the parameters as we do for oneway commands.offset=dissect_thrift_t_binary(tvb,pinfo,tcustom_tree,offset,thrift_opt,TRUE,1,hf_tcustom_ping_payload);break;caseME_THRIFT_T_REPLY:/* TODO: Dissect the answer. */break;default:// ME_THRIFT_T_ONEWAY or ME_THRIFT_T_EXCEPTION// Something is wrong, let the generic dissector handle that.returnNOT_AN_EXPECTED_PDU;}// We still need to dissect the ending `T_STOP` in all cases.offset=dissect_thrift_t_stop(tvb,pinfo,tcustom_tree,offset);注意:你也可以对 oneway 命令做检查,在这种情况下只有 ME_THRIFT_T_ONEWAY 是有效的。
关于应答解析,我们需要理解可能有 3 种应答:
最后一种情况很简单:由于这些异常在 Thrift 本身中描述,T_EXCEPTION 消息由通用解析器处理。
另一方面,T_REPLY 稍微复杂一些。如 RPC 规范所述,应答要么包含 field 0 作为返回类型,要么恰好包含一个在 IDL 中定义了 field id 的异常。
为了能够解析正确元素,我们需要知道 T_REPLY 中包含的 field id。这个信息同样由 thrift_option_data_t 结构提供:
caseME_THRIFT_T_REPLY:// Dissect the answer.switch(thrift_opt->reply_field_id){case0:// If the return type is void, this `case 0:` only contains the `break;`.offset=dissect_thrift_t_binary(tvb,pinfo,tcustom_tree,offset,thrift_opt,TRUE,0,hf_tcustom_ping_return);break;case1:// Exception are just structures with a specific use.offset=dissect_thrift_t_struct(tvb,pinfo,tcustom_tree,offset,thrift_opt,TRUE,1,hf_tcustom_out_of_memory_exception,ett_tcustom_out_of_memory_exception,tcustom_out_of_memory_exception);break;/*case 2: offset = dissect_thrift_t_struct(tvb, pinfo, tcustom_tree, offset, thrift_opt, TRUE, 2, hf_tcustom_some_other_exception, ett_tcustom_some_other_exception, tcustom_some_other_exception); break; // et caetera. */default:// Unsupported exception, let the generic dissector handle that.returnNOT_AN_EXPECTED_PDU;}break;我们不需要按异常在该特定命令中获得的名称区分异常(使用 hf_tcustom_ping_oom_exc 或 hf_tcustom_ping_so_exc),因为对于给定命令,同一异常类型不会出现多次(而我们可以有多个相同类型的参数或字段);这样,异常的 hf id 和 ett tree 只需定义一次。
注意:无论某个特定命令是否定义了 application exception,返回类型始终是 T_REPLY 中的字段编号 0。
所有这些示例的完整解析器(已编译但未测试)附在 packet-tcustom.c 中供参考。
观察参数序列化,它看起来确实像一个结构。事实上,即使定义也像一个带有一些限制的结构:
唯一差异是这个 struct 的开头不存在 field header(field type = struct 和 field id)。
既然这类缺失的 field header 也存在于 3 种容器类型中,那么一定应该有解析 struct(或任何其他类型)的能力,对吧?
这正是 dissect_thrift_t_<type> 函数的 is_field 参数的用途。将其设置为 FALSE,函数就不会从 header 解析开始。
回到 registration(bool unregister, string server_name, i16 port) 的定义,另一种解析方式是用一个结构定义和一次 dissect_thrift_t_struct 调用替换 4 次 dissect_thrift_t_<type> 调用(⚠️T_STOP 始终是 struct 的一部分):
staticconstthrift_member_ttcustom_registration_params[]={{&hf_tcustom_registration_unregister,1,FALSE,DE_THRIFT_T_BOOL,TMFILL},{&hf_tcustom_registration_server_name,2,FALSE,DE_THRIFT_T_BINARY,TMUTF8},{&hf_tcustom_registration_port,3,FALSE,DE_THRIFT_T_I8,TMFILL},{NULL,0,FALSE,DE_THRIFT_T_STOP,TMFILL}};// In this case, the TCustom tree holds the structure fields so we disable the creation of an additional sub-tree.offset=dissect_thrift_t_struct(tvb,pinfo,tcustom_tree,offset,thrift_opt,FALSE,0,DISABLE_SUBTREE,DISABLE_SUBTREE,tcustom_registration_params);// No call to dissect_thrift_t_stop()if(offset>0)proto_item_set_end(tcustom_pi,tvb,offset);returnoffset;如果协议包含大量具有相同参数列表的命令,这可能会很有趣。
同样的原则也可以应用于返回值和异常,不同之处在于,在这种情况下所有内容都是 optional,并且 nominal return type 的 field id 是 0(这是 IDL 中唯一不可见的值)。
对于 binary ping(1: binary payload),结构定义如下:
staticconstthrift_member_ttcustom_ping_result[]={{&hf_tcustom_ping_return,0,TRUE,DE_THRIFT_T_BINARY,TMRAW},// Omitted if return type is void{&hf_tcustom_out_of_memory_exception,1,TRUE,DE_THRIFT_T_STRUCT,&ett_tcustom_out_of_memory_exception,{.members=tcustom_out_of_memory_exception,.s.expert_info=&ei_tcustom_out_of_memory_exception},NULL},//{ &hf_tcustom_some_other_exception, 2, TRUE, DE_THRIFT_T_STRUCT, &ett_tcustom_some_other_exception, { .members = tcustom_some_other_exception, .s.expert_info = &ei_tcustom_some_other_exception }, NULL },{NULL,0,FALSE,DE_THRIFT_T_STOP,TMFILL}};// …caseME_THRIFT_T_REPLY:offset=dissect_thrift_t_struct(tvb,pinfo,tcustom_tree,offset,thrift_opt,FALSE,0,DISABLE_SUBTREE,DISABLE_SUBTREE,tcustom_ping_result);// WARNING: Make sure that dissect_thrift_t_stop is not called after that.前提是 ei_tcustom_out_of_memory_exception 和 ei_tcustom_some_other_exception 已定义为:
staticexpert_fieldei_tcustom_out_of_memory_exception;staticexpert_fieldei_tcustom_some_other_exception;并在 proto_register_tcustom() 中使用 expert_register_field_array() 注册,以便轻松告知用户抛出的异常。
否则,只需使用 .s.expert_info = NULL。
第一个真实示例中,我们将解析 agent.thrift 中描述的 emitBatch 命令,它依赖 jaeger.thrift 中定义的结构。
完整解析器在 EnigmaTriton/wireshark 的 jaeger 分支上共享在 GitLab 中,提交历史遵循下面描述的流程。
⚠️ 该分支可能仍然是针对 Wireshark 3.6 编写的,请牢记较新版本所需的变更。
SampleCaptures 上提供了可用于测试的抓包文件。
对于第一个提交,我们创建一个简单解析器,只在 Wireshark 中注册该解析器。
为了在每一步都有可编译的解析器(即使在最后几个提交之前我们还没有解析任何命令),我们基本上会遵循文件中定义各种类型的顺序,并按相同顺序定义匹配的解析器“对象”。
这种方法的思路大致类似于自动解析器生成器读取 .thrift IDL 定义时会遵循的流程。
由于枚举非常容易定义供 Wireshark 使用,并且只能是 leaf 类型(不像结构或容器那样可以包含其他必须先定义的结构),无论它们在 .thrift 文件中的定义顺序如何,我们都从枚举开始。
由于 thrift 将文件视为子命名空间,不同文件中可能有重复名称,因此我们需要使用几个基本组成部分确保若干元素的唯一性:
value_string 数组的名称将采用 <protoabbrev><filename><enum_name>_vals,以遵循 Wireshark 解析器中使用的约定;在第一个例子中,它会转换为 jaeger_jaeger_TagType_vals。
覆盖枚举后,我们需要定义结构,从不依赖其他结构的结构开始。
最简单的方法是从没有任何 include 的 .thrift 文件开始,并从上到下解析它们。在这种情况下,我们只针对 agent.thrift 中的 emitBatch 命令,它只依赖 jaeger.thrift 类型,因此我们会完全忽略 zipkincore.thrift 文件。
对于结构,我们需要为每个字段定义一个 hf id,一个用于定义结构内容的 member_thrift_tarray,以及结构本身的 ett tree。
⚠️ 前 2 个字段是 mandatory(因此第三个位置的 "optional" member 为 FALSE),其余字段是 optional,确保为每个字段使用正确的值。到处使用 TRUE(全部 optional)可以避免逐个检查每个字段是否 mandatory,但 Wireshark 将无法检测与缺少 mandatory 字段相关的 malformed PDU。
由于这是第一个结构(且没有任何容器字段),所有字段都使用简单类型,不需要额外变量定义即可在数组中使用。
我们只是准备第一章中描述的 TMUTF8 和 TMRAW 常量,因为我们已经需要它们(目前只需要一两次,但之后可能有帮助)。
这时出现第一个问题:我的仓库启用了 Wireshark 推荐的 pre-commit hook,它拒绝带有重复 protoabbrev 的字段 key,因此 "jaeger.jaeger.something" 不被接受。
我选择第二种方案,并且由于 "jaeger.jaegertracing.something"(来自文件中定义的 namespaces)也会被拒绝,我们将使用 "jaeger.tracing.something",并且为了保持一致,在变量名中使用 jaeger_tracing_,就好像文件名是 tracing.thrift。
创建结构后,我们还会添加一个简单的 thrift_member_t 来定义该结构本身,因为如果该结构用于容器中,可能会需要它。为此,我们需要为结构本身创建额外的 hf id。
对于第二个结构,我们以与第一个结构相同的方式处理:
这里的主要点是其中一个字段是 list,因此我们需要更多内容才能让它工作:
ett tree 很容易定义和初始化,并且 list 中元素的类型是 Tag 类型,而我们恰好已经为这个确切用途定义了一个 thrift_member_t。
SpanRef 是简单结构之一,只包含少量整数和一个 enum(除了使用 VALS(value_string[]) 外,它类似于整数)。
我们再次创建 hf id 列表、ett tree、结构内容以及结构 element(以防需要)。
在我们已经完成的工作之后,尤其是在 Log 之后,Span 结构相当直接:
不过,flags 字段的文档表明该整数实际上是 flag-typed enum(因此有这个名称),这种格式既不受 Thrift 支持,也不受 Thrift 解析器支持;但在这种情况下,它足够小,可以进一步帮助用户立即看到该值的含义。
自动解析器生成器无法做到这一点,但这种小的手动改进可以极大帮助简化 trace 分析。
此结构没有特别之处,一个 mandatory 字段是 UTF-8 字符串,一个 optional 字段是包含先前定义结构之一的 list。
非常基础的结构,只包含 3 个 mandatory 64-bit 整数。
此结构包含一些我们现在已经习惯的元素(结构列表和整数),但也包含作为我们当前准备的结构的直接成员的结构。在这种情况下,设置甚至比 list 更简单,因为我们不需要为它定义 ett tree。
在 Batch 结构的内容定义中,我们直接使用为结构本身定义的 ett tree,并且 .s.members 值是描述内部结构内容的 thrift_member_t 数组。
最后这个结构非常基础(单个 mandatory boolean),以至于它之所以是结构,可能只是为了在 Jaeger 协议需要在此位置提供更多信息时便于未来扩展。
顺带一提,当你的协议可能随时间演进时,这是一个好的设计决策。
jaeger.thrift 文件以一个期望应答的简单命令定义结束。没有定义异常,因此我们不必处理该情况。
第一步是创建负责解析的函数,并在 proto_reg_handoff_jaeger 中注册它。
我们根据 Generic usage 章节 Basic type 部分提供的骨架定义它,并添加对 thrift_opt->mtype 值的检查。
唯一参数(ME_THRIFT_T_CALL 情况)是 Batch 结构的 list,因此我们只需要为该 list 定义 ett tree,并使用我们已经为 Batch 准备的内容配合 dissect_thrift_t_list 函数。
返回类型(ME_THRIFT_T_REPLY 情况,或像这里一样的 else)也是结构 list,因此我们需要为返回类型定义另一个 ett tree。由于没有异常,我们可以直接调用 dissect_thrift_t_list 并完成。
遗憾的是,提供的抓包不包含任何 submitBatches 调用,因此我们还不能检查我们的解析器。
由于 jaeger.thrift 文件现在已经完全覆盖,我们可以切换到 agent.thrift 以及我们感兴趣的 emitBatch 命令。
我们再次创建骨架函数,并在 proto_reg_handoff_jaeger 中注册它。
在这种情况下,这是 oneway 命令,因此我们不必关心 thrift_opt->mtype,因为只有一种可能性。
由于我们只有一个参数且它是结构,因此不需要定义 hf id,可以直接使用我们为该结构定义的通用 hf id。
我们添加一次带有正确参数的 dissect_thrift_t_struct 调用即可完成;示例抓包现在可以按照我们想要的所有细节解析。
此时,我们认为解析器已经完成,因此清理通过系统化方法创建但未使用的元素,以避免任何编译警告,并获得通过所有 merge checks 的解析器。🏆
如果包匹配不受支持版本的基于 Thrift 的协议(缺失 struct member、类型错误……),Thrift 通用解析器可以接管解析,并使用二进制流将 PDU 显示为通用 Thrift payload 作为回退。
不过,如 #17694(已关闭)中检测到的,malformed packet 可能触发过多嵌套调用,导致超过系统限制并崩溃。!4954(已合并)中的变更引入了最大嵌套深度限制。
如果默认值(25 层嵌套)不适合你的协议,最快的修改方式是通过 "Thrift nested types depth" 首选项;但你可能需要为每个命令使用自定义值,这有助于即使在子解析器不完整时也检测不正确的 PDU。
在将解析交回通用解析器时,可以通过将 "Thrift nested types depth" 首选项设置为更合适的值来实现;但你可能希望根据每个参数的预期深度为每个命令使用不同值,即使子解析器不完整。在这种情况下,可以在通过错误返回值将控制权交回通用解析器之前,将 thrift_option_data_t.nested_type_depth member 设置为期望值。
对于 emitBatch,最大深度可通过以下嵌套类型找到:Batch/list<Span>/list<Log>/list<Tag>/basic_types。鉴于每个类型和每个容器都会引入 1 层嵌套,预期最大深度为 8:
这在子解析器代码中转换为以下代码:
if(offset>0){proto_item_set_end(jaeger_pi,tvb,offset);}else{// In case of failure and fallback_on_generic is activated in Thrift generic dissector.thrift_opt->nested_type_depth=8;}returnoffset;在 Wireshark 3.6 到 4.4 之间,若干要求发生了变化,如 Sub-dissector fast upgrade 中所述,我们需要先应用这些变更,然后才能使用新功能:
不过,Kalied 的引入可以通过生成大部分解析器来减轻维护负担,因此我们使用它生成新的 packet-jaeger.c:
./kalied -b--prefix-method--namespace--filterreturn--name Jaeger '-aTriton Circonflexe'--email=triton[at]kumal.info -o ../packet-jaeger.c ../captures/idl/jaeger/agent.thrift与上一版本比较后,我们回迁了一些有用元素:
虽然 Thrift 协议不支持 enum flags,Wireshark 完全能够处理这类 bitset。
为此,我们创建一个 dissect_jaeger_tracing_Span_flags 标准解析函数来处理该值。
尽管 Jaeger 使用 Thrift Compact Protocol,但收到的 tvbuff_t 是完全展开的 4 字节 big-endian 值,以简化函数编写,并能够编写一个同时适用于 Thrift Compact Protocol 和 Thrift Binary Protocol 的解析器。
在添加新 flags 之前,enum 转换会保留为一种快速查看结果的方式,同时仍可轻松按特定 flag 的值进行过滤。
第二个示例是对匿名抓包进行逆向得到的协议,它允许我们覆盖所有数据类型以及上面 Jaeger 解析器未覆盖的一些元素。
特别是,Jaeger 使用 ONEWAY 命令,允许解析器在没有任何预先检查的情况下开始解析数据;而此协议使用与 REPLY 关联的 CALL 命令,这要求子解析器在分析内容之前检查方向(并且还可以返回异常,尽管样例抓包中没有)。
此协议也作为插件开发,而不是集成解析器。
完整解析器在 EnigmaTriton/wireshark 的 armeria 分支上共享在 GitLab 中,提交历史遵循下面描述的流程。
⚠️ 该分支可能仍然是针对 Wireshark 3.6 编写的,请牢记较新版本所需的变更。
SampleCaptures 上提供了抓包文件。
与 Jaeger 解析器不同,armeria 解析器不是集成式,而是作为插件创建。
如果你正在为不打算共享的内部协议开发解析器,这很可能是你会选择的方案,以避免每次新版本都必须重新构建 Wireshark。
第一个提交创建了一个最小化的 Thrift 子解析器插件,并包含 .thrift IDL 文件(构建插件并不需要,只是作为参考添加)。
正如我们为 Jaeger 子解析器所做的那样,我们从枚举开始;枚举非常容易定义供 Wireshark 使用,并且只能是 leaf 类型。
为了便于维护(我们知道要搜索什么)并确保名称唯一,我们将使用与 Jaeger 相同的命名规则:
value_string 数组的名称将采用 <protoabbrev><filename><enum_name>_vals,以遵循 Wireshark 解析器中使用的约定;在第一个例子中,它会转换为 armeria_common_roman_numerals_vals。
除了定义 value_string 数组之外,我们还为每个枚举添加 thrift_member_t 结构。每个结构的定义取决于一个 hf id 的定义,因此我们声明并注册所有这些 hf id。
这样,我们可以快速定义以这些枚举作为元素的容器,而无需回头检查它是否已经定义(更糟的是,为它出现的每个容器定义一个不同的版本)。
定义枚举后,我们按 .thrift 文件中定义的相同顺序继续处理结构,以确保在处理某个定义时所有依赖项都已存在。
我们定义的第一个结构(即网络上的 DE_THRIFT_T_STRUCT)是经典的“variant”类型,这里名为 value_content,它是一个 union:所有字段都是 optional,并且对于任何给定实例,恰好定义其中一个字段。
流程如第一段理论说明和 Jaeger 结构中所述,但有一个小差异:
由于这是 union,我们没有为 union 创建 hf id 和 ett tree,而是在 thrift_member_t element 中使用 DISABLE_SUBTREE。这样 union 在解析树中会显示为单个元素,而不是一个始终只包含 1 个元素的树。
为了处理 ASCII string 和 binary buffer,我们还定义 TMASCII 和 TMRAW fillers(使用 ASCII 而不是 UTF-8 会让 Wireshark 高亮使用非 ASCII 字符的 payload,即使它们在 UTF-8 中有效;这有助于在出现问题时进行分析)。
第二个结构名为 date_time,看起来非常相似,因为所有字段同样是 optional,但它不是 union,因为在给定对象实例中可以(而且确实会)定义多个字段,因此我们像处理任何其他结构一样处理:
并且因为子解析器的一个目的就是提高抓包可读性,我们还为月份定义了 value_string 数组,就像它们在 enum 中定义一样。
不过,我们没有像其他枚举那样定义 hf id 和 thrift_member_t,因为这不是该结构之外使用的类型。
接下来,我们处理 cardinal_data 和 range 结构。
这两个结构都相当直接,所有字段都是 optional,并且在给定结构中都属于同一类型:
下一个结构是 db_range,只包含一个 optional 字段(可能是为了未来扩展做准备),它本身是 range 类型结构,因此我们使用上一提交中定义的 armeria_common_range 数组来指定 .s.members 定义。
接下来是 element 和 acceptable 结构,它们都相对容易指定,所有字段都是 booleans。
此处主要注意点是每个字段的 requiredness,optional 会导致第三个 member 为 TRUE,required 字段则显示 FALSE 值。
如前所述,我们可以通过将所有字段都视为 optional 来让自己更轻松,但 Wireshark 将无法检测缺失 mandatory 字段。
line 结构稍微不那么单调。所有字段都是 optional,但我们几乎看到了所有简单类型,也有几个结构。
对结构系统性定义 ett tree 允许我们在编写 line 结构的 thrift_member_t 数组时不需要任何额外定义。
接下来的 3 个结构没有展示我们在之前结构中未遇到过的内容(只有基本类型,除了 restriction.minimum_quantity 之外全部为 optional)。
另一方面,它展示了我们以前并非总是看到的东西:某些字段没有指定 requiredness。
在这种情况下,Thrift 建议从 sender 的角度应将其视为 required,从 receiver 的角度应将其视为 optional,以兼容可能遵循也可能不遵循这些建议的各种实现。
Wireshark 在所有情况下都是“receiver”,因此我们采用“optional”路线。
Armeria 协议的最终 struct 定义涉及 grimm_data,它比其他结构稍微繁琐,因为该结构包含多个 list。
这是我们第一次使用为每个结构系统性定义的 armeria_common_<structure_name>_element;但根据你的协议(以及使用的容器数量),这种情况可能发生得更频繁。
我们需要在此子解析器中定义的最后几类类型是异常。
如通用部分所述,从序列化角度看,异常与结构完全一样,唯一区别是它们在使用 Thrift RPC 的应用程序中的授权用途。
考虑到这一点,Armeria 协议异常的解析很容易,因为这些结构只包含基本类型。
在这种情况下,由于 error_code 参数始终存在(并且我们恰好知道它对协议中的每个新异常都是 mandatory),我们决定稍微变通规则,对每次使用该枚举都使用已定义的 hf_armeria_common_error_code。
这将允许我们在整个抓包中全局过滤 armeria.common.error_code,这在分析期间可能很有用。
事实上,我们也可以为 message 定义单个 hf id,用于过滤并使用更明显的过滤器 armeria.exception.error_code 和 armeria.exception.message,但这开始有点偏离自动化路径太多(如果你完全手动编写子解析器,这没问题,是一种很好的捷径)。
对于 Armeria 协议,我们可以很容易地从 IDL 定义中看到(事实上这是 Armeria 协议中任意命令的一条设计规则),可被抛出的异常遵循 2 条规则:
由于成功情况下的返回值始终是 REPLY 的字段编号 0,这些规则允许我们定义一个通用函数,无论状态如何,它都会为我们处理 reply 的解析。
这与 Hijacking structure dissection feature 中的思路非常相似,即将 reply 定义为 union,但有一些额外收益:
它接收定义“返回值 + 异常”这个 union 的 thrift_member_t 数组作为参数。
虽然此函数专用于上述规则,但你自己的协议可能也有一些规则,允许创建类似但略有不同的函数。例如,如果任何异常始终关联到相同的 field id(basic_exception 始终是编号 1,invalid_parameter_exception 始终是编号 2,……)。
为了让事情更循序渐进,我们不会从 armeria.thrift 文件中描述的第一个命令开始,而是从接下来的命令开始。
我们从通用说明中给出的骨架开始,然后添加参数和 reply 处理。
需要添加到基本骨架函数中的第一件事是处理 CALL vs. REPLY。通用说明建议使用 switch 以确保正确处理所有情况,因此我们按建议操作。
NOT_AN_EXPECTED_PDU 的值 0 将向通用解析器表示我们没有解析任何内容。
对于 CALL,我们需要处理参数,这些参数全部 mandatory,并且必须按顺序排列,从 field id 编号 1 开始。
最简单的实现方式,尤其是当字段很少时,就是有多少参数就添加多少个 offset = dissect_thrift_t_<type>(…);。
在这两个情况下,我们都只有一个参数:
为了覆盖这些简单参数,我们只需要定义描述参数名称和关联过滤器的 hf id。
注意,与 Jaeger 一样,如果我们尝试使用 <protoabbrev>.<namespace>.<command_name>.* 过滤器,pre-commit hook 会再次抱怨“duplicated protobbrev”,因为文件是 armeria.thrift。在这种情况下,问题较小,因为命令名在网络上必须唯一,因为 namespace 不是内容的一部分,所以我们直接丢弃 namespace(变量名中也同样丢弃以保持一致)。
返回值也相当简单,但我们也希望处理可能的异常。为此,我们创建匹配 union 的 thrift_member_t 数组,其中结果作为 field 0,异常按 .thrift 文件中定义的各自 field id 放置。
名称并不重要,因为我们位于函数内部,所以使用通用名称以便更快复制粘贴。😼
对于异常,可以使用为 thrift_member_t element 定义的 hf id,因为我们并不真正关心异常发生在哪里:
对于成功结果本身,我们需要定义一个不与参数冲突的新名称,因此有 2 种方案:
定义 pseudo-union 后,我们只需调用辅助函数进行解析。
IDL 中定义的第一个命令在处理参数方面需要几个额外步骤,因为它不是直接值,而是一组值(来自枚举)。
我们不需要更多,因为已经为枚举定义了 thrift_member_t(以及 thrift_member_t 中引用的 hf id),因此可以直接将其用作 element 参数(记住我们需要提供对结构的引用)。
结果看起来也可能稍微更复杂,因为它是结构,但大多数需求已经满足。
我们要解析的下一个命令在参数侧简单得多:没有任何参数,ME_THRIFT_T_CALL 情况只是空的。
另一方面,reply 增加了一种新类型的结果:枚举值 list:
接下来的 2 个命令具有完全相同的第一个参数(相同名称、相同类型),但由于过滤值包含命令名,我们无法复用 hf id(仍然可以通过复制粘贴并只修改 hf 变量名和过滤字符串来更快完成)。
由于这个共同的第一个参数是结构,因此除了 hf id 之外不需要其他内容。
对于 someone_tries_to_analyze 的第二个参数,我们有一组枚举值,因此按 anonymous_things 相同方式处理:
关于结果,第一个命令返回单个结构,因此只需要定义 hf id,并在 thrift_member_t 数组的第一个元素中添加正确值。
第二个命令返回结构 set,这与枚举值 set 没有太大不同:
下面是没有特定解析时的样子:
到这一步,我们暂停一下进行编译,并用示例抓包检查已实现的内容:
重组后,此结果超过 50 kB。
该结构本身包含更多子结构:
date_time 及其中的值表明它可能是某种日志。
其中一些结构属于没有子解析器时最难分析的结构:
element 和 acceptable 结构只包含 boolean 字段。
acceptable 中并非所有字段都是 mandatory,这让分析更糟。
下面是使用正确特定解析后的样子:
除了字段清晰得多之外,我们还可以在同一屏幕上看到更多信息。
然后,我们把插件当前状态交给同事,他们看到成果后立即请我们喝了一杯当之无愧的咖啡。☕
接下来,我们处理 there_is_no_spoon_trust_me,它没有呈现任何未见过的特征,因为它没有任何参数,并返回一组枚举值,本质上与 anonymous_command_differently 中的 list 相同。
命令 yet_another_command_passed 提出了一个新挑战,显然不是因为没有参数,而是结果是字符串 list,因此我们需要多准备几项:
在这种情况下,我们选择将元素命名为 "Key Name",因为它看起来与我们放入下一个命令 dict_keys 参数中的值相同。如果没有这个提示(或在自动代码生成中),名称会类似 "Result Element" 或简单的 "Element"。
接下来的命令使用与前面命令相同的工具:
为 list 参数定义 hf id 和 ett tree。
为 list 元素定义 hf id,因为我们还没有:
如果参数本身有复数名称,你可能可以使用单数;
使用 "Object List" 时,"Object" 会是安全选择;
在没有任何洞见时(考虑自动化),可以使用名称 "Element";
在这种情况下是手动编写,并且 dict_keys 很可能表示字典 key。
为 list 结果定义 hf id 和 ett tree。
使用 union 的 thrift_member_t 完成 This_command_runs 的处理。
对于 unknown_command_in,它甚至更简单,只有一个枚举值参数和一个结构结果:
现在,我们来到令人头疼的 another_anonymous_command,其中参数足够简单(一组整数,可能在匿名化之前是枚举,但看到值的数量后我偷懒了 😴),但返回值是一个 map,其 value 是 union list。
我们从参数开始:
对于结果,需要更多步骤,并深入计算机科学中最困难的问题之一:命名事物(或者也许我们会保持通用且不具体)。
为了处理依赖项,我们需要从对象 leaf 开始,一路向下处理到作为结果的 map root:
考虑 list 的元素:幸运的是,它是一个带有自身通用 hf id 和 thrift_member_t element 描述的 union。
进入下一层,考虑 map 的 key 和 value:
为 key 定义 hf id(并坚持使用 "Key" 名称)和 thrift_member_t(再次强调,定义 enum 本可以免除这项工作 😞)
使用先前定义的 thrift_member_t,为 value 定义 hf id(并坚持使用 "Value" 名称)、ett tree(它是 list)和 thrift_member_t。
最后,处理结果本身:
为结果定义 hf id。
定义 ett tree,因为它是 map。
使用 key 和 value thrift_member_t 作为 members .m.key 和 .m.value,将 map 添加到 union 定义中。
再次强调,命令的子解析使分析比通用解析容易得多,在这种情况下有 2 个原因:
在这种情况下,样例抓包相当虎头蛇尾,因为 map 只包含 1 个 key-value 对,并且其中的 list 只包含 1 个元素。
我们解析的最后一个命令使用与之前相同的工具:
此提交完成了我们的子解析器实现,所有内容都在 Wireshark 中漂亮地显示出来。
现在,是时候清理代码中那些被系统性定义为“以防万一”但最终未使用的元素了。
第一步处理编译器关于未使用变量的明显警告,因此我们清理它们。
现在,编译干净整洁,因此我们运行可用于 pre-commit hook 或仅用于验证代码遵循建议的各种验证脚本。
在这种情况下,只有 tools/checkhf.pl 抱怨 “unused href entr[ies]”,因此我们移除所有被引用的 hf id 变量和注册项。
请记住,你自己的子解析器可能需要在编译警告和各种验证脚本之间来回处理,即使这里一次就足够了(还要注意,在清理编译警告之前,tools/checkhf.pl 并没有抱怨)。
对于小型接口,你可能可以轻松手动开发子解析器;但当接口增长时,它很快就会变得繁琐且容易出错。
Jaeger 示例实际上只是整个 Jaeger 接口的一个非常小的子集,而 Armeria 示例只覆盖了匿名抓包(一些未出现在抓包中的 optional 子结构为了简化变成了 integer 或 boolean,而不可见的 90% 或 95% 为了演示目的被完全丢弃)。
即使对于这些简化示例,也使用了一定程度的自动化(sed+regex,或 vim + :%s/…/…/g,手动编辑,再循环回第一步),编写起来仍然相当漫长,而且完全不可维护,因为每次协议演进时,所有半自动步骤都必须按顺序重新运行。
考虑到这一点,很明显我们需要能够把 *.thrift 文件输入到某个地方,并通过一个动作(或足够接近一个动作)获得对我们基于 Thrift 协议的完整解析。
一种诱人的方法可能是像 Protobuf 侧那样:更新 Wireshark,使其自动读取并解析 *.thrift 文件,以显示正确解析。
虽然这非常有趣,并且仍然是最终目标,但按照下面的建议从 IDL 文件生成代码可能更容易(并且当它完成后,它可以成为内部 parser 的基础,因为逻辑会随时可用:带正确参数单次调用 dissect_thrift_t_struct() 始终足以覆盖完整 PDU)。
第一个注意点应是解析器中使用的各种变量(主要是 hf id 和 ett tree)的命名:
虽然第一个选项对代码生成器来说似乎更容易,但用户会失去轻松自定义(hf id)的可能性。如果做得好,它可以在保持低维护成本的同时极大帮助分析(只需在新代码生成后应用 patch)。想到的例子包括 Jaeger 解析器中 Span 结构的 flags 字段,或 date_time 结构中的 month 字段。
自动化的另一个有力理由是,我们需要为每个结构字段、参数和返回类型创建大量 href;但即便如此,仍然存在一个问题:hf id 应该链接到内容类型,还是链接到结构中的位置(或命令参数名,类似)。
当类型在 .thrift 文件中定义时,例如结构和枚举,以类型为基础相当直接,因为我们已经考虑为 thrift_member_t “element” 系统性创建 hf id。
另一方面,如果只以类型作为唯一元素,就意味着无法区分同一结构中类型相同但用途不同的 2 个字段。
不过,当它是容器或基本类型(integer、string 等)时,我们无论如何都需要考虑名称,并伴随额外问题:
生成子解析器时应考虑的另一个元素是过滤字符串的选择,该字符串以后可用于 Wireshark 的 display filter。
是否希望将 namespace 集成到路径中?
如果这样做,就有 “protoabbrev” 重复的风险;这是否会成为阻塞问题取决于你的策略。
集成 namespace 还可能导致路径非常长,从而不实用。
另一方面,省略 namespace 可能导致类型名冲突(一个定义良好的协议不应有这种情况,但我们需要考虑没有额外约束的每个 well-formed IDL)。
如果 dissector generator 的最终用户负责保证唯一性,这个选择可能很容易交给他们。
我们想按字段或参数的类型搜索,还是按名称搜索?
按名称搜索意味着每个结构的每个字段都有不同的 hf id(见上一节)。
按类型搜索不会增加这个约束,并允许通过单次搜索找到给定类型的每次出现。
如何过滤函数结果?
使用函数名而不添加子元素?
使用通用名称如 “result” 或 “return”?(前者看起来更自然,但后者保证避免冲突,因为它在大多数语言中都是保留字,包括 Thrift 本身。)
最后一点涉及两个示例末尾的清理提交,代码生成器应跟踪哪些内容被使用,哪些未被使用,以避免生成后必须清理未使用变量。
如果做得好,如果协议恰好仍包含不再使用的结构定义,它可以避免生成整个 thrift_member_t 数组,从而限制内存使用。
如果有一天列表包含超过 1 个元素,创建一个表格比较可用功能会很有趣。
为了便于升级基于 Thrift 的子解析器,本节描述子解析器中为针对较新 Wireshark 分支编译所需的代码变更。
本页最初为 Wireshark 3.6 编写,并且 Thrift 解析器进行了一次重要重做。
鉴于变更数量,现有子解析器很可能应使用本文档重新编写:
将所有 { .members = array_of_thrift_member_t_definition } 替换为
members 字段被替换为子结构 s,其中包含字段 members 和 expert_info。
当定义的结构是异常时,将 expert_info 字段设置为指向 expert_field 的指针,将指示通用 Thrift 解析器把它与被解析的结构(或者更准确地说,是异常)关联起来。
这允许你用更简单的返回类型结构/union 定义(包含所有可能异常,如 Hijacking structure dissection feature 中所述)替换 Functions with a reply 中描述的 switch。
按照 commit 2a9bc633 移除 proto 变量初始化(并非 Thrift 特有)。
移除 proto、header field、expert info 和 subtree 变量的 init。
可使用 tools/convert-proto-init.py 脚本完成转换。
在每个 thrift_member_t 定义末尾添加 , NULL(TMFILL 已包含;如果定义了 TMRAW 和/或 TMUTF8,则更新它们)。
额外参数是一个 dissector_t 函数指针,可用于为特定字段编写自定义解析器,如多处所述。
TCustom 协议中 initialize 函数的 init_vector binary 参数的基本类型。
TCustom 协议中 registration 函数的 unregister boolean 参数同样适用基本类型。
在 Jaeger 协议示例中使用自定义解析器处理 Span.flags enum flag。
⚠️ 所有类型的处理都与 TBinaryProtocol 格式对齐,这意味着 TCompactProtocol 类型会发生转换:
Booleans 必须只考虑给定 byte 的最低有效位来处理。
Varints 会扩展为其预期长度,并采用 big endian 编码。
Doubles 会重新编码为 big endian。