DEV Community

Cover image for Protecting PowerShell scripts with PowerShell Pro Obfuscator
Bartosz Wójcik
Bartosz Wójcik

Posted on

Protecting PowerShell scripts with PowerShell Pro Obfuscator

In this article I'd like to walk through why I built PowerShell Pro Obfuscator, what it actually does to a .ps1 file, and how the pieces fit together — without the brochure tone.

PowerShell is excellent at what it was designed for: automation, configuration, DevOps pipelines, and managing machines on Windows and Linux. The downside of that convenience is obvious once you ship anything proprietary. A script is usually a plain text file, or it sits inside a module that anyone with file access can open in an editor. There is no compilation step that hides your logic the way a native binary might. Credentials, API endpoints, licensing checks, and algorithms are all sitting there in clear text unless you do something about it.

I already work on other protection tools, so the problem felt familiar — but PowerShell's grammar, hosting model, and the way people actually deploy scripts needed their own approach. That is what became PowerShell Pro Obfuscator.

PowerShell Pro Obfuscator main window

Why plain .ps1 is a hard sell for proprietary logic

When I hand a customer or a colleague a script, I am also handing them the full source. They can search for strings, copy functions into another project, or patch a license check with a few keystrokes. That is fine for open examples. It is less fine when the script is the product.

Obfuscation does not make a script "uncrackable." What I aim for is a higher cost of casual reading and editing: rename identifiers, encrypt literals, scramble control flow, and add checks that fight the most common ways people poke at protected scripts. The output still has to run under a normal PowerShell host after I test it.

What I put into the tool

At a high level, PowerShell Pro Obfuscator takes .ps1 source, parses it, runs a selectable set of transforms, and emits a new script. I expose the same engine through a Windows GUI, a command-line client for Windows and Linux, an online form, and an API — so I can use it interactively or wire it into a build.

PowerShell Pro Obfuscator options

The options panel is where I trade protection against size and runtime cost. Not every pass belongs on every script. A small utility might only need renaming and string encryption; something I care about more might get VM lifting, finite-state automata, integrity checks, and anti-debug probes as well.

Obfuscation strategies I rely on

Polymorphic encryption and noise

String and numeric literals are the first things people grep for. I encrypt strings and integers/floats in a polymorphic way so each build looks different, and I sprinkle decoy noise so static search is less useful. The script still has to decrypt and compute the same values at runtime — the point is that the source you open is no longer a readable map of the original intent.

Code virtualization

For selected statements I lift logic into a small, randomly generated VM: shuffled opcodes, decoy cases, and an obfuscated dispatcher. Someone reading the file has to understand the virtual machine instead of a straight sequence of PowerShell statements. That is heavier than renaming, so I treat it as an optional layer rather than a default for every line.

Finite-state automata (FSA)

Linear blocks are easy to follow top to bottom. The FSA pass rewrites them into dual-state automata with opaque schedulers and shuffled handlers. Reconstructing the original order means chasing numeric states, transition tables, and decoy paths. I use this when I want control flow to stop looking like a tutorial sample.

Anti-debugging

Interactive analysis is often more dangerous than a quick glance at the file. I insert polymorphic probes that look for attached debuggers, PowerShell breakpoints, debug preference / tracing modes, and related host signals. When a check fires, the obfuscated script exits quietly. That will not stop a determined analyst with a custom host, but it raises the friction for the usual interactive session.

Self-integrity checks

Tampering is cheap if decryptors always trust the on-disk file. A bootstrap probe looks at script shape (for example function counts and integrity tokens) and sets a tamper key when the file no longer matches the obfuscated build. String decryptors consume that key, so a casually patched script tends to return garbage instead of plaintext. I think of it as a self-defending layer against drive-by edits.

Protection linker

Late in the pipeline I wire honeypot resolvers, bogus helper calls with random arguments, and shallow-stack tripwires that stay active only until bootstrap finishes. Snippets ripped out of the file keep noisy call surfaces that look real under static review. After a successful integrity check, normal execution is unchanged — the noise is for people who copy fragments without the full bootstrap path.

Before and after

Here is a tiny script I use as a smoke example. Before obfuscation it is obvious what it does:

function Get-Greeting {
    param([string]$Name)
    Write-Host "Hello World from $Name!"
}
Get-Greeting "PowerShell Pro Obfuscator"
Enter fullscreen mode Exit fullscreen mode

After a full pass, the same behaviour is buried under renaming, encrypted payloads, noise, and helper functions. A short excerpt of real output looks like this:

$script:_HnJTskg = 0
$jwNTQ = 297 * 400 + 36
$x4e8bfda = [Math]::Abs($jwNTQ - 8074)
$_EvKocNn = [Math]::Max($jwNTQ, $x4e8bfda) - [Math]::Min($jwNTQ, $x4e8bfda)
[void]$_EvKocNn
$script:_jUoXkBYh = 0
function gnJjzMCN3V8P {
    param([int]$slot, [int]$salt, [int]$guard)
    if (-not ((Get-Variable -Name _HnJTskg -Scope Script -ErrorAction SilentlyContinue).Value)) { return '' }
    @('JFE', 'm0ao', 'R8Ysw') | ForEach-Object { $_.ToUpper() } | Out-Null
if ((((($slot * 31) + $salt) -band 65535) -ne $guard)) { return '' }
    $tk = (Get-Variable -Name _jUoXkBYh -Scope Script -ErrorAction SilentlyContinue).Value
    $IEm39CSpDOEFp = @{ 'Ouj1' = 455; 'vjzO' = 170; 'IQNV' = 291 }
$IEm39CSpDOEFp['R5LrM'] = $IEm39CSpDOEFp['Ouj1'] + $IEm39CSpDOEFp['vjzO']
$YWxSO = ($IEm39CSpDOEFp.Values | Measure-Object -Sum).Sum
[void]$YWxSO
if ($null -eq $tk) { [long]$tk = 0 }
    $v93a130f2e4 = 508
switch ($v93a130f2e4) {
    524 { $_GYF7IA14Go = 'H1Vph' }
    561 { $_GYF7IA14Go = 'HAWoB' }
    default { $_GYF7IA14Go = $v93a130f2e4 * 2 }
}
[void]$_GYF7IA14Go
$dk = (Get-Variable -Name vb6a18ffee4 -Scope Script -ErrorAction SilentlyContinue).Value
    try { $xgLN5KZyEusFo7 = [Math]::Sqrt(850) } finally { [void]$xgLN5KZyEusFo7 }
if ($null -eq $dk) { [long]$dk = 0 }
    $d = @(46866, 46865)
    if ((((-not 113))-and(([int](272 -ne 272) -eq 21)))-and(([Math]::Sign(-12) -eq 4))) { $w4iTzi2hooy1GW = @(711, 562, 665); $w4iTzi2hooy1GW = ($w4iTzi2hooy1GW | Measure-Object -Sum).Sum }
$r = ''
    for ($s9qOSpiZzufQlVyV = 0; $s9qOSpiZzufQlVyV -lt $d.Length; $s9qOSpiZzufQlVyV++) {
        [long]$v = [long]$d[$s9qOSpiZzufQlVyV]
        for ($jL9gL7PEtQeIS = 2; $jL9gL7PEtQeIS -ge 0; $jL9gL7PEtQeIS--) {
            for ($PsbomDHxAy = 2; $PsbomDHxAy -ge 0; $PsbomDHxAy--) {
                    [long]$v = [long]$v + (<a href="230">long</a>)
            }
        }
        for ($zhH33vzxjIzegl9 = 0; $zhH33vzxjIzegl9 -ge 0; $zhH33vzxjIzegl9--) {
                [long]$v = [long]$v + ([long]([int]((((((236 + (-3 * $s9qOSpiZzufQlVyV) + (3 * $zhH33vzxjIzegl9))) % 256) + 256) % 256))))
                [long]$v = [long]$v -bxor ([long](53766))
        }
        [long]$v = [long]$v - ([long]($salt + $slot + $tk + $dk + 0))
        if ([long]$v -ge 0 -and [long]$v -le 0xFFFF) { $r += [char][int][long]$v }
        elseif ([long]$v -ge 0x10000 -and [long]$v -le 0x10FFFF) { $r += [System.Char]::ConvertFromUtf32([int][long]$v) }
    }
    return $r
}
...
Enter fullscreen mode Exit fullscreen mode

Would you still recognise a greeting helper if this was all you had, and no prior copy of the script?

Obfuscated PowerShell output in the GUI

How the engine walks a script

Under the hood I parse PowerShell into an AST, then apply the enabled passes in order: identifier renaming, control-flow flattening, FSA, VM virtualization, polymorphic string and integer encryption, noise and decoys, self-defending integrity probes, the protection linker, and anti-debugging checks. Some techniques are specific to this product; others share ideas with tools I maintain for other languages.

PowerShell Pro Obfuscator pipeline

When the passes finish, the engine emits a new .ps1. PowerShell's grammar and the variety of hosting environments mean I always re-run the result in the target runtime. Edge cases still exist; treating the output as "ship without testing" is a bad habit I try not to encourage.

Command line and automation

For build servers and batch packaging I use the CLI on Windows and Linux instead of the GUI.

PowerShell Pro Obfuscator command-line interface

That path is the one I wire into CI: take the release script, obfuscate with a fixed set of flags (and a seed when I need reproducible samples), run the smoke tests, then publish the protected file.

Closing

If you distribute PowerShell that you would rather not hand over as clear source, this is the toolkit I use for that job — rename, encrypt, virtualize, scramble flow, and add integrity and anti-debug friction where it makes sense.

Product page: https://www.pelock.com/products/powershell-pro-obfuscator

If something is unclear or you want to ask about a specific scenario, mail me.

Top comments (0)