How to Deobfuscate PowerShell Scripts

To deobfuscate a PowerShell script is to undo, layer by layer, the transformations an attacker applied to hide what a script actually does — and to do it without ever running the script. A suspicious one-liner that arrives as a wall of Base64, or as a tangle of character codes and string concatenation, is usually a perfectly ordinary PowerShell command underneath: a download, a decoded payload, a command that runs in memory and never touches disk. The obfuscation is not there to be clever. It is there to get past signature scanners, to make the command line unreadable at a glance, and to make a human analyst give up before the interesting part. This article explains why each layer exists, how to peel it off as inert text, and what the pieces you find actually mean. You can run the whole workflow in the PowerShell Deobfuscator, which decodes layer by layer entirely in your browser — nothing is executed, and nothing leaves your machine.

Why attackers obfuscate PowerShell — and why the encoded form is the first thing to peel off

PowerShell is built into every Windows machine, it can download and execute code from the internet without writing a file to disk, and it runs with the privileges of whoever launched it. That combination is why it is one of the most abused tools in the MITRE ATT&CK framework, catalogued as technique T1059.001, and why the first thing an attacker does with a malicious PowerShell command is hide it. A plain-text command like DownloadString followed by Invoke-Expression is trivially detectable: any scanner, any log review, any analyst glancing at a process command line will spot it. Encoded, compressed, and chopped into pieces, the same command becomes a string of characters that matches no known signature and means nothing to a human reader.

That is also why the encoded form is the first thing to peel off, before anything else. Until you decode it, you do not know whether you are looking at an installer, a prank, or a credential stealer — and every other judgement you make is a guess. The good news is that obfuscation is almost always reversible by static unwinding: the payload has to be decodable by the machine that runs it, so every layer has a deterministic inverse. Base64 decodes, gzip inflates, character codes map back to letters, concatenated strings join. The work is mechanical, not mysterious, and it is the single highest-value step in triaging a suspicious script: one decode turns "unreadable blob" into a readable command you can actually reason about.

-EncodedCommand and UTF-16LE: why Base64 that looks like gibberish is usually readable text

The most common form of PowerShell obfuscation you will ever meet is a single switch: powershell.exe -EncodedCommand followed by a long Base64 string. It looks like random characters, but it is almost always just ordinary script text, encoded in a way that surprises people the first time they see it. PowerShell’s -EncodedCommand expects the Base64 to decode to UTF-16LE — two bytes per character, with a zero byte after every ASCII letter — because that is the encoding of .NET strings on Windows. So the Base64 of the harmless command Write-Host "hello" begins SQB3AHIAaQB0AGUALQBIAG8AcwB0ACA… and the tell-tale pattern is the repeated "A" characters that come from all those zero bytes.

Knowing this changes how you read an encoded blob. A Base64 string that decodes to UTF-8 gibberish is probably not UTF-8 at all: try UTF-16LE first, and the "gibberish" resolves into readable PowerShell. The same applies to a bare Base64 blob with no -EncodedCommand switch in sight — long tokens of valid Base64 that decode to printable text are the same trick without the flag. The practical rule is simple: when you see a long Base64 token in a PowerShell command line, assume it is a script until decoding proves otherwise, and decode it inertly — as text, in a tool that never executes it — rather than pasting it into a chat window or a terminal to see what happens.

The layer cake: compression, char codes, concatenation, -f, backticks

Real payloads rarely stop at one layer. Once the -EncodedCommand is decoded, the script underneath is often itself encoded again: a gzip-compressed blob wrapped in another Base64 string, or a sequence of character codes like [char]68+[char]105 that spells out a command one number at a time, or a string chopped into fragments and glued back together with plus signs, or a format string with the -f operator filling in the pieces at runtime, or backticks sprinkled through keywords so that Do`wnloadString no longer matches a naive search for DownloadString. Each layer exists for a different reason. Compression shrinks a long payload into a short one-liner and defeats text search entirely. Character codes and concatenation defeat signature matching by ensuring the suspicious words never appear literally in the file. The -f operator and backticks defeat simple string searches while keeping the script syntactically valid.

The defence against all of them is the same: unwind one layer at a time, in order, and stop when nothing further applies. Decode the Base64, then check whether the result is compressed and inflate it, then resolve the character codes, join the fragments, substitute the format arguments, and strip the backticks — each step producing plain text that the next step can work on. A tool that does this as a visible timeline, one card per layer with the before and after, is worth more than a tool that silently outputs a final answer, because the timeline is itself evidence: it shows which layers were present, and when the unwinding stops early — "could not unwind further" — that is a finding, not a failure. It tells you the payload contains something dynamic, encrypted, or runtime-built that static analysis alone cannot reach.

Script Block Logging 4104: how long scripts get split and reassembled

There is one source of deobfuscated PowerShell that requires no decoding at all: the machine itself. When Script Block Logging is enabled, Windows records the content of every script block PowerShell processes as event 4104 in the Microsoft-Windows-PowerShell/Operational log — and it records the code as PowerShell saw it, after the engine has already expanded the -EncodedCommand and the obfuscation layers. A suspicious one-liner that arrived as Base64 appears in the 4104 event as the readable script it actually ran. This is why the feature is the single most valuable PowerShell logging setting a defender can enable, and why it is off by default: it is verbose, and the logs can contain credentials and other sensitive data, so Microsoft recommends Protected Event Logging alongside it for anything beyond diagnostics.

The catch is that Windows splits long script blocks across several 4104 events. Each event carries the same ScriptBlockId, a MessageNumber, and a MessageTotal — part 2 of 5, say — and the full script only exists when the parts are grouped by that ID, ordered by message number, and concatenated. A single event in isolation may end mid-word, and a search that reads only the first fragment will miss whatever the payload does at the end. Reassembly is therefore a mechanical step with one thing to check: whether any part is missing. If the log says 2 of 3 and part 2 never arrived — because the log rolled over, or was cleared — the reassembled text is incomplete, and any conclusion drawn from it is provisional. The same grouping logic is what the EVTX Viewer uses when it flags 4104 events in exported .evtx files; see also the companion article Read .evtx files without a Windows machine.

Reading the indicators: download cradles, AMSI bypass, execution-policy bypass

Once a script is readable, the next step is not to declare it malicious — it is to read the indicators it contains as leads to verify. A download cradle — DownloadString, DownloadFile, Net.WebClient, Invoke-WebRequest, bitsadmin, certutil pulling a file from a URL — tells you the script fetches code from somewhere, and the next question is where, and whether that destination is expected on your network. An AMSI bypass — amsiInitFailed, AmsiUtils, or code that patches the Antimalware Scan Interface — tells you the author wanted the script to run without being scanned, which is a strong signal but not a verdict: penetration testers and some legitimate tools do the same. An execution-policy bypass (-ExecutionPolicy Bypass, -ep bypass) tells you the author did not want PowerShell’s own guardrails to stop the script, which is common in both malware and in ordinary automation that runs unsigned scripts.

The discipline is to treat each indicator as a question, not an answer. A download cradle plus a hidden window (-WindowStyle Hidden) plus an encoded command is a pattern worth escalating; any one of them alone is a pattern worth checking. Context is what turns a lead into a verdict: who launched the process, what its parent was, whether the URL it contacts is known, whether the same script appears on other machines. A deobfuscator that lists indicators with the matched snippet and a "verify in context" note is doing the honest job; one that stamps a script "malicious" or "clean" from a pattern list is overstating what static analysis can know. And the reverse matters too: a script with no indicators found is not proven safe — it may simply be using a technique the list does not cover, or building its payload dynamically at runtime.

The safe workflow: never paste it into chat, never run it, decode inertly

The single most important rule of handling a suspicious PowerShell script is that you never run it to find out what it does, and you never paste it into a chat window to ask an AI either. Running it executes the payload — that is the outcome you are trying to avoid. Pasting it into a chat sends a potentially malicious script to a third-party service, leaves it in a conversation history you do not control, and often gets you a refusal anyway, because AI systems routinely decline to deobfuscate scripts that may be malicious. The safe workflow is boring on purpose: copy the blob into a browser-local deobfuscator, or a sandboxed analysis machine, and treat the decoded output as inert text to read, never as code to execute.

From there, the workflow is a short checklist. First, decode layer by layer until the text stops changing, and note where the unwinding stopped. Second, read the indicators and write down what each one would mean if true, then verify each against context — the parent process, the destination URL, the machine it came from. Third, hand the result to the people or systems built for the next step: your EDR console, a sandbox detonation, your incident-response team. A deobfuscator’s job ends at readable text and honest indicators; the verdict belongs to the sandbox and the analyst, not to the decoding tool. If the script came from a machine you manage, check whether Script Block Logging was on — if it was, the 4104 events are the ground truth of what ran, and they are worth collecting before the log rolls over.

Sources

Frequently asked questions

Is it safe to deobfuscate a suspicious PowerShell script?

Yes, if the decoding is purely static — treating the blob as text and never executing it. A browser-local deobfuscator that decodes Base64, inflates gzip, resolves char codes and joins strings is only rearranging text; nothing runs. What is not safe is pasting the script into a terminal, double-clicking a .ps1 file, or pasting it into a chat service, which either executes it or sends it to a third party. Keep the decoding inert and keep the decoded output as text to read.

I decoded the -EncodedCommand and the result still looks encoded. What now?

That is normal: real payloads stack layers. The decoded text may itself be a gzip blob wrapped in Base64, or a string built from [char] codes, concatenation, or the -f format operator. Unwind one layer at a time — decode, then check for compression, then resolve char codes and joins — until the text stops changing. If it stops changing while still unreadable, the payload is probably encrypted or built dynamically at runtime, and static unwinding has reached its limit: that is a finding to hand to a sandbox, not a failure of the tool.

Does a clean result mean the script is safe?

No. A clean result means no known indicator pattern matched and every layer that could be unwound was unwound — it does not prove the script is harmless. A payload can be encrypted, can build its strings at runtime from values only the running machine knows, or can simply use a technique the indicator list does not cover. Treat a clean result as "nothing found by static analysis" and verify in a sandbox or with your EDR before deciding anything. The same applies in reverse: one indicator is a lead to verify, not a verdict.