100天YARA挑战:如何编写.NET代码签名
阅读时间:13分钟(3548词)
如果YARA签名仅依赖字符串来检测.NET程序集,其检测能力将非常有限。我们探索了更多检测机会,包括IL代码、方法签名定义和特定的自定义属性。了解底层.NET元数据结构、令牌和流有助于编写更精确高效的签名,即使在相关恶意软件样本不可用的情况下也能发挥作用。
案例1:基于屏幕截图的YARA签名
偶尔,恶意软件分析师需要基于文章或社交媒体帖子编写威胁狩猎或检测签名,而手头没有样本。样本可能是机密的、未在公共分享网站上提供,或者哈希值不可用。尽管这是特定用例,但本文还将教您如何为.NET签名添加上下文,以及在样本存在时如何选择正确格式而无需使用十六进制编辑器作为中间步骤。
如果只有屏幕截图,可以使用哪些信息?
首先,dnSpy会话的屏幕截图可能显示方法名称、参数名称、方法标识符和类名称。此外,可能还有包含独特盐值、密码或编码有效载荷的整数数组。反编译的代码通常也可见于屏幕截图中,但通常无法还原为IL代码模式。我们将讨论如何为每种模式选择适当的格式。
类似以下的屏幕截图显示了程序集信息,这些信息在内部保存为自定义属性。
图1:dnSpy中恶意软件样本的程序集信息截图
不了解.NET内部结构的分析师可能会编写如下签名。为了确保考虑字符串的不同编码,他们可能对所有字符串应用ascii和wide修饰符。
⚠️YARA规则以纯文本形式提供,以避免博客被检测并防止URL意外。
图2:有错误的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堆上的用户定义字符串。
图3:元数据头和.NET流
元数据头中出现了首次检测机会,因为混淆器可能添加无效流,例如两个同名流,或规范中未定义的流名。仅此异常通常不足以检测恶意软件,但可用于编写强大的混淆器检测签名,为逆向工程师和恶意软件分析师提供重要信息。
下表描述了每个流的用途和高级格式描述。在决定YARA签名中使用哪些修饰符和模式时,请以此作为参考。
流名 | 格式 | 内容 |
---|---|---|
#Blob | 任意大小二进制对象的堆,按4字节边界对齐,每个对象前有压缩长度,字符串通常为UTF-8,例如"\x05Hello" | 保存包括:默认名称、方法和属性签名、自定义属性(例如程序集信息、类型库guid) |
#GUID | 16字节二进制对象数组 | 全局唯一标识符,如MVID |
#Strings | UTF-8字符串,始终由零字节包围,例如"\x00Hello\x00" | 方法名、类名、字段名、参数名 |
#US | UTF-16字符串堆,前有压缩长度,尾字节为0或1,指示是否至少有一个字符需要两个字节编码,例如对于"\x05H\x00E\x00\x00"尾字节为0x00,但对于"\x05\xCA\xFE\xBA\xBE\x01"必须为0x01 | 用户代码中定义的字符串常量 |
#~ 或 #- | 元数据表 |
某些流(#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签名:
图4:有错误的YARA签名供比较
图5:基于程序集信息的修复YARA签名和演示压缩长度检查的附加url
在修复的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查看的方法如下:
图6:要检测的反编译方法
令牌
任何为x86代码编写签名的人都知道,函数或数据位置的地址通常应该被通配以创建鲁棒的签名模式。这是因为对代码的微小更改(如附加变量、函数和指令)也会在重新编译后影响这些地址。
在这方面,.NET令牌类似于x86中的地址。就像地址一样,它们的值可能随重新编译而变化。然而,它们并不完全相同,不建议通配整个令牌。
.NET程序集中有两种类型的令牌:编码令牌和非编码令牌。非编码令牌是IL代码的一部分。
.NET元数据由许多表组成,这些表定义类、参数、方法等。令牌引用元数据表中的行。这意味着它们描述两个数据点:指定使用哪行的记录标识符和指示引用哪表的表索引。
每个令牌由4字节组成。第一个字节是表索引,也称为令牌类型。剩余字节2-4是记录标识符(RID)。第一个字节定义元数据表,RID定义使用此表中的哪个条目。
为什么表索引也称为令牌类型?这是因为每个元数据表负责存储特定类型的条目。例如,方法保存在表mdtMethodDef中,这意味着指向此表的任何令牌都是方法定义引用,令牌类型为0x06。
令牌类型本身在每个.NET程序集中具有相同的值,使其成为编写签名时的重要数据点。下表列出它们的值(见[2]第76页)。
令牌类型 | 值(RID | (类型 « 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。
这意味着对于此调用指令,我们通配字节2-4,因为我们希望保留调用成员引用的信息。生成的子模式将如下所示:
|
|
IL代码的完整YARA签名可能如下所示:
图7:带有令牌RID通配符的IL代码签名
注意我们保留了数组大小和Range(0,256)调用的整数值。根据上下文和这些值更改的概率,此类值可能也需要通配。表示加密密钥、活动ID或版本号的值经常发生变化。
一些文章建议通配包括令牌类型在内的完整令牌,然而,这样做通常没有优势。相反,除了丢失类型信息外,如果剩余字节序列没有足够长度,这可能导致性能不佳。对于YARA模式,建议至少4个连续字节没有通配符,因为YARA的搜索算法首先使用4字节子字符串(称为原子)进行初步扫描(见[4])。
检测方法的每个部分
方法由主体、方法名、参数名和签名组成。在.NET程序集中,这些保存在不同的流中,因此位于程序集的不同位置。
图8:保存.NET方法数据的位置
假设我们想使用所有这些信息进行YARA签名。
首先,方法名以及参数名保存在#Strings流中。因此,我们知道方法名和参数名将由零字节包围并以UTF-8格式保存。这不仅在我们仅基于屏幕截图编写签名时有用,而且在样本可用时节省时间,因为我们不需要在十六进制编辑器中查找这些名称的表示形式。
其次,IL代码引用的任何字符串以UTF-16编码存储在#US堆中。我们已经在案例1中讨论了#String和#US字符串。
第三,方法的主体是实际IL代码。我们在上一节讨论了此部分。
最后,方法签名保存在#Blob流中。上下文中的方法签名指方法期望的调用约定、参数类型和返回类型,不应与检测签名混淆。此类方法签名的构建如下:
|
|
Ildasm.exe显示方法签名的字节序列。使用方法的完全限定名,显示字节序列的合适命令是:
|
|
以下显示示例输出,最后一行是方法签名的字节序列:
|
|
方法签名具有以下含义:
- 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条目都需要它。
此方法签名的最终十六进制模式是:
|
|
方法签名模式本身是弱数据点。此外,除非扫描引擎解析.NET元数据,方法签名无法与方法主体和名称关联。因此,对于纯模式搜索,任何具有相同方法签名的方法都会匹配。因此,向YARA规则添加上下文是有用的,但肯定不足以独立工作。
案例2的最终签名
基于前一节的知识,我们向Buffer方法的YARA签名添加更多字符串:
图9:带有IL代码模式和用于存储.NET方法数据的各种流模式的YARA签名
代码引用字符串"X2”。虽然它仅由2个字符组成,但我们通过使用#US元素有前置长度和此情况下尾随0的知识,充分增加了$us_string模式长度。
此外,我们为此练习包括类名和命名空间。
我们通过以下方式提取方法签名:
|
|
方法签名的字节序列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, “使用.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