Recovering scripts from the ScriptBlock cache

Have you ever accidentally overwritten a powershell script on disk? Didn't have a versioned copy in source control? Let's see if PowerShell can help us recover from such an incident...

Ouch. That's unfortunate. Especially if you don't have a versioned copy of the script. But as Jason Shirk noted - if the hosting process is still running when you realize it, you actually have a chance at recovering the script:

So... how might one do that, exactly? Let's try and see if we can reproduce the original problem, and then extract the script from this "scriptblock cache".

Sawing through the branch you're sitting on

Reproducing the situation described in the original tweet is as simple as putting the following two lines of code in a *.ps1 script file anywhere on disk:

Start-Transcript -Path $PSCommandPath 
Stop-Transcript

So let's place that in a file in the current directory, execute the script, and see what happens:

PS C:\Users\IISResetMe> @'
>> Start-Transcript -Path $PSCommandPath 
>> Stop-Transcript
>> '@ |Set-Content replace.ps1
PS C:\Users\IISResetMe> Get-Content replace.ps1
Start-Transcript -Path $PSCommandPath 
Stop-Transcript
PS C:\Users\IISResetMe> .\replace.ps1
Transcript started, output file is C:\Users\IISResetMe\replace.ps1
Transcript stopped, output file is C:\Users\IISResetMe\replace.ps1

And sure enough, we've now overwritten the script with a transcript log of it's own execution - oops!

Accessing the ScriptBlock cache

There's no way to directly access the scriptblock cache through the public API - so we'll need a bit of reflection magic to unearth the cached version of our script.

The backing field for the scriptblocks in the cache is a static dictionary owned by the [scriptblock] class. In Windows PowerShell we can access it like so:

$cachedScriptsMemberInfo = [scriptblock].GetMember('_cachedScripts', 'Static,NonPublic')[0]
$cache = $cachedScriptsMemberInfo.GetValue($null)

$cache will now hold a reference to the dictionary backing the script cache - mapping keys consisting of (scriptPath, content) to a compiled [scriptblock] instance. To find the key corresponding to our script, let's enumerate all the keys and filter for the expected path against the first item in each tuple:

PS C:\Users\IISResetMe> $relevantKeys = $cache.Keys.Where({$_.Item1 -like '*\replace.ps1'})
PS C:\Users\IISResetMe> $relevantKeys |Select Item1,Item2 |Format-List

Item1 : C:\Users\IISResetMe\replace.ps1
Item2 : Start-Transcript -Path $PSCommandPath
        Stop-Transcript

Boom, script recovered!

Making it work for PowerShell core

The scriptblock caching facility hasn't changed much over time, so we can re-use almost the same trick for PowerShell Core or PowerShell 7 - just change the field name to s_cachedScripts instead:

function Get-ScriptBlockCache
{
    param()

    $fieldName = '_cachedScripts'
    if($PSVersionTable.PSVersion.Major -ge 6){
      $fieldName = 's_cachedScripts'
    }

    $cachedScriptsMemberInfo = [scriptblock].GetMember($fieldName, 'Static,NonPublic')[0]
    # We're only interested in the keys, since they contain the original script text
    return $cachedScriptsMemberInfo.GetValue($null).Keys
}

Now we can simply do Get-ScriptBlockCache |Where-Object Item1 -like *replace.ps1 |Format-List

The scriptblock cache implementation can be found in CompiledScriptBlock.cs, right here.