PowerShell for Your SOCs

The Elephant in the Room

This has been on my to-learn list going on six months now. My Windows haterade glass has been full to the brim ever since the integrated Xbox app failed to connect to their servers, causing many nights of pain when I needed the Master Chief Collection. Twelve year old game by the way. However, this skill requirement on every SOC analyst role job posting motivated me to put aside my distaste for this operating system to learn the unfamiliar scripting language known as PowerShell. With the help of my pseudo professor Claude of Anthropic U and the PS documentation, we are now a functioning member, and dare I say appreciator of the PowerShell scripting language. If you are an aspiring SOC analyst or someone in IT looking to automate repetitive tasks, this post is for you. Follow me through my journey going from illiterate to really cool PS script supplier.

Before You Start

Run the Update-Help command below

Update-Help

Wax On, Wax Off: Why PowerShell Is Different

Call me old fashioned, but I have to first learn why a new tool exists and why it is any different from its peers such as Bash or the CMD prompt. PowerShell's commands pass objects, not text. Which is to say, when you get output in Bash from your commands the data is just strings which requires you to parse through the strings with other tools, think grep or sed, and carefully pick out what is relevant for your use case. As the script or amount of data grows it can become overly tedious to write. Objects on the other hand, have properties about them and methods to interact with them. PowerShell follows a verb-noun approach. Let's take a simple use case of grabbing the running processes utilizing the most memory with Bash and then PS.

ps aux | sort -k6 -nr | head

Seems simple enough right, if you print out ps aux and you see that the 6th space/column prints out the memory in KB, then just sort from largest to smallest with -nr (numeric and reverse) and you're done. Wrong. An analyst also needs process name, PID, parent process name, command, executable path and so on. If you wanted to parse all of the output of ps aux to then grab you all of the relevant data into fields that are practical you would end up with this eyesore below.

ps aux | awk 'NR>1 && $6 > 204800 {
    cmd=""; for (i=11; i<=NF; i++) cmd = cmd $i " "
    printf "%s\t%s\t%.1f MB\t%s\n", $11, $2, $6/1024, cmd
    }' | sort -t$'\t' -k3 -nr

You would need a loop because the command line is split on spaces so awk sees it as many separate fields and you need to stitch them back together. If you needed to add another field of data you would have to the re-count and adjust your loop. With PS, its this simple:

Get-Process | Where-Object {$_.WS -gt 200MB } | Sort-Object WS -Descending | Select-Object ProcessName, Id, @{n='MemoryMB'; e={[math]::Round($_.WS /1MB, 1)}}

You can call the object's property by name and manipulate it from there with coding logic you are familiar with such as -gt or greater than. By the way, the part in the command after "Id" in the command is where I make a custom column name. The n stands for name (alias) of the custom column and e stands for expression, what is to placed for this alias. The Process module in Get-Process cmdlet does not know Memory in MB so you have to take a property that does exist for it like WS (working set) and then go from there. There will be other situations soon where I need to include a property that the module doesn't have.

With PS you can guess commands as long as you look up what properties a module has after reading documentation or utilizing the built in help feature with commands like:

Get-Process | Get-Member -MemberType Property
Get-Process -Id $PID | Format-List *
Get-Help Get-Process -Full

Scripting for Process Triage

Revision 1: Process and Parent Names

After a couple test scripts to learn the syntax, it was time to come up with the first revision of a script I will use for identifying suspicious processes. Now I know you're jumping out of your seat, but slow your roll and be like Gideon's 300. Before the first character is typed lets discuss the goal of the script. We want to gather the processes and find the ones where Word spawned PowerShell with a macro or where WMI is used right next to some base64, more or less. To accomplish this we need visibility to the process name, the parent process name, the directory the process is running from, and the command that spawned these processes. The module that has all of these properties is CimInstance Win32_Process. We can gather those properties filter them for what makes them suspicious and print us what is flagged. Below is my rudimentary version:

function Get-ProcessTriage {
    param([string]$Name = '*')

    $all = Get-CimInstance Win32_Process

    $names = @{}

    foreach ($p in $all) {$names[$p.ProcessId] = $p.Name}

    $all | Where-Object { $_.Name -like $Name } | Sort-Object CreationDate -Descending | Select-Object @{n='ComputerName'; e={$env:COMPUTERNAME}}, ProcessId, ParentProcessId, @{n='ParentName'; e={ if ($names.ContainsKey($_.ParentProcessId)) { $names[$_.ParentProcessId] } else { 'exited' } }}, Name, ExecutablePath, CommandLine, @{n='StartTime'; e={$_.CreationDate}} | Export-Csv .\Procs_w_Parent.csv -NoTypeInformation
}

The flexibility of PS is showcased here with the Parameter feature in line 2 of this script. Parameters in PS are optional or mandatory variables that will be taken when the command is ran as input from the user. Here is the syntax for different types of parameters:

param(
    [Parameter(Mandatory)][string]$Path,
    [Parameter(Mandatory)][string]$Pattern,
    [switch]$Recurse,
    [string]$Extension = '*.txt',
    [int]$ThresholdMB = 200
)

Mandatory parameters prompt the user for the input every time the script or function is ran/called. Not having the param mandatory enables for default values such as in the function "Get-ProcessTriage" above where majority of the time I will be searching through all of the processes, but maybe I want to filter for only the processes that have to do with Windows or svchost. My favorite is the switch parameter that does exactly what a switch is for other commands. If I were to type the function with the switch "-Recurse" it will trigger whatever function or logic I have tied to the variable "$Recurse". This was used in a for loop function I wrote to find a string pattern within a directory with any file I would like it to look through. The Recurse switch allows me to make the search recursive through the directories within the starting directory. The other thing I want to point out is the syntax here: "Where-Object { $_.Name -like $Name }. The underline is for the current object which in this case is the current process that $all aka Get-CimInstance Win32_Process is on.

A key translation that makes this first revision helpful from an analyst's perspective is grabbing the PPID and translating that to its process name. So Get-CimInstance Win32_Process has a ParentProcessId property, but not the PPID's name. When staring at 150 rows in an excel sheet I could further match up some digits, but why make our lives harder when we can get the PPID's name? Which is why the hash table $names exists, creating key and value pairs. You can see the foreach loop below which takes the process ID's and pairs them to their name. The logic in the custom column for the parent process's name checks the table first if the PPID exists at all. If there wasn't the else {'exited'} PS would just have an empty entry for the parent process name. We know malware can spawn processes and their parent exits which we want pay attention to.

So now we have an excel sheet of 100 to 200 processes with the information we need, now imagine sifting through that in the middle of the night or if you have to use this script for a dozen endpoints? We can do better.

Revision 2: The Sauce

Lets go over the functionality that would move us from taking half an hour to gather our findings to instant results at scale. We get the processes and their names, but it would be nice to only export the ones that are suspicious to the spreadsheet. It would also be nice to be able to turn these filters on and off. Having the script flag or filter out what we already know to be indicators of compromise is what we're aiming for. Without further ado, here is Get-ProcessTriage v2.0:

function Get-ProcessTriage {
    param(
        [string]$Name = '*',
        [switch]$FlaggedOnly
    )

    # --- Rules ---
    $writablePathRx = '\\(AppData|Temp|Users\\Public)\\'
    $encodedRx = '\s-e(nc\w*|c)?\s+[A-Za-z0-9+/=]{20,}'
    $riskyParents  = 'winword.exe','excel.exe','powerpnt.exe','outlook.exe','wmiprvse.exe'
    $shellChildren = 'cmd.exe','powershell.exe','wscript.exe','cscript.exe','mshta.exe'

    $all = Get-CimInstance Win32_Process
    $names = @{}
    foreach ($p in $all) { $names[$p.ProcessId] = $p.Name }

    $results = foreach ($p in $all) {
        if ($p.Name -notlike $Name) { continue }

        $parentName = if ($names.ContainsKey($p.ParentProcessId)) { $names[$p.ParentProcessId] } else { '<exited>' }

        $flags = @()
        # rules go here
        if ($p.ExecutablePath -match $writablePathRx) { $flags += 'UserWritablePath' }
        if ($p.CommandLine -match $encodedRx) { $flags += 'EncodedCommand' }
        if ($riskyParents -contains $parentName -and $shellChildren -contains $p.Name) { $flags += 'SuspiciousParentChild' }

        [pscustomobject]@{
            ComputerName    = $env:COMPUTERNAME
            ProcessId       = $p.ProcessId
            ParentProcessId = $p.ParentProcessId
            ParentName      = $parentName
            Name            = $p.Name
            ExecutablePath  = $p.ExecutablePath
            CommandLine     = $p.CommandLine
            StartTime       = $p.CreationDate
            Flags           = $flags -join '; '
        }
    }
    $rows = $results
    if ($FlaggedOnly) { $rows = $rows | Where-Object { $_.Flags } }

    $rows | Sort-Object StartTime -Descending | Export-Csv .\triage_results.csv -NoTypeInformation
}

I have already discussed a lot of the syntax of how PS works which isn't new here at all, but the best part about this script is the rules section. Each rule is a hard coded variable that screams suspicious. The $flags variable checks the respective property against the rule which will append the $flags list with a string letting me know which rule was tripped. Each process gets its own empty list called $flags that becomes a custom column in the output. Let's go over the rules:

$writablePathRx

Checks each processes ExecutablePath for directories that are available for low level privilege users (all users) to write to. Malware will typically write to these as they do not require admin privileges to do so.

$encodedRx

Flags any command for the encode flag. PowerShells built in encoding often gets used to obfuscate their malware to bypass endpoint security checks.

$riskyParents and $shellChildren

The processes in $riskyParents are not risky in themselves, but when paired with the $shellChildren variable it is certainly behavior to further investigate.

Filtering Down to the Flagged

The $FlaggedOnly is the best part of the entire logic here. It's a switch that only prints/exports the processes that become flagged! This narrows down 150-200 rows to under 10 (give or take depending on how many trojans you have from trying to pirate Skyrim)!

Continuing Soon

This one blog post did take around a week to put together between work and my personal life, but now with this new found confidence for PS I aim to post more scripts here soon. Process triage is only one slice of the pie anyhow. I still need to create scripts for the registry, scheduled tasks, command history, network connections, and event logs. The formula I will follow to speed up the creation of these is describe the functionality and goal of the script, find a module that contains those data points, and then pretty much mirror the outline I have with this function. Once they are all completed I will instead be exporting it using JSON so that I can feed it into n8n probably.