原文地址:http://blog.cylance.com/cracking-ransomware
0x00 背景
在2013年初,某公司向我们求助,因为他们有大量的重要文件被勒索软件加密,导致无法访问。这次攻击者使用的,是一款披着“反儿童色情邮件”外衣的勒索软件,它会遍历所有磁盘,并加密其中的重要文件。由于该软件发动攻击时,会安装备份驱动器,因此总的来说,公司的数据会面临一定的损失。不过幸运的是,我们能够破解其口令,从而将被加密的数据恢复如初。下面,我们详细介绍破解的流程。
我们之所以拖到现在才将其公之于众,主要担心会促使勒索软件的开发者采用强度更高的口令保护方法。有大量证据表明,恶意软件开发者经常会浏览安全公司的有关文章。例如,在我们破解这个软件几个月后 ,就有另外一家公司公开宣称能够恢复被该勒索软件所加密的文件。虽然这家公司没有公布细节,但是看上去恶意软件作者仍然认真采取了相应的对策。该勒索软件的新版本不久就面世了,与此同时,原来的口令猜解技术也毫无悬念地失效了。不仅如此,其作者还在软件注释中明确介绍了弱口令生成缺陷的有关情况。虽然早在2013年的美国黑帽大会上,我们就介绍过如何攻击伪随机数生成器,但是在如何破解勒索软件方面,这还是头一回公之于众。
在公布勒索软件本身漏洞方面,我们希望研究人员酌情处理;最稳妥的方案是将其提交给受害者信赖的机构或公司,这样,只要漏洞仍然存在于勒索软件中,就能为受害者争取更长的救助时间。
0x01 改进型的ACCDFISA
最初的勒索软件样本,都是来自同一个系列:ACCDFISA。由于该系列开发者的人为错误或者疏漏,该软件系列早就被研究人员干倒了。不过,这次我们所面临的勒索软件,比以前的有了很大改进。它自称使用了AES-256加密算法,为每位受害者都单独使用了一个长256个字符的随机密钥,并且将其发送给了攻击者。此外,它还声称为了防止未加密的原始文件和口令被人恢复,已经将其彻底删除了。实际上,这些声明只是勒索软件妄图让受害者放弃反抗,乖乖就范的攻心术罢了。但是,勒索软件作者会不会真有如此神通呢?
为此,我们来研究被加密一个的文件,这个文件已经被更名为:“(!! to decrypt email id ... to ...@... !!).exe
”。它实际上是一个存放经过加密的RAR文件的WinRAR自解包程序。很明显,如果想要寻找WinRAR加密实现的漏洞的话,那是指望不了,所以,我们另辟蹊径,直接从破解口令下手。为此,我们需要了解创建该口令的那些代码。
在被感染的驱动器上,我们不仅发现了这个勒索软件,同时还发现其他一些看起来也与此有关的文件。其中一个文件,就是Microsoft Sysinternals中的sdelete工具,该工具的作用是永久性的删除文件,所以我们不仅畅想:要是该软件存在Bug的话,我们就能速战速决了。此外,我们还找到了一个名为“NoSafeMode
”的库,以及一个建立自解包程序的RAR工具。这些文件的存在不禁使人浮想联翩:攻击者已经制造出了一个窃取代码的弗兰肯斯坦怪物,然后与勒索软件的主要可执行代码简单缝合在一起,就像PureBasic 所写的那样。这里的RAR工具就是我们破解该勒索软件的突破口。由于这个工具需要提供密钥口令来作为命令行参数,因此我们推断,如果从勒索软件中启动这个压缩工具的代码处开始回溯,就能够找到构造该口令的代码了。
0x02 寻找口令生成器
首先,在我们一次性的机器上运行该勒索软件,然后,连接调试工具来拦截CreateProcess
调用,这个调用的作用是启动加密文件的那个RAR工具。不一会儿,调试工具就停下来,这时就可以查看完整的命令行了,其中口令就位于“-hp”选项中。
当在调试工具中运行的勒索软件试图启动该RAR工具(这里它被伪装成“svchost.exe
”)时,就会被我们截获。这样,我们可以看到执行勒索软件的命令的具体内容了,其中就包括用来加密文件的口令。
经过一番折腾后,我们得到了一个口令,虽然它未必能够用来解密受害者文件,但是,至少为逆向工程提供了有用的线索:我们可以寻找口令或者口令中的片段,搜索可能用于生成口令的“字母表”(如果它是随机生成的话),以及从命令行中寻找建立口令的“-hp”字符串。
拦截到的口令,看起来像是由57个字母、数字和标点符号混合而成的。对于这个口令,如果是由人键入的话,看起来好像太随意了一点,并且其前缀为字符串“aes”。后者也许只是一个巧合,就像汽车牌照上也会出现有意义的单词或者号码,同时,这个前缀也可能是勒索软件中的硬编码字符串造成的。事实上,当我们用反汇编程序打开该勒索软件时,我们不仅找到了字符串“aes”,而且发现,更为完整的前缀实际上是“aesT322
”:
在这个勒索软件的反汇编代码中,出现了硬编码的字符串“aesT322
”,这与我们之前拦截的口令的前七个字符非常吻合。同时,我们还为在之前的逆向工程期间得到的一些函数(“_main”, “_strcat_edx”
)和全局变量(“PasswordPrefix
”)进行了命名。
这表明,该口令事实上是由“aesT322
”后跟50个左右的随机字符组合而成的。
在上图中,部分高亮显示的指令,就是用来加载指向“aesT322
”字符串的引用的。我们由此推断,后面的指令,将加载一个指向存储字符串的全局变量的引用。我们已经给该变量取名为“PasswordPrefix
”,下面我们来定位该变量在内存中的具体地址。
该勒索软件的数据段包含了一些与生成随机口令有关的全局变量。就像以前一样,这里也对某些变量重新进行命名并加以注释。
知道了这些变量的地址后,我们就可以利用调试工具来查看在实际测试期间,这些变量到底存放了哪些值,具体如下所示:
尽管我们可以轻松地通过反汇编程序浏览勒索软件的程序代码,但是这样做的缺点是,这只能针对磁盘上的静态程序,或者说是“无生命的”程序。要想操作运行在内存中的、活蹦乱跳的勒索软件进程的话,我们还需借助于调试工具。下面,我们就通过它来观察三个字符串变量的取值情况。
正如我们所预料的那样,名为“passwordprefix
”的变量指向前缀字符串“aest322
”的一个副本,名为“passwordrandom
”的变量指向由50个随机字符构成的字符串,而名为“passwordfull
”的变量则指向由前面两部分连接而成的一个字符串。
然后,利用前面的发现和方法,继续跟踪字符串“-hp”。通过反汇编程序,我们迅速锁定了几个实例,下面便是其中之一:
我们在程序代码中发现了字符串“-hp”的一个实例,显然,它肯定位于构建含有口令的命令行并(通过CreateProcessA
或ShellExecuteA
)执行该命令的代码中。 到此为止,我们虽然对该勒索软件有了更多的了解,但是仍然不知道是否足以解救受害者。不过有一件事情是肯定的,那就是该口令不能适用于所有受害者。幸运的是,通过反汇编程序我们可以轻松找出程序中所有访问某个变量的代码,这样我们就能追踪到构造“PasswordFull
”的代码了:
上面的代码,通过连接“aesT322
”和随机字符串而构造了“PasswordFull
”所指向的字符串。
然后,我们利用交叉引用找到了“PasswordRandom
”。
这个循环(图中用粗虚线箭头加以指示)的作用是从由78个字符组成的“字母表”中挑选50个字符组成一个串,通过前面拦截的口令可以看出,它好像是随机挑选出来的。 我们还发现了一个循环,它的循环计数是从1到50,这跟口令中随机部分的那50个字符的长度非常吻合。同时,我们还在这个循环内部找到了一个字符串,看起来非常像是用来随机选择50个字符的“字母表”。在这个字母表中,包含了26个小写字母,26个大写字母,10个数字,以及16个标点符号。
接下来,我们需要找出选择随机字符的那个函数,当然,上面循环内部的指令肯定会调用这个函数的。尽管人们普遍认为计算机能够产生真正的随机数,但是现实是,它只能带来伪随机数。长期以来,人们不断探索针对伪随机数生成器的攻击技术,以便能够战胜加密术,这为我们的工作做好了很好的铺垫。上面,我们标出了一个名为“_get_random_Alphabet_char
”的函数,其反汇编代码如下所示:
观察上面的反汇编代码不难发现,函数“_get_random_Alphabet_char
”好像生成了一个随机数,并将其保存到了局部变量“var_8
”中。并且,这段循环代码会顺序遍历字母表的各个字符,直到抵达某个字符为止。然后,那个字符被转换成一个单字符字符串,其地址被存放到被我们命名为“RandomAlphabetChar
”的全局变量内。
当确定出上面高亮显示的函数就是PureBasic
的随机数生成函数“Rnd”之后,这些汇编代码就立马变得非常容易阅读了。这个函数的代码如下所示:
随机数函数“Rnd”的作用是,确保初始化伪随机数生成器,以生成一个介于0到指定最大值“arg_0
”之间的一个数值。当然,这个范围也包括了它的两个边界值。 函数“Rnd”封装了许多其他函数的调用,尤其是我们前面命名的那些函数。这里的“_internal_Randomize_default
”函数,除了调用一些Windows函数外,还调用了该勒索软件自身的一个函数,该函数被命名为“_internal_RandomInit
”。下面,我们将这些函数并列显示,具体如下图所示。
在上图中,左窗口显示的是“_internal_Randomize_default
”函数的反汇编结果,而右窗口中是我们更为感兴趣的“_internal_RandomInit
”函数的反汇编结果。 现在,我们终于取得了一个重大突破。在左边窗口可以看到,PRNG是通过由执行这个程序代码的线程的标示符和以毫秒计算的系统运行时间共同得到的一个32位数字来初始化或者说指定种子值的,而这两个数值相对来说都比较容易预测。在右边的窗口中,我们高亮显示了一个常量“magic”,这是一个专用的数字,通常用于网络搜索。在这里,这个数字好像采用了16进制的形式,具体为53A9B4FBh
,当然,还有其他可能的表示形式,如0x53A9B4FB
,或者十进制表示形式1403630843
。它后面的指令,可以转换为“1 - EAX * 0x53A9B4FB
”的形式,这意味着我们所看到的这个常量实际上可能是负数-1403630843
,如果看作是无符号数字的话,还可以表示为AC564B05h
,0xAC564B05
或者2891336453
。利用上面的这些内容作为搜索词进行网络搜索,我们竟然在PureBasic
论坛中找到了与这些随机数生成程序有关的源代码,以及相应的反汇编代码。
下面所谓“Rnd
”函数的反汇编代码中明显的循环操作进一步印证了我们的发现,它跟我们从网络上找到的源代码的反汇编代码是一致的。
上面高亮显示的“ror
”助记符表示的是循环左移指令,分别表示循环左移13比特和5比特。并且,我们从网上找到的源代码也在类似的上下文中执行循环左移操作,甚至移动的位数也是相同的。
好了,见证奇迹的时刻就要到了。由于它使用了32位的种子值,也就是说,可能的口令至多能有2的32次方种,而不是从78个字符组成的字母表中真正随机挑选出50个字符的可能组合数。之所以出现这种情况,是因为计算机无法真正实现随机性。对于任何给定的种子值,PRNG每次用它初始化的时候,都会以完全相同的次序生成完全相同的数值。由于这个随机数种子值是一个32位的数字,也就是说其取值范围是从0到2的32次方左右,因此可能的初始状态也会受到相同的限制。
如果我们能知道被侵害系统在受到攻击之前已经运行的时间的话,那么就能极大地缩小种子值中运行时间分量的取值范围,从而显著减少需要尝试的口令的数量。当然,列出所有2的32次方个口令也很麻烦。但是就本例而言,种子值的来源,即线程标识符和系统运行时间却给我们提供了莫大的帮助。前者是4的倍数,并且通常小于10000,而后者的变化范围则要更大一些:它以49.7天为周期,取值从0变为2的32次方,之后,又从0开始循环往复,步进通常为15或者16。如果我们能获悉被侵害系统在受到攻击之前已经运行的时间的话,那么就能极大地缩小种子值的运行时间分量的取值范围,从而显著减少需要尝试的口令的数量。
0x03 猜解口令
实际上,猜口令并不是很难,问题在于这需要耗费大量的时间。当然,单纯利用给定种子值生成一个口令的话,速度是极快的,但是对于我们匆匆拼凑起来的这台一次性机器来说,我们只进行了一种猜解试验,即尝试解密RAR看看能否找出有用的东西来,这种尝试所需的运算量非常之大,尤其是需要尝试几百万次的时候。正如结果所展示的那样,虽然没能找到受害者电脑运行时间方面的线索,但是,却出乎意料地发现了可能比这个更有用的东西。 首先,在检查被感染的磁盘的时候,我们注意到,\ProgramData
文件夹下面隐藏了许多子目录,这些目录名是由随机字母组合而成的,并且这些子目录下面还隐藏了许多奇怪的文件。此外,我们还发现了一个名为\ProgramData\svcfnmainstvestvs\stppthmainfv.dll
的文本文件,其中共21行,每行含有8个随机字母。随着进一步的检查,我们发现每一行内容实际上就是勒索软件\ProgramData
目录下面的由随机字母组成的子目录名或文件名,只不过顺序给倒过来了。
文件\ProgramData\svcfnmainstvestvs\stppthmainfv.dll
中的内容,是我们在运行测试过程中得到的。虽然这个文件的扩展名是.dll
,但是这纯粹是忽悠人的,它实际上就是一个文本文件。对于每一行字母,只要把它们倒过来,例如chlqfohk
,都对应于一个子目录或者文件的随机名称,这些都是勒索软件在\ProgramData
目录下面创建的。
当然,这些数据的价值不在于我们需要这些名称,而在于我们知道了它是PRNG在感染后输出的。在实际发生感染的时候,例如本次面临的这个案例,我们当然无法像测试运行这样获得相应口令,但是仍然有机会找到stppthmainfv.dll
或者根据被感染驱动器的\ProgramData目录中的内容来重构它。利用这些数据,我们就能够暴力破解所有可能的种子值,最多需要2的32次方次,就能找出PRNG生成这些随机名称时所对应的种子值了。对于目前的计算机来说,这种暴力搜索可能需要几个小时才能完成,但是相对于利用RAR工具逐个尝试推测的口令来说,速度要快上好几个数量级了。
这里有几点需要注意。首先,就像在随机函数中识别出的线程本地存储(TLS)调用所暗示的那样,每个线程都有自己的PRNG状态,并且在第一次被“Rnd”调用时,都会单独进行初始化。碰巧的是,那八个随机字母构成的名称是由勒索软件进程的主线程生成的,而密码是由分线程决定的。下面是生成21个名称的一段循环代码;标为绿色的代码交叉引用(“CODE XREF
”)注释表明,该代码驻留在程序的“start”函数中,并且该函数是该进程第一个执行的线程。
在勒索软件的“start”函数中的这段循环代码,将创建21个随机的文件名和子目录名。
这是上面提到过的那个从A到Z的字母表中随机选择8个字母函数中的循环代码段。正如所料,该函数会针对每个字符都调用一次“Rnd”。
通过跟踪生成口令的代码,我们找到了一个标记为“sub_406582
”的函数,它会被我们命名为“_ServiceMain
”的函数调用,当勒索软件作为一个Windows服务运行的时候,这个函数将在一个单独的线程中运行。也就是说,我们关心的这两个代码段在执行时会使用不同的PRNG状态,每个状态都是由不同的随机数的种子值来产生的。通过暴力破解种子值,我们可以得到随机的文件名或子目录名,但是却无法直接得到口令的种子值,尽管如此,我们已经离成功越来越近了,这主要得益于口令种子值构成部分的简单性和可预测性。换句话说,种子值的系统运行时间部分和线程标识符部分还是有很大的不同的,因为线程可以在不同的时刻启动,并且如果多个线程同时运行的话,必须使用不同的ID,但是它们不会相差太大。因此,要是手头上有了第一个种子值,我们就能适当地将第二个种子值的取值范围缩小到几十万个之内。
第二个注意事项是,我们已经知道了字母的次序,但是这一点很容易就会被忽视。另外,从技术上讲PRNG会发出一个数字序列。就本例而言,就像前面的快照所显示的那样,数字0到25表示的就是字母A到Z,所以很明显,这个字母表就是用来将字母映射到一个数字的。然而,在其他情况下,这些字母可能会出现遗漏、重复、重新排序,或者穿插了stppthmainfv.dll
中所见之外的字符。无论出现上述哪一种情况,都意味着我们需要花费许多小时来破除这些阻止我们暴力破解的障碍。
第三个需要注意的事项是,我们无法确定PRNG的种子值初始化之后,该勒索软件是否立即生成了这些名称或口令,也就是说,这中间否有什么岔子。如果其他代码先于这两个线程中的某一个,或先于这两个线程之前就已经调用过了Rnd的话,则意味着PRNG的状态已经不是我们所关心的随机数生成代码给它提供种子值时的原始状态了。我们需要弄清,“Rnd”被我们感兴趣的那个线程调用之前,已经被其他线程调用了多少次,同时,还要去掉在生成我们自己推测性的名称和口令之前的那些随机数。
因此,我们需要找出针对“Rnd”的各个调用。如果将反汇编代码稍微上滚一点,就会看到“start”函数中针对“Rnd”的唯一的一个调用,具体如下所示:
这是该勒索软件对“Rnd”的第一次调用,并且是生成文件和子目录名称的循环代码之前的唯一的一次调用。
另一方面,执行口令生成代码的“_ServiceMain”线程对“Rnd”总共调用了3次,并且都是在它使用PRNG构造口令之前调用的,具体见下图。
这三个针对“Rnd”的调用,都先于“_ServiceMain”线程中生成口令的那些代码。
因为存在这三个调用,因此,当我们准备开始生成候选密码的时候,每次初始化好随机数种子值后,最好放弃前三个随机数。
现在,我们终于要开始暴力破解了。对于寻找名称的种子值的代码,如下所示。需要注意的是,为了简洁起见,我们这里省略了实现PRNG的PureBasic
代码。
for (unsigned int seed = 0; ; seed++)
{
// seed the PRNG with a possible value
RandomInit(seed);
// discard one random number
Rnd();
size_t i;
for (i = 0; i < 21 * 8; i++)
{
// generate the next random letter
// Rnd(25): from 0 to 25 inclusive
char ch = "abcdefghijklmnopqrstuvwxyz"[Rnd(25)];
// does this letter match the next in the sequence?
if (ch != "chlqfohkayfwicdd...dszeljdp"[i])
break;
}
// did we complete the entire sequence?
if (i == 21 * 8)
{
// yes, display the result and finish
printf("Names seed = %u\n", seed);
break;
}
// no, try the next seed value
}
在一个单核机器上面,我们使用了大约4秒钟的时间,测试了31956209种可能的值,并发现最后一个种子值即31956208
生成的字母序列与“stppthmainfv.dll
”找到的完全一致。这个数字说明,我们之前的观点是正确的。
尽管我们这个将这些信息转化为结果的系统算不上优雅,但作为原型却是行之有效的。即使我们靠硬猜的话,需要尝试的用来生成口令的种子值不会超过32768或者180000(以毫秒为单位的3分钟时间),就能找出前面恢复出来的名称对应的种子值。因此,我们可以根据这个范围内的种子值来生成一个大约包含200000个口令的清单,代码如下所示:
// this is the names seed value we brute-forced earlier
unsigned int namesseed = 31956208;
// loop through a range of seed values around the determined names seed value
for (unsigned int seed = namesseed - 32768; seed <= namesseed + 180000; seed++)
{
// seed the PRNG with a candidate password seed value
RandomInit(seed);
// discard three random numbers
Rnd();
Rnd();
Rnd();
char pwrandom[51];
pwrandom[50] = '\0';
// generate the fifty-char random portion of the password corresponding to
// the candidate seed value, using the alphabet extracted from the ransomware
// Rnd(77): from 0 to 77 inclusive (this alphabet contains 78 characters)
for (size_t i = 0; i < 50; i++)
pwrandom[i] = "abc...xyzABC...XYZ0123456789!@#$%^&*&*()-+_="[Rnd(77)];;
// output the full password; this output can be captured to compile a list
printf("aesT322%s\n", pwrandom);
}
运行上面的代码,将运行结果重定向到一个文本文件中。这个存放待测试的口令的文本文件的大小只有12MB左右,但是,如果不缩小口令种子值取值范围的话,则需要测试的口令有2的32次方个,那么存放它们的文本文件的大小就会陡升至236GB左右。这样一来,我们就可以编写一个批处理文件,让它运行7-Zip,遍历文件中的口令来解压经过加密的RAR自解包程序。对于7-Zip来说,有时候即使已经正确解密了,它也会谎报军情,为此,我们需要使用find命令在其输出中搜索只有文件被正确解密才可能出现的东西。我们使用的批处理文件如下所示:
@for /f %%p in (pwlist.txt) do @(
7z.exe l "testtargetfile_0123456789abcdef.docx(!! to decrypt email id ... to ... !!).exe" "-p%%p" 2>&1 |find ".."
if NOT ERRORLEVEL 1 (echo "%%p")
)
我们让这个批处理文件通宵运行,在第二天早晨终于找到了期盼已久的东西:正确的口令。至此,我们大功告成了! 我们没有辜负受害者对我们的信任,最终将其数据恢复如初。