Submind YouTube summaries
Thumbnail for Compiled JavaScript - V8 compilation pipeline and Bytenode

Compiled JavaScript - V8 compilation pipeline and Bytenode

Watch on YouTube

Video 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.