Sample Analysis: JS Dropper to Lua VM Loader
TL;DR:
A JavaScript dropper that unpacks, in stages, to a Lua virtual-machine-obfuscated loader. Analyzed statically start to finish; the final payload was carved without execution.
Summary
The sample is a JavaScript dropper that unpacks through three stages to a Lua loader protected by a VM obfuscator in the Luraph family. Only 5 of 61 engines flagged it.
The whole chain was analyzed statically.
box-js confirmed the dropper's
behavior without a full detonation, and the embedded payload was
carved out by replaying the script's own decode logic in Node, so
the final Lua stage was recovered without ever running the malware.
The final payload's logic is still sealed inside the Lua VM; devirtualizing it is a separate effort noted at the end.
Sample identifiers
| Field | Value |
|---|---|
| SHA256 |
ab191154271ea79c6dfdb20e28df59055889ebccbfa5d3af05841f2dadd8aa32
|
| File type | JavaScript (.js), Windows Script Host |
| Size | ~3.5 MB (inflated by obfuscation padding) |
| Source | MalwareBazaar, tagged as AgentTesla |
| VT detections | 5 / 61 at time of analysis (uploaded ~1 day prior) |
The AgentTesla tag on MalwareBazaar turned out to be the delivery context, not the final payload. Static triage showed the actual sample is a JS dropper carrying a Lua loader, which is a more interesting and less-cataloged technique.
1. Stage 1: the JavaScript dropper
The file opens with a fake motivational-comment banner ("CHAMPIONS ARE MADE NOT BORN") that occupies the visible top of the file. The real code is pushed far to the right on a single long line, so an analyst who skims the top sees gym quotes and potentially moves on.
After beautifying with
js-beautify, the structure
resolved: every identifier carries the obfuscator's prefix
AJCWTTPZSBNRIHLR, and the file is
padded with two kinds of junk. Long runs of
☸ (the Unicode wheel-of-dharma
character) and filler objects full of animal names (falcon, badger,
cobra) that inflate the file and
bury the logic.
AJCWTTPZSBNRIHLR prefix.
☸ is the wheel-of-dharma
character.
The decode mechanism is a stack of
split(x).join('') calls, each
stripping one junk character so no single find/replace reveals the
payload. Reading the chain bottom-up, the strip sequence was:
? ! ~ space ☸ # $ % ^ & * >.
A separate helper (split('').reverse().join('')) reverses some strings on top of that. Reading the file is a
matter of replaying these transforms rather than guessing.
split/join strip
chain.
String decoding uses an MSXML trick rather than an obvious decoder:
the payload is placed in an XML node with
dataType = 'bin.base64', and
reading nodeTypedValue returns the
decoded binary. This base64 decodes without calling anything named
like a decoder.
nodeTypedValue.
2. Behavior (box-js)
Running the dropper under box-js,
which emulates WScript and ActiveX and logs intent without
executing, surfaced the behavior before the emulator hit a function
it couldn't provide:
-
Mark-of-the-Web bypass:
cmd /c echo. > "<script path>:Zone.Identifier"overwrites the Zone.Identifier alternate data stream, stripping the "downloaded from the internet" tag so dropped files look local and dodge SmartScreen. -
Drops and runs an EXE: checks
FileExists(MyDocuments\RLHIRNBSZPTTWCJA), then runsMyDocuments\AJCWTTPZSBNRIHLR[.]exewithMyDocuments\RLHIRNBSZPTTWCJAas an argument. -
Write mechanism: three
ADODB.Stream.SaveToFile(path, 0x2)calls, where0x2isadSaveCreateOverWrite. StreamType = 1(binary), fed from the MSXMLnodeTypedValuebase64 nodes.
box-js output: MotW wipe and
execution.
box-js output: the three
SaveToFile writes.
0x2 =
adSaveCreateOverWrite.
So the dropper writes three files to MyDocuments, strips their MotW, and executes one as an EXE with another as its argument.
3. Stage 2: carving the payload
box-js emulated the script but quit
before it wrote the EXE to disk, so I pulled the payload out by hand
instead. The idea: make my own script do the exact same decoding the
malware does to itself, without running the malware.
The payload is stored in a variable called
E3 as one long encoded string. To
hide it, the author wrapped it in several layers:
- the data is reversed (stored backwards)
-
it's padded with thousands of
☸(☸) characters -
it's sprinkled with junk symbols (
? ! ~ # $ % ^ & *and more) - underneath all that, it's base64
So the extractor undoes those layers in order:
-
grab the
AJCWTTPZSBNRIHLR0000E3string - delete the
☸padding - keep only valid base64 characters, which throws out all the junk symbols in one step
- reverse it back to the right direction
- base64-decode
That last "keep only base64 characters" step was the key simplification. Instead of hunting down every junk symbol one at a time (which caused a few failed attempts), I just had the script "discard anything that isn't a base64 character." Whatever's left is the real payload.
const fs = require('fs');
const s = fs.readFileSync('tesla-beautified.js', 'utf8');
const m = s.match(new RegExp("AJCWTTPZSBNRIHLR0000E3\\s*=\\s*(?:\\w+\\()?'([^']*)'"));
let st = m[1].split('\\u2638').join('');
let b64 = st.replace(/[^A-Za-z0-9+/=]/g, '').split('').reverse().join('');
fs.writeFileSync('payload1.bin', Buffer.from(b64, 'base64'));
The decoded head was
local AJCWTTP = "|E;;;;...", 601 KB
of text, not a PE. The
local keyword flagged it as a Lua
script carrying yet another encoded blob inside a string variable,
pointing to a further stage.
4. Stage 3: the Lua VM loader
The carved payload is a Lua script, about 601 KB, and the bottom of
it is clearly a mini interpreter (a "virtual
machine"). Instead of running normal Lua code, it runs its own
custom instructions one at a time. The tells are all there: a loop
that reads instructions and acts on each (coroutine.create
/ coroutine.resume), a big switch
that handles instruction numbers (d==33, d==34, ...), and
setmetatable calls with gibberish
names. One of its hidden error messages decodes to
NYI: extended SETLIST, a phrase
these Lua protectors are known to leave behind.
The script has two parts:
-
the top holds the real payload as one encoded
string (
local AJCWTTP = "|E;;;...") - the bottom is the interpreter that decodes and runs it
That shape, an encoded blob plus a built-in interpreter to run it, is the signature of the Luraph family of Lua obfuscators. Luraph is a commercial protection tool that's been around since 2017 and is notoriously hard to crack publicly. The weird invalid characters that broke the beautifier earlier are also one of its deliberate anti-analysis tricks.
This likely explains the low detection (5 out of 61). At this layer, the malicious code never looks like normal code or a Windows program on disk, it's custom instructions for a custom interpreter. I'd say most AV engines don't know how to read that, so most of them see nothing to flag.
IOCs
| Type | Value |
|---|---|
| SHA256 |
ab191154271ea79c6dfdb20e28df59055889ebccbfa5d3af05841f2dadd8aa32
|
| Dropped EXE | MyDocuments\AJCWTTPZSBNRIHLR[.]exe |
| Dropped file (arg) | MyDocuments\RLHIRNBSZPTTWCJA |
| Drop directory (box-js default) | %APPDATA%\Microsoft\Templates\ |
| Behavior |
Zone.Identifier (MotW) wipe via
cmd /c echo.
|
| Obfuscator |
AJCWTTPZSBNRIHLR identifier prefix;
☸ padding; Lua VM (Luraph-family)
|
| Carved Lua stage (payload1.bin) |
4fefabebc2344633a42ceccd98fb66d8e5363d99c6f8783d86975c1bb2c68a2c
|
Network IOCs (C2 domains/IPs) are still sealed inside the Lua VM and
were not recovered in this pass through. The
.ru-style strings visible in the JS
are part of the obfuscator's junk filler, not real indicators.
Overall purpose
This is a multi-stage loader chain. The JavaScript dropper strips
Mark-of-the-Web, writes three files to MyDocuments via
ADODB.Stream, and executes one as
an EXE with another as its argument. The carried payload is a
Luraph-family Lua VM loader whose real logic, including any C2,
stays sealed inside custom bytecode. Devirtualizing that VM is the
next step.