100天掌握YARA:编写.NET恶意软件特征规则
当YARA规则仅依赖字符串匹配时,其对.NET程序集的检测能力非常有限。我们将探索更多检测机会,包括IL代码、方法签名定义和特定自定义属性。了解底层.NET元数据结构、令牌和流有助于构建更精确高效的特征规则,甚至在相关恶意样本不可用时也能发挥作用。
案例1:基于截图的YARA规则
有时恶意软件分析师需要根据文章或社交媒体帖子编写威胁狩猎规则,但手头没有样本。样本可能属于机密信息、未公开分享或无法获取哈希值。虽然这是特定场景,但本文还将教会您如何为.NET特征添加上下文,以及如何在有样本时跳过十六进制编辑器直接选择正确格式。
若只有截图,我们能利用哪些信息?首先,dnSpy会话截图可能显示方法名、参数名、方法标识符和类名。此外还可能包含独特盐值、密码或编码载荷的整数数组。反编译代码也常出现在截图中,但通常无法还原为IL代码模式。我们将讨论如何为每种模式选择适当格式。
不了解.NET内部机制的分析师可能会编写如下规则。为确保考虑字符串的不同编码,他们可能对所有字符串应用ascii和wide修饰符。
⚠️ 为避免博客被检测,YARA规则以纯文本形式提供
|
|
但该规则不会匹配样本,因为这些模式存在常见缺陷。在讨论.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 | 默认名称、方法和属性签名、自定义属性(如程序集信息、类型库GUID) |
#GUID | 16字节二进制对象数组 | 全局唯一标识符如MVID |
#Strings | UTF-8字符串,始终以零字节包围 | 方法名、类名、字段名、参数名 |
#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)迭代数组。
|
|
操作码部分是此列表中的第二列。例如,最后一个调用Range(0,256)的指令有以下十六进制字节序列:
|
|
第一个字节0x28是call指令的操作码。接下来的三个字节0x20 0x00 0x00是RID,因为令牌以小端格式保存。最后一个字节0x0A是令牌类型mdtMemberRef。
这意味着对于此call指令,我们通配2-4字节,因为我们想保留调用成员引用的信息。生成的子模式如下:
|
|
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流中。上下文中的方法签名指方法期望的调用约定、参数类型和返回类型,不应与检测规则混淆。此类方法签名的构建如下:
|
|
Ildasm.exe显示方法签名的字节序列。使用方法完全限定名,显示字节序列的合适命令是:
|
|
以下示例输出显示最后一行中的方法签名字节序列:
|
|
方法签名含义如下:
- 0x20是调用约定IMAGE_CEE_CS_CALLCONV_HASTHIS,表示这是一个实例方法
- 0x02是参数数量,即2
- 0x01是返回类型VOID
- 0x12 0x29是第一个参数,0x12引用CLASS类型,0x29是类引用的编码令牌
- 0x12 0x2D是第二个参数,0x12引用CLASS类型,0x2D是类引用的编码令牌
正如我们在IL代码模式中通配RID一样,我们也应该通配方法签名中的编码令牌。编码令牌是令牌的压缩形式,允许比4字节更小的尺寸。它们不用于IL代码,但用于内部结构如方法签名。
此外,我们在模式前添加长度0x07,因为每个#Blob条目都需要它。
此方法签名的最终十六进制模式是:
|
|
方法签名模式本身是一个弱数据点。此外,除非扫描引擎解析.NET元数据,否则方法签名不能与方法主体和名称关联。因此,对于纯模式搜索,任何具有相同方法签名的方法都会匹配。因此,它对为YARA规则添加上下文很有用,但肯定不足以独立使用。
案例2的最终规则
基于上一节的知识,我们为Buffer方法的YARA规则添加更多字符串:
|
|
代码引用字符串"X2”。虽然它只有2个字符,但我们利用#US元素有前置长度和尾随0的知识,适当增加了$us_string模式长度。
此外,我们为此练习包含类名和命名空间。
我们通过以下命令提取方法签名:
|
|
方法签名的字节序列0x05 0x00 0x01 0x1D 0x05 0x0E组成如下:
- 0x05是前置长度5
- 0x00是默认调用约定IMAGE_CEE_CS_CALLCONV_DEFAULT
- 0x01是参数数量
- 0x1D表示返回类型是SZARRAY
- 0x05表示数组基础类型是byte
- 0x0E表示第一个参数是string类型
不需要通配符,因为没有编码令牌存在。
.NET YARA规则技巧
了解内部结构有助于为规则添加上下文。这会产生更准确和鲁棒的检测规则,因为我们增加了模式嵌入正确结构的可能性。
此外,它增加了我们在YARA中的表达能力,在处理缺失信息时带来更多灵活性和更少错误。它还提高了效率,因为我们不需要在十六进制编辑器中查找正确格式。
这不仅适用于.NET。其他类型的规则,如CPython字节码的规则,也能从考虑其文件和数据结构中受益。
可读性和可维护性的价值不应低估。需要逆向工程样本代码以确定其检测内容的规则,通常需要与从头编写类似规则相同的质量检查和时间维护。IL代码的YARA字节模式应始终包含检测到的IL代码的反汇编或反编译注释。
参考文献
[1] Brian Wallace, 2015, “使用.NET GUID帮助搜寻恶意软件”, VirusBulletin
[2] Serge Lidin, 2014, “.NET IL汇编器”, Apress
[3] https://learn.microsoft.com/en-us/dotnet/framework/unmanaged-api/metadata/corcallingconvention-enumeration
样本哈希
f9ee3eff3345ea280c01d5fce5461b24c537cf6c3dfadc626ef73eed815c2008