在发布了《从PowerShell进程转储中提取活动历史记录》这篇文章后,我收到了一个有趣的问题:“如果没有捕获到原始文件,是否可以提取到已执行的脚本内容呢(从磁盘里)?”
答案是“可以”,但是操作起来很复杂。 我们从头开始一步一步看看是如何进行取证的,第一步先安装PowerShell的WinDbg模块,我们需要它来做一些自动化的工作。
首先搭建我们的取证实验环境,在C:temp
之类的目录下创建如下简单的脚本:
打开PowerShell会话,运行这个脚本。然后在任务管理器中Dump Powershell 内存:
现在,使用WinDbg模块连接到Dump下来的文件:
Connect-DbgSession -ArgumentList '-z "C:UsersleeAppDataLocalTemppowershell.DMP"'
开始取证
在开始我们的取证之前,让我们脑暴一下。我们的目的是提取Powershell会话中运行的脚本对象内容(如果存在的话),那么问题就是,如何找到这个脚本对象呢?
首先,我们使用SOS的Dump Object
命令来转储进程内存中的所有对象内容。然后,我们用!DumpHeap
命令开始查找所有对象实例(eg:这里我们甚至没有用-Type
过滤指令)。这一步和接下来的操作将花费很长时间,所以建议在睡觉之前跑这个命令 : D
$allReferences = dbg !dumpheap -short
一旦我们导出了所有对象引用,便可使用!do
(Dump Object)命令将它们全部可视化。转储对象的输出中不包括被转储对象的地址,因此我们用Add-Member
来跟踪它。
$allObjects = $allReferences | Foreach-Object { $object = dbg "!do $_"; Add-Member -InputObject $object Address $_ -PassThru -Force }
(第二天)确实是一大堆数据呢!这个进程大概有100万个被SOS识别出来的对象实例。他们中有SOS可以可视化的GUID吗? 我们来看看:
看起来我们运气还不错。在上百万个对象中,我们设法将范围缩小到PowerShell内存中的7个System.String
对象,这些对象以某种方式引用了GUID。如果我们认为信息可能一直在System.String
中,我们可以使用$allReferences = dbg !dumpheap –type System.String –short
代替一开始的$allObjects
来加快查询。 问题又来了,我们怎么知道谁包含了这些GUID呢?
为了找到答案,我们使用SOS的!gcroot
命令。 这个命令通常用于诊断托管内存的泄漏问题。例如:“为什么CLR保留了字符串的一千万个实例?”。对于任何给定对象,!gcroot
命令告诉您哪个对象引用了它,这样递归一直到对象树的根节点。 让我们探讨一下这些根节点。
好了,所以最后一个元素( 数组中的第6个 )并不是最终的根节点。它不会再被引用,很快就会被垃圾收集器清理干净。
第5个是以一个对象数组为根节点的,其中一个元素是ConcurrentDictionary
,它包含一个ScriptBlock
,ScriptBlock
中包含CompiledScriptBlockData
,CompiledScriptBlockData
包含了PowerShell AST中的节点,最后PowerShell AST引用了CommandAst AST
,最终引用了这个GUID。
看起来还不错,那其他的呢,我们看看实例中的第4项:
有意思! 这个是以相同的根对象数组(0000026e101e9a40),相同的ConcurrentDictionary
(0000026e003bc440)开头的,但这次最后是一个简单配对的元组(包含我们要找的String和另一个String)。 让我们深入了解那个元组及其包含的字符串:
这个元组有两个元素。 第一个元素看起来是执行脚本的路径,第二个元素看起来是该脚本中的内容。 让我们看看PowerShell Source怎么定义这些数据结构的。 搜索一下ConcurrentDictionary
, 在第三页我们可以看到:
有一个名为CompiledScriptBlock
的类。 它包含一个名为s_cachedScripts
的静态(进程范围)缓存。 这是一个将一对字符串映射到ScriptBlock
实例的字典。 如果您阅读了源代码,您可以确切地看到Tuple的内容(一个脚本路径到ScriptBlock缓存内容的映射):
这个数据结构就是我们最终要关心的。出于性能原因,PowerShell维护一个内部脚本块缓存,这样每次看到脚本时都不需要重新编译脚本块。 该缓存是路径和脚本内容的关键。 存储在缓存中的东西是ScriptBlock
类的一个实例,它包含了已编译脚本的AST。
所以现在我们知道了它的存在,就可以自动化地并有目的性地去提取这些东西! 现在我们正式开始写提取脚本,我们要做的就是:
- 使用
!dumpheap
查找此元组类的实例。dumpheap命令会进行字符串搜索,我们可以使用正则表达式进行一些后期过滤 - 我们拿到了实际想要研究的元组类的MT
- 使用该MT作为过滤器再次运行
!dumpheap
现在我们可以深入其中的一个节点。 它有一个m_key,我们可以研究一下。
差不多了!让我们从结果中提取出两个东西,然后获得一个漂亮的PowerShell对象:
这是一个漫长的过程,但是我们从一开始的假设到最后提取出脚本内容,都完整的过了一遍。现在,即使原始脚本文件已经被删除,你也能够从Powershell内存中恢复有所的脚本内容了。
以下是将所有这些都打包成了一个函数的最终脚本:
function Get-ScriptBlockCache
{
$nodeType = dbg !dumpheap -type ConcurrentDictionary |
Select-String 'ConcurrentDictionary.*Node.*Tuple.*String.*String.*]]$'
$nodeMT = $nodeType | ConvertFrom-String | Foreach-Object P1
$nodeAddresses = dbg !dumpheap -mt $nodeMT -short
$keys = $nodeAddresses | % { dbg !do $_ } | Select-String m_key
$keyAddresses = $keys | ConvertFrom-String | Foreach-Object P7
foreach($keyAddress in $keyAddresses) {
$keyObject = dbg !do $keyAddress
$item1 = $keyObject | Select-String m_Item1 | ConvertFrom-String | % P7
$string1 = dbg !do $item1 | Select-String 'String:s+(.*)' | % { $_.Matches.Groups[1].Value }
$item2 = $keyObject | Select-String m_Item2 | ConvertFrom-String | % P7
$string2 = dbg !do $item2 | Select-String 'String:s+(.*)' | % { $_.Matches.Groups[1].Value }
[PSCustomObject] @{ Path = $string1; Content = $string2 }
}
}