Scroll Top
Evotec Services sp. z o.o., ul. Drozdów 6, Mikołów, 43-190, Poland

Restoring (Recovering) PowerShell Scripts from Event Logs


A few days ago, I was asked to take a look at PowerShell Malware. While I don't know much about malware, my curiosity didn't let me skip on this occasion, and I was handed over WindowsPowerShell.evtx file. Ok, that's not what I expected! I wanted PowerShell .ps1 files that I can read and assess? Well, you play with the cards you were dealt with. What I was handed over was PowerShell Event Log. PowerShell writes whatever you execute, and it thinks it is risky, to Windows PowerShell Operation Event Log.

The same thing is done for PowerShell 6 or 7, but it's stored in a different event log.

Of course, that's by default. You can also enhance PowerShell logging capabilities and log all script runs into Event Log. Additionally, the default log size is only 15MB. So if you would like to utilize this feature, you need some additional configurations.

Restore-PowerShellScript for the rescue

So while you can go thru Event Log and read all those scripts, extract it, copy/paste, I am way too lazy for that. There's also a problem if the script is large enough because Event Log will split large scripts across multiple events. For that purpose I've developed small modules that can be used for analyzing code that is stored in PowerShell Event Logs.

Restore-PowerShellScript -Type WindowsPowerShell -Path $PSScriptRoot\ScriptsLocal -Verbose -Format -AddMarkdown

With that small command above, you can extract everything that is stored in the WindowsPowerShell log. As you probably noticed, there's also Format and AddMarkdown switch. The first one uses PSScriptAnalyzer to format code before saving it; the second one adds additional explanation where the data is coming from.

VERBOSE: Get-Events - Preparing data to scan computer EVOWIN
VERBOSE: Get-Events - Filter parameters provided LogName = Microsoft-Windows-PowerShell/Operational
VERBOSE: Get-Events - Events to process in Total (unique): 2
VERBOSE: Get-Events - Events to process in Total ID: 4103, 4104
VERBOSE: Get-Events - Running query with parallel enabled...
VERBOSE: Get-Events -------------START---------------------
VERBOSE: Get-Events - Inside EVOWIN for Events ID: 4103 4104
VERBOSE: Get-Events - Inside EVOWIN for Events LogName: Microsoft-Windows-PowerShell/Operational
VERBOSE: Get-Events - Inside EVOWIN for Events RecordID:
VERBOSE: Get-Events - Inside EVOWIN for Events Oldest: False
VERBOSE: Get-Events - Inside EVOWIN Data in FilterHashTable LogName Microsoft-Windows-PowerShell/Operational
VERBOSE: Get-Events - Inside EVOWIN Data in FilterHashTable Id 4103 4104
VERBOSE: Constructed structured query:
<QueryList><Query Id="0" Path="microsoft-windows-powershell/operational"><Select Path="microsoft-windows-powershell/operational">*[((System/EventID=4103) or (System/EventID=4104))]</Select></Query></QueryList>.
VERBOSE: Get-Events - Inside EVOWIN Events found 582
VERBOSE: Get-Events - Inside EVOWIN Processing events...
VERBOSE: Get-Events - Inside EVOWIN Time to generate 0 hours, 0 minutes, 29 seconds, 766 milliseconds
VERBOSE: Get-Events --------------END----------------------
VERBOSE: Get-Events - Overall errors: 0
VERBOSE: Get-Events - Overall events processed in total for the report: 582
VERBOSE: Get-Events - Overall time to generate 0 hours, 0 minutes, 29 seconds, 851 milliseconds
VERBOSE: Get-Events - Overall events processing end
VERBOSE: Using settings hashtable.
VERBOSE: Analyzing Script Definition.
VERBOSE: Running PSPlaceCloseBrace rule.
VERBOSE: Found 0 violations.
VERBOSE: Fixed 0 violations.
VERBOSE: Running PSPlaceOpenBrace rule.
VERBOSE: Found 0 violations.
VERBOSE: Fixed 0 violations.
VERBOSE: Running PSUseConsistentWhitespace rule.
VERBOSE: Found 38 violations.
VERBOSE: Fixed 38 violations.
VERBOSE: Analyzing Script Definition.
VERBOSE: Running PSUseConsistentIndentation rule.
VERBOSE: Found 360 violations.
VERBOSE: Fixed 360 violations.
VERBOSE: Analyzing Script Definition.
VERBOSE: Running PSAlignAssignmentStatement rule.
VERBOSE: Found 38 violations.
VERBOSE: Fixed 38 violations.
VERBOSE: Analyzing Script Definition.
VERBOSE: Running PSUseCorrectCasing rule.
VERBOSE: Found 0 violations.
VERBOSE: Fixed 0 violations.

What the script did is scan all event logs, find all scripts within them, merge them (if they were split across different events) and save them to disks separately. When you go into a folder that has all the files you will see all files having similar structure to this one:

This gives you full PowerShell Script content and additional information added as the top comment. As extra security, I've made sure that the accidental run of a script is blocked.

I'm basically forcing all PowerShell scripts that are getting written to disk as if they were coming from the internet. This way before you can run it, you need to unblock it. Otherwise, you will be treated with a security message.

I thought this would be beneficial, as you never know is sleeping in your event logs. Of course Restore-PowerShellScript command has ability to query computers remotely.

# Keep in mind AD1/AD2 will do it in parallel
Restore-PowerShellScript -Type WindowsPowerShell -Path $PSScriptRoot\ScriptsRemote -ComputerName AD1, AD2 -Verbose -Format -AddMarkdown

All queries are done using parallel, so asking 2-10 servers at the same time shouldn't be a problem.

Installing PowerShellManager and support modules

All those described features of PowerShellManager are one command away. You simply need to use Install-Module cmdlet and it will get installed from PowerShellGallery.

Install-Module PowerShellManager

This module, to do its job, uses two additional modules. PSScriptAnalyzer, which is responsible for formatting and PSEventViewer, which I wrote that is a wrapper around Get-WinEvent. It makes things very easy when parsing Event Logs, solves everyday problems, and runs in parallel, so querying multiple servers doesn't mean waiting hours for output. Of course, you only need to install them separately if you don't intend to use PowerShellManager as shown above. When you install PowerShellManager it will install all required dependencies or use the ones that are installed already (PSScriptAnalyzer may already be there).

Install-Module PSScriptAnalyzer -Force
Install-Module PSEventViewer -Force

For sources, as always visit GitHub. All my projects are hosted on it.


If you're not a module kind of guy and prefer copy/pasting code, you can get it from GitHub or find it below. Keep in mind that it requires PSEventViewer and optionally PSScriptAnalyzer if you intend to use the formatting feature.

function Restore-PowerShellScript {
    [cmdletBinding(DefaultParameterSetName = 'Request')]
        [Parameter(ParameterSetName = 'Request', Mandatory)][ValidateSet('PowerShell', 'WindowsPowerShell')][string] $Type,
        [Parameter(ParameterSetName = 'Request')][string[]] $ComputerName,
        [Parameter(ParameterSetName = 'Events')][Array] $Events,
        [Parameter(ParameterSetName = 'File')][string] $EventLogPath,
        [Parameter(Mandatory)][Alias('FolderPath')][string] $Path,

        [DateTime] $DateFrom,
        [DateTime] $DateTo,
        [switch] $AddMarkdown,
        [switch] $Format,
        [switch] $Unblock
    if (-not $Events) {
        $getEventsSplat = [ordered] @{
            ID       = 4103, 4104
            DateFrom = $DateFrom
            DateTo   = $DateTo
        if ($ComputerName) {
            $getEventsSplat.Computer = $ComputerName
        if ($Type -eq 'WindowsPowerShell') {
            $getEventsSplat['LogName'] = 'Microsoft-Windows-PowerShell/Operational'
        } elseif ($Type -eq 'PowerShell') {
            $getEventsSplat['LogName'] = 'PowerShellCore/Operational'
        if ($EventLogPath -and (Test-Path -LiteralPath $EventLogPath)) {
            $getEventsSplat['Path'] = $EventLogPath
        $Events = Get-Events @getEventsSplat -Verbose:$VerbosePreference
    $FormatterSettings = @{
        IncludeRules = @(
        Rules        = @{
            PSPlaceOpenBrace           = @{
                Enable             = $true
                OnSameLine         = $true
                NewLineAfter       = $true
                IgnoreOneLineBlock = $true

            PSPlaceCloseBrace          = @{
                Enable             = $true
                NewLineAfter       = $false
                IgnoreOneLineBlock = $true
                NoEmptyLineBefore  = $false

            PSUseConsistentIndentation = @{
                Enable              = $true
                Kind                = 'space'
                PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline'
                IndentationSize     = 4

            PSUseConsistentWhitespace  = @{
                Enable          = $true
                CheckInnerBrace = $true
                CheckOpenBrace  = $true
                CheckOpenParen  = $true
                CheckOperator   = $true
                CheckPipe       = $true
                CheckSeparator  = $true

            PSAlignAssignmentStatement = @{
                Enable         = $true
                CheckHashtable = $true

            PSUseCorrectCasing         = @{
                Enable = $true
    $Cache = [ordered] @{}
    foreach ($U in $Events) {
        if ($null -eq $U.ScriptBlockText -or $U.ScriptBlockText -eq 0) {
        if (-not $Cache[$U.ScriptBlockId]) {
            $Cache[$U.ScriptBlockId] = [ordered] @{}
        $Cache[$U.ScriptBlockId]["0"] = $U
        $Cache[$U.ScriptBlockId]["$($U.MessageNumber)"] = $U.ScriptBlockText
    if (-not (Test-Path -Path $Path)) {
        $null = New-Item -ItemType Directory -Path $Path
    foreach ($ScriptBlockID in $Cache.Keys) {
        [int] $ScriptBlockCount = $Cache[$ScriptBlockID]['0'].MessageTotal
        [string] $Script = for ($i = 1; $i -le $ScriptBlockCount; $i++) {
        if ($Format) {
            try {
                $Script = Invoke-Formatter -ScriptDefinition $Script -Settings $FormatterSettings -ErrorAction Stop
            } catch {
                Write-Warning "Restore-PowerShellScript - Formatter failed to format. Skipping formatting."
        $FileName = -join ($($Cache[$ScriptBlockID]['0'].MachineName), '_', "$($ScriptBlockID).ps1")
        $FilePath = [io.path]::Combine($Path, $FileName)
        if ($AddMarkdown) {
                "RecordID = $($Cache[$ScriptBlockID]['0'].RecordID)"
                "LogName = $($Cache[$ScriptBlockID]['0'].LogName)"

                "MessageTotal = $($Cache[$ScriptBlockID]['0'].MessageTotal)"
                "MachineName = $($Cache[$ScriptBlockID]['0'].MachineName)"
                "UserId = $($Cache[$ScriptBlockID]['0'].UserId)"
                "TimeCreated = $($Cache[$ScriptBlockID]['0'].TimeCreated)"
                "LevelDisplayName = $($Cache[$ScriptBlockID]['0'].LevelDisplayName)"
            ) | Out-File -FilePath $FilePath
        } else {
            $Script | Out-File -FilePath $FilePath
        if (-not $Unblock) {
            $data = [System.Text.StringBuilder]::new().AppendLine('[ZoneTransfer]').Append('ZoneId=3').ToString()
            Set-Content -Path $FilePath -Stream "Zone.Identifier" -Value $data

I would suggest, however, that if you're interested in this to star/watch this project on GitHub because I have a feeling I will be adding a few additional features that help scan PowerShell logs to assess run time of scripts, their execution paths and so on.

Additional information

Above PowerShell command is a proof that you should avoid putting sensitive data straight into scripts. Don't put passwords directly in them as if someone did their homework PowerShell allows companies to fully monitor what is being executed and restore it if necessary. If you want to find out more about methods used in this blog post I recommend two other blog posts.

Second blog post was actually my motivation – when I saw Mathias creating a script to recover deleted script from the cache I immediately thought about doing the same thing but from Event Logs.

Related Posts