Breaking the Seal: Static Deobfuscation of JSCeal’s Compiled V8 Bytecode
At a glance
- Severity
- Low
- Used in attacks
- No flaws named
- Reported by
- 1 outlet
Research by: hasherezade
Key Points
- Since early 2025, Check Point Research has been tracking JSCeal, a sophisticated cryptocurrency-focused stealer with broader credential-theft, surveillance, and traffic-interception capabilities, delivered as compiled V8 bytecode (JSC files).
- The payloads are protected with javascript-obfuscator, using multiple techniques including RC4-protected strings, control-flow flattening, proxy functions, and operation wrappers.
- Our goal was to recover the code to a level that enables detailed analysis, comparison between samples, and tracking of the malware’s evolution.
- CPR developed a fully static deobfuscation pipeline that transforms View8 pseudocode without executing the malware. An optional LLM-assisted renaming stage can then be used to make large, recovered codebases easier to navigate.
- The complete toolkit is publicly available at jsc_deobfuscator.
- The deobfuscated output enabled detailed analysis of JSCeal’s capabilities and their implementation, including keylogging, browser and credential theft, and HTTPS traffic interception through a local MITM proxy.
- We presented this research at Black Hat USA 2026. This article complements the talk by documenting the methodology in greater technical depth and providing additional examples and implementation details.
- We conclude with a brief look at more recent JSCeal developments, including V8 code caches generated for a newer Node.js/V8 version, an additional payload-encryption layer, and macOS targeting.
Introduction
JSCeal is a stealer delivered as compiled V8 bytecode (.jsc) and executed by a bundled Node.js runtime, targeting cryptocurrency applications (other vendors also tag it with the names WEEVILPROXY or MeadowLocust). Its campaign activity dates back to March 2024 [1]; Check Point Research has been tracking the malware since early 2025. Our previous publication from July 2025 [1] focused on the campaigns, delivery chain, and targeting. In this article, we focus on the analysis problem hidden inside the final payload.
Unlike ordinary JavaScript malware, JSCeal reaches the analyst after two transformations have already removed much of the information that source-oriented tools depend on. First, the JavaScript is heavily obfuscated. Then it is compiled into V8’s internal bytecode representation and shipped as cached data rather than source code. The resulting format is version-specific, poorly served by mature reverse-engineering tooling, and unsuitable for most standard JavaScript deobfuscation workflows.
From the attacker’s perspective, this combination is attractive because it is inexpensive to produce. Node.js and its package ecosystem provide ready-made building blocks for complex applications, while public tools such as javascript-obfuscator [6] can add several layers of source-level obfuscation before compilation. The analyst receives only the compiled artifact.
In 2024, our colleague Moshe Marelus published View8, an open-source decompiler for V8 bytecode [2]. We used it as the foundation for a static deobfuscation pipeline tailored to the patterns found in JSCeal. During this work, we extended View8 [3] to make its output reproducible and suitable for automated post-processing, and implemented dedicated passes for value propagation, string reconstruction, control-flow unflattening, proxy and operation-wrapper resolution, and additional cleanup.
The goal is not perfect source recovery — V8 compilation is lossy, and the output of decompilation remains pseudocode. Instead, we aimed to recover enough structure and semantics to read the malware as code again: follow its logic, compare samples, locate capability branches, and validate behavior against concrete strings, APIs, paths, and data flow.
Later in the article, we use one selected JSCeal payload as a case study and walk through portions of the recovered code, including browser and cryptocurrency theft, keylogging, screenshot capture, and a local HTTPS interception proxy.
Distributed payloads
Let’s start by understanding the role of the JSC files in the whole attack chain.
The payloads were delivered in campaigns that began with malvertising and were followed by multiple PowerShell scripts. The complete flow is illustrated below:
The last stage consists of two ZIP archives downloaded by PowerShell:
- node.zip – a packaged Node.js runtime
- build.zip, containing the final payload and supporting components:
winpty-agent.exe– an agent for a hidden Windows console (open source)winpty.dll– a module that allows interaction with the hidden console (open source)app.jsc– The JSCeal malware payloadpreflight.js– a decompression script- Native
.nodemodules (PE format) used by the payload
The final JSC payload is distributed in Brotli-compressed [5] form and decompressed by preflight.js.
The loading is triggered by the last PowerShell script in the chain, containing the command line:
.\node.exe -r .\preflight.js .\app.jsc (the option -r forces Node to run a JS file before loading the main module).
The size and complexity of the JSC payloads varied. They were all obfuscated with the same open-source obfuscator [6].
Analysis methodology
While typical analysis procedures were sufficient for the earlier stages, the final JSC payload remained challenging. Because it was delivered as a V8 code cache rather than JavaScript source, conventional source-level JavaScript instrumentation was not directly applicable. Native-level hooking and dynamic binary instrumentation (DBI) could reveal process and API activity, but did not recover the payload’s JavaScript-level semantics at a useful level. Sandbox execution therefore provided mainly low-level system-interaction telemetry. To understand the payload’s logic, we turned to static analysis, which required deobfuscation.
Since the JSC payload is Brotli-compressed, the first step is to remove this layer. This yields the V8 code cache, which can then be supplied to a compatible disassembler. The disassembled output is then passed to the View8-based pipeline, which includes decompilation and transformation by multiple deobfuscation passes. Each pass can be used as a self-contained script. To support modularity, we extended View8 with pickle serialization of its internal object graph. We also added function-level visibility controls and metadata annotations (details in Appendix A).
Our toolkit is publicly available at https://github.com/hasherezade/jsc_deobfuscator [7]
The following flowchart describes the major steps of the pipeline; details of each follow in subsequent sections.
We applied the pipeline to 23 JSCeal payloads collected over several months (Appendix B); it produced analyzable output in all cases.
Environment Setup
The toolkit used for the main body of this research was developed on Linux.
The JSCeal generation analyzed in depth in this research used a bundled Node.js runtime based on V8 10.2.154.26-node.25. The distributed app.jsc was Brotli-compressed; after decompression, the resulting file was a V8 code cache that could be supplied to a compatible disassembler.
V8 cached data is version-sensitive, so before decompilation we first need to obtain a correct bytecode listing. We followed the general approach used by the View8 fork from j4k0xb [4]: build the corresponding V8 version, apply the required patches, and use a small program based directly on the V8 API to consume the cache.
During this process, we encountered a bug in the original V8 code that caused a string-printing problem and corrupted some disassemblies containing wide characters. It passed a 16-bit code unit through byte-oriented printable-character handling, which could inject malformed output into string literals and break View8 downstream. We patched the printer so that printable ASCII remains literal, byte-sized non-printable values use \xNN, and wider values are emitted as \uNNNN. The patch is included in the public repository [9], and the complete build procedure is documented on the project Wiki [10].
The released toolkit contains both the disassembler source and the V8 patches required for the supported generation. A prebuilt Linux disassembler is also distributed with the project [7] release.
Decompiled output
Once we have the correct disassembly, we can proceed with decompilation. However, there are some details to keep in mind.
View8 does not reconstruct the original JavaScript source. It lifts V8 bytecode into pseudocode that reflects its underlying execution model.
Recovered functions are represented in a form such as:
function func_[name]_0x[disassembly_address]([arguments_list])
The entry point is a function labeled start, for example: func_start_0x323d9daddcd9.
In ordinary View8 output, the hexadecimal suffix is derived from address values emitted during disassembly. Because these values may differ between runs, our modified View8 can normalize function identifiers deterministically based on parse order. This makes the results reproducible (details: Appendix A).
The pseudocode follows the underlying V8 concepts rather than ordinary JavaScript local-variable names. Each function can make use of its arguments, the accumulator, and a set of local virtual registers. It also has access to its own constant pool, global variables, and context storage exposed through Scope. Function arguments are represented as a0 to aN, while local virtual registers are printed as r0 to rN. ACCU denotes the current V8 accumulator value.
Functions can declare nested functions and share values with them through their surrounding context. In View8, these relationships are visible through the declarer hierarchy and Scope[...] references. Values placed into a scope by a declarer function may later be consumed by nested functions. Reconstructing those relationships is essential for JSCeal because the obfuscator frequently moves constants, decoder offsets, proxy references, and dictionary objects through scope rather than keeping them local.
As the root of the function hierarchy, the start function is the only function without a declarer. The start function also initializes the global bindings used throughout the program. In raw View8 output this is visible through DeclareGlobals, for example:
ACCU = DeclareGlobals(["oQ", "kg", "xQ", func_yz_0x323d9daeb509, 893, [...] ])
For readability, our modified View8 marks global identifiers explicitly with a global_ prefix. The prefix prevents collisions with local register notation and makes later propagation easier to follow.
Since the original JavaScript was obfuscated before compilation, the View8 output contains artifacts introduced by the obfuscator, making the recovered pseudocode considerably harder to interpret. A detailed explanation of each obfuscation layer and the applied countermeasures is provided later in this article.
For example, a single function from a JSCeal payload decompiled by View8 looks like this:
function func_unknown_0x398fa079bb71(a0)
{
r2 = Scope[19][74][func_Ht_0x398fa0799da9(136760, "ZCe3")]
r2 = r2(a0)
r3 = func_Ht_0x398fa0799da9(57973, "Vbp&")
r3 = (r3 + func_Ht_0x398fa0799da9(194117, "Af5z"))
r3 = (r3 + func_Ht_0x398fa0799da9(86681, "XDjZ"))
r1 = r2[(r3 + func_Ht_0x398fa0799da9(100990, "5Yvr"))]
r1 = r1()
r2 = func_Ht_0x398fa0799da9(75831, "b6Sj")
r0 = r1[(r2 + func_Ht_0x398fa0799da9(49188, "Amc*"))]
return r0()
}
This is already significant progress compared with the raw bytecode, but the remaining obfuscation still makes most of the output effectively unreadable. The rest of the pipeline progressively removes those layers and transforms the output into pseudocode suitable for practical analysis.
ℹ️ One syntax detail is worth keeping in mind throughout the article: View8 uses its own pseudocode notation and should not be interpreted as literal JavaScript. For example, an expression such as
!r6 === "0"represents the negation of the entire comparison — semantically:r6 !== "0".
Obfuscation layers
The analyzed JSCeal payloads were protected with javascript-obfuscator [6]. Its configuration is highly customizable, and the exact combination varied between samples. Across the corpus, we repeatedly observed four groups of transformations:
- Renamed identifiers. Function and variable names are replaced with short or nonsensical identifiers.
- String protection. Important strings are split into chunks and reconstructed through decoder functions. In the dominant variant observed in JSCeal, the stored chunks are encoded and RC4-protected.
- Control-flow flattening. Selected functions are transformed into state machines whose intended block order is hidden behind a dispatcher.
- Proxy and operation indirection. Function calls are forwarded through proxy helpers, while simple operations such as addition, subtraction, comparison, or function invocation are wrapped in dedicated helper functions.
The deobfuscation pipeline has to follow a specific order because the result of one pass can expose information required by the next. For example, string deobfuscation reveals not only the text used in the code, but also keys for dictionaries containing variables and function references.
Propagating values
Before we can start peeling away the obfuscation layers, we need to set the stage by propagating the variables used in the code and performing all the necessary simplifications.
Often, functions that we have to parse and resolve are not called directly, but through different variables: globals, scopes, or local registers. A similar problem applies to their arguments. Until we have everything filled and mapped, it won’t be possible to really understand the flow.
Propagating values is non-trivial: it is done in multiple ways, at different layers of the obfuscation process. Demonstrating the full variety used would take too much space, so let’s focus on a few examples. We illustrate with string decryption functions here, but the same propagation logic applies to proxy resolution and operation inlining described later. Details on the actual string deobfuscation are given in the next section, “Reconstructing strings”.
Below is a tiny function used to deobfuscate a chunk of a string. The input argument (a1) is modified by a value passed via Scope.
function func_r_0x24543eceeb91(a0, a1)
{
r1 = (a1 - Scope[10083][2]["c"])
return func_mt_0x3120801469(r1, a0)
}
Without knowing the actual value, we won’t be able to do the calculation required for deobfuscation. The scope is filled by a function higher in the declaration hierarchy. Once we find the particular line, we are ready to fill it.
function func_yZ_0x24543ecedfc9(a0)
{
[...]
Scope[10083][2] = new {"c": 742}
[...]
After the substitution, we get:
function func_r_0x24543eceeb91(a0, a1)
{
r1 = (a1 - 742)
return func_mt_0x3120801469(r1, a0)
}
In this form, the function is ready to be parsed, and we can see that the value 742 is subtracted from the input argument.
Another problem is that in many parts of the code, calls to interesting functions have their arguments passed via local variables. While parsing a line, it is not immediately clear what arguments are being passed.
In the given example, the function deobfuscating a string chunk, func_r_0x24543eceeb91, is called with two arguments that are passed via dictionaries. We first collect those dictionaries, and then substitute their uses with corresponding values.
Before:
r0 = new {"c": "SwH7", "n": 84197, "x": "PEKM", "Y": 104422, ...}
[...]
r7 = func_r_0x24543eceeb91(r0["c"], r0["n"])
r7 = (r7 + func_r_0x24543eceeb91(r0["x"], r0["Y"]))
After:
r7 = func_r_0x24543eceeb91("SwH7", 84197)
r7 = (r7 + func_r_0x24543eceeb91("PEKM", 104422))
Once those preparations are completed, we are ready to parse the functions and resolve their outputs.
Reconstructing strings
String reconstruction is the first major deobfuscation stage. Strings are valuable artifacts on their own: they expose API names, paths, commands, URLs, object fields, and targeted services. More importantly for this pipeline, they also unlock later transformations. Recovered strings become dictionary keys, property names, and control-flow order sequences used by the unflattening and proxy-resolution passes.
The analyzed samples used two string-obfuscation variants provided by javascript-obfuscator [6]. We implemented [7] a separate pass for each.
The simpler variant, addressed by deobf_str1.py, stores string fragments in an array and retrieves them through an index transformation. It appeared only in an older sample.
The dominant variant, addressed by deobf_str2.py, adds several more layers: encoded string chunks, RC4 encryption, a large family of decoder wrappers, and arithmetic transformations of the chunk index. This is the variant described below.
Details on deobfuscation modes used by each payload are listed in Appendix C.
The string obfuscation rabbit-hole
Let’s take a closer look at how the most common JSCeal string obfuscation is implemented. This is the mode addressed by deobf_str2.py.
Just like in the simplest mode, each string is split into chunks. Then, each chunk is RC4 encrypted with a different key. The resulting content is Base64-encoded. Such obfuscated chunks are accumulated in a single array, stored inside one of the functions, and retrieved from there into a global scope. It is initialized in the start function.
An example of how the function holding the array of chunks may look is given below (keep in mind that the array may contain thousands of elements):
function func_KV_0x18c3e8c9a1c1()
{
r0 = Scope[0]
Scope[10824][2] = new ["s8ohWR3dRx8", "ffddSSo6sW", ... ]
}
When the program needs a string, it calls one of many decoder functions. A typical call contains a numeric value and a short RC4 key:
r2 = func_xt_0x274f42c4e909(71692, "%]hf")
The argument order is varied: some decoder functions receive (number, key), while others receive (key, number). The number is used to calculate the index of the chunk to be decrypted, relative to the aforementioned global list. The calculation is done inside the function.
To make things more complex, deobfuscation is done not just by one function, but by many similar instances. The instances may call one another, each one of them adding or subtracting a different value to the input argument. In order to calculate the actual chunk index, we have to follow the whole chain of functions, parse them, and repeat the operations they performed. At the end of the chain there is always a strongly obfuscated parent function that contributes the final operation.
The values used in calculations are not hard-coded in the function but passed via scope (details described in “Propagating values”). Example of a single deobfuscating function:
function func_r_0x7b2a9768611(a0, a1)
{
r1 = (a1 - Scope[1][2]["V"])
return func_xt_0x274f42c4e909(r1, a0)
}
In the above case, the index was passed via argument a1. The value retrieved from the scope is first subtracted from it. The result, along with the argument a0 representing the RC4 key, is passed to the next deobfuscation function (func_xt_0x274f42c4e909) which performs similar operations. The chain of similar calls follows multiple layers until it reaches the parent function which adds or subtracts the final value from the index, retrieves the chunk from the global array, and performs the decryption operation.
Recovering the root offset
As mentioned earlier, at the top of the chain of different deobfuscating functions that call one another, there is always an obfuscated parent. Instead of deobfuscating it, we decided to treat it as a black box. Recovering its index shift involves several steps.
The parent functions are the first string decoding functions to be declared, and in the start function, they may be called directly. Just like in the case of their children, two arguments are expected: the RC4 key, and the number used for index calculation.
Once we have found the parent, we track its direct calls and collect the arguments.
We know that the chunk index is obtained by an arithmetic operation (addition or subtraction) on the passed number. We can express it as:
index = arg (+|-) X
The goal is to find the correct X (index shift). Since this value is used to calculate the index of the chunk, the upper bound is the number of chunks in the array (N). We test candidate shifts from 0 to N-1, apply each to the input index, and attempt to decrypt the resulting chunk. If the output looks like a valid string, we treat that X as the index shift candidate.
Conceptually:
for candidate_shift in 0 .. N-1:
candidate_chunk = array[(input_index + candidate_shift) mod N]
plaintext = RC4(candidate_chunk, key)
if plaintext looks plausible:
keep candidate_shift
A plausible result from a single call is not enough: an invalid chunk can occasionally produce printable text when decrypted with the given key. The implementation therefore requires at least three distinct input/output observations for the same decoder function. It computes the candidate shifts per set, intersects those sets, and accepts the value only when it produces a printable result for each. In all the analyzed payloads this condition was sufficient to find the appropriate index shift.
This can be viewed as a bounded brute-force search. The implementation tests possible index shifts within the string-array length and uses multiple independent calls to eliminate candidates that do not produce consistent printable results.
Once the root configuration is known, the pass propagates the index shift through the collected function graph to the callers, calculating the cumulative index delta applied by each individual decoder.
Overview of the string deobfuscating pass
The string deobfuscation pass requires all arguments to be filled, as described in “Propagating values”. It works in the following steps:
- Retrieves the start function
- Searches for the function aggregating obfuscated string chunks. It is always referenced by the start function and can be spotted by a known pattern of the call. Example:
ACCU = func_unknown_0x93e23cef019(func_KV_0x18c3e8c9a1c1, 940600)
- Follows and parses the function with chunks (in the above case:
func_KV_0x18c3e8c9a1c1). Stores the list for further use. - Searches all the string decoding functions, recovers the parent index shifts, and calculates the resulting index shift for each decoder function. The input arguments can be arranged in two ways: either
Rc4Key, OffsetorOffset, Rc4Key– this is recognized and added to the function prototype.
r2 = (r2 + func_xt_0x274f42c4e909(99288, "h^gm")) //Offset, Rc4Key
After the first run, the deobfuscator stores parsed and calculated arguments in a CSV file. If the pass has to be re-run, the list is pre-loaded, which saves time.
Example of the listing (format: function_name,index_shift,is_index_first):
func_xt_0x274f42c4e909,125103,True func_Et_0x1d8d5672d829,125093,False func_u_0x3fa27d771f29,125016,True func_r_0x93e23cf1d91,125126,True func_n_0x93e23cf22a1,126086,True ...
After all the deobfuscating functions have been resolved, each of their resolved occurrences is replaced with its output value. The deobfuscated chunks are then chained together to form the full string.
- r5 = func_n_0x34d57d25f3b9(60787, "Bz&S") //"defau" - r4 = xF[(r5 + "lt")] + r4 = global_xF["default"] - r5 = func_n_0x34d57d25f3b9(58819, "5C8Q") // "globa" - r5 = (r5 + func_n_0x34d57d25f3b9(17159, "SldQ")) //"lAgen" - return r4[(r5 + "t")] + return r4["globalAgent"]
After the deobfuscation is completed, the functions responsible for string decoding are no longer needed. Their representation is hidden in the code and not printed in the decompilation output.
Scale and performance
For the 23-sample dataset used in the final measurements [8], the string layer contained approximately:
- 130,000 encoded chunks on average, with observed values from about 19,000 to 217,000;
- 10,000 decoder configurations on average, with observed values from about 2,200 to 13,000.
Measured runtime for the string stage was:
After string reconstruction, the output contains both substituted plaintext and a standalone string listing. This is often the first point at which the payload starts exposing concrete artifacts such as commands, registry paths, browser targets, cryptocurrency platforms, and the attacker’s embedded public key.
Artifact overview
In addition to the main output of the pass (which is the decompiled and pickled file), the list of all the strings is dumped as text. It helps quickly give an idea of which functionalities are implemented, and to compare different payloads.
Example – listing of strings extracted from a sample: e27ae65977287bdfb7b0e15fd3603f85.deobf.txt.strings.txt
Among the interesting artifacts, we can find the public key of the attackers:
"\n-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtRdWl/ucoH+ZnVuxHrx2\ncTbwEY2LucyUqEJVl6trmNYaJTFX9qDYA8Z4VOaFO86MHg0cY1mJ8NALzTqDt20C\nlnqYtLEuo0Fqg9pJMhnEb078F31dilgdK+5bK7LgwXps06KQ+Dk7XxaqkbPFa7oZ\n73/q4FhrYEtBxFno0WJla7mq49/W4wJb753WYWTjRMjBKVaUIOtAtGdBp8Li2WX2\nPDqxftDcvT8hJf5H6tMJ3tQRpyHu7ljkwdivamG/labZpzKhijK7BMgrd7251sjh\n7zD6prnafayjK+nfD1dvok7Rd8TV8sa1FK8T0uMmGFdUVGK+X4f45AwNWn8OINLE\nVwIDAQAB\n-----END PUBLIC KEY-----"
There are strings related to deploying hidden PowerShell scripts and running content from a Base64-encoded blob:
"powershell -NoProfile -WindowStyle Hidden -Command \"" "Invoke-Expression ([System.Text.Encoding]::" ".GetString([System.Convert]::FromBase64String($_.unattend.Extensions."
Multiple strings suggest that the malware enumerates installed browsers, and tries to query the saved secrets, cookies, OAuth tokens, and other data:
"iterInstalledBrowsers" "getCookies" "application" "launch" "values" "createBrowserContext" "newPage" "setCookie" "getPasswords" "div[data-identifier=\"" "findInstalledBrowser" "--user-data-dir=" "--profile-directory=" "withCreateProcessUser" "user_id" "oauth_token" "google" "saveOAuthToken" "/oauth2/:version/token?grant_type=authorization_code&client_id="
It also queries all installed applications and targets Telegram accounts:
"listTelegramSessions" "listInstalledApplications"
To achieve its goals, it uses the capability to spawn additional processes:
"Process exited with code " spawn
It creates a local proxy server with its own certificate:
"address" close "listen" "127.0.0.1" "createServer" pki rsa "generateKeyPair" "createCertificate" "publicKey" "serialNumber" "certificateToPem"
Some strings are fragments of URLs for particular cryptocurrency vaults and are related to checking account balances:
".phantom-labs.vault." "totalBalanceInUSDT" "free_margin_usd" "floating_usd" "historical_balances_per_asset_category" "total_usd_market_value" "customer_account_USDT_balance_available" "binance"
Many of the deobfuscated strings come from Node.js modules bundled into the payload and give an idea of what functionality to expect.
Comprehensive analysis of all the artifacts is beyond this short overview. You can find the extracted strings from all analyzed samples in the directory with additional materials [8].
Control flow unflattening
Some of the most important functions of the malware are obfuscated using Control Flow Flattening (CFF).
To resolve this layer, we must make sure that all strings are deobfuscated and propagated, because they are crucial for the execution logic. In the listing produced by the previously described filter, we find some strings in the format [number0]|[number1]|[number2]... for example: “3|2|1|0|4”. Such strings denote an order of chunks to be executed.
Typically, CFF is implemented as a state machine. We can see it represented by a while loop. In each iteration of the loop, the number is fetched from the list. This number is further checked against nested if statements, directing to the chunk of code to be executed. In the simplest form, a chunk ends with continue, causing the loop to progress to another case.
Example (from: 03f4e47b9c2283c32bb8f8f042ce6e41):
function func_Mz_0x6035be98311(a0)
{
r5 = Scope[0]
r2 = func_r_0x6035be98a69
Scope[6705][2] = new {"w": 1342}
r6 = new {"jGBGz": null, "hBPBb": null, "qbyOP": null, "ykkYm": null, "SeAyf": null, "yHrsY": null, "umIdy": null, "RBgqe": null}
r6["jGBGz"] = "3|2|1|0|4"
r6["hBPBb"] = func_hBPBb_0x6035be990e9
r6["qbyOP"] = "wss"
r6["ykkYm"] = func_ykkYm_0x6035be991e9
r6["SeAyf"] = func_SeAyf_0x6035be992e9
r6["yHrsY"] = "https"
r6["umIdy"] = "http"
r6["RBgqe"] = "Invalid protocol"
r1 = r6
r7 = r1["jGBGz"]
r6 = r7["split"]
r3 = r6("|")
r4 = 0
while (true)
{
r7 = Number(r4)
r4 = (Number(r4) + 1)
r6 = r3[r7]
if (!r6 === "0")
{
if (!r6 === "1")
{
if (!r6 === "2")
{
if (!r6 === "3")
{
if (!r6 === "4")
{
continue
}
r7 = r1["hBPBb"]
r10 = r1["qbyOP"]
if (r7(a0, r10))
{
r7 = global_Tb["default"]
return r7["globalAgent"]
}
continue
}
r7 = r1["ykkYm"]
if (r7(a0, "ws"))
{
r7 = global_Nb["default"]
return r7["globalAgent"]
}
continue
}
r7 = r1["SeAyf"]
r10 = r1["yHrsY"]
if (r7(a0, r10))
{
r7 = global_Tb["default"]
return r7["globalAgent"]
}
continue
}
r7 = a0["split"]
r7 = r7(":")
a0 = r7[0]
r7 = r1["hBPBb"]
r10 = r1["umIdy"]
if (r7(a0, r10))
{
r7 = global_Nb["default"]
return r7["globalAgent"]
}
continue
}
r8 = r1["RBgqe"]
ACCU = Error
ACCU = Error(r8)
break
}
return undefined
}
We start the deobfuscation by identifying the beginnings and ends of each code chunk. For example, to find the chunk number 0, we first need to identify the if statement that actually checks against the negation of this condition: if (!r6 === "0"). Once we find the statement, we have to skip the body under it (since it is a negation) and find the first closing bracket with the same indentation as the statement itself. This is where the chunk indexed as 0 actually starts.
Once we have all the chunks mapped, we rearrange them by the order defined by the string, adjusting their indentations.
The same function, unflattened:
function func_Mz_0x6035be98311(a0)
{
r5 = Scope[0]
r6 = new {"jGBGz": null, "hBPBb": null, "qbyOP": null, "ykkYm": null, "SeAyf": null, "yHrsY": null, "umIdy": null, "RBgqe": null}
r6["hBPBb"] = func_hBPBb_0x6035be990e9
r6["qbyOP"] = "wss"
r6["ykkYm"] = func_ykkYm_0x6035be991e9
r6["SeAyf"] = func_SeAyf_0x6035be992e9
r6["yHrsY"] = "https"
r6["umIdy"] = "http"
r6["RBgqe"] = "Invalid protocol"
r1 = r6
r4 = 0
r7 = a0["split"]
r7 = r7(":")
a0 = r7[0]
r7 = r1["hBPBb"]
r10 = r1["umIdy"]
if (r7(a0, r10))
{
r7 = global_Nb["default"]
return r7["globalAgent"]
}
r7 = r1["SeAyf"]
r10 = r1["yHrsY"]
if (r7(a0, r10))
{
r7 = global_Tb["default"]
return r7["globalAgent"]
}
r7 = r1["ykkYm"]
if (r7(a0, "ws"))
{
r7 = global_Nb["default"]
return r7["globalAgent"]
}
r7 = r1["hBPBb"]
r10 = r1["qbyOP"]
if (r7(a0, r10))
{
r7 = global_Tb["default"]
return r7["globalAgent"]
}
r8 = r1["RBgqe"]
ACCU = Error
ACCU = Error(r8)
return undefined
}
For the sake of comparison, let’s see it with further deobfuscation filters applied:
function func_Mz_0x6035be98311(a0)
{
r4 = 0
r7 = a0["split"]
r7 = r7(":")
a0 = r7[0]
if (a0 === "http")
{
return global_Nb["default"]["globalAgent"]
}
if (a0 === "https")
{
return global_Tb["default"]["globalAgent"]
}
if (a0 === "ws")
{
return global_Nb["default"]["globalAgent"]
}
if (a0 === "wss")
{
return global_Tb["default"]["globalAgent"]
}
ACCU = Error
ACCU = Error("Invalid protocol")
return undefined
}
At this point the function’s intention becomes clear. It performs a lookup that returns the appropriate globalAgent for a given protocol.
The caveats
Sometimes, the chunks of code that are executed in each state are decompiled in a way that makes them difficult to separate cleanly. Let’s take a look at the following example:
while (true) //The dispatcher loop
{
r15 = Number(r4)
r4 = (Number(r4) + 1)
r14 = r3[r15]
if (!r14 === "0")
{
// Other chunks...
// [...]
}
// Chunk 0:
r15 = r2["Uugef"]
if (r15(r11, r12))
{
ACCU = 0
continue ///<- this is not the end of the chunk...
}
r15 = r2["PgJCU"]
r17 = r2["LqFvW"]
r17 = r17(r11, r12)
if (r15(r17, r5))
{
ACCU = 1
continue ///<- this is not the end of the chunk...
}
return -1
break
}
We have continue statements inside the if blocks. In the original flow, this leads to jumping back to the top of the loop and fetching another chunk from the list. But when we unflatten the flow, and remove the loop, it no longer makes sense, so this logic has to be rewritten.
The chunk should therefore look as follows after this adjustment:
// Chunk 0:
r15 = r2["Uugef"]
if (r15(r11, r12))
{
ACCU = 0
}
else // added else statement
{
r15 = r2["PgJCU"]
r17 = r2["LqFvW"]
r17 = r17(r11, r12)
if (r15(r17, r5))
{
ACCU = 1
}
else // added else statement
{
return -1
}
}
The continue statements have been removed, and the code that originally followed each if statement has been moved into the corresponding else clause.
The current version of our deobfuscation pass can handle such scenarios. It automatically removes the nested continue statements and reconstructs the equivalent logic by building an else clause from the code that follows the original if statement. This has proved sufficient in the majority of the analyzed cases. However, we may occasionally encounter more complex or ambiguous variants that are not yet resolved. These cases will be addressed in future versions as our toolkit [7] evolves.
Resolving Proxies and Operations
Across the code, we often encounter functions that act as proxies for other functions. Their only role is to complicate the flow, misleading readers about the actual function being called and making its arguments harder to parse.
The simplest proxies look as follows: the actual function that is about to be called is just passed as one of the arguments.
function func_hgFUm_0x17275c6577e9(a0, a1, a2, a3, a4, a5, a6)
{
r1 = a1
r2 = a2
r3 = a3
r4 = a4
r5 = a5
r6 = a6
return a0(r1, r2, r3, r4, r5, r6)
}
function func_oWgYF_0x17275c6566c1(a0, a1, a2, a3, a4)
{
r1 = a1
r2 = a2
r3 = a3
r4 = a4
return a0(r1, r2, r3, r4)
}
They are usually simple to resolve. First, we reduce each of them to their basic form, which removes the use of the local registers. For example:
function func_INBzN_0x16abcdb5cc69(a0, a1, a2, a3)
{
- r1 = a1
- r2 = a2
- r3 = a3
- return a0(r1, r2, r3)
+ return a0(a1, a2, a3)
}
Then, we replace their calls. After all the calls to the particular proxy are replaced with their basic meaning, the proxy itself can be hidden in the code.
Example:
-function func_INBzN_0x16abcdb5cc69(a0, a1, a2, a3)
-{
- return a0(a1, a2, a3)
-}
@@ -22213,7 +21565,7 @@ function func_J_0x16abcdb59891()
}
else
{
- ACCU = func_INBzN_0x16abcdb5cc69(func_k_0x16abcdb5a2d9, <this>, null, null)
+ ACCU = func_k_0x16abcdb5a2d9(<this>, null, null)
}
As with proxy calls, there are plenty of other small functions that should be resolved and hidden. In multiple places in the code we can find operations that are implemented by functions, with obfuscated names.
For example:
function func_wcmWN_0x35459f2fab89(a0, a1)
{
return a0 in a1
}
function func_eBvDY_0x35459f2fa789(a0, a1)
{
return (a0 - a1)
}
function func_wNPyv_0x35459f2fa689(a0, a1)
{
return (a0 / a1)
}
function func_oEEDc_0x35459f2faa89(a0, a1)
{
return a0(a1)
}
The same operation can also be defined by multiple instances of an identical function (i.e. there are multiple functions implementing simple addition).
One of our deobfuscating passes is meant to replace calls to such functions with the actual operations that they represent. However, the functions may not be called directly. So, before we proceed with the substitution, we need to apply all needed simplifications.
Iterative propagation of the structures
To complicate the flow even more, the variables and functions are often not used directly. They may be first defined as a local dictionary, initialized, then passed further, to be referenced in different parts of the code.
In the snippet below, a dictionary is first assigned to the local register r1, filled with references to functions, and further assigned to the scope variable (Scope[846][21]).
r1 = new {"hKCZK": null, "kdujm": null, "siBVG": null, "qQNNx": null, "ECBQT": null, "Bdomb": null}
r1["hKCZK"] = func_hKCZK_0x24149a8df611
r1["kdujm"] = func_kdujm_0x24149a8df931
r1["siBVG"] = func_siBVG_0x24149a8dfbe1
r1["qQNNx"] = func_qQNNx_0x24149a8dfe99
r1["ECBQT"] = func_ECBQT_0x24149a8e0151
r1["Bdomb"] = func_Bdomb_0x24149a8e0409
Scope[846][21] = r1
Then, each of these functions is called indirectly, by one of the children of the declarer.
Notice that the keys of many of the dictionaries are strings. This is why decrypting strings is such a crucial step in the whole pipeline: without them, we are unable to proceed further.
Due to the layered nature of the obfuscator, the pass that propagates such defined structures must be run multiple times at different stages. The arguments to the string deobfuscation functions are also often passed via dictionaries set into a scope. One such example is given below – in this case, the string decoding function is called via register r5, and its two arguments are passed via Scope[846][3]:
r10 = Scope[846][21][r5(Scope[846][3]["N"], Scope[846][3]["M"])]
Only after filling them in and deobfuscating strings are we able to see the actual key of the next dictionary (in the given case, it is "qQNNx"). The next run of the pass allows us to resolve this key to the value it was mapped to by another function (here: it is a reference to the function func_qQNNx_0x24149a8dfe99).
r10 = Scope[846][21]["qQNNx"] //func_qQNNx_0x24149a8dfe99
This is not the end of the rabbit-hole. The referenced function may itself use values passed in a similar way. Below we can see that it first fetches some function via Scope[845][29] using the key "PQxQy" and then calls this function with two arguments. Basically, it is a wrapper.
function func_qQNNx_0x24149a8dfe99(a0, a1)
{
r1 = Scope[845][29]["PQxQy"]
return r1(a0, a1)
}
Once we track upstream what is behind this key, we find a reference to another function:
r4 = new {... "PQxQy": null, ...}
...
r4["PQxQy"] = func_PQxQy_0x24149a8dd581
...
Scope[845][29] = r4
Finally, after resolving it to a self-contained unit we find that this whole chain leads to the execution of a simple atomic operation:
function func_PQxQy_0x24149a8dd581(a0, a1)
{
return (a0 - a1)
}
By peeling the layers, one by one, we manage to express such operations with their literal meaning. An example of the complete simplification process is given below.
Step 1 (initial decompiled code):
function func_value_0x24149a8e3d19(a0)
{
[...]
r10 = Scope[846][21][r5(Scope[846][3]["N"], Scope[846][3]["M"])]
r13 = r5(Scope[846][3]["k"], Scope[846][3]["Q"])
r12 = r0[(r13 + "h")]
r10 = r10(r12, a0)
Step 2 (resolve arguments for the string deobfuscation function func_me_0x24149a8e4421):
r10 = Scope[846][21][func_me_0x24149a8e4421(12568, "%]hf")] //"qQNNx"
r13 = func_me_0x24149a8e4421(34408, "[Jy3") //"lengt"
r12 = r0[(r13 + "h")]
r10 = r10(r12, a0)
Step 3 (the string revealed the key of another dictionary passed via scope, that resolves to a function):
r10 = Scope[846][21]["qQNNx"] // func_qQNNx_0x24149a8dfe99
r12 = r0["length"]
r10 = r10(r12, a0)
Step 4 (the found function is called in the line below; it resolves to a proxy function):
r12 = r0["length"]
r10 = func_qQNNx_0x24149a8dfe99(r12, a0) // -> func_PQxQy_0x24149a8dd581
Step 5 (substitute the proxy function with the actual function it calls):
r12 = r0["length"] r10 = func_PQxQy_0x24149a8dd581(r12, a0)
Step 6 (the call resolves to an atomic operation and can be substituted by such):
r12 = r0["length"] r10 = (r12 - a0)
The given example is just one of the possible variants in which such a propagation chain may work. It has been presented to give an idea of the underlying complexity.
Interpreting the flow
Once we have the major obfuscation layers removed, the malware starts revealing its shape. This allows us to pinpoint the most important building blocks of the whole execution flow, and guide next steps.
The entry point of the file is the function labeled start. At the very end of it, the functions that will be running the main operations are set up. Example:
global_Xr = func_Xr_0x93e23cef8e9
[...]
d7e = global_Xr(func_unknown_0x217bb6195779)
[...]
G7e = {}
M7e = global_Xr(func_unknown_0x7b2a97682c9)
j7e = require("dns")
ACCU = global_n2()
r1 = j7e["setServers"]
r3 = new [0, 0]
r3[0] = "1.1.1.1"
r3[1] = "8.8.8.8"
ACCU = r1(r3)
ACCU = global_Soe(__filename)
if (global_Soe(__filename))
{
ACCU = global_d7e()
ACCU = global_kV(s7e)
}
else
{
ACCU = global_M7e()
ACCU = global_kV(G7e)
}
r0 = ACCU
return ACCU
}
This still contains some obfuscation patterns that need to be understood and removed.
Proxy functions using scopes
The start function sets up several proxy functions that are further referenced via globals. They come in a few different variants, but we will illustrate the most common type. Let’s focus on the fragments of the earlier snippet:
global_Xr = func_Xr_0x93e23cef8e9
...
d7e = global_Xr(func_unknown_0x217bb6195779)
...
M7e = global_Xr(func_unknown_0x7b2a97682c9)
...
if (global_Soe(__filename))
{
ACCU = global_d7e()
...
}
else
{
ACCU = global_M7e()
...
}
The global_Xr variable points to the following function:
function func_Xr_0x93e23cef8e9(a0, a1)
{
r0 = Scope[0]
Scope[8554][3] = a0
Scope[8554][2] = a1
return func_unknown_0x93e23cef9f9
}
That function finishes by returning a reference to another function, which makes the second part of the flow. It uses the scope arguments that were previously set up:
function func_unknown_0x93e23cef9f9()
{
if (Scope[8554][3])
{
r0 = Scope[8554][3]
Scope[8554][3] = 0
Scope[8554][2] = r0(0)
}
return Scope[8554][2]
}
The first step in deobfuscating it is recognizing how these functions behave when joined as one unit. It could be represented by the following pseudo-code:
function Xr(fn, cached) {
return function thunk() {
if (fn) {
const tmp = fn;
fn = 0;
cached = tmp(0);
}
return cached;
};
}
This is a lazy, one-shot wrapper: on its first invocation it calls the supplied function and caches the result; subsequent calls return the cached value. In the initialization sites shown here, the thunk is used to reach the underlying function, so for analysis we can collapse that indirection and expose the actual target directly.
We can observe it referenced similarly to the example below:
global_d7e = global_Xr(func_unknown_0x217bb6195779) [...] ACCU = global_d7e()
There is now a global thunk wrapping the target function. Once we understand this indirection, in the initialization path shown here we can expose the target directly:
ACCU = func_unknown_0x217bb6195779()
So, the final dispatcher can be interpreted as:
if (global_Soe(__filename))
{
ACCU = func_unknown_0x217bb6195779()
ACCU = global_kV(s7e)
}
else
{
ACCU = func_unknown_0x7b2a97682c9()
ACCU = global_kV(G7e)
}
Finding the vital functions
To understand the flow further, we need to see what happens in the function called in each branch. Let’s look at one of them:
function func_unknown_0x7b2a97682c9()
{
r5 = Scope[0]
r2 = func_r_0x7b2a9768611
r6 = new {"bALca": null, "rPEMA": null, "PUUhv": null, "zEykL": null}
r6["rPEMA"] = func_rPEMA_0x7b2a9768939
r6["PUUhv"] = func_PUUhv_0x7b2a9768a39
r6["zEykL"] = func_zEykL_0x7b2a9768b39
r1 = r6
r4 = 0
r7 = r1["zEykL"]
ACCU = r7(P7e)
r7 = r1["rPEMA"]
ACCU = r7(X7e)
r7 = r1["PUUhv"]
ACCU = r7(N7e)
r7 = r1["rPEMA"]
ACCU = r7(R7e)
return undefined
}
Functions like rPEMA simply perform calls via a proxy:
function func_rPEMA_0x7b2a9768939(a0)
{
return a0()
}
So the real meaning is:
function func_unknown_0x7b2a97682c9()
{
r4 = 0
ACCU = global_P7e()
ACCU = global_X7e()
ACCU = global_N7e()
ACCU = global_R7e()
return undefined
}
In the other branch of the statement, it is:
function func_unknown_0x217bb6195779()
{
r4 = 0
ACCU = global_RU()
ACCU = global_n7e()
ACCU = global_c7e()
ACCU = global_Xf()
ACCU = global_FE()
ACCU = global_x7e()
return undefined
}
Functions such as P7e are defined in the start function as globals and resolve to:
global_RU = global_Xr(func_unknown_0x217bb618aaf1) global_n7e = global_Xr(func_unknown_0x217bb618e1f1) global_c7e = global_Xr(func_unknown_0x217bb61943c1) global_Xf = global_Xr(func_unknown_0x1cab5d7b26e9) global_FE = global_Xr(func_unknown_0x1d8d5671d7f1) global_x7e = func_x7e_0x217bb6194f91 global_P7e = global_Xr(func_unknown_0x7b2a9764cb1) global_X7e = global_Xr(func_unknown_0x7b2a9766509) global_N7e = global_Xr(func_unknown_0x7b2a975fa61) global_R7e = global_Xr(func_unknown_0x7b2a9751711)
Those are the functions that implement the actual malware functionality. Some of them are further obfuscated, for example:
function func_unknown_0x217bb618e1f1()
{
r3 = Scope[0]
r4 = new {"Vhzac": null, "ZljYv": null, "MbImZ": null}
r4["Vhzac"] = func_Vhzac_0x217bb618e6d1
r4["ZljYv"] = func_ZljYv_0x217bb618e7d1
r4["MbImZ"] = func_MbImZ_0x217bb618e8d1
r1 = r4
r4 = r1["Vhzac"]
ACCU = r4(f1)
r4 = r1["ZljYv"]
r7 = r1["Vhzac"]
r7 = r7(Qs)
global_eb = r4(Di, r7)
r4 = r1["MbImZ"]
ACCU = r4(ag)
return undefined
}
After replacing the wrappers, we can see more clearly what the above code represents:
function func_unknown_0x217bb618e1f1()
{
r3 = Scope[0]
ACCU = global_f1() // global_f1 = global_Xr(func_unknown_0x23f664e8d2b1)
r7 = global_Qs() // global_Qs = global_du(func_unknown_0x1cab5d7a90b1)
global_eb = global_Di(r7) // global_Di = func_Di_0x93e23cf17b1
ACCU = global_ag() // global_ag = global_Xr(func_unknown_0x1cab5d7b0399)
return undefined
}
Further substituting the globals with their literal values and removing all the proxy layers finally reveals the bare dispatcher functions that can be easily followed and analyzed.
After the final transformation, the function presented above takes the following form:
function func_unknown_0x217bb618e1f1()
{
ACCU = func_unknown_0x23f664e8d2b1()
r7 = func_unknown_0x1cab5d7a90b1["exports"]()
global_eb = func_Di_0x93e23cf17b1(r7)
ACCU = func_unknown_0x1cab5d7b0399()
return undefined
}
LLM-assisted function renaming
After the deterministic deobfuscation passes, the output is structurally much cleaner: strings are visible, important flattened flows have been reconstructed, and many proxy and operation-wrapper functions have disappeared. One problem remains unavoidable: compilation and obfuscation have destroyed the original semantic function names.
For a small program, an analyst could rename important functions manually. JSCeal contains thousands of functions, including a large amount of bundled dependency code, so manual naming does not scale. We therefore added an optional LLM-assisted renaming stage as a navigation aid.
The distinction is important: the LLM does not perform the core deobfuscation, and its output is not treated as evidence. It receives code that has already been recovered by the static pipeline and proposes labels intended to make the resulting function graph easier to browse.
Dependency-aware renaming
Because functions depend on other functions, the order in which they are sent to the renamer matters.
Reproduced in full under licence from Check Point Research. © Check Point Research. Written by shlomoo@checkpoint.com.
Coverage
One outlet has carried this so far.
2026-08-31 13:38 UTC
Related stories
- Iranian hackers use CHOSEN BRICK Windows malware to spy on targets
BleepingComputer · 2026-09-16
- Malware bypasses browser checks to force install Chrome, Edge extensions
BleepingComputer · 2026-09-16
- Three Threat Groups Target Russian Enterprises With Backdoors, Ransomware, and Wipers
The Hacker News · 2026-09-16
- PHP Webshell Campaign Targets WordPress Through Critical WooCommerce Plugin Bug
Infosecurity Magazine · 2026-09-16
- Attacker Hijacks AI Coding Assistant Session, Spreads Shai-Hulud Across About 100 Repositories
The Hacker News · 2026-09-16