利用Frida重建著名的Windows通用密码后门
在本文中,我们将以重建著名的本地Windows密码后门为例,为读者演示如何利用Frida,通过动态插桩技术实现软件的快速探查与原型构建。
搭建Frida环境
首先,我们来介绍如何安装和配置Frida。本文中,我们将使用基于Python的标准Frida环境,因为它提供了许多简单易用的工具,非常便于上手。为此,只需在Windows上安装好Python,然后,在命令行环境中执行pip install frida frida-tools命令即可完成Frida的安装。
搭建好Frida环境之后,接下来要干点什么呢?考虑到我对跟密码相关的东东天生没有抵抗力,因此,我决定鼓捣一下Windows本地安全认证子系统服务(lsass.exe)。虽然网络上面已经有许多介绍lsass的文献,但是为了提高趣味性,我决定将Frida附加到lsass.exe,以开始我们的冒险之旅。对于当前加载的模块以及任何模块的导出函数,都将是我们感兴趣的对象。最开始的时候,我尝试以管理员身份从命令行中执行frida lsass.exe命令,可惜无法正确附加到该进程:
RtlCreateUserThread返回0xc0000022错误
不过,从PowerShell执行环境下运行Frida则会顺风顺水:
Frida已经附加到lsass.exe
事实证明,在我的Windows 10系统中,默认情况下并没有赋予命令行环境SeDebugPrivilege权限,但是在调用PowerShell时,则赋予了该权限。
在管理员命令执行环境下SeDebugPrivilege权限被禁用了
既然如此,我们不妨通过编写脚本来枚举lsass。我们最初的脚本非常简单,只是调用Process.enumerateModules()函数枚举并输出各个模块的名称,从而弄清楚lsass中加载了哪些模块。
枚举lsass加载的模块
借助搜索引擎了解这些DLL时,其中的身份验证包msv1_0.dll首先引起了我的关注。按照网上的介绍,它负责本地登录验证,所以,我们就把它定为我们的研究对象。Frida有一个名为frida-trace的实用程序(它是frida-tools包的一部分),可以用来“跟踪”DLL中的函数调用。为了跟踪msv1_0.dll,我们将使用runas完成本地交互式登录。
msv1_0.dll的导出函数
执行两次本地交互式身份验证操作时,rida-trace对msv1_0.dll的跟踪结果
如上所示,借助frida-trace,我们可以轻松了解代码的执行情况:LsaApCallPackageUntrust()->MsvSamValidate(),然后,在执行两次本地登陆操作时,又调用了两次LsaApLogonTerminated()函数。为了在不阅读MsvSamValidate()的函数原型的情况下弄清其返回值,我决定在onLeave()函数中使用一个简单的log(retval)语句来查看函数的返回值(如果有的话)。这个函数是frida-trace为那些与跟踪目标相匹配的方法创建的、自动生成式处理程序的一部分,它会将一小段javascript代码转储到__handler__目录中。
MsvValidate的返回值
在这里有一个简单的假设:如果提供的凭证无效,MsvSamValidate()会返回一个非NULL值(可能是错误代码或其他之类的东西)。这里,我们并不关心该方法到底做了些什么,特别是在提供正确凭证的情况下,相反,我们只想覆盖其返回值,这样,提供的凭证是否正确就无关紧要了。为此,可以编辑由frida-trace生成的处理程序,并在onLeave()方法中添加一个retval.replace(0x0)语句,结果……
Windows死机了
事实证明,盲目修改LSASS的内部结构的话,后果就会很严重,但是,把这作为一个练手的机会的话,还是很不错的。好了,我们回到正题,要想正确地利用MsvSamValidate(),必须首先弄清其内部运行机制。
后门——方法#1
上次尝试失利之后,我开始研究LSASS和身份验证包的在线资料,其中读到的一篇文章是介绍适用于任意本地Windows帐户的“通用”密码后门的。此后,我的注意力开始转向RtlCompareMemory。根据该文章称,这个模块在进行身份验证时会调用RtlCompareMemory,用来对来自本地SAM数据库的MD4值与根据用户提供的密码计算得到的MD4值进行比较。为了演示其后门,这篇文章提供了许多示例代码,并实现了一个通过硬编码的密码来触发成功的身份验证的场景。根据MSDN文档的介绍,需要为RtlCompareMemory提供三个参数,其中前两个是指针。第三个参数是要比较的字节的长度。该函数只返回一个值,指出两个内存块中有多少字节是相同的。在比较MD4的时候,如果有16个字节是相同的,则认为两个内存块是相同的,这时,RtlCompareMemory函数将返回0x10。
为了从LSASS的角度来了解RtlCompareMemory的使用情况,我决定使用frida-trace来可视化函数的调用。得益于我们提前知道了自己感兴趣的是哪个函数,所以,在使用frida-trace时直接指定目标函数名称,就能找出哪些DLL或代码调用了这个函数。
来自lsas.exe且未经过滤的RltCompareMemory调用
不难发现,在Kernel32.dll和ntdll.dll中都对RtlCompareMemroy函数进行了解析。虽然我关注的是ntdll.dll,但事实证明,两者都用到了这个函数。调用该函数时,输出会在终端中疾驰而过,所以很难看清(从上面屏幕截图中的"ms"读数就可以看出)。因此,需要对输出进行过滤,让其只显示相关的调用。我遇到的第一个问题是:"这些调用是来自kernel32还是ntdll呢?",为此,我在自动生成的frida-trace处理程序中添加了一个模块字符串来进行区分。
添加到log()中的模块信息
运行修改后的处理程序时,我注意到两个模块中的RTLCompareMemory函数每次都被调用。这就有意思啦。于是,我决定记下它们的比较长度。我想两者也许之间也许会有所不同吧?别忘了,RTLCompareMemory的第三个参数就是长度值,所以我们可以从内存中转储该值。
转储RTLCompareMemory的第三个参数
如您所见,在两个已识别模块中的RTLCompareMemory调用,就连它们的长度参数也是完全相同的。眼下,我决定把注意力放到ntdll.dll中的函数上面,暂时忽略kernel32.dll模块。于是,我将进行比较的字节转储到了正屏幕上,希望能够从中得到一些有用的线索。辛运的是,Frida有一个专门的hexdump助手,正好可以用于完成这些工作!
RtlCompareMemory正在比较的字节内容
我瞪着眼看了老长一段时间,努力从中寻找任何可能的线索,特别是与进行身份验证时相关的内容。后来终于想起一件事:我给这个用户帐户设置的密码是……password,最后,我发现存放密码的缓冲区是RtlCompareMemory必须比较的内存块之一。
用于存放利用ASCII、NULL填充的密码的内存块,是RTLCompareMemory用到的内存块之一
我还注意到,RtlCompareMemory会使用不同的块大小进行比较。由于本地Windows SAM数据库将帐户的密码存储为MD4哈希值,因此,这些哈希值在内存中可以表示为16个字节。知道RtlCompareMemory要比较的字节的长度后,我决定对其输出进行过滤,只显示比较16个字节的内存的报告。这也是前面提到的文章中,检查后门密码时所采取的过滤方式。这样一来,frida-trace生成的输出的可读性就更高了,可以帮助我们更好的了解软件的内部机制。
- 向runas命令提供正确的和错误的密码,RtlCompareMemory函数在每种情况下都会被调用五次。
- 对于输入的密码,其前八个字符似乎每个字符间都填充了一个0x00字节,很可能是在进行unicode编码时为组成一个16字节流所致,以便于与其他内容(未知值)进行比较。
- 对RtlCompareMemory进行第四次调用时,好像与来自SAM数据库的哈希值进行了比较,该哈希值是以arg[0]的形式提供的。由于测试帐户的密码是password,因此,其MD4哈希值为8846f7eaee8fb117ad06bdd830b7586c。
当提供无效密码testing123时,对RtlCompareMemory(包括内存块内容)的5次调用,需要比较的内存长度是16个字节
当提供有效密码password时,对RtlCompareMemory(包括内存块内容)的5次调用,需要比较的内存长度是16个字节
此外,还需该记录函数的返回值,以便了解在条件成立和不成立的情况下,到底返回什么。为此,我又使用runas进行了两次身份验证,一次使用有效密码,另一次使用无效密码,观察RtlCompareMemory函数的返回值。
RtlCompareMemory函数的返回值
对RtlCompareMemory的第四次调用,其返回值是在条件成立情况下(实际上比较的是MD4值)匹配的字节数,它应该是16(十六进制形式为0x10)。根据目前掌握的情况,我天真地以为完全可以创建一个"通用后门",方法是对于所有来自LSASS内部的、比较长度为16字节的RtlCompareMemory调用,一律让其返回0x10。这就意味着可以使用任意密码了,对吧?为此,我对frida-trace处理程序进行了相应的更新,主要在retval.replace(0x10)上面,这表示在onLeave方法中匹配了16个字节并通过了测试!
覆盖RtlCompareMemory的返回值后,身份验证失败!
RtlCompareMemory被调用的次数减少到只有2次(通常是5次),而且即使提供了正确的密码,也无法通过身份验证。看来,这个方法行不通。至于原因,我们猜测可能是覆盖操作破坏了其内部验证机制,在这些内部机制中,RtlCompareMemory的返回值可能用于阴性测试。
对于B计划,我决定直接重新创建原文中的后门。这意味着,在使用特定密码进行身份验证时,设法让每次检查都成功(换句话说,让RtlCompareMemory函数返回0x10)。从之前的测试可以了解到,第四次调用RtlCompareMemory会对两个缓冲区进行比较,其中,一个缓冲区存放的是利用所提供密码计算出的MD4值的,另一个缓冲区存放的是本地SAM数据库中的MD4值。因此,我们应该先嵌入已知密码的MD4值,并在提供该密码时触发该后门。下面这行python2代码,可以用来计算单词backdoor的MD4值,并将其格式化为可供javascript代码使用的数组形式:
import hashlib;print([ord(x) for x inhashlib.new('md4','backdoor'.encode('utf-16le')).digest()])
当在python2解释器中运行时,上面的代码的输出结果类似于 [22, 115, 28,159, 35, 140, 92, 43, 79, 18, 148, 179, 250, 135, 82, 84] 这样的内容。这是一个可以在Frida脚本中使用的字节数组,用于比较提供的密码是否为backdoor,如果是,则从RtlCompareMemory函数返回0x10。这么做,还能防止由于让RtlCompareMemory在比较任何16字节内存时都盲目返回0x10而破坏其他验证机制的情况发生。
到目前为止,我们一直在使用frida-trace及其自动生成的处理程序与RtlCompareMemory函数进行交互。在与目标函数快速进行交互方面,这种做法非常理想,但从长远来看,我们还需要更强大的方法。理想情况下,我们希望能够轻松共享简单的javascript代码段。为了复制我们一直在使用的功能,我们可以使用Frida Interceptor API,提供ntdll!RtlCompareMemory的地址,并在那里使用自动生成的处理程序执行我们的逻辑。我们可以使用Module API找到函数的地址,并使用该地址调用getExportByName。
//from:https://github.com/sensepost/frida-windows-playground/blob/master/RtlCompareMemory_backdoor.js
const RtlCompareMemory=Module.getExportByName('ntdll.dll', 'RtlCompareMemory');
// generate bytearrays with python:
// import hashlib;print([ord(x) for xinhashlib.new('md4', 'backdoor'.encode('utf-16le')).digest()])
//const newPassword = new Uint8Array([136,70, 247, 234,238, 143, 177, 23, 173, 6, 189, 216, 48, 183, 88, 108]); //password
const newPassword = new Uint8Array([22,115, 28, 159, 35,140, 92, 43, 79, 18, 148, 179, 250, 135, 82, 84]); //backdoor
Interceptor.attach(RtlCompareMemory, {
onEnter: function (args) {
this.compare = 0;
if (args[2] == 0x10) {
const attempt = newUint8Array(ptr(args[1]).readByteArray(16));
this.compare = 1;
this.original = attempt;
}
},
onLeave: function (retval) {
if (this.compare == 1) {
var match = true;
for (var i = 0; i !=this.original.byteLength; i++) {
if(this.original[i] != newPassword[i]) {
match= false;
}
}
if (match) {
retval.replace(16);
}
}
}
});
生成脚本后,从具有管理权限的PowerShell环境中通过frida lsass.exe -l.\backdoor.js命令调用Frida时,任何本地帐户都可以使用backdoor作为密码通过身份验证。
后门——方法#2
我们上面留后门的方法仍然具有某些局限性:首先,通过网络登录(例如使用smbclient启动的登录)时,无法使用backdoor密码,其次,我们希望可以使用任意密码进行身份验证,而非只能使用backdoor作为密码(或脚本中嵌入的任何内容)。借助于我们已经编写好的脚本,我决定更深入地研究一下,并尝试找出调用RTLCompareMemory的原因。
实际上,我还是非常喜欢进行反向跟踪的,而对于Frida来说,只需借助于Thread模块的backtrace()方法即可生成相应的回溯信息。通过回溯,我们能够找出RtlCompareMemory函数的调用源,从而进行更加深入的分析。
每次调用RtlCompareMemory时打印相应的回溯信息
我调查了5个回溯信息,从中发现了2个值得玩味的函数名称:第一个是MsvValidateTarget,第二个是MsvpPasswordValidate。MsvpValidateTarget是紧跟在MsvpSamValidate之后进行调用的,这就是本文前面采用的钩子技术的失败原因,因为那里可能正在进行更多的处理。MsvpPasswordValidate函数是在第4次调用RtlCompareMemory时进行调用的,其作用是在进行交互式身份验证时比较两个MD4哈希值是否相等,具体如前所述。此外,我还在网上搜索了MsvpPasswordValidate函数方面的资料,发现这个方法经常用于密码后门!实际上,它与Inception用于身份验证绕过的方法相同。太棒了,也许这条路走对了。由于在网上没有找到MsvpPasswordValidate的函数原型,于是请出IDA神器,很快就发现MsvpPasswordValidate具有7个参数。我认为这里是使用钩子(hook),并记录相应的返回值的大好时机。
转储MsvpPasswordValidate的参数和返回值
在命令行中执行runas /user:user cmd命令,当输入正确的密码时,MsvpPasswordValidate将返回0x1,否则将返回0x0。看起来并不难,对吧?于是,我着手改造现有的钩子,实际上,只需让MsvpPasswordValidate总是返回0x1即可。如此一来,对于任何有效用户帐户都可以通过任何密码进行身份验证,即使使用网络身份验证时也是如此!
对于有效用户帐户,任何密码都可以顺利通过身份验证
// from:https://github.com/sensepost/frida-windows-playground/blob/master/MsvpPasswordValidate_backdoor.js
const MsvpPasswordValidate =Module.getExportByName(null,'MsvpPasswordValidate');
console.log('MsvpPasswordValidate @ ' +MsvpPasswordValidate);
Interceptor.attach(MsvpPasswordValidate, {
onLeave: function (retval) {
retval.replace(0x1);
}
});
创建独立的Frida可执行文件
到目前为止,我们构建的钩子对于Frida python模块具有很大的依赖性。不过,并不是所有的Windows目标系统上都安装了这个模块,因此,我们必须设法解决这个问题。虽然可以使用py2exe来生成独立的可执行文件,但是,生成的文件往往过于臃肿,此外,还有许多其他的缺点。因此,我们决定用C语言来构建一个独立的可执行文件,从而完全摆脱对于Python运行时的依赖。
实际上,Frida源代码存储库提供了一些示例代码,以供那些使用较低级别绑定的用户进行参考。如果希望选择这条道路的话,需要在两种类型的C绑定之间做出选择:一个包含V8 javascript引擎(frida-core/frida-gumjs),另一个不包含V8 javascript引擎(frida-gum)。当使用frida-gum时,需要使用C API实现插桩,从而完全跳过javascript引擎层。当需要考虑二进制文件的大小时,使用frida-gum具有明显的优势,但是实现的复杂性会有所增加。如果使用frida-core,则可以重用前面已经编写好的javascript钩子,并将其嵌入到可执行文件中。但是,这时的可执行文件需要打包V8引擎,文件大小不如使用frida-gum时有优势。
这里提供了一个使用frida-core的完整示例,我们的例子就是以它为模板的,唯一的不同之处就是确定目标进程ID的方法。原始代码以进程ID作为参数,而我们的代码则使用frida_device_get_process_by_name_sync确定目标进程ID,并提供lsass.exe作为感兴趣的实际进程PID。 该函数的定义位于frida-core.h中,您可以将其作为frida-coredevkit的一部分进行下载。接下来,我嵌入了绕过钩子即MSVPasswordValidate,并使用Visual Studio Community 2017编译了该项目。最后,得到了一个44MB的可执行文件,现在,目标系统是否安装了Python,它都可以正常运行了。不过,从文件大小来看,也许Py2exe并不是一个坏主意……
passback二进制文件,大小为44MB,它将注入到lsass.exe中,以允许使用任何密码通过本地身份验证
实际上,我们还需要做一些工作来优化生成的可执行文件的整体大小,但这需要从源代码重新构建frida-core devkit,同时还需要删除我们不需要的部分。在这里,这些任务不妨留给读者作为练习。如果您有兴趣的话,可以从这里下载与passback有关的源代码存储库,在VisualStudio 2017中打开它后,点击“build”按钮即可。
小结
在本文中,我们以重建两个Windows后门密码为例,为读者详细介绍了Frida的使用方法。对于文中用到的所有示例代码,读者可以从这个GitHub存储库中下载。
本文由白帽汇整理并翻译,不代表白帽汇任何观点和立场
来源:https://sensepost.com/blog/2019/recreating-known-universal-windows-password-backdoors-with-frida/
最新评论