65.9K
CodeProject 正在变化。 阅读更多。
Home

二进制混淆

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (22投票s)

2014年12月25日

CPOL

16分钟阅读

viewsIcon

63769

downloadIcon

1493

如何在二进制级别添加混淆以保护您的技术

引言

二进制混淆是一种旨在隐藏实际应用程序代码的技术,使其难以被无法访问您源代码的外部人员理解您的程序的作用。混淆技术并不能将您的应用程序变成一个无法破解的程序,因为通过适当的努力,任何人都可以访问解密的数据。这是因为 CPU(目前)无法读取加密数据,因此您必须向其提供未加密的指令。最终,这只是一个让非技术人员难以理解您的应用程序执行过程的努力。这只是在您的程序被破解之前争取时间的问题。

混淆主要允许您的程序被自动化工具屏蔽。任何针对二进制模式搜索的工具扫描都将一无所获。如果有人使用某种虚拟环境模拟您的工具,他将能够无误地运行您的应用程序,并且内存检查可能导致检测到未加密的代码。这些操作通常由人(而不是机器)执行,并且可能非常乏味且成本高昂(就人工小时而言),因此最终,这将导致潜在的黑客直接放弃,迫使他们选择另一条路线。

在本文中,我们将介绍混淆技术的基础。实际上,有多种方法可以执行此类操作,有些更高级,有些则不那么高级。最基本的方法是将所有代码加密到发布的二进制文件中,并在运行时解密,仅修改虚拟内存中的结构。通过这种技术,物理文件将不会被修改,也不会以明文格式出现在文件系统中。

对于提供的示例,虚拟内存将被修改以允许读/写,然后将被解密以允许该技术的执行。还有其他未在此解释的技术,允许在堆栈或栈上进行解密,但这些是更高级的方法。

背景

读者必须清楚以下几点

首先,也是最重要的一点,您必须了解编译器如何打包二进制文件。我们将处理 Windows,因此使用的结构是可移植可执行文件 (Portable Executable)。有必要了解 PE 部分是什么,以及操作系统映像加载器如何使用它们。

其次,您必须大致了解 CPU 实际上在处理什么。您在编辑器中编写的内容,然后编译,会被编译器翻译成 CPU 代码。这段代码是 CPU 相关的,因此 32 位和 64 位很重要,并且会改变局面。更改 CPU 架构将导致更改所 presented 项目的部分内容以使其重新工作。

第三,您必须掌握密码学和安全基础。我将简单介绍一些密码学方法,而不深入细节。您将能够阅读本文档,但我建议您通过查阅更技术性的文章来加深您的知识。

最后但同样重要的是,您应该非常了解 C、汇编和 CPU 指令集。

工具

我将使用 Visual Studio 开发工具来撰写本文。Visual Studio 是一个集成开发环境 (IDE),它允许为 Windows 和其他操作系统构建应用程序(nmake 项目)。

混淆介绍

现在我们有了一个项目,但我们不希望它很容易被读取。我们该怎么做?

好吧,我们拥有的第一个也是最有用的工具是我们代码的规模和复杂性。想想看:一个已经开始的项目已经很难阅读了。每个人都有自己的编码风格,这比使用的语言的简单语法更重要。我们说的是您使用局部变量的习惯(在代码开头定义或分布在代码中),项目的结构(所有内容都在一个大文件中,还是以更分层的结构组织),以及您如何使用预处理器指令。现在将这种视图扩展到一个更大的项目(从 50 到数百个文件),该项目使用您自己的抽象(或不抽象)来处理使应用程序工作的多种技术。

可以有许多技术直接编译到应用程序中,而无需使用 .dll 文件,这些文件可能存在于机器上,也可能不存在。与其检查先决条件,不如将静态库中的源文件包含到应用程序中,以避免 DLL Hell 问题。所有这些代码,即使是由第三方公司或开发人员设计的,都会增加您代码的复杂性。根据黑客破解您应用程序的耐心极限,他可能会认为这项工作不值得花费时间来破解。

现在试着想象在那个巨大的混乱中插入一些混淆。这对决定攻击您实现的专业人士来说将更加令人沮丧,而这正是我们试图实现的。请记住:没有什么能永远持续下去,这只是争取时间发布新的、更复杂的混淆,或者开发一种新技术,使黑客解密的技术过时。

目标二进制文件的结构

准备好的二进制文件很小,复杂度也很低。它们只是以千字节为单位的实用程序,这使得它们很容易成为任何想要窃取您技术的人的目标。如果您想稍微保护这些应用程序,那么一些混淆是必要的。

项目对不同代码部分执行混淆。通过这种架构,我们可以自由地创建新代码并决定将其放在哪个部分,是明文部分还是混淆部分。请记住,解密器必须是明文形式的,否则 CPU 无法解密您的数据。如果解密器被其他代码“包围”,那么将其分离将很困难,尤其是当它很少使用时。

这个想法可以应用于一个通用的部分,所以您也可以有一个不同的数据部分(包含连接字符串、密码或游戏作弊码),可以与低兴趣的数据(普通变量值或输出字符串)隔离。

编译结束后,实用程序二进制文件的结构如下

 +------------------+ File start
 | DOS header       |
 +------------------+
 | DOS stub         |
 +------------------+
 | NT header        |
 +------------------+
 | Sections headers | Information over the binary sections
 +------------------+
 |       ...        | 
 +------------------+
 | .dummy           | Section with code to obfuscate
 +------------------+
 |                  |
 |                  |
 |                  |
 |       ...        |
 |                  |
 |                  |
 |                  |
 +------------------+ EOF

.text 区域通常包含应用程序代码,安全级别较低。其他部分,.dummy,将包含我们想要保护的技术。请注意,这不是混淆技术唯一的处理方式(您可以决定混合混淆代码和明文代码),但肯定是与 C 编译器兼容性最好的方式。通常,混合混淆代码和明文代码意味着您必须使用大量汇编代码,并且您必须以一种会给工作团队带来麻烦的方式来组织您的项目(其他程序员可能会弄乱您编写的代码)。维护一个单独的混淆部分有助于组织。

提示:您是否听说过多阶段混淆?好吧,想象一下您的代码有三个不同的部分(此处称为节);每个部分都用不同的技术和不同的密钥进行混淆,并依赖于第一个部分。例如,您可以拥有 .dummy1、.dummy2 和 .dummy3。 .dummy3 的解密器位于 .dummy2 中,.dummy2 用 .dummy1 中的技术/密钥进行解密。链接混淆将增加您项目的复杂性,从而使外部攻击者更难破解。

提示:谁说您必须包含解密器?您能想象有人拼命地查看您的代码却找不到您想要的东西吗?该部分可以保留在那里,保持沉默,直到您决定使用外部工具(例如 .dll)来解密它。

简单节混淆

简单并不意味着愚蠢。这只是选择适合您技术。您不想为您的漂亮的 Soraka 动画头像文件应用 2048 位 RSA 密钥。最终丢失它并没有那么糟糕。简单方法回答了这个问题:在不损失一个月开发时间的情况下,如何为我的技术应用混淆的第一个想法是什么?

在这里,我们开始使用一个简单的单字节密钥对整个节进行 XOR 加密。单字节密钥足以避免自动扫描您的代码,但专家很容易看穿。在应用程序版本发布期间交换密钥可以更新混淆。这会导致任何黑客重复操作来再次解密您的代码。

那么,当您在项目中选择这种混淆时会发生什么?嗯,准备好的例程将定位目标可执行文件中的 .dummy 节。一旦定位了文件偏移量和大小,它就会在单独的缓冲区中应用密钥到内容,最后用编码的节替换二进制可执行节。

让我们快速看一下加密前后可执行文件的结构

The naiveA project .dummy section:

55 8b ec 81 ec c0 00 00 00 53 56 57 8d bd 40 ff
ff ff b9 30 00 00 00 b8 cc cc cc cc f3 ab 8b 45
08 83 c0 01 5f 5e 5b 8b e5 5d c3 cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ... and so on until section end.

NaiveA project .dummy section after obfuscation:

00 de b9 d4 b9 95 55 55 55 06 03 02 d8 e8 15 aa
aa aa ec 65 55 55 55 ed 99 99 99 99 a6 fe de 10
5d d6 95 54 0a 0b 0e de b0 08 96 99 99 99 99 99 <-- 0x99 strange pattern recognized.
99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99
99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99
99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99
99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99
99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 ... and so on until section end.

两个节之间的差异很明显。当一个有经验的逆向工程师快速查看这种二进制代码时,他会立即意识到该节有问题。最初,他识别出该节包含代码,因为节头中的标志表明了这一点。如果他查看代码,在这种情况下(其他密钥可能产生不同的输出),他找不到有效的过程(每个过程调用约定都有其自己的 prologue 和 epilogue,这不会改变)。这很奇怪但并不罕见:编写自定义汇编代码来执行某些性能关键操作并不少见,因此他可以推断出已经编写了某种自定义汇编。

该节这里的奇怪行为是您可以看到直到节末尾的 **0x99** 填充模式。编译器通常会填充空间以达到二的幂大小。代码中有如此多的 cwd 指令意味着什么?工程师会立即回想起填充模式通常是 0x000xcc,具体取决于编译器做出的决定。如果他假设填充模式是 0xcc 并借助一个非常基本的脚本进行测试,他就可以找出密钥(在本例中为 **0x55**),然后用未加密的节替换该节。

正如您所见,按照描述进行操作需要一些时间,并且要求二进制文件的读取者具有一定的逆向工程技能和一些直觉。通常这种直觉来自于普通 PC 用户没有的经验和知识,因此对技术防御水平较低的应用程序应用混淆就足够了。

提示:这种非常基本的混淆技术甚至允许您的可执行文件绕过自动化扫描器,并且可以每次运行时用不同的密钥**再次加密**(正如您所见,可以在内存中轻松定位和修改节)。您可以创建可执行文件的副本并在每次执行时替换旧的,这样您将永远不会有相同的密钥(和相同的二进制模式)。

最终,我们混淆它所需的时间与外部人员翻译/阅读它所需的时间对我们来说是巨大的优势。

下一步:更长的密钥能解决我们的问题吗?

直观地说,使用更长的密钥可以提高程序的安全性,因为即使逆向工程师成功解密了我们代码的一个字节,他也必须继续他的工作才能获得完整、可读的二进制代码。如果我们尝试这种方法会怎样?另一个项目,naiveB,有助于回答这个问题,正如我在上一章所做的那样,现在我将向您展示这种混淆对您的代码节的影响。

The naiveB project .dummy section:

55 8b ec 81 ec c0 00 00 00 53 56 57 8d bd 40 ff
ff ff b9 30 00 00 00 b8 cc cc cc cc f3 ab 8b 45
08 83 c0 01 5f 5e 5b 8b e5 5d c3 cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ... and so on until section end.

NaiveB project .dummy section after obfuscation:

55 8a ee 82 e8 c5 06 07 08 5a 5c 5c 81 b0 4e f0
ef ee ab 23 14 15 16 af d4 d5 d6 d7 ef b6 95 5a
08 82 c2 02 5b 5b 5d 8c ed 54 c9 c7 c0 c1 c2 c3
dc dd de df d8 d9 da db d4 d5 d6 d7 d0 d1 d2 d3
cc cd ce cf c8 c9 ca cb c4 c5 c6 c7 c0 c1 c2 c3 <-- Repeating pattern visible here.
dc dd de df d8 d9 da db d4 d5 d6 d7 d0 d1 d2 d3
cc cd ce cf c8 c9 ca cb c4 c5 c6 c7 c0 c1 c2 c3
dc dd de df d8 d9 da db d4 d5 d6 d7 d0 d1 d2 d3 ... and so on until section end.

正如您所见,现在没有 0x99 单字节了;这要归功于我们更长的密钥。但是,还有另一个模式,现在每两行重复一次。这意味着,应用 XOR 加密后,重复模式的长度与我们选择的密钥一样长,适用于您决定应用的任何密钥。这是逆向工程师已知的一个问题。在这种情况下,他可以替换他脚本中的密钥,并且无需花费额外时间即可解密您的混淆节,与我们的第一次尝试相比。这意味着在这种情况下,让我们的密钥更长并不像我们最初设想的那样有效。

下一步:避免混淆节中出现任何重复模式

嗯,实际上,在密码学中有一种方法可以实现无法破解的加密,但这种功能带有某些限制。一次性密码本 (One-time pads) 是与要加密的数据一样长的密钥,由真正的随机数生成器生成(这不像您想象的那么容易,没有专用硬件)。想法是有一个随机数池,您用它来加密数据;如果这样的数字具有足够的随机性,那么就不会有重复模式,逆向工程师也没有重复数据可以用来破解您的代码。这就像为数据的每个字节选择一个单独的密钥,并且密钥始终随机更改。

这种方法的问题在于您将拥有其他数据需要保护或隐藏,即密钥本身。让我们看看当我们决定将这种密钥应用于我们要混淆的节时会发生什么

The naiveC project .dummy section:

55 8b ec 81 ec c0 00 00 00 53 56 57 8d bd 40 ff
ff ff b9 30 00 00 00 b8 cc cc cc cc f3 ab 8b 45
08 83 c0 01 5f 5e 5b 8b e5 5d c3 cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ... and so on until section end.

NaiveC project .dummy section after obfuscation:

8e bf 9c 59 96 b5 92 03 85 b9 fa d1 45 bf 44 1e
31 15 19 60 f6 53 fe 12 05 b9 ca 45 78 f8 b6 ee
18 c2 fb 99 8d fa 04 0e a5 a7 02 b5 94 d6 a4 62
bb 70 72 f6 bc f7 8c 22 10 56 b6 b3 7e 0a ff 2f
d4 d6 29 32 b4 e4 bc b2 19 7b c2 c8 ac c4 46 48
8a 92 61 22 01 70 36 c2 53 3d 57 7d a9 1e 56 c5
54 0b 98 9e 58 45 e7 7a 23 e5 b0 a2 c4 99 1d e7
2f 1b 9b 78 f8 92 5e 1c 75 4d 83 aa 01 cd 16 2f
f7 82 be 10 9c 81 36 39 f8 95 3d cd b6 44 68 a6
39 e3 6e 1f 01 64 bd 31 1f 9e b3 2b de 16 97 f6
6a 75 e9 2f 1e 32 88 ce 80 82 9a cf 17 e5 a1 c6
e8 a2 b4 59 0d ee 33 91 58 a1 d8 b1 97 29 4a 19
48 dc 9b 7d 8e ef bc 6a 2c dc 58 71 9a 0c 5f 18
d6 51 0c 8c f4 98 68 7b 69 15 38 a2 1c 67 0d b2
b7 95 23 40 05 89 24 65 54 64 5d bb dc 1a b2 41
b0 08 ae d1 95 0b 05 18 61 52 c5 ce 57 7f b9 37
ff 52 19 71 43 27 df 1f d7 d1 fb b5 f8 3e 59 cc
39 25 89 b8 81 ce 18 b1 99 09 f5 4f 2d 49 c7 d9
9a 2a 3d 40 77 51 95 27 de bb a1 c6 24 51 8e 38
e7 da 9f 41 f0 41 e3 b4 89 99 a2 fb 01 66 25 58
45 f6 e7 8c b9 28 ee 77 e8 73 d6 bf 98 92 21 fc
0a 9e bf 63 81 3d 8c 41 e8 9a 43 a4 48 65 b5 8c
ba 6d a7 ef 2b 8a 1a c5 36 30 e4 31 6e 71 30 b1
a3 6a 41 e8 64 79 bd 4a 57 1d 48 90 fd c6 ee 2d ... and so on until section end, without patterns.

正如您所见,即使使用像 rand() 过程这样简单的机制,也会产生如此混乱的结果,以至于逆向工程师的工作将花费太长时间来直接尝试解密您的代码(没有专门的应用程序)。如果他发现您使用的是 OTP 而不是 RSA 加密,那么他将改变他的目标并寻找密钥。如果他找到了,那么他将能够解密您的代码。

提示:使用像一次性密码本这样的专用密钥可能很难管理;这完全取决于您需要保护什么以及您可以投入多少时间。也许选择一个更长的密钥就足够了。想象一下使用您自己的应用程序的一部分作为密钥:.text 节将在加载时(为了访问您的实用程序所需的数据或导入的函数)由加载器在重定位期间更改,但 .data 节通常在启动时是静态的。您可以使用它来将 XOR 加密应用于您的混淆节(整个节作为密钥)。

现在,所有的简单方法都以简单直接的方式处理代码混淆:只需对其进行加密。如您所见,这些方法的结果可能产生易于破解或难以破解的代码。所有简单方法的共同点是整个节都被加密,并且任何逆向工程师都会将其识别为这样。即使是一次性密码本方法也是明显不可读的,没有任何逻辑上的重复结构(这通常表明加密)。最后,我们暴露了不尝试隐藏节,而只是依赖于应用的加密。

下一步:智能混淆

嗯,并不那么智能。这只是我在思考二进制混淆时想到的第一个例子。让我们试着总结一下我们在之前的测试中遇到的情况:使用 XOR 加密(如果您决定使用其他加密算法,所有内容都会改变)将导致二进制混淆,该混淆可以避免自动扫描器搜索可能导致检测到您的技术的二进制字符串。另一方面,这种基本的加密很容易被检测到,然后被花费一些时间阅读您的应用程序的逆向工程师解密。从这里,您可以选择两种方式让逆向工程师更难工作:改进您的加密或更好地隐藏您的技术。

改进加密是一个不错的选择,但通常也需要大量开发时间。在最后一个示例中,我们将看到一种隐藏我们技术更好的想法。我们回到第一个示例:我们想使用一个单字节密钥进行 XOR 加密,但我们不希望我们的密钥轻易暴露。我们也不希望逆向工程师轻易识别我们的加密过程。

这个想法只是混淆需要混淆的部分,而不考虑那些无需保护的部分。每个过程都有一个 prologue 和一个 epilogue,这取决于您过程所使用的调用约定。我们将使用这些标记作为加密例程的激活器/去激活器。目标是拥有任何工具都能识别的合法过程,其中包含混淆代码。当有人使用逆向工程师工具(如 IDA)查看它们时,他们将无法为这些例程分配逻辑,但很难认为它们是混淆的,因为它们看起来是合法的。

The naiveC project .dummy section:

55 8b ec 81 ec c0 00 00 00 53 56 57 8d bd 40 ff
ff ff b9 30 00 00 00 b8 cc cc cc cc f3 ab 8b 45
08 83 c0 01 5f 5e 5b 8b e5 5d c3 cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ... and so on until section end.

NaiveC project .dummy section after obfuscation:

55 8b ec d4 b9 95 55 55 55 06 03 02 d8 e8 15 aa <-- the inner part of the procedure is encrypted.
aa aa ec 65 55 55 55 ed 99 99 99 99 a6 fe de 10
5d d6 95 54 0a 0b 0e 8b e5 5d c3 cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc
cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ... and so on until section end.

正如您所见,不再有 0x99 模式问题,因此该节看起来是合法的。此外,避免这种模式可以避免暴露我们的密钥。prologue 和 epilogue 标识其为符合标准调用约定的真实过程。这是您代码中有效代码节中的一个有效过程。识别它为加密需要一些时间,并且解密它会稍微困难一些(因为您不知道过程内部有什么)。现在想象一下将这种启发式方法扩展到我们使用简单项目所做的那样。应用多字节密钥将使其足够具有挑战性,以劝阻任何初级逆向工程师。您可以有一个 512 字节的密钥,在加密 512 字节二进制代码后重复,这可能是 2 到 3 个过程的大小(通常人们认为在每个过程开始时,密钥都从头开始获取,但事实并非如此)。

测试代码

好吧,如果您想使用提供的代码,您可以随意使用。我通常在讲解文章的过程中测试代码,以清楚地说明作者在展示什么。在我的代码中,您会找到一个混淆器(binobf 项目),可以修改它来加密目标实用程序的所需节。您还可以将 binobf 的输出转储到日志文件中,以检查节在计算步骤中如何变化(通常使用命令 binobf.exe > log.txt)。在混淆器的主要源文件中,您会找到各种过程,您可以调用它们来更改本文中提出的示例。项目中的注释应该足够清晰,以便进行个人测试dummy 实用程序。

如果您不理解代码中的任何内容……嗯,也许您应该查看背景要求。程序足够简单,可以允许任何人选择要运行的示例。如果您有任何问题,请评论文章并等待答复。

要测试该项目,您可以选择命令行或仅使用 Visual Studio。这取决于您想深入研究的程度。

历史

  • 2014/12/24 - 文章的初始提交
© . All rights reserved.