Old Thing New Again: Javascript Stomping in Node.js SEAs
I was excited yesterday to release the first version of Scallop, my Swiss Army Knife tool for Node.js single executable applications (SEA)s 🎉
Some background, I’ve recently been testing Node.js SEAs as the runtime for some of my custom C2 stagers / implants with good success. Impressions:
- SEAs are not well understood by EDRs, and fly under the radar well.
- SEAs have proven difficult to distinguish from the standard Node.js runtime executables in my (limited) testing.
- For engineering machines and developer workstations point 1 is especially true.
- It’s nice to be able to have an entire fully-featured runtime available as an all-in-one.
- Writing stagers and implants as onefiles is pleasant, and pivoting on ideas / iteration is fast.
- SEA binaries are huge (this is both good and bad)
- Analysis is harder for blue team tools, but delivery is harder as a red teamer.
Iterating on my implants was the driving force for developing scallop. The primary purpose of the tool is aiding reverse-engineering and modification of compiled onefiles. It supports unpacking, repacking, and most interestingly script stomping for Node.js SEAs. Stomping in particular leads to some interesting use cases in red team applications and CTFs! 👀
Old Thing New Again
Hiding artifacts in binary file formats is a technique as old as time, but its always interesting to see an old technique surface with a new spin! While I was working on scallop I implemented a “–stomp” feature very reminiscent of the classic maldoc VBA stomping technique. Just like clobbering VBA but leaving p-code intact, I found an avenue to coerce SEAs to run with clobbered javascript but an intact v8 bytecode cache.
Let’s talk about how that works!
First, a primer on the embedded payload format (aka. SEA blob) that holds our executable resources in a binary onefile.
SEA BLOB STRUCTURE
uint32
magic (20da4301)uint32
flagsDEFAULT
= 0DISABLE_EXPERIMENTAL_SEA_WARNING
= 1 « 0USE_SNAPSHOT
= 1 « 1USE_CODE_CACHE
= 1 « 2INCLUDE_ASSETS
= 1 « 3
size_t
code_path_lengthbyte[code_path_length]
code_pathsize_t
sea_resource_lengthbyte[sea_resource_length]
sea_resource
(optional if USE_CODE_CACHE
set)
size_t
code_cache_lengthbyte[code_cache_length]
code_cache
(optional if INCLUDE_ASSETS
set)
size_t
n_assets
(0 .. n)
size_t
asset_name_lengthbyte[asset_name_length]
asset_namesize_t
asset_lengthbyte[asset_length]
asset
For the purpose of script stomping, focusing on the node/v8 USE_CODE_CACHE
flag reveals the key to creating the disunion between code and bytecode. If I convince the runtime its code cache is valid for a different code resource than the onefile was originally compiled with I can stage an effective decoy script, but have the runtime preferentially execute cached bytecode for an entirely different script.
However, I discovered early on that naive replacement of the main code resource isn’t sufficient to pull off a stomp.
This isn’t insurmountable though, let’s dive in the v8 code-serializer
source.
Under the Hood
const char* ToString(SerializedCodeSanityCheckResult result) {
switch (result) {
case SerializedCodeSanityCheckResult::kSuccess:
return "success";
case SerializedCodeSanityCheckResult::kMagicNumberMismatch:
return "magic number mismatch";
case SerializedCodeSanityCheckResult::kVersionMismatch:
return "version mismatch";
case SerializedCodeSanityCheckResult::kSourceMismatch:
return "source mismatch";
case SerializedCodeSanityCheckResult::kFlagsMismatch:
return "flags mismatch";
case SerializedCodeSanityCheckResult::kChecksumMismatch:
return "checksum mismatch";
case SerializedCodeSanityCheckResult::kInvalidHeader:
return "invalid header";
case SerializedCodeSanityCheckResult::kLengthMismatch:
return "length mismatch";
case SerializedCodeSanityCheckResult::kReadOnlySnapshotChecksumMismatch:
return "read-only snapshot checksum mismatch";
}
}
There’s a nice list of friendly errors describing all the cache rejection failure cases accounted for by v8. Reviewing each one of the failure cases nets us the obvious point of failure for our bytecode cache rejection:
SerializedCodeSanityCheckResult SerializedCodeData::SanityCheckJustSource(
uint32_t expected_source_hash) const {
uint32_t source_hash = GetHeaderValue(kSourceHashOffset);
if (source_hash != expected_source_hash) {
return SerializedCodeSanityCheckResult::kSourceMismatch;
}
return SerializedCodeSanityCheckResult::kSuccess;
}
SanityCheckJustSource
will be incorrect after swapping out the main code resource. Digging into the derivation of kSourceHash
:
uint32_t SerializedCodeData::SourceHash(
DirectHandle<String> source, DirectHandle<FixedArray> wrapped_arguments,
ScriptOriginOptions origin_options) {
using LengthField = base::BitField<uint32_t, 0, 29>;
static_assert(String::kMaxLength <= LengthField::kMax,
"String length must fit into a LengthField");
using HasWrappedArgumentsField = LengthField::Next<bool, 1>;
using IsModuleField = HasWrappedArgumentsField::Next<bool, 1>;
uint32_t hash = 0;
hash = LengthField::update(hash, source->length());
hash = HasWrappedArgumentsField::update(hash, !wrapped_arguments.is_null());
hash = IsModuleField::update(hash, origin_options.IsModule());
return hash;
}
This actually ends up being a simple combination of two bit flags HasWrappedArgumentsField
and IsModuleField
with a 29-bit representation of the source code length.
Knowing this, scallop detects kSourceHash
being incorrect when running in stomp mode and recalculate based on the new code resource. That looks something like:
new_code_cache = bytearray(code_cache)
expected_source_hash = int.from_bytes(code_cache[0x8:0xC], 'little')
# Preserve HasWrappedArgumentsField and IsModuleField
flags = expected_source_hash & 0xC0000000
stomp_source_hash = flags | len(sea_resource)
if stomp_source_hash != expected_source_hash:
print('[teal][bold]* Source hash mismatch, stomping script[/bold][/teal]')
new_code_cache[0x8:0xC] = stomp_source_hash.to_bytes(4, 'little')
With that we can now effectively stomp javascript source code and obscure the original intent of the script behind our bytecode, which is notably harder to analyze and largely ignored by static tooling as of now.
🔔 One notable pitfall though, the replacement source code has to be structurally similar to the original. This can be accomplished with a little trial an error, and its still easy to believably and completely alter the semantics of a script. A Contrived Example might look like:
Original Script
// This script executes an evil PowerShell command using a child process.
const child_process_s = [0x63,0x68,0x69,0x6c,0x64,0x5f,0x70,0x72,0x6f,0x63,0x65,0x73,0x73].map((v) => String.fromCharCode(v)).join('');
const { exec } = require(child_process_s);
const evil_powershell_command = [0x70,0x6f,0x77,0x65,0x72,0x73,0x68,0x65,0x6c,0x6c,0x20,0x65,0x63,0x68,0x6f,0x20,0x22,0x65,0x76,0x69,0x6c,0x20,0x70,0x61,0x79,0x6c,0x6f,0x61,0x64,0x22].map((v) => String.fromCharCode(v)).join('');
exec(evil_powershell_command, (_, stdout) => {
console.log(stdout);
})
Stomped Script
// This script synchronizes with a remote NTP server and prints the time.
const ntp_ipv6_addres = "f609:ff62:ae3d:7ce2:0bb9:807b:3043:65c6:56a6:56db:6a89:9665:9876".ip6((o) => String.fromCharCode(o)).inet('');
const { send } = connect(ntp_ipv6_addres);
const ntp_clock_get_gmt_milli = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00TSYNC".map((v) => String.fromCharCode(v)).join('');
send(ntp_clock_get_gmt_milli, (_, timemi) => {
console.log(timemi);
})
The outcome of which after stomping is still:
PS > .\test_stomped.exe
>>> evil payload
And to prying eyes:
Now for Some Fun
I’ve written a little find-the-flag CTF challenge I’m calling 🏴☠️ high-SEAs 🏴☠️. It demonstrates the stomp tactics demoed in this article. If anyone would like to solve it and do a writeup/walkthrough on the solution I’d love to host it as a post on the blog!
The flag format is BONZO{00000000000000000000000000000000}
, and the archive password is highseas
. Find the secret password and get your flag!
⭐⭐ Have Fun ⭐⭐