-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPyDeobfuscate.py
More file actions
402 lines (318 loc) · 15.6 KB
/
Copy pathPyDeobfuscate.py
File metadata and controls
402 lines (318 loc) · 15.6 KB
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
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
#!/usr/bin/env python3
"""
* ================================================== *
*
* Pyobfuscate.com Deobfuscator
* Coded By : K00HYAR
* Date : 2026/07/27 - 2:52:12.32
*
* pyobfuscate.com output comes in a few different shapes depending on
* version/tool, each handled by its own strategy below:
*
* - a `pyobfuscate(pyc=..., pye=...)` call - base85 halves of an
* AES-256-GCM ciphertext keyed by PBKDF2.
* - a `pyobfuscate = <literal>` / `obfuscate = <literal>` assignment -
* an eval()'d structure hiding one of three AES-CBC/CFB sub-variants.
* - a `bytes.fromhex(...)` blob - zlib + a runtime-built AST + base85.
*
* Adding a future format means writing one new ObfuscationLayer subclass,
* not touching Deobfuscator itself.
* ================================================== *
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from argparse import (
ArgumentParser,
RawDescriptionHelpFormatter
)
from ast import unparse as UnparseAST
from base64 import b85decode as Base85Decode
from hashlib import (
sha256 as Sha256,
pbkdf2_hmac as Pbkdf2Hmac
)
from logging import (
basicConfig as LoggingConfig,
getLogger as GetLogger,
DEBUG as LOGGING_DEBUG,
INFO as LOGGING_INFO
)
from re import (
compile as RegexCompile,
DOTALL as DotAllMode
)
from sys import (
setrecursionlimit as SetRecursionLimit,
exit as ExitProgram
)
from zlib import decompress as ZlibDecompress
from pathlib import Path
from typing import (
Dict,
List,
Optional
)
from Crypto.Cipher import AES
from Crypto.Protocol.KDF import PBKDF2
from Crypto.Util.Padding import unpad as Unpad
__LOGGER__ = GetLogger(__name__)
__VERSION__ = "1.4.0"
class DeobfuscationError(Exception):
"""Incase anything goes wrong :0"""
class ObfuscationLayer(ABC):
"""Common interface every pyobfuscate.com layer strategy must implement."""
__NAME__: str = "unnamed"
@abstractmethod
def __Matches__(self, content: str) -> bool:
"""Return True if `content` looks like this layer's obfuscation format."""
@abstractmethod
def __Decode__(self, content: str) -> str:
"""Reverse this layer's obfuscation and return the original source."""
class AesGcmLayer(ObfuscationLayer):
"""The `pyobfuscate(pyc=..., pye=...)` layer: AES-256-GCM keyed by PBKDF2."""
__NAME__: str = "aes-gcm"
__SALT_END__: int = 0x10
__NONCE_END__: int = 0x20
__TAG_END__: int = 0x30
__KEY_LENGTH__: int = 0x20
__KDF_ITERATIONS__: int = 0xF4240
__PYC_PATTERN__ = RegexCompile(r"'pyc'\s*:\s*\"\"\"(.*?)\"\"\"", DotAllMode)
__PYE_PATTERN__ = RegexCompile(r"'pye'\s*:\s*\"\"\"(.*?)\"\"\"", DotAllMode)
__KEY_PATTERN__ = RegexCompile(r"['\"]([lI]+)['\"]", DotAllMode)
def __Matches__(self, content: str) -> bool:
return "pyobfuscate(" in content
def __Decode__(self, content: str) -> str:
for line in content.splitlines(keepends=True):
if not (line.strip().startswith("pyobfuscate(")):
continue
pyc_match = self.__PYC_PATTERN__.search(line)
pye_match = self.__PYE_PATTERN__.search(line)
key_match = self.__KEY_PATTERN__.search(line)
if not ((pyc_match and pye_match and key_match)):
raise DeobfuscationError("malformed pyobfuscate(...) call: missing pyc/pye/key segment")
payload = pyc_match.group(1) + pye_match.group(1)
password = key_match.group(0).replace('"', "")
return self.__DecryptAesGCM__(payload, password)
raise DeobfuscationError("no pyobfuscate(...) call found in the given file")
@classmethod
def __DecryptAesGCM__(cls, payload_b85: str, password: str) -> str:
raw = Base85Decode(payload_b85.encode("utf-8"))
salt = raw[:cls.__SALT_END__]
nonce = raw[cls.__SALT_END__:cls.__NONCE_END__]
tag = raw[cls.__NONCE_END__:cls.__TAG_END__]
ciphertext = raw[cls.__TAG_END__:]
key = PBKDF2(password, salt, dkLen=cls.__KEY_LENGTH__, count=cls.__KDF_ITERATIONS__)
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
return cipher.decrypt_and_verify(ciphertext, tag).decode("utf-8")
class EvalAssignmentLayer(ObfuscationLayer):
"""
The `pyobfuscate = <literal>` / `obfuscate = <literal>` layer.
The assignment's value, once eval()'d, unpacks into one of three
AES sub-variants that were reverse-engineered from real samples and
have no reliable static signature telling them apart - so all three
are attempted in turn, and whichever one doesn't raise wins.
"""
__NAME__: str = "eval-assignment"
__ASSIGNMENT_PATTERN__ = RegexCompile(r"^\s*(?:pyobfuscate|obfuscate)\s*=\s*(.*)")
___SECOND_ASSIGNMENT_PATTERN__ = RegexCompile(r"^\s*\w+\s*=")
__CBC_KEY_LEN__: int = 0x18 # 24 bytes -> AES-192
__KDF_ITERATIONS__: int = 0x186A0 # 100,000
__SALT_LEN__: int = 0x08
__CFB_KEY_LEN__: int = 0x10 # 16 bytes -> AES-128
def __Matches__(self, content: str) -> bool:
return any(self.__ASSIGNMENT_PATTERN__.match(line) for line in content.splitlines())
def __Decode__(self, content: str) -> str:
literal_text = self.__ExtractLiteral__(content)
if (literal_text is None):
raise DeobfuscationError("no pyobfuscated assignment found in the given file")
try:
value = eval(literal_text)
except Exception as error:
raise DeobfuscationError(f"could not evaluate the embedded literal: {error}") from error
for variant in (self.__DecryptVariantA__, self.__DecryptVariantB__, self.__DecryptVariantC__):
try:
return variant(value)
except Exception:
continue
raise DeobfuscationError("none of the methods could decrypt the code")
@classmethod
def __ExtractLiteral__(cls, content: str) -> Optional[str]:
fragments: List[str] = []
collecting = False
for line in content.splitlines():
if (collecting):
if (line.strip() == "" or cls.___SECOND_ASSIGNMENT_PATTERN__.match(line.strip())):
break
fragments.append(line.strip())
continue
match = cls.__ASSIGNMENT_PATTERN__.match(line)
if (match):
fragments.append(match.group(1).strip())
collecting = True
return " ".join(fragments) if fragments else None
def __DecryptVariantA__(self, value) -> str:
"""Password = value[0][0] + value[1][0] (full string). AES-CBC blob at value[1][2]."""
password = str(list(value)[0][0] + list(value)[1][0])
return self.__DecryptCBC__(value[1][2], password)
def __DecryptVariantB__(self, value) -> str:
"""Same as variant A, but the second password fragment drops its last character."""
password = str(list(value)[0][0] + list(value)[1][0][:-1])
return self.__DecryptCBC__(value[1][2], password)
@classmethod
def __DecryptCBC__(cls, blob: bytes, password: str) -> str:
key = Sha256(password.encode()).digest()[:cls.__CBC_KEY_LEN__]
cipher = AES.new(key, AES.MODE_CBC, blob[:AES.block_size])
return Unpad(cipher.decrypt(blob[AES.block_size:]), AES.block_size).decode()
@classmethod
def __DecryptVariantC__(cls, value) -> str:
"""Dict-shaped value: base85 blob keyed by a password with one decoy char per side."""
blob_b85 = list(value.values())[0]
password = list(value.keys())[0][1:-1]
raw = Base85Decode(blob_b85.encode("utf-8"))
salt, ciphertext = raw[:cls.__SALT_LEN__], raw[cls.__SALT_LEN__:]
digest = Pbkdf2Hmac("sha256", password.encode(), salt, cls.__KDF_ITERATIONS__)
key, iv = digest[:cls.__CFB_KEY_LEN__], digest[cls.__CFB_KEY_LEN__:]
return AES.new(key, AES.MODE_CFB, iv).decrypt(ciphertext).decode()
class ZlibAstLayer(ObfuscationLayer):
"""The `bytes.fromhex(...)` layer: zlib + a runtime-built AST + base85."""
__NAME__: str = "zlib-ast"
__HEX_PATTERN__ = RegexCompile(r"fromhex\('([0-9a-fA-F]+)'(?!\))")
__B85_PATTERN__ = RegexCompile(r"\.b85decode\('([^']+)'\.encode\(\)\)")
__PLH_PATTERN__ = RegexCompile(r"(\w+)\s*=\s*None")
__RECURSION_LIMIT__: int = 0x5F5E100
def __Matches__(self, content: str) -> bool:
return bool(self.__HEX_PATTERN__.search(content))
def __Decode__(self, content: str) -> str:
hex_matches = self.__HEX_PATTERN__.findall(content)
if not (hex_matches):
raise DeobfuscationError("no recognizable obfuscation layer found in file")
decompressed = ZlibDecompress(bytes.fromhex(hex_matches[0])).decode()
stage_source = ";".join(decompressed.split(";")[:-1])
SetRecursionLimit(self.__RECURSION_LIMIT__)
placeholder_matches = self.__PLH_PATTERN__.findall(stage_source)
if not (placeholder_matches):
raise DeobfuscationError("could not locate the intermediate placeholder variable")
placeholder_name = placeholder_matches[0]
namespace: Dict[str, object] = {}
exec(stage_source, namespace)
reconstructed_source = UnparseAST(eval(placeholder_name, namespace))
b85_matches = self.__B85_PATTERN__.findall(reconstructed_source)
if (not b85_matches):
raise DeobfuscationError("could not locate the base85-encoded payload")
return Base85Decode(b85_matches[0].encode()).decode()
class Deobfuscator:
"""
Tries each registered ObfuscationLayer strategy in turn and uses the
first one whose `__Matches__()` call returns True - or, if a specific
layer name was requested, skips detection and calls that one directly.
`Deobfuscator()(path)` and `Deobfuscator().process_file(path)` are
equivalent; the CLI below is a thin wrapper that fans this out over
however many files were passed on the command line.
"""
__OUTPUT_SUFFIX__: str = "_Deobfuscated"
__LAYERS__: List[ObfuscationLayer] = [AesGcmLayer(), EvalAssignmentLayer(), ZlibAstLayer()]
def __init__(self, output_path: Optional[str] = None, layer_name: Optional[str] = None) -> None:
self.__output_override__ = output_path
self.__layer_name__: Optional[str] = layer_name
self.__source_path__: Optional[str] = None
self.__output_path__: Optional[str] = None
self.__result__: Optional[str] = None
def __call__(self, source_path: str, output_path: Optional[str] = None) -> str:
return self.process_file(source_path, output_path)
def __repr__(self) -> str:
return f"<{type(self).__name__} source={self.__source_path__!r} output={self.__output_path__!r}>"
def process_file(self, source_path: str, output_path: Optional[str] = None) -> str:
resolved_output = output_path or self.__output_override__ or self.__BuildOutput__(source_path)
raw_content = self.__ReadSourceFile__(source_path)
self.__result__ = self.__Deobfuscate__(raw_content)
self.__WriteOutput__(resolved_output, self.__result__)
self.__source_path__, self.__output_path__ = source_path, resolved_output
__LOGGER__.info("%s -> %s", source_path, resolved_output)
return resolved_output
@staticmethod
def __ReadSourceFile__(path: str) -> str:
with open(path, "r", encoding="utf-8") as file_handle:
return file_handle.read()
@staticmethod
def __WriteOutput__(path: str, content: str) -> None:
Path(path).parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as file_handle:
file_handle.write(content)
@classmethod
def __BuildOutput__(cls, source_path: str, output_dir: Optional[str] = None, suffix: Optional[str] = None) -> str:
source = Path(source_path)
extension = source.suffix or ".py"
filename = f"{source.stem}{suffix or cls.__OUTPUT_SUFFIX__}{extension}"
directory = Path(output_dir) if output_dir else source.parent
return str(directory / filename)
def __Deobfuscate__(self, content: str) -> str:
if (self.__layer_name__):
for layer in self.__LAYERS__:
if (layer.__NAME__ == self.__layer_name__):
return layer.__Decode__(content)
raise DeobfuscationError(f"unknown method '{self.__layer_name__}'")
for layer in self.__LAYERS__:
if (layer.__Matches__(content)):
return layer.__Decode__(content)
raise DeobfuscationError("no recognizable pyobfuscate.com layer found in file")
def Parser() -> ArgumentParser:
parser = ArgumentParser(
prog="PyDeobfuscate",
description="Reverses pyobfuscate.com's AES-GCM, eval-assignment, and zlib/base85 obfuscation.",
formatter_class=RawDescriptionHelpFormatter,
epilog=(
"examples:\n"
" %(prog)s sample.py\n"
" %(prog)s *.py -o decoded/ -v\n"
" %(prog)s a.py b.py -s _plain\n"
" %(prog)s legacy.py -m eval-assignment\n"
),
)
parser.add_argument(
"files", nargs="+", metavar="FILE",
help="one or more obfuscated source files",
)
parser.add_argument(
"-o", "--output-dir", metavar="DIR",
help="write all output files into DIR instead of next to their source",
)
parser.add_argument(
"-s", "--suffix", default=Deobfuscator.__OUTPUT_SUFFIX__, metavar="SUFFIX",
help="suffix inserted before the extension of each output file (default: %(default)s)",
)
parser.add_argument(
"-m", "--method", choices=[layer.__NAME__ for layer in Deobfuscator.__LAYERS__], default=None,
help="force a specific layer instead of auto-detecting (default: try each layer in turn)",
)
parser.add_argument(
"-v", "--verbose", action="store_true",
help="enable debug-level logging",
)
parser.add_argument("--version", action="version", version=f"%(prog)s {__VERSION__}")
return parser
def Configure(verbose: bool) -> None:
if (verbose):
LoggingConfig(level=LOGGING_DEBUG, format="%(asctime)s %(levelname)-7s %(message)s", datefmt="%H:%M:%S")
else:
LoggingConfig(level=LOGGING_INFO, format="%(levelname)s: %(message)s")
def Run(paths: List[str], output_dir: Optional[str], suffix: str, method: Optional[str]) -> int:
deobfuscator = Deobfuscator(layer_name=method)
failures = 0
for path in paths:
try:
output_path = Deobfuscator.__BuildOutput__(path, output_dir=output_dir, suffix=suffix)
deobfuscator.process_file(path, output_path=output_path)
except (DeobfuscationError, ValueError, OSError) as error:
__LOGGER__.error("%s: %s", path, error)
failures += 1
return failures
def main() -> None:
args = Parser().parse_args()
Configure(args.verbose)
failures = Run(args.files, args.output_dir, args.suffix, args.method)
total = len(args.files)
__LOGGER__.info("done: %d/%d succeeded", total - failures, total)
if (failures):
ExitProgram(1)
if (__name__ == "__main__"):
main()