恶意软件分析学习指南(一)
原文:
annas-archive.org/md5/6464eec061058ae554d0950e983941aa译者:飞龙
前言
计算机和互联网技术的进步改变了我们的生活,并且彻底改革了组织进行商业活动的方式。然而,技术的演变和数字化也带来了网络犯罪活动。对关键基础设施、数据中心以及私营/公共部门、国防、能源、政府和金融领域的网络攻击威胁日益增加,这给从个人到大公司各方都带来了独特的挑战。这些网络攻击利用恶意软件(也称为恶意程序)进行财务盗窃、间谍活动、破坏、知识产权盗窃和政治动机。
随着对手变得更加复杂并进行先进的恶意软件攻击,检测和响应此类入侵对于网络安全专业人员至关重要。恶意软件分析已成为应对高级恶意软件和定向攻击的必备技能。恶意软件分析需要对多种不同技能和学科有均衡的知识。换句话说,学习恶意软件分析需要时间并且需要耐心。
本书教授了使用恶意软件分析来理解 Windows 恶意软件行为和特征的概念、工具和技术。本书首先介绍了恶意软件分析的基本概念,然后逐步深入到更高级的代码分析和内存取证概念。为了帮助你更好地理解这些概念,书中通过实际的恶意软件样本、感染的内存镜像和可视化图表来展示示例。此外,还提供了足够的信息来帮助你理解所需的概念,并且在可能的情况下,提供了额外资源的参考,以供进一步阅读。
如果你是恶意软件分析领域的新手,本书应该能帮助你入门;如果你在该领域已有经验,本书将进一步提升你的知识。无论你是为了进行取证调查、响应事件,还是为了兴趣而学习恶意软件分析,本书都能帮助你实现目标。
本书适合谁
如果你是一个事件响应人员、网络安全调查员、系统管理员、恶意软件分析员、取证专家、学生,或者是对学习或提高恶意软件分析技能感兴趣的安全专业人员,那么本书适合你。
本书内容
第一章,恶意软件分析简介,向读者介绍了恶意软件分析的概念、恶意软件分析的类型,以及如何建立一个隔离的恶意软件分析实验环境。
第二章,静态分析,教授从恶意二进制文件中提取元数据的工具和技术。它展示了如何比较和分类恶意软件样本。你将学习如何在不执行程序的情况下确定二进制文件的各个方面。
第三章,动态分析,教授确定恶意软件行为及其与系统交互的工具和技术。你将学习如何获取与恶意软件相关的网络和主机指示器。
第四章,汇编语言与反汇编基础,提供汇编语言的基本理解,并教授进行代码分析所需的基本技能。
第五章,使用 IDA 反汇编,介绍IDA Pro反汇编器的功能,你将学习如何使用IDA Pro进行静态代码分析(反汇编)。
第六章,调试恶意二进制文件,教授使用x64dbg和IDA Pro调试器调试二进制文件的技术。你将学习如何使用调试器控制程序的执行并操纵程序的行为。
第七章,恶意软件功能与持久性,描述了使用逆向工程分析恶意软件的各种功能。还涉及恶意程序使用的各种持久性方法。
第八章,代码注入与挂钩,教授恶意程序常用的代码注入技术,如何在合法进程中执行恶意代码。还介绍了恶意软件使用的挂钩技术,通过这些技术恶意代码能够重定向控制到恶意代码,以监控、阻止或过滤 API 的输出。你将学习如何分析使用代码注入和挂钩技术的恶意程序。
第九章,恶意软件混淆技术,涵盖恶意程序用来隐藏信息的编码、加密和包装技术。它教授了不同的策略来解码/解密数据并解包恶意二进制文件。
第十章,使用内存取证狩猎恶意软件,介绍了使用内存取证检测恶意组件的技术。你将学习使用不同的 Volatility 插件来检测和识别内存中的取证痕迹。
第十一章,使用内存取证检测高级恶意软件,介绍了高级恶意软件用来躲避取证工具的隐匿技巧。你将学习如何调查和检测用户模式和内核模式的根工具组件。
要最大化利用本书
熟悉编程语言,如 C 和 Python,将会有所帮助(特别是理解第 5、6、7、8 和 9 章中涵盖的概念)。如果你写过一些代码,并对编程概念有基本了解,你将能够最大化地利用本书。
如果你没有编程知识,仍然可以理解第 1、2 和 3 章中涵盖的基本恶意软件分析概念。然而,你可能会觉得理解其余章节中的概念稍显困难。为了帮助你赶上进度,每章都提供了足够的信息和额外的资源,你可能需要额外阅读以完全理解这些概念。
下载彩色图片
我们还提供了一个 PDF 文件,包含本书中使用的截图/图表的彩色图像。你可以在此下载: www.packtpub.com/sites/default/files/downloads/LearningMalwareAnalysis_ColorImages.pdf。
使用的约定
本书中使用了多种文本约定。
CodeInText:用于代码示例、文件夹名称、文件名、注册表键和值、文件扩展名、路径名、虚拟 URL、用户输入、函数名称和 Twitter 账号。例如:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”
任何命令行输入都会以粗体突出显示,示例如下:
$ sudo inetsim
INetSim 1.2.6 (2016-08-29) by Matthias Eckert & Thomas Hungenberg
Using log directory: /var/log/inetsim/
Using data directory: /var/lib/inetsim/
当我们希望引起你对特定代码或输出部分的注意时,相关的行或项会以粗体显示:
$ python vol.py -f tdl3.vmem --profile=WinXPSP3x86 ldrmodules -p 880
Volatility Foundation Volatility Framework 2.6
Pid Process Base InLoad InInit InMem MappedPath
--- ----------- -------- ----- ------- ----- ----------------------------
880 svchost.exe 0x10000000 False False False \WINDOWS\system32\TDSSoiqh.dll
880 svchost.exe 0x01000000 True False True \WINDOWS\system32\svchost.exe
880 svchost.exe 0x76d30000 True True True \WINDOWS\system32\wmi.dll
880 svchost.exe 0x76f60000 True True True \WINDOWS\system32\wldap32.dll
斜体:用于新术语、重要单词或词组、恶意软件名称以及键盘组合。示例:按Ctrl + C复制
屏幕文本:菜单或对话框中的文字会像这样出现在正文中。示例:从管理面板中选择“系统信息”。
警告或重要提示会以这种方式出现。提示和技巧会以这种方式出现。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请通过电子邮件feedback@packtpub.com与我们联系,并在邮件主题中提到书名。如果你对本书的任何内容有疑问,请发送邮件至questions@packtpub.com。
勘误:虽然我们已经尽最大努力确保内容的准确性,但错误仍然会发生。如果您在本书中发现任何错误,我们将不胜感激,如果您能向我们报告。请访问 www.packtpub.com/submit-erra…,选择您的书籍,点击“勘误提交表单”链接,并输入相关详情。
盗版:如果您在互联网上发现任何我们作品的非法复制品,我们将不胜感激,如果您能提供该素材的地址或网站名称。请通过copyright@packtpub.com与我们联系,并附上该资料的链接。
如果您有兴趣成为作者:如果您在某个领域拥有专业知识,并且有兴趣写作或为书籍贡献内容,请访问 authors.packtpub.com。
评论
请留下评论。阅读并使用本书后,为什么不在您购买书籍的网站上留下您的评论呢?潜在读者可以看到并参考您的公正意见来做出购买决策,我们也能了解您对我们产品的看法,而我们的作者也能看到您对其书籍的反馈。谢谢!
若想了解更多有关 Packt 的信息,请访问 packtpub.com。
第一章:恶意软件分析简介
网络攻击数量无疑在增加,攻击目标包括政府、军事、公共和私营部门。这些网络攻击集中在攻击个人或组织,企图窃取有价值的信息。有时,这些网络攻击被认为与网络犯罪或国家支持的团体有关,但也可能是由个人团体为实现目标而实施的。这些网络攻击大多数使用恶意软件(也称为恶意程序)来感染目标。分析恶意软件所需的知识、技能和工具对于检测、调查和防御此类攻击至关重要。
在本章中,你将学习以下内容:
-
恶意软件的含义及其在网络攻击中的作用
-
恶意软件分析及其在数字取证中的重要性
-
不同类型的恶意软件分析
-
设置实验室环境
-
获取恶意软件样本的各种来源
1. 什么是恶意软件?
恶意软件是执行恶意操作的代码;它可以是可执行文件、脚本、代码或任何其他软件。攻击者使用恶意软件来窃取敏感信息、监视感染的系统或控制该系统。它通常未经你同意便进入你的系统,可以通过多种通信渠道传播,如电子邮件、网页或 USB 驱动器。
以下是恶意软件执行的一些恶意操作:
-
干扰计算机操作
-
窃取敏感信息,包括个人、商业和财务数据
-
未经授权访问受害者的系统
-
监视受害者
-
发送垃圾邮件
-
参与分布式拒绝服务攻击(DDOS)
-
锁定计算机上的文件并勒索赎金
恶意软件是一个广泛的术语,指的是不同类型的恶意程序,如特洛伊木马、病毒、蠕虫和 Rootkit。在进行恶意软件分析时,你经常会遇到各种类型的恶意程序;这些恶意程序通常根据其功能和攻击方式进行分类,如下所示:
-
病毒或蠕虫:能够自我复制并传播到其他计算机的恶意软件。病毒需要用户干预,而蠕虫可以在没有用户干预的情况下传播。
-
特洛伊木马:一种伪装成普通程序的恶意软件,诱使用户将其安装到系统中。安装后,它可以执行恶意操作,如窃取敏感数据、将文件上传到攻击者的服务器或监控摄像头。
-
后门 / 远程访问木马(RAT):这是一种特洛伊木马,它允许攻击者访问并执行被攻击系统上的命令。
-
广告软件:一种向用户展示不需要的广告(广告)的恶意软件。通常通过免费下载传播,并可以强制在你的系统上安装软件。
-
僵尸网络:这是一个由感染了相同恶意软件(称为僵尸)的计算机组成的群体,等待从攻击者控制的指挥与控制服务器接收指令。攻击者可以向这些僵尸发送命令,执行恶意活动,如分布式拒绝服务(DDOS)攻击或发送垃圾邮件。
-
信息窃取器:旨在窃取敏感数据(如银行凭证或键盘输入)从被感染系统的恶意软件。这些恶意程序的示例包括键盘记录器、间谍软件、嗅探器和表单抓取器。
-
勒索软件:通过将用户锁定在计算机外或加密文件来控制系统的恶意软件。
-
Rootkit(根套件):恶意软件,能够为攻击者提供对被感染系统的特权访问,并隐藏其自身或其他软件的存在。
-
下载器或投放器:旨在下载或安装额外恶意软件组件的恶意软件。
有一个便捷的资源可以帮助了解恶意软件术语和定义,访问链接:blog.malwarebytes.com/glossary/。
基于功能对恶意软件进行分类可能并不总是可行的,因为单一恶意软件可能包含多个功能,而这些功能可能属于刚才提到的多个类别。例如,恶意软件可能包括一个蠕虫组件,扫描网络寻找易受攻击的系统,并在成功利用后投放另一个恶意软件组件,如后门或勒索软件。
恶意软件分类也可以根据攻击者的动机进行。例如,如果恶意软件用于窃取个人、商业或专有信息以获取利润,则可以将其分类为犯罪软件或商品恶意软件。如果恶意软件用于针对特定组织或行业,窃取信息/收集情报以进行间谍活动,则可以将其分类为定向恶意软件或间谍恶意软件。
2. 什么是恶意软件分析?
恶意软件分析是研究恶意软件行为的过程。恶意软件分析的目的是了解恶意软件的工作原理,以及如何检测和消除它。它包括在安全环境中分析可疑的二进制文件,识别其特征和功能,从而构建更好的防御措施以保护组织的网络。
3. 为什么进行恶意软件分析?
执行恶意软件分析的主要动机是从恶意软件样本中提取信息,帮助应对恶意软件事件。恶意软件分析的目标是确定恶意软件的能力,检测它并将其隔离。它还帮助确定可识别的模式,这些模式可以用于治愈和防止未来的感染。以下是你进行恶意软件分析的一些原因:
-
确定恶意软件的性质和目的。例如,它可以帮助你判断恶意软件是否是信息窃取者、HTTP 机器人、垃圾邮件机器人、Rootkit、键盘记录器或 RAT 等。
-
了解系统是如何被攻破的及其影响。
-
识别与恶意软件相关的网络指标,这些指标可以用于通过网络监控检测类似的感染。例如,在你的分析过程中,如果你确定恶意软件与某个特定的域名/IP 地址进行通信,那么你可以使用这个域名/IP 地址创建一个签名,并监控网络流量,以识别所有与该域名/IP 地址通信的主机。
-
提取基于主机的指标,如文件名和注册表键,这些可以用于通过主机监控确定类似的感染。例如,如果你发现某个恶意软件创建了一个注册表键,你可以将这个注册表键作为一个指标,创建一个签名,或者扫描你的网络以识别具有相同注册表键的主机。
-
确定攻击者的意图和动机。例如,在你的分析过程中,如果发现恶意软件窃取银行凭证,那么你可以推断出攻击者的动机是为了经济利益。
威胁情报团队通常使用通过恶意软件分析确定的指标来分类攻击,并将其归类为已知的威胁。恶意软件分析可以帮助你获取关于谁可能是攻击背后的人(竞争对手、国家支持的攻击组织等)的信息。
4. 恶意软件分析类型
要理解恶意软件的工作原理和特征,并评估其对系统的影响,通常需要使用不同的分析技术。以下是这些分析技术的分类:
-
静态分析:这是在不执行二进制文件的情况下分析其过程。它是最容易执行的,允许你提取与可疑二进制文件相关的元数据。静态分析可能不会揭示所有所需的信息,但有时能提供有趣的信息,帮助你确定接下来分析的重点。第二章,静态分析,涵盖了使用静态分析从恶意软件二进制文件中提取有用信息的工具和技术。
-
动态分析(行为分析):这是在隔离环境中执行可疑二进制文件并监控其行为的过程。此分析技术容易执行,并且可以提供二进制文件执行过程中的活动的有价值的洞察。该分析技术有用,但不能揭示恶意程序的所有功能。第三章,动态分析,涵盖了使用动态分析确定恶意软件行为的工具和技术。
-
代码分析:这是一种高级技术,专注于分析代码以理解二进制文件的内部工作原理。这项技术揭示了仅通过静态和动态分析无法得出的信息。代码分析进一步分为静态代码分析和动态代码分析。静态代码分析包括反汇编可疑的二进制文件,并查看代码以理解程序的行为,而动态代码分析则是在受控环境中调试可疑的二进制文件,以理解其功能。代码分析需要了解编程语言和操作系统的概念。接下来的章节(第 4 到第九章)将介绍执行代码分析所需的知识、工具和技术。
-
内存分析(内存取证):这是分析计算机 RAM 中的取证痕迹的技术。通常这是一种取证技术,但将其整合到恶意软件分析中将有助于了解恶意软件感染后的行为。内存分析对于确定恶意软件的隐蔽性和回避能力特别有用。在随后的章节中(第 10 和第十一章),你将学习如何进行内存分析。
在进行恶意软件分析时,整合不同的分析技术可以揭示大量的上下文信息,这对你的恶意软件调查将非常有价值。
5. 设置实验室环境
恶意程序的分析需要一个安全的实验室环境,因为你不希望感染你的系统或生产系统。恶意软件实验室可以根据可用资源(硬件、虚拟化软件、Windows 许可证等)来设立得很简单或很复杂。本节将指导你在单一物理系统上设置一个简单的个人实验室,实验室由*虚拟机(VMs)*组成。如果你希望设置一个类似的实验室环境,可以随时跟着操作,或者跳到下一节(第六部分:恶意软件来源)。
5.1 实验室需求
在开始搭建实验室之前,你需要一些组件:一个运行基础操作系统Linux、Windows或macOS X的物理系统,并安装虚拟化软件(如VMware或VirtualBox)。在分析恶意软件时,你将会在基于 Windows 的虚拟机(Windows VM)上执行恶意软件。使用虚拟机的好处是,在完成恶意软件分析后,你可以将其恢复到干净的状态。
VMware Workstation(适用于 Windows 和 Linux)可从 www.vmware.com/products/workstation/workstation-evaluation.html 下载,VMware Fusion(适用于 macOS X)可从 www.vmware.com/products/fusion/fusion-evaluation.html 下载,适用于不同操作系统版本的 VirtualBox 可从 www.virtualbox.org/wiki/Downloads 下载。
为了创建一个安全的实验室环境,你应该采取必要的预防措施,避免恶意软件从虚拟化环境中逃逸并感染你的物理(主机)系统。以下是设置虚拟化实验室时需要记住的几点:
-
保持你的虚拟化软件更新。这是必要的,因为恶意软件可能会利用虚拟化软件中的漏洞,从虚拟环境逃逸并感染主机系统。
-
在虚拟机(VM)内安装一个全新的操作系统副本,并且不要在虚拟机中存放任何敏感信息。
-
在分析恶意软件时,如果你不希望恶意软件连接到互联网,那么你应该考虑使用 仅主机 网络配置模式,或通过使用模拟服务将你的网络流量限制在实验室环境内。
-
不要连接任何可能后来用于物理机器的可移动媒体,如 USB 驱动器。
-
由于你将分析 Windows 恶意软件(通常是可执行文件或 DLL),建议为主机机器选择一个基础操作系统,如 Linux 或 macOS X,而不是 Windows。这是因为即使 Windows 恶意软件从虚拟机逃逸,它仍然无法感染你的主机机器。
5.2 实验室架构概述
本书中将使用的实验室架构包括一台运行 Ubuntu Linux 的 物理机器(称为主机机器),以及多个 Linux 虚拟机(Ubuntu Linux 虚拟机) 和 Windows 虚拟机(Windows 虚拟机) 实例。这些虚拟机会配置在同一网络中,并使用 仅主机 网络配置模式,以确保恶意软件无法连接互联网,且网络流量被隔离在实验室环境内。
Windows 虚拟机 是分析过程中执行恶意软件的地方,而 Linux 虚拟机 用于监控网络流量,并将配置为模拟互联网服务(如 DNS、HTTP 等),以便在恶意软件请求这些服务时提供适当的响应。例如,Linux 虚拟机会被配置为当恶意软件请求 DNS 服务时,提供正确的 DNS 响应。第三章,动态分析,详细介绍了这一概念。
以下图显示了一个简单的实验室架构示例,我将在本书中使用。在此设置中,Linux 虚拟机 将预配置为 IP 地址 192.168.1.100,而 Windows 虚拟机 的 IP 地址将设置为 192.168.1.x(其中 x 为 1 到 254 之间的任何数字,除了 100)。Windows 虚拟机的默认网关和 DNS 将设置为 Linux 虚拟机的 IP 地址(即 192.168.1.100),以便所有 Windows 网络流量都通过 Linux 虚拟机路由。接下来的部分将指导您设置 Linux 虚拟机和 Windows 虚拟机以匹配此设置。
您不必局限于前面图示的实验室架构;可以有不同的实验室配置,无法为每种可能的配置提供说明。在本书中,我将向您展示如何设置并使用前述图中的实验室架构。
也可以设置一个包含多个运行不同版本 Windows 的虚拟机的实验室;这样可以在不同版本的 Windows 操作系统上分析恶意软件样本。包含多个 Windows 虚拟机的示例配置类似于以下图所示:
5.3 设置和配置 Linux 虚拟机
要设置 Linux 虚拟机,我将使用 Ubuntu 16.04.2 LTS Linux 发行版 (releases.ubuntu.com/16.04/)。我选择 Ubuntu 的原因是,本书中涉及的大多数工具要么已经预安装,要么可以通过 apt-get 包管理器获得。以下是配置 Ubuntu 16.04.2 LTS 在 VMware 和 VirtualBox 上的逐步流程。根据您系统上安装的虚拟化软件(VMware 或 VirtualBox),可以自由遵循此处提供的说明:
如果您不熟悉安装和配置虚拟机,请参考 VMware 的指南:pubs.vmware.com/workstation-12/topic/com.vmware.ICbase/PDF/workstation-pro-12-user-guide.pdf 或 VirtualBox 用户手册 (www.virtualbox.org/manual/UserManual.html)。
-
从
releases.ubuntu.com/16.04/下载 Ubuntu 16.04.2 LTS,并将其安装到 VMware Workstation/Fusion 或 VirtualBox 中。如果您希望安装其他版本的 Ubuntu Linux,只要您能够安装包并解决依赖问题,您可以自由选择。 -
在 Ubuntu 上安装 虚拟化工具;这将允许 Ubuntu 的屏幕分辨率自动调整以匹配你的显示器几何形状,并提供额外的增强功能,例如能够共享剪贴板内容,以及在主机和 Linux 虚拟机 之间进行复制/粘贴或拖放文件。要在 VMware Workstation 或 VMware Fusion 上安装虚拟化工具,你可以按照此链接中的步骤,或观看此视频。安装完成后,重启系统。
-
如果你使用的是 VirtualBox,你必须安装 Guest Additions 软件。为此,在 VirtualBox 菜单中选择 设备 | 插入客户机附加 CD 镜像。这将打开 Guest Additions 对话框。然后点击运行以从虚拟 CD 调用安装程序。提示时输入密码并重启系统。
-
一旦 Ubuntu 操作系统和虚拟化工具安装完成,启动 Ubuntu 虚拟机并安装以下工具和包。
-
安装 pip;pip 是一个包管理系统,用于安装和管理用 Python 编写的包。在本书中,我将运行一些 Python 脚本,其中一些依赖于第三方库。为了自动化安装第三方包,你需要安装 pip。在终端运行以下命令来安装并升级 pip:
$ sudo apt-get update
$ sudo apt-get install python-pip
$ pip install --upgrade pip
以下是本书中将使用的一些工具和 Python 包。要安装这些工具和 Python 包,请在终端中运行以下命令:
$ sudo apt-get install python-magic
$ sudo apt-get install upx
$ sudo pip install pefile
$ sudo apt-get install yara
$ sudo pip install yara-python
$ sudo apt-get install ssdeep
$ sudo apt-get install build-essential libffi-dev python python-dev \ libfuzzy-dev
$ sudo pip install ssdeep
$ sudo apt-get install wireshark
$ sudo apt-get install tshark
- INetSim (
www.inetsim.org/index.html) 是一个强大的工具,可以模拟恶意软件常常需要与之交互的各种互联网服务(例如 DNS 和 HTTP)。稍后,你将了解如何配置 INetSim 来模拟这些服务。要安装 INetSim,请使用以下命令。INetSim 的使用将在第三章中详细介绍,标题为 动态分析。如果你在安装 INetSim 时遇到困难,请参考文档 (www.inetsim.org/packages.html):
$ sudo su
# echo "deb http://www.inetsim.org/debian/ binary/" > \ /etc/apt/sources.list.d/inetsim.list
# wget -O - http://www.inetsim.org/inetsim-archive-signing-key.asc | \
apt-key add -
# apt update
# apt-get install inetsim
- 现在,你可以通过配置虚拟设备使用 Host-only 网络模式来隔离 Ubuntu 虚拟机。在 VMware 中,打开网络适配器设置并选择 Host-only 模式,如下图所示。保存设置并重启虚拟机。
在 VirtualBox 中,关闭 Ubuntu 虚拟机,然后打开设置。选择网络并将适配器设置更改为 Host-only Adapter,如下图所示;点击确定。
在 VirtualBox 中,有时选择 Host-only 适配器选项时,接口名称可能显示为未选择。在这种情况下,你需要首先通过导航到文件|首选项|网络|Host-only 网络|添加 Host-only 网络来创建至少一个 Host-only 接口。点击确定,然后bring 上设置。选择网络并将适配器设置更改为 Host-only 适配器,如下图所示。点击确定。
- 现在我们将为 Ubuntu Linux 虚拟机分配一个静态 IP 地址
192.168.1.100。为此,启动 Linux 虚拟机,打开终端窗口,输入命令ifconfig,并记录下接口名称。在我的情况下,接口名称是ens33。在你的情况下,接口名称可能会有所不同。如果不同,你需要根据以下步骤做出相应的更改**。** 使用以下命令打开/etc/network/interfaces文件:
$ sudo gedit /etc/network/interfaces
在文件末尾添加以下条目(确保将ens33替换为系统中的接口名称),并保存文件:
auto ens33
iface ens33 inet static
address 192.168.1.100
netmask 255.255.255.0
/etc/network/interfaces文件现在应如下所示。新增的条目已在此高亮显示:
# interfaces(5) file used by ifup(8) and ifdown(8)
auto lo
iface lo inet loopback
auto ens33
iface ens33 inet static
address 192.168.1.100
netmask 255.255.255.0
然后重新启动 Ubuntu Linux 虚拟机。此时,Ubuntu 虚拟机的 IP 地址应已设置为192.168.1.100。你可以通过运行以下命令来验证:
$ ifconfig
ens33 Link encap:Ethernet HWaddr 00:0c:29:a8:28:0d
inet addr:192.168.1.100 Bcast:192.168.1.255 Mask:255.255.255.0
inet6 addr: fe80::20c:29ff:fea8:280d/64 Scope:Link
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:21 errors:0 dropped:0 overruns:0 frame:0
TX packets:49 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1000
RX bytes:5187 (5.1 KB) TX bytes:5590 (5.5 KB)
- 下一步是配置INetSim,使其能够在配置的 IP 地址
192.168.1.100上监听并模拟所有服务。默认情况下,它监听本地接口(127.0.0.1),需要将其更改为192.168.1.100。为此,使用以下命令打开位于/etc/inetsim/inetsim.conf的配置文件:
$ sudo gedit /etc/inetsim/inetsim.conf
转到配置文件中的service_bind_address部分,并添加以下条目:
service_bind_address 192.168.1.100
配置文件中添加的条目(高亮显示)应如下所示:
# service_bind_address
#
# IP address to bind services to
#
# Syntax: service_bind_address <IP address>
#
# Default: 127.0.0.1
#
#service_bind_address 10.10.10.1
service_bind_address 192.168.1.100
默认情况下,INetSim 的 DNS 服务器会将所有域名解析到127.0.0.1。我们希望将域名解析到192.168.1.100(Linux 虚拟机的 IP 地址)。为此,转到配置文件中的dns_default_ip部分,并添加如下所示的条目:
dns_default_ip 192.168.1.100
配置文件中添加的条目(在以下代码中高亮显示)应如下所示:
# dns_default_ip
#
# Default IP address to return with DNS replies
#
# Syntax: dns_default_ip <IP address>
#
# Default: 127.0.0.1
#
#dns_default_ip 10.10.10.1
dns_default_ip 192.168.1.100
配置更改完成后,保存配置文件并启动 INetSim 主程序。验证所有服务是否正常运行,并检查inetsim是否在192.168.1.100上监听,如以下代码中高亮显示的那样。你可以通过按CTRL+C停止服务:
$ sudo inetsim
INetSim 1.2.6 (2016-08-29) by Matthias Eckert & Thomas Hungenberg
Using log directory: /var/log/inetsim/
Using data directory: /var/lib/inetsim/
Using report directory: /var/log/inetsim/report/
Using configuration file: /etc/inetsim/inetsim.conf
=== INetSim main process started (PID 2640) ===
Session ID: 2640
Listening on: 192.168.1.100
Real Date/Time: 2017-07-08 07:26:02
Fake Date/Time: 2017-07-08 07:26:02 (Delta: 0 seconds)
Forking services...
* irc_6667_tcp - started (PID 2652)
* ntp_123_udp - started (PID 2653)
* ident_113_tcp - started (PID 2655)
* time_37_tcp - started (PID 2657)
* daytime_13_tcp - started (PID 2659)
* discard_9_tcp - started (PID 2663)
* echo_7_tcp - started (PID 2661)
* dns_53_tcp_udp - started (PID 2642)
[..........REMOVED.............]
* http_80_tcp - started (PID 2643)
* https_443_tcp - started (PID 2644)
done.
Simulation running.
- 有时,你需要在主机和虚拟机之间传输文件。要在VMware上启用此功能,请关闭虚拟机并打开设置。选择选项|访客隔离,然后勾选启用拖放和启用复制粘贴。保存设置。
在 Virtualbox 中,当虚拟机处于关闭状态时,进入设置 | 常规 | 高级,确保共享剪贴板和拖放功能都设置为双向。点击 OK 保存设置。
- 此时,Linux 虚拟机已配置为使用 Host-only 模式,并且 INetSim 已设置为模拟所有服务。最后一步是创建一个快照(干净快照),并给它取一个你选择的名称,这样你在需要时可以将其恢复到干净的状态。在 VMware Workstation 中,点击 VM | Snapshot | Take Snapshot 来创建快照。在 VirtualBox 中,同样可以通过点击 Machine | Take Snapshot 来完成。
除了 拖放 功能外,还可以通过共享文件夹将文件从宿主机传输到虚拟机;有关 VirtualBox 的说明,请参考以下链接(www.virtualbox.org/manual/ch04.html#sharedfolders),有关 VMware 的说明,请参考以下链接(docs.vmware.com/en/VMware-Workstation-Pro/14.0/com.vmware.ws.using.doc/GUID-AACE0935-4B43-43BA-A935-FC71ABA17803.html)。
5.4 设置和配置 Windows 虚拟机
在设置 Windows 虚拟机之前,首先需要在虚拟化软件(如 VMware 或 VirtualBox)中安装你选择的 Windows 操作系统(例如 Windows 7、Windows 8 等)。安装 Windows 后,按照以下步骤操作:
- 从
www.python.org/downloads/下载 Python。确保下载 Python 2.7.x 版本(例如 2.7.13);本书中使用的大多数脚本是为 Python 2.7 版本编写的,可能无法在 Python 3 中正确运行。下载文件后,运行安装程序。确保勾选安装 pip 以及将 python.exe 添加到路径中的选项,如下截图所示。安装 pip 可以方便地安装任何第三方 Python 库,添加 Python 到路径中则可以在任何位置运行 Python。
-
将 Windows 虚拟机配置为使用 Host-only 网络配置模式。要在 VMware 或 VirtualBox 中做到这一点,请打开网络设置并选择 Host-only 模式;保存设置后重启虚拟机 (此步骤类似于 设置和配置 Linux 虚拟机 部分的内容)。
-
将 Windows 虚拟机的 IP 地址配置为
192.168.1.x(选择任何 IP 地址,但不能是192.168.1.100,因为该 IP 已配置给 Linux 虚拟机使用),并将默认网关和 DNS 服务器设置为 Linux 虚拟机的 IP 地址(即192.168.1.100),如下面的截图所示*。* 这样配置是为了在我们执行 Windows 虚拟机上的恶意程序时,所有的网络流量都会通过 Linux 虚拟机进行转发。
- 启动 Linux 虚拟机和 Windows 虚拟机,并确保它们可以相互通信。您可以通过运行 ping 命令来检查连接性,如此屏幕截图所示:
- Windows Defender 服务需要在您的 Windows 虚拟机上禁用,因为在执行恶意软件样本时可能会产生干扰。要做到这一点,请按下Windows 键 + R打开运行菜单,输入gpedit.msc,然后按Enter启动本地组策略编辑器。在本地组策略编辑器的左侧窗格中,导航至计算机配置 | 管理模板 | Windows 组件 | Windows Defender。在右侧窗格中,双击“关闭 Windows Defender 策略”进行编辑;然后选择启用并点击确定:
-
为了能够在主机机器和 Windows 虚拟机之间传输文件(拖放)和复制剪贴板内容,请按照Linux 虚拟机设置和配置部分的第 7 步中提到的说明进行操作。
-
创建一个干净的快照,以便在每次分析后恢复到原始/干净状态。拍摄快照的步骤在Linux 虚拟机设置和配置部分的第 10 步中已经介绍过。
此时,您的实验环境应该已经准备就绪。您的干净快照中的 Linux 和 Windows 虚拟机应该处于仅主机网络模式,并且应该能够彼此通信。在本书中,我将介绍各种恶意软件分析工具;如果您希望使用这些工具,您可以将它们复制到虚拟机上的干净快照中。为了保持您的干净快照最新,只需将这些工具传输/安装到虚拟机上,并创建一个新的干净快照。
6. 恶意软件来源
一旦您建立了实验室,您将需要恶意软件样本进行分析。在本书中,我在示例中使用了各种恶意软件样本,由于这些样本来自真实攻击,我决定不随书分发它们,因为分发此类样本可能存在法律问题。您可以通过搜索各种恶意软件存储库来找到它们(或类似样本)。以下是一些可以获取用于分析的恶意软件样本的来源。其中一些来源允许您免费下载恶意软件样本(或免费注册后),而一些则要求您联系所有者建立账户,之后您将能够获取样本:
-
Hybrid Analysis:
www.hybrid-analysis.com/ -
KernelMode.info:
www.kernelmode.info/forum/viewforum.php?f=16 -
VirusBay:
beta.virusbay.io/ -
Contagio malware dump:
contagiodump.blogspot.com/ -
AVCaesar:
avcaesar.malware.lu/ -
Malwr:
malwr.com/ -
VirusShare:
virusshare.com/ -
theZoo:
thezoo.morirt.com/
你可以在 Lenny Zeltser 的博客文章中找到指向其他各种恶意软件来源的链接,网址为zeltser.com/malware-sample-sources/。
如果上述方法都不适用,并且你希望获取本书中使用的恶意软件样本,请随时联系作者。
概述
在分析恶意程序之前,建立一个隔离的实验室环境至关重要。在进行恶意软件分析时,你通常会运行敌对代码以观察其行为,因此,拥有一个隔离的实验室环境将防止恶意代码意外传播到你的系统或网络中的生产系统。在下一章中,你将学习如何使用静态分析提取恶意软件样本中的有价值信息。
第二章:静态分析
静态分析是分析可疑文件的一种技术,无需执行它。这是一种初步的分析方法,涉及从可疑二进制文件中提取有用的信息,以便做出明智的决策,决定如何分类或分析它,以及接下来分析工作的重点。本章将介绍多种工具和技术,用于从可疑二进制文件中提取有价值的信息。
本章将学习以下内容:
-
识别恶意软件的目标架构
-
病毒指纹识别
-
使用杀毒引擎扫描可疑二进制文件
-
提取与文件相关的字符串、函数和元数据
-
识别用来阻碍分析的混淆技术
-
分类和比较恶意软件样本
这些技术可以揭示关于文件的不同信息。并不要求必须遵循所有这些技术,也不需要按呈现的顺序来执行。使用哪些技术取决于你的目标和可疑文件周围的上下文。
1. 确定文件类型
在分析过程中,确定可疑二进制文件的文件类型将帮助你识别恶意软件的目标操作系统(如 Windows、Linux 等)和架构(32 位或 64 位平台)。例如,如果可疑的二进制文件是Portable Executable(PE)文件类型,这是 Windows 可执行文件(.exe、.dll、.sys、.drv、.com、.ocx等)的文件格式,那么你可以推测该文件是为 Windows 操作系统设计的。
大多数基于 Windows 的恶意软件都是以.exe、.dll、.sys等扩展名结尾的可执行文件。但仅仅依靠文件扩展名是不推荐的。文件扩展名并不是文件类型的唯一指标。攻击者通过修改文件扩展名和改变文件外观,利用各种技巧隐藏其文件,诱使用户执行它。与其依赖文件扩展名,不如使用文件签名来确定文件类型。
文件签名是写入文件头部的唯一字节序列。不同的文件具有不同的签名,可以用来识别文件的类型。Windows 可执行文件,也叫做PE 文件(例如以.exe、.dll、.com、.drv、.sys等结尾的文件),其文件签名是MZ或十六进制字符4D 5A,位于文件的前两个字节。
一个方便的资源可以帮助确定不同文件类型的文件签名,基于它们的扩展名,网址为www.filesignatures.net/。
1.1 使用手动方法识别文件类型
确定文件类型的手动方法是通过在十六进制编辑器中打开文件查找 文件签名。十六进制编辑器 是一种工具,它允许检查人员检查文件的每个字节;大多数十六进制编辑器提供许多功能,帮助分析文件。以下截图展示了使用 HxD 十六进制编辑器(mh-nexus.de/en/hxd/)打开可执行文件时,前两个字节中的文件签名 MZ:
在选择 Windows 的十六进制编辑器时,你有很多选项;这些十六进制编辑器提供不同的功能。有关各种十六进制编辑器的列表和比较,请参阅此链接:
en.wikipedia.org/wiki/Comparison_of_hex_editors。
在 Linux 系统中,要查找文件签名,可以使用 xxd 命令,它会生成文件的十六进制转储,如下所示:
$ xxd -g 1 log.exe | more
0000000: 4d 5a 90 00 03 00 00 00 04 00 00 00 ff ff 00 00 MZ..............
0000010: b8 00 00 00 00 00 00 00 40 00 00 00 00 00 00 00 ........@.......
0000020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0000030: 00 00 00 00 00 00 00 00 00 00 00 00 e8 00 00 00 ................
1.2 使用工具识别文件类型
另一种便捷的确定文件类型的方法是使用文件识别工具。在 Linux 系统中,可以使用 file 实用程序来实现。以下示例中,file 命令被应用于两个不同的文件。从输出结果可以看出,尽管第一个文件没有任何扩展名,但它被识别为一个 32 位可执行文件(PE32),而第二个文件则是一个 64 位(PE32+)可执行文件:
$ file mini
mini: PE32 executable (GUI) Intel 80386, for MS Windows
$ file notepad.exe
notepad.exe: PE32+ executable (GUI) x86-64, for MS Windows
在 Windows 上,CFF Explorer(属于 Explorer Suite)(www.ntcore.com/exsuite.php) 可以用来确定文件类型;它不仅限于确定文件类型,还可以作为一个出色的工具来检查可执行文件(包括 32 位和 64 位),并允许你检查 PE 内部结构、修改字段和提取资源。
1.3 使用 Python 确定文件类型
在 Python 中,python-magic 模块可以用来确定文件类型。在 Ubuntu Linux 虚拟机上安装此模块的过程在第一章,恶意软件分析介绍中已有介绍。在 Windows 上,要安装 python-magic 模块,可以按照github.com/ahupp/python-magic中提到的步骤进行操作。
安装 python-magic 后,可以在脚本中使用以下命令来确定文件类型:
$ python Python 2.7.12 (default, Nov 19 2016, 06:48:10) >>> import magic
>>> m = magic.open(magic.MAGIC_NONE)
>>> m.load()
>>> ftype = m.file(r'log.exe')
>>> print ftype
PE32 executable (GUI) Intel 80386, for MS Windows
为了演示如何检测文件类型,我们以一个文件为例,该文件通过将扩展名从 .exe 改为 .doc.exe,被伪装成一个 Word 文档。在这种情况下,攻击者利用了默认情况下 “隐藏已知文件类型的扩展名” 在 “Windows 文件夹选项” 中启用的事实;该选项会阻止文件扩展名显示给用户。以下截图展示了启用 “隐藏已知文件类型的扩展名” 后文件的外观:
打开 CFF Explorer 文件后,可以发现它是一个 32 位的可执行文件,而不是一个 Word 文档,如下所示:
2. 指纹识别恶意软件
指纹识别涉及基于文件内容生成可疑二进制文件的加密哈希值。加密哈希算法,如 MD5、SHA1 或 SHA256,被认为是生成恶意软件样本文件哈希的事实标准。以下列表概述了加密哈希值的用途:
-
仅根据文件名识别恶意软件样本是无效的,因为同一恶意软件样本可能会使用不同的文件名,但基于文件内容计算的加密哈希值始终保持不变。因此,针对可疑文件的加密哈希值在整个分析过程中充当独特的标识符。
-
在动态分析过程中,当恶意软件被执行时,它可能会将自身复制到另一个位置,或释放另一个恶意软件。拥有样本的加密哈希值可以帮助确定新释放/复制的样本是否与原始样本相同,或是否是不同的样本。这些信息有助于你决定分析是只针对一个样本进行,还是需要分析多个样本。
-
文件哈希值常常作为与其他安全研究人员共享的指标,帮助他们识别样本。
-
文件哈希值可以用来确定该样本是否曾经被在线搜索或在多个反病毒扫描服务的数据库中检测到,如 VirusTotal。
2.1 使用工具生成加密哈希值
在 Linux 系统中,可以使用 md5sum、sha256sum 和 sha1sum 工具生成文件哈希值:
$ md5sum log.exe
6e4e030fbd2ee786e1b6b758d5897316 log.exe
$ sha256sum log.exe
01636faaae739655bf88b39d21834b7dac923386d2b52efb4142cb278061f97f log.exe
$ sha1sum log.exe
625644bacf83a889038e4a283d29204edc0e9b65 log.exe
对于 Windows,许多生成文件哈希值的工具可以在网上找到。HashMyFiles (www.nirsoft.net/utils/hash_my_files.html) 就是其中一个工具,可以生成单个或多个文件的哈希值,并且它还会用相同的颜色高亮显示相同的哈希值。在下图中,可以看到 log.exe 和 bunny.exe 根据其哈希值是相同的样本:
你可以在这里查看各种哈希工具的列表及对比:
en.wikipedia.org/wiki/Comparison_of_file_verification_software。在仔细审查后,选择最适合你需求的工具。
2.2 使用 Python 确定加密哈希值
在 Python 中,可以使用 hashlib 模块生成文件哈希值,如下所示:
$ python
Python 2.7.12 (default, Nov 19 2016, 06:48:10)
>>> import hashlib
>>> content = open(r"log.exe","rb").read()
>>> print hashlib.md5(content).hexdigest()
6e4e030fbd2ee786e1b6b758d5897316
>>> print hashlib.sha256(content).hexdigest()
01636faaae739655bf88b39d21834b7dac923386d2b52efb4142cb278061f97f
>>> print hashlib.sha1(content).hexdigest()
625644bacf83a889038e4a283d29204edc0e9b65
3. 多重反病毒扫描
使用多个杀毒软件扫描可疑的二进制文件有助于确定该文件是否含有恶意代码签名。特定文件的签名名称可以提供有关该文件及其功能的更多信息。通过访问相应的杀毒软件供应商网站或在搜索引擎中搜索该签名,你可以获得关于可疑文件的更多细节。这些信息可以帮助你后续的调查,并缩短分析时间。
3.1 使用 VirusTotal 扫描可疑二进制文件
VirusTotal (www.virustotal.com) 是一个流行的基于 Web 的恶意软件扫描服务。它允许你上传文件,然后使用各种杀毒软件扫描该文件,扫描结果会实时显示在网页上。除了上传文件进行扫描外,VirusTotal 的网页界面还提供了通过 哈希值、URL、域名 或 IP 地址 搜索其数据库的功能。VirusTotal 还提供了一个名为 VirusTotal Graph 的有用功能,它建立在 VirusTotal 数据集之上。使用 VirusTotal Graph,你可以可视化你提交的文件与其相关指标(如 域名、IP 地址 和 URL)之间的关系。它还允许你在每个指标之间进行切换和浏览;如果你想快速确定与恶意二进制文件相关的指标,这个功能非常有用。有关 VirusTotal Graph 的更多信息,请参考文档:support.virustotal.com/hc/en-us/articles/115005002585-VirusTotal-Graph。
以下截图显示了恶意二进制文件的检测名称,可以看到该二进制文件已通过 67 个杀毒引擎进行扫描,其中 60 个引擎检测到该二进制文件为恶意文件。如果你希望在二进制文件上使用VirusTotal Graph来可视化指标关系,只需点击 VirusTotal Graph 图标并使用你的 VirusTotal(社区)账户登录:
VirusTotal 提供不同的私人(付费)服务 (
support.virustotal.com/hc/en-us/articles/115003886005-Private-Services),这些服务允许你进行威胁狩猎并下载提交给它的样本。
3.2 使用 VirusTotal 公共 API 查询哈希值
VirusTotal 还通过其公共 API 提供脚本功能 (www.virustotal.com/en/documentation/public-api/);它允许你自动提交文件,检索文件/URL 扫描报告,以及检索域名/IP 报告。
以下是一个展示如何使用 VirusTotal 公共 API 的 Python 脚本。该脚本以哈希值(MD5/SHA1/SHA256)作为输入,并查询 VirusTotal 数据库。要使用以下脚本,你需要使用Python 2.7.x版本;必须连接到互联网,并且必须有一个 VirusTotal 公共 API 密钥(可以通过注册VirusTotal帐户获得)。一旦你获得了 API 密钥,只需更新api_key变量中的 API 密钥:
以下脚本和本书中大多数脚本用于演示概念;它们没有执行输入验证或错误处理。如果你希望将它们用于生产环境,应该考虑修改脚本,遵循这里提到的最佳实践:www.python.org/dev/peps/pep-0008/。
import urllib
import urllib2
import json
import sys
hash_value = sys.argv[1]
vt_url = "https://www.virustotal.com/vtapi/v2/file/report"
api_key = "<update your api key here>"
parameters = {'apikey': api_key, 'resource': hash_value}
encoded_parameters = urllib.urlencode(parameters)
request = urllib2.Request(vt_url, encoded_parameters)
response = urllib2.urlopen(request)
json_response = json.loads(response.read())
if json_response['response_code']:
detections = json_response['positives']
total = json_response['total']
scan_results = json_response['scans']
print "Detections: %s/%s" % (detections, total)
print "VirusTotal Results:"
for av_name, av_data in scan_results.items():
print "\t%s ==> %s" % (av_name, av_data['result'])
else:
print "No AV Detections For: %s" % hash_value
通过给定二进制文件的 MD5 哈希值运行前面的脚本,可以显示该二进制文件的防病毒检测结果和签名名称。
$ md5sum 5340.exe
5340fcfb3d2fa263c280e9659d13ba93 5340.exe
$ python vt_hash_query.py 5340fcfb3d2fa263c280e9659d13ba93
Detections: 44/56
VirusTotal Results:
Bkav ==> None
MicroWorld-eScan ==> Trojan.Generic.11318045
nProtect ==> Trojan/W32.Agent.105472.SJ
CMC ==> None
CAT-QuickHeal ==> Trojan.Agen.r4
ALYac ==> Trojan.Generic.11318045
Malwarebytes ==> None
Zillya ==> None
SUPERAntiSpyware ==> None
TheHacker ==> None
K7GW ==> Trojan ( 001d37dc1 )
K7AntiVirus ==> Trojan ( 001d37dc1 )
NANO-Antivirus ==> Trojan.Win32.Agent.cxbxiy
F-Prot ==> W32/Etumbot.K
Symantec ==> Trojan.Zbot
[.........Removed..............]
另一种选择是使用 PE 分析工具,如pestudio(www.winitor.com/)或PPEE(www.mzrst.com/)。加载二进制文件后,二进制文件的哈希值会自动从 VirusTotal 数据库中查询,并显示结果,如下图所示:
在线扫描工具如VirSCAN(
www.virscan.org/)、Jotti Malware Scan(virusscan.jotti.org/)和OPSWAT 的 Metadefender(www.metadefender.com/#!/scan-file)允许你使用多个反病毒扫描引擎扫描可疑文件,其中一些还允许你进行哈希值查询。
在使用反病毒扫描器扫描二进制文件或将二进制文件提交给在线反病毒扫描服务时,有几个因素/风险需要考虑:
-
如果可疑的二进制文件未被反病毒扫描引擎检测到,并不一定意味着该二进制文件是安全的。这些反病毒引擎依赖签名和启发式方法来检测恶意文件。恶意软件作者可以轻松修改其代码并使用混淆技术来绕过这些检测,因此某些反病毒引擎可能未能将该二进制文件识别为恶意。
-
当你将二进制文件上传到公共网站时,提交的二进制文件可能会与第三方和供应商共享。可疑的二进制文件可能包含敏感的、个人的或属于你组织的专有信息,因此不建议将作为机密调查一部分的二进制文件提交给公共反病毒扫描服务。大多数基于网页的反病毒扫描服务允许你使用加密哈希值(MD5、SHA1 或 SHA256)搜索它们已有的扫描文件数据库;因此,提交二进制文件的替代方法是根据二进制文件的加密哈希进行搜索。
-
当你将一个二进制文件提交到在线的病毒扫描引擎时,扫描结果会存储在它们的数据库中,且大多数扫描数据是公开的,可以稍后查询。攻击者可以使用搜索功能查询他们样本的哈希值,检查他们的二进制文件是否已被检测到。如果他们的样本被检测到,攻击者可能会改变战术以避免被检测。
4. 提取字符串
字符串是嵌入在文件中的 ASCII 和 Unicode 可打印字符序列。提取字符串可以为可疑二进制文件提供程序功能线索和相关指示。例如,如果恶意软件创建了一个文件,文件名 会作为字符串存储在二进制文件中。或者,如果恶意软件解析了由攻击者控制的 域名,则该域名会作为字符串存储。通过二进制文件提取的字符串可能包含对文件名、URL、域名、IP 地址、攻击命令、注册表键等的引用。虽然字符串无法清楚地展示文件的目的和功能,但它们可以提供恶意软件可能执行的操作的提示。
4.1 使用工具提取字符串
要从可疑的二进制文件中提取字符串,可以在 Linux 系统上使用 strings 工具。默认情况下,strings 命令提取至少四个字符长的 ASCII 字符串。通过使用 -a 选项,可以从整个文件中提取字符串。以下从恶意二进制文件中提取的 ASCII 字符串显示了对IP 地址的引用。这表明,当这个恶意软件被执行时,它可能会与该 IP 地址建立连接:
$ strings -a log.exe
!This program cannot be run in DOS mode.
Rich
.text
`.rdata
@.data
L$"%
h4z@
128.91.34.188
%04d-%02d-%02d %02d:%02d:%02d %s
在以下示例中,从名为 Spybot 的恶意软件提取的 ASCII 字符串表明了它的 DOS 和 键盘记录 功能:
$ strings -a spybot.exe
!This program cannot be run in DOS mode.
.text
`.bss
.data
.idata
.rsrc
]_^[
keylog.txt
%s (Changed window
Keylogger Started
HH:mm:ss]
[dd:MMM:yyyy,
SynFlooding: %s port: %i delay: %i times:%i.
bla bla blaaaasdasd
Portscanner startip: %s port: %i delay: %ssec.
Portscanner startip: %s port: %i delay: %ssec. logging to: %s
kuang
sub7
%i.%i.%i.0
scan
redirect %s:%i > %s:%i)
Keylogger logging to %s
Keylogger active output to: DCC chat
Keylogger active output to: %s
error already logging keys to %s use "stopkeylogger" to stop
startkeylogger
passwords
恶意软件样本还使用 Unicode(每个字符 2 字节)字符串。为了从二进制文件中获取有用信息,有时你需要同时提取 ASCII 和 Unicode 字符串。要使用 strings 命令提取 Unicode 字符串,可以使用 -el 选项。
在以下示例中,恶意软件样本并未揭示出异常的 ASCII 字符串,但提取的 Unicode 字符串显示了对 域名 和 Run 注册表键(恶意软件常用来在重启后保持存活)的引用;它还突出了恶意软件可能具有将程序添加到防火墙白名单的能力:
$ strings -a -el multi.exe
AppData
44859ba2c98feb83b5aab46a9af5fefc
haixxdrekt.dyndns.hu
True
Software\Microsoft\Windows\CurrentVersion\Run
Software\
.exe
SEE_MASK_NOZONECHECKS
netsh firewall add allowedprogram "
在 Windows 上,pestudio (www.winitor.com) 是一款方便的工具,能够显示 ASCII 和 Unicode 字符串。pestudio 是一款出色的 PE 分析工具,用于执行对可疑二进制文件的初步恶意软件评估,旨在从 PE 可执行文件中提取各种有用信息。此工具的其他多种功能将在后续章节中详细介绍。
以下截图显示了 pestudio 列出的一些ASCII和Unicode字符串,它通过在黑名单列中突出显示一些显著的字符串,帮助你专注于二进制文件中的有趣字符串:
由 Mark Russinovich 移植到 Windows 的strings工具(
technet.microsoft.com/en-us/sysinternals/strings.aspx)和PPEE(www.mzrst.com/)是其他可以用来提取 ASCII 和 Unicode 字符串的工具。
4.2 使用 FLOSS 解码混淆字符串
大多数情况下,恶意软件作者使用简单的字符串混淆技术来避免被检测到。在这种情况下,那些混淆过的字符串不会出现在strings工具和其他字符串提取工具中。FireEye 实验室混淆字符串解码器(FLOSS)是一款旨在自动识别和提取恶意软件中混淆字符串的工具。它可以帮助你识别恶意软件作者想要隐藏的字符串,避免被字符串提取工具提取。FLOSS也可以像strings工具一样,用于提取人类可读的字符串(ASCII 和 Unicode)。你可以从github.com/fireeye/flare-floss下载适用于 Windows 或 Linux 的FLOSS。
在下面的示例中,运行一个FLOSS独立的二进制文件在恶意软件样本上,不仅提取了人类可读的字符串,还解码了混淆过的字符串,并提取了栈字符串,这些是strings工具和其他字符串提取工具遗漏的。以下输出显示了对可执行文件、Excel 文件和运行注册表项的引用:
$ chmod +x floss
$ ./floss 5340.exe
FLOSS static ASCII strings
!This program cannot be run in DOS mode.
Rich
.text
`.rdata
@.data
[..removed..]
FLOSS decoded 15 strings
kb71271.log
R6002
- floating point not loaded
\Microsoft
winlogdate.exe
~tasyd3.xls
[....REMOVED....]
FLOSS extracted 13 stack strings
BINARY
ka4a8213.log
afjlfjsskjfslkfjsdlkf
'Clt
~tasyd3.xls
"%s"="%s"
regedit /s %s
[HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run]
[.....REMOVED......]
如果你只对解码/栈字符串感兴趣,并且想要从 FLOSS 输出中排除静态字符串(ASCII 和 Unicode),那么可以使用--no-static-strings开关。关于 FLOSS 工作原理及其各种使用选项的详细信息,请访问www.fireeye.com/blog/threat-research/2016/06/automatically-extracting-obfuscated-strings.html。
5. 确定文件混淆
尽管字符串提取是一种出色的技术,可以获取有价值的信息,但恶意软件作者常常对其恶意软件二进制文件进行混淆或加固。恶意软件作者使用混淆技术来保护恶意软件的内部工作原理,防止安全研究人员、恶意软件分析师和逆向工程师的分析。这些混淆技术使得检测/分析二进制文件变得困难;从这样的二进制文件中提取字符串的结果是字符串数量非常少,而且大多数字符串都是模糊不清的。恶意软件作者通常使用诸如打包器和加密器之类的程序对文件进行混淆,以避免安全产品如反病毒软件的检测,并破坏分析过程。
5.1 打包器和加密器
打包器(Packer)是一个程序,它将可执行文件作为输入,并使用压缩来混淆可执行文件的内容。这个混淆后的内容会存储在新可执行文件的结构中;最终结果是一个带有混淆内容的新可执行文件(打包程序),并存储在磁盘上。在执行这个打包程序时,它会执行一个解压例程,在运行时将原始二进制文件提取到内存中并触发执行。
加密器(Cryptor)类似于打包器(Packer),但它使用加密而非压缩来混淆可执行文件的内容,加密后的内容存储在新的可执行文件中。在执行加密程序时,它会运行解密例程,从内存中提取原始二进制文件,并触发执行。
为了展示文件混淆的概念,我们以一个名为Spybot的恶意软件样本(未打包)为例;从Spybot中提取字符串后,显示出可疑的可执行文件名和 IP 地址,如下所示:
$ strings -a spybot.exe
[....removed....]
EDU_Hack.exe
Sitebot.exe
Winamp_Installer.exe
PlanetSide.exe
DreamweaverMX_Crack.exe
FlashFXP_Crack.exe
Postal_2_Crack.exe
Red_Faction_2_No-CD_Crack.exe
Renegade_No-CD_Crack.exe
Generals_No-CD_Crack.exe
Norton_Anti-Virus_2002_Crack.exe
Porn.exe
AVP_Crack.exe
zoneallarm_pro_crack.exe
[...REMOVED...]
209.126.201.22
209.126.201.20
然后,Spybot样本通过一个流行的打包工具UPX(upx.github.io/)进行了打包,结果是生成了一个新的打包可执行文件(spybot_packed.exe)。以下命令输出显示了原始文件与打包文件之间的大小差异。UPX 使用压缩,因此打包后的二进制文件比原始二进制文件小:
$ upx -o spybot_packed.exe spybot.exe
Ultimate Packer for eXecutables
Copyright (C) 1996 - 2013
UPX 3.91 Markus Oberhumer, Laszlo Molnar & John Reiser Sep 30th 2013
File size Ratio Format Name
-------------------- ------ ----------- -----------
44576 -> 21536 48.31% win32/pe spybot_packed.exe
Packed 1 file.
$ ls -al
total 76
drwxrwxr-x 2 ubuntu ubuntu 4096 Jul 9 09:04 .
drwxr-xr-x 6 ubuntu ubuntu 4096 Jul 9 09:04 ..
-rw-r--r-- 1 ubuntu ubuntu 44576 Oct 22 2014 spybot.exe
-rw-r--r-- 1 ubuntu ubuntu 21536 Oct 22 2014 spybot_packed.exe
对打包二进制文件运行 strings 命令会显示被混淆的字符串,且没有透露出太多有价值的信息;这也是攻击者混淆文件的原因之一:
$ strings -a spybot_packed.exe
!This program cannot be run in DOS mode.
UPX0
UPX1
.rsrc
3.91
UPX!
t ;t
/t:VU
]^M
9-lh
:A$m
hAgo .
C@@f.
Q*vPCi
%_I;9
PVh29A
[...REMOVED...]
UPX 是一个常见的打包工具,你经常会遇到用 UPX 打包的恶意软件样本。在大多数情况下,你可以使用-d选项来解包样本。一个示例命令是upx -d -o spybot_unpacked.exe spybot_packed.exe。
5.2 使用 Exeinfo PE 检测文件混淆
大多数合法的可执行文件不会混淆内容,但一些可执行文件可能会这样做,以防止他人检查其代码。当你遇到一个被打包的样本时,它很可能是恶意的。为了检测 Windows 上的打包工具,你可以使用像Exeinfo PE这样的免费工具(exeinfo.atwebpages.com/);它具有易于使用的图形界面。写这本书时,它使用超过 4,500 个签名(存储在userdb.txt文件中)来检测构建程序时使用的各种编译器、打包工具或加密工具。除了检测打包工具,Exeinfo PE的另一个有趣功能是,它会提供如何解包样本的信息或参考。
将打包的Spybot恶意软件样本加载到Exeinfo PE中显示它是用 UPX 打包的,并且还给出了使用哪个命令来解压混淆文件的提示;这可以让你的分析变得更加轻松:
其他可以帮助你进行打包检测的 CLI 和 GUI 工具包括 TrID (
mark0.net/soft-trid-e.html),TRIDNet (mark0.net/soft-tridnet-e.html),Detect It Easy (ntinfo.biz/),RDG Packer Detector (www.rdgsoft.net/),packerid.py (github.com/sooshie/packerid),和 PEiD (www.softpedia.com/get/Programming/Packers-Crypters-Protectors/PEiD-updated.shtml)。
6. 检查 PE 头部信息
Windows 可执行文件必须符合 PE/COFF(可移植可执行/通用对象文件格式)。PE 文件格式被 Windows 可执行文件(如 .exe、.dll、.sys、.ocx 和 .drv)使用,这些文件通常被称为 可移植可执行(PE) 文件。PE 文件是由一系列结构和子组件组成,包含了操作系统加载到内存所需的信息。
当一个可执行文件被编译时,它包含一个头部(PE 头部),该头部描述了其结构。当二进制文件被执行时,操作系统加载器从 PE 头部读取信息,然后将二进制内容从文件加载到内存中。PE 头部包含的信息有:可执行文件需要加载到内存中的位置、执行开始的地址、应用程序所依赖的库/函数列表以及二进制文件使用的资源。检查 PE 头部可以获得关于二进制文件及其功能的丰富信息。
本书并不涵盖 PE 文件结构的基础知识。然而,与恶意软件分析相关的概念将在以下小节中进行讨论;有各种资源可以帮助理解 PE 文件结构。以下是一些理解 PE 文件结构的优秀资源:
-
深入了解 Win32 可移植可执行文件格式 - 第一部分:
-
深入了解 Win32 可移植可执行文件格式 - 第二部分:
-
PE 头部和结构:
www.openrce.org/reference_library/files/reference/PE%20Format.pdf -
PE101 - Windows 可执行文件解析:
通过将可疑文件加载到 PE 分析工具中,你可以清楚地了解 PE 文件格式。以下是一些允许你检查和修改 PE 结构及其子组件的工具:
-
CFF Explorer:
www.ntcore.com/exsuite.php -
PPEE(puppy):
www.mzrst.com/ -
PEBrowse Professional:
www.smidgeonsoft.prohosting.com/pebrowse-pro-file-viewer.html
后续部分将介绍一些对恶意软件分析有用的重要 PE 文件属性。诸如 pestudio (www.winitor.com) 或 PPEE (puppy): www.mzrst.com/ 等工具,可以帮助你探索 PE 文件中的有趣信息。
6.1 检查文件依赖关系和导入
通常,恶意软件与文件、注册表、网络等进行交互。为了执行这些交互,恶意软件通常依赖于操作系统暴露的功能。Windows 导出大部分执行这些交互所需的功能,这些功能被称为应用程序编程接口(API),并存储在动态链接库(DLL)文件中。可执行文件通常从提供不同功能的各种 DLL 文件中导入并调用这些函数。可执行文件从其他文件(主要是 DLL)导入的函数称为导入函数(或imports)。
例如,如果恶意软件可执行文件想要在磁盘上创建一个文件,它可以使用 Windows 中的 CreateFile() API,该 API 存储在 kernel32.dll 中。为了调用该 API,恶意软件首先必须将 kernel32.dll 加载到其内存中,然后调用 CreateFile() 函数。
检查恶意软件依赖的 DLL 以及它从这些 DLL 导入的 API 函数,可以帮助了解恶意软件的功能和能力,并预测其执行过程中可能发生的情况。Windows 可执行文件中的文件依赖关系存储在 PE 文件结构的导入表中。
在以下示例中,spybot 样本被加载到 pestudio 中。点击 pestudio 中的库按钮,显示出可执行文件依赖的所有 DLL 文件,以及从每个 DLL 导入的函数数量。这些是程序执行时会加载到内存中的 DLL 文件:
点击 pestudio 中的导入按钮会显示从这些 DLL 导入的 API 函数。在以下截图中,恶意软件从 wsock32.dll 导入与网络相关的 API 函数(如 connect、socket、listen、send 等),这表明恶意软件在执行时很可能会连接到互联网或执行某些网络活动。 pestudio 会在黑名单栏中突出显示恶意软件常用的 API 函数。在后续章节中,将更详细地介绍如何检查 API 函数的技巧:
有时,恶意软件可以在运行时显式加载 DLL,使用 LoadLibrary() 或 LdrLoadDLL() 等 API 调用,并且可以通过 GetProcessAdress() API 来解析函数地址。在运行时加载的 DLL 信息不会出现在 PE 文件的导入表中,因此工具不会显示这些信息。
有关 API 函数及其功能的信息可以从 MSDN(Microsoft Developer Network) 获得。输入 API 名称在搜索框中(msdn.microsoft.com/en-us/default.aspx),以获取有关该 API 的详细信息。
除了确定恶意软件功能外,导入项还可以帮助你检测恶意软件样本是否被混淆。如果你遇到一个导入项非常少的恶意软件,那么这强烈表明它是一个打包的二进制文件。
为了证明这一点,让我们比较 未打包的 spybot 样本 和 打包的 spybot 样本 之间的导入项。以下截图显示了未打包的 spybot 样本中有 110 个导入项:
另一方面,spybot 的 打包样本 仅显示了 12 个导入:
有时你可能需要使用 Python 来列举 DLL 文件和导入的函数(可能是为了处理大量文件);这可以使用 Ero Carerra 的 pefile 模块完成(github.com/erocarrera/pefile)。在 第一章,恶意软件分析简介 中已介绍如何在 Ubuntu Linux 虚拟机上安装 pefile 模块。如果你使用的是其他操作系统,可以通过 pip 安装(pip install pefile)。以下 Python 脚本演示了如何使用 pefile 模块列举 DLL 文件和导入的 API 函数:
import pefile
import sys
mal_file = sys.argv[1]
pe = pefile.PE(mal_file)
if hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'):
for entry in pe.DIRECTORY_ENTRY_IMPORT:
print "%s" % entry.dll
for imp in entry.imports:
if imp.name != None:
print "\t%s" % (imp.name)
else:
print "\tord(%s)" % (str(imp.ordinal))
print "\n"
以下是运行上述脚本对 spybot_packed.exe 样本进行分析后的结果;从输出中可以看到 DLL 文件和导入函数的列表:
$ python enum_imports.py spybot_packed.exe
KERNEL32.DLL
LoadLibraryA
GetProcAddress
VirtualProtect
VirtualAlloc
VirtualFree
ExitProcess
ADVAPI32.DLL
RegCloseKey
CRTDLL.DLL
atoi
[...REMOVED....]
6.2 检查导出
可执行文件和 DLL 可以导出函数,供其他程序使用。通常,DLL 导出由可执行文件导入的函数 (exports)。DLL 本身无法独立运行,依赖于主进程来执行其代码。攻击者通常会创建一个导出包含恶意功能的函数的 DLL。为了执行 DLL 中的恶意函数,必须通过某种方式使其被加载到一个进程中,并调用这些恶意函数。DLL 还可以从其他库(DLL)导入函数以执行系统操作。
检查导出的函数可以快速了解 DLL 的功能。在以下示例中,加载一个与恶意软件 Ramnit 相关的 DLL 到 pestudio 中,查看其导出函数,从而推测其功能。当一个进程加载这个 DLL 时,某个时刻,这些函数会被调用来执行恶意活动:
导出函数的名称可能无法完全反映恶意软件的功能。攻击者可能使用随机或伪造的导出名称来误导你的分析,或者将你引入误区。
在 Python 中,可以使用 pefile 模块 枚举导出函数,如下所示:
$ python
Python 2.7.12 (default, Nov 19 2016, 06:48:10)
>>> import pefile
>>> pe = pefile.PE("rmn.dll")
>>> if hasattr(pe, 'DIRECTORY_ENTRY_EXPORT'):
... for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols:
... print "%s" % exp.name
...
AddDriverPath
AddRegistryforME
CleanupDevice
CleanupDevice_EX
CreateBridgeRegistryfor2K
CreateFolder
CreateKey
CreateRegistry
DeleteDriverPath
DeleteOemFile
DeleteOemInfFile
DeleteRegistryforME
DuplicateFile
EditRegistry
EnumerateDevice
GetOS
[.....REMOVED....]
6.3 检查 PE 段表和段
PE 文件的实际内容被划分为多个段(sections)。这些段紧接在 PE 头之后。这些段代表的是 代码 或 数据,并具有如读/写等内存属性。代表代码的段包含将由处理器执行的指令,而包含数据的段可以代表不同类型的数据,如读/写程序数据(全局变量)、导入/导出表、资源等。每个段都有一个独特的名称,用来表示该段的目的。例如,名为 .text 的段表示代码,并具有 read-execute 属性;名为 .data 的段表示全局数据,并具有 read-write 属性。
在可执行文件的编译过程中,编译器会添加一致的段名。下表列出了 PE 文件中一些常见的段:
| 段名 | 描述 |
|---|---|
.text 或 CODE | 包含可执行代码。 |
.data 或 DATA | 通常包含读/写数据和全局变量。 |
.rdata | 包含只读数据。有时它还包含导入和导出信息。 |
.idata | 如果存在,包含导入表。如果不存在,则导入信息存储在 .rdata 段中。 |
.edata | 如果存在,包含导出信息。如果不存在,则导出信息位于 .rdata 段中。 |
.rsrc | 此段包含可执行文件使用的资源,如图标、对话框、菜单、字符串等。 |
这些节名称主要供人类使用,操作系统并不使用它们,这意味着攻击者或混淆软件可能会创建具有不同名称的节。如果你遇到不常见的节名称,应当对此保持怀疑,并且需要进一步分析以确认其是否具有恶意。
这些节的信息(如节名称、节的位置以及其特征)存在于 PE 头中的节表中。检查节表将提供关于节及其特征的信息。
当你在pestudio中加载一个可执行文件并点击节时,它会显示从节表提取的节信息及其属性(如读/写等)。以下是来自 pestudio 的可执行文件的节信息截图,截图中一些相关字段在此进行解释:
| 字段 | 描述 |
|---|---|
| 名称 | 显示节名称。在这种情况下,可执行文件包含四个节(.text、.data、.rdata和.rsrc)。 |
| 虚拟大小 | 指示加载到内存时节的大小。 |
| 虚拟地址 | 这是节在内存中的相对虚拟地址(即,从可执行文件的基地址开始的偏移)。 |
| 原始大小 | 指示节在磁盘上的大小。 |
| 原始数据 | 指示文件中节所在的偏移位置。 |
| 入口点 | 这是代码开始执行的 RVA(相对虚拟地址)。在这种情况下,入口点位于.text节中,这是正常的。 |
检查节表也有助于识别 PE 文件中的任何异常。以下截图显示了一个使用 UPX 打包的恶意软件的节名称;该恶意软件样本包含以下差异:
-
节名称不包含编译器添加的常见节(如
.text、.data等),而是包含UPX0和UPX1节名称。 -
入口点位于
UPX1节中,这表明执行将从此节开始(解压缩例程)。 -
通常,
原始大小和虚拟大小应该几乎相等,但由于节对齐的原因,存在小的差异是正常的。在这种情况下,原始大小为0,表示该节不会占用磁盘空间,但虚拟大小指定该节在内存中占用更多空间(大约127 KB)。这强烈表明这是一个打包的二进制文件。造成这种差异的原因是,当一个打包的二进制文件执行时,打包程序的解压例程会在运行时将解压后的数据或指令复制到内存中。
以下 Python 脚本演示了如何使用pefile模块来显示节及其特征:
import pefile
import sys
pe = pefile.PE(sys.argv[1])
for section in pe.sections:
print "%s %s %s %s" % (section.Name,
hex(section.VirtualAddress),
hex(section.Misc_VirtualSize),
section.SizeOfRawData)
print "\n"
以下是运行前面 Python 脚本后的输出:
$ python display_sections.py olib.exe
UPX0 0x1000 0x1f000 0
UPX1 0x20000 0xe000 53760
.rsrc 0x2e000 0x6000 24576
Michael Ligh 和 Glenn P. Edwards 开发的 pescanner 是一个出色的工具,可以根据 PE 文件属性检测可疑的 PE 文件;它使用启发式方法而不是签名,并且即使没有签名,也能帮助你识别被打包的二进制文件。你可以从 github.com/hiddenillusion/AnalyzePE/blob/master/pescanner.py 下载脚本副本。
6.4 检查编译时间戳
PE 头部包含指定二进制文件编译时间的信息;检查这个字段可以帮助你了解恶意软件首次创建的时间。这些信息对于构建攻击活动的时间线非常有用。攻击者也可能修改时间戳,防止分析人员了解实际的时间戳。有时候,编译时间戳可以用来分类可疑样本。以下示例显示了一个恶意软件二进制文件,其时间戳被修改为 2020 年的未来日期。在这种情况下,尽管无法检测到实际的编译时间戳,但这些特征可以帮助你识别异常行为:
在 Python 中,你可以使用以下 Python 命令来确定编译时间戳:
>>> import pefile
>>> import time
>>> pe = pefile.PE("veri.exe")
>>> timestamp = pe.FILE_HEADER.TimeDateStamp
>>> print time.strftime("%Y-%m-%d %H:%M:%S",time.localtime(timestamp))
2020-01-06 08:36:17
所有 Delphi 二进制文件的编译时间戳都设置为 1992 年 6 月 19 日,这使得很难检测到实际的编译时间戳。如果你正在调查一个时间戳设置为这个日期的恶意软件二进制文件,很可能你正在查看 Delphi 二进制文件。以下博客文章 www.hexacorn.com/blog/2014/12/05/the-not-so-boring-land-of-borland-executables-part-1/ 提供了有关如何从 Delphi 二进制文件获取编译时间戳的信息。
6.5 检查 PE 资源
可执行文件所需的资源,如图标、菜单、对话框和字符串,都存储在可执行文件的资源部分(.rsrc)中。攻击者通常将附加的二进制文件、诱饵文档、配置数据等信息存储在资源部分,因此检查资源部分可以揭示二进制文件的有价值信息。资源部分还包含版本信息,可以揭示有关来源、公司名称、程序作者细节和版权信息。
Resource Hacker (www.angusj.com/resourcehacker/) 是一个非常好的工具,可以用来检查、查看和提取可疑二进制文件中的资源。我们以一个看起来像 Excel 文件的二进制文件为例(注意文件扩展名被更改为 .xls.exe),如下所示:
将恶意二进制文件加载到 Resource Hacker 中,显示三个资源(图标,二进制 和 图标组)。该恶意软件样本使用了 Microsoft Excel 的图标(以给人一种 Excel 表格的假象):
可执行文件还包含二进制数据;其中一个具有文件签名D0 CF 11 E0 A1 B1 1A E1。这组字节表示 Microsoft Office 文档文件的文件签名。在这种情况下,攻击者在资源部分存储了一个诱饵 Excel 表。在执行时,恶意软件在后台执行,并且这个诱饵 Excel 表显示给用户作为一种转移:
要将二进制文件保存到磁盘上,请右键单击要提取的资源,然后单击“保存资源”到*.bin 文件,如下面的屏幕截图所示。在这种情况下,资源被保存为sample.xls。下面的屏幕截图显示了将显示给用户的诱饵 Excel 表:
通过探索资源部分的内容,可以了解很多关于恶意软件特征的信息。
7. 比较和分类恶意软件
在进行恶意软件调查时,当您遇到一个恶意软件样本时,您可能想知道该恶意软件样本是否属于特定的恶意软件家族,或者它是否具有与先前分析的样本相匹配的特征。将可疑二进制文件与先前分析的样本或存储在公共或私人存储库中的样本进行比较,可以了解恶意软件家族、其特征以及与先前分析的样本的相似性。
虽然加密哈希(MD5/SHA1/SHA256)是一种检测相同样本的好方法,但它并不能帮助识别相似的样本。恶意软件作者经常改变恶意软件的微小方面,这会完全改变哈希值。以下部分描述了一些可以帮助比较和分类可疑二进制文件的技术:
7.1 使用模糊哈希分类恶意软件
模糊哈希是一种比较文件相似性的好方法。ssdeep (ssdeep.sourceforge.net)是一个有用的工具,用于为样本生成模糊哈希,还有助于确定样本之间的相似度百分比。这种技术在比较可疑二进制文件与存储库中的样本时非常有用,以识别相似的样本;这有助于识别属于相同恶意软件家族或相同行动者组的样本。
您可以使用ssdeep来计算和比较模糊哈希。在 Ubuntu Linux 虚拟机上安装ssdeep已在第一章中介绍过。要确定样本的模糊哈希,请运行以下命令:
$ ssdeep veri.exe
ssdeep,1.1--blocksize:hash:hash,filename
49152:op398U/qCazcQ3iEZgcwwGF0iWC28pUtu6On2spPHlDB:op98USfcy8cwF2bC28pUtsRptDB,"/home/ubuntu/Desktop/veri.exe"
为了演示模糊哈希的使用,让我们以一个包含三个恶意软件样本的目录为例。在下面的输出中,您可以看到所有三个文件具有完全不同的 MD5 哈希值:
$ ls
aiggs.exe jnas.exe veri.exe
$ md5sum *
48c1d7c541b27757c16b9c2c8477182b aiggs.exe
92b91106c108ad2cc78a606a5970c0b0 jnas.exe
ce9ce9fc733792ec676164fc5b2622f2 veri.exe
ssdeep 中的美观匹配模式(-p选项)可以用来确定相似度百分比。从以下输出可以看出,在三个样本中,有两个样本的相似度为 99%,这表明这两个样本可能属于同一恶意软件家族:
$ ssdeep -pb *
aiggs.exe matches jnas.exe (99)
jnas.exe matches aiggs.exe (99)
如前面的示例所示,加密哈希在确定样本之间的关系时并没有提供帮助,而模糊哈希技术则识别了样本之间的相似性。
你可能有一个包含多个恶意软件样本的目录。在这种情况下,可以使用递归模式(-r)在包含恶意软件样本的目录及其子目录上运行ssdeep,如这里所示:
$ ssdeep -lrpa samples/
samples//aiggs.exe matches samples//crop.exe (0)
samples//aiggs.exe matches samples//jnas.exe (99)
samples//crop.exe matches samples//aiggs.exe (0)
samples//crop.exe matches samples//jnas.exe (0)
samples//jnas.exe matches samples//aiggs.exe (99)
samples//jnas.exe matches samples//crop.exe (0)
你还可以将可疑的二进制文件与文件哈希列表进行匹配。在以下示例中,所有二进制文件的 ssdeep 哈希被重定向到一个文本文件(all_hashes.txt),然后将可疑二进制文件(blab.exe)与文件中的所有哈希进行匹配。从以下输出可以看出,可疑二进制文件(blab.exe)与jnas.exe完全相同(100% 匹配),并且与aiggs.exe的相似度为 99%。你可以使用这种技术将任何新文件与先前分析过的样本哈希进行比较:
$ ssdeep * > all_hashes.txt
$ ssdeep -m all_hashes.txt blab.exe
/home/ubuntu/blab.exe matches all_hashes.txt:/home/ubuntu/aiggs.exe (99)
/home/ubuntu/blab.exe matches all_hashes.txt:/home/ubuntu/jnas.exe (100)
在 Python 中,模糊哈希可以使用python-ssdeep(pypi.python.org/pypi/ssdeep/3.2)计算。在第一章,恶意软件分析入门中介绍了如何在 Ubuntu Linux 虚拟机上安装python-ssdeep模块。要计算和比较模糊哈希,可以在脚本中使用以下命令:
$ python
Python 2.7.12 (default, Nov 19 2016, 06:48:10)
>>> import ssdeep
>>> hash1 = ssdeep.hash_from_file('jnas.exe')
>>> print hash1
384:l3gexUw/L+JrgUon5b9uSDMwE9Pfg6NgrWoBYi51mRvR6JZlbw8hqIusZzZXe:pIAKG91Dw1hPRpcnud
>>> hash2 = ssdeep.hash_from_file('aiggs.exe')
>>> print hash2
384:l3gexUw/L+JrgUon5b9uSDMwE9Pfg6NgrWoBYi51mRvR6JZlbw8hqIusZzZWe:pIAKG91Dw1hPRpcnu+
>>> ssdeep.compare(hash1, hash2)
99
>>>
7.2 使用导入哈希分类恶意软件
导入哈希是另一种可以用来识别相关样本和同一威胁行为者组使用的样本的技术。导入哈希(或imphash)是一种通过计算基于库/导入函数(API)名称及其在可执行文件中特定顺序的哈希值的技术。如果文件是从相同的源代码编译的,并且采用相同的方式,那么这些文件的imphash值通常会相同。在你的恶意软件调查中,如果你遇到具有相同 imphash 值的样本,说明它们具有相同的导入地址表,很可能是相关的。
有关导入哈希的详细信息,以及如何使用它来追踪威胁行为者组,请阅读www.fireeye.com/blog/threat-research/2014/01/tracking-malware-import-hashing.html。
当你将一个可执行文件加载到pestudio时,它会计算出 imphash,如此处所示:
在 Python 中,可以使用pefile模块生成 imphash。以下 Python 脚本以样本为输入并计算其 imphash:
import pefile
import sys
pe = pefile.PE(sys.argv[1])
print pe.get_imphash()
运行前面的脚本对恶意软件样本进行处理后,输出结果如下:
$ python get_imphash.py 5340.exe
278a52c6b04fae914c4965d2b4fdec86
你还应该查看blog.jpcert.or.jp/2016/05/classifying-mal-a988.html,该页面详细介绍了使用导入 API 和模糊哈希技术(impfuzzy)来分类恶意软件样本的内容。
为了演示导入哈希的使用,我们以来自同一威胁团体的两个样本为例。在以下输出中,样本具有不同的加密哈希值(MD5),但这些样本的 impash 是相同的;这表明它们可能是从相同的源编译的,并且以相同的方式进行编译:
$ md5sum *
3e69945e5865ccc861f69b24bc1166b6 maxe.exe
1f92ff8711716ca795fbd81c477e45f5 sent.exe
$ python get_imphash.py samples/maxe.exe
b722c33458882a1ab65a13e99efe357e
$ python get_imphash.py samples/sent.exe
b722c33458882a1ab65a13e99efe357e
拥有相同 imphash 的文件不一定来自同一威胁组;你可能需要从各种来源关联信息来分类你的恶意软件。例如,恶意软件样本可能是使用一个在不同团体之间共享的通用构建工具生成的;在这种情况下,样本可能会有相同的 imphash。
7.3 使用部分哈希分类恶意软件
类似于导入哈希,部分哈希也可以帮助识别相关的样本。当可执行文件在 pestudio 中加载时,它会计算每个部分的 MD5(.text、.data、.rdata 等)。要查看部分哈希,请点击部分,如下所示:
在 Python 中,可以使用 pefile 模块来确定各个部分的哈希值,如下所示:
>>> import pefile
>>> pe = pefile.PE("5340.exe")
>>> for section in pe.sections:
... print "%s\t%s" % (section.Name, section.get_hash_md5())
...
.text b1b56e7a97ec95ed093fd6cfdd594f6c
.rdata a7dc36d3f527ff2e1ff7bec3241abf51
.data 8ec812e17cccb062515746a7336c654a
.rsrc 405d2a82e6429de8637869c5514b489c
在分析恶意软件样本时,你应该考虑为恶意二进制文件生成模糊哈希、imphash 和部分哈希,并将它们存储在一个存储库中;这样,当你遇到一个新的样本时,可以将其与这些哈希进行比较,以确定相似性。
7.4 使用 YARA 分类恶意软件
恶意软件样本可以包含许多字符串或二进制指示符;识别对恶意软件样本或恶意软件家族独特的字符串或二进制数据有助于恶意软件的分类。安全研究人员根据二进制文件中包含的独特字符串和二进制指示符来分类恶意软件。有时,恶意软件也可以根据一般特征进行分类。
YARA (virustotal.github.io/yara/) 是一款强大的恶意软件识别和分类工具。恶意软件研究人员可以根据恶意软件样本中包含的文本或二进制信息创建 YARA 规则。这些 YARA 规则由一组字符串和一个布尔表达式组成,布尔表达式决定其逻辑。一旦编写了规则,你可以使用这些规则通过 YARA 工具扫描文件,或者使用 yara-python 将其与其他工具集成。本书不会涵盖编写 YARA 规则的所有细节,但包含了足够的信息,帮助你入门。有关编写 YARA 规则的详细信息,请阅读 YARA 文档(yara.readthedocs.io/en/v3.7.0/writingrules.html)。
7.4.1 安装 YARA
你可以从(virustotal.github.io/yara/)下载并安装YARA。在第一章中介绍了在 Ubuntu Linux 虚拟机上安装 YARA 的过程,恶意软件分析入门。如果你希望在其他操作系统上安装 YARA,请参阅安装文档:yara.readthedocs.io/en/v3.3.0/gettingstarted.html
7.4.2 YARA 规则基础
安装完成后,下一步是创建 YARA 规则;这些规则可以是通用的或非常具体的,可以使用任何文本编辑器创建。为了理解 YARA 规则的语法,我们来看一个简单的 YARA 规则示例,它查找任何文件中的可疑字符串,如下所示:
rule suspicious_strings
{
strings:
$a = "Synflooding"
$b = "Portscanner"
$c = "Keylogger"
condition:
($a or $b or $c)
}
YARA 规则由以下组件组成:
-
规则标识符: 这是描述规则的名称(在前面的示例中为
suspicious_strings)。规则标识符可以包含任何字母数字字符和下划线字符,但第一个字符不能是数字。规则标识符是区分大小写的,且不能超过 128 个字符。 -
字符串定义: 这是定义将成为规则一部分的字符串(文本、十六进制或正则表达式)所在的部分。如果规则不依赖于任何字符串,可以省略此部分。每个字符串都有一个标识符,由一个
$字符后跟一串字母数字字符和下划线组成。在前面的规则中,$a、$b和$c可以看作是包含值的变量。这些变量随后将在条件部分中使用。 -
条件部分: 这不是一个可选部分,逻辑部分就在这里。此部分必须包含一个布尔表达式,指定规则匹配或不匹配的条件。
7.4.3 运行 YARA
一旦准备好规则,下一步是使用 yara 工具根据 YARA 规则扫描文件。在前面的示例中,规则查找了三个可疑字符串(分别定义在$a、$b和$c中),并且根据条件,如果文件中存在任何一个字符串,规则就会匹配。该规则保存为suspicious.yara,并且对包含恶意软件样本的目录运行 yara 时,返回了两个符合该规则的恶意软件样本:
$ yara -r suspicious.yara samples/
suspicious_strings samples//spybot.exe
suspicious_strings samples//wuamqr.exe
默认情况下,前面的 YARA 规则将匹配 ASCII 字符串,并执行区分大小写的匹配。如果你希望规则同时检测 ASCII 和 Unicode(宽字符)字符串,那么可以在字符串旁边指定ascii和wide修饰符。nocase修饰符将执行不区分大小写的匹配(即,它会匹配 Synflooding、synflooding、sYnflooding 等)。修改后的规则以实现不区分大小写的匹配,并查找 ASCII 和 Unicode 字符串,如下所示:
rule suspicious_strings
{
strings:
$a = "Synflooding" ascii wide nocase
$b = "Portscanner" ascii wide nocase
$c = "Keylogger" ascii wide nocase
condition:
($a or $b or $c)
}
运行上述规则检测到包含 ASCII 字符串的两个可执行文件,它还识别了一个包含 Unicode 字符串的文档(test.doc):
$ yara suspicious.yara samples/
suspicious_strings samples//test.doc
suspicious_strings samples//spybot.exe
suspicious_strings samples//wuamqr.exe
上述规则匹配任何包含这些 ASCII 和 Unicode 字符串的文件。它检测到的文档(test.doc)是一个合法文档,且其内容包含了这些字符串。
如果你打算在可执行文件中查找字符串,可以像下面这样创建规则。在以下规则中,条件中的$mz在0处指定 YARA 在文件开头查找签名4D 5A(PE 文件的前两个字节);这确保只有 PE 可执行文件会触发该签名。文本字符串用双引号括起来,而十六进制字符串则用大括号括起来,如$mz变量中的情况:
rule suspicious_strings
{
strings:
$mz = {4D 5A}
$a = "Synflooding" ascii wide nocase
$b = "Portscanner" ascii wide nocase
$c = "Keylogger" ascii wide nocase
condition:
($mz at 0) and ($a or $b or $c)
}
现在,运行上述规则只检测到可执行文件:
$ yara -r suspicious.yara samples/
suspicious_strings samples//spybot.exe
suspicious_strings samples//wuamqr.exe
7.4.4 YARA 的应用
让我们再看一个例子,使用的是第 6.5 节中曾经使用过的样本,检查 PE 资源。该样本(5340.exe)将一个诱饵 Excel 文档存储在它的资源区段;一些恶意软件程序会存储诱饵文档,以便在执行时向用户展示。以下 YARA 规则检测包含嵌入的 Microsoft Office 文档的可执行文件。如果在文件中偏移量大于1024字节处找到十六进制字符串(跳过 PE 头),并且filesize指定文件末尾,则触发规则:
rule embedded_office_document
{
meta:
description = "Detects embedded office document"
strings:
$mz = { 4D 5A }
$a = { D0 CF 11 E0 A1 B1 1A E1 }
condition:
($mz at 0) and $a in (1024..filesize)
}
运行上述 YARA 规则只检测到包含嵌入 Excel 文档的样本:
$ yara -r embedded_doc.yara samples/
embedded_office_document samples//5340.exe
以下示例通过数字证书的序列号来检测名为9002 RAT的恶意软件样本。RAT 9002 使用了序列号为45 6E 96 7A 81 5A A5 CB B9 9F B8 6A CA 8F 7F 69的数字证书(blog.cylance.com/another-9002-trojan-variant)。这个序列号可以作为签名,用来检测拥有相同数字证书的样本:
rule mal_digital_cert_9002_rat
{
meta:
description = "Detects malicious digital certificates used by RAT 9002"
ref = "http://blog.cylance.com/another-9002-trojan-variant"
strings:
$mz = { 4D 5A }
$a = { 45 6e 96 7a 81 5a a5 cb b9 9f b8 6a ca 8f 7f 69 }
condition:
($mz at 0) and ($a in (1024..filesize))
}
运行该规则检测到所有具有相同数字证书的样本,所有这些样本最终都被确定为RAT 9002样本:
$ yara -r digi_cert_9002.yara samples/
mal_digital_cert_9002_rat samples//ry.dll
mal_digital_cert_9002_rat samples//rat9002/Mshype.dll
mal_digital_cert_9002_rat samples//rat9002/bmp1f.exe
YARA 规则也可以用来检测打包器。在第五部分,确定文件混淆中,我们讨论了如何使用Exeinfo PE工具来检测打包器。Exeinfo PE使用存储在名为userdb.txt的纯文本文件中的签名。以下是Exeinfo PE用于检测UPX打包器的示例签名格式:
[UPX 2.90 (LZMA)]
signature = 60 BE ?? ?? ?? ?? 8D BE ?? ?? ?? ?? 57 83 CD FF EB 10 90 90 90 90 90 90 8A 06 46 88 07 47 01 DB 75 07 8B 1E 83 EE FC 11 DB 72 ED B8 01 00 00 00 01 DB 75 07 8B 1E 83 EE FC 11 DB 11 C0 01 DB
ep_only = true
上述签名中的ep_only=true意味着Exeinfo PE只应在程序入口点的地址处检查签名(即代码开始执行的地方)。上述签名可以转换为 YARA 规则。YARA 的新版本支持PE模块,它允许你使用 PE 文件格式的属性和特征来创建针对 PE 文件的规则。如果你使用的是 YARA 的新版本,Exeinfo PE 签名可以转化为如下所示的 YARA 规则:
import "pe"
rule UPX_290_LZMA
{
meta:
description = "Detects UPX packer 2.90"
ref = "userdb.txt file from the Exeinfo PE"
strings:
$a = { 60 BE ?? ?? ?? ?? 8D BE ?? ?? ?? ?? 57 83 CD FF EB 10 90 90 90 90 90 90 8A 06 46 88 07 47 01 DB 75 07 8B 1E 83 EE FC 11 DB 72 ED B8 01 00 00 00 01 DB 75 07 8B 1E 83 EE FC 11 DB 11 C0 01 DB }
condition:
$a at pe.entry_point
}
如果您使用不支持 PE 模块的旧版本的 YARA,则使用以下规则:
rule UPX_290_LZMA
{
meta:
description = "Detects UPX packer 2.90"
ref = "userdb.txt file from the Exeinfo PE"
strings:
$a = { 60 BE ?? ?? ?? ?? 8D BE ?? ?? ?? ?? 57 83 CD FF EB 10 90 90 90 90 90 90 8A 06 46 88 07 47 01 DB 75 07 8B 1E 83 EE FC 11 DB 72 ED B8 01 00 00 00 01 DB 75 07 8B 1E 83 EE FC 11 DB 11 C0 01 DB }
condition:
$a at entrypoint
}
现在,在样本目录上运行一个 yara 规则,检测到使用 UPX 打包的样本:
$ yara upx_test_new.yara samples/
UPX_290_LZMA samples//olib.exe
UPX_290_LZMA samples//spybot_packed.exe
使用上述方法,Exeinfo PE 的userdb.txt中的所有打包器签名都可以转换为 YARA 规则。
PEiD 是另一个检测打包器的工具(此工具不再受支持);它将签名存储在文本文件UserDB.txt中。由 Matthew Richard 编写的 Python 脚本peid_to_yara.py(Malware Analyst's Cookbook 的一部分)和 Didier Steven 的peid-userdb-to-yara-rules.py(github.com/DidierStevens/DidierStevensSuite/blob/master/peid-userdb-to-yara-rules.py)将UserDB.txt签名转换为 YARA 规则。
YARA 可用于检测任何文件中的模式。以下 YARA 规则可检测不同变种的Gh0stRAT恶意软件的通信:
rule Gh0stRat_communications
{
meta:
Description = "Detects the Gh0stRat communication in Packet Captures"
strings:
$gst1 = {47 68 30 73 74 ?? ?? 00 00 ?? ?? 00 00 78 9c}
$gst2 = {63 62 31 73 74 ?? ?? 00 00 ?? ?? 00 00 78 9c}
$gst3 = {30 30 30 30 30 30 30 30 ?? ?? 00 00 ?? ?? 00 00 78 9c}
$gst4 = {45 79 65 73 32 ?? ?? 00 00 ?? ?? 00 00 78 9c}
$gst5 = {48 45 41 52 54 ?? ?? 00 00 ?? ?? 00 00 78 9c}
$any_variant = /.{5,16}\x00\x00..\x00\x00\x78\x9c/
condition:
any of ($gst*) or ($any_variant)
}
在包含网络数据包捕获(pcaps)的目录上运行上述规则,检测到一些 pcaps 中的 GhostRAT 模式,如下所示:
$ yara ghost_communications.yara pcaps/
Gh0stRat_communications pcaps//Gh0st.pcap
Gh0stRat_communications pcaps//cb1st.pcap
Gh0stRat_communications pcaps//HEART.pcap
分析恶意软件后,您可以创建签名以识别其组件;以下代码显示了一个检测Darkmegi Rootkit驱动程序和 DLL 组件的示例 YARA 规则:
rule Darkmegi_Rootkit
{
meta:
Description = "Detects the kernel mode Driver and Dll component of Darkmegi/waltrodock rootkit"
strings:
$drv_str1 = "com32.dll"
$drv_str2 = /H:\\RKTDOW~1\\RKTDRI~1\\RKTDRI~1\\objfre\\i386\\RktDriver.pdb/
$dll_str1 = "RktLibrary.dll"
$dll_str2 = /\\\\.\\NpcDark/
$dll_str3 = "RktDownload"
$dll_str4 = "VersionKey.ini"
condition:
(all of them) or (any of ($drv_str*)) or (any of ($dll_str*))
}
在分析单个Darkmegi样本后创建了上述规则;然而,在包含恶意软件样本的目录上运行上述规则,检测到所有与模式匹配的Darkmegi rootkit 样本:
$ yara darkmegi.yara samples/
Darkmegi_Rootkit samples//63713B0ED6E9153571EB5AEAC1FBB7A2
Darkmegi_Rootkit samples//E7AB13A24081BFFA21272F69FFD32DBF-
Darkmegi_Rootkit samples//0FC4C5E7CD4D6F76327D2F67E82107B2
Darkmegi_Rootkit samples//B9632E610F9C91031F227821544775FA
Darkmegi_Rootkit samples//802D47E7C656A6E8F4EA72A6FECD95CF
Darkmegi_Rootkit samples//E7AB13A24081BFFA21272F69FFD32DBF
[......................REMOVED..............................]
YARA 是一个强大的工具;创建 YARA 规则以扫描已知样本库可以识别和分类具有相同特征的样本。
在规则中使用的字符串可能会产生误报。测试您的签名与已知良好文件,并考虑可能触发误报的情况是个好主意。要编写健壮的 YARA 规则,请阅读www.bsk-consulting.de/2015/02/16/write-simple-sound-yara-rules/。要生成 YARA 规则,您可以考虑使用 Florian Roth 的yarGen(github.com/Neo23x0/yarGen)或 Joe Security 的 YARA 规则生成器(www.yara-generator.net/)。
摘要
静态分析是恶意软件分析的第一步;它允许您从二进制文件中提取有价值的信息,并有助于比较和分类恶意软件样本。本章向您介绍了各种工具和技术,使用这些工具和技术可以确定恶意软件二进制的不同方面,而无需执行它。在下一章节动态分析中,您将学习如何通过在隔离环境中执行来确定恶意软件的行为。
第三章:动态分析
动态分析(行为分析)涉及通过在隔离环境中执行样本并监控其活动、交互和对系统的影响来分析样本。在上一章中,你学习了不执行嫌疑二进制文件的情况下检查其不同方面的工具、概念和技术。在本章中,我们将基于这些信息,通过动态分析进一步探索嫌疑二进制文件的性质、目的和功能。
你将学习以下主题:
-
动态分析工具及其功能
-
模拟互联网服务
-
动态分析所涉及的步骤
-
监控恶意软件活动并理解其行为
1. 实验环境概述
在进行动态分析时,你将执行恶意软件样本,因此你需要有一个安全的实验环境,以防止生产系统受到感染。为了演示这些概念,我将使用在第一章中配置的隔离实验环境,恶意软件分析简介。下图展示了用于执行动态分析的实验环境,且本书中整个实验过程都使用相同的实验架构:
在此设置中,Linux 和 Windows 虚拟机都被配置为使用仅主机网络配置模式。Linux 虚拟机的 IP 地址预配置为192.168.1.100,而 Windows 虚拟机的 IP 地址设置为192.168.1.50。Windows 虚拟机的默认网关和 DNS 设置为 Linux 虚拟机的 IP 地址(192.168.1.100),因此所有 Windows 网络流量都通过 Linux 虚拟机路由。
Windows 虚拟机将在分析过程中用于执行恶意软件样本,Linux 虚拟机将用于监控网络流量,并配置为模拟互联网服务(如 DNS、HTTP 等),以便在恶意软件请求这些服务时提供适当的响应。
2. 系统和网络监控
当恶意软件被执行时,它可以以多种方式与系统交互并执行不同的活动。例如,执行时,恶意软件可能会生成子进程,在文件系统中丢弃附加文件,为其持久性创建注册表键值,下载其他组件或从命令和控制服务器获取命令。监控恶意软件与系统和网络的交互有助于更好地了解恶意软件的性质和目的。
在动态分析过程中,当恶意软件被执行时,你将进行各种监控活动。其目标是收集与恶意软件行为及其对系统影响相关的实时数据。以下列表概述了动态分析过程中进行的不同类型的监控活动:
-
进程监控:在恶意软件执行过程中,监控进程活动并检查结果进程的属性。
-
文件系统监控:包括监控恶意软件执行期间的实时文件系统活动。
-
注册表监控:涉及监控恶意二进制文件访问/修改的注册表键以及正在读取/写入的注册表数据。
-
网络监控:涉及监控恶意软件执行期间系统进出流量的实时情况。
前述监控活动有助于收集与恶意软件行为相关的主机和网络信息。接下来的章节将介绍这些活动的实际应用。在下一节中,您将了解可以用于执行这些监控活动的各种工具。
3. 动态分析(监控)工具
在执行动态分析之前,了解您将使用的工具来监控恶意软件行为是至关重要的。本章及整本书将涵盖各种恶意软件分析工具。如果您按照第一章中的说明设置了实验环境,您可以将这些工具下载到您的主机机器上,然后将这些工具传输/安装到虚拟机中,并拍摄一个全新的、干净的快照。
本节介绍了各种动态分析工具及其一些功能。稍后在本章中,您将了解如何使用这些工具来监控恶意软件执行过程中的行为。您需要以管理员权限运行这些工具;可以通过右键单击可执行文件并选择“以管理员身份运行”来实现。在阅读过程中,建议您运行这些工具,并熟悉它们的功能。
3.1 使用 Process Hacker 检查进程
Process Hacker(processhacker.sourceforge.net/)是一个开源的多功能工具,帮助监控系统资源。它是检查系统上正在运行的进程并检查进程属性的绝佳工具。它还可以用于探索服务、网络连接、磁盘活动等。
一旦恶意软件样本执行,该工具可以帮助您识别新创建的恶意软件进程(其进程名称和进程 ID),通过右键单击进程名称并选择“属性”,您可以检查各种进程属性。您还可以右键单击进程并终止它。
以下截图展示了 Process Hacker 列出系统上所有正在运行的进程,以及wininit.exe的属性:
3.2 使用 Process Monitor 确定系统与恶意软件的交互
Process Monitor(technet.microsoft.com/en-us/sysinternals/processmonitor.aspx)是一个高级监控工具,展示了进程与文件系统、注册表及进程/线程活动的实时交互。
当你运行这个工具(以管理员身份运行)时,你会立刻注意到它会捕获所有系统事件,如下图所示。要停止捕获事件,可以按 Ctrl + E,要清除所有事件,可以按 Ctrl + X。下图展示了在干净系统上由进程监视器捕获的活动:
从进程监视器捕获的事件中,你可以看到即使是干净系统也会产生大量活动。在进行恶意软件分析时,你只会对恶意软件产生的活动感兴趣。为了减少噪音,你可以使用过滤功能来隐藏不需要的条目,并根据特定属性进行过滤。要访问此功能,选择过滤器菜单,然后点击过滤(或按 Ctrl + L)。在下面的截图中,过滤器被配置为仅显示与进程svchost.exe相关的事件:
3.3 使用 Noriben 记录系统活动
虽然进程监视器是一个监控恶意软件与系统交互的好工具,但它可能会产生大量噪音,需要手动努力去过滤这些噪音。Noriben(github.com/Rurik/Noriben)是一个与进程监视器配合使用的 Python 脚本,帮助收集、分析和报告恶意软件的运行时指标。使用 Noriben 的优点是它提供了预定义的过滤器,帮助减少噪音,让你可以专注于与恶意软件相关的事件。
要使用 Noriben,首先下载它到你的 Windows 虚拟机中,将其解压到一个文件夹,然后将进程监视器(Procmon.exe)复制到同一个文件夹中,最后运行 Noriben.py Python 脚本,如下图所示:
当你运行Noriben时,它会启动进程监视器。一旦监控完成,你可以通过按 Ctrl + C 停止 Noriben,这将终止进程监视器。终止后,Noriben 会将结果存储在同一目录中的文本文件(.txt)和CSV 文件(.csv)中。文本文件将根据类别(如进程、文件、注册表和网络活动)将事件分隔成不同部分,如下图所示。还需注意,事件数量大大减少,因为它应用了预定义的过滤器,减少了大部分不必要的噪音:
CSV 文件包含按时间轴排序的所有事件(进程、文件、注册表和网络活动),如下面的截图所示:
文本文件和CSV文件可以提供不同的视角。如果你对基于类别的事件摘要感兴趣,可以查看文本文件;如果你对事件发生的顺序感兴趣,可以查看CSV文件。
3.4 使用 Wireshark 捕获网络流量
当恶意软件执行时,你将希望捕获由恶意软件运行所产生的网络流量;这将帮助你了解恶意软件使用的通信通道,并帮助确定基于网络的指示器。Wireshark(www.wireshark.org/)是一款数据包嗅探器,可以捕获网络流量。在第一章,恶意软件分析简介中介绍了如何在Linux 虚拟机上安装 Wireshark。要在 Linux 上启动 Wireshark,请运行以下命令:
$ sudo wireshark
要开始在网络接口上捕获流量,点击捕获 | 选项(或按Ctrl + K),选择网络接口,然后点击开始:
3.5 使用 INetSim 模拟服务
大多数恶意软件在执行时会连接到互联网(命令与控制服务器),而允许恶意软件连接到其 C2 服务器并不是一个好主意,而且有时这些服务器可能不可用。在恶意软件分析过程中,你需要在不允许恶意软件与实际的*命令与控制(C2)*服务器联系的情况下,确定恶意软件的行为,但同时,你需要提供恶意软件所需的所有服务,以便它能继续操作。
INetSim 是一个免费的基于 Linux 的软件套件,用于模拟标准的互联网服务(如 DNS、HTTP/HTTPS 等)。在第一章,恶意软件分析简介中介绍了如何在Linux 虚拟机上安装和配置INetSim。启动INetSim后,它会模拟各种服务,如下所示的输出所示,并且它还运行一个处理定向到非标准端口的虚拟服务:
$ sudo inetsim
INetSim 1.2.6 (2016-08-29) by Matthias Eckert & Thomas Hungenberg
Using log directory: /var/log/inetsim/
Using data directory: /var/lib/inetsim/
Using report directory: /var/log/inetsim/report/
Using configuration file: /etc/inetsim/inetsim.conf
Parsing configuration file.
Configuration file parsed successfully.
=== INetSim main process started (PID 2758) ===
Session ID: 2758
Listening on: 192.168.1.100
Real Date/Time: 2017-07-09 20:56:44
Fake Date/Time: 2017-07-09 20:56:44 (Delta: 0 seconds)
Forking services...
* irc_6667_tcp - started (PID 2770)
* dns_53_tcp_udp - started (PID 2760)
* time_37_udp - started (PID 2776)
* time_37_tcp - started (PID 2775)
* dummy_1_udp - started (PID 2788)
* smtps_465_tcp - started (PID 2764)
* dummy_1_tcp - started (PID 2787)
* pop3s_995_tcp - started (PID 2766)
* ftp_21_tcp - started (PID 2767)
* smtp_25_tcp - started (PID 2763)
* ftps_990_tcp - started (PID 2768)
* pop3_110_tcp - started (PID 2765)
[...............REMOVED.
..............]
* http_80_tcp - started (PID 2761)
* https_443_tcp - started (PID 2762)
done.
Simulation running.
除了模拟服务,INetSim还可以记录通信,并且可以配置为响应 HTTP/HTTPS 请求,并根据扩展名返回任何文件。例如,如果恶意软件从 C2 服务器请求一个可执行文件(.exe),INetSim可以向恶意软件返回一个虚拟的可执行文件。通过这种方式,你可以了解恶意软件在从 C2 服务器下载可执行文件后会做什么。
以下示例演示了INetSim的使用。在这个例子中,一个恶意软件样本在Windows 虚拟机上执行,并且在Linux 虚拟机上使用Wireshark捕获网络流量,未启用INetSim。以下截图显示了 Wireshark 捕获的流量。它显示了被感染的 Windows 系统(192.168.1.50)首先尝试通过解析 C2 域名与 C2 服务器通信,但由于我们的 Linux 虚拟机没有运行 DNS 服务器,该域名无法解析(如端口不可达消息所示):
这次,恶意软件被执行,网络流量通过运行 INetSim(模拟服务)的 Linux 虚拟机捕获。从下图可以看到,恶意软件首先解析 C2 域名,解析结果为 Linux 虚拟机的 IP 地址192.168.1.100。解析完成后,恶意软件通过 HTTP 通信下载文件(settings.ini):
从下图可以看到,HTTP 响应是由 INetSim 模拟的 HTTP 服务器提供的。在这种情况下,HTTP 请求中的User-Agent字段表明标准浏览器并未发起该通信,这样的指示可以用于创建网络签名:
通过模拟服务,成功确定恶意软件在执行后会从 C2 服务器下载文件。像 INetSim 这样的工具可以让安全分析师快速确定恶意软件的行为,并捕获其网络流量,而无需手动配置所有服务(如 DNS、HTTP 等)。
另一个替代INetSim的工具是FakeNet-NG(github.com/fireeye/flare-fakenet-ng),它可以通过模拟网络服务来拦截并重定向所有或特定的网络流量。
4. 动态分析步骤
在动态分析(行为分析)过程中,你将遵循一系列步骤来确定恶意软件的功能。以下是动态分析过程中涉及的步骤:
-
恢复到干净的快照:这包括将虚拟机恢复到干净的状态。
-
运行监控/动态分析工具:在此步骤中,你将在执行恶意软件样本之前运行监控工具。为了充分利用前一部分所介绍的监控工具,你需要以管理员权限运行它们。
-
执行恶意软件样本:在此步骤中,你将以管理员权限运行恶意软件样本。
-
停止监控工具:这包括在恶意软件二进制文件执行指定时间后终止监控工具。
-
分析结果:这包括收集监控工具的数据/报告并对其进行分析,以确定恶意软件的行为和功能。
5. 将一切整合起来:分析恶意软件可执行文件
一旦你了解了动态分析工具和动态分析过程中涉及的步骤,这些工具可以结合使用,以从恶意软件样本中提取最大的信息。在本节中,我们将执行静态和动态分析,以确定恶意软件样本(sales.exe)的特征和行为。
5.1 样本的静态分析
我们将从静态分析开始检查恶意软件样本。在静态分析中,由于恶意软件样本没有执行,因此可以在 Linux 虚拟机 或 Windows 虚拟机 上执行,使用在 第二章 静态分析 中讨论的工具和技术。我们将从确定 文件类型 和 加密哈希 开始。根据以下输出,恶意软件二进制文件是一个 32 位的可执行文件:
$ file sales.exe
sales.exe: PE32 executable (GUI) Intel 80386, for MS Windows
$ md5sum sales.exe
51d9e2993d203bd43a502a2b1e1193da sales.exe
使用 strings 工具从二进制文件中提取的 ASCII 字符串包含一组批处理命令的引用,看起来像是删除文件的命令。这些字符串还显示了对批处理文件(_melt.bat)的引用,这表明在执行时,恶意软件可能会创建一个批处理(.bat)文件并执行这些批处理命令。这些字符串还引用了 RUN 注册表键;这很有趣,因为大多数恶意软件会在 RUN 注册表键中添加一个条目,以便在重启后仍能保持在系统中:
!This program cannot be run in DOS mode.
Rich
.text
`.rdata
@.data
.rsrc
[....REMOVED....]
:over2
If not exist "
" GoTo over1
del "
GoTo over2
:over1
del "
_melt.bat
[....REMOVED....]
Software\Microsoft\Windows\CurrentVersion\Run
检查导入显示出对 文件系统 和 注册表 相关 API 调用的引用,表明恶意软件能够执行文件系统和注册表操作,如以下输出所示。WinExec 和 ShellExecuteA API 调用的存在,表明恶意软件有能力调用其他程序(创建新进程):
kernel32.dll
[.....REMOVED......]
SetFilePointer
SizeofResource
WinExec
WriteFile
lstrcatA
lstrcmpiA
lstrlenA
CreateFileA
CopyFileA
LockResource
CloseHandle
shell32.dll
SHGetSpecialFolderLocation
SHGetPathFromIDListA
ShellExecuteA
advapi32.dll
RegCreateKeyA
RegSetValueExA
RegCloseKey
从 VirusTotal 数据库查询哈希值显示 58 个杀毒软件的检测,签名名称表明我们可能正在处理一个名为 PoisonIvy 的恶意软件样本。要从 VirusTotal 执行哈希搜索,你需要互联网访问权限,如果要使用 VirusTotal 的公共 API,则需要一个 API 密钥,您可以通过注册 VirusTotal 账户来获取该密钥:
$ python vt_hash_query.py 51d9e2993d203bd43a502a2b1e1193da
Detections: 58/64
VirusTotal Results:
Bkav ==> None
MicroWorld-eScan ==> Backdoor.Generic.474970
nProtect ==> Backdoor/W32.Poison.11776.CM
CMC ==> Backdoor.Win32.Generic!O
CAT-QuickHeal ==> Backdoor.Poisonivy.EX4
ALYac ==> Backdoor.Generic.474970
Malwarebytes ==> None
Zillya ==> Dropper.Agent.Win32.242906
AegisLab ==> Backdoor.W32.Poison.deut!c
TheHacker ==> Backdoor/Poison.ddpk
K7GW ==> Backdoor ( 04c53c5b1 )
K7AntiVirus ==> Backdoor ( 04c53c5b1 )
Invincea ==> heuristic
Baidu ==> Win32.Trojan.WisdomEyes.16070401.9500.9998
Symantec ==> Trojan.Gen
TotalDefense ==> Win32/Poison.ZR!genus
TrendMicro-HouseCall ==> TROJ_GEN.R047C0PG617
Paloalto ==> generic.ml
ClamAV ==> Win.Trojan.Poison-1487
Kaspersky ==> Trojan.Win32.Agentb.jan
NANO-Antivirus ==> Trojan.Win32.Poison.dstuj
ViRobot ==> Backdoor.Win32.A.Poison.11776
[..................REMOVED...........................]
5.2 样本的动态分析
为了了解恶意软件的行为,本章讨论了动态分析工具,并遵循以下动态分析步骤:
-
Windows 虚拟机和 Linux 虚拟机都已恢复到干净的快照。
-
在 Windows 虚拟机上,Process Hacker 以管理员权限启动,用于确定进程属性,随后执行了 Noriben Python 脚本(该脚本又启动了 Process Monitor),以检查恶意软件与系统的交互。
-
在 Linux 虚拟机上,启动了 INetSim 模拟网络服务,执行了 Wireshark 并配置为捕获网络接口上的网络流量。
-
在所有监控工具运行时,恶意软件以管理员权限(右键 | 以管理员身份运行)执行了大约 40 秒。
-
40 秒后,Windows 虚拟机上的 Noriben 被停止,Linux 虚拟机上的 INetSim 和 Wireshark 被停止。
-
收集并检查了来自监控工具的结果,以了解恶意软件的行为。
在执行动态分析后,通过不同的监控工具确定了以下关于恶意软件的信息:
- 执行恶意样本 (
sales.exe) 后,创建了一个名为iexplorer.exe的新进程,进程 ID 是1272。进程可执行文件位于%Appdata%目录中。以下截图是 Process Hacker 显示的新创建进程的输出:
- 通过检查 Noriben 日志,可以确定恶意软件在
%AppData%目录中放置了一个名为iexplorer.exe的文件。文件名 (iexplorer.exe) 与 Internet Explorer (iexplore.exe) 浏览器的文件名相似。这种技术是攻击者有意使恶意二进制看起来像合法可执行文件的一种尝试:
[CreateFile] sales.exe:3724 > %AppData%\iexplorer.exe
放置文件后,恶意软件执行了该文件。由此产生的新进程 iexplorer.exe 就是 Process Hacker 显示的进程:
[CreateProcess] sales.exe:3724 > "%AppData%\iexplorer.exe"
然后,恶意软件放置了另一个名为 MDMF5A5.tmp_melt.bat 的文件,如下输出所示。此时可以推断出我们在静态分析期间找到的 _melt.bat 字符串与另一个名为 MDMF5A5.tmp 的字符串连接在一起,用于生成文件名 MDMF5A5.tmp_melt.bat。生成文件名后,恶意软件将此文件名保存到磁盘上:
[CreateFile] sales.exe:3724 > %LocalAppData%\Temp\MDMF5A5.tmp_melt.bat
然后通过调用 cmd.exe 执行放置的批处理 (.bat) 脚本:
[CreateProcess] sales.exe:3724 > "%WinDir%\system32\cmd.exe /c %LocalAppData%\Temp\MDMF5A5.tmp_melt.bat"
由于 cmd.exe 执行了批处理脚本,原始文件 (sales.exe) 和批处理脚本 (MDMF5A5.tmp_melt.bat) 都被删除,如下代码片段所示。此行为确认了批处理 (.bat) 文件的删除功能(回想一下,在字符串提取过程中发现了删除文件的批处理命令):
[DeleteFile] cmd.exe:3800 > %UserProfile%\Desktop\sales.exe
[DeleteFile] cmd.exe:3800 > %LocalAppData%\Temp\MDMF5A5.tmp_melt.bat
恶意二进制然后将删除文件的路径添加到 RUN 注册表键中以保持持久性,这使得恶意软件能够在系统重新启动后继续运行:
[RegSetValue] iexplorer.exe:1272 > HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run\HKLM Key = C:\Users\test\AppData\Roaming\iexplorer.exe
- 从 Wireshark 捕获的网络流量中可以看到,恶意软件解析了 C2 域并在端口
80上建立了连接:
端口 80 通信的 TCP 流,如下截图所示,不是标准的 HTTP 流量;这表明恶意软件可能使用了自定义协议或加密通信。在大多数情况下,恶意软件使用自定义协议或加密其网络流量以避开基于网络的签名。您需要对恶意二进制进行代码分析,以确定网络流量的性质。在接下来的章节中,您将学习执行代码分析的技术,以深入了解恶意软件二进制的内部工作原理:
比较放置样本 (iexplorer.exe) 的加密哈希和原始二进制 (sales.exe) 的哈希显示它们是相同的:
$ md5sum sales.exe iexplorer.exe
51d9e2993d203bd43a502a2b1e1193da sales.exe
51d9e2993d203bd43a502a2b1e1193da iexplorer.exe
总结来说,当恶意软件被执行时,它会将自身复制到 %AppData% 目录下,并命名为 iexplorer.exe,然后丢弃一个批处理脚本,该脚本的作用是删除原始二进制文件及其自身。恶意软件接着会在注册表中添加一个条目,以便每次系统启动时都能启动它。该恶意二进制文件可能会加密其网络流量,并使用非标准协议通过端口 80 与 命令与控制(C2) 服务器进行通信。
通过结合静态分析和动态分析,成功地确定了恶意二进制文件的特征和行为。这些分析技术还帮助识别了与恶意软件样本相关的网络和主机基础的指标。
事件响应团队利用从恶意软件分析中确定的指标,创建网络和主机基础的签名,以检测网络上的其他感染。当进行恶意软件分析时,记录下那些能够帮助你或你的事件响应团队检测网络上感染主机的指标。
6. 动态链接库(DLL)分析
动态链接库(DLL) 是一个包含函数(称为 导出函数 或 exports)的模块,这些函数可以被其他程序(如可执行文件或 DLL)使用。可执行文件可以通过从 DLL 导入来使用 DLL 中实现的函数。
Windows 操作系统包含许多导出各种函数的 DLL,这些函数被称为 应用程序编程接口(APIs)。这些 DLL 中包含的函数供进程用于与文件系统、进程、注册表、网络和图形用户界面(GUI)进行交互。
要在 CFF Explorer 工具中显示导出的函数,加载导出函数的 PE 文件并点击 Export Directory。下方的截图展示了 Kernel32.dll 导出的一些函数(它是一个操作系统 DLL,位于 C:\Windows\System32 目录)。Kernel32.dll 导出的一项函数是 CreateFile;该 API 函数用于创建或打开文件:
在下方的截图中,可以看到 notepad.exe 导入了 kernel32.dll 导出的部分函数,包括 CreateFile 函数。当你用记事本打开或创建文件时,它会调用在 Kernel32.dll 中实现的 CreateFile API:
在前面的示例中,notepad.exe 并不需要在其代码中实现创建或打开文件的功能。为了实现这一点,它只需导入并调用在 Kernel32.dll 中实现的 CreateFile API。实现 DLL 的优势在于,其代码可以被多个应用程序共享。如果一个应用程序想要调用 API 函数,它必须先加载导出该 API 的 DLL 副本到其内存空间中。
如果你想了解更多关于动态链接库的知识,请阅读以下文档:support.microsoft.com/en-us/help/815065/what-is-a-dll 和 msdn.microsoft.com/en-us/library/windows/desktop/ms681914(v=vs.85).aspx。
6.1 攻击者为什么使用 DLL
你经常会看到恶意软件作者将他们的恶意代码以 DLL 而非可执行文件的形式分发。以下列出了一些攻击者将其恶意代码实现为 DLL 的原因:
-
双击无法执行 DLL;DLL 需要一个主机进程来运行。通过将恶意代码分发为 DLL,恶意软件作者可以将他/她的 DLL 加载到任何进程中,包括合法进程如
Explorer.exe、winlogon.exe等。这种技术使攻击者能够隐藏恶意软件的行为,所有恶意活动看起来都是源自主机进程。 -
将 DLL 注入到已运行的进程中使攻击者能够在系统上持久存在。
-
当 DLL 被一个进程加载到其内存空间时,DLL 将访问整个进程的内存空间,从而使其能够操纵进程的功能。例如,攻击者可以将 DLL 注入到浏览器进程中,并通过重定向其 API 函数来窃取凭据。
-
分析 DLL 不像分析可执行文件那样直接,可能更加棘手。
大多数恶意软件样本会释放或下载一个 DLL,然后将该 DLL 加载到另一个进程的内存空间中。加载完 DLL 后,传播者/加载器组件会自行删除。因此,在进行恶意软件调查时,你可能只会找到 DLL。接下来的部分介绍了分析 DLL 的技术。
6.2 使用 rundll32.exe 分析 DLL
要确定恶意软件的行为并通过动态分析监控其活动,理解如何执行 DLL 是至关重要的。如前所述,DLL 需要一个进程来运行。在 Windows 上,可以使用 rundll32.exe 来启动 DLL 并调用 DLL 中导出的函数。以下是使用 rundll32.exe 启动 DLL 并调用导出函数的语法::
rundll32.exe <full path to dll>,<export function> <optional arguments>
与 rundll32.exe 相关的参数解释如下:
-
DLL 的完整路径: 指定 DLL 的完整路径,该路径不能包含空格或特殊字符。
-
导出函数: 这是 DLL 中加载后将调用的函数。
-
可选参数: 参数是可选的,如果提供,则在调用导出函数时将传递这些参数。
-
逗号: 这是放置在 DLL 的完整路径和导出函数之间的符号。导出函数对于语法的正确性是必需的。
6.2.1 rundll32.exe 的工作方式
理解rundll32.exe的工作原理对于避免在运行 DLL 时发生错误非常重要。当你使用前述命令行参数启动rundll32.exe时,rundll32.exe将执行以下步骤:
-
传递给
rundll32.exe的命令行参数首先会经过验证;如果语法不正确,rundll32.exe会终止。 -
如果语法正确,它会加载提供的 DLL。加载 DLL 后,DLL 的入口点函数将被执行(该函数会进一步调用
DLLMain函数)。大多数恶意软件会在DLLMain函数中实现其恶意代码。 -
加载 DLL 后,
rundll32.exe会获取导出函数的地址并调用该函数。如果无法确定该函数的地址,rundll32.exe将终止。 -
如果提供了可选参数,那么在调用导出函数时,这些可选参数将作为参数传递给该函数。
关于 rundll32 接口及其工作原理的详细信息,请参见本文:support.microsoft.com/en-in/help/164787/info-windows-rundll-and-rundll32-interface。
6.2.2 使用 rundll32.exe 启动 DLL
在恶意软件调查中,你会遇到不同版本的 DLL。了解如何识别和分析它们对于确定其恶意行为至关重要。以下示例涵盖了涉及 DLL 的不同场景。
示例 1 – 分析一个没有导出的 DLL
每当加载 DLL 时,它的入口点函数都会被调用(该函数会进一步调用其DLLMain函数)。攻击者可以在DLLMain函数中实现恶意功能(如键盘记录、信息窃取等),而无需导出任何函数。
在以下示例中,恶意 DLL(aa.dll)不包含任何导出,这说明所有恶意功能可能都实现于其DLLmain函数中,只有当 DLL 被加载时(从DLL 入口点调用),该函数才会被执行。从以下截图可以看出,恶意软件从wininet.dll导入函数(该库导出了与 HTTP 或 FTP 相关的函数)。这表明恶意软件可能在DLLMain函数内调用这些网络函数,通过 HTTP 或 FTP 协议与 C2 服务器进行交互:
你可能会认为,由于没有导出函数,可以使用以下语法执行一个 DLL:
C:\>rundll32.exe C:\samples\aa.dll
当你使用前述语法运行 DLL 时,DLL 不会成功执行;同时,你也不会收到任何错误提示。原因是,当rundll32.exe验证命令行语法时(第 1 步,详见第 6.2.1 节 rundll32.exe 的工作原理),语法检查未通过。因此,rundll32.exe会在未加载 DLL 的情况下退出。
你需要确保命令行语法正确,才能成功加载 DLL。以下输出中显示的命令应该能够成功运行 DLL。在以下命令中,test 是一个虚拟名称,并没有这样一个导出函数,它只是用来确保命令行语法是正确的。在运行以下命令之前,我们在本章中提到的各种监控工具(Process Hacker、Noriben、Wireshark、Inetsim)已启动:
C:\>rundll32.exe C:\samples\aa.dll,test
运行命令后,收到以下错误信息,但 DLL 已成功执行。在这种情况下,由于语法正确,rundll32.exe 加载了 DLL(步骤 2, 在 第 6.2.1 节 rundll32.exe 的工作原理 中提到)。因此,调用了它的 DLL 入口点 函数(进而调用了 DLLMain,其中包含恶意代码)。然后,rundll32.exe 尝试查找导出函数 test 的地址(这就是 步骤 3, 在 第 6.2.1 节 rundll32.exe 的工作原理 中提到)。由于找不到 test 的地址,显示了以下错误信息。尽管显示了错误信息,DLL 还是成功加载了(这正是我们希望监控其活动的原因):
执行后,恶意软件与 C2 域建立了 HTTP 连接,并下载了一个文件(Thanksgiving.jpg),如下所示的 Wireshark 输出所示:
示例 2 – 分析包含导出函数的 DLL
在这个示例中,我们将查看另一个恶意 DLL(obe.dll)。以下截图显示了该 DLL 导出的两个函数(DllRegisterServer 和 DllUnRegisterServer):
该 DLL 示例通过以下命令运行。尽管 obe.dll 已加载到 rundll32.exe 的内存中,但没有触发任何行为。这是因为 DLL 的入口点函数没有实现任何功能:
C:\>rundll32.exe c:\samples\obe.dll,test
另一方面,通过运行以下带有 DllRegisterServer 函数的示例,触发了与 C2 服务器的 HTTPS 通信。由此可以推断出 DLLRegisterServer 实现了网络功能:
C:\>rundll32.exe c:\samples\obe.dll,DllRegisterServer
以下截图显示了 Wireshark 捕获的网络流量:
你可以编写一个脚本来确定 DLL 中的所有导出函数(如在 第二章 静态分析 中所述),并在运行监控工具时按顺序调用它们。这种技术有助于理解每个导出函数的功能。DLLRunner(
github.com/Neo23x0/DLLRunner)是一个执行 DLL 中所有导出函数的 Python 脚本。
示例 3 – 分析接受导出参数的 DLL
以下示例展示了如何分析一个接受导出参数的 DLL。此示例中使用的 DLL 是通过 PowerPoint 传送的,具体描述见此链接:securingtomorrow.mcafee.com/mcafee-labs/threat-actors-use-encrypted-office-binary-format-evade-detection/。
DLL(SearchCache.dll)包含一个导出函数_flushfile@16,其功能是删除文件。该导出函数接受一个参数,即要删除的文件:
为了演示删除功能,创建了一个测试文件(file_to_delete.txt),并启动了监控工具。测试文件通过以下命令被传递给导出函数_flushfile@16。运行以下命令后,测试文件被从磁盘中删除:
rundll32.exe c:\samples\SearchCache.dll,_flushfile@16 C:\samples\file_to_delete.txt
以下是 Noriben 日志的输出,显示rundll32.exe删除文件(file_to_delete.txt):
Processes Created:[CreateProcess] cmd.exe:1100 > "rundll32.exe c:\samples\SearchCache.dll,_flushfile@16 C:\samples\file_to_delete.txt" [Child PID: 3348]
File Activity: [DeleteFile] rundll32.exe:3348 > C:\samples\file_to_delete.txt
为了确定导出函数接受的参数及其类型,您需要执行代码分析。您将在接下来的章节中学习代码分析技巧。
6.3 分析带有进程检查的 DLL
大多数情况下,使用rundll32.exe启动 DLL 时会正常工作,但某些 DLL 会检查它们是否在特定进程下运行(例如explorer.exe或iexplore.exe),并且如果它们在其他进程下运行(包括rundll32.exe),可能会改变行为或终止自己。在这种情况下,您需要将 DLL 注入到特定进程中以触发其行为。
使用RemoteDLL(securityxploded.com/remotedll.php)这样的工具,可以将 DLL 注入到系统中的任何正在运行的进程。它允许通过三种不同的方法注入 DLL;这很有用,因为如果一种方法失败,可以尝试另一种方法。
以下示例中使用的 DLL(tdl.dll)是TDSS Rootkit的一部分。该 DLL 不包含任何导出项;所有的恶意行为都在 DLL 的入口点函数中实现。使用以下命令运行 DLL 时,出现了一个错误,提示 DLL 初始化例程失败,这表明DLL 入口点函数未能成功执行:
为了理解触发错误的条件,进行了静态代码分析(逆向工程)。分析代码后发现,DLL 在其入口点函数中进行了检查,判断它是否在spoolsv.exe(打印机后台处理程序服务)下运行。如果它在任何其他进程下运行,则 DLL 初始化失败:
目前,暂时不用担心如何执行代码分析。您将在接下来的章节中学习如何执行代码分析的技巧。
为触发该行为,恶意 DLL 必须通过 RemoteDLL 工具注入到 spoolsv.exe 进程中。注入 DLL 到 spoolsv.exe 后,监控工具捕获到了以下活动。恶意软件在 C:\ 盘上创建了一个文件夹(resycled)和一个文件 autorun.inf。然后,它将一个文件 boot.com 投放到新创建的文件夹 C:\resycled 中:
[CreateFile] spoolsv.exe:1340 > C:\autorun.inf
[CreateFolder] spoolsv.exe:1340 > C:\resycled
[CreateFile] spoolsv.exe:1340 > C:\resycled\boot.com
恶意软件添加了以下注册表项;从这些添加的项中可以看出,恶意软件在注册表中存储了一些加密数据或配置信息:
[RegSetValue] spoolsv.exe:1340 > HKCR\extravideo\CLSID\(Default) = {6BF52A52-394A-11D3-B153-00C04F79FAA6}
[RegSetValue] spoolsv.exe:1340 > HKCR\msqpdxvx\msqpdxpff = 8379
[RegSetValue] spoolsv.exe:1340 > HKCR\msqpdxvx\msqpdxaff = 3368
[RegSetValue] spoolsv.exe:1340 > HKCR\msqpdxvx\msqpdxinfo =}gx~yc~dedomcyjloumllqYPbc
[RegSetValue] spoolsv.exe:1340 > HKCR\msqpdxvx\msqpdxid = qfx|uagbhkmohgn""YQVSVW_,(+
[RegSetValue] spoolsv.exe:1340 > HKCR\msqpdxvx\msqpdxsrv = 1745024793
以下截图显示了恶意软件在 80 端口上的 C2 通信:
在恶意软件调查过程中,你将遇到仅在被加载为服务时才会运行的 DLL。这类 DLL 被称为 服务 DLL。要完全理解服务 DLL 的工作原理,需要掌握代码分析和 Windows API 知识,这将在后续章节中介绍。
总结
动态分析是一种很好的技术,可以帮助理解恶意软件的行为,并确定其网络和主机相关的指标。你可以使用动态分析来验证在静态分析过程中获得的发现。将静态分析和动态分析相结合,有助于你更好地理解恶意软件的二进制文件。基本的动态分析有其局限性,若要深入了解恶意软件二进制文件的工作原理,就需要进行代码分析(逆向工程)。
例如,本章中使用的大部分恶意软件样本通过加密通信与其 C2 服务器进行通信。通过动态分析,我们仅能确定通信是加密的,但要了解恶意软件如何加密流量以及加密了哪些数据,你需要学习如何进行代码分析。
在接下来的几章中,你将学习执行代码分析的基础知识、工具和技术。
第四章:汇编语言与反汇编入门
静态分析和动态分析是理解恶意软件基本功能的绝佳技术,但这些技术并不能提供关于恶意软件功能的所有必要信息。恶意软件作者通常使用高级语言(如 C 或 C++)编写恶意代码,然后通过编译器将其编译成可执行文件。在你的调查过程中,你只能获得恶意可执行文件,而没有源代码。为了深入了解恶意软件的内部工作原理,并理解恶意二进制文件的关键方面,需要进行代码分析。
本章将介绍进行代码分析所需的概念和技能。为了更好地理解该主题,本章将结合 C 编程和汇编语言编程中的相关概念。为了理解本章涉及的概念,要求你具备基本的编程知识(最好是 C 编程)。如果你不熟悉基本的编程概念,可以从一本入门编程书籍开始学习(可以参考本章末尾提供的附加资源),然后再回来阅读本章。
从代码分析(逆向工程)角度将涵盖以下主题:
-
计算机基础、内存和 CPU
-
数据传输、算术运算和位运算
-
分支和循环
-
函数和栈
-
数组、字符串和结构体
-
x64 架构的概念
1. 计算机基础
计算机是一种处理信息的机器。计算机中的所有信息都是通过比特表示的。比特是一个基本单位,它可以取值 0 或 1。比特的集合可以表示一个数字、一个字符或任何其他信息。
基本数据类型:
一组 8 个比特组成一个字节。一个字节用两个十六进制数字表示,每个十六进制数字是 4 比特大小,称为半字节(nibble)。例如,二进制数 01011101 转换为十六进制是 5D。数字 5(0101)和数字 D(1101)就是这两个半字节:
除了字节外,还有其他数据类型,例如 word,它是 2 字节(16 位)大小;double word (dword) 是 4 字节(32 位);quadword (qword) 是 8 字节(64 位):
数据解释:
一个字节或字节序列可以有不同的解释。例如,5D 可以表示二进制数 01011101,或者十进制数 93,或者字符 ]。字节 5D 还可以表示一条机器指令,pop ebp。
类似地,两个字节序列 8B EC(word)可以表示 short int 35820 或一条机器指令,mov ebp,esp。
双字(dword)值 0x010F1000 可以解释为一个整数值 17764352,也可以解释为一个内存地址。这完全取决于如何解释字节,字节或字节序列的意义取决于其用途。
1.1 内存
主内存(RAM) 存储计算机的代码(机器码)和数据。计算机的主内存是一个字节数组(十六进制格式的字节序列),每个字节都标有一个唯一的编号,称为 地址。第一个地址从 0 开始,最后一个地址取决于所使用的硬件和软件。地址和值以十六进制表示:
1.1.1 数据在内存中的存储方式
在内存中,数据以 小端 格式存储;也就是说,低位字节存储在较低的地址,后续字节依次存储在内存中逐渐增高的地址:
1.2 CPU
中央处理单元(CPU) 执行指令(也叫 机器指令)。CPU 执行的指令以字节序列的形式存储在内存中。在执行指令时,需要的数据(也以字节序列形式存储)从内存中提取。
CPU 本身在其芯片内包含了一小部分内存,称为 寄存器组。寄存器用于存储在执行过程中从内存中提取的值。
1.2.1 机器语言
每个 CPU 都有一组它可以执行的指令。CPU 执行的指令构成了 CPU 的机器语言。这些机器指令以字节序列的形式存储在内存中,并被 CPU 提取、解释和执行。
编译器 是一种将用编程语言(如 C 或 C++)编写的程序转换为机器语言的程序。
1.3 程序基础
在本节中,你将学习编译过程和程序执行过程中发生的事情,以及程序执行时各个计算机组件如何相互作用。
1.3.1 程序编译
以下列表概述了可执行文件的编译过程:
-
源代码是用高级语言编写的,例如 C 或 C++。
-
程序的源代码通过编译器进行处理。编译器将用高级语言编写的语句转换为一种称为 目标文件 或 机器代码 的中间形式,这种形式不可读,旨在供处理器执行。
-
然后,目标代码会传递给链接器。链接器将目标代码与所需的库文件(DLL)链接,以生成可以在系统上运行的可执行文件:
1.3.2 硬盘上的程序
让我们通过一个示例来理解编译后的程序在磁盘上的样子。我们以一个简单的 C 程序为例,程序将字符串打印到屏幕:
#include <stdio.h>
int main() {
char *string = "This is a simple program";
printf("%s",string);
return 0;
}
上述程序经过编译器编译生成了一个可执行文件(print_string.exe)。在 PE Internals 工具中打开已编译的可执行文件(www.andreybazhan.com/pe-internals.html)会显示由编译器生成的五个节(.text、.rdata、.data、.rsrc和.reloc)。关于这些节的信息可以在第二章中找到,静态分析部分。这里我们将主要关注两个节:.text和.data。.data节的内容如下图所示:
在上面的截图中,你可以看到我们在程序中使用的字符串This is a simple program存储在文件偏移量0x1E00的.data节中。这个字符串不是代码,而是程序所需的数据。同样,.rdata节包含只读数据,有时也包含导入/导出信息。.rsrc节包含可执行文件使用的资源。
.text节的内容如下图所示:
显示在.text节中的字节序列(具体来说是35个字节,从文件偏移量0x400开始)是机器代码。我们编写的源代码经过编译器转换成了机器代码(或机器语言程序)。机器代码对于人类来说不易阅读,但处理器(CPU)知道如何解读这些字节序列。机器代码包含处理器将执行的指令。编译器将数据和代码分隔到磁盘上的不同节中。为了简化起见,我们可以将可执行文件视为包含代码(.text)和数据(.data、.rdata等):
1.3.3 程序在内存中的表现
在上一节中,我们检查了磁盘上可执行文件的结构。现在我们来理解当可执行文件被加载到内存时发生了什么。当双击可执行文件时,操作系统会为进程分配内存,并通过操作系统加载器将可执行文件加载到分配的内存中。以下简化的内存布局图有助于你理解这个概念;请注意,磁盘上可执行文件的结构与内存中的可执行文件结构相似:
在上面的示意图中,堆用于程序执行过程中进行动态内存分配,其内容是可变的。栈用于存储局部变量、函数参数和返回地址。你将在后面的章节中详细了解栈。
前面展示的内存布局大大简化了,组件的位置可以是任何顺序。内存中还包含了各种动态链接库(DLLs),这些在前面的图示中没有展示,为了简化起见。你将在接下来的章节中详细了解进程内存。
现在,让我们回到我们编译后的可执行文件(print_string.exe),并将其加载到内存中。该可执行文件已在x64dbg调试器中打开,调试器将可执行文件加载到了内存中(我们将在后面的章节中介绍x64dbg;现在我们将专注于可执行文件在内存中的结构)。在以下截图中,你可以看到可执行文件已加载到内存地址0x010F0000,并且可执行文件的所有部分也已加载到内存中。需要记住的一点是,你看到的内存地址是虚拟地址,而不是物理内存地址。虚拟地址最终会被转换为物理内存地址(你将在后面的章节中了解更多关于虚拟地址和物理地址的内容):
检查.data部分在内存地址0x010F3000的位置,可以看到字符串This is a simple program。
检查.text部分在内存地址0x010F1000的位置,可以看到字节序列,这就是机器码。
一旦包含代码和数据的可执行文件被加载到内存中,CPU 会从内存中取出机器码,解释并执行它。在执行机器指令时,所需的数据也会从内存中提取。在我们的示例中,CPU 从.text部分获取包含指令(在屏幕上打印)的机器码,并从.data部分获取要打印的字符串(数据)This is a simple program。以下图示将帮助你可视化 CPU 和内存之间的交互:
在执行指令时,程序还可能与输入/输出设备交互。在我们的示例中,当程序执行时,字符串被打印到计算机屏幕上(输出设备)。如果机器码中有接收输入的指令,处理器(CPU)将与输入设备(例如键盘)进行交互。
总结一下,程序执行时会执行以下步骤:
-
程序(包含代码和数据)被加载到内存中。
-
CPU 从内存中获取机器指令,解码并执行它。
-
CPU 从内存中获取所需的数据;数据也可以被写入内存。
-
CPU 可能会根据需要与输入/输出系统进行交互:
1.3.4 程序反汇编(从机器码到汇编代码)
正如你所预期的,机器码包含了关于程序内部工作的详细信息。例如,在我们的程序中,机器码包含了在屏幕上打印的指令,但对于人类来说,尝试理解机器码(它是以字节序列形式存储的)会非常困难。
一个反汇编器/调试器(如IDA Pro或x64dbg)是一个将机器码翻译成低级代码(称为汇编代码,即汇编语言程序)的程序,可以被读取和分析,以确定程序的工作原理。下图显示了机器码(.text部分的字节序列)被翻译成表示13条可执行指令(push ebp、mov ebp,esp等)的汇编指令。这些翻译后的指令称为汇编语言指令。
你可以看到,汇编指令比机器码更容易阅读。注意,反汇编器是如何将字节55翻译为可读的汇编指令push ebp,并将接下来的两个字节8B EC翻译为mov ebp,esp,依此类推:
从代码分析的角度来看,确定程序的功能主要依赖于理解这些汇编指令以及如何解释它们。
在本章的其余部分,你将学习理解汇编代码所需的技能,以便逆向工程恶意二进制文件。在接下来的章节中,你将学习进行代码分析所必需的 x86 汇编语言指令的概念;x86,也称为 IA-32(32 位),是 PC 上最常见的架构。Microsoft Windows 运行在 x86(32 位)架构和 Intel 64(x64)架构上。你将遇到的大多数恶意软件都是为 x86(32 位)架构编译的,并且可以在 32 位和 64 位 Windows 上运行。章节末,你将理解 x64 架构以及 x86 和 x64 之间的区别。
2. CPU 寄存器
如前所述,CPU 包含称为寄存器的特殊存储器。由于 CPU 可以比内存中数据的访问速度快得多,因此从内存中提取的值会暂时存储在这些寄存器中,以便执行操作。
2.1 通用寄存器
x86 CPU 有八个通用寄存器:eax,ebx,ecx,edx,esp,ebp,esi 和 edi。这些寄存器的大小为 32 位(4 字节)。程序可以按 32 位(4 字节)、16 位(2 字节)或 8 位(1 字节)值来访问这些寄存器。这些寄存器的低 16 位(2 字节)可以作为 ax,bx,cx,dx,sp,bp,si 和 di 来访问。eax,ebx,ecx 和 edx 的低 8 位(1 字节)可以分别引用为 al,bl,cl 和 dl。高 8 位可以通过 ah,bh,ch 和 dh 来访问。在以下示意图中,eax 寄存器包含 4 字节值 0xC6A93174。程序可以通过访问 ax 寄存器来访问低 2 字节(0x3174),通过访问 al 寄存器来访问低字节(0x74),而下一个字节(0x31)则可以通过使用 ah 寄存器来访问:
2.2 指令指针(EIP)
CPU 有一个特殊的寄存器,叫做 eip;它包含下一条指令的地址。当指令执行时,eip 将指向内存中下一条要执行的指令。
2.3 EFLAGS 寄存器
eflags 寄存器是一个 32 位的寄存器,寄存器中的每一位都是一个标志。EFLAGS 寄存器中的位用于指示计算的状态,并控制 CPU 操作。标志寄存器通常不会直接引用,但在执行计算或条件指令时,每个标志位都会被设置为 1 或 0。除了这些寄存器外,还有一些其他寄存器,称为段寄存器(cs,ss,ds,es,fs,和 gs),它们用于跟踪内存中的各个段。
3. 数据传输指令
汇编语言中的基本指令之一是 mov 指令。顾名思义,这条指令将数据从一个位置移动到另一个位置(从源位置到目标位置)。mov 指令的一般形式如下;这类似于高级语言中的赋值操作:
mov dst,src
mov 指令有不同的变体,接下来将会介绍。
3.1 将常量移入寄存器
mov 指令的第一种变体是将一个*常量(或立即数值)*移动到寄存器中。在以下示例中,;(分号)表示注释的开始;分号后面的内容不属于汇编指令。这只是一个简短的描述,帮助你理解这个概念:
mov eax,10 *; moves 10 into EAX register, same as eax=10*
mov bx,7 *; moves 7 in bx register, same as bx=7*
mov eax,64h *; moves hex value 0x64 (i.e 100) into EAX*
3.2 从寄存器到寄存器的值传送
将一个值从一个寄存器传送到另一个寄存器,可以通过将寄存器名称作为操作数放置到 mov 指令中来实现:
mov eax,ebx *; moves content of ebx into eax, i.e eax=ebx*
以下是两条汇编指令的示例。第一条指令将常量值 10 移入 ebx 寄存器。第二条指令将 ebx 寄存器的值(即 10)移入 eax 寄存器;因此,eax 寄存器将包含值 10:
mov ebx,10 *; moves 10 into ebx, ebx = 10*
mov eax,ebx *; moves value in ebx into eax, eax = ebx or eax = 10*
3.3 从内存到寄存器的值传送
在查看将值从内存移动到寄存器的汇编指令之前,我们先尝试理解值是如何存储在内存中的。假设你在 C 程序中定义了一个变量:
int val = 100;
以下列表概述了程序运行时发生的事情:
-
整数占用 4 个字节,因此整数
100作为 4 个字节(00 00 00 64)存储在内存中。 -
四个字节的顺序是按照之前提到的小端格式存储的。
-
整数
100存储在某个内存地址。假设100存储在从0x403000开始的内存地址中;你可以将这个内存地址看作是标记为val:
要将一个值从内存移动到寄存器中,你必须使用该值的地址。以下汇编指令将把存储在内存地址 0x403000 处的 4 字节移入寄存器 eax。方括号表示你要的是存储在该内存位置的值,而不是地址本身:
mov eax,[0x403000] *; eax will now contain 00 00 00 64 (i.e 100)*
请注意,在前面的指令中,你不需要在指令中指定 4 字节;根据目标寄存器(eax)的大小,它会自动确定移动多少字节。以下截图将帮助你理解执行前述指令后的情况:
在逆向工程中,你通常会看到类似下面的指令。方括号中可能包含一个寄存器、一个加到寄存器上的常量,或者一个寄存器加到另一个寄存器上。所有以下图示的指令都将把存储在方括号中指定的内存地址的值移动到寄存器中。最简单的记法是,方括号中的一切都代表一个地址:
mov eax,[ebx] *; moves value at address specifed by ebx register*
mov eax,[ebx+ecx] *; moves value at address specified by ebx+ecx*
mov ebx,[ebp-4] *; moves value at address specified by ebp-4*
另一个你通常会遇到的指令是 lea 指令,表示加载有效地址;这条指令将加载地址,而不是值:
lea ebx,[0x403000] *; loads the address 0x403000 into ebx*
lea eax, [ebx] *; if ebx = 0x403000, then eax will also contain 0x403000*
有时,你会遇到类似以下的指令。这些指令与前面提到的指令相同,都是将存储在内存地址(由 ebp-4 指定)中的数据传送到寄存器中。dword ptr 只是表示一个 4 字节(dword)的值从由 ebp-4 指定的内存地址移动到 eax:
mov eax,dword ptr [ebp-4] *; same as mov eax,[ebp-4]*
3.4 从寄存器到内存的值的移动
你可以通过交换操作数,将值从寄存器移动到内存,使得内存地址位于左侧(目标),而寄存器位于右侧(源):
mov [0x403000],eax *; moves 4 byte value in eax to memory location starting at 0x403000*
mov [ebx],eax *; moves 4 byte value in eax to the memory address specified by ebx*
有时,你会遇到类似以下的指令。这些指令将常量值移动到内存位置;dword ptr 只是指定一个 dword 值(4 字节)被移动到内存位置。同样,word ptr 指定一个 word(2 字节)被移动到内存位置:
mov dword ptr [402000],13498h *; moves dword value 0x13496 into the address 0x402000*
mov dword ptr [ebx],100 *; moves dword value 100 into the address specified by ebx*
mov word ptr [ebx], 100 *; moves a word 100 into the address specified by ebx*
在之前的情况下,如果ebx包含内存地址0x402000,那么第二条指令将100以00 00 00 64(4 字节)的形式复制到内存位置0x402000开始的地址,第三条指令将100以00 64(2 字节)的形式复制到内存位置0x40200开始的地址,如下所示:
让我们来看一个简单的挑战。
3.5 拆解挑战
以下是一个简单 C 代码片段的反汇编输出。你能搞清楚这段代码做了什么吗?并且你能将它翻译回伪代码(高级语言等效代码)吗?请运用到目前为止你学到的所有概念来解决这个挑战。挑战的答案将在下一节中介绍,解决挑战后我们还将回顾原始的 C 代码片段:
mov dword ptr [ebp-4],1 ➊
mov eax,dword ptr [ebp-4] ➋
mov dword ptr [ebp-8],eax ➌
3.6 反汇编解决方案
之前的程序将一个值从一个内存位置复制到另一个位置。在➊处,程序将dword值1复制到内存地址(由ebp-4指定)。在➋处,相同的值被复制到eax寄存器中,然后在➌处被复制到另一个内存地址ebp-8。
反汇编代码可能一开始难以理解,所以让我来分解一下,使其变得简单。我们知道,在像 C 这样的高级语言中,你定义的变量(例如int val;)其实只是一个内存地址的符号名称(如前所述)。根据这个逻辑,让我们识别内存地址引用并给它们起个符号名称。在反汇编程序中,我们有两个地址(方括号内):ebp-4 和 ebp-8。让我们给它们起名字,假设ebp-4 = a和ebp-8 = b。现在,程序应该像这样:
mov dword ptr [a],1 *; treat it as mov [a],1*
mov eax,dword ptr [a] *; treat it as mov eax,[a]*
mov dword ptr [b],eax *; treat it as mov [b],eax*
在高级语言中,当你给一个变量赋值,比如val = 1,值1被移动到由val变量表示的地址。在汇编中,这可以表示为mov [val], 1。换句话说,val = 1在高级语言中与汇编中的mov [val],1是等效的。运用这个逻辑,之前的程序可以写成一个高级语言的等效代码:
a = 1
eax = a
b = eax ➍
记住,寄存器是 CPU 用于临时存储的地方。所以,让我们将所有寄存器名称替换为=符号右侧的值(例如,将eax替换为其值a,位于➍)。结果代码如下所示:
a = 1
eax = a ➎
b = a
在之前的程序中,eax寄存器用于临时存储a的值,因此我们可以删除第➎行的条目(即删除=符号左侧包含寄存器的条目)。现在我们得到的是简化后的代码,如下所示:
a = 1
b = a
在高级语言中,变量有数据类型。让我们尝试确定这些变量a和b的数据类型。有时,可以通过了解变量如何被访问和使用来确定数据类型。从反汇编的代码中,我们知道dword值(4 字节)1被移入变量a,然后又复制到b。现在我们知道这些变量的大小为 4 字节,这意味着它们可能是int、float或指针类型。为了确定确切的数据类型,让我们考虑以下内容。
变量a和b不能是float类型,因为从反汇编代码中我们知道eax参与了数据传输操作。如果是浮点值,则会使用浮点寄存器,而不是使用像eax这样的通用寄存器。
在这种情况下,变量a和b不能是指针,因为值1不是一个有效的地址。所以,我们可以猜测a和b应该是int类型。
基于这些观察结果,我们现在可以将程序重写如下:
int a;
int b;
a = 1;
b = a;
现在我们已经解决了这个问题,让我们来看一下反汇编输出的原始 C 代码片段。原始 C 代码片段如下所示。将其与我们确定的结果进行对比。注意,尽管无法完全恢复原始的 C 程序(并不总是能恢复出完全相同的 C 程序),但我们仍然能够构建一个与原始程序类似的程序,现在也更容易确定程序的功能:
int x = 1;
int y;
y = x;
如果你正在反汇编一个较大的程序,那么标记所有内存地址会非常困难。通常,你会使用反汇编器或调试器的功能来重命名内存地址并进行代码分析。你将在下一章学习如何使用反汇编器的功能进行代码分析。当你处理较大的程序时,最好将程序分解成小块代码,翻译成你熟悉的某种高级语言,然后对其余的代码块做同样的事情。
4. 算术操作
在汇编语言中,你可以执行加法、减法、乘法和除法。加法和减法分别使用add和sub指令进行。这些指令接受两个操作数:目标和源。add指令将源操作数与目标操作数相加,并将结果存储在目标中。sub指令从目标操作数中减去源操作数,结果存储在目标中。这些指令根据操作设置或清除eflags寄存器中的标志。这些标志可以在条件语句中使用。如果结果为零,sub指令会设置零标志(zf),如果目标值小于源值,则设置进位标志(cf)。以下概述了这些指令的一些变体:
add eax,42 *; same as eax = eax+42*
add eax,ebx *; same as eax = eax+ebx*
add [ebx],42 *; adds 42 to the value in address specified by ebx*
sub eax, 64h *; subtracts hex value 0x64 from eax, same as eax = eax-0x64*
有一个特殊的增量(inc)和减量(dec)指令,可用于向寄存器或内存位置加1或减1:
inc eax *; same as eax = eax+1*
dec ebx *; same as ebx = ebx-1*
乘法是使用mul指令完成的。mul指令只接受一个操作数;该操作数与al、ax或eax寄存器的内容相乘。乘法的结果存储在ax、dx 和 ax或edx 和 eax寄存器中。
如果mul指令的操作数为8 位(1 字节),则与 8 位al寄存器相乘,并将乘积存储在ax寄存器中。如果操作数为16 位(2 字节),则与ax寄存器相乘,并将乘积存储在dx和ax寄存器中。如果操作数为32 位(4 字节),则与eax寄存器相乘,并将乘积存储在edx和eax寄存器中。将乘积存储在比输入值大一倍的寄存器中的原因是,当两个值相乘时,输出值可能比输入值大得多。以下概述了mul指令的变体:
mul ebx *;ebx is multiplied with eax and result is stored in EDX and EAX*
mul bx *;bx is multiplied with ax and the result is stored in DX and AX*
除法是使用div指令执行的。div只接受一个操作数,可以是寄存器或内存引用。要执行除法,将被除数(要除的数)放入edx 和 eax寄存器中,其中edx保存最高有效dword。执行div指令后,商存储在eax中,余数存储在edx寄存器中:
div ebx *; divides the value in EDX:EAX by EBX*
4.1 反汇编挑战
让我们接受另一个简单的挑战。以下是一个简单 C 程序的反汇编输出。您能够弄清楚这个程序的功能,并将其翻译回伪代码吗?
mov dword ptr [ebp-4], 16h
mov dword ptr [ebp-8], 5
mov eax, [ebp-4]
add eax, [ebp-8]
mov [ebp-0Ch], eax
mov ecx, [ebp-4]
sub ecx, [ebp-8]
mov [ebp-10h], ecx
4.2 反汇编解决方案
您可以逐行阅读代码并尝试确定程序的逻辑,但如果将其翻译回某种高级语言,则会更容易。为了理解前述程序,让我们使用之前介绍的相同逻辑。前述代码包含四个内存引用。首先,让我们标记这些地址 - ebp-4=a、ebp-8=b、ebp-0Ch=c和ebp-10H=d。标记地址后,它翻译为以下内容:
mov dword ptr [a], 16h
mov dword ptr [b], 5
mov eax, [a]
add eax, [b]
mov [c], eax
mov ecx, [a]
sub ecx, [b]
mov [d], ecx
现在,让我们将上述代码翻译成伪代码(高级语言等效)。代码如下:
a = 16h *; h represents hexadecmial, so 16h (0x16) is 22 in decimal*
b = 5
eax = a
eax = eax + b ➊
c = eax ➊
ecx = a
ecx = ecx-b ➊
d = ecx ➊
将所有寄存器名称替换为等号右侧(即➊处)的相应值,我们得到以下代码:
a = 22
b = 5
eax = a ➋
eax = a+b ➋
c = a+b
ecx = a ➋
ecx = a-b ➋
d = a-b
在去除所有左侧含有寄存器的=符号处的条目后,我们得到以下代码:
a = 22
b = 5
c = a+b
d = a-b
现在,我们已将八行汇编代码简化为四行伪代码。此时,你可以看出代码执行的是加法和减法操作,并将结果存储。你可以根据代码中变量的大小和使用方式(上下文)来推断变量类型,如前所述。变量a和b用于加法和减法,因此这些变量必须是整数类型,而变量c和d存储整数加法和减法的结果,因此可以推测它们也是整数类型。现在,前面的代码可以写成如下形式:
int a,b,c,d;
a = 22;
b = 5;
c = a+b;
d = a-b;
如果你对反汇编输出的原始 C 程序感到好奇,以下是原始的 C 程序来满足你的好奇心。注意,我们是如何将汇编代码写回到其等效的高级语言中的:
int num1 = 22;
int num2 = 5;
int diff;
int sum;
sum = num1 + num2;
diff = num1 - num2;
5. 位运算
在这一部分,你将学习操作位的汇编指令。位是从最右侧开始编号的;最右边的位(最低有效位)的位位置是0,位位置向左增加。最左边的位称为最高有效位。以下是一个示例,展示了一个字节5D (0101 1101)的位及其位位置。相同的逻辑适用于word、dword和qword:
位运算指令之一是not指令;它只需要一个操作数(既作为源操作数又作为目标操作数),并将所有位取反。如果eax寄存器包含FF FF 00 00 (11111111 11111111 00000000 00000000),则以下指令会将所有位取反,并将结果存储在eax寄存器中。因此,eax寄存器将包含00 00 FF FF (00000000 00000000 11111111 11111111):
not eax
and、or和xor指令执行位与(and)、位或(or)和位异或(xor)操作,并将结果存储到目标位置。这些操作类似于 C 语言或 Python 语言中的and (&)、or (|)和xor (^)操作。以下示例中,and操作会对bl寄存器的位0和cl寄存器的位0、bl寄存器的位1和cl寄存器的位1等执行操作。结果存储在bl寄存器中:
and bl,cl *; same as bl = bl & cl*
在前面的示例中,如果bl寄存器包含5 (0000 0101),cl寄存器包含6 (0000 0110),那么and操作的结果将是4 (0000 0100),如图所示:
bl: 0000 0101
cl: 0000 0110
--------------------------------------
After and operation bl: 0000 0100
同样,or和xor操作会对操作数的相应位执行操作。以下展示了一些示例指令:
or eax,ebx *; same as eax = eax | ebx*
xor eax,eax *; same eax = eax^eax, this operation clears the eax register*
shr(右移位)和shl(左移位)指令需要两个操作数(目标和计数)。目标可以是寄存器或内存引用。其一般形式如下所示。这两条指令将目标中的比特按照计数操作数指定的位数向右或向左移动;这些指令执行的操作与 C 或 Python 编程语言中的shift left (<<)和shift right(>>)相同:
shl dst,count
在下面的示例中,第一条指令(xor eax, eax)清空了eax寄存器,随后将4移入al寄存器,al寄存器的内容(即4 (0000 0100))被左移了2位。经过该操作后(最左侧的两个比特被移除,右侧添加了两个0比特),操作完成后,al寄存器将包含0001 0000(即0x10):
xor eax,eax
mov al,4
shl al, 2
有关位运算符如何工作的详细信息,请参阅en.wikipedia.org/wiki/Bitwise_operations_in_C和www.programiz.com/c-programming/bitwise-operators。
rol(左循环移位)和ror(右循环移位)指令类似于移位指令。与移位操作不同,它们不会移除被移位的比特,而是将它们旋转到另一端。以下是一些示例指令:
rol al,2
在前面的示例中,如果al包含0x44 (0100 0100),则rol操作的结果将是0x11 (0001 0001)。
6. 分支与条件语句
本节将重点讨论分支指令。到目前为止,你已经看到了按顺序执行的指令;但是在许多情况下,你的程序需要在不同的内存地址执行代码(如if/else语句、循环、函数等)。这可以通过使用分支指令来实现。分支指令将执行控制转移到不同的内存地址。为了进行分支,汇编语言中通常使用跳转指令。跳转分为两种:条件跳转和无条件跳转。
6.1 无条件跳转
在无条件跳转中,跳转总是会发生。jmp指令告诉 CPU 去执行不同内存地址的代码。这类似于 C 语言中的goto语句。当执行以下指令时,控制权会转移到跳转地址,并从那里开始执行:
jmp <jump address>
6.2 条件跳转
在条件跳转中,控制会根据某些条件转移到一个内存地址。要使用条件跳转,你需要能够改变标志(设置或清除)的指令。这些指令可以执行算术操作或按位操作。x86 指令提供了 cmp 指令,它将*第二操作数(源操作数)从第一操作数(目标操作数)*中减去,并在不将差值存储到目标中的情况下改变标志。在以下指令中,如果 eax 包含值 5,那么 cmp eax,5 会设置零标志(zf=1),因为这次操作的结果为零:
cmp eax,5 *; subtracts eax from 5, sets the flags but result is not stored*
另一种改变标志而不存储结果的指令是 test 指令。test 指令执行按位 and 操作,并在不存储结果的情况下改变标志。在以下指令中,如果 eax 的值为零,那么零标志将被设置(zf=1),因为当你 and 0 与 0 时,结果是 0:
test eax,eax *; performs and operation, alters the flags but result in not stored*
cmp 和 test 指令通常与条件 jump 指令一起使用,用于决策判断。
条件跳转指令有几种变体;这里展示了其一般格式:
jcc <address>
上述格式中的 cc 表示条件。这些条件是根据 eflags 寄存器中的位来评估的。以下表格列出了不同的条件跳转指令、它们的别名以及用来评估条件的 eflags 寄存器中的位:
| 指令 | 描述 | 别名 | 标志 |
|---|---|---|---|
jz | 如果为零则跳转 | `je** | **zf=1` |
jnz | 如果不为零则跳转 | `jne** | **zf=0` |
jl | 如果小于则跳转 | `jnge** | **sf=1` |
jle | 如果小于或等于则跳转 | `jng** | **zf=1 或 sf=1` |
jg | 如果大于则跳转 | `jnle** | **zf=0 和 sf=0` |
jge | 如果大于或等于则跳转 | `jnl** | **sf=0` |
jc | 如果进位则跳转 | `jb,jnae** | **cf=1` |
jnc | 如果无进位则跳转 | `jnb,jae** | ** .` |
6.3 if 语句
从逆向工程的角度来看,识别分支/条件语句非常重要。为了做到这一点,理解分支/条件语句(如 if、if-else 和 if-else if-else)是如何翻译成汇编语言是至关重要的。让我们看一个简单的 C 程序的例子,并尝试理解 if 语句是如何在汇编层面实现的:
if (x == 0) {
x = 5;
}
x = 2;
在之前的 C 程序中,如果条件为真(if x==0),则执行if块内的代码;否则,跳过if块,控制转移到x=2。可以把控制转移理解为跳转。现在,问问自己:什么时候会发生跳转?当x不等于0时就会发生跳转。这正是前述代码在汇编语言中的实现方式(如下所示);请注意,在第一条汇编指令中,x与0进行了比较,第二条指令中,当x不等于0时,将跳转到end_if(换句话说,它会跳过mov dword ptr [x],5并执行mov dword ptr [x],2)。请注意,C 程序中的相等条件(==)在汇编语言中被反转为“不等于”(jne):
cmp dword ptr [x], 0
jne end_if
mov dword ptr [x], 5
end_if:
mov dword ptr [x], 2
以下截图展示了 C 语言编程语句和对应的汇编指令:
6.4 If-Else 语句
现在,大家一起看看if/else语句是如何转换为汇编语言的。我们以以下 C 代码为例:
if (x == 0) {
x = 5;
}
else {
x = 1;
}
在上述代码中,尝试确定在什么情况下会发生跳转(控制会被转移)。有两种情况:如果x不等于0,跳转到else块;或者,如果x等于0(if x == 0),那么在执行x=5(if块的结尾)后,将跳转到跳过else块,直接执行else块后的代码。
以下是该 C 程序的汇编语言翻译;请注意,在第一行中,x的值与0进行了比较,如果x不等于0(条件被反转,如前所述),将跳转到else块。在else块之前,请注意有一个无条件跳转到end。这个跳转确保了如果x等于0,执行完if块内的代码后,跳过else块并直接到达程序末尾:
cmp dword ptr [x], 0
jne else
mov dword ptr [x], 5
jmp end
else:
mov dword ptr [x], 1
end:
6.5 If-Elseif-Else 语句
以下是包含if-ElseIf-else语句的 C 代码:
if (x == 0) {
x = 5;
}
else if (x == 1) {
x = 6;
}
else {
x = 7;
}
根据上述代码,尝试确定何时会发生跳转(控制转移)。有两个条件跳转点;如果x不等于0,将跳转到else_if块,如果x不等于1(这是else if中的条件检查),跳转将发生到else。此外,还有两个无条件跳转:在if块内x=5(if块结束时)和在else if内x=6(else if块结束时)。这两个无条件跳转跳过了else语句,直接到达程序末尾。
以下是显示条件跳转和无条件跳转的汇编语言翻译:
cmp dword ptr [ebp-4], 0
jnz else_if
mov dword ptr [ebp-4], 5
jmp short end
else_if:
cmp dword ptr [ebp-4], 1
jnz else
mov dword ptr [ebp-4], 6
jmp short end
else:
mov dword ptr [ebp-4], 7
end:
6.6 汇编反汇编挑战
以下是程序的反汇编输出;让我们将以下代码转换为它的高级语言等效形式。运用你之前学到的技术和概念来解决这个挑战:
mov dword ptr [ebp-4], 1
cmp dword ptr [ebp-4], 0
jnz loc_40101C
mov eax, [ebp-4]
xor eax, 2
mov [ebp-4], eax
jmp loc_401025
loc_40101C:
mov ecx, [ebp-4]
xor ecx, 3
mov [ebp-4], ecx
loc_401025:
6.7 反汇编解决方案
我们从为地址(ebp-4)分配符号名称开始。将符号名称分配给内存地址引用后,我们得到以下代码:
mov dword ptr [x], 1
cmp dword ptr [x], 0 ➊
jnz loc_40101C ➋
mov eax, [x] ➍
xor eax, 2
mov [x], eax
jmp loc_401025 ➌
loc_40101C:
mov ecx, [x] ➎
xor ecx, 3
mov [x], ecx ➏
loc_401025:
在前面的代码中,注意 ➊ 和 ➋ 处的 cmp 和 jnz 指令(这是一个条件语句),并注意 jnz 与 jne 相同(跳转如果不相等)。既然我们已经确定了条件语句,让我们尝试确定这是什么类型的条件语句(if、if/else、if/else if/else 等等);为此,请关注跳转。➋ 处的条件跳转跳转到 loc_401010C,而在 loc_40101C 之前,有一个无条件跳转到 loc_401025。根据我们之前学到的知识,这具备了 if-else 语句的特征。准确来说,➍ 到 ➌ 之间的代码是 if 块的一部分,而 ➎ 到 ➏ 之间的代码是 else 块的一部分。为了提高可读性,我们将 loc_40101C 重命名为 else,将 loc_401025 重命名为 end:
mov dword ptr [x], 1 ➐
cmp dword ptr [x], 0 ➊
jnz else ➋
mov eax, [x] ➍
xor eax, 2
mov [x], eax ➑
jmp end ➌
else:
mov ecx, [x] ➎
xor ecx, 3
mov [x], ecx ➏
end:
在前面的汇编代码中,x 在 ➐ 处被赋值为 1;x 的值与 0 比较,如果等于 0(➊ 和 ➋),则 x 与 2 做异或运算,并将结果存储回 x(➍ 到 ➑)。如果 x 不等于 0,则 x 与 3 做异或运算(➎ 到 ➏)。
阅读汇编代码有点复杂,因此让我们将前面的代码写成高级语言的等效代码。我们知道 ➊ 和 ➋ 是一个 if 语句,你可以将其理解为“如果 x 不等于 0,则跳转到 else” (记住 jnz 是 jne 的别名)。
如果你回想一下,观察 C 代码如何转换为汇编,条件在转换为汇编代码时被反转了。既然我们现在看到的是汇编代码,为了将这些语句写回到高级语言,你需要反转条件。为此,问问自己这个问题:在 ➋ 处,何时跳转不会发生?跳转不会发生的情况是当 x 等于 0 时,因此你可以将前面的代码写成伪代码,如下所示。请注意,在以下代码中,cmp 和 jnz 指令被转换为一个 if 语句;另外,注意条件是如何被反转的:
x = 1
if(x == 0)
{
eax = x
eax = eax ^ 2 ➒
x = eax ➒
}
else {
ecx = x
ecx = ecx ^ 3 ➒
x = ecx ➒
}
现在我们已经确定了条件语句,接下来让我们将 = 操作符右侧的所有寄存器(在 ➒ 处)替换为它们对应的值。这样做之后,我们得到以下代码:
x = 1
if(x == 0)
{
eax = x ➓
eax = x ^ 2 ➓
x = x ^ 2
}
else {
ecx = x ➓
ecx = x ^ 3 ➓
x = x ^ 3
}
移除所有包含在 = 操作符左侧的寄存器(在 ➓ 处),我们得到以下代码:
x = 1;
if(x == 0)
{
x = x ^ 2;
}
else {
x = x ^ 3;
}
如果你感兴趣,下面是拆解挑战中使用的原始 C 程序,与你在前面的代码片段中看到的进行比较。正如你所见,我们能够将多行汇编代码还原回它们对应的高级语言代码。现在,相比直接阅读汇编代码,这段代码更容易理解:
int a = 1;
if (a == 0)
{
a = a ^ 2;
}
else {
a = a ^ 3;
}
7. 循环
循环会执行一段代码,直到某个条件满足为止。最常见的两种循环类型是 for 和 while。到目前为止,你看到的跳转和条件跳转都是向前跳转,而循环是向后跳转的。首先,让我们理解 for 循环的功能。for 循环的一般形式如下所示:
for (initialization; condition; update_statement ) {
block of code
}
这是 for 语句的工作原理。initialization 语句只执行一次,之后会评估 condition;如果条件为真,则执行 for 循环内部的代码块,然后执行 update_statement。
while 循环与 for 循环相同。在 for 循环中,initialization、condition 和 update_statment 一起指定,而在 while 循环中,initialization 与 condition 检查是分开的,并且 update_statement 在循环内部指定。while 循环的一般形式如下所示:
initialization
while (condition)
{
block of code
update_statement
}
让我们通过以下来自简单 C 程序的代码片段,了解循环在汇编级别是如何实现的:
int i;
for (i = 0; i < 5; i++) {
}
上述代码可以使用 while 循环来编写,如下所示:
int i = 0;
while (i < 5) {
i++;
}
我们知道,跳转用于实现条件和循环,因此让我们从跳转的角度来思考。在 while 和 for 循环中,我们试图确定所有会导致跳转的情况。在这两种情况下,当 i 大于或等于 5 时,跳转会发生,控制会转移到循环外(换句话说,跳到循环之后)。当 i 小于 5 时,while 循环内部的代码会执行,且在执行 i++ 后会进行向后的跳转,以再次检查条件。
以下是前述代码在汇编语言中的实现方式(如下所示)。在下面的汇编代码中,在 ➊ 处,注意有一个跳转到某个地址(标记为 while_start);这表示一个循环。在循环内部,条件在 ➋ 和 ➌ 通过使用 cmp 和 jge(如果大于或等于则跳转)指令进行检查;这里的代码在检查 i 是否大于或等于 5。如果满足该条件,则跳转到 end(跳出循环)。注意,在 ➌ 处,C 语言中的 小于**(**<) 条件通过 jge 指令被反转为 大于或等于**(**>=)。初始化在 ➍ 处进行,其中 i 被赋值为 0:
mov [i],0 ➍
while_start:
cmp [i], 5 ➋
jge end ➌
mov eax, [i]
add eax, 1
mov [i], eax
jmp while_start ➊
end:
以下图显示了 C 编程语句和对应的汇编指令:
7.1 拆解挑战
让我们将以下代码转换为其高级等效代码。使用你到目前为止学到的技术和概念来解决这个挑战:
mov dword ptr [ebp-8], 0
mov dword ptr [ebp-4], 0
loc_401014:
cmp dword ptr [ebp-4], 4
cmp dword ptr [ebp-4], 4
jge loc_40102E
mov eax, [ebp-8]
add eax, [ebp-4]
mov [ebp-8], eax
mov ecx, [ebp-4]
add ecx, 1
mov [ebp-4], ecx
jmp loc_401014
loc_40102E:
7.2 反汇编解决方案
前面的代码包含两个内存地址(ebp-4和ebp-8);我们将ebp-4重命名为x,将ebp-8重命名为y。修改后的代码如下所示:
mov dword ptr [y], 1
mov dword ptr [x], 0
loc_401014:
cmp dword ptr [x], 4 ➋
jge loc_40102E ➌
mov eax, [y]
add eax, [x]
mov [y], eax
mov ecx, [x] ➎
add ecx, 1
mov [x], ecx ➏
jmp loc_401014 ➊
loc_40102E: ➍
在前面的代码中,在➊处,有一个跳转到loc_401014的操作,表示这是一个循环;因此,我们将loc_401014重命名为loop。在➋和➌处,检查变量x的条件(使用cmp和jge);代码在检查x是否大于或等于4。如果条件成立,它将跳出循环,跳转到loc_40102E(在➍处)。x的值会增加1(从➎到➏),这就是更新语句。基于这些信息,可以推测x是控制循环的循环变量。现在,我们可以将前面的代码写成高级语言的等效代码;但为了做到这一点,记住我们需要将条件从jge(大于或等于时跳转)反转为小于时跳转。修改后的代码如下所示:
y = 1
x = 0
while (x<4) {
eax = y
eax = eax + x ➐
y = eax ➐
ecx = x
ecx = ecx + 1 ➐
x = ecx ➐
}
将=运算符右侧的所有寄存器(在➐处)替换为它们之前的值,我们得到以下代码:
y = 1
x = 0
while (x<4) {
eax = y ➑
eax = y + x ➑
y = y + x
ecx = x ➑
ecx = x + 1 ➑
x = x + 1
}
现在,去除所有在=符号左侧包含寄存器的条目(在➑处),我们得到以下代码:
y = 1;
x = 0;
while (x<4) {
y = y + x;
x = x + 1;
}
如果你感到好奇,以下是反汇编输出的原始 C 程序。比较我们之前确定的代码和下面来自原始程序的代码;注意到如何逆向工程并反编译反汇编输出到其原始等效代码:
int a = 1;
int i = 0;
while (i < 4) {
a = a + i;
i++;
}
8. 函数
函数是一个执行特定任务的代码块;通常,程序包含许多函数。当调用一个函数时,控制权会转移到不同的内存地址。然后,CPU 执行该内存地址处的代码,并在代码执行完毕后返回(控制权转移回来)。函数包含多个组成部分:函数可以通过参数接收数据作为输入,具有包含执行代码的函数体,包含用于临时存储值的局部变量,并且可以输出数据。
参数、局部变量和函数控制流都存储在内存中的一个重要区域——栈。
8.1 栈
栈是操作系统在创建线程时分配的一块内存区域。栈是以 后进先出(LIFO) 的结构组织的,这意味着你压入栈中的最新数据将是第一个从栈中移除的数据。你通过使用 push 指令将数据(称为 压栈)压入栈中,使用 pop 指令从栈中移除数据(称为 弹栈)。push 指令将一个 4 字节 的值压入栈中,pop 指令从栈顶弹出一个 4 字节 的值。push 和 pop 指令的通用形式如下所示:
push source ; pushes source on top of the stack
pop destination ; copies value from the top of the stack to the destination
栈是从高地址增长到低地址的。这意味着,当栈被创建时,esp 寄存器(也称为 栈指针)指向栈顶(高地址),随着你使用 push 指令将数据压入栈中,esp 寄存器会减少 4(esp-4)指向一个更低的地址。当你使用 pop 指令弹出一个值时,esp 会增加 4(esp+4)。让我们看看以下汇编代码,尝试理解栈的内部运作:
push 3
push 4
pop ebx
pop edx
在执行上述指令之前,esp 寄存器指向栈顶(例如,地址为 0xff8c),如图所示:
在执行第一条指令(push 3)后,ESP 被减去 4(因为 push 指令将一个 4 字节 的值压入栈中),并且值 3 被放入栈中;此时,ESP 指向栈顶,地址为 0xff88。执行第二条指令(push 4)后,esp 再次减去 4;此时,esp 的值为 0xff84,它现在是栈顶。当执行 pop ebx 时,栈顶的值 4 被移到 ebx 寄存器中,esp 增加了 4(因为 pop 从栈中移除一个 4 字节 的值)。因此,esp 此时指向栈顶,地址为 0xff88。同样地,当执行 pop edx 指令时,栈顶的值 3 被放入 edx 寄存器中,esp 返回到原来的位置 0xff8c:
在上图中,虽然栈中的值从逻辑上被移除,但它们在内存中物理上仍然存在。而且,注意到最近压入的值(4)是第一个被移除的。
8.2 调用函数
汇编语言中的 call 指令可以用来调用一个函数。call 指令的通用形式如下所示:
call <some_function>
从代码分析的角度来看,可以将some_function视为一个包含代码块的地址。当执行call指令时,控制权转移到some_function(一个代码块),但在此之前,它会通过将下一个指令的地址(即call <some_function>后面的指令)推入堆栈来保存该地址。推入堆栈的call指令后的地址被称为返回地址。一旦some_function执行完毕,存储在堆栈上的返回地址会从堆栈中弹出,执行将从弹出的地址继续。
8.3 从函数返回
在汇编语言中,要从函数返回,使用ret指令。该指令从堆栈顶端弹出地址;弹出的地址会被放入eip寄存器中,然后控制权转移到该弹出的地址。
8.4 函数参数和返回值
在x86架构中,函数接受的参数会被推入堆栈中,返回值则放置在eax寄存器中。
为了理解这个函数,让我们以一个简单的 C 程序为例。当执行以下程序时,main()函数调用test函数并传递两个整数参数:2和3。在test函数内部,参数的值被复制到局部变量x和y中,并且test返回一个值0(返回值):
int test(int a, int b)
{
int x, y;
x = a;
y = b;
return 0;
}
int main()
{
test(2, 3);
return 0;
}
首先,让我们看看main()函数中的语句如何被翻译成汇编指令:
push 3 ➊
push 2 ➋
call test ➌
add esp, 8 ; after test is exectued, the control is returned here
xor eax, eax
前三条指令,➊、➋和➌,表示函数调用test(2,3)。参数(2和3)在函数调用之前按反向顺序(从右到左)推入堆栈,第二个参数3在第一个参数2之前被推入堆栈。推送参数后,函数test()在➌处被调用;结果,下一个指令add esp,8的地址被推入堆栈(这就是返回地址),然后控制权转移到test函数的起始地址。假设在执行指令➊、➋、➌之前,esp(栈指针)指向堆栈顶部的地址0xFE50。以下图示展示了执行➊、➋、➌前后发生的情况:
现在,让我们聚焦于test函数,如下所示:
int test(int a, int b)
{
int x, y;
x = a;
y = b;
return 0;
}
以下是test函数的汇编语言翻译:
push ebp ➍
mov ebp, esp ➎
sub esp, 8 ➑
mov eax, [ebp+8]
mov [ebp-4], eax
mov ecx, [ebp+0Ch]
mov [ebp-8], ecx
xor eax, eax ➒
mov esp, ebp ➏
pop ebp ➐
ret ➓
第一条指令➍将ebp(也叫帧指针)保存在堆栈中;这样做是为了在函数返回时能够恢复它。由于将ebp的值压入堆栈,esp寄存器将减少4。在下一条指令(➎)中,esp的值被复制到ebp中;因此,esp和ebp都指向堆栈的顶部,如下所示。从现在开始,ebp将保持在固定位置,应用程序将使用ebp来引用函数参数和局部变量:
通常你会在大多数函数的开始找到push ebp和mov ebp, esp这两条指令;这两条指令被称为函数前导。这些指令负责为函数设置环境。在➏和➐处,两个指令(mov esp,ebp和pop ebp)执行函数前导的逆操作。这些指令被称为函数尾部,它们在函数执行完毕后恢复环境。
在➑处,sub esp,8进一步减少了esp寄存器的值。这样做是为了为局部变量(x和y)分配空间。现在,堆栈看起来如下所示:
请注意,ebp仍然位于固定位置,函数参数可以通过从ebp的正偏移量来访问(ebp + 某个值)。局部变量可以通过从ebp的负偏移量来访问(ebp - 某个值)。例如,在上图中,第一个参数(2)可以通过地址ebp+8访问(即a的值),第二个参数可以通过地址ebp+0xc访问(即b的值)。局部变量可以通过地址ebp-4(局部变量x)和ebp-8(局部变量y)访问。
大多数编译器(如微软的 Visual C/C++编译器)使用基于固定ebp的堆栈帧来引用函数参数和局部变量。GNU 编译器(如 gcc)默认不使用基于ebp的堆栈帧,但它们使用一种不同的技术,其中使用ESP(堆栈指针)寄存器来引用函数参数和局部变量。
函数内部的实际代码位于➑和➏之间,如下所示:
mov eax, [ebp+8]
mov [ebp-4], eax
mov ecx, [ebp+0Ch]
mov [ebp-8], ecx
我们可以将参数ebp+8重命名为a,将ebp+0Ch重命名为b。地址ebp-4可以重命名为变量x,ebp-8重命名为变量y,如下所示:
mov eax, [a]
mov [x], eax
mov ecx, [b]
mov [y], ecx
使用前面讲解的技巧,以上语句可以翻译为以下伪代码:
x = a
y = b
在➒处,xor eax,eax将eax的值设置为0。这是返回值(return 0)。返回值总是存储在eax寄存器中。在➏和➐处的函数尾部指令恢复了函数环境。➏处的指令mov esp,ebp将ebp的值复制到esp中;结果,esp将指向ebp所指向的地址。➐处的pop ebp从栈中恢复旧的ebp;此操作后,esp将增加4。执行完➏和➐处的指令后,栈的状态如下所示:
在➓处,当执行ret指令时,栈顶的返回地址被弹出并放入eip寄存器中。同时,控制转移到返回地址(即main函数中的add esp,8)。由于弹出了返回地址,esp增加了4。此时,控制从test函数返回到main函数。main中的指令add esp,8清理了栈,esp被恢复到其原始位置(地址0xFE50,即我们开始的地方),如下所示。此时,栈上的所有值从逻辑上被移除,尽管它们在物理上仍然存在。函数的工作原理就是如此:
在之前的示例中,main函数调用了test函数,并通过将参数压入栈中(从右到左的顺序)传递给了test函数。main函数被称为调用者(或调用函数),而test是被调用者(或被调用函数)。main函数(调用者)在函数调用后,通过执行add esp,8指令清理栈。该指令的作用是移除压入栈中的参数,并将栈指针(esp)恢复到函数调用前的位置;这样的函数被认为使用了cdecl调用约定。调用约定决定了如何传递参数以及在被调用函数完成后,谁(调用者或被调用者)负责从栈中移除这些参数。大多数编译后的 C 程序通常遵循cdecl调用约定。在cdecl约定中,调用者将参数按从右到左的顺序压入栈中,且调用者在函数调用后负责清理栈。还有其他的调用约定,如stdcall和fastcall。在stdcall中,参数由调用者按从右到左的顺序压入栈中,且被调用者(被调用函数)负责清理栈。微软 Windows 利用stdcall约定来处理 DLL 文件导出的函数(API)。在fastcall调用约定中,前几个参数通过放入寄存器传递给函数,剩余的参数则按从右到左的顺序压入栈中,被调用者类似于stdcall约定负责清理栈。你通常会看到 64 位程序遵循fastcall调用约定。
9. 数组与字符串
数组是由相同数据类型组成的列表。数组元素存储在内存中的连续位置,这使得访问数组元素变得非常方便。以下定义了一个包含三个元素的整数数组,数组的每个元素在内存中占用 4 个字节(因为一个整数是 4 个字节长):
int nums[3] = {1, 2, 3}
数组名称nums是一个指向数组第一个元素的常量指针(也就是说,数组名称指向数组的基地址)。在高级语言中,访问数组元素时,你可以使用数组名称和索引。例如,你可以通过nums[0]访问第一个元素,通过nums[1]访问第二个元素,以此类推:
在汇编语言中,数组中任何元素的地址是通过三样东西计算得出的:
-
数组的基地址
-
元素的索引
-
数组中每个元素的大小
当你在高级语言中使用 nums[0] 时,它被翻译为 [nums+0*<每个元素的字节大小>],其中 0 是索引,nums 表示数组的基址。从前面的例子中,你可以按如下方式访问整型数组的元素(每个元素的大小为 4 字节):
nums[0] = [nums+0*4] = [0x4000+0*4] = [0x4000] = 1
nums[1] = [nums+1*4] = [0x4000+1*4] = [0x4004] = 2
nums[2] = [nums+2*4] = [0x4000+2*4] = [0x4008] = 3
nums 整型数组的一般形式可以表示如下:
nums[i] = nums+i*4
以下展示了访问数组元素的一般格式:
[base_address + index * size of element]
9.1 反汇编挑战
将以下代码翻译为其高级等价形式。使用你目前为止学到的技巧和概念来解决这个挑战:
push ebp
mov ebp, esp
sub esp, 14h
mov dword ptr [ebp-14h], 1
mov dword ptr [ebp-10h], 2
mov dword ptr [ebp-0Ch], 3
mov dword ptr [ebp-4], 0
loc_401022:
cmp dword ptr [ebp-4], 3
jge loc_40103D
mov eax, [ebp-4]
mov ecx, [ebp+eax*4-14h]
mov [ebp-8], ecx
mov edx, [ebp-4]
add edx, 1
mov [ebp-4], edx
jmp loc_401022
loc_40103D:
xor eax, eax
mov esp, ebp
pop ebp
ret
9.2 反汇编解决方案
在前面的代码中,前两条指令(push ebp 和 mov ebp, esp)代表了函数序言。类似地,倒数第二条指令前的两行(mov esp,ebp 和 pop ebp)代表了函数尾声。我们知道,函数序言和尾声并不是代码的核心部分,但它们用于为函数设置环境,因此可以移除以简化代码。第三条指令,sub,14h,表明为局部变量分配了20 (14h)字节空间;我们知道,这条指令同样不是代码的一部分(它只是用于为局部变量分配空间),也可以忽略。去除这些不属于实际代码的指令后,我们得到以下内容:
1\. mov dword ptr [ebp-14h], 1
2\. mov dword ptr [ebp-10h], 2 ➐
3\. mov dword ptr [ebp-0Ch], 3 ➑
4\. mov dword ptr [ebp-4], 0 ➍
loc_401022: ➋
5\. cmp dword ptr [ebp-4], 3 ➌
6\. jge loc_40103D ➌
7\. mov eax, [ebp-4]
8\. mov ecx, [ebp+eax*4-14h] ➏
9\. mov [ebp-8], ecx
10\. mov edx, [ebp-4] ➎
11\. add edx, 1 ➎
12\. mov [ebp-4], edx ➎
13\. jmp loc_401022 ➊
loc_40103D:
14\. xor eax, eax
15\. ret
在 ➊ 处的回退跳转到 loc_401022 表示循环,➊ 和 ➋ 之间的代码是循环的一部分。让我们来识别 循环变量、循环初始化、条件检查 和 更新语句。在 ➌ 处的两条指令是条件检查,它检查 [ebp-4] 的值是否 大于或等于 3;当此条件满足时,跳转到循环外部。同样的变量 [ebp-4] 在 ➍ 处被初始化为 0,并在 ➎ 处使用指令进行递增。所有这些细节表明,ebp-4 是循环变量,因此我们可以将 ebp-4 重命名为 i(ebp-4=i)。
在 ➏ 处,指令 [ebp+eax*4-14h] 代表数组访问。我们来尝试识别数组的各个组成部分(基址、索引和每个元素的大小)。我们知道,局部变量(包括数组元素)是通过 ebp-<某个值>(即 ebp 的负偏移量)来访问的,因此我们可以将 [ebp+eax*4-14h] 重写为 [ebp-14h+eax*4]。这里,ebp-14h 表示数组在栈上的基址,eax 表示 索引,而 4 是数组每个元素的大小。由于 ebp-14h 是基址,意味着该地址也代表数组的第一个元素,如果我们假设数组名为 val,那么 ebp-14h = val[0]。
现在我们已经确定了数组的第一个元素,接下来让我们尝试找出其他元素。从数组表示法来看,在这种情况下,我们知道每个元素的大小是4字节。所以,如果val[0] = ebp-14h,那么val[1]应该位于下一个更高的地址,即ebp-10h,val[2]应该在ebp-0Ch,依此类推。注意到ebp-10h和ebp-0Ch在➐和➑处被引用。我们将ebp-10h重命名为val[1],将ebp-14h重命名为val[2]。我们仍然没有弄清楚这个数组包含多少个元素。首先,让我们替换所有已确定的值,并将前面的代码写成高级语言等效的形式。最后两条指令xor eax,eax和ret可以写为return 0,所以伪代码现在如下所示:
val[0] = 1
val[1] = 2
val[2] = 3
i = 0
while (i<3)
{
eax = i
ecx = [val+eax*4] ➒
[ebp-8] = ecx ➒
edx = i
edx = edx + 1 ➒
i = edx ➒
}
return 0
在➒处,将所有在=运算符右侧的寄存器名称替换为其对应的值,我们将得到以下代码:
val[0] = 1
val[1] = 2
val[2] = 3
i = 0
while (i<3)
{
eax = i ➓
ecx = [val+i*4] ➓
[ebp-8] = [val+i*4]
edx = i ➓
edx = i + 1 ➓
i = i + 1
}
return 0
删除在➓处=运算符左侧包含寄存器名称的所有条目,我们得到以下代码:
val[0] = 1
val[1] = 2
val[2] = 3
i = 0
while (i<3)
{
[ebp-8] = [val+i*4]
i = i + 1
}
return 0
从我们之前学到的知识,当我们使用nums[0]访问整数数组的元素时,它与[nums+0*4]是一样的,nums[1]与[nums+1*4]是一样的,这意味着nums[i]的一般形式可以表示为[nums+i*4],也就是nums[i] = [nums+i*4]。根据这个逻辑,我们可以在前面的代码中将[val+i*4]替换为val[i]。
现在,我们在前面的代码中剩下了地址ebp-8;这可能是一个局部变量,也可能是数组val[3]的第四个元素(很难说)。如果我们假设它是局部变量,并将ebp-8重命名为x(ebp-8=x),那么得到的代码将如下所示。从以下代码中,我们可以看出,代码可能在循环遍历数组的每个元素(使用索引变量i),并将值分配给变量x。从代码中,我们还可以得出一个额外的信息:如果索引i被用来遍历数组的每个元素,那么我们可以猜测该数组可能包含三个元素(因为在退出循环之前,索引i的最大值为2):
val[0] = 1
val[1] = 2
val[2] = 3
i = 0
while (i<3)
{
x = val[i]
i = i + 1
}
return 0
如果将ebp-8视为局部变量x,不再将ebp-8视为数组的第四个元素(ebp-8 = val[3]),那么代码将转换为以下形式。现在,代码可以被不同地解释,即数组现在有四个元素,代码会遍历前三个元素。在每次迭代中,值被赋给第四个元素:
val[0] = 1
val[1] = 2
val[2] = 3
i = 0
while (i<3)
{
val[3] = val[i]
i = i + 1
}
return 0
正如你从前面的例子中可能已经猜到的那样,通常无法准确地将汇编代码反编译回原始形式,因为编译器生成代码的方式(而且,代码可能没有所有所需的信息)。不过,这种技术应该有助于确定程序的功能。下面显示的是反汇编输出的原始 C 程序;注意我们之前确定的内容与这里的原始代码之间的相似性:
int main()
{
int a[3] = { 1, 2, 3 };
int b, i;
i = 0;
while (i < 3)
{
b = a[i];
i++;
}
return 0;
}
9.3 字符串
字符串是字符数组。当你定义一个字符串时,如下所示,会在每个字符串的末尾添加一个空终止符(字符串终止符)。每个元素占用 1 字节内存(换句话说,每个 ASCII 字符的长度是 1 字节):
char *str = "Let"
字符串名str是一个指针变量,它指向字符串中的第一个字符(换句话说,它指向字符数组的基地址)。下图显示了这些字符在内存中的存储方式:
从前面的例子中,你可以访问字符数组(字符串)的元素,如下所示:
str[0] = [str+0] = [0x4000+0] = [0x4000] = L
str[1] = [str+1] = [0x4000+1] = [0x4001] = e
str[2] = [str+2] = [0x4000+2] = [0x4002] = t
字符数组的一般形式可以表示如下:
str[i] = [str+i]
9.3.1 字符串指令
x86 系列处理器提供了字符串指令,这些指令用于操作字符串。指令逐步遍历字符串(字符数组),并以b、w和d作为后缀,表示操作数据的大小(1、2或4字节)。字符串指令使用寄存器eax、esi和edi。寄存器eax,或其子寄存器ax和al,用于存储值。寄存器esi充当源地址寄存器(存储源字符串的地址),而edi是目标地址寄存器(存储目标字符串的地址)。
执行完字符串操作后,esi和edi寄存器会自动递增或递减(你可以将esi和edi视为源索引和目标索引寄存器)。eflags寄存器中的方向标志(DF)决定了esi和edi是否应该递增或递减。cld指令清除方向标志(df=0);如果df=0,那么索引寄存器(esi和edi)会递增。std指令设置方向标志(df=1);在这种情况下,esi和edi会递减。
9.3.2 从内存到内存的移动(movsx)
movsx 指令用于将一系列字节从一个内存位置移动到另一个位置。movsb 指令用于将 1 字节从由 esi 寄存器指定的地址复制到由 edi 寄存器指定的地址。movsw, movsd 指令将 2 字节和 4 字节从 esi 指定的地址复制到 edi 指定的地址。数据移动后,esi 和 edi 寄存器会根据数据项的大小,分别增加或减少 1、2 或 4 字节。在以下汇编代码中,假设标记为 src 的地址包含字符串 "Good",并紧跟一个 空字符终止符(0x0)。在执行 ➊ 指令后,esi 将包含字符串 "Good" 的起始地址(换句话说,esi 将包含字符 G 的地址),而 ➋ 指令将设置 EDI 包含一个内存缓冲区(dst)的地址。执行 ➌ 指令时,将会把 1 字节(字符 G)从 esi 指定的地址复制到 edi 指定的地址。执行完 ➌ 指令后,esi 和 edi 都会增加 1,以指向下一个地址:
➊ lea esi,[src] ; "Good",0x0
➋ lea edi,[dst]
➌ movsb
以下截图将帮助你理解在执行 movsb 指令之前和之后发生了什么。若使用的是 movsw 而非 movsb,则会将 2 字节从 src 复制到 dst,同时 esi 和 edi 会各自增加 2:
9.3.3 重复指令(rep)
movsx 指令只能复制 1、2 或 4 字节,但要复制多字节内容时,需配合 rep 指令和字符串指令使用。rep 指令依赖于 ecx 寄存器,并根据 ecx 寄存器指定的次数重复执行字符串指令。执行完 rep 指令后,ecx 的值会递减。以下汇编代码将字符串 "Good"(包括 空字符终止符)从 src 复制到 dst:
lea esi,[src] ; "Good",0x0
lea edi,[dst]
mov ecx,5
rep movsb
rep 指令与 movsx 指令一起使用时,相当于 C 编程中的 memcpy() 函数。rep 指令有多种形式,允许在执行循环时,根据条件提前终止。下表列出了不同形式的 rep 指令及其条件:
| 指令 | 条件 |
|---|---|
rep | 重复直到 ecx=0 |
repe, repz | 重复直到 ecx=0 或 ZF=0 |
repne, repnz | 重复直到 ecx=0 或 ZF=1 |
9.3.4 从寄存器存储值到内存(stosx)
stosb指令将一个字节从 CPU 的al寄存器移动到由edi指定的内存地址(目标索引寄存器)。同样,stosw和stosd指令将数据从ax(2 字节)和eax(4 字节)移动到由edi指定的地址。通常,stosb指令与rep指令一起使用,用于将缓冲区的所有字节初始化为某个值。以下汇编代码将目标缓冲区填充为5个双字(dword),所有值为0(换句话说,它将5*4=20字节的内存初始化为0)。当stosb与rep一起使用时,它相当于 C 编程中的memset()函数:
mov eax, 0
lea edi,[dest]
mov ecx,5
rep stosd
9.3.5 从内存加载到寄存器(lodsx)
lodsb指令将由esi指定的内存地址中的一个字节移到al寄存器中。同样,lodsw和lodsd指令将由esi指定的内存地址中的 2 个字节和 4 个字节数据分别移到ax和eax寄存器中。
9.3.6 扫描内存(scasx)
scasb指令用于在字节序列中搜索(或扫描)某个字节值的存在或不存在。要搜索的字节值放置在al寄存器中,内存地址(缓冲区)放置在edi寄存器中。scasb指令通常与repne指令一起使用(repne scasb),并将ecx设置为缓冲区的长度;这会逐个字节地检查,直到找到al寄存器中的指定字节,或者直到ecx变为0。
9.3.7 比较内存中的值(cmpsx)
cmpsb指令用于比较由esi指定的内存地址中的一个字节与由edi指定的内存地址中的一个字节,以确定它们是否包含相同的数据。cmpsb通常与repe(repe cmpsb)一起使用,用于比较两个内存缓冲区;在这种情况下,ecx将被设置为缓冲区的长度,比较会持续进行,直到ecx=0或者两个缓冲区不相等。
10. 结构体
结构体将不同类型的数据组合在一起;结构体的每个元素称为成员。结构体成员通过常量偏移量进行访问。为了理解这个概念,来看一下以下的 C 程序。simpleStruct定义包含三个不同数据类型的成员变量(a、b 和 c)。main函数在➊处定义了结构体变量(test_stru),并将结构体变量的地址(&test_stru)作为第一个参数在➋处传递给update函数。在update函数内部,成员变量被赋予了值:
struct simpleStruct
{
int a;
short int b;
char c;
};
void update(struct simpleStruct *test_stru_ptr) {
test_stru_ptr->a = 6;
test_stru_ptr->b = 7;
test_stru_ptr->c = 'A';
}
int main()
{
struct simpleStruct test_stru; ➊
update(&test_stru); ➋
return 0;
}
为了理解结构体成员是如何访问的,让我们来看一下update函数的反汇编输出。在➌位置,结构体的基地址被移动到eax寄存器中(记住,ebp+8表示第一个参数;在我们的例子中,第一个参数包含了structure的base address)。此时,eax寄存器包含了结构体的基地址。在➍位置,整数值6通过将偏移量0加到基地址上([eax+0],也就是[eax])赋值给第一个成员。因为整数占用4字节,注意到在➎位置,short int 值 7(存储在cx寄存器中)通过将偏移量4加到基地址上赋值给第二个成员。类似地,41h(即A)的值在➏位置通过将偏移量6加到基地址上赋值给第三个成员:
push ebp
mov ebp, esp
mov eax, [ebp+8] ➌
mov dword ptr [eax], 6 ➍
mov ecx, 7
mov [eax+4], cx ➎
mov byte ptr [eax+6], 41h ➏
mov esp,ebp
pop ebp
ret
从前面的例子可以看出,每个结构体成员都有自己的偏移量,并通过将常量偏移量加到基地址来访问;因此,通用形式可以写成如下:
[base_address + constant_offset]
结构体在内存中看起来与数组非常相似,但你需要记住一些要点来区分它们:
-
数组元素始终具有相同的数据类型,而结构体的成员不必具有相同的数据类型。
-
数组元素大多是通过相对于基地址的变量偏移量访问(例如,
[eax + ebx]或[eax+ebx*4]),而结构体则大多是通过相对于基地址的常量偏移量访问(例如,[eax+4])。
11. x64 架构
一旦你理解了 x86 架构的概念,就更容易理解 x64 架构了。x64 架构是作为 x86 的扩展设计的,与 x86 的指令集非常相似,但从代码分析的角度看,仍然有一些你需要注意的区别。本节涵盖了一些 x64 架构的差异:
-
第一个区别是,32 位(4 字节)通用寄存器
eax、ebx、ecx、edx、esi、edi、ebp和esp被扩展为 64 位(8 字节);这些寄存器被命名为rax、rbx、rcx、rdx、rsi、rdi、rbp和rsp。新增的八个寄存器命名为r8、r9、r10、r11、r12、r13、r14和r15。如你所料,程序可以将寄存器访问为 64 位(RAX、RBX等)、32 位(eax、ebx等)、16 位(ax、bx等)或 8 位(al、bl等)。例如,你可以将RAX寄存器的下半部分访问为EAX,并将最底层的字访问为AX。你可以通过在寄存器名称后附加b、w、d或q来访问寄存器r8-r15的字节、字、双字或四字。 -
x64 架构可以处理 64 位(8 字节)数据,所有的地址和指针都是 64 位(8 字节)大小。
-
x64 CPU 具有一个 64 位的指令指针
(rip),它包含下一条将执行的指令的地址,并且还具有一个 64 位的标志寄存器(rflags),但目前只使用低 32 位(eflags)。 -
x64 架构支持
rip-relative寻址。现在可以使用rip寄存器来引用内存位置;也就是说,你可以访问当前指令指针偏移一定量的数据。 -
另一个主要的区别是,在 x86 架构中,函数参数是按之前提到的方式推送到栈上的,而在 x64 架构中,前四个参数通过
rcx、rdx、r8和r9寄存器传递,如果程序包含额外的参数,它们则存储在栈上。我们来看一个简单的 C 代码示例(printf函数);该函数有六个参数:
printf("%d %d %d %d %d", 1, 2, 3, 4, 5);
以下是为 32 位(x86)处理器编译的 C 代码的反汇编;在这种情况下,所有参数都按逆序推送到栈上,在调用printf后,使用add esp,18h来清理栈。因此,很容易判断printf函数有六个参数:
push 5
push 4
push 3
push 2
push 1
push offset Format ; "%d %d %d %d %d"
call ds:printf
add esp, 18h
以下是为 64 位(x64)处理器编译的 C 代码的反汇编。第一个指令,在➊位置,分配了0x38(即 56 字节)空间到栈上。第一个、第二个、第三个和第四个参数存储在rcx、rdx、r8和r9寄存器中(在调用printf之前),分别位于➋、➌、➍、➎。第五个和第六个参数存储在栈上(在分配的空间中),使用的指令位于➏和➐。在此情况下,没有使用push指令,因此很难判断内存地址是局部变量还是函数的参数。在此情况下,格式字符串有助于确定传递给printf函数的参数数量,但在其他情况下则不容易:
sub rsp, 38h ➊
mov dword ptr [rsp+28h], 5 ➐
mov dword ptr [rsp+20h], 4 ➏
mov r9d, 3 ➎
mov r8d, 2 ➍
mov edx, 1 ➌
lea rcx, Format ; "%d %d %d %d %d" ➋
call cs:printf
英特尔 64 位(x64)和 IA-32(x86)架构包含许多指令。如果你遇到本章没有涉及的汇编指令,可以从software.intel.com/en-us/articles/intel-sdm下载最新的英特尔架构手册,指令集参考(卷 2A、2B、2C 和 2D)可以从software.intel.com/sites/default/files/managed/a4/60/325383-sdm-vol-2abcd.pdf下载。
11.1 分析 64 位 Windows 上的 32 位可执行文件
64 位 Windows 操作系统可以运行 32 位可执行文件;为此,Windows 开发了一个名为WOW64(Windows 32 位在 Windows 64 位上的子系统)的子系统。WOW64 子系统允许在 64 位 Windows 上执行 32 位二进制文件。当你运行一个可执行文件时,它需要加载 DLL 以调用 API 函数与系统进行交互。32 位可执行文件不能加载 64 位 DLL(而 64 位进程也不能加载 32 位 DLL),因此微软为 32 位和 64 位分别提供了不同的 DLL。64 位二进制文件存储在\Windows\system32目录下,而 32 位二进制文件存储在\Windows\Syswow64目录下。
32 位应用程序在 64 位 Windows(Wow64)下运行时,可能表现与在本机 32 位 Windows 上的行为不同。当你在 64 位 Windows 上分析 32 位恶意软件时,如果你发现恶意软件访问了system32目录,实际上它是在访问syswow64目录(操作系统会自动将其重定向到Syswow64目录)。如果一个 32 位恶意软件(在 64 位 Windows 上执行时)在\Windows\system32目录写入文件,那么你需要检查\Windows\Syswow64目录中的文件。类似地,访问%windir%\regedit.exe会被重定向到%windir%\SysWOW64\regedit.exe。这种行为差异可能会在分析时造成混淆,因此理解这种差异非常重要,为了避免混淆,最好在 32 位 Windows 环境中分析 32 位二进制文件。
要了解 WOW64 子系统如何影响你的分析,请参考The WOW-Effect by Christian Wojner (www.cert.at/static/downloads/papers/cert.at-the_wow_effect.pdf)
12. 额外资源
以下是一些额外的资源,帮助你更深入地了解 C 编程、x86 和 x64 汇编语言编程:
-
学习 C 语言:
www.programiz.com/c-programming -
Greg Perry 和 Dean Miller 的C 编程绝对初学者指南
-
x86 汇编编程教程:
www.tutorialspoint.com/assembly_programming/ -
保罗·卡特博士的PC 汇编语言:
pacman128.github.io/pcasm/ -
Intel x86 简介 - 架构、汇编、应用程序和修辞:
opensecuritytraining.info/IntroX86.html -
Jeff Duntemann 的汇编语言逐步学习
-
Ray Seyfarth 的64 位 Windows 汇编编程入门
13. 总结
在本章中,你学习了理解和解释汇编代码所需的概念和技巧。本章还强调了 x32 和 x64 架构之间的关键区别。你在本章中学习的反汇编和反编译(静态代码分析)技巧将帮助你更深入地理解恶意代码如何在底层运行。在下一章,我们将介绍代码分析工具(反汇编器和调试器),你将学习这些工具提供的各种功能如何简化你的分析,并帮助你检查与恶意二进制文件相关的代码。