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

一组简单的类来加密数据

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.15/5 (27投票s)

2004年11月5日

CPOL

11分钟阅读

viewsIcon

115911

downloadIcon

3760

一组简单的类来加密数据

引言

几年前,我和我后来的妻子共同运营了一个基于 IRC 的问答游戏网站。我负责编程(游戏客户端和匹配的游戏感知聊天客户端),她负责网站。我们一起负责与人相关的管理工作。其中就包括选择(或“劝说” :))志愿者游戏主持人来编写和运行游戏。

IRC 网络 - 信任的问题

IRC 网络,就像互联网上的许多地方一样,吸引了各种各样神经有问题的人;这些人会躲在 IP 地址的“匿名”背后,尽可能地捣乱。因此,有必要赋予游戏主持人封锁、踢出或禁止捣乱玩家的权力。

这是通过连接到 IRC 网络并提供适当的密码来实现的。(适当,因为大多数 IRC 网络都有不同级别的权限,权限级别由所使用的密码指定)。大多数聊天客户端都通过要求用户输入类似 /join #chatroom password 的命令来方便这一点。然后,聊天服务器会根据提供的密码分配相应的权限级别。

我们的 IRC 网络略有不同。大多数 IRC 网络都有涵盖各种主题的许多聊天室,每个聊天室都有其自己的“小圈子”主持人,以及各自的行为规则。我们的服务器,因为是专门用于游戏的,所以聊天室的选择更有限,并且所有聊天室都适用统一的行为规则。更重要的是,在“经典”IRC 网络上的每个主持人都会知道他们在每个聊天室的主持密码。这是一个重大的弱点。一旦密码泄露(而且这种泄露不一定是恶意的,可能只是简单地在 /join 关键字中拼写错误),更改密码可能需要大量的协调工作。所有合法用户都必须被识别并更新。而且几乎总是会怀疑密码泄露是故意的。

避免信任的需要

我决定为我的聊天客户端设计一个模型,使主持人无需知道主持人密码。为了实现这一点,我需要想出一个满足以下目标的加密方案。
  • 任何密码都不应以明文形式保存在任何地方。

  • 应该难以逆向工程。

  • 应该有时限。换句话说,聊天客户端使用的加密数据需要定期更新。

  • 应该能够检测是否有人将 PC 时钟调回了过去。

  • 最后但同样重要的是:我应该能够编写、调试和测试。我不是密码学家!

我在此提供的代码和实用程序基于我在游戏软件中使用的代码。但请注意,它并非完全相同的代码,我也绝不会提供相同的加密密钥。(截至撰写本文时,该网站仍在运行,尽管我已不再参与其中)。

抽象

现在,让我们将视线从 IRC 网络移开,从更高的层面来审视。再次以不同的方式表达,这些是我的目标。
  • 加密应用程序感兴趣但需要隐藏的某些文本。

  • 使其难以在不知道具体细节的情况下解密。

  • 设置时限,包括检测 PC 时钟是否被调回到过去的能力。
为了方便起见,我添加了第四个要求。加密实体应该是 MFC 存档。这意味着加密实体必须是一个 CObject 派生对象,实现了 DECLARE_SERIALIMPLEMENT_SERIAL 宏。我对我使用 MFC 序列化机制的行为毫无歉意。它使我能够轻松地保存和重新加载复杂的数据结构。

CObject 派生对象被序列化到内存存档中,然后该存档被加密并写入存储。加载过程则相反:加密实体被加载到内存存档中,解密,然后序列化到您提供的对象实例中。

方案

我想出的方案是对序列化的内存块进行三次 XOR 操作,与三个不同的加密密钥进行运算。此外,编码实体包含两个时间戳。第一个时间戳是整个编码实体到期的时间。第二个时间戳是最后一次解码实体的日期。这意味着每次解码实体时,我们都会通过重写第二个时间戳来更新它。

第二个时间戳(“销毁日期”)用于保护第一个时间戳(“到期日期”)。如果 PC 时钟时间早于“销毁日期”,我们就知道有些不对劲。

“啊哈!”你可能会说。销毁日期每次我们运行程序都会改变。如果我知道有一个时间戳记录了我们上次解码实体的日期,那么我就可以解码销毁日期,更重要的是,可以重写它,从而破坏检测 PC 时钟是否被调回过去的能力。

嗯,是的,如果你能找到销毁日期在哪里,你就可以做到。所以我添加了另一个变量。写入编码实体的第一个值是调用 QueryPerformanceCounter() 返回值的低 32 位。然后,在对实体进行三次 XOR 操作(与三个加密密钥)之后,我对实体其余部分(不包括初始 32 位)进行最后一次 XOR 操作,使用初始 32 位作为密钥。因此,在每次解码实体后,写入存储的整个实体都会改变其字节值。当整个实体发生变化时,要检测出唯一的实质性变化是销毁日期,这并不容易。

简单但有效。如果你知道编码实体的最初 32 位是第一层加密密钥,那么你总能将实体的其余部分解码回标准状态。

这能让你走多远?其实并不远。你仍然有一块看似随机的二进制值。

“啊哈!”你可能会说。我破解的 exe 文件必须包含解密密钥。我手边有一个 UNIX strings 实用程序的实现,它可以列出 exe 中的所有字符串。解密密钥肯定在里面。

是的,它们在那里。但正如我之前已经展示过的,我是一个狡猾的混蛋。加密密钥也被加密到了 exe 中。为了使其难以破解,我使用随机字符序列作为 meta key。这些字符用作解密密钥,用于解密用于解密存储中的加密实体(在用时间戳解密之后)。真是该死的密码学!!!

我提供了一个简单的基于对话框的实用程序 (MetaKeyGen),用于在给定 meta keydesired key 的情况下生成元加密密钥。结果是一个 C 代码字符串,表示由 meta key 编码的 desired key,你将其复制并粘贴到你的源文件中。

作为最后的安全措施(是的,我知道我很偏执,但你有没有经历过需要营救一个被敌对用户占领的 IRC 网络?我也没,也许是因为这种偏执)。此类只存储加密实体。每次调用者请求数据时,数据将在所有内部缓冲区被随机值覆盖并释放后返回。我无法阻止调试器看到进程内存的内容,但我可以使其对破解者更困难。偏执确实会带来性能损失,但这一切都在内存中完成,所以损失并不算太大。我使用的随机值是调用 rand() 返回值的最低有效字节。因此,每次程序运行时,它所需的临时存储都会被不同的值覆盖。

请注意,前面提到的到期日期和销毁日期实际上是 CObject 派生类的成员,如果发现它们无效,您的对象有责任决定适当的措施。

CEncryptedData 是存储加密实体的类。加密实体仅仅是一个 BYTE 块,其中包含需要保护的文本。该类提供了构造函数,用于对注册表或磁盘文件进行加载和保存操作,并且它还实现了 QueryPerformanceCounter() 混淆。
class CEncryptedData
{
    friend class CDataDecrypter;
public:
    CEncryptedData(LPCTSTR szFileName);
    CEncryptedData(HKEY baseKey, LPCTSTR szPath, LPCTSTR szKey);

    ~CEncryptedData();

    BYTE *Data(DWORD& dwLen);
    void Attach(BYTE *pbData);

private:
    void Save();

    void    Obfuscate();
    BYTE    *m_pbData;
    DWORD   m_dwLen;
    HKEY    m_baseKey;
    CString m_csPath,
            m_csKey;
};
Data() 函数返回一个指向 BYTE 加密块的指针,该块仍需要使用三重 XOR 方案进行解密。

CDataDecrypter 是一个提供 en/de crypter 核心功能的类。

class CDataDecrypter
{
public:
    CDataDecrypter();

    void Decrypt(CEncryptedData *pbData, LPCTSTR szMetaKey, CObject *pObj);
    void Encrypt(CEncryptedData*& pbData, LPCTSTR szMetaKey, CObject *pObj);

private:
    void Scramble(BYTE *pbSrc, BYTE *pbDest, LPCTSTR szKey, int iLen);

    static BYTE key0[];  //  these are the strings returned by 
    static BYTE key1[];  //  metakeygen and compiled into your
    static BYTE key2[];  //  app
};
Decrypt() 函数接受一个指向加密实体(在去混淆后)的指针,一个用于解码三个加密密钥的 meta key,以及一个指向派生自 CObject 的可序列化对象的指针,该对象包含您的数据。该类的调试版本会 assert 传入的对象指针确实是可序列化的。

key0key1key2 是由 MetaKeyGen 创建的 BYTE 数组,并被编译到您的 exe 文件中。

CEncryptedData 不需要存储在类实例化时就存在。它会以一种无害的方式失败,做“正确的事情”。这就是您创建存储的新实例的机制。使用所需存储的名称调用适当的 CEncryptedData 构造函数,即使存储尚不存在,然后调用 CDataDecrypter::Encrypt 来加密您的对象并将其写入所需的存储。

此类会在返回前覆盖内部缓冲区。但是,它们无法覆盖您已解码的对象。由您来管理已解码对象的生命周期。解码对象,使用其数据,并在解码后尽快销毁它(并覆盖内存)。您的 CObject 派生对象至少应在其析构函数中将所有分配的内存清零,然后再释放内存。

这些类不是线程安全的。这仅在多线程同时尝试解密您的加密实体而另一个线程尝试保存同一实体时才重要。Save() 函数会更新加密实体的最初 32 位,对其进行混淆,将其写入存储,然后再次进行混淆以撤销第一次混淆的效果。

使用此方案时应避免的事情

  • 不要认为此方案可以提供防复制。它不能。最多只能用于使用唯一 ID 签名软件副本,该 ID 标识特定的许可证持有者。如果您的软件突然通过盗版软件泄露,此方案可用于识别泄露者。当然,只有当您为每个副本分发一个唯一的加密实体来标识该副本的接收者时,这才能起作用。

  • 添加验证函数到类以使您的代码能够验证正确解密是诱人的。但如果您这样做,那么您就为试图破解的攻击者提供了一种验证他们破解您的方案成功的方法。毕竟,您的代码正在调用一个未知的函数并测试结果。然后它会分支到弹出消息框的代码,显示一条解密失败的消息。破解者会查找 exe 中的这些字符串,并找出它们的引用位置。从那里,很容易就能完全破解您的保护方案。在我开发这些类的场景中,不需要验证检查。最坏的情况是,返回的值是无效日期或错误的密码。

  • 起初,在应用程序启动时验证授权软件似乎是合理的。一个天真的检查会通过调用 CWinApp::InitInstance() 来解密加密实体。糟糕的主意!请记住,如果您试图保护您的软件,您也在试图对抗破解者。使用调试器单步执行启动代码,定位可执行文件的入口点并不难。

  • 出于同样的原因,试图在“关于”框中显示来自加密实体的信息并不是一个好主意。任何有能力的开发人员都可以找到“关于”框的调用位置,并从那里开始逆向工程。

我如何绕过最后两点?您可以在应用程序启动时设置一个计时器,通过 PostMessage() 将验证命令发送到您的主消息循环。在应用程序启动一段时间后,应用程序会自行验证,并在验证失败时采取适当措施。同样,在您的“关于”框的 OnInitDialog() 过程中,您可以发送一条消息给自己,或设置一个计时器,在 WM_INITDIALOG 消息处理后一段时间更新 UI 元素。但最好是,直到您需要加密实体中的信息时,才验证该加密实体。

结论

我的方案是无法破解的吗?当然不是!只有傻瓜才认为他能创造出别人无法打破的东西。但在该方案使用的五年里,我们的 IRC 网络从未出现过安全漏洞。当然,这可能仅仅是因为没有人尝试过。我永远不会知道。

它在为我设计的场景之外有什么用?老实说,我不知道。它可以用于保护 FTP 密码,例如博客客户端。或者它可以用于唯一地签名一份授权给特定个人的软件副本。无论如何,它可以作为我偏执的纪念碑 :) 不过,我在我正在写的下一篇文章中使用了这些类。

© . All rights reserved.