硬编码加密密钥泄露:商业闭源软件的安全隐患

本文详细讲述了在商业闭源软件中发现硬编码加密密钥的真实案例,涉及DLL修改、密码恢复和软件平台安全风险,通过PowerShell反射技术实现未修改DLL的解密操作,揭示了嵌入式密钥的安全隐患。

不雅暴露:你的秘密正在泄露

硬编码的加密密钥?在我商业购买的闭源软件中?这比你想象的要常见得多。就像,真的非常常见。

这篇博客文章详细讲述了一个真实的加密密钥发现、DLL修改、密码恢复和软件平台安全风险的故事。请注意,所有对具体目标软件的引用都已擦除,所有加密敏感材料都已为讲述这个故事而进行了模拟,希望能避免惹恼太多人。

现在,事不宜迟,开始我们的故事…

那不是哈希…

几年前,我正在查看Teams通知,看到以下消息发送给所有测试者:

“哈希挑战 - 第一步,可能是识别哈希类型。使用了哈希生成器,似乎没有结果匹配。用户名是dbadmin,哈希是I3bnJsdcK4qwstvVaekB5CzcT7ESjmR/xpB8IKNtMFc=。任何建议都将非常棒” - Jordan Drysdale, 2022-07-25

继续我作为BHIS最“容易被技术问题吸引”的小怪物的趋势,我决定至少给那个“哈希”值一个快速的嗅探测试,看看是否能给我的好朋友Jordan Drysdale一些有用的东西。乍一看,很明显哈希值至少是base64编码的,所以我使用Python对值进行base64解码,然后将其转换为十六进制字符串,最后使用hashid Linux实用程序来确定值可能是什么哈希类型(如果有的话)。从hashid命令输出中,看起来哈希可能是类似SHA-256的东西。

可能哈希值的转换

我把它扔回给Jordan,以防它有用,但这种情况的某些方面让我怀疑:这真的是哈希吗?Jordan在哪里找到这个值的?此时,我完全投入了,并与他通了个电话,他给我看了一个类似于下面截图的配置文件。

CryptKeeper配置文件

正如这篇博客文章引言中所暗示的,这里的“CryptKeeper”是一个名为“[已编辑]”的软件的替身,该软件被[已编辑]组织用来帮助他们管理他们的[已编辑]。你或许能想象我多么希望前面的句子没有被编辑,但唉,这不是我们发现自己所处的情况。只要知道,尽管这个例子可能看起来是人为设计的,但它非常接近我们在那次命运攸关的 engagement 中在野外发现的情况。

经过讨论,Jordan和我最终得出了一个简单的结论:那个密码值没有被哈希,而是被加密了。这开始变得有趣了。

偷看一眼

此时,Jordan打开了一个名为dnSpy的工具,并打开了我们找到的配置文件附近的一个DLL文件。实际上,这导致加载了一堆程序集,既有来自第三方软件的,也有来自.NET框架本身的。经过一些粗略的浏览,Jordan最终搜索了单词“crypt”。这返回了几个结果,其中许多看起来特别诱人。

CryptKeeper搜索结果

正如我们所看到的,似乎有加密和解密方法,以及三个获取似乎是AES加密的密钥、盐和IV值的方法。考虑到这一点,我们打开了来自CryptKeeper.Security命名空间的RadicalRijndael类。

和兄弟们一起破解一个冷的

好吧,现在我们这里有什么?两个用于加密和解密的公共方法,它们自己在调用额外的辅助函数之前检索似乎是静态密钥、盐和IV值。我们滚动到类的底部,观察负责获取加密材料的函数,结果发现是简单地返回字节数组作为字符串的方法。

密钥检索方法

Jordan把这三个值发给我,我把它们扔进Python shell,看看我们在处理什么。经过一些轻微的处理,我们发现所有三个值都是人类可读的ASCII字符串。

解码的加密秘密材料

直接从源头来

随着我们对受影响软件的理解,以及必要的加密材料被烘焙到编译的DLL中的知识,我们现在需要解密我们加密的密码值。在经过几次使用各种不同工具的天真解密尝试失败后,我们有了一个如何进行的想法。通过将DLL加载到内存中,你可以然后使用一种称为“反射程序集”的方法访问该DLL中的资源。

反射程序集入门

当一个类是公共的,并且理想情况下当一个方法是静态的(允许直接执行而不需要关联类的实例)时,你可以运行类似以下PowerShell命令来加载和直接运行.NET方法。

1
2
[System.Reflection.Assembly]::LoadFrom("Path\To\Assembly\File.dll") 
[Namespace.Class]::Method() 

或者,在更新版本的PowerShell中,你可以使用新的using命令。

1
2
3
using assembly ".\File.dll" 
using namespace Namespace 
[Class]::Method() 

这在具有限制性应用程序控制机制但不限制PowerShell访问的环境中加载和运行程序集非常有效,但它要求目标类是公共的,目标方法既是公共的又是静态的。从CryptKeeper中的加密例程中,我们有一个非公共的内部类和公共但非静态的方法。

如果一个类是公共的但一个方法不是静态的,那根本不是大问题。以CryptKeeper DLL中的简单公共Greeter类为例:

公共类,公共非静态方法

所以只是为了说明这一点,以下截图显示我们对Greeter类型有可见性,但对名为Hello的方法没有可见性。

对类可见,对方法不可见

因为Hello方法不是静态的,但是公共的,我们可以通过Greeter类的实例访问该方法。为此,我们可以运行[Greeter]::New()或New-Object Greeter。

创建Greeter类的实例

如果我们然后将这两个命令中的任何一个的结果保存到PowerShell变量中,我们可以然后通过创建的对象调用Hello方法。

成功的非静态方法调用

好吧,很酷。这是问题的一半解决了。然而,当我们尝试访问内部RadicalRijndael类时,我们被阻止了。

对内部类不可见

现在,我们只需要弄清楚如何获得对RadicalRijndael类的访问权限。

拿走并烘焙

我想到的第一种获得对我们目标类访问权限的方法是将DLL外传到一台安装了dnSpy和.NET构建工具的机器上。从那里,可以在dnSpy界面中右键单击类名并将其修改为我们喜欢的样子。

C#类修改菜单条目

在这种情况下,我们需要做的就是将类从“internal”改为“public”,然后单击“Compile”按钮。

类修改和重新编译

随着类重新编译,我们可以然后将更改保存到新模块。在这种情况下,为了保留原始文件,我认为最好创建一个新的DLL文件。

将模块另存为新DLL

最后,我们可以用PowerShell反射程序集加载修改后的DLL,然后访问新公开的类来解密我们的密码,如下所示。

使用修改后的DLL成功解密密码

非菜单项

现在,虽然我喜欢外传、修改和反射程序集技术,但自我们最初发现以来的几年里,有些事情一直困扰着我:我们是否可能绕过.NET可见性检查并直接访问私有或内部代码?如果我们能够做到这一点,我们可以在客户端环境中利用加密代码而无需任何外传,并附加好处是降低在“传统”方式中完成此操作所需的知识门槛和软件设置。

带着这个困扰的问题,我开始了我的搜索。回顾我们已经知道的内容,以下截图显示了一个传统的反射程序集DLL加载,随后尝试访问我们的公共Greeter和内部RadicalRijndael类,分别成功和失败。

类型可见性

在研究时,我找到了对Import-Module和Add-Type PowerShell cmdlet的引用,但它们似乎都没有提供对内部或私有类型的可见性,所以我查看了加载程序集的字段,最终找到了一个名为GetType的方法,有几个重载。

程序集GetType函数重载

通读Microsoft GetType文档,我观察了下面显示的重载的文档。

Microsoft GetType文档摘录

也许我们可以使用GetType(String)来使用绝对名称获取我们DLL中类的句柄…

1
2
$greeter = $asm.GetType("CryptKeeper.Security.Greeter")
$radical = $asm.GetTYpe("CryptKeeper.Security.RadicalRijndael")

是的,绝对可以!

使用GetType对两个类可见

然而,在尝试创建对象实例时,我们仍然遇到问题。如下所示,我们可以使用New-Object cmdlet创建Greeter的实例,但不能创建RadicalRijndael的实例。

仍然找不到RadicalRijndael类型

所以,让我们深入研究加载到$radical变量中的类型,寻找任何带有“constructor”一词的东西。最终,我们找到了GetConstructors()方法,文档在这里。

GetConstructors方法文档摘录

请注意,还有一个GetConstructor方法来搜索和获取特定的构造函数,但我无法很好地掌握它。相反,让我们看看RadicalRijndael类的构造函数。由于我们在dnSpy中没有看到任何显式构造函数,我们应该期望只看到默认构造函数,而确实如此。

RadicalRijndael默认构造函数

由于GetConstructors方法返回一个列表,我们可以通过将结果的第零个元素保存到PowerShell变量中来获取默认构造函数。从那里,深入研究构造函数对象,我们遇到了Invoke方法,它有几个重载。

将默认构造函数分配给变量,Invoke方法重载摘要

我们只有一个构造函数,并且我们知道它不接受任何参数,所以我们这里应该不需要做任何太花哨的事情。我花了一些时间查找Invoke方法的文档,但没有找到太多。最终,心血来潮,我尝试用$null的参数值运行Invoke方法,然后你瞧…

成功的构造函数调用

哇。看那个,我们我们的对象就在那里!将结果捕获到变量中,我们可以使用Get-Member cmdlet并过滤成员类型为“Method”,我们可以看到我们的公共Encrypt和Decrypt方法!

公共方法可见性

这意味着我们可以在不首先修改DLL的情况下解密我们的密码。

使用未修改的DLL成功解密密码

好的,所以从头开始,我们最终得到以下PowerShell命令序列。

1
2
3
4
5
$asm = [System.Reflection.Assembly]::LoadFrom("C:\Users\moth\CryptKeeper\CryptKeeper.dll")
$rr_type = $asm.GetType("CryptKeeper.Security.RadicalRijndael")
$rr_ctor = $rr_type.GetConstructors()[0]
$rr = $rr_ctor.Invoke($null)
$rr.Decrypt("I3bnJsdcK4qwstvVaekB5CzcT7ESjmR/xpB8IKNtMFc=")

我们得到解密后的密码作为结果。

全部整合

进一步工作和结论

展望这种方法可能带我们去的地方,我设想(并且诚实地希望为这篇博客文章工作)一个像Snaffler这样的工具的扩展,以促进发现可能值得注意的加密库等的引用。Snaffler存储库有一个名为“UltraSnaffler”的替代工具,但我在时间用完之前无法编译它,也无法想出一个好的方法来查看DLL文件,而不会在时间和资源上令人望而却步。

你可能认为这种事情是一个已解决的问题:不要像这样在代码中存储秘密。而且确实是。但从“世界有一个答案”到“每个开发团队都正确实施它”还有很长的路要走。谁知道你可能会在源代码或编译的DLL中发现什么,由于不良实践、宽松的遗留代码卫生,甚至是善意的向后兼容性努力而被保留下来?绝对值得一看。我们当然会的。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计