Compiled JavaScript - V8 compilation pipeline and Bytenode
Watch on YouTubeVideo summary
The video introduces the V8 compilation pipeline and its relevance to analyzing modern malware that utilizes Electron frameworks or packaged executables containing protected JavaScript code. In many cases, developers distribute only bytecode rather than source code to hinder reverse engineering, making it essential for analysts to understand how this data is generated and managed. The process begins with a parser converting JavaScript source into an Abstract Syntax Tree (AST), which then feeds into the Ignition compiler to produce initial bytecode. This architecture was largely adopted because bytecode has a significantly smaller memory footprint than ASTs, offering performance benefits on resource-constrained devices like smartphones used for web browsing.
The V8 engine employs a tiered compilation strategy involving three distinct levels of optimization: Sparkplug (baseline), Maglev (mid-tier), and TurboFan (top-tier). Each subsequent level generates more optimized machine code but requires substantially longer compile times, often ten times slower than the previous stage compared to logarithmic scales. To balance performance with efficiency, V8 caches bytecode alongside metadata; if a function is called frequently enough or its argument types stabilize, it moves up these tiers for faster execution. However, this caching mechanism relies on comparing source hashes and engine versions during deserialization, meaning that any change in the JavaScript code length or engine version forces a full recompilation rather than reusing existing optimized artifacts.
Bytecode Node (Bytenode) alters this standard pipeline by shipping pre-compiled bytecode along with a dummy string designed to match the exact byte length of the original source code. This trick ensures that the V8 engine's caching logic always succeeds in deserializing and reusing the provided bytecode, effectively bypassing the Ignition compiler entirely since it is not included in Bytenode distributions. Consequently, when analyzing such malware, analysts cannot rely on standard debugging commands like `printBytecode` because those tools invoke the missing Ignition component; instead, they receive only the Electron framework's own compiled code mixed with the sample's protected bytecode. Understanding these specific constraints and execution environments is crucial for successfully reverse engineering samples that bundle their runtime within a single executable file to prevent easy decompilation or analysis by third parties.
Read the full video transcript
Welcome to malware analysis for
hedgehogs. This is going to be a
two-part series, at least. Maybe I will
do a third video. I'm not sure about
that yet.
So, a lot of malware nowadays ships with
an extra pkg and electron framework. So,
it's V8 based JavaScript code.
And some of them use a form of
protection where you only get the
bytecode and not the JavaScript source
code. And this is what we are going to
look at today. More specifically, we
will see how bytecode works, how the V8
compilation pipeline works, and use this
as a basis for reverse engineering
malware like this. So, have fun. Today
is theoretical part only and
in the next video we check out a
specific sample and how to analyze it.
Also, if you want to learn malware
analysis, go on my website. I provide
courses for that.
Beginners and an intermediate course.
And also, check out our Discord channel.
If you want to ask questions there, go
for it.
So, how does the V8 pipeline actually
work?
At the beginning, we have JavaScript
code.
The JavaScript source code is fed into a
parser and the parser generates
something that is called an abstract
syntax tree. So, a representation of the
source code plus some information about
the scope. The abstract syntax tree is
then fed into a bytecode compiler. And
the bytecode compiler generates
bytecode.
And this right here is the interpreter
stage.
The bytecode compiler wasn't always used
for Chrome and Chromium. And the reason
this was introduced is that bytecode
itself has a much smaller memory
footprint than the abstract syntax tree
and the scope. And this was very
advantageous because a lot of
smartphones are used to access the
internet with Chrome. So, smartphones,
they don't have as much memory and they
benefit a lot with smaller memory
footprint. If a function is used very
often in a certain way, it will be
better to compile this with a
just-in-time compiler at first to
unoptimized machine code.
And this is also called a baseline
compiler because it doesn't do the
optimization yet.
Now, if a function gets executed very
often and it uses very often the same
types as arguments, for instance, there
is some information that can be used to
optimize it better. If you always use
the same type, you can produce machine
code that specifically for these types
will be very efficient and run very
fast.
JavaScript is a dynamically typed
language, which means it's not quite
clear just based on the source code what
kind of types will be used. Yeah, but
after some time, you have the statistics
and then we can produce uh optimized
code. And first, we have, let's say, a
mid-tier optimizer.
This will generate optimized machine
code, but it could be better.
And if this function is used even more
often, then we can also spend the time
to generate fully optimized machine code
with a top-tier optimizer. If you look
up how V8 works, you may find some
10-years-old slides of TurboFan and
crankshaft and stuff like that.
These slides are a little bit outdated.
So, from what I could figure out, the
current pipeline uses the following
technologies.
I name them so you can look them up and
research them better. So, the bytecode
compiler is called Ignition.
The baseline compiler is Sparkplug. This
one exists roughly since 2021.
>> [clears throat]
>> Then we have as the mid-tier compiler
Maglev, which was introduced in 2023.
And then we have TurboFan.
So, this is mainly to look them up. You
don't really need to remember their
names if you just
want to reverse engineer V8 bytecode.
Now, why do we have these
well, levels or tiers of optimizers?
That is because the more you go to the
right, the more time it takes to compile
code. And it's not just a little bit,
it's like 10 times
slower. This is the introduction of
Maglev in 2023
on the dev blog for V8, and we can, if
we scroll down here, see some
performance
measurements.
And this is a graph
telling us how much or how fast are
TurboFan, Maglev, and Sparkplug in
comparison.
And the thing to note here is
this isn't a linear scale. So,
this is always like 10 times slower
than before. So, SparkPlug, which
doesn't do much optimization, is roughly
here. I'm not sure what number this
would be. I mean, the next best number
is 0.01
milliseconds for a single function. So,
this is the compile time in logarithmic
scale for a single function in
milliseconds.
And then, if you go to Maglev, we have
roughly
0.1
milliseconds. And if you take roughly
the middle of the TurboFan measurements
here, um we have one. So, it's always
like roughly 10 times slower when it
comes to the magnitude. And that's why
we do not just want fully optimized
machine code for every function that is
used.
Now, although we have machine code at
some point, the bytecode is always a
central component that is kept alongside
the
machine code. So, in case the optimized
code isn't optimal anymore, let's say
you change the types that you suddenly
use for a function, and there is a
decision that we have to optimize this
in a different way, then it will start
with the bytecode again and go through
the compiler tiers. So, how does caching
work now, exactly? We do not want this
V8 engine to compile everything from
start to end every time we run it,
right? So, caching is used to say, "Hey,
reuse this code. Don't compile
everything again."
And the way this works is, so again, we
have let's say we have our first run,
right?
Uh then ignition will of course generate
new byte code.
Now let's say
we have our second run.
And now there must
a decision must be made
whether ignition shall generate new byte
code or shall use the older one.
And how is this done?
Well, we have we have two things here.
We have the byte code and we have the
JavaScript code at this point.
And the byte code itself also comes with
some meter data attached. So let's say
meter
data.
This is from the first run.
And now there is a check or comparison.
In the source code,
the value that is compared is called
source hash. This may seem like this is
a hash
of this, right?
But actually the source hash is just the
length of the JavaScript code. It's
nothing more almost. So everything that
counts for this comparison whether the
JavaScript code has changed is this.
There's also the V8 engine version that
it checks. So like sanity checks is this
byte code done for the right version
that we are currently using of the
engine?
Is this the right architecture?
And so on.
Um
check some comparisons. Yeah, but this
part here, this is the most relevant
one.
Now if the lengths of the JavaScript
code to the byte code, so how do we know
the lengths of the byte code? It's in
the a data. Okay, so meter data says
that was the length or that was the
source hash of our bytecode. It also
keeps the other information here.
So, if it's the same the bytecode that
we already have from the very first run
is deserialized.
This is in a certain form in a
serialized form. It's deserialized
so the compiler can directly use it. So,
that's basically the caching here that
happens.
Let's say caching is used in this case.
If it's no
we need to recompile
the JavaScript code.
Now, we know how the caching works and
how ignition compiles everything. So,
how does this work with byte node?
Byte node does the following.
It generates
the bytecode.
But, what it also does
is it generates a dummy string that is
supposed to be the JavaScript code. And
these two
they have the same source hash. So, it
makes a string in a length that's
the same for the bytecode. And that way
it forces
the deserialization
pass.
Now, we have
uh
this comparison
and
the answer is almost always yes.
If it's no
we have a problem
because recompilation doesn't work. This
will simply result in an exception or a
crash or something.
So, almost always
if byte node works correctly the
deserialization path will be taken. So,
it will basically force it to use
caching in this instance. To do that, by
node hooks require.
And require is something like
load. If you look at the original source
code, you will see that require
implements a
a lot of file types. Yeah, require
loads
by default. It requires supports the
following files and file extensions and
will properly load them.
Now, by node hooks require and in doing
so, it adds another extension that will
be supported, which is then JSC.
And what is the purpose of all this?
It's, of course, protection. So, that
whoever uses by node does not ship
easily readable JavaScript code. So,
what does this now mean for this
pipeline when we use by node? With by
node, we ship the JavaScript engine
without any JavaScript source code. So,
there is no JavaScript source code.
There's also no parser that creates an
AST. Furthermore, there is no by code
compiler since we already have the by
code here. The only thing that will
happen is that this by code is reused.
And this wasn't the case before. Before
that, sometimes the by code generation
may have been triggered again. For
instance, if a different engine is used
or if the version changes of the engine.
So, you have an update, for instance.
And in those cases, the by code will be
regenerated. But if you ship the by code
as a matter developer or as a person who
wants to prevent reverse engineering of
their JavaScript software, they also
need to ship the V8 engine with the
bytecode. Otherwise, the system that the
malware infects may simply not have the
same execution environment. And that's
why we very often see such malware
shipped with Electron or an Exe or PKG.
They will pack the execution environment
inside one executable. That also means
if you want to analyze this bytecode in
any dynamic way, so if you need to run
it for analysis,
then you must use the very same engine
that this is shipped with.
This knowledge how the V8 compilation
pipeline works and how Bytecode
protection works will help you evaluate
better if certain techniques can be
successful for reverse engineering a
sample. For instance, in the past when I
did not know anything about that, I
assumed I could just use the print
bytecode command and switch to get the
bytecode for a compiled JavaScript
sample. And of course this does not
work. Why? The print bytecode is located
in Ignition, in the bytecode compiler.
And that compiler is not part of the
Bytecode Node
pipeline anymore, so it can't produce
any bytecode, right?
The only thing you get back from this is
the bytecode of the Electron framework,
which in that case was also present. And
that can stump you, that can
take a lot of time to sift through all
of the code that you just got there and
just to realize there is nothing
that can be part
of the sample If you work as a malware
analyst at a company, you may have time
constraints. And in the next video, I
will show you in the practical
application how to analyze such samples
quickly using hooks.