100天掌握YARA:如何编写.NET代码特征规则
100天掌握YARA:如何编写.NET代码特征规则
如果YARA规则仅依赖字符串匹配来检测.NET程序集,其能力将非常有限。我们将探索更多检测机会,包括IL代码、方法签名定义和特定自定义属性。了解底层.NET元数据结构、令牌和流有助于构建更精确高效的签名,即使相关恶意软件样本可能不可用。
案例1:基于截图的YARA规则
偶尔,恶意软件分析师需要根据文章或社交媒体帖子编写威胁狩猎或检测规则,而手头没有样本。样本可能是机密的,未在公共共享网站上提供,或哈希值不可用。尽管这是一个特定的用例,但本文还将教你如何为.NET签名添加上下文,以及在样本存在时如何在不使用十六进制编辑器的情况下选择正确的格式。
如果只有截图,可以使用哪些信息?首先,dnSpy会话的截图可能显示方法名称、参数名称、方法标识符和类名称。此外,可能还有包含独特盐值、密码或编码负载的整数数组。反编译代码在截图中也经常可见,但通常无法还原为IL代码模式。我们将讨论如何为这些模式中的每一个选择合适的格式。
类似以下的截图显示了程序集信息,这些信息在内部保存为自定义属性。
不了解.NET内部结构的分析师可能会编写如下签名。为了确保考虑到字符串的不同编码,他们可能会对所有字符串应用ascii和wide修饰符。
⚠️YARA规则以纯文本形式提供,以避免博客被检测到,并防止URL意外触发。
然而,该条件不会匹配样本,因为这些模式存在常见陷阱。在讨论.NET内部结构后,我们将解释这些陷阱并创建一个改进的工作签名。
.NET元数据头和流
.NET文件是包含公共语言运行时(CLR)元数据的可移植可执行文件。CLR头的位置设置在PE文件头数据目录的第15个条目中,该条目在PE COFF规范中名为CLR运行时头。
CLR头指向元数据头,元数据头以存储签名'BSJB'开头。元数据头定义了流头。标准.NET可执行文件具有以下流:#GUID、#Strings、#US、#Blob以及优化的(#~)或未优化的(#-)元数据流(见图3)。
元数据流引用#GUID、#Strings、#Blob中的数据,并指向IL代码。IL代码本身可能引用#US堆上的用户定义字符串。
元数据头中的第一个检测机会出现,因为混淆器可能会添加无效流,例如两个同名的流,或规范中未定义的流名。仅此异常通常不足以检测恶意软件,但可用于构建强大的混淆器检测签名,为逆向工程师和恶意软件分析师提供重要信息。
下表列出了每个流的用途和高级格式描述。在决定YARA签名中使用哪些修饰符和模式时,请参考此表。
流名 | 格式 | 内容 |
---|---|---|
#Blob | 任意大小的二进制对象堆,按4字节边界对齐,每个对象前有压缩长度,字符串通常为UTF-8,例如“\x05Hello” | 默认名称、方法和属性签名、自定义属性(如程序集信息、类型库GUID) |
#GUID | 16字节二进制对象数组 | 全局唯一标识符,如MVID |
#Strings | UTF-8字符串,始终以零字节包围,例如“\x00Hello\x00” | 方法名、类名、字段名、参数名 |
#US | UTF-16字符串堆,前有压缩长度,尾字节为0或1,表示是否至少有一个字符需要两个字节编码 | 用户代码中定义的字符串常量 |
#~ 或 #-- | 元数据表 |
Blob和#US等流在每个流元素前添加压缩长度。压缩长度计算如下(参见[2]第68页):
值范围 | 压缩大小 | 压缩值 |
---|---|---|
0x0 - 0x7F | 1字节 | |
0x80 - 0x3FFF | 2字节 | 0x8000 |
0x4000 - 0x1FFFFFFF | 4字节 | 0xC0000000 |
只要#US字符串和#Blob条目短于128字节,前置的压缩长度与实际长度相同。这可能是恶意软件分析师想要创建的大多数模式的情况。
正是因为前置长度,如果长度恰好是字母数字字符,fullword修饰符可能会阻止匹配。
GUID
我们的示例截图中显示的GUID也称为TypeLib ID,首次由Brian Wallace在其文章“使用.NET GUID帮助狩猎恶意软件”[1]中描述。
Typelib ID由Visual Studio添加,唯一标识一个项目。它保存在#Blob流中,因此始终以其长度0x24(即'$'字符)为前缀。这是一个强大的模式,可以独立存在,并且对重新编译具有鲁棒性。
对于像AgentTesla这样源代码泄露的恶意软件家族,如果目标是检测该家族,则可能不应使用TypeLib ID。
Wallace提到的另一个GUID是#GUID流中的MVID。MVID随重新编译而变化,适用于识别特定样本,例如查看是否重新打包了相同的负载。它不适用于编写重新编译鲁棒的检测签名。
案例1的固定签名
现在我们可以修复基于程序集信息截图的错误YARA签名:
在固定的YARA签名中,我们删除了“AssemblyTitle”和“Guid”字符串,因为这些字符串在元数据表中编码,实际上并不出现在二进制文件中。
此外,我们使用基于表1的格式。$guid和$title字符串来自程序集信息,因此它们保存在#Blob流中,并带有前置的压缩长度。这意味着不需要wide修饰符。
一个常见的陷阱是对#Blob(或#US)字符串使用YARA的fullword修饰符。前置长度可能在字母数字范围内,就像我们示例中的$title一样。它的长度为0x34,恰好也是字符'a'。因此,fullword修饰符会阻止此类字符串的匹配,这不是我们想要的。
通过检查前置长度,我们有一个极好的替代方案来实现fullword修饰符的意图。有三种不同的检查长度的方法:
- 可以直接嵌入字符串模式中(参见$guid和$title)。
- 可以使用十六进制模式(此处未显示),但由于可读性较差,建议用解码字符串的注释补充这些模式。
- 可以在条件中检查长度,这对于仍然可读的宽字符串很有用(参见$url的条件)。
除了可以像fullword修饰符一样工作外,包含前置长度还为签名模式添加了结构上下文。一个意外作为方法名(#Strings流)出现的程序集信息文本可能不是我们要寻找的模式。
为了展示#US字符串和2字节压缩长度的另一个示例,我还添加了$url。这样的下载URL可能在分析报告中提到,这里我们假设它可能被IL代码引用,因此是#US流的一部分。此URL的长度为98个字符,即98*2=96字节(0xC4),因为#US以UTF-16保存它们。此外,#US流条目有一个附加的0x0或0x1,这意味着我们必须将长度增加1字节,现在为0xC5。值0xC5在0x80 – 0x3FFF范围内,因此使用2字节编码此长度。应用公式,我们得到:(0x8000 | 0xC5) = 0x80C5。
错误的$timestamp没有考虑时间戳以小端格式保存。知道此时间戳是PE头的一部分,我们通过将其放置在PE签名的固定偏移处来为模式添加上下文。或者,YARA的“pe”模块解析时间戳——然而,解析的缺点是性能可能更差,并且只能在足够有效的PE映像上运行,但可能无法检测嵌入文件、内存转储或损坏文件中的恶意软件。因此,更通用的选择是基于模式的解决方案。
最后,我们更改$forms字符串,因为“WindowsFormsApp54”、“Program”和“Main”是命名空间、类和方法,它们作为单独的条目保存在#Strings堆中。它们的连接在元数据表中编码,无法通过单个模式覆盖。我们从YARA规则中完全删除“Program”和“Main”,因为它们是相对常见的字符串。“WindowsFormsApp54”是Visual Studio使用的默认名称。除了编程练习外,它在干净文件中应该不常见,加上时间戳,我们可能会找到用于截图的样本。因为“WindowsFormsApp54”保存在#Strings中,所以它以零字节包围。
一个警告:特别是对于威胁狩猎规则,通常必须在没有样本的情况下编写,手动计算压缩长度等细节可能容易出错。但是了解.NET流中使用的底层结构和编码有助于避免我们在错误的狩猎规则中看到的典型错误。当你为生产环境编写实际的检测签名时,这些结构细节很容易提取,并且可以很好地避免误报。
案例2:检测方法和IL代码
对于简单的情况,恶意.NET样本的字符串列表提供了足够的信息来编写YARA签名。然而,混淆使这种方法无法使用,如果它编码用户定义的字符串并替换方法、字段和类名。为了成功为此类文件创建签名,多才多艺的分析师可能需要查看实际的IL代码和方法签名。
我们将为案例2查看的方法如下:
令牌
任何为x86代码编写签名的人都知道,函数或数据位置的地址通常应该被通配以创建鲁棒的模式。这是因为对代码的小更改(如附加变量、函数和指令)也会在重新编译后影响这些地址。
.NET令牌在这方面类似于x86中的地址。就像地址一样,它们的值可能会随着重新编译而改变。然而,它们并不完全相同,通配整个令牌是不推荐的。
.NET程序集中有两种类型的令牌:编码令牌和非编码令牌。非编码令牌是IL代码的一部分。
.NET元数据由许多表组成,这些表定义了类、参数、方法等。令牌引用元数据表中的一行。这意味着它们描述了两个数据点:一个记录标识符,用于指定使用哪一行;一个表索引,用于指示它们引用哪个表。
每个令牌由4个字节组成。第一个字节是表索引,也称为令牌类型。剩余的字节2-4是记录标识符(RID)。虽然第一个字节定义了元数据表,但RID定义了该表中的哪个条目被使用。
为什么表索引也称为令牌类型?这是因为每个元数据表负责存储某种类型的条目。例如,方法保存在mdtMethodDef表中,这意味着指向此表的任何令牌都是方法定义引用,令牌类型为0x06。
令牌类型本身在每个.NET程序集中具有相同的值,这使它们成为编写签名时的重要数据点。下表列出了它们的值(参见[2]第76页)。
| 令牌类型 | 值 (RID | (Type << 24)) |
|---------------------------|--------------------------------|
| mdtModule | 0x00000000 |
| mdtTypeRef | 0x01000000 |
| mdtTypeDef | 0x02000000 |
| mdtFieldDef | 0x04000000 |
| mdtMethodDef | 0x06000000 |
| mdtParamDef | 0x08000000 |
| mdtInterfaceDef | 0x09000000 |
| mdtMemberRef | 0x0A000000 |
| mdtCustomAttribute | 0x0C000000 |
| mdtPermission | 0x0E000000 |
| mdtSignature | 0x11000000 |
| mdtEvent | 0x14000000 |
| mdtProperty | 0x17000000 |
| mdtModuleRef | 0x1A000000 |
| mdtTypeSpec | 0x1B000000 |
| mdtAssembly | 0x20000000 |
| mdtAssemblyRef | 0x23000000 |
| mdtFile | 0x26000000 |
| mdtExportedType | 0x27000000 |
| mdtManifestResource | 0x28000000 |
| mdtGenericParam | 0x2A000000 |
| mdtMethodSpec | 0x2B000000 |
| mdtGenericParamConstraint | 0x2C000000 |
另一方面,RID应该被通配,因为类似于x86中的地址,它们的值可能会在添加或删除表条目以及重新编译样本时发生变化。
IL代码模式和通配符
让我们使用有关令牌的知识来创建IL代码签名。要查看操作码,请在dnSpy中打开一个样本,并选择“IL代码”作为语言。然后复制并粘贴要添加到签名中的代码序列。
我们的Buffer方法的部分输出如下。此代码初始化一个大小为256的数组和一个字典,然后使用Enumerable.Range(0, 256)迭代数组。
/* 0x00000378 2000010000 */ IL_0000: ldc.i4 256
/* 0x0000037D 8D19000001 */ IL_0005: newarr [mscorlib]System.String
/* 0x00000382 0A */ IL_000A: stloc.0
/* 0x00000383 731F00000A */ IL_000B: newobj instance void class [mscorlib]System.Collections.Generic.Dictionary`2<string, uint8>::.ctor()
/* 0x00000388 0B */ IL_0010: stloc.1
/* 0x00000389 16 */ IL_0011: ldc.i4.0
/* 0x0000038A 2000010000 */ IL_0012: ldc.i4 256
/* 0x0000038F 282000000A */ IL_0017: call class [mscorlib] System.Collections.Generic.IEnumerable`1<int32> [System.Core]System.Linq.Enumerable::Range(int32, int32)
操作码部分是此列表中的第二列。例如,最后一个调用Range(0,256)的指令具有以下十六进制字节序列:
28 20 00 00 0A
第一个字节0x28是调用指令的操作码。
接下来的三个字节0x20 0x00 0x00是RID,因为令牌以小端格式保存。最后一个字节0x0A是令牌类型mdtMemberRef。
这意味着对于此调用指令,我们通配字节2-4,因为我们希望保留调用成员引用的信息。生成的子模式将如下所示:
28 ?? ?? ?? 0A
IL代码的完整YARA签名可能如下所示:
请注意,我们保留了数组大小和Range(0,256)调用的整数值。根据上下文和这些值变化的概率,可能需要通配这些值。表示加密密钥、活动ID或版本号的值通常会发生变化。
一些文章建议通配包括令牌类型在内的完整令牌,然而,这样做通常没有优势。相反,除了丢失类型信息外,如果剩余的字节序列长度不足,这可能会导致性能不佳。对于YARA模式,建议至少4个连续字节没有通配符,因为YARA的搜索算法首先使用4字节子字符串(称为原子)进行扫描(参见[4])。
检测方法的每个部分
方法由主体、方法名称、参数名称和签名组成。在.NET程序集中,这些保存在不同的流中,因此位于程序集的不同位置。
假设我们希望在YARA签名中使用所有这些信息。
首先,方法的名称以及参数名称保存在#Strings流中。因此,我们知道方法名称和参数名称将以零字节包围并以UTF-8格式保存。这不仅在我们仅基于截图编写签名时有用,而且在样本可用时节省时间,因为我们不需要在十六进制编辑器中查找这些名称的表示形式。
其次,任何由IL代码引用的字符串都以UTF-16编码保存在#US堆中。我们已经在案例1中讨论了#String和#US字符串。
第三,方法的主体是实际的IL代码。我们在上一节中讨论了这部分。
最后,方法签名保存在#Blob流中。此上下文中的方法签名指的是方法期望的调用约定、参数类型和返回类型,不应与检测签名混淆。这种签名的构建如下:
method_sig ::= <callconven_method> <num_of_args> <return_type> [<arg_type>[,<arg_type>]*]
Ildasm.exe显示方法签名的字节序列。使用方法完全限定名称,显示字节序列的合适命令是:
ILDasm.exe /text /bytes /nobar /item="ns11.Class9::method_22" sample
以下显示了一个示例输出,其中最后一行是方法签名的字节序列:
.method public hidebysig instance void
method_22(class [System.Drawing]System.Drawing.Imaging.BitmapData Param_55,
class [mscorlib]System.IO.MemoryStream Param_56) cil managed
SIG: 20 02 01 12 29 12 2D
方法签名具有以下含义:
- 0x20是调用约定IMAGE_CEE_CS_CALLCONV_HASTHIS,表示这是一个实例方法,参见[3]和[2]第146页
- 0x02是参数数量,即2
- 0x01是返回类型VOID,参见[2]第141页
- 0x12 0x29是第一个参数,0x12引用CLASS类型(参见[2]第145页),0x29是类引用的编码令牌
- 0x12 0x2D是第二个参数,0x12引用CLASS类型(参见[2]第145页),0x2D是类引用的编码令牌
就像我们在IL代码模式中通配RID一样,我们也应该通配方法签名中的编码令牌。编码令牌是令牌的压缩形式,允许比4字节更小的尺寸。它们不在IL代码中使用,但在方法签名等内部结构中使用。
此外,我们在模式前添加长度0x07,因为每个#Blob条目都需要它。
此方法签名的最终十六进制模式是:
07 20 02 01 12 ?? 12 ??
方法签名模式本身是一个弱数据点。此外,除非扫描引擎解析.NET元数据,否则方法签名无法与方法主体和名称关联。因此,对于纯模式搜索,任何具有相同方法签名的方法都会匹配。因此,为YARA规则添加上下文是有用的,但单独使用肯定不够。
案例2的最终签名
基于上一节的知识,我们为Buffer方法的YARA签名添加了一些字符串:
代码引用了字符串“X2”。尽管它只包含2个字符,但我们通过使用#US元素有前置长度和尾随0的知识,增加了$us_string模式长度。
此外,我们为此练习包含了类名和命名空间。
我们通过以下方式提取方法签名:
ildasm.exe /text /bytes /nobar /item="WindowsFormsApp54.Small::Buffer"
方法签名的字节序列0x05 0x00 0x01 0x1D 0x05 0x0E组成如下:
- 0x05是前置长度5
- 0x00是默认调用约定IMAGE_CEE_CS_CALLCONV_DEFAULT,参见[3]和[2]第146页
- 0x01是参数数量
- 0x1D表示返回类型是SZARRAY,参见[2]第137页
- 0x05表示数组的基础类型是byte,参见[2]第134页
- 0x0E表示第一个参数是string类型,参见[2]第145页
不需要通配,因为没有编码令牌存在。
.NET YARA签名技巧
了解内部结构有助于为签名添加上下文。这导致更准确和鲁棒的检测签名,因为我们增加了模式嵌入正确结构的机会。
此外,它增加了我们在YARA中的表达能力,在处理缺失信息时带来更大的灵活性和更少的错误。它还提高了效率,因为我们不需要在十六进制编辑器中查找正确的格式。
这不仅适用于.NET。其他类型的签名,如CPython字节码的签名,也受益于考虑其文件和数据结构的价值。
可读性和可维护性的价值不应被低估。需要逆向工程样本代码以确定它们检测内容的签名,通常需要与从头编写类似签名相同的时间进行质量检查和维护。IL代码的YARA字节模式应始终包含检测到的IL代码的反汇编或反编译注释。
参考文献
[1] Brian Wallace, 2015, “Using .NET GUIDs to help hunt for malware”, VirusBulletin
[2] Serge Lidin, 2014, “.NET IL Assembler”, Apress
[3] https://learn.microsoft.com/en-us/dotnet/framework/unmanaged-api/metadata/corcallingconvention-enumeration
样本哈希
f9ee3eff3345ea280c01d5fce5461b24c537cf6c3dfadc626ef73eed815c2008
更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)
公众号二维码