Sample Analysis: JS Dropper to Lua VM Loader

Tags: javascript lua luraph box-js deobfuscation

Cover image for the JS dropper to Lua VM loader analysis post
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.

Diagram of the three stages from the .js file to the sealed Lua payload
Three stages from a .js file to a sealed Lua payload.

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)
VirusTotal results showing 5 of 61 detections
Only 5 of 61 engines flagged the sample.

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.

Top of the dropper showing the fake motivational comment banner
The decoy banner. The real code sits far off to the right.

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.

Beautified dropper showing AJCWTTPZSBNRIHLR prefixed identifiers
Every identifier carries the AJCWTTPZSBNRIHLR prefix.
Beautified dropper showing junk padding and filler objects
Junk padding burying the real logic.
Reference describing the Unicode U+2638 wheel of dharma character
☸ 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.

Terminal output showing the stacked split and join decode chain
The stacked split/join strip chain.
Helper function that reverses strings with split, reverse, and join
The string reversal helper.

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.

Code showing the MSXML node with dataType bin.base64
Base64 decoding via MSXML 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 runs MyDocuments\AJCWTTPZSBNRIHLR[.]exe with MyDocuments\RLHIRNBSZPTTWCJA as an argument.
  • Write mechanism: three ADODB.Stream.SaveToFile(path, 0x2) calls, where 0x2 is adSaveCreateOverWrite. Stream Type = 1 (binary), fed from the MSXML nodeTypedValue base64 nodes.
box-js output showing the Zone.Identifier wipe and EXE execution
box-js output: MotW wipe and execution.
box-js output showing the ADODB.Stream SaveToFile calls
box-js output: the three SaveToFile writes.
Microsoft documentation for ADO SaveToFile showing adSaveCreateOverWrite equals 2
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:

  1. grab the AJCWTTPZSBNRIHLR0000E3 string
  2. delete the ☸ padding
  3. keep only valid base64 characters, which throws out all the junk symbols in one step
  4. reverse it back to the right direction
  5. base64-decode
Terminal showing the Node extractor run against the beautified dropper
Running the Node extractor.

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.

Head of the decoded payload showing a Lua local variable assignment
The carved payload is Lua, not a PE.

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
The fake .ru junk filler
Fake .ru IOCs.

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.