第 9 章数据包解析

9.1. 数据包解析的工作原理

对于一个已封装好的协议包,每个解析器(dissector)对其负责的一部分协议进行解码,然后将解码过程交给后续的解析器。

每个解析都从帧(Frame)解析器开始,它解析捕获文件本身的细节(例如时间戳)。从那里开始它将数据传递到最低级别的数据解析器,例如和以太网报头对应的以太网解析器。然后将有效载荷(payload)传递到下一个解析器(例如 IP),依此类推。在每个阶段,数据包的细节被解码和展示。

解析器可以内置到 Wireshark 中,也可以编写为自注册插件(共享库或 DLL)。将您的解析器作为插件或内置几乎没有什么区别。您可以通过声明为WS_DLL_PUBLIC的公开的函数ABI进行有限的函数访问。

将解剖器编写为插件的最大好处是,在编辑内置解析器的代码后重新编译插件比重新编译wireshark快得多。因此从插件开始通常会使开发更快,而完成的代码作为内置解析器则可能更有意义。

注意:

阅读 README.dissector

文件doc/README.dissector包含有关编写解剖器的详细信息。在许多情况下,它比本文档更新。

9.2. 添加基本解析器

让我们逐步添加一个基本的解析器。我们将从虚构的“foo”协议开始。它由以下基本项目组成。

  • 数据包类型 - 8 位。可能的值:1 - 初始化,2 - 终止,3 - 数据。
  • 一组以 8 位存储的标志。0x01 - 开始数据包,0x02 - 结束数据包,0x04 - 优先级数据包。
  • 一个序列号 - 16 位。
  • 一个IPv4地址。

9.2.1. 建立解析器

您需要做出的第一个决定是该解剖器是内置的解剖器并包含在主程序中还是插件中。

刚开始时插件更容易编写,所以让我们从它开始。只需稍加注意修改该插件就可以转换为内置的解析器。

解析器初始化。 

#include "config.h"
#include <epan/packet.h>#define FOO_PORT 1234static int proto_foo = -1;void
proto_register_foo(void)
{proto_foo = proto_register_protocol ("FOO Protocol", /* name        */"FOO",          /* short name  */"foo"           /* filter_name */);
}

让我们一次过一点。首先,我们有一些样板包含文件。基本上文件固定从这里开始。

然后是#define定义foo协议使用的UDP端口。

接下来我们有proto_foo变量,它用于存储我们的协议句柄的一个int并初始化为-1。当在主程序中注册解析器时将设置此句柄。将所有未导出的变量和函数设为静态类型以最大程度地减少名称空间污染是一种很好的做法。这种使用静态变量和函数的方法通常不是问题,除非您的解析器变得巨大无比以至于它跨越多个文件(译注:以至于需要互相使用对方模块里的函数或者变量)。

现在我们有了与主程序交互的基础知识,我们将从两个协议解析器设置函数开始:proto_register_XXX和proto_reg_handoff_XXX.

每个协议都必须有一个形式为“proto_register_XXX”的注册函数。该函数用于在Wireshark中注册协议。调用注册例程的代码是自动生成的,并在Wireshark启动时调用。在本例中函数名为proto_register_foo。

proto_register_foo调用proto_register_protocol(),它需要一个name,short name和filter_name。name(名称)和short name(短名称)用于“Preferences(首选项)”和“Enabled protocols(启用的协议)”对话框以及文档生成的字段名称列表。filter_name用作(数据包展示)过滤器的名称。proto_register_protocol() 返回一个协议句柄,可用于引用协议并获得协议解析器的句柄。

接下来我们需要一个切换(handoff)例程。

解析器切换。 

void
proto_reg_handoff_foo(void)
{static dissector_handle_t foo_handle;foo_handle = create_dissector_handle(dissect_foo, proto_foo);dissector_add_uint("udp.port", FOO_PORT, foo_handle);
}

切换(handoff)例程将协议处理例程与协议的载荷相关联。它由两个主要步骤组成: 第一步是创建一个解析器句柄,这个句柄关联了协议和用以进行实际解析的函数。第二步是注册该解析器句柄,这样当处理与协议相关的载荷时这个对应的解析器就会被调用。

在这个例子中,proto_reg_handoff_foo()调用create_dissector_handle() 获取foo协议的解析器句柄。然后它使用dissector_add_uint()将UDP端口FOO_PORT (1234) 上的载荷与foo协议相关联,以便Wireshark在UDP端口1234上接收流量数据时调用dissect_foo()。

按惯例Wireshark的解析器中proto_register_foo()和 proto_reg_handoff_foo()是解析器源码中的最后两个函数。

下一步是编写解析函数dissect_foo()。我们将从一个基本的占位符开始。

解析。

static int
dissect_foo(tvbuff_t *tvb, packet_info *pinfo, proto_tree *tree _U_, void *data _U_)
{col_set_str(pinfo->cinfo, COL_PROTOCOL, "FOO");/* Clear the info column */col_clear(pinfo->cinfo,COL_INFO);return tvb_captured_length(tvb);
}

dissect_foo()用来剖析提交给它的数据包。数据包数据保存在此处名为tvb的特殊缓冲区中。packet_info结构包含有关协议的一般数据,我们可以在此处更新信息。tree参数是进行细节分析的地方。请注意,_U_是向编译器发出参数tree和data未使用的声明,这样编译器就不会打印(“参数或变量未使用”的)警告。

现在我们将在这里做最低限度的实现。col_set_str()用于将Wireshark的协议列设置为“FOO”(译注:对应wireshark默认视图数据包列表中第五列,通常展示HTTP、ARP等),以便每个人都可以看到它被识别。我们唯一要做的另一件事是清除INFO列中的所有数据(如果它正在显示)。

在这一点上,我们已经准备好编译和安装一个基本的解析器。这个解析器除了识别协议并标记它之外不做任何事情。这是解析器的完整代码:

完整的packet-foo.c :

#include "config.h"
#include <epan/packet.h>#define FOO_PORT 1234static int proto_foo = -1;static int
dissect_foo(tvbuff_t *tvb, packet_info *pinfo, proto_tree *tree _U_, void *data _U_)
{col_set_str(pinfo->cinfo, COL_PROTOCOL, "FOO");/* Clear the info column */col_clear(pinfo->cinfo,COL_INFO);return tvb_captured_length(tvb);
}void
proto_register_foo(void)
{proto_foo = proto_register_protocol ("FOO Protocol", /* name        */"FOO",          /* short_name  */"foo"           /* filter_name */);
}void
proto_reg_handoff_foo(void)
{static dissector_handle_t foo_handle;foo_handle = create_dissector_handle(dissect_foo, proto_foo);dissector_add_uint("udp.port", FOO_PORT, foo_handle);
}

要编译这个解析器并创建一个插件,除了packet-foo.c中的解析器源代码之外,还需要一些支持文件:

  • CMakeLists.txt - 包含此插件的 CMake文件和版本信息。
  • packet-foo.c - 你的解析器源码。
  • plugin.rc.in - 包含Windows DLL资源模板。(可选的)

gryphon 插件目录 (plugins/epan/gryphon) 中提供了这些文件的示例。如果您从gryphon插件复制文件,则需要使用正确的插件名称、版本信息和相关文件并更新CMakeLists.txt以进行编译。

在源码顶级目录中,将CMakeListsCustom.txt.example复制到CMakeListsCustom.txt并将您的插件路径添加到CUSTOM_PLUGIN_SRC_DIR.

将解析器编译为DLL或共享库,然后按照第 3.7 节“运行您的 Wireshark 版本”中的详细说明从构建目录运行Wireshark,或者将插件二进制文件复制到Wireshark安装后的插件目录中并运行它。

9.2.2. 解析协议的细节

现在我们已经启动并运行了基本的解析器,让我们用它做一些事情。最简单的开始是标记有效载荷。我们可以通过构建一个子树来将我们的结果解码为有效载荷。该子树将保存foo协议的所有详细信息,并有助于直观显示细节内容(译注:对应wireshark默认视图的下方协议树部分)。

我们用proto_tree_add_item()来添加新的子树,如下所示:

插件数据包剖析。 

static int
dissect_foo(tvbuff_t *tvb, packet_info *pinfo, proto_tree *tree, void *data _U_)
{col_set_str(pinfo->cinfo, COL_PROTOCOL, "FOO");/* Clear out stuff in the info column */col_clear(pinfo->cinfo,COL_INFO);proto_item *ti = proto_tree_add_item(tree, proto_foo, tvb, 0, -1, ENC_NA);return tvb_captured_length(tvb);
}

由于该FOO协议没有封装另一个协议,所以我们消耗了所有tvb的数据,即从0到结束(-1)。

最后一个参数指定“编码”并设置为 ENC_NA(“不适用”),因为协议没有专门规定使用大端 ( ENC_BIG_ENDIAN) 或小端 ( ENC_LITTLE_ENDIAN)。

添加对proto_tree_add_item()的调用后,协议树的详情显示中应该有一个标签FOO。选择此标签将突出显示数据包的其余内容。

现在让我们进入下一步开始添加一些协议分析的代码。为此我们需要构造表来定义数据包中将出现哪些字段并存储子树的打开/关闭状态。我们将这些静态分配的数组添加到文件的开头并命名它们 hf_register_info('hf' 是 'header field' 的缩写)和ett. 然后proto_register_protocol()将调用proto_register_field_array() 和 proto_register_subtree_array()来注册数组:

注册数据结构。 

static int hf_foo_pdu_type = -1;
static gint ett_foo = -1;/* ... */void
proto_register_foo(void)
{static hf_register_info hf[] = {{ &hf_foo_pdu_type,{ "FOO PDU Type", "foo.type",FT_UINT8, BASE_DEC,NULL, 0x0,NULL, HFILL }}};/* Setup protocol subtree array */static gint *ett[] = {&ett_foo};proto_foo = proto_register_protocol ("FOO Protocol", /* name       */"FOO",          /* short_name */"foo"           /* filter_name*/);proto_register_field_array(proto_foo, hf, array_length(hf));proto_register_subtree_array(ett, array_length(ett));
}

如您所见,在数据头字段数组中定义了一个字段"foo.type"。

现在调用dissect_foo()来解析中的FOO PDU类型(引用为foo.type)字段,先通过使用proto_item_add_subtree()添加FOO协议的子树,然后再调用proto_tree_add_item() 添加项。

分析器开始分析数据包。 

    proto_item *ti = proto_tree_add_item(tree, proto_foo, tvb, 0, -1, ENC_NA);proto_tree *foo_tree = proto_item_add_subtree(ti, ett_foo);proto_tree_add_item(foo_tree, hf_foo_pdu_type, tvb, 0, 1, ENC_BIG_ENDIAN);

如前所述,foo协议以8位的packet type开始,它可能具有三个值:1 - 初始化,2 - 终止,3 - 数据。以下是我们如何添加数据包详细信息:

proto_item_add_subtree()调用已向协议树添加了一个子节点,我们将在此处进行详细分析。该节点的扩展由ett_foo变量控制。当您在不同节点(或数据包)之间移动时它会记住是否应该扩展节点。从下个调用中可以看出,所有后续分析(的结果)都将添加到此树中。proto_tree_add_item()以foo_tree为参数调用(译注:即在这个参数代表的子树中调用,往这颗子树下添加项),这里使用hf_foo_pdu_type来控制项目的格式。pdu type是一个字节的数据,从0开始。我们假设它是网络顺序(也称为大端),所以这就是为什么我们使用ENC_BIG_ENDIAN(作为最后一个参数). 对于1字节长度的数据不存在什么顺序问题,但最好使其与可能存在的任何多字节字段相同,并且我们将在下一节中看到,此特定协议使用网络顺序。

如果我们仔细查看hf_foo_pdu_type静态数组中的声明,我们可以看到定义的细节。

static hf_register_info hf[] = {{ &hf_foo_pdu_type,{ "FOO PDU Type", "foo.type",FT_UINT8, BASE_DEC,NULL, 0x0,NULL, HFILL }}
};
  • hf_foo_pdu_type - 节点的索引。
  • FOO PDU Type- 项目的标签。
  • foo.type - 项目的缩写名称,用于数据包过滤器的显示(例如,foo.type=1)。
  • FT_UINT8 - 项目的类型:一个 8 位无符号整数。这与我们上面的调用相符,我们告诉它只查看一个字节。
  • BASE_DEC - 对于整数类型,这告诉它打印为十进制数。如果更有意义,它可以是十六进制 (BASE_HEX) 或八进制 (BASE_OCT)。

现在我们将忽略结构的其余部分。

如果您安装此插件并试用它,您会看到一些看起来很有用的东西。

现在让我们完成对简单协议的分析。我们需要向 hfarray 添加更多变量,以及更多的函数调用。

结束数据包分析。

...
static int hf_foo_flags = -1;
static int hf_foo_sequenceno = -1;
static int hf_foo_initialip = -1;
...static int
dissect_foo(tvbuff_t *tvb, packet_info *pinfo, proto_tree *tree, void *data _U_)
{gint offset = 0;...proto_item *ti = proto_tree_add_item(tree, proto_foo, tvb, 0, -1, ENC_NA);proto_tree *foo_tree = proto_item_add_subtree(ti, ett_foo);proto_tree_add_item(foo_tree, hf_foo_pdu_type, tvb, offset, 1, ENC_BIG_ENDIAN);offset += 1;proto_tree_add_item(foo_tree, hf_foo_flags, tvb, offset, 1, ENC_BIG_ENDIAN);offset += 1;proto_tree_add_item(foo_tree, hf_foo_sequenceno, tvb, offset, 2, ENC_BIG_ENDIAN);offset += 2;proto_tree_add_item(foo_tree, hf_foo_initialip, tvb, offset, 4, ENC_BIG_ENDIAN);offset += 4;...return tvb_captured_length(tvb);
}void
proto_register_foo(void) {......{ &hf_foo_flags,{ "FOO PDU Flags", "foo.flags",FT_UINT8, BASE_HEX,NULL, 0x0,NULL, HFILL }},{ &hf_foo_sequenceno,{ "FOO PDU Sequence Number", "foo.seqn",FT_UINT16, BASE_DEC,NULL, 0x0,NULL, HFILL }},{ &hf_foo_initialip,{ "FOO PDU Initial IP", "foo.initialip",FT_IPv4, BASE_NONE,NULL, 0x0,NULL, HFILL }},......
}
...

这里分析了这个简单的虚构的协议的所有部分。我们在其中引入了一个新变量offset以帮助跟踪我们在数据包解析中的当前位置。有了这些额外的数据位定义和分析,现在可以完整分析整个协议的内容。

9.2.3. 改进解剖信息

我们肯定可以用一些额外的数据来改进协议的显示。第一步是添加一些文本标签。让我们从标记数据包类型开始。通过添加一些额外的东西,对这类事情有一些有用的支持。首先我们将一个简单的类型表添加到name。

命名数据包类型。 

static const value_string packettypenames[] = {{ 1, "Initialise" },{ 2, "Terminate" },{ 3, "Data" },{ 0, NULL }
};

这是一个方便的数据结构,可用于查找值的名称。有一些程序可以直接访问这个查找表,但我们不需要这样做,因为为它已经添加了一些支持代码。我们只需要使用VALS宏并将适当部分数据提供给给它。

向协议添加名称。 

   { &hf_foo_pdu_type,{ "FOO PDU Type", "foo.type",FT_UINT8, BASE_DEC,VALS(packettypenames), 0x0,NULL, HFILL }}

这有助于破译数据包,我们可以对标志结构做类似的事情。为此,我们需要向表中添加更多数据。

向协议添加标志。 

#define FOO_START_FLAG      0x01
#define FOO_END_FLAG        0x02
#define FOO_PRIORITY_FLAG   0x04static int hf_foo_startflag = -1;
static int hf_foo_endflag = -1;
static int hf_foo_priorityflag = -1;static int
dissect_foo(tvbuff_t *tvb, packet_info *pinfo, proto_tree *tree, void *data _U_)
{......static int* const bits[] = {&hf_foo_startflag,&hf_foo_endflag,&hf_foo_priorityflag,NULL};proto_tree_add_bitmask(foo_tree, tvb, offset, hf_foo_flags, ett_foo, bits, ENC_BIG_ENDIAN);offset += 1;......return tvb_captured_length(tvb);
}void
proto_register_foo(void) {......{ &hf_foo_startflag,{ "FOO PDU Start Flags", "foo.flags.start",FT_BOOLEAN, 8,NULL, FOO_START_FLAG,NULL, HFILL }},{ &hf_foo_endflag,{ "FOO PDU End Flags", "foo.flags.end",FT_BOOLEAN, 8,NULL, FOO_END_FLAG,NULL, HFILL }},{ &hf_foo_priorityflag,{ "FOO PDU Priority Flags", "foo.flags.priority",FT_BOOLEAN, 8,NULL, FOO_PRIORITY_FLAG,NULL, HFILL }},......
}
...

这里要注意一些事情。对于flag,由于每一位都是不同的flag,我们使用了type FT_BOOLEAN,因为标志是打开或关闭的。其次,我们在数据的第7个字段中包含标志掩码,这允许系统屏蔽相关位。我们还将第5个字段更改为 8,以表明在提取标志时我们正在查看8位数量。最后,我们将额外的构造添加到解析例程中。

现在开始看起来功能相当齐全,但是我们还可以做一些其他的事情来使它看起来更漂亮。目前,我们的解析将数据包显示为“Foo 协议”,虽然正确,但信息量不大。我们可以通过添加更多细节来增强这一点。首先,让我们获取协议类型的实际值。我们可以使用函数 tvb_get_guint8()来做到这一点。有了这个值,我们可以做几件事。首先,我们可以设置非详细视图的INFO列(译注:在wireshark默认视图数据包列表的第七列)以显示它是哪种类型的PDU - 这在查看协议跟踪时非常有用。其次,我们还可以在分析窗口中显示此信息。

增强显示效果。 

static int
dissect_foo(tvbuff_t *tvb, packet_info *pinfo, proto_tree *tree, void *data _U_)
{gint offset = 0;guint8 packet_type = tvb_get_guint8(tvb, 0);col_set_str(pinfo->cinfo, COL_PROTOCOL, "FOO");/* Clear out stuff in the info column */col_clear(pinfo->cinfo,COL_INFO);col_add_fstr(pinfo->cinfo, COL_INFO, "Type %s",val_to_str(packet_type, packettypenames, "Unknown (0x%02x)"));proto_item *ti = proto_tree_add_item(tree, proto_foo, tvb, 0, -1, ENC_NA);proto_item_append_text(ti, ", Type %s",val_to_str(packet_type, packettypenames, "Unknown (0x%02x)"));proto_tree *foo_tree = proto_item_add_subtree(ti, ett_foo);proto_tree_add_item(foo_tree, hf_foo_pdu_type, tvb, offset, 1, ENC_BIG_ENDIAN);offset += 1;return tvb_captured_length(tvb);
}

因此,在这里获取前8位的值后,我们将它与一个内置实用函数val_to_str()一起使用以查找该值。如果未找到该值,我们将提供一个十六进制打印值字符串。我们使用它两次,一次在列的INFO字段中 - 如果它被显示同样我们将此数据附加到我们解析树的底部。

9.3. 如何添加专家项目

显示协议字段及其值解释的解析器非常有用。如果解析器可以将您的注意力吸引到值得注意的地方,那就更有帮助了。它们可以是“会话的开始标志”一样简单的东西,也可以是“无效值​​”一样复杂的东西。

在这里,我们使用我们的解析器FOO为序列号(sequence number )为零的项添加一个专家项目(假设这是该协议的一个值得注意的事情)。

专家项目的建立。 

#include <epan/expert.h>static expert_field ei_foo_seqn_zero = EI_INIT;/* ... */void
proto_register_foo(void)
{/* ... */expert_module_t* expert_foo;/* ... */static ei_register_info ei[] = {{&ei_foo_seqn_zero,{ "foo.seqn_zero", PI_SEQUENCE, PI_CHAT,"Sequence number is zero", EXPFILL }}};/* ... */expert_foo = expert_register_protocol(proto_foo);expert_register_field_array(expert_foo, ei, array_length(ei));
}

让我们一步一步来。专家项所需的数据结构和函数可以在epan/expert.h中找到,因此我们必须包含该文件。

接下来,我们必须为要添加到解析器中的每种类型的专家项目分配一个expert_field结构。这个结构用EI_INIT初始化.

现在我们必须注册那个我们为其提供专家信息的协议。由于我们前面已经有一个注册我们协议的函数,我们也将在那里添加了专家信息注册。这是通过调用expert_register_protocol()并将之前注册的协议句柄传递给它来完成的。

接下来我们需要注册一组定义,那些我们想要添加到解析器中的专家项目的定义。该数组与之前的头字段数组不同,它包含了分析引擎在创建和处理专家项时所需的所有数据。

专家项目的定义由一个指向我们之前定义过的结构expert_field指针和一个包含专家项目本身的数据元素的结构组成。

  • "foo.seqn_zero" - 专家项显示过滤器
  • PI_SEQUENCE - 专家项所属的组
  • PI_CHAT - 专家项目的复杂性
  • “Sequence number is zero” - 添加到解析器的文本字符串

现在我们将暂时忽略结构的其余部分。

为了保持对许多专家项目的概览(To keep an overview of lots of expert items),将它们分类为组(group)是有帮助的。目前有定义几种类型的组,例如checksum,sequence, protocol等等。所有这些都在EPAN/proto.h头文件中定义。

并非每个值得注意的字段值都具有相同的重要性。用户可能会乐于知道“开始会话”,但是“无效值错误”则可能是协议中的重大错误。需要分配以下其中的一个用以区别这些专家项的重要性: comment,chat,note,warn或error。尽量选择合适而最低的。您目前正在研究的主题可能比几周后看起来更重要。

在专家项数组建立后,我们通过调用expert_register_field_array()函数将其添加到解析引擎中。

现在专家项目的所有信息都已定义并注册,是时候将专家项目实际添加到解析器中了。

专家项目的使用。

static int
dissect_foo(tvbuff_t *tvb, packet_info *pinfo, proto_tree *tree, void *data _U_)
{guint32 sequenceno = 0xFFFF;/* ... */ti = proto_tree_add_item_ret_uint(foo_tree, hf_foo_sequenceno,tvb, offset, 2, ENC_BIG_ENDIAN, &sequenceno);if (sequenceno == 0) {expert_add_info(pinfo, ti, &ei_foo_seqn_zero);}/* ... */
}

用于添加序列号解析的函数略有改动。首先函数创建的proto_item保存在预先定义的变量ti中,字段的实际值保存在变量sequenceno中。我们现在可以使用该字段的值来确定是否添加专家项。

添加专家项目只需调用expert_add_info()并将packet_info结构、专家项目添加到的协议树项(proto item)ti以及先前定义和注册的专家项目信息(expert item information)传递给它。

9.4. 如何处理转换后的数据

一些协议对数据做了一些聪明(或复杂)的事情。他们可能会加密数据或压缩数据,或其中的一部分。如果您知道这些步骤时如何发生的,则可以在解析器内逆转它们(通过解密或解压得到原始数据)。

由于加密可能很棘手,让我们考虑压缩的情况。这些技术也适用于其他数据转换,其中在检查数据之前需要执行一些步骤。

这里基本上需要做的是识别需要转换的数据,获取该数据并将其转换为新的流,然后对其调用解析器。通常这需要根据数据包中的线索“即时”完成。有时这需要与其他技术结合使用,例如数据包重组。下面展示了一种实现这种效果的技术。

在解析器中解压数据包。 

    guint8 flags = tvb_get_guint8(tvb, offset);offset ++;if (flags & FLAG_COMPRESSED) { /* the remainder of the packet is compressed */guint16 orig_size = tvb_get_ntohs(tvb, offset);guchar *decompressed_buffer = (guchar*)wmem_alloc(pinfo->pool, orig_size);offset += 2;decompress_packet(tvb_get_ptr(tvb, offset, -1),tvb_captured_length_remaining(tvb, offset),decompressed_buffer, orig_size);/* Now re-setup the tvb buffer to have the new data */next_tvb = tvb_new_child_real_data(tvb, decompressed_buffer, orig_size, orig_size);add_new_data_source(pinfo, next_tvb, "Decompressed Data");} else {next_tvb = tvb_new_subset_remaining(tvb, offset);}offset = 0;/* process next_tvb from here on */

这里的第一步是识别压缩。在(上文代码中)这种情况下,一个标志字节提醒我们数据包的其余部分已被压缩。接下来我们检索数据包的原始大小,通常情况下它会在协议内(某个字段中)。如果不是,它可能是压缩例程实现中的一部分(译注:比如可以约定好了固定大小的原始数据包,不够就填充空值),在这种情况下逻辑会有所不同。

因此,根据大小调用wmem_alloc()在内存池pinfo->pool中分配一个缓冲区用来接收未压缩的数据,并将数据包解压缩到其中。使用tvb_get_ptr()函数可从偏移量开始获取指向数据包原始数据的指针。在这种情况下,解压程序还需要知道(压缩数据的)长度信息,这是由tvb_captured_length_remaining()函数给出的 。

接下来,我们调用tvb_new_child_real_data()从这些数据构建一个新的tvb缓冲区 。这个数据是我们原始数据的"子数据",所以调用的这个函数名也承认这一点。在使用pinfo->pool时无需调用tvb_set_free_cb() (当pinfo内存池生命周期结束时内存块将自动释放)。最后我们把这个tvb作为一个新的数据源加入,展示细节时这些解压后的字节就如同原始数据一样(译注:这里肯定要有什么标识用来识别是不是解压后的数据,否则会误导用户)。

(新的tvb缓冲区next_tvb)设置完成后,解析器的其余部分可以解析缓冲区next_tvb,因为它是一个新缓冲区,所以当我们从该缓冲区的开头再次开始时偏移量是0。为了让解剖器在无论是否涉及压缩的情况下其余部分工作都能工作,在没有压缩(标识)时,我们使用tvb_new_subset_remaining()函数用从旧缓冲区当前偏移量开始到旧缓冲区最后的这一段数据生成了一个新缓冲区。这使得不管数据是否压缩,在这里开始分析数据包(结果)都完全相同。

9.5. 如何重新组装拆分的数据包

某些协议有时必须将一个大数据包拆分为多个其他数据包。在这种情况下,除非您拥有所有数据,否则无法正确进行解析。第一个数据包没有足够的数据,随后的数据包没有期望的格式。要解析这些数据包,您需要等到所有部分的数据都到达后才开始解析。

以下部分将指导您完成两种常见的情况。有关所有可能用到的函数、结构和参数的说明,请参阅epan/reassemble.h。

9.5.1. 如何重新组装拆分的UDP数据包

举个例子,让我们来看看一个协议,它位于 UDP 之上,它分裂了自己的数据流。如果一个数据包大于某个给定的大小,它将被分成块,并以某种方式在其协议中标识出。

为了处理这样的流,我们需要一些东西来触发。我们需要知道这个数据包是多数据包序列的一部分。我们需要知道序列中有多少个数据包。我们还需要知道何时拥有所有数据包。

对于这个例子,我们假设有一个简单的协议内信令机制来提供细节。一个标志字节,表示多包序列和最后一个包的存在,后跟序列ID和包序列号。

msg_pkt ::= SEQUENCE {.....flags ::= SEQUENCE {fragment    BOOLEAN,last_fragment   BOOLEAN,.....}msg_id  INTEGER(0..65535),frag_id INTEGER(0..65535),.....
}

重新组装分片 - 第 1 部分。 

#include <epan/reassemble.h>...
save_fragmented = pinfo->fragmented;
flags = tvb_get_guint8(tvb, offset); offset++;
if (flags & FL_FRAGMENT) { /* fragmented */tvbuff_t* new_tvb = NULL;fragment_data *frag_msg = NULL;guint16 msg_seqid = tvb_get_ntohs(tvb, offset); offset += 2;guint16 msg_num = tvb_get_ntohs(tvb, offset); offset += 2;pinfo->fragmented = TRUE;frag_msg = fragment_add_seq_check(msg_reassembly_table,tvb, offset, pinfo,msg_seqid, NULL, /* ID for fragments belonging together */msg_num, /* fragment sequence number */tvb_captured_length_remaining(tvb, offset), /* fragment length - to the end */flags & FL_FRAG_LAST); /* More fragments? */

我们首先保存这个数据包的分片状态,以便我们以后可以恢复它。接下来是一些特定于协议的东西,如果它存在则从流中挖掘出分片数据。确定它存在后,我们让函数fragment_add_seq_check()完成它的工作。我们需要为其提供一定数量的参数:

  • msg_reassembly_table表用于簿记,稍后描述它。
  • 我们正在分析的tvb缓冲区。
  • 分片数据包开始的偏移量。
  • 提供的数据包信息。
  • 分片流的序列号。可能有多个分片流传输中,这用于确定要用于重新组装的相关片段。
  • 用于识别分片的可选附加数据。对于大多数解剖器可以设置为NULL(如示例中所做的那样)。
  • msg_num是分片序列中的数据包编号。
  • 这里的长度指定为tvb的剩余部分,因为我们想要其余的数据包数据。
  • 最后一个参数表明这是否是最后一个片段。(根据协议的不同)这可能是一个标志,或者可能协议中有一个计数器。

重新组装分片第 2 部分。 

    new_tvb = process_reassembled_data(tvb, offset, pinfo,"Reassembled Message", frag_msg, &msg_frag_items,NULL, msg_tree);if (frag_msg) { /* Reassembled */col_append_str(pinfo->cinfo, COL_INFO," (Message Reassembled)");} else { /* Not last packet of reassembled Short Message */col_append_fstr(pinfo->cinfo, COL_INFO," (Message fragment %u)", msg_num);}if (new_tvb) { /* take it all */next_tvb = new_tvb;} else { /* make a new subset */next_tvb = tvb_new_subset_remaining(tvb, offset);}
}
else { /* Not fragmented */next_tvb = tvb_new_subset_remaining(tvb, offset);
}.....
pinfo->fragmented = save_fragmented;

将片段数据传递给重组处理程序后,我们现在可以检查是否有完整的消息。如果有足够的信息,该例程将返回新重组的数据缓冲区。

之后,我们将添加显示一些信息性消息以表明这是序列的一部分。然后可以继续对缓冲区和解剖进行一些操作。通常除非片段已重新组装否则您可能不会进一步解析,因为此时不会找到太多东西。如果您愿意,有时可以部分解码序列中的第一个数据包。

现在我们传入未知数据到fragment_add_seq_check()中。

重新组装片段 - 初始化。

static reassembly_table reassembly_table;static void
proto_register_msg(void)
{reassembly_table_register(&msg_reassemble_table,&addresses_ports_reassembly_table_functions);
}

首先在协议初始化例程中声明并初始化一个reassembly_table结构。第二个参数是用于识别片段的函数。我们将使用addresses_ports_reassembly_table_functions,以给定序列号(msg_seqid)、源地址和目标地址以及端口从数据包中识别片段。

之后,分配一个fragment_items结构并填充以一系列ett项、hf数据项和一个字符串标记。ett和hf值应包含在相关表中,就像您的协议可能使用的所有其他变量一样。hf变量需要放置在结构中,如下所示。当然名称可能需要调整。

重新组装片段 - 数据。 

...
static int hf_msg_fragments = -1;
static int hf_msg_fragment = -1;
static int hf_msg_fragment_overlap = -1;
static int hf_msg_fragment_overlap_conflicts = -1;
static int hf_msg_fragment_multiple_tails = -1;
static int hf_msg_fragment_too_long_fragment = -1;
static int hf_msg_fragment_error = -1;
static int hf_msg_fragment_count = -1;
static int hf_msg_reassembled_in = -1;
static int hf_msg_reassembled_length = -1;
...
static gint ett_msg_fragment = -1;
static gint ett_msg_fragments = -1;
...
static const fragment_items msg_frag_items = {/* Fragment subtrees */&ett_msg_fragment,&ett_msg_fragments,/* Fragment fields */&hf_msg_fragments,&hf_msg_fragment,&hf_msg_fragment_overlap,&hf_msg_fragment_overlap_conflicts,&hf_msg_fragment_multiple_tails,&hf_msg_fragment_too_long_fragment,&hf_msg_fragment_error,&hf_msg_fragment_count,/* Reassembled in field */&hf_msg_reassembled_in,/* Reassembled length field */&hf_msg_reassembled_length,/* Tag */"Message fragments"
};
...
static hf_register_info hf[] =
{
...
{&hf_msg_fragments,{"Message fragments", "msg.fragments",FT_NONE, BASE_NONE, NULL, 0x00, NULL, HFILL } },
{&hf_msg_fragment,{"Message fragment", "msg.fragment",FT_FRAMENUM, BASE_NONE, NULL, 0x00, NULL, HFILL } },
{&hf_msg_fragment_overlap,{"Message fragment overlap", "msg.fragment.overlap",FT_BOOLEAN, 0, NULL, 0x00, NULL, HFILL } },
{&hf_msg_fragment_overlap_conflicts,{"Message fragment overlapping with conflicting data","msg.fragment.overlap.conflicts",FT_BOOLEAN, 0, NULL, 0x00, NULL, HFILL } },
{&hf_msg_fragment_multiple_tails,{"Message has multiple tail fragments","msg.fragment.multiple_tails",FT_BOOLEAN, 0, NULL, 0x00, NULL, HFILL } },
{&hf_msg_fragment_too_long_fragment,{"Message fragment too long", "msg.fragment.too_long_fragment",FT_BOOLEAN, 0, NULL, 0x00, NULL, HFILL } },
{&hf_msg_fragment_error,{"Message defragmentation error", "msg.fragment.error",FT_FRAMENUM, BASE_NONE, NULL, 0x00, NULL, HFILL } },
{&hf_msg_fragment_count,{"Message fragment count", "msg.fragment.count",FT_UINT32, BASE_DEC, NULL, 0x00, NULL, HFILL } },
{&hf_msg_reassembled_in,{"Reassembled in", "msg.reassembled.in",FT_FRAMENUM, BASE_NONE, NULL, 0x00, NULL, HFILL } },
{&hf_msg_reassembled_length,{"Reassembled length", "msg.reassembled.length",FT_UINT32, BASE_DEC, NULL, 0x00, NULL, HFILL } },
...
static gint *ett[] =
{
...
&ett_msg_fragment,
&ett_msg_fragments
...

这些hf变量在重组例程内部使用,以建立有用的链接,并将数据添加到解析器中。它产生从一个数据包到另一个数据包的链接,例如片段数据包具有到完全重组数据包的链接。同样,重组后的数据包也有指向单个数据包的后向指针。其他变量用于标记错误。

9.5.2. 如何重新组装拆分的 TCP 数据包

分析器有一个tvbuff_t指针,该指针保存TCP数据包的有效载荷。此有效负载包含应用层协议的标头和数据。

在分析应用层协议时,您不能假设每个TCP数据包都只包含一个应用层消息。一个应用层消息可以拆分为多个TCP数据包。

您也不能假设一个TCP数据包只会包含一个应用层消息,并且消息头位于TCP负载的开头。一个TCP 数据包中可以传输多个消息,因此一条消息可以从任意位置开始。

这听起来很复杂,但有一个简单的解决方案。 tcp_dissect_pdus()为您重新组装所有这些tcp数据包。该函数在epan/dissectors/packet-tcp.h 中实现。

重组TCP分组。 

#include "config.h"#include <epan/packet.h>
#include <epan/prefs.h>
#include "packet-tcp.h"...#define FRAME_HEADER_LEN 8/* This method dissects fully reassembled messages */
static int
dissect_foo_message(tvbuff_t *tvb, packet_info *pinfo _U_, proto_tree *tree _U_, void *data _U_)
{/* TODO: implement your dissecting code */return tvb_captured_length(tvb);
}/* determine PDU length of protocol foo */
static guint
get_foo_message_len(packet_info *pinfo _U_, tvbuff_t *tvb, int offset, void *data _U_)
{/* TODO: change this to your needs */return (guint)tvb_get_ntohl(tvb, offset+4); /* e.g. length is at offset 4 */
}/* The main dissecting routine */
static int
dissect_foo(tvbuff_t *tvb, packet_info *pinfo, proto_tree *tree, void *data)
{tcp_dissect_pdus(tvb, pinfo, tree, TRUE, FRAME_HEADER_LEN,get_foo_message_len, dissect_foo_message, data);return tvb_captured_length(tvb);
}...

如您所见,这非常简单。只需在您的主解析例程调用tcp_dissect_pdus(),并将您的消息解析代码移到另一个函数中。每当消息重新组合时都会调用此函数。

参数tvb、pinfo、tree和data只是(从dissect_foo的入参)传递给 tcp_dissect_pdus(). 第四个参数是指示是否应该重新组装数据的一个标志。这也可以根据解析器偏好设置。参数5表示至少有多少数据可用才能确定foo消息的长度。参数6是指向返回此长度的方法的函数指针。当至少前一个参数(参数5)中给出的字节数可用时,它才会被调用。参数7是指向真实消息解析器的函数指针。参数8是从父级解析器传入的数据。

对于那些在确定消息长度之前需要更多数据的协议,函数可以返回零。其他小于固定长度的值将导致异常。(Protocols which need more data before the message length can be determined can return zero. Other values smaller than the fixed length will result in an exception.)

9.6. 如何tap协议

将Tap接口添加到协议可以让它做一些很有用的事情。特别是您可以从Tap接口生成协议统计信息。

tap基本上是一种允许其他项目看到在解析协议时发生了什么的方式。在主程序中注册一个tap,然后在每次解析时它会被调用。一些任意协议特定数据和可以使用的例程一起提供。(Some arbitrary protocol specific data is provided with the routine that can be used.)

要创建tap,您首先要注册一个tap。一个tap被注册为一个整数句柄,并调用例程register_tap()注册。这里需要一个字符串名称来再次查找它。

初始化一个tap。 

#include <epan/packet.h>
#include <epan/tap.h>static int foo_tap = -1;struct FooTap {gint packet_type;gint priority;...
};void proto_register_foo(void)
{...foo_tap = register_tap("foo");

虽然您可以在没有协议特定数据的情况下对tap进行编程,但它通常不是很有用。因此,声明一个可以通过tap传递的结构是一个好主意。这需要是一个静态结构,因为它将在解析例程返回后继续使用。通常最好挑选出您正在解析的协议的一些通用部分到Tap数据中。可能是数据包类型、优先级或状态代码。该结构确实需要包含在头文件中,以便其他想要监听tap的组件可以包含它。

一旦你定义了这些,只需要简单填充一个协议特定结构然后调用tap_queue_packet,可能作为解剖器的最后一部分。

调用协议tap。 

static int
dissect_foo(tvbuff_t *tvb, packet_info *pinfo, proto_tree *tree, void *data _U_)
{...fooinfo = wmem_alloc(pinfo->pool, sizeof(struct FooTap));fooinfo->packet_type = tvb_get_guint8(tvb, 0);fooinfo->priority = tvb_get_ntohs(tvb, 8);...tap_queue_packet(foo_tap, pinfo, fooinfo);return tvb_captured_length(tvb);
}

这现在使那些感兴趣的各方能够监听到此协议对话的详细信息。

9.7. 如何生成协议统计信息

鉴于您有协议的tap接口,您可以使用它从协议跟踪中生成一些有趣的统计数据(很可能很有趣!)。

这可以在单独的插件中完成,也可以在进行解析工作的同一个插件中完成。后一种方案更好,因为 tap和stats模块通常依赖于协议相关特定数据的共享,前者可能会在两个不同的插件之间失去同步。

这是从上述TAP接口生成统计信息的机制。

初始化统计接口。

#include <epan/stats_tree.h>/* register all http trees */
static void register_foo_stat_trees(void) {stats_tree_register_plugin("foo", "foo", "Foo/Packet Types", 0,foo_stats_tree_packet, foo_stats_tree_init, NULL);
}WS_DLL_PUBLIC_DEF void plugin_register_tap_listener(void)
{register_foo_stat_trees();
}

从下往上工作,首先定义插件接口入口点plugin_register_tap_listener(). 这里只调用了初始化函数register_foo_stat_trees()。

接下来轮到调用stats_tree_register_plugin()函数,该函数接受三个字符串、一个整数和三个回调函数。

  1. 这是注册的tap名称。
  2. 统计名称的缩写。
  3. 统计模块的名称。“/”字符可用于制作子菜单。
  4. 每个数据包回调的标志(Flags for per-packet callback)
  5. 调用以生成统计信息的函数。
  6. 调用以初始化统计数据的函数。
  7. 调用以清理统计数据的函数。

在当前情况下,我们只需要前两个函数,因为没有什么特别的东西需要清理。

初始化统计会话。 

static const guint8* st_str_packets = "Total Packets";
static const guint8* st_str_packet_types = "FOO Packet Types";
static int st_node_packets = -1;
static int st_node_packet_types = -1;static void foo_stats_tree_init(stats_tree* st)
{st_node_packets = stats_tree_create_node(st, st_str_packets, 0, STAT_DT_INT, TRUE);st_node_packet_types = stats_tree_create_pivot(st, st_str_packet_types, st_node_packets);
}

现在我们创建一个新的树节点来处理总的数据包,然后我们创建一个数据透视表来处理有关不同数据包类型的统计信息,并作为它(总数据包)的子节点。

生成统计数据。 

static tap_packet_status foo_stats_tree_packet(stats_tree* st, packet_info* pinfo, epan_dissect_t* edt, const void* p)
{struct FooTap *pi = (struct FooTap *)p;tick_stat_node(st, st_str_packets, 0, FALSE);stats_tree_tick_pivot(st, st_node_packet_types,val_to_str(pi->packet_type, packettypenames, "Unknown packet type (%d)"));return TAP_PACKET_REDRAW;
}

目前对统计数据的处理非常简单。首先我们在st_str_packets数据包的节点上调用了tick_stat_node函数。然后stats_tree_tick_pivot()在st_node_packet_types子树上的调用可以允许我们按数据包的类型记录统计信息。

9.8. 如何使用对话

关于如何在解析器中使用会话(conversations)的一些信息可以在文件doc/README.dissector的第2.2章中找到。

9.9. idl2wrs:从 CORBA IDL 文件创建解剖器

Wireshark 的许多解析器都是自动生成的。本节展示如何从CORBA IDL文件生成一个。

9.9.1. 它是什么?

正如您可能从名称中猜到的那样,idl2wrs采用用户指定的IDL文件并尝试构建一个解析器,该解析器可以通过GIOP解码IDL流量。它生成的文件是“C”代码,它应该可以作为Wireshark解析器进行编译。

idl2wrs工具解析omniidl编译器提供给它的数据结构,并使用packet-giop.[ch]中可用的GIOP API,生成get_CDR_xxx调用以解码线路上的CORBA流量。

它由4个主要文件组成。

README.idl2wrs

本文件

wireshark_be.py

主编译器后端(compiler backend)

wireshark_gen.py

生成C代码的辅助类。

idl2wrs

一个简单的shell脚本包装器,终端用户应该使用它从IDL文件生成解析器。

9.9.2. 为什么要这样做?

了解GIOP/IIOP上的CORBA流量是什么样的并帮助构建可以帮助排除CORBA互通故障的工具,这一点很重要。在看到很多关于如何在八位字节流中表示特定IDL类型的讨论后,情况尤其如此。

我还收到了一些评论/反馈,比如说,当教学生什么是CORBA流量“在线”时,这个工具对于CORBA课程来说是很好的。在一个伟大的开源项目上工作也是很酷的,比如“Wireshark”的案例(Wireshark · Go Deep.)。

9.9.3. 如何使用 idl2wrs

要用idl2wrs生成Wireshark解析器,您需要以下内容:

  • 必须安装 Python。见Welcome to Python.org
  • omniidl from the omniORB 包必须可用。见omniORB
  • 当然,您需要安装Wireshark来编译代码并在需要时对其进行调整。idl2wrs是标准 Wireshark发行版的一部分

要使用 idl2wrs 从 idl 文件生成 Wireshark 解剖器,请使用以下过程:

  • 将 C 代码写入标准输出。

$ idl2wrs <your_file.idl>

例如:

$ idl2wrs echo.idl

  • 要写入文件,只需重定向输出。

$ idl2wrs echo.idl > packet-test-idl.c

您可能希望注释掉 register_giop_user_module() 代码,它将使您使用启发式解析。

如果您不想使用shell脚本包装器,请尝试执行步骤3或4。

  • 将C代码写入标准输出。

$ omniidl -p ./ -b wireshark_be <your file.idl>

例如:

$ omniidl -p ./ -b wireshark_be echo.idl

  • 要写入文件,只需重定向输出。

$ omniidl -p ./ -b wireshark_be echo.idl > packet-test-idl.c

您可能希望注释掉 register_giop_user_module() 代码,它将使您使用启发式解析。

  • 将生成的 C 代码复制到Wireshark源目录中的子目录 epan/dissectors/。

$ cp packet-test-idl.c /dir/where/wireshark/lives/epan/dissectors/

新的解析器必须添加到同一目录中的CMakeLists.txt。查找声明DISSECTOR_SRC并在那里            添加新的解析器。例如,

DISSECTOR_SRC = \ ${CMAKE_CURRENT_SOURCE_DIR}/packet-2dparityfec.c                      ${CMAKE_CURRENT_SOURCE_DIR}/packet-3com-njack.c ...

变成

DISSECTOR_SRC = \ ${CMAKE_CURRENT_SOURCE_DIR}/packet-test-idl.c \         ${CMAKE_CURRENT_SOURCE_DIR}/packet-2dparityfec.c \         ${CMAKE_CURRENT_SOURCE_DIR}/packet-3com-njack.c \ ...

对于接下来的步骤,请转到Wireshark源代码的顶级目录。

  • 创建构建目录

$ mkdir build && cd build

  • 运行 cmake

$ cmake ..

  • 构建代码

$ make

  • 祝你好运 !!

9.9.4. 要做的事

  • 生成异常代码(尚未完成),但可以手动添加。
  • 枚举尚未转换为符号值(尚未完成),但可以手动添加。
  • 添加命令行选项等
  • 更多我确定:-)

9.9.5. 限制

查看packet-giop.c 中的 TODO 列表

9.9.6. 注意

传递给omniidl的选项-p ./表示wireshark_be.py和wireshark_gen.py在当前目录中。如果您将这些文件放在其他地方,这个参数可能需要调整。

如果它提示找不到某些模块(例如tempfile.py),您可能需要检查 PYTHONPATH 是否设置正确。

wireshark官方文档第 9 章数据包解析相关推荐

  1. OpenGL ES着色器语言之语句和结构体(官方文档第六章)内建变量(官方文档第七、八章)...

    OpenGL ES着色器语言之语句和结构体(官方文档第六章) OpenGL ES着色器语言的程序块基本构成如下: 语句和声明 函数定义 选择(if-else) 迭代(for, while, do-wh ...

  2. OpenGL ES着色器语言之变量和数据类型(二)(官方文档第四章)

    OpenGL ES着色器语言之变量和数据类型(二)(官方文档第四章) 4.5精度和精度修饰符 4.5.1范围和精度 用于存储和展示浮点数.整数变量的范围和精度依赖于数值的源(varying,unifo ...

  3. 【官方文档】Fluent Bit 数据管道之过滤插件(Kubernetes)

    文章目录 1. 配置参数 2. 处理 'log' 值 3. Kubernetes Annotations 3.1. Pod 定义中的 annotations 示例 3.1.1. 建议一个解析器 3.1 ...

  4. 【官方文档】Fluent Bit 数据管道之输入插件(Tail)

    文章目录 1. 配置参数 2. 多行支持 2.1. 多行核心 (v1.8) 2.2. 多行和容器 (v1.8) 2.3. 旧的多行配置参数 2.4. 旧的 Docker 模式配置参数 3. 入门指南 ...

  5. 官方文档-丰富你的数据

    对应7.16官方文档路径: Ingest pipelines » Enrich your data 官方地址如下: https://www.elastic.co/guide/en/elasticsea ...

  6. 【官方文档】Fluent Bit 数据管道之输出插件(Kafka)

    文章目录 1. 配置参数 2. 入门指南 2.1. 命令行 2.2. 配置文件 官方文档地址: Kafka Kafka 输出插件允许将你的记录输入到 Apache Kafka 服务中.这个插件使用官方 ...

  7. 【官方文档】Fluent Bit 数据管道之过滤插件(Parser)

    文章目录 1. 配置参数 2. 配置文件 3. 保留原始字段 3.1. Reserve_Data 3.2. Preserve_Key 官方文档地址: Parser Parser 过滤器插件允许解析事件 ...

  8. 【官方文档】Fluentd 通过 RPM 包安装在 Red Hat Linux

    文章目录 1. td-agent 是什么? 2. calyptia-fluentd 是什么? 3. 用于安装 td-agent 3.1. 步骤 0:安装前 3.2. 步骤 1:从 rpm Reposi ...

  9. Symfony 官方文档 第 1 章 1 - Symfony介绍

    原文请看 这里 第 1 章 1 - Symfony介绍 Symfony能做什么? 使用Symfony需要掌握哪些知识? 读完这一章你就知道答案了. Symfony简介 通过自动化完成一些特定的开发模式 ...

最新文章

  1. p40鸿蒙系统体验,苦心等待值了!华为P40成功运行鸿蒙OS,超级流畅
  2. 做一个p2p打洞的C#程序
  3. SAP Spartacus delivery mode continue button单元测试失败原因分析
  4. 【洛谷新手村】简单字符串 p1055 ISBN号码
  5. okHttp3连接池简单使用
  6. android markdown 框架,Android Studio MarkDown风格README的正确打开姿势
  7. html5学习笔记(progress)
  8. 计算机绘图第二章,机械制图电子教桉-02第二章+计算机绘图..ppt
  9. 使用httpclient调用url出现错误Illegal character in scheme name at index 0解决方案
  10. 触发器-- 肖敏_入门系列_数据库进阶 60、触发器(三) --youku
  11. Sqlserver 默认连接 master 库
  12. 数字IC面试总结(大厂面试经验分享)
  13. 先验分布/后验分布/似然估计/贝叶斯公式
  14. MOS管工作原理及特性
  15. Blogbus适用的日志发布工具【超级写手】
  16. 魔金多商户商城平台管理
  17. android 应用未验证,解决微信分享显示“未验证应用”问题。
  18. error: 'FILE' undeclared (first use in this function)
  19. 【K580键盘】蓝牙连接一直失败
  20. 【智能优化算法-天鹰算法】基于天鹰优化算法求解多目标优化问题附matlab代码

热门文章

  1. 风讯dotNETCMS源码
  2. 命令行 上下箭头符号_命令行基础知识:符号链接
  3. 2021海豚百度指数批量查询软件【急速】
  4. 如何修复MacBook上的粘滞键
  5. 不重装系统将系统移动到固态硬盘,并修改为C盘
  6. RPD and Rap Sheet (Hard Version)(交互题,不进位加法、不退位减法)
  7. 如何使用Python获取高德地图中的地铁线路数据(geojson版本)
  8. C语言strchr()函数
  9. [C] numeral systems 进制转换
  10. 计算机必备网站程序员必备大学牲编程科研人员