pyjsclear 0.1.4__tar.gz → 0.1.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyjsclear-0.1.4/pyjsclear.egg-info → pyjsclear-0.1.5}/PKG-INFO +3 -2
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/README.md +2 -1
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/__init__.py +17 -8
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/__main__.py +1 -0
- pyjsclear-0.1.5/pyjsclear/deobfuscator.py +391 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/generator.py +32 -31
- pyjsclear-0.1.5/pyjsclear/parser.py +87 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/scope.py +236 -184
- pyjsclear-0.1.5/pyjsclear/transforms/aa_decode.py +148 -0
- pyjsclear-0.1.5/pyjsclear/transforms/anti_tamper.py +121 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/base.py +11 -8
- pyjsclear-0.1.5/pyjsclear/transforms/class_static_resolver.py +229 -0
- pyjsclear-0.1.5/pyjsclear/transforms/class_string_decoder.py +387 -0
- pyjsclear-0.1.5/pyjsclear/transforms/cleanup.py +342 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/constant_prop.py +54 -33
- pyjsclear-0.1.5/pyjsclear/transforms/control_flow.py +374 -0
- pyjsclear-0.1.5/pyjsclear/transforms/dead_branch.py +150 -0
- pyjsclear-0.1.5/pyjsclear/transforms/dead_class_props.py +344 -0
- pyjsclear-0.1.5/pyjsclear/transforms/dead_expressions.py +43 -0
- pyjsclear-0.1.5/pyjsclear/transforms/dead_object_props.py +239 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/else_if_flatten.py +20 -7
- pyjsclear-0.1.5/pyjsclear/transforms/enum_resolver.py +267 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/eval_unpack.py +21 -21
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/expression_simplifier.py +91 -96
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/global_alias.py +57 -31
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/hex_escapes.py +31 -23
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/hex_numerics.py +11 -8
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/jj_decode.py +180 -170
- pyjsclear-0.1.5/pyjsclear/transforms/jsfuck_decode.py +633 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/logical_to_if.py +27 -14
- pyjsclear-0.1.5/pyjsclear/transforms/member_chain_resolver.py +171 -0
- pyjsclear-0.1.5/pyjsclear/transforms/noop_calls.py +118 -0
- pyjsclear-0.1.5/pyjsclear/transforms/nullish_coalescing.py +120 -0
- pyjsclear-0.1.5/pyjsclear/transforms/object_packer.py +156 -0
- pyjsclear-0.1.5/pyjsclear/transforms/object_simplifier.py +224 -0
- pyjsclear-0.1.5/pyjsclear/transforms/optional_chaining.py +217 -0
- pyjsclear-0.1.5/pyjsclear/transforms/property_simplifier.py +111 -0
- pyjsclear-0.1.5/pyjsclear/transforms/proxy_functions.py +281 -0
- pyjsclear-0.1.5/pyjsclear/transforms/reassignment.py +230 -0
- pyjsclear-0.1.5/pyjsclear/transforms/require_inliner.py +103 -0
- pyjsclear-0.1.5/pyjsclear/transforms/sequence_splitter.py +261 -0
- pyjsclear-0.1.5/pyjsclear/transforms/single_use_vars.py +221 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/string_revealer.py +297 -261
- pyjsclear-0.1.5/pyjsclear/transforms/unreachable_code.py +68 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/unused_vars.py +56 -20
- pyjsclear-0.1.5/pyjsclear/transforms/variable_renamer.py +544 -0
- pyjsclear-0.1.5/pyjsclear/transforms/xor_string_decode.py +295 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/traverser.py +75 -84
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/utils/ast_helpers.py +118 -86
- pyjsclear-0.1.5/pyjsclear/utils/string_decoders.py +162 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5/pyjsclear.egg-info}/PKG-INFO +3 -2
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyproject.toml +2 -1
- pyjsclear-0.1.4/pyjsclear/deobfuscator.py +0 -317
- pyjsclear-0.1.4/pyjsclear/parser.py +0 -46
- pyjsclear-0.1.4/pyjsclear/transforms/aa_decode.py +0 -150
- pyjsclear-0.1.4/pyjsclear/transforms/anti_tamper.py +0 -103
- pyjsclear-0.1.4/pyjsclear/transforms/class_static_resolver.py +0 -212
- pyjsclear-0.1.4/pyjsclear/transforms/class_string_decoder.py +0 -333
- pyjsclear-0.1.4/pyjsclear/transforms/cleanup.py +0 -283
- pyjsclear-0.1.4/pyjsclear/transforms/control_flow.py +0 -327
- pyjsclear-0.1.4/pyjsclear/transforms/dead_branch.py +0 -89
- pyjsclear-0.1.4/pyjsclear/transforms/dead_class_props.py +0 -254
- pyjsclear-0.1.4/pyjsclear/transforms/dead_expressions.py +0 -29
- pyjsclear-0.1.4/pyjsclear/transforms/dead_object_props.py +0 -150
- pyjsclear-0.1.4/pyjsclear/transforms/enum_resolver.py +0 -175
- pyjsclear-0.1.4/pyjsclear/transforms/jsfuck_decode.py +0 -610
- pyjsclear-0.1.4/pyjsclear/transforms/member_chain_resolver.py +0 -149
- pyjsclear-0.1.4/pyjsclear/transforms/noop_calls.py +0 -79
- pyjsclear-0.1.4/pyjsclear/transforms/nullish_coalescing.py +0 -109
- pyjsclear-0.1.4/pyjsclear/transforms/object_packer.py +0 -131
- pyjsclear-0.1.4/pyjsclear/transforms/object_simplifier.py +0 -197
- pyjsclear-0.1.4/pyjsclear/transforms/optional_chaining.py +0 -153
- pyjsclear-0.1.4/pyjsclear/transforms/property_simplifier.py +0 -68
- pyjsclear-0.1.4/pyjsclear/transforms/proxy_functions.py +0 -238
- pyjsclear-0.1.4/pyjsclear/transforms/reassignment.py +0 -197
- pyjsclear-0.1.4/pyjsclear/transforms/require_inliner.py +0 -77
- pyjsclear-0.1.4/pyjsclear/transforms/sequence_splitter.py +0 -213
- pyjsclear-0.1.4/pyjsclear/transforms/single_use_vars.py +0 -164
- pyjsclear-0.1.4/pyjsclear/transforms/unreachable_code.py +0 -38
- pyjsclear-0.1.4/pyjsclear/transforms/variable_renamer.py +0 -511
- pyjsclear-0.1.4/pyjsclear/transforms/xor_string_decode.py +0 -228
- pyjsclear-0.1.4/pyjsclear/utils/string_decoders.py +0 -140
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/LICENSE +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/MANIFEST.in +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/NOTICE +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/THIRD_PARTY_LICENSES.md +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/__init__.py +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/utils/__init__.py +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear.egg-info/SOURCES.txt +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear.egg-info/dependency_links.txt +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear.egg-info/entry_points.txt +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear.egg-info/requires.txt +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear.egg-info/top_level.txt +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/setup.cfg +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.5}/tests/test_regression.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyjsclear
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Pure Python JavaScript deobfuscator
|
|
5
5
|
Author-email: Intezer Labs <info@intezer.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -120,5 +120,6 @@ See [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) and
|
|
|
120
120
|
[NOTICE](NOTICE) for full attribution.
|
|
121
121
|
|
|
122
122
|
Test samples include obfuscated JavaScript from the
|
|
123
|
-
[JSIMPLIFIER dataset](https://zenodo.org/records/17531662) (GPL-3.0)
|
|
123
|
+
[JSIMPLIFIER dataset](https://zenodo.org/records/17531662) (GPL-3.0)
|
|
124
|
+
and the [Obfuscated JavaScript Dataset](https://www.kaggle.com/datasets/fanbyprinciple/obfuscated-javascript-dataset),
|
|
124
125
|
used solely for evaluation purposes.
|
|
@@ -94,5 +94,6 @@ See [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) and
|
|
|
94
94
|
[NOTICE](NOTICE) for full attribution.
|
|
95
95
|
|
|
96
96
|
Test samples include obfuscated JavaScript from the
|
|
97
|
-
[JSIMPLIFIER dataset](https://zenodo.org/records/17531662) (GPL-3.0)
|
|
97
|
+
[JSIMPLIFIER dataset](https://zenodo.org/records/17531662) (GPL-3.0)
|
|
98
|
+
and the [Obfuscated JavaScript Dataset](https://www.kaggle.com/datasets/fanbyprinciple/obfuscated-javascript-dataset),
|
|
98
99
|
used solely for evaluation purposes.
|
|
@@ -5,14 +5,18 @@ and javascript-deobfuscator (3 surface-cleanup modules) into a single
|
|
|
5
5
|
Python package.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
8
10
|
from .deobfuscator import Deobfuscator
|
|
9
11
|
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
__all__ = ['Deobfuscator', 'deobfuscate', 'deobfuscate_file']
|
|
14
|
+
|
|
15
|
+
__version__ = '0.1.5'
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
def deobfuscate(code: str, max_iterations: int = 50) -> str:
|
|
15
|
-
"""Deobfuscate JavaScript code
|
|
19
|
+
"""Deobfuscate JavaScript code and return cleaned source.
|
|
16
20
|
|
|
17
21
|
Args:
|
|
18
22
|
code: JavaScript source code string.
|
|
@@ -24,7 +28,11 @@ def deobfuscate(code: str, max_iterations: int = 50) -> str:
|
|
|
24
28
|
return Deobfuscator(code, max_iterations=max_iterations).execute()
|
|
25
29
|
|
|
26
30
|
|
|
27
|
-
def deobfuscate_file(
|
|
31
|
+
def deobfuscate_file(
|
|
32
|
+
input_path: str | Path,
|
|
33
|
+
output_path: str | Path | None = None,
|
|
34
|
+
max_iterations: int = 50,
|
|
35
|
+
) -> str | bool:
|
|
28
36
|
"""Deobfuscate a JavaScript file.
|
|
29
37
|
|
|
30
38
|
Args:
|
|
@@ -40,8 +48,9 @@ def deobfuscate_file(input_path: str, output_path: str | None = None, max_iterat
|
|
|
40
48
|
|
|
41
49
|
result = deobfuscate(code, max_iterations=max_iterations)
|
|
42
50
|
|
|
43
|
-
if output_path:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
if not output_path:
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
with open(output_path, 'w') as output_file:
|
|
55
|
+
output_file.write(result)
|
|
56
|
+
return result != code
|
|
@@ -7,6 +7,7 @@ from . import deobfuscate
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def main() -> None:
|
|
10
|
+
"""Parse CLI arguments and run the deobfuscator."""
|
|
10
11
|
parser = argparse.ArgumentParser(description='Deobfuscate JavaScript files.')
|
|
11
12
|
parser.add_argument('input', help='Input JS file (use - for stdin)')
|
|
12
13
|
parser.add_argument('-o', '--output', help='Output file (default: stdout)')
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Multi-pass deobfuscation orchestrator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .generator import generate
|
|
6
|
+
from .parser import parse
|
|
7
|
+
from .scope import build_scope_tree
|
|
8
|
+
from .transforms.aa_decode import aa_decode
|
|
9
|
+
from .transforms.aa_decode import is_aa_encoded
|
|
10
|
+
from .transforms.anti_tamper import AntiTamperRemover
|
|
11
|
+
from .transforms.class_static_resolver import ClassStaticResolver
|
|
12
|
+
from .transforms.class_string_decoder import ClassStringDecoder
|
|
13
|
+
from .transforms.cleanup import EmptyIfRemover
|
|
14
|
+
from .transforms.cleanup import LetToConst
|
|
15
|
+
from .transforms.cleanup import OptionalCatchBinding
|
|
16
|
+
from .transforms.cleanup import ReturnUndefinedCleanup
|
|
17
|
+
from .transforms.cleanup import TrailingReturnRemover
|
|
18
|
+
from .transforms.cleanup import VarToConst
|
|
19
|
+
from .transforms.constant_prop import ConstantProp
|
|
20
|
+
from .transforms.control_flow import ControlFlowRecoverer
|
|
21
|
+
from .transforms.dead_branch import DeadBranchRemover
|
|
22
|
+
from .transforms.dead_class_props import DeadClassPropRemover
|
|
23
|
+
from .transforms.dead_expressions import DeadExpressionRemover
|
|
24
|
+
from .transforms.dead_object_props import DeadObjectPropRemover
|
|
25
|
+
from .transforms.else_if_flatten import ElseIfFlattener
|
|
26
|
+
from .transforms.enum_resolver import EnumResolver
|
|
27
|
+
from .transforms.eval_unpack import eval_unpack
|
|
28
|
+
from .transforms.eval_unpack import is_eval_packed
|
|
29
|
+
from .transforms.expression_simplifier import ExpressionSimplifier
|
|
30
|
+
from .transforms.global_alias import GlobalAliasInliner
|
|
31
|
+
from .transforms.hex_escapes import HexEscapes
|
|
32
|
+
from .transforms.hex_escapes import decode_hex_escapes_source
|
|
33
|
+
from .transforms.hex_numerics import HexNumerics
|
|
34
|
+
from .transforms.jj_decode import is_jj_encoded
|
|
35
|
+
from .transforms.jj_decode import jj_decode
|
|
36
|
+
from .transforms.jsfuck_decode import is_jsfuck
|
|
37
|
+
from .transforms.jsfuck_decode import jsfuck_decode
|
|
38
|
+
from .transforms.logical_to_if import LogicalToIf
|
|
39
|
+
from .transforms.member_chain_resolver import MemberChainResolver
|
|
40
|
+
from .transforms.noop_calls import NoopCallRemover
|
|
41
|
+
from .transforms.nullish_coalescing import NullishCoalescing
|
|
42
|
+
from .transforms.object_packer import ObjectPacker
|
|
43
|
+
from .transforms.object_simplifier import ObjectSimplifier
|
|
44
|
+
from .transforms.optional_chaining import OptionalChaining
|
|
45
|
+
from .transforms.property_simplifier import PropertySimplifier
|
|
46
|
+
from .transforms.proxy_functions import ProxyFunctionInliner
|
|
47
|
+
from .transforms.reassignment import ReassignmentRemover
|
|
48
|
+
from .transforms.require_inliner import RequireInliner
|
|
49
|
+
from .transforms.sequence_splitter import SequenceSplitter
|
|
50
|
+
from .transforms.single_use_vars import SingleUseVarInliner
|
|
51
|
+
from .transforms.string_revealer import StringRevealer
|
|
52
|
+
from .transforms.unreachable_code import UnreachableCodeRemover
|
|
53
|
+
from .transforms.unused_vars import UnusedVariableRemover
|
|
54
|
+
from .transforms.variable_renamer import VariableRenamer
|
|
55
|
+
from .transforms.xor_string_decode import XorStringDecoder
|
|
56
|
+
from .utils.ast_helpers import _CHILD_KEYS
|
|
57
|
+
from .utils.ast_helpers import get_child_keys
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_SCOPE_TRANSFORMS: frozenset[type] = frozenset(
|
|
61
|
+
{
|
|
62
|
+
ConstantProp,
|
|
63
|
+
SingleUseVarInliner,
|
|
64
|
+
ReassignmentRemover,
|
|
65
|
+
ProxyFunctionInliner,
|
|
66
|
+
UnusedVariableRemover,
|
|
67
|
+
ObjectSimplifier,
|
|
68
|
+
StringRevealer,
|
|
69
|
+
VariableRenamer,
|
|
70
|
+
VarToConst,
|
|
71
|
+
LetToConst,
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# StringRevealer runs first to decode string arrays before other transforms
|
|
76
|
+
# modify wrapper function structure. Remaining order follows obfuscator-io-deobfuscator.
|
|
77
|
+
TRANSFORM_CLASSES: list[type] = [
|
|
78
|
+
StringRevealer,
|
|
79
|
+
HexEscapes,
|
|
80
|
+
HexNumerics,
|
|
81
|
+
ClassStringDecoder,
|
|
82
|
+
XorStringDecoder,
|
|
83
|
+
MemberChainResolver,
|
|
84
|
+
DeadClassPropRemover,
|
|
85
|
+
ClassStaticResolver,
|
|
86
|
+
EnumResolver,
|
|
87
|
+
RequireInliner,
|
|
88
|
+
GlobalAliasInliner,
|
|
89
|
+
UnusedVariableRemover,
|
|
90
|
+
ConstantProp,
|
|
91
|
+
ReassignmentRemover,
|
|
92
|
+
SingleUseVarInliner,
|
|
93
|
+
DeadBranchRemover,
|
|
94
|
+
UnreachableCodeRemover,
|
|
95
|
+
NoopCallRemover,
|
|
96
|
+
EmptyIfRemover,
|
|
97
|
+
DeadObjectPropRemover,
|
|
98
|
+
ObjectPacker,
|
|
99
|
+
ProxyFunctionInliner,
|
|
100
|
+
SequenceSplitter,
|
|
101
|
+
DeadExpressionRemover,
|
|
102
|
+
ExpressionSimplifier,
|
|
103
|
+
NullishCoalescing,
|
|
104
|
+
OptionalChaining,
|
|
105
|
+
LogicalToIf,
|
|
106
|
+
ElseIfFlattener,
|
|
107
|
+
OptionalCatchBinding,
|
|
108
|
+
ReturnUndefinedCleanup,
|
|
109
|
+
TrailingReturnRemover,
|
|
110
|
+
ControlFlowRecoverer,
|
|
111
|
+
PropertySimplifier,
|
|
112
|
+
AntiTamperRemover,
|
|
113
|
+
ObjectSimplifier,
|
|
114
|
+
StringRevealer,
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
_EXPENSIVE_TRANSFORMS: frozenset[type] = frozenset({ControlFlowRecoverer, ProxyFunctionInliner, ObjectPacker})
|
|
118
|
+
|
|
119
|
+
_POST_PASS_TRANSFORMS: list[type] = [VariableRenamer, VarToConst, LetToConst]
|
|
120
|
+
|
|
121
|
+
# File-size thresholds
|
|
122
|
+
_LARGE_FILE_SIZE: int = 500_000 # 500 KB — reduce iterations
|
|
123
|
+
_MAX_CODE_SIZE: int = 2_000_000 # 2 MB — use lite mode
|
|
124
|
+
_LITE_MAX_ITERATIONS: int = 10
|
|
125
|
+
_NODE_COUNT_LIMIT: int = 50_000 # skip ControlFlowRecoverer above this
|
|
126
|
+
_VERY_LARGE_NODE_COUNT: int = 100_000 # cap iterations to 3
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _count_nodes(syntax_tree: dict) -> int:
|
|
130
|
+
"""Return the total number of nodes in *syntax_tree*.
|
|
131
|
+
|
|
132
|
+
Uses a direct iterative loop instead of simple_traverse to avoid
|
|
133
|
+
per-node callback overhead.
|
|
134
|
+
"""
|
|
135
|
+
child_keys_map = _CHILD_KEYS
|
|
136
|
+
_get_child_keys = get_child_keys
|
|
137
|
+
_dict = dict
|
|
138
|
+
_list = list
|
|
139
|
+
_type = type
|
|
140
|
+
|
|
141
|
+
count = 0
|
|
142
|
+
stack = [syntax_tree]
|
|
143
|
+
while stack:
|
|
144
|
+
node = stack.pop()
|
|
145
|
+
node_type = node.get('type')
|
|
146
|
+
if node_type is None:
|
|
147
|
+
continue
|
|
148
|
+
count += 1
|
|
149
|
+
child_keys = child_keys_map.get(node_type)
|
|
150
|
+
if child_keys is None:
|
|
151
|
+
child_keys = _get_child_keys(node)
|
|
152
|
+
for key in child_keys:
|
|
153
|
+
child = node.get(key)
|
|
154
|
+
if child is None:
|
|
155
|
+
continue
|
|
156
|
+
if _type(child) is _list:
|
|
157
|
+
for item in child:
|
|
158
|
+
if _type(item) is _dict and 'type' in item:
|
|
159
|
+
stack.append(item)
|
|
160
|
+
elif _type(child) is _dict and 'type' in child:
|
|
161
|
+
stack.append(child)
|
|
162
|
+
return count
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Deobfuscator:
|
|
166
|
+
"""Multi-pass JavaScript deobfuscator.
|
|
167
|
+
|
|
168
|
+
Applies a configurable sequence of AST transforms in a loop until the code
|
|
169
|
+
stabilises or *max_iterations* is reached, then runs cosmetic post-passes.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
_MAX_OUTER_CYCLES: int = 5
|
|
173
|
+
|
|
174
|
+
def __init__(self, code: str, max_iterations: int = 50) -> None:
|
|
175
|
+
self.original_code: str = code
|
|
176
|
+
self.max_iterations: int = max_iterations
|
|
177
|
+
|
|
178
|
+
def _run_pre_passes(self, code: str) -> str | None:
|
|
179
|
+
"""Detect whole-file encodings (JSFuck, AAEncode, etc.) and decode them.
|
|
180
|
+
|
|
181
|
+
Returns the decoded source when a known encoding is found, or ``None``
|
|
182
|
+
to continue with the normal AST pipeline.
|
|
183
|
+
"""
|
|
184
|
+
if is_jsfuck(code):
|
|
185
|
+
decoded = jsfuck_decode(code)
|
|
186
|
+
if decoded:
|
|
187
|
+
return decoded
|
|
188
|
+
|
|
189
|
+
if is_aa_encoded(code):
|
|
190
|
+
decoded = aa_decode(code)
|
|
191
|
+
if decoded:
|
|
192
|
+
return decoded
|
|
193
|
+
|
|
194
|
+
if is_jj_encoded(code):
|
|
195
|
+
decoded = jj_decode(code)
|
|
196
|
+
if decoded:
|
|
197
|
+
return decoded
|
|
198
|
+
|
|
199
|
+
if is_eval_packed(code):
|
|
200
|
+
decoded = eval_unpack(code)
|
|
201
|
+
if decoded:
|
|
202
|
+
return decoded
|
|
203
|
+
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
def execute(self) -> str:
|
|
207
|
+
"""Run all deobfuscation passes and return cleaned JavaScript source."""
|
|
208
|
+
code = self.original_code
|
|
209
|
+
|
|
210
|
+
decoded = self._run_pre_passes(code)
|
|
211
|
+
if decoded:
|
|
212
|
+
recursive_deobfuscator = Deobfuscator(decoded, max_iterations=self.max_iterations)
|
|
213
|
+
return recursive_deobfuscator.execute()
|
|
214
|
+
|
|
215
|
+
syntax_tree = self._try_parse_or_fallback(code)
|
|
216
|
+
if isinstance(syntax_tree, str):
|
|
217
|
+
return syntax_tree
|
|
218
|
+
|
|
219
|
+
return self._transform_loop(syntax_tree, code)
|
|
220
|
+
|
|
221
|
+
def _try_parse_or_fallback(self, code: str) -> dict | str:
|
|
222
|
+
"""Parse *code* into an AST, falling back to hex-decode on failure.
|
|
223
|
+
|
|
224
|
+
Returns the parsed AST dict on success, or a decoded/original source
|
|
225
|
+
string when parsing fails.
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
return parse(code)
|
|
229
|
+
except SyntaxError:
|
|
230
|
+
decoded = decode_hex_escapes_source(code)
|
|
231
|
+
if decoded != code:
|
|
232
|
+
return decoded
|
|
233
|
+
return self.original_code
|
|
234
|
+
|
|
235
|
+
def _transform_loop(self, syntax_tree: dict, code: str) -> str:
|
|
236
|
+
"""Run the outer generate-reparse convergence loop and post-passes.
|
|
237
|
+
|
|
238
|
+
Returns the best deobfuscated source produced.
|
|
239
|
+
"""
|
|
240
|
+
previous_code = code
|
|
241
|
+
last_changed_tree: dict | None = None
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
for _cycle in range(self._MAX_OUTER_CYCLES):
|
|
245
|
+
changed = self._run_ast_transforms(
|
|
246
|
+
syntax_tree,
|
|
247
|
+
code_size=len(previous_code),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if not changed:
|
|
251
|
+
break
|
|
252
|
+
|
|
253
|
+
last_changed_tree = syntax_tree
|
|
254
|
+
generated = self._try_generate(syntax_tree)
|
|
255
|
+
if generated is None or generated == previous_code:
|
|
256
|
+
break
|
|
257
|
+
|
|
258
|
+
previous_code = generated
|
|
259
|
+
try:
|
|
260
|
+
syntax_tree = parse(generated)
|
|
261
|
+
except SyntaxError:
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
any_post_changed = self._run_post_passes(syntax_tree)
|
|
265
|
+
|
|
266
|
+
if last_changed_tree is None and not any_post_changed:
|
|
267
|
+
return self.original_code
|
|
268
|
+
|
|
269
|
+
return self._try_generate(syntax_tree) or previous_code
|
|
270
|
+
except RecursionError:
|
|
271
|
+
# Deeply nested JS can exceed Python's recursion limit during
|
|
272
|
+
# parsing or AST walking. Return best result so far.
|
|
273
|
+
return previous_code
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _try_generate(syntax_tree: dict) -> str | None:
|
|
277
|
+
"""Generate source from *syntax_tree*, returning ``None`` on failure."""
|
|
278
|
+
try:
|
|
279
|
+
return generate(syntax_tree)
|
|
280
|
+
except Exception:
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _run_post_passes(syntax_tree: dict) -> bool:
|
|
285
|
+
"""Run cosmetic post-passes (renaming, var-to-const).
|
|
286
|
+
|
|
287
|
+
Returns ``True`` if any post-pass modified the AST.
|
|
288
|
+
"""
|
|
289
|
+
any_changed = False
|
|
290
|
+
for post_transform_class in _POST_PASS_TRANSFORMS:
|
|
291
|
+
try:
|
|
292
|
+
if post_transform_class(syntax_tree).execute():
|
|
293
|
+
any_changed = True
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
return any_changed
|
|
297
|
+
|
|
298
|
+
def _run_ast_transforms(self, syntax_tree: dict, code_size: int = 0) -> bool:
|
|
299
|
+
"""Run all AST transform passes.
|
|
300
|
+
|
|
301
|
+
Returns ``True`` if any transform modified the AST.
|
|
302
|
+
"""
|
|
303
|
+
node_count = _count_nodes(syntax_tree) if code_size > _LARGE_FILE_SIZE else 0
|
|
304
|
+
iteration_limit = self._compute_iteration_limit(code_size, node_count)
|
|
305
|
+
active_transforms = self._select_transforms(code_size, node_count)
|
|
306
|
+
|
|
307
|
+
skipped_transforms: set[type] = set()
|
|
308
|
+
|
|
309
|
+
scope_tree: dict | None = None
|
|
310
|
+
node_scope: dict | None = None
|
|
311
|
+
scope_dirty: bool = True
|
|
312
|
+
|
|
313
|
+
any_transform_changed = False
|
|
314
|
+
for iteration in range(iteration_limit):
|
|
315
|
+
modified = False
|
|
316
|
+
for transform_class in active_transforms:
|
|
317
|
+
if transform_class in skipped_transforms:
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
result, scope_tree, node_scope, scope_dirty = self._execute_single_transform(
|
|
321
|
+
syntax_tree,
|
|
322
|
+
transform_class,
|
|
323
|
+
scope_tree,
|
|
324
|
+
node_scope,
|
|
325
|
+
scope_dirty,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
if result is None:
|
|
329
|
+
continue
|
|
330
|
+
if result:
|
|
331
|
+
modified = True
|
|
332
|
+
any_transform_changed = True
|
|
333
|
+
elif iteration > 0:
|
|
334
|
+
skipped_transforms.add(transform_class)
|
|
335
|
+
|
|
336
|
+
if not modified:
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
return any_transform_changed
|
|
340
|
+
|
|
341
|
+
def _compute_iteration_limit(self, code_size: int, node_count: int) -> int:
|
|
342
|
+
"""Determine the maximum iteration count based on file/AST size."""
|
|
343
|
+
limit = self.max_iterations
|
|
344
|
+
if code_size > _LARGE_FILE_SIZE:
|
|
345
|
+
limit = min(limit, _LITE_MAX_ITERATIONS)
|
|
346
|
+
if node_count > _VERY_LARGE_NODE_COUNT:
|
|
347
|
+
limit = min(limit, 3)
|
|
348
|
+
return limit
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def _select_transforms(code_size: int, node_count: int) -> list[type]:
|
|
352
|
+
"""Return the transform list, excluding expensive ones for large inputs."""
|
|
353
|
+
if code_size > _MAX_CODE_SIZE or node_count > _NODE_COUNT_LIMIT:
|
|
354
|
+
return [transform for transform in TRANSFORM_CLASSES if transform not in _EXPENSIVE_TRANSFORMS]
|
|
355
|
+
return TRANSFORM_CLASSES
|
|
356
|
+
|
|
357
|
+
@staticmethod
|
|
358
|
+
def _execute_single_transform(
|
|
359
|
+
syntax_tree: dict,
|
|
360
|
+
transform_class: type,
|
|
361
|
+
scope_tree: dict | None,
|
|
362
|
+
node_scope: dict | None,
|
|
363
|
+
scope_dirty: bool,
|
|
364
|
+
) -> tuple[bool | None, dict | None, dict | None, bool]:
|
|
365
|
+
"""Run a single transform, rebuilding scope lazily as needed.
|
|
366
|
+
|
|
367
|
+
Returns ``(result, scope_tree, node_scope, scope_dirty)`` where
|
|
368
|
+
*result* is ``True``/``False`` for success/no-change, or ``None``
|
|
369
|
+
if the transform raised an exception.
|
|
370
|
+
"""
|
|
371
|
+
try:
|
|
372
|
+
if transform_class in _SCOPE_TRANSFORMS and scope_dirty:
|
|
373
|
+
scope_tree, node_scope = build_scope_tree(syntax_tree)
|
|
374
|
+
scope_dirty = False
|
|
375
|
+
|
|
376
|
+
if transform_class in _SCOPE_TRANSFORMS:
|
|
377
|
+
transform = transform_class(
|
|
378
|
+
syntax_tree,
|
|
379
|
+
scope_tree=scope_tree,
|
|
380
|
+
node_scope=node_scope,
|
|
381
|
+
)
|
|
382
|
+
else:
|
|
383
|
+
transform = transform_class(syntax_tree)
|
|
384
|
+
|
|
385
|
+
result = transform.execute()
|
|
386
|
+
except Exception:
|
|
387
|
+
return None, scope_tree, node_scope, scope_dirty
|
|
388
|
+
|
|
389
|
+
if result:
|
|
390
|
+
scope_dirty = True
|
|
391
|
+
return result, scope_tree, node_scope, scope_dirty
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""ESTree AST to JavaScript code generator."""
|
|
2
|
+
|
|
2
3
|
from __future__ import annotations
|
|
3
4
|
|
|
5
|
+
|
|
4
6
|
# Operator precedence (higher = binds tighter)
|
|
5
7
|
_PRECEDENCE = {
|
|
6
8
|
'=': 3,
|
|
@@ -72,9 +74,9 @@ def generate(node: dict | None, indent: int = 0) -> str:
|
|
|
72
74
|
return str(node)
|
|
73
75
|
|
|
74
76
|
node_type = node.get('type', '')
|
|
75
|
-
|
|
76
|
-
if
|
|
77
|
-
return
|
|
77
|
+
generator_function = _GENERATORS.get(node_type)
|
|
78
|
+
if generator_function:
|
|
79
|
+
return generator_function(node, indent)
|
|
78
80
|
return f'/* unknown: {node_type} */'
|
|
79
81
|
|
|
80
82
|
|
|
@@ -153,12 +155,12 @@ def _gen_function(node: dict, indent: int, is_expression: bool = False) -> str:
|
|
|
153
155
|
name = generate(node['id'], indent) if node.get('id') else ''
|
|
154
156
|
params = ', '.join(generate(param, indent) for param in node.get('params', []))
|
|
155
157
|
async_prefix = 'async ' if node.get('async') else ''
|
|
156
|
-
|
|
158
|
+
generator_prefix = '*' if node.get('generator') else ''
|
|
157
159
|
body = generate(node['body'], indent)
|
|
158
160
|
if name:
|
|
159
|
-
return f'{async_prefix}function{
|
|
161
|
+
return f'{async_prefix}function{generator_prefix} {name}({params}) {body}'
|
|
160
162
|
# Anonymous: always put space before parens (Babel style)
|
|
161
|
-
return f'{async_prefix}function{
|
|
163
|
+
return f'{async_prefix}function{generator_prefix} ({params}) {body}'
|
|
162
164
|
|
|
163
165
|
|
|
164
166
|
def _gen_function_decl(node: dict, indent: int) -> str:
|
|
@@ -172,13 +174,13 @@ def _gen_function_expr(node: dict, indent: int) -> str:
|
|
|
172
174
|
def _gen_arrow(node: dict, indent: int) -> str:
|
|
173
175
|
params = node.get('params', [])
|
|
174
176
|
async_prefix = 'async ' if node.get('async') else ''
|
|
175
|
-
|
|
177
|
+
parameter_string = '(' + ', '.join(generate(param, indent) for param in params) + ')'
|
|
176
178
|
body = node.get('body', {})
|
|
177
|
-
|
|
179
|
+
body_string = generate(body, indent)
|
|
178
180
|
# Wrap object literal in parens to avoid ambiguity with block
|
|
179
181
|
if body.get('type') == 'ObjectExpression':
|
|
180
|
-
|
|
181
|
-
return f'{async_prefix}{
|
|
182
|
+
body_string = '(' + body_string + ')'
|
|
183
|
+
return f'{async_prefix}{parameter_string} => {body_string}'
|
|
182
184
|
|
|
183
185
|
|
|
184
186
|
def _gen_return(node: dict, indent: int) -> str:
|
|
@@ -344,13 +346,13 @@ def _gen_assignment(node: dict, indent: int) -> str:
|
|
|
344
346
|
|
|
345
347
|
def _gen_member(node: dict, indent: int) -> str:
|
|
346
348
|
object_code = generate(node['object'], indent)
|
|
347
|
-
|
|
349
|
+
object_type = node['object'].get('type', '')
|
|
348
350
|
computed = node.get('computed')
|
|
349
351
|
|
|
350
352
|
needs_parens = False
|
|
351
|
-
if
|
|
353
|
+
if object_type == 'Literal' and isinstance(node['object'].get('value'), (int, float)):
|
|
352
354
|
needs_parens = not computed
|
|
353
|
-
elif
|
|
355
|
+
elif object_type in (
|
|
354
356
|
'BinaryExpression',
|
|
355
357
|
'UnaryExpression',
|
|
356
358
|
'ConditionalExpression',
|
|
@@ -364,12 +366,12 @@ def _gen_member(node: dict, indent: int) -> str:
|
|
|
364
366
|
object_code = f'({object_code})'
|
|
365
367
|
|
|
366
368
|
property_code = generate(node['property'], indent)
|
|
367
|
-
|
|
369
|
+
accessor = '?.' if node.get('optional') else '.'
|
|
368
370
|
if computed:
|
|
369
371
|
if node.get('optional'):
|
|
370
372
|
return f'{object_code}?.[{property_code}]'
|
|
371
373
|
return f'{object_code}[{property_code}]'
|
|
372
|
-
return f'{object_code}{
|
|
374
|
+
return f'{object_code}{accessor}{property_code}'
|
|
373
375
|
|
|
374
376
|
|
|
375
377
|
def _gen_call(node: dict, indent: int) -> str:
|
|
@@ -377,18 +379,18 @@ def _gen_call(node: dict, indent: int) -> str:
|
|
|
377
379
|
callee_type = node['callee'].get('type', '')
|
|
378
380
|
if callee_type in ('FunctionExpression', 'ArrowFunctionExpression', 'SequenceExpression'):
|
|
379
381
|
callee = f'({callee})'
|
|
380
|
-
|
|
382
|
+
argument_string = ', '.join(generate(argument, indent) for argument in node.get('arguments', []))
|
|
381
383
|
if node.get('optional'):
|
|
382
|
-
return f'{callee}?.({
|
|
383
|
-
return f'{callee}({
|
|
384
|
+
return f'{callee}?.({argument_string})'
|
|
385
|
+
return f'{callee}({argument_string})'
|
|
384
386
|
|
|
385
387
|
|
|
386
388
|
def _gen_new(node: dict, indent: int) -> str:
|
|
387
389
|
callee = generate(node['callee'], indent)
|
|
388
|
-
|
|
389
|
-
if
|
|
390
|
-
|
|
391
|
-
return f'new {callee}({
|
|
390
|
+
arguments = node.get('arguments', [])
|
|
391
|
+
if arguments:
|
|
392
|
+
argument_string = ', '.join(generate(argument, indent) for argument in arguments)
|
|
393
|
+
return f'new {callee}({argument_string})'
|
|
392
394
|
return f'new {callee}()'
|
|
393
395
|
|
|
394
396
|
|
|
@@ -407,14 +409,13 @@ def _gen_conditional(node: dict, indent: int) -> str:
|
|
|
407
409
|
|
|
408
410
|
|
|
409
411
|
def _gen_sequence(node: dict, indent: int) -> str:
|
|
410
|
-
|
|
411
|
-
return exprs
|
|
412
|
+
return ', '.join(generate(expression, indent) for expression in node.get('expressions', []))
|
|
412
413
|
|
|
413
414
|
|
|
414
415
|
def _gen_bracket_list(elements: list, indent: int) -> str:
|
|
415
416
|
"""Generate a bracketed list of elements, replacing None with empty slots."""
|
|
416
|
-
|
|
417
|
-
return '[' + ', '.join(
|
|
417
|
+
generated_elements = [generate(element, indent) if element is not None else '' for element in elements]
|
|
418
|
+
return '[' + ', '.join(generated_elements) + ']'
|
|
418
419
|
|
|
419
420
|
|
|
420
421
|
def _gen_array(node: dict, indent: int) -> str:
|
|
@@ -568,8 +569,8 @@ def _gen_method_def(node: dict, indent: int) -> str:
|
|
|
568
569
|
return f'{static_prefix}set {key}({params}) {body}'
|
|
569
570
|
case _:
|
|
570
571
|
async_prefix = 'async ' if value.get('async') else ''
|
|
571
|
-
|
|
572
|
-
return f'{static_prefix}{async_prefix}{
|
|
572
|
+
generator_prefix = '*' if value.get('generator') else ''
|
|
573
|
+
return f'{static_prefix}{async_prefix}{generator_prefix}{key}({params}) {body}'
|
|
573
574
|
|
|
574
575
|
|
|
575
576
|
def _gen_yield(node: dict, indent: int) -> str:
|
|
@@ -639,9 +640,9 @@ def _gen_import_declaration(node: dict, indent: int) -> str:
|
|
|
639
640
|
specifiers = node.get('specifiers', [])
|
|
640
641
|
if not specifiers:
|
|
641
642
|
return f'import {source}'
|
|
642
|
-
default_specifiers = [
|
|
643
|
-
namespace_specifiers = [
|
|
644
|
-
named_specifiers = [
|
|
643
|
+
default_specifiers = [spec for spec in specifiers if spec.get('type') == 'ImportDefaultSpecifier']
|
|
644
|
+
namespace_specifiers = [spec for spec in specifiers if spec.get('type') == 'ImportNamespaceSpecifier']
|
|
645
|
+
named_specifiers = [spec for spec in specifiers if spec.get('type') == 'ImportSpecifier']
|
|
645
646
|
parts = []
|
|
646
647
|
if default_specifiers:
|
|
647
648
|
parts.append(_gen_import_specifier(default_specifiers[0], indent))
|