tentod 0.1.0__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.
- tentod-0.1.0/PKG-INFO +37 -0
- tentod-0.1.0/README.md +6 -0
- tentod-0.1.0/pyproject.toml +3 -0
- tentod-0.1.0/setup.cfg +4 -0
- tentod-0.1.0/setup.py +33 -0
- tentod-0.1.0/tentod/GUIDE.md +53 -0
- tentod-0.1.0/tentod/__init__.py +1 -0
- tentod-0.1.0/tentod/__main__.py +81 -0
- tentod-0.1.0/tentod/_runtime.py +21 -0
- tentod-0.1.0/tentod/assembly.py +134 -0
- tentod-0.1.0/tentod/decompiler.py +59 -0
- tentod-0.1.0/tentod/deobfuscator.py +50 -0
- tentod-0.1.0/tentod/facts.py +51 -0
- tentod-0.1.0/tentod/pipeline.py +61 -0
- tentod-0.1.0/tentod/verify.py +45 -0
- tentod-0.1.0/tentod.egg-info/PKG-INFO +37 -0
- tentod-0.1.0/tentod.egg-info/SOURCES.txt +25 -0
- tentod-0.1.0/tentod.egg-info/dependency_links.txt +1 -0
- tentod-0.1.0/tentod.egg-info/requires.txt +2 -0
- tentod-0.1.0/tentod.egg-info/top_level.txt +1 -0
- tentod-0.1.0/tests/test_assembly.py +109 -0
- tentod-0.1.0/tests/test_decompiler.py +86 -0
- tentod-0.1.0/tests/test_deobfuscator.py +46 -0
- tentod-0.1.0/tests/test_facts.py +44 -0
- tentod-0.1.0/tests/test_main.py +115 -0
- tentod-0.1.0/tests/test_pipeline.py +94 -0
- tentod-0.1.0/tests/test_verify.py +56 -0
tentod-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tentod
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: tentod
|
|
5
|
+
Home-page: https://github.com/rijulg/tentod
|
|
6
|
+
Author: Rijul Gupta
|
|
7
|
+
Author-email: rijulg@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: dotnet
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Python: >=3.13
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: class-argparse==0.1.4
|
|
19
|
+
Requires-Dist: pythonnet==3.1.0
|
|
20
|
+
Dynamic: author
|
|
21
|
+
Dynamic: author-email
|
|
22
|
+
Dynamic: classifier
|
|
23
|
+
Dynamic: description
|
|
24
|
+
Dynamic: description-content-type
|
|
25
|
+
Dynamic: home-page
|
|
26
|
+
Dynamic: keywords
|
|
27
|
+
Dynamic: license
|
|
28
|
+
Dynamic: requires-dist
|
|
29
|
+
Dynamic: requires-python
|
|
30
|
+
Dynamic: summary
|
|
31
|
+
|
|
32
|
+
# tentod
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
python -m tentod recover <assembly.dll>
|
|
36
|
+
python -m tentod guide # workflow for automated/LLM use
|
|
37
|
+
```
|
tentod-0.1.0/README.md
ADDED
tentod-0.1.0/setup.cfg
ADDED
tentod-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import setuptools
|
|
3
|
+
|
|
4
|
+
with open("README.md", "r") as readme:
|
|
5
|
+
long_description = readme.read()
|
|
6
|
+
|
|
7
|
+
setuptools.setup(
|
|
8
|
+
name="tentod",
|
|
9
|
+
packages=setuptools.find_namespace_packages(include=["tentod", "tentod.*"]),
|
|
10
|
+
package_data={"tentod": ["GUIDE.md"]},
|
|
11
|
+
version=os.environ.get("RELEASE_VERSION", "0.0.0"),
|
|
12
|
+
license="MIT",
|
|
13
|
+
description="tentod",
|
|
14
|
+
author="Rijul Gupta",
|
|
15
|
+
author_email="rijulg@gmail.com",
|
|
16
|
+
url="https://github.com/rijulg/tentod",
|
|
17
|
+
keywords=["dotnet"],
|
|
18
|
+
long_description=long_description,
|
|
19
|
+
long_description_content_type="text/markdown",
|
|
20
|
+
python_requires=">=3.13",
|
|
21
|
+
install_requires=[
|
|
22
|
+
"class-argparse==0.1.4",
|
|
23
|
+
"pythonnet==3.1.0",
|
|
24
|
+
],
|
|
25
|
+
classifiers=[
|
|
26
|
+
"Development Status :: 4 - Beta",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"License :: OSI Approved :: MIT License",
|
|
29
|
+
"Operating System :: POSIX :: Linux",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Topic :: Utilities",
|
|
32
|
+
],
|
|
33
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# tentod — guide for automated (LLM) use
|
|
2
|
+
|
|
3
|
+
tentod recovers readable Python from .NET assemblies in stages. The
|
|
4
|
+
deterministic stages are commands; the one creative stage (writing the Python)
|
|
5
|
+
is done by you, the model, against the contract below. Print this any time with
|
|
6
|
+
`python -m tentod guide`.
|
|
7
|
+
|
|
8
|
+
## Turnkey workflow
|
|
9
|
+
|
|
10
|
+
Given one assembly, or a list/folder of them, process **each assembly
|
|
11
|
+
independently** — they share nothing, so fan out one worker per assembly and run
|
|
12
|
+
these steps for each:
|
|
13
|
+
|
|
14
|
+
1. **Recover.** `python -m tentod recover <assembly.dll>`
|
|
15
|
+
- If it prints `SKIP not a managed assembly`, the file is native or not a
|
|
16
|
+
.NET assembly — move on, it is not yours to process.
|
|
17
|
+
- Otherwise it prints the paths of the decompiled C#, the **deobfuscated C#**,
|
|
18
|
+
and `facts.json`, plus counts (`types`/`strings`/`routes`/`inlined`).
|
|
19
|
+
2. **Read** the deobfuscated C# and `facts.json`. The deobfuscated C# is the
|
|
20
|
+
source of truth for control flow and every literal (routes, keys, headers).
|
|
21
|
+
3. **Write** the Python module (see the contract below). Map the assembly's
|
|
22
|
+
namespace to directories: `A.B.C` → `A/B/C.py`.
|
|
23
|
+
4. **Verify.** `python -m tentod verify <assembly.dll> <your_output.py>`
|
|
24
|
+
- Resolve every entry under `missing` (a required literal your Python lacks).
|
|
25
|
+
- Review every entry under `omitted`: each must be something you dropped on
|
|
26
|
+
purpose (an internal log line, a diagnostic message) — never a route, query
|
|
27
|
+
key, header, or secret name.
|
|
28
|
+
5. **Report** the coverage line and any deliberate omissions for that assembly.
|
|
29
|
+
|
|
30
|
+
Work on independent assemblies concurrently; each worker owns one assembly end
|
|
31
|
+
to end (recover → write → verify).
|
|
32
|
+
|
|
33
|
+
## Style contract for the Python you write
|
|
34
|
+
|
|
35
|
+
Faithful, idiomatic Python — not a mechanical transliteration of the C#.
|
|
36
|
+
|
|
37
|
+
1. **One module** at the namespace-mapped path.
|
|
38
|
+
2. **Class-based.** No module-level functions. The public surface is a single
|
|
39
|
+
class; each user-facing operation is one public method.
|
|
40
|
+
3. **Private helpers are `__`-prefixed** (name-mangled) — request building, key
|
|
41
|
+
resolution, response handling.
|
|
42
|
+
4. **Module-level `CONSTANTS`** (base URLs, defaults) are fine.
|
|
43
|
+
5. **Faithful.** Use the exact literals from the C#/facts. Invent nothing.
|
|
44
|
+
Reproduce behavior, not the C# class hierarchy — skip DTOs, DI, and
|
|
45
|
+
async-state-machine scaffolding a client does not need.
|
|
46
|
+
6. **Type hints throughout.** Prefer the standard library.
|
|
47
|
+
7. **Docstring states provenance** and lists any endpoints/behavior you
|
|
48
|
+
intentionally left out, so the omission is a decision, not an oversight.
|
|
49
|
+
|
|
50
|
+
## Done
|
|
51
|
+
|
|
52
|
+
`python -m tentod verify` reports `OK` (nothing under `missing`) and every
|
|
53
|
+
`omitted` entry is one you can justify.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""tentod."""
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""CLI."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from class_argparse import ClassArgParser
|
|
6
|
+
|
|
7
|
+
from .assembly import DotNetAssembly, NotManagedAssembly
|
|
8
|
+
from .pipeline import DotNetRecoveryPipeline
|
|
9
|
+
|
|
10
|
+
DEFAULT_OUT_ROOT = "out"
|
|
11
|
+
GUIDE = Path(__file__).with_name("GUIDE.md")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Main(ClassArgParser):
|
|
15
|
+
"""tentod."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
super().__init__(name="tentod")
|
|
19
|
+
|
|
20
|
+
def __resolve(self, assembly: str) -> str:
|
|
21
|
+
p = Path(assembly)
|
|
22
|
+
if p.exists():
|
|
23
|
+
return str(p)
|
|
24
|
+
if not p.suffix:
|
|
25
|
+
withdll = p.with_suffix(".dll")
|
|
26
|
+
if withdll.exists():
|
|
27
|
+
return str(withdll)
|
|
28
|
+
raise SystemExit(f"not found: {assembly}")
|
|
29
|
+
|
|
30
|
+
def guide(self):
|
|
31
|
+
print(GUIDE.read_text())
|
|
32
|
+
|
|
33
|
+
def recover(
|
|
34
|
+
self,
|
|
35
|
+
assembly: str,
|
|
36
|
+
out_dir: str = DEFAULT_OUT_ROOT,
|
|
37
|
+
force: bool = False,
|
|
38
|
+
):
|
|
39
|
+
dll = self.__resolve(assembly)
|
|
40
|
+
try:
|
|
41
|
+
result = DotNetRecoveryPipeline(dll, out_dir).recover(force=force)
|
|
42
|
+
except NotManagedAssembly:
|
|
43
|
+
print(f"SKIP not a managed assembly: {dll}")
|
|
44
|
+
return
|
|
45
|
+
print(result.csharp)
|
|
46
|
+
print(result.deobfuscated)
|
|
47
|
+
print(result.facts_path)
|
|
48
|
+
print(
|
|
49
|
+
f"types={len(result.facts.types)} "
|
|
50
|
+
f"strings={len(result.facts.flat_strings())} "
|
|
51
|
+
f"routes={len(result.facts.routes)} "
|
|
52
|
+
f"inlined={result.replacements}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def verify(
|
|
56
|
+
self,
|
|
57
|
+
assembly: str,
|
|
58
|
+
python: str,
|
|
59
|
+
out_dir: str = DEFAULT_OUT_ROOT,
|
|
60
|
+
):
|
|
61
|
+
dll = self.__resolve(assembly)
|
|
62
|
+
report = DotNetRecoveryPipeline(dll, out_dir).verify(python)
|
|
63
|
+
print(report.render())
|
|
64
|
+
if not report.ok:
|
|
65
|
+
raise SystemExit(1)
|
|
66
|
+
|
|
67
|
+
def strings(self, assembly: str):
|
|
68
|
+
try:
|
|
69
|
+
asm = DotNetAssembly.load(self.__resolve(assembly))
|
|
70
|
+
except NotManagedAssembly:
|
|
71
|
+
print(f"SKIP not a managed assembly: {assembly}")
|
|
72
|
+
return
|
|
73
|
+
for bank, entries in asm.decrypt_strings().items():
|
|
74
|
+
print(f"### {bank}")
|
|
75
|
+
for name, value in entries.items():
|
|
76
|
+
print(f" {name}() = {value!r}")
|
|
77
|
+
print()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__": # pragma: no cover
|
|
81
|
+
Main()()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Runtime bootstrap."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@functools.lru_cache(maxsize=1)
|
|
8
|
+
def runtime(): # pragma: no cover
|
|
9
|
+
os.environ.setdefault("PYTHONNET_RUNTIME", "mono")
|
|
10
|
+
import clr # noqa: F401
|
|
11
|
+
|
|
12
|
+
from System.Reflection import Assembly, BindingFlags
|
|
13
|
+
|
|
14
|
+
flags = (
|
|
15
|
+
BindingFlags.Static
|
|
16
|
+
| BindingFlags.Instance
|
|
17
|
+
| BindingFlags.Public
|
|
18
|
+
| BindingFlags.NonPublic
|
|
19
|
+
| BindingFlags.DeclaredOnly
|
|
20
|
+
)
|
|
21
|
+
return Assembly, flags
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Assembly reflection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from ._runtime import runtime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NotManagedAssembly(Exception):
|
|
11
|
+
"""Raised when a file is not a managed assembly (e.g. a native DLL)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class MethodInfo:
|
|
16
|
+
name: str
|
|
17
|
+
static: bool
|
|
18
|
+
returns: str
|
|
19
|
+
params: list[str] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TypeInfo:
|
|
24
|
+
fullname: str
|
|
25
|
+
kind: str
|
|
26
|
+
methods: list[MethodInfo] = field(default_factory=list)
|
|
27
|
+
enum_members: list[str] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DotNetAssembly:
|
|
31
|
+
"""A loaded assembly queried through reflection."""
|
|
32
|
+
|
|
33
|
+
__STRING_BANK_MIN = 3
|
|
34
|
+
|
|
35
|
+
def __init__(self, asm, flags=None) -> None:
|
|
36
|
+
self.__asm = asm
|
|
37
|
+
self.__flags = flags
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def load(cls, dll_path: str) -> "DotNetAssembly":
|
|
41
|
+
assembly, flags = runtime()
|
|
42
|
+
try:
|
|
43
|
+
obj = assembly.LoadFrom(str(dll_path))
|
|
44
|
+
except Exception as e:
|
|
45
|
+
if "BadImageFormat" in type(e).__name__:
|
|
46
|
+
raise NotManagedAssembly(str(dll_path)) from e
|
|
47
|
+
raise
|
|
48
|
+
return cls(obj, flags)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def full_name(self) -> str:
|
|
52
|
+
return str(self.__asm.FullName)
|
|
53
|
+
|
|
54
|
+
def __types(self):
|
|
55
|
+
try:
|
|
56
|
+
return list(self.__asm.GetTypes())
|
|
57
|
+
except Exception as e:
|
|
58
|
+
loaded = getattr(e, "Types", None)
|
|
59
|
+
return [t for t in loaded if t is not None] if loaded else []
|
|
60
|
+
|
|
61
|
+
def __is_accessor(self, m) -> bool:
|
|
62
|
+
return (
|
|
63
|
+
m.IsStatic
|
|
64
|
+
and m.ReturnType.Name == "String"
|
|
65
|
+
and len(m.GetParameters()) == 0
|
|
66
|
+
and not m.Name.startswith("get_")
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def __string_banks(self):
|
|
70
|
+
banks = []
|
|
71
|
+
for t in self.__types():
|
|
72
|
+
accessors = [m for m in t.GetMethods(self.__flags) if self.__is_accessor(m)]
|
|
73
|
+
if len(accessors) >= self.__STRING_BANK_MIN:
|
|
74
|
+
banks.append((t, accessors))
|
|
75
|
+
return banks
|
|
76
|
+
|
|
77
|
+
def decrypt_strings(self) -> dict[str, dict[str, str]]:
|
|
78
|
+
out: dict[str, dict[str, str]] = {}
|
|
79
|
+
for t, accessors in self.__string_banks():
|
|
80
|
+
bank: dict[str, str] = {}
|
|
81
|
+
for m in accessors:
|
|
82
|
+
try:
|
|
83
|
+
bank[m.Name] = str(m.Invoke(None, None))
|
|
84
|
+
except Exception:
|
|
85
|
+
continue
|
|
86
|
+
if bank:
|
|
87
|
+
out[t.Name] = bank
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
def enums(self) -> dict[str, list[str]]:
|
|
91
|
+
out: dict[str, list[str]] = {}
|
|
92
|
+
for t in self.__types():
|
|
93
|
+
if t.IsEnum:
|
|
94
|
+
try:
|
|
95
|
+
out[t.FullName] = list(t.GetEnumNames())
|
|
96
|
+
except Exception:
|
|
97
|
+
out[t.FullName] = []
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
def __kind(self, t) -> str:
|
|
101
|
+
if t.IsEnum:
|
|
102
|
+
return "enum"
|
|
103
|
+
if t.IsInterface:
|
|
104
|
+
return "interface"
|
|
105
|
+
if t.IsValueType:
|
|
106
|
+
return "struct"
|
|
107
|
+
return "class"
|
|
108
|
+
|
|
109
|
+
def types(self, include_generated: bool = False) -> list[TypeInfo]:
|
|
110
|
+
bank_names = {t.Name for t, _ in self.__string_banks()}
|
|
111
|
+
result: list[TypeInfo] = []
|
|
112
|
+
for t in self.__types():
|
|
113
|
+
if not include_generated and (
|
|
114
|
+
t.Name in bank_names or "PrivateImplementationDetails" in t.FullName
|
|
115
|
+
):
|
|
116
|
+
continue
|
|
117
|
+
ti = TypeInfo(fullname=t.FullName, kind=self.__kind(t))
|
|
118
|
+
if t.IsEnum:
|
|
119
|
+
try:
|
|
120
|
+
ti.enum_members = list(t.GetEnumNames())
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
else:
|
|
124
|
+
for m in t.GetMethods(self.__flags):
|
|
125
|
+
ti.methods.append(
|
|
126
|
+
MethodInfo(
|
|
127
|
+
name=m.Name,
|
|
128
|
+
static=m.IsStatic,
|
|
129
|
+
returns=m.ReturnType.Name,
|
|
130
|
+
params=[p.ParameterType.Name for p in m.GetParameters()],
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
result.append(ti)
|
|
134
|
+
return result
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Decompilation stage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IlspyDecompiler:
|
|
12
|
+
"""Wrapper over the ``ilspycmd`` executable."""
|
|
13
|
+
|
|
14
|
+
__TOOL_CANDIDATES = ("ilspycmd", "~/.dotnet/tools/ilspycmd")
|
|
15
|
+
|
|
16
|
+
def __init__(self, ilspycmd: str | None = None) -> None:
|
|
17
|
+
self.__exe = ilspycmd or self.__discover()
|
|
18
|
+
|
|
19
|
+
def __discover(self) -> str:
|
|
20
|
+
for cand in self.__TOOL_CANDIDATES:
|
|
21
|
+
found = shutil.which(cand) or (
|
|
22
|
+
str(Path(cand).expanduser())
|
|
23
|
+
if Path(cand).expanduser().exists()
|
|
24
|
+
else None
|
|
25
|
+
)
|
|
26
|
+
if found:
|
|
27
|
+
return found
|
|
28
|
+
raise RuntimeError("ilspycmd not found")
|
|
29
|
+
|
|
30
|
+
def __env(self) -> dict[str, str]:
|
|
31
|
+
env = dict(os.environ)
|
|
32
|
+
env.setdefault("DOTNET_ROLL_FORWARD", "Major")
|
|
33
|
+
env.setdefault("DOTNET_CLI_TELEMETRY_OPTOUT", "1")
|
|
34
|
+
env.setdefault("DOTNET_NOLOGO", "1")
|
|
35
|
+
return env
|
|
36
|
+
|
|
37
|
+
def decompile(self, dll_path: str, out_dir: str, force: bool = False) -> Path:
|
|
38
|
+
dll = Path(dll_path)
|
|
39
|
+
out = Path(out_dir)
|
|
40
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
expected = out / f"{dll.stem}.decompiled.cs"
|
|
42
|
+
if expected.exists() and not force:
|
|
43
|
+
return expected
|
|
44
|
+
proc = subprocess.run(
|
|
45
|
+
[self.__exe, str(dll), "-o", str(out)],
|
|
46
|
+
env=self.__env(),
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
)
|
|
50
|
+
if proc.returncode != 0:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
f"ilspycmd failed ({proc.returncode}):\n{proc.stderr or proc.stdout}"
|
|
53
|
+
)
|
|
54
|
+
if expected.exists():
|
|
55
|
+
return expected
|
|
56
|
+
produced = sorted(out.glob("*.cs"))
|
|
57
|
+
if not produced:
|
|
58
|
+
raise RuntimeError(f"no .cs produced in {out}")
|
|
59
|
+
return produced[0]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Deobfuscation stage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StringDeobfuscator:
|
|
10
|
+
"""Rewrites accessor calls to their decrypted literals."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, string_banks: dict[str, dict[str, str]]) -> None:
|
|
13
|
+
self.__banks = string_banks
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def __csharp_literal(value: str) -> str:
|
|
17
|
+
esc = (
|
|
18
|
+
value.replace("\\", "\\\\")
|
|
19
|
+
.replace('"', '\\"')
|
|
20
|
+
.replace("\n", "\\n")
|
|
21
|
+
.replace("\r", "\\r")
|
|
22
|
+
.replace("\t", "\\t")
|
|
23
|
+
)
|
|
24
|
+
return f'"{esc}"'
|
|
25
|
+
|
|
26
|
+
def __bank_pattern(self, qualifier: str, bank: dict[str, str]) -> re.Pattern:
|
|
27
|
+
names = "|".join(re.escape(n) for n in sorted(bank, key=len, reverse=True))
|
|
28
|
+
return re.compile(re.escape(qualifier) + r"\.(" + names + r")\(\)")
|
|
29
|
+
|
|
30
|
+
def deobfuscate_text(self, source: str) -> tuple[str, int]:
|
|
31
|
+
count = 0
|
|
32
|
+
for qualifier, bank in self.__banks.items():
|
|
33
|
+
if not bank:
|
|
34
|
+
continue
|
|
35
|
+
pattern = self.__bank_pattern(qualifier, bank)
|
|
36
|
+
|
|
37
|
+
def repl(m: re.Match) -> str:
|
|
38
|
+
nonlocal count
|
|
39
|
+
count += 1
|
|
40
|
+
return self.__csharp_literal(bank[m.group(1)])
|
|
41
|
+
|
|
42
|
+
source = pattern.sub(repl, source)
|
|
43
|
+
return source, count
|
|
44
|
+
|
|
45
|
+
def deobfuscate_file(self, cs_in: str, cs_out: str) -> tuple[Path, int]:
|
|
46
|
+
rewritten, count = self.deobfuscate_text(Path(cs_in).read_text())
|
|
47
|
+
out = Path(cs_out)
|
|
48
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
out.write_text(rewritten)
|
|
50
|
+
return out, count
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Facts stage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from .assembly import DotNetAssembly
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class AssemblyFacts:
|
|
13
|
+
assembly: str
|
|
14
|
+
strings: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
15
|
+
enums: dict[str, list[str]] = field(default_factory=dict)
|
|
16
|
+
routes: list[str] = field(default_factory=list)
|
|
17
|
+
types: list[dict] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
def flat_strings(self) -> list[str]:
|
|
20
|
+
seen: dict[str, None] = {}
|
|
21
|
+
for bank in self.strings.values():
|
|
22
|
+
for value in bank.values():
|
|
23
|
+
seen.setdefault(value, None)
|
|
24
|
+
return list(seen)
|
|
25
|
+
|
|
26
|
+
def to_json(self) -> str:
|
|
27
|
+
return json.dumps(asdict(self), indent=2, ensure_ascii=False)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_json(cls, text: str) -> "AssemblyFacts":
|
|
31
|
+
return cls(**json.loads(text))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FactsExtractor:
|
|
35
|
+
def extract(self, asm: DotNetAssembly) -> AssemblyFacts:
|
|
36
|
+
strings = asm.decrypt_strings()
|
|
37
|
+
routes = sorted(
|
|
38
|
+
{
|
|
39
|
+
value
|
|
40
|
+
for bank in strings.values()
|
|
41
|
+
for value in bank.values()
|
|
42
|
+
if value.startswith("/")
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
return AssemblyFacts(
|
|
46
|
+
assembly=asm.full_name,
|
|
47
|
+
strings=strings,
|
|
48
|
+
enums=asm.enums(),
|
|
49
|
+
routes=routes,
|
|
50
|
+
types=[asdict(t) for t in asm.types()],
|
|
51
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Pipeline orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .assembly import DotNetAssembly
|
|
9
|
+
from .decompiler import IlspyDecompiler
|
|
10
|
+
from .deobfuscator import StringDeobfuscator
|
|
11
|
+
from .facts import AssemblyFacts, FactsExtractor
|
|
12
|
+
from .verify import RecoveryVerifier, VerifyReport
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RecoverResult:
|
|
17
|
+
csharp: Path
|
|
18
|
+
deobfuscated: Path
|
|
19
|
+
facts_path: Path
|
|
20
|
+
facts: AssemblyFacts
|
|
21
|
+
replacements: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DotNetRecoveryPipeline:
|
|
25
|
+
def __init__(self, dll_path: str, out_root: str) -> None:
|
|
26
|
+
self.__dll = Path(dll_path)
|
|
27
|
+
self.__root = Path(out_root) / self.__dll.stem
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def root(self) -> Path:
|
|
31
|
+
return self.__root
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def facts_path(self) -> Path:
|
|
35
|
+
return self.__root / "facts.json"
|
|
36
|
+
|
|
37
|
+
def recover(self, force: bool = False) -> RecoverResult:
|
|
38
|
+
asm = DotNetAssembly.load(str(self.__dll)) # classifies before decompiling
|
|
39
|
+
banks = asm.decrypt_strings()
|
|
40
|
+
csharp = IlspyDecompiler().decompile(
|
|
41
|
+
str(self.__dll),
|
|
42
|
+
str(self.__root / "csharp"),
|
|
43
|
+
force=force,
|
|
44
|
+
)
|
|
45
|
+
deobf, replacements = StringDeobfuscator(banks).deobfuscate_file(
|
|
46
|
+
str(csharp),
|
|
47
|
+
str(self.__root / "deobf" / f"{self.__dll.stem}.deobf.cs"),
|
|
48
|
+
)
|
|
49
|
+
facts = FactsExtractor().extract(asm)
|
|
50
|
+
self.facts_path.write_text(facts.to_json())
|
|
51
|
+
return RecoverResult(csharp, deobf, self.facts_path, facts, replacements)
|
|
52
|
+
|
|
53
|
+
def load_facts(self) -> AssemblyFacts:
|
|
54
|
+
if self.facts_path.exists():
|
|
55
|
+
return AssemblyFacts.from_json(self.facts_path.read_text())
|
|
56
|
+
return FactsExtractor().extract(DotNetAssembly.load(str(self.__dll)))
|
|
57
|
+
|
|
58
|
+
def verify(self, python_path: str) -> VerifyReport:
|
|
59
|
+
facts = self.load_facts()
|
|
60
|
+
source = Path(python_path).read_text()
|
|
61
|
+
return RecoveryVerifier().verify(facts, source)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Verification stage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .facts import AssemblyFacts
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class VerifyReport:
|
|
12
|
+
matched: list[str] = field(default_factory=list)
|
|
13
|
+
omitted: list[str] = field(default_factory=list)
|
|
14
|
+
missing_routes: list[str] = field(default_factory=list)
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def ok(self) -> bool:
|
|
18
|
+
return not self.missing_routes
|
|
19
|
+
|
|
20
|
+
def render(self) -> str:
|
|
21
|
+
total = len(self.matched) + len(self.omitted)
|
|
22
|
+
lines = [f"coverage: {len(self.matched)}/{total}"]
|
|
23
|
+
if self.missing_routes:
|
|
24
|
+
lines.append("")
|
|
25
|
+
lines.append(f"missing ({len(self.missing_routes)}):")
|
|
26
|
+
lines += [f" ! {r}" for r in self.missing_routes]
|
|
27
|
+
if self.omitted:
|
|
28
|
+
lines.append("")
|
|
29
|
+
lines.append(f"omitted ({len(self.omitted)}):")
|
|
30
|
+
lines += [f" - {s!r}" for s in self.omitted]
|
|
31
|
+
lines.append("")
|
|
32
|
+
lines.append("OK" if self.ok else "FAIL")
|
|
33
|
+
return "\n".join(lines)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RecoveryVerifier:
|
|
37
|
+
def verify(self, facts: AssemblyFacts, python_source: str) -> VerifyReport:
|
|
38
|
+
report = VerifyReport()
|
|
39
|
+
for literal in facts.flat_strings():
|
|
40
|
+
if not literal.strip():
|
|
41
|
+
continue
|
|
42
|
+
bucket = report.matched if literal in python_source else report.omitted
|
|
43
|
+
bucket.append(literal)
|
|
44
|
+
report.missing_routes = [r for r in facts.routes if r not in python_source]
|
|
45
|
+
return report
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tentod
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: tentod
|
|
5
|
+
Home-page: https://github.com/rijulg/tentod
|
|
6
|
+
Author: Rijul Gupta
|
|
7
|
+
Author-email: rijulg@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: dotnet
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Python: >=3.13
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: class-argparse==0.1.4
|
|
19
|
+
Requires-Dist: pythonnet==3.1.0
|
|
20
|
+
Dynamic: author
|
|
21
|
+
Dynamic: author-email
|
|
22
|
+
Dynamic: classifier
|
|
23
|
+
Dynamic: description
|
|
24
|
+
Dynamic: description-content-type
|
|
25
|
+
Dynamic: home-page
|
|
26
|
+
Dynamic: keywords
|
|
27
|
+
Dynamic: license
|
|
28
|
+
Dynamic: requires-dist
|
|
29
|
+
Dynamic: requires-python
|
|
30
|
+
Dynamic: summary
|
|
31
|
+
|
|
32
|
+
# tentod
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
python -m tentod recover <assembly.dll>
|
|
36
|
+
python -m tentod guide # workflow for automated/LLM use
|
|
37
|
+
```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
tentod/GUIDE.md
|
|
5
|
+
tentod/__init__.py
|
|
6
|
+
tentod/__main__.py
|
|
7
|
+
tentod/_runtime.py
|
|
8
|
+
tentod/assembly.py
|
|
9
|
+
tentod/decompiler.py
|
|
10
|
+
tentod/deobfuscator.py
|
|
11
|
+
tentod/facts.py
|
|
12
|
+
tentod/pipeline.py
|
|
13
|
+
tentod/verify.py
|
|
14
|
+
tentod.egg-info/PKG-INFO
|
|
15
|
+
tentod.egg-info/SOURCES.txt
|
|
16
|
+
tentod.egg-info/dependency_links.txt
|
|
17
|
+
tentod.egg-info/requires.txt
|
|
18
|
+
tentod.egg-info/top_level.txt
|
|
19
|
+
tests/test_assembly.py
|
|
20
|
+
tests/test_decompiler.py
|
|
21
|
+
tests/test_deobfuscator.py
|
|
22
|
+
tests/test_facts.py
|
|
23
|
+
tests/test_main.py
|
|
24
|
+
tests/test_pipeline.py
|
|
25
|
+
tests/test_verify.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tentod
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from tentod.assembly import DotNetAssembly, NotManagedAssembly
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _sample_types(clr):
|
|
7
|
+
return [
|
|
8
|
+
clr.Type("ns.Bank0", methods=[
|
|
9
|
+
clr.Method("m1", value="/x"),
|
|
10
|
+
clr.Method("m2", value="y"),
|
|
11
|
+
clr.Method("m3", raises=True), # decryptor helper -> skipped
|
|
12
|
+
]),
|
|
13
|
+
clr.Type("ns.AllRaise", methods=[
|
|
14
|
+
clr.Method("a", raises=True),
|
|
15
|
+
clr.Method("b", raises=True),
|
|
16
|
+
clr.Method("c", raises=True),
|
|
17
|
+
]),
|
|
18
|
+
clr.Type("ns.<PrivateImplementationDetails>Z", name="Z"),
|
|
19
|
+
clr.Type("ns.EnumGood", enum=["A", "B"], kind="enum"),
|
|
20
|
+
clr.Type("ns.EnumRaise", enum="raise", kind="enum"),
|
|
21
|
+
clr.Type("ns.IFoo", kind="interface"),
|
|
22
|
+
clr.Type("ns.SBar", kind="struct"),
|
|
23
|
+
clr.Type("ns.CBaz", methods=[
|
|
24
|
+
clr.Method("M", static=False, ret="Void", params=("String", "Int32")),
|
|
25
|
+
]),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_load_wraps_loadfrom(monkeypatch, clr):
|
|
30
|
+
sentinel = clr.Assembly([], fullname="Loaded, Version=9")
|
|
31
|
+
|
|
32
|
+
class Loader:
|
|
33
|
+
@staticmethod
|
|
34
|
+
def LoadFrom(path):
|
|
35
|
+
assert path == "x.dll"
|
|
36
|
+
return sentinel
|
|
37
|
+
|
|
38
|
+
monkeypatch.setattr("tentod.assembly.runtime", lambda: (Loader, None))
|
|
39
|
+
asm = DotNetAssembly.load("x.dll")
|
|
40
|
+
assert asm.full_name == "Loaded, Version=9"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_load_skips_native(monkeypatch):
|
|
44
|
+
class BadImageFormatException(Exception):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
class Loader:
|
|
48
|
+
@staticmethod
|
|
49
|
+
def LoadFrom(path):
|
|
50
|
+
raise BadImageFormatException("native")
|
|
51
|
+
|
|
52
|
+
monkeypatch.setattr("tentod.assembly.runtime", lambda: (Loader, None))
|
|
53
|
+
with pytest.raises(NotManagedAssembly):
|
|
54
|
+
DotNetAssembly.load("native.dll")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_load_reraises_other_errors(monkeypatch):
|
|
58
|
+
class Loader:
|
|
59
|
+
@staticmethod
|
|
60
|
+
def LoadFrom(path):
|
|
61
|
+
raise RuntimeError("disk on fire")
|
|
62
|
+
|
|
63
|
+
monkeypatch.setattr("tentod.assembly.runtime", lambda: (Loader, None))
|
|
64
|
+
with pytest.raises(RuntimeError):
|
|
65
|
+
DotNetAssembly.load("x.dll")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_decrypt_strings(clr):
|
|
69
|
+
asm = DotNetAssembly(clr.Assembly(_sample_types(clr)))
|
|
70
|
+
banks = asm.decrypt_strings()
|
|
71
|
+
assert banks == {"Bank0": {"m1": "/x", "m2": "y"}} # AllRaise dropped (empty)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_enums(clr):
|
|
75
|
+
asm = DotNetAssembly(clr.Assembly(_sample_types(clr)))
|
|
76
|
+
assert asm.enums() == {"ns.EnumGood": ["A", "B"], "ns.EnumRaise": []}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_types_default_skips_generated_and_covers_kinds(clr):
|
|
80
|
+
asm = DotNetAssembly(clr.Assembly(_sample_types(clr)))
|
|
81
|
+
by_name = {t.fullname: t for t in asm.types()}
|
|
82
|
+
assert "ns.Bank0" not in by_name # string bank skipped
|
|
83
|
+
assert "ns.<PrivateImplementationDetails>Z" not in by_name
|
|
84
|
+
assert by_name["ns.EnumGood"].enum_members == ["A", "B"]
|
|
85
|
+
assert by_name["ns.EnumRaise"].enum_members == [] # GetEnumNames raised
|
|
86
|
+
assert by_name["ns.IFoo"].kind == "interface"
|
|
87
|
+
assert by_name["ns.SBar"].kind == "struct"
|
|
88
|
+
cbaz = by_name["ns.CBaz"]
|
|
89
|
+
assert cbaz.kind == "class"
|
|
90
|
+
assert cbaz.methods[0].params == ["String", "Int32"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_types_include_generated(clr):
|
|
94
|
+
asm = DotNetAssembly(clr.Assembly(_sample_types(clr)))
|
|
95
|
+
names = {t.fullname for t in asm.types(include_generated=True)}
|
|
96
|
+
assert "ns.Bank0" in names
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_types_recovers_from_partial_load(clr):
|
|
100
|
+
class Boom:
|
|
101
|
+
FullName = "Boom"
|
|
102
|
+
|
|
103
|
+
def GetTypes(self):
|
|
104
|
+
err = RuntimeError("partial")
|
|
105
|
+
err.Types = [clr.Type("ns.X")]
|
|
106
|
+
raise err
|
|
107
|
+
|
|
108
|
+
asm = DotNetAssembly(Boom())
|
|
109
|
+
assert [t.fullname for t in asm.types()] == ["ns.X"]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import types
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from tentod.decompiler import IlspyDecompiler
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _proc(returncode=0, stderr="", stdout=""):
|
|
9
|
+
return types.SimpleNamespace(returncode=returncode, stderr=stderr, stdout=stdout)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_discover_via_which(monkeypatch):
|
|
13
|
+
monkeypatch.setattr(
|
|
14
|
+
"tentod.decompiler.shutil.which",
|
|
15
|
+
lambda c: "/usr/bin/ilspycmd" if c == "ilspycmd" else None,
|
|
16
|
+
)
|
|
17
|
+
d = IlspyDecompiler()
|
|
18
|
+
assert d._IlspyDecompiler__exe == "/usr/bin/ilspycmd"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_discover_via_path(monkeypatch, tmp_path):
|
|
22
|
+
exe = tmp_path / "ilspycmd"
|
|
23
|
+
exe.write_text("")
|
|
24
|
+
monkeypatch.setattr("tentod.decompiler.shutil.which", lambda c: None)
|
|
25
|
+
monkeypatch.setattr(
|
|
26
|
+
IlspyDecompiler, "_IlspyDecompiler__TOOL_CANDIDATES", (str(exe),)
|
|
27
|
+
)
|
|
28
|
+
assert IlspyDecompiler()._IlspyDecompiler__exe == str(exe)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_discover_not_found(monkeypatch):
|
|
32
|
+
monkeypatch.setattr("tentod.decompiler.shutil.which", lambda c: None)
|
|
33
|
+
monkeypatch.setattr(
|
|
34
|
+
IlspyDecompiler, "_IlspyDecompiler__TOOL_CANDIDATES", ("/nope/xyz",)
|
|
35
|
+
)
|
|
36
|
+
with pytest.raises(RuntimeError):
|
|
37
|
+
IlspyDecompiler()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_decompile_idempotent(monkeypatch, tmp_path):
|
|
41
|
+
out = tmp_path / "out"
|
|
42
|
+
out.mkdir()
|
|
43
|
+
(out / "a.decompiled.cs").write_text("cached")
|
|
44
|
+
called = []
|
|
45
|
+
monkeypatch.setattr("tentod.decompiler.subprocess.run", lambda *a, **k: called.append(1))
|
|
46
|
+
result = IlspyDecompiler("ilspycmd").decompile(str(tmp_path / "a.dll"), str(out))
|
|
47
|
+
assert result == out / "a.decompiled.cs"
|
|
48
|
+
assert not called # subprocess never invoked
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_decompile_success(monkeypatch, tmp_path):
|
|
52
|
+
out = tmp_path / "out"
|
|
53
|
+
|
|
54
|
+
def fake_run(cmd, env, capture_output, text):
|
|
55
|
+
(out / "a.decompiled.cs").write_text("cs")
|
|
56
|
+
return _proc()
|
|
57
|
+
|
|
58
|
+
monkeypatch.setattr("tentod.decompiler.subprocess.run", fake_run)
|
|
59
|
+
result = IlspyDecompiler("ilspycmd").decompile(str(tmp_path / "a.dll"), str(out))
|
|
60
|
+
assert result == out / "a.decompiled.cs"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_decompile_nonzero_raises(monkeypatch, tmp_path):
|
|
64
|
+
monkeypatch.setattr(
|
|
65
|
+
"tentod.decompiler.subprocess.run", lambda *a, **k: _proc(1, stderr="boom")
|
|
66
|
+
)
|
|
67
|
+
with pytest.raises(RuntimeError, match="boom"):
|
|
68
|
+
IlspyDecompiler("ilspycmd").decompile(str(tmp_path / "a.dll"), str(tmp_path / "o"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_decompile_falls_back_to_glob(monkeypatch, tmp_path):
|
|
72
|
+
out = tmp_path / "out"
|
|
73
|
+
|
|
74
|
+
def fake_run(cmd, env, capture_output, text):
|
|
75
|
+
(out / "other.cs").write_text("cs") # not the expected name
|
|
76
|
+
return _proc()
|
|
77
|
+
|
|
78
|
+
monkeypatch.setattr("tentod.decompiler.subprocess.run", fake_run)
|
|
79
|
+
result = IlspyDecompiler("ilspycmd").decompile(str(tmp_path / "a.dll"), str(out))
|
|
80
|
+
assert result == out / "other.cs"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_decompile_no_output_raises(monkeypatch, tmp_path):
|
|
84
|
+
monkeypatch.setattr("tentod.decompiler.subprocess.run", lambda *a, **k: _proc())
|
|
85
|
+
with pytest.raises(RuntimeError, match="no .cs"):
|
|
86
|
+
IlspyDecompiler("ilspycmd").decompile(str(tmp_path / "a.dll"), str(tmp_path / "o"))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from tentod.deobfuscator import StringDeobfuscator
|
|
2
|
+
|
|
3
|
+
BANKS = {
|
|
4
|
+
"Bank0": {
|
|
5
|
+
"p": "/alpha",
|
|
6
|
+
"U": "keyname",
|
|
7
|
+
"u": "value",
|
|
8
|
+
"esc": 'a\\b"c\nd\re\tf',
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_inlines_qualified_calls_only():
|
|
14
|
+
src = "x = A(z); y = Bank0.p(); d[Bank0.U()] = a;\n"
|
|
15
|
+
out, count = StringDeobfuscator(BANKS).deobfuscate_text(src)
|
|
16
|
+
assert count == 2
|
|
17
|
+
assert '"/alpha"' in out
|
|
18
|
+
assert '["keyname"] = a' in out
|
|
19
|
+
assert "A(z)" in out # unqualified local call untouched
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_escapes_specials():
|
|
23
|
+
out, _ = StringDeobfuscator(BANKS).deobfuscate_text("Bank0.esc()")
|
|
24
|
+
assert out == '"a\\\\b\\"c\\nd\\re\\tf"'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_unknown_accessor_untouched():
|
|
28
|
+
out, count = StringDeobfuscator(BANKS).deobfuscate_text("Bank0.zzz()")
|
|
29
|
+
assert count == 0
|
|
30
|
+
assert "Bank0.zzz()" in out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_empty_bank_skipped():
|
|
34
|
+
out, count = StringDeobfuscator({"Empty": {}}).deobfuscate_text("Empty.p()")
|
|
35
|
+
assert count == 0
|
|
36
|
+
assert out == "Empty.p()"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_deobfuscate_file_round_trip(tmp_path):
|
|
40
|
+
src = tmp_path / "in.cs"
|
|
41
|
+
src.write_text("v = Bank0.p();")
|
|
42
|
+
dst = tmp_path / "sub" / "out.cs"
|
|
43
|
+
out_path, count = StringDeobfuscator(BANKS).deobfuscate_file(str(src), str(dst))
|
|
44
|
+
assert out_path == dst
|
|
45
|
+
assert count == 1
|
|
46
|
+
assert dst.read_text() == 'v = "/alpha";'
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from tentod.facts import AssemblyFacts, FactsExtractor
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FakeAsm:
|
|
5
|
+
full_name = "Fake, Version=1.0.0.0"
|
|
6
|
+
|
|
7
|
+
def decrypt_strings(self):
|
|
8
|
+
return {"bank": {"p": "/alpha", "k": "keyname", "n": "not-a-route"}}
|
|
9
|
+
|
|
10
|
+
def enums(self):
|
|
11
|
+
return {"E": ["A", "B"]}
|
|
12
|
+
|
|
13
|
+
def types(self):
|
|
14
|
+
return [type("T", (), {})()]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_flat_strings_dedupes_and_preserves_order():
|
|
18
|
+
facts = AssemblyFacts(
|
|
19
|
+
assembly="Fake",
|
|
20
|
+
strings={"a": {"x": "one", "y": "two"}, "b": {"z": "two", "w": "three"}},
|
|
21
|
+
)
|
|
22
|
+
assert facts.flat_strings() == ["one", "two", "three"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_json_round_trip():
|
|
26
|
+
facts = AssemblyFacts(
|
|
27
|
+
assembly="Fake",
|
|
28
|
+
strings={"bank": {"p": "/alpha"}},
|
|
29
|
+
enums={"E": ["A", "B"]},
|
|
30
|
+
routes=["/alpha"],
|
|
31
|
+
types=[{"fullname": "X", "kind": "class", "methods": [], "enum_members": []}],
|
|
32
|
+
)
|
|
33
|
+
assert AssemblyFacts.from_json(facts.to_json()) == facts
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_extractor_collects_routes_and_surface(monkeypatch):
|
|
37
|
+
monkeypatch.setattr(
|
|
38
|
+
"tentod.facts.asdict", lambda t: {"fullname": "T"}
|
|
39
|
+
)
|
|
40
|
+
facts = FactsExtractor().extract(FakeAsm())
|
|
41
|
+
assert facts.assembly == "Fake, Version=1.0.0.0"
|
|
42
|
+
assert facts.routes == ["/alpha"] # only the "/"-prefixed literal
|
|
43
|
+
assert facts.enums == {"E": ["A", "B"]}
|
|
44
|
+
assert facts.types == [{"fullname": "T"}]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import types
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
import tentod.__main__ as M
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _dll(tmp_path):
|
|
9
|
+
f = tmp_path / "a.dll"
|
|
10
|
+
f.write_text("")
|
|
11
|
+
return f
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_resolve_existing(tmp_path):
|
|
15
|
+
f = _dll(tmp_path)
|
|
16
|
+
assert M.Main()._Main__resolve(str(f)) == str(f)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_resolve_adds_dll_suffix(tmp_path):
|
|
20
|
+
_dll(tmp_path)
|
|
21
|
+
assert M.Main()._Main__resolve(str(tmp_path / "a")) == str(tmp_path / "a.dll")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_resolve_missing_raises():
|
|
25
|
+
with pytest.raises(SystemExit):
|
|
26
|
+
M.Main()._Main__resolve("/nope/x") # no suffix -> tries .dll -> raises
|
|
27
|
+
with pytest.raises(SystemExit):
|
|
28
|
+
M.Main()._Main__resolve("/nope/x.dll") # has suffix -> straight to raise
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_recover(monkeypatch, tmp_path, capsys):
|
|
32
|
+
facts = types.SimpleNamespace(
|
|
33
|
+
types=[1, 2], routes=["/x"], flat_strings=lambda: ["a", "b", "c"]
|
|
34
|
+
)
|
|
35
|
+
result = types.SimpleNamespace(
|
|
36
|
+
csharp="cs.cs", deobfuscated="d.cs", facts_path="f.json",
|
|
37
|
+
facts=facts, replacements=5,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
class FakePipe:
|
|
41
|
+
def __init__(self, dll, out_dir):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def recover(self, force):
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
monkeypatch.setattr(M, "DotNetRecoveryPipeline", FakePipe)
|
|
48
|
+
M.Main().recover(str(_dll(tmp_path)))
|
|
49
|
+
out = capsys.readouterr().out
|
|
50
|
+
assert "types=2 strings=3 routes=1 inlined=5" in out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _pipe_with_report(ok):
|
|
54
|
+
report = types.SimpleNamespace(ok=ok, render=lambda: "REPORT")
|
|
55
|
+
|
|
56
|
+
class FakePipe:
|
|
57
|
+
def __init__(self, dll, out_dir):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
def verify(self, python):
|
|
61
|
+
return report
|
|
62
|
+
|
|
63
|
+
return FakePipe
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_verify_ok(monkeypatch, tmp_path):
|
|
67
|
+
monkeypatch.setattr(M, "DotNetRecoveryPipeline", _pipe_with_report(True))
|
|
68
|
+
M.Main().verify(str(_dll(tmp_path)), "gen.py") # no SystemExit
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_verify_fail_exits(monkeypatch, tmp_path):
|
|
72
|
+
monkeypatch.setattr(M, "DotNetRecoveryPipeline", _pipe_with_report(False))
|
|
73
|
+
with pytest.raises(SystemExit):
|
|
74
|
+
M.Main().verify(str(_dll(tmp_path)), "gen.py")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_guide(capsys):
|
|
78
|
+
M.Main().guide()
|
|
79
|
+
assert "tentod" in capsys.readouterr().out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_recover_skips_native(monkeypatch, tmp_path, capsys):
|
|
83
|
+
class FakePipe:
|
|
84
|
+
def __init__(self, dll, out_dir):
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
def recover(self, force):
|
|
88
|
+
raise M.NotManagedAssembly("native")
|
|
89
|
+
|
|
90
|
+
monkeypatch.setattr(M, "DotNetRecoveryPipeline", FakePipe)
|
|
91
|
+
M.Main().recover(str(_dll(tmp_path)))
|
|
92
|
+
assert "SKIP" in capsys.readouterr().out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_strings_skips_native(monkeypatch, tmp_path, capsys):
|
|
96
|
+
def boom(path):
|
|
97
|
+
raise M.NotManagedAssembly("native")
|
|
98
|
+
|
|
99
|
+
monkeypatch.setattr(M, "DotNetAssembly", types.SimpleNamespace(load=boom))
|
|
100
|
+
M.Main().strings(str(_dll(tmp_path)))
|
|
101
|
+
assert "SKIP" in capsys.readouterr().out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_strings(monkeypatch, tmp_path, capsys):
|
|
105
|
+
class FakeAsm:
|
|
106
|
+
def decrypt_strings(self):
|
|
107
|
+
return {"bank": {"m": "val"}}
|
|
108
|
+
|
|
109
|
+
monkeypatch.setattr(
|
|
110
|
+
M, "DotNetAssembly", types.SimpleNamespace(load=lambda p: FakeAsm())
|
|
111
|
+
)
|
|
112
|
+
M.Main().strings(str(_dll(tmp_path)))
|
|
113
|
+
out = capsys.readouterr().out
|
|
114
|
+
assert "### bank" in out
|
|
115
|
+
assert "m() = 'val'" in out
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from tentod.facts import AssemblyFacts
|
|
4
|
+
import tentod.pipeline as P
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _FakeAsm:
|
|
8
|
+
full_name = "Fake"
|
|
9
|
+
|
|
10
|
+
def decrypt_strings(self):
|
|
11
|
+
return {"b": {"p": "/x"}}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _FakeLoader:
|
|
15
|
+
@staticmethod
|
|
16
|
+
def load(path):
|
|
17
|
+
return _FakeAsm()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _FakeDeobf:
|
|
21
|
+
def __init__(self, banks):
|
|
22
|
+
self.banks = banks
|
|
23
|
+
|
|
24
|
+
def deobfuscate_file(self, cs_in, cs_out):
|
|
25
|
+
Path(cs_out).parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
Path(cs_out).write_text("deobf")
|
|
27
|
+
return Path(cs_out), 7
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_paths():
|
|
31
|
+
pipe = P.DotNetRecoveryPipeline("dir/a.dll", "root")
|
|
32
|
+
assert pipe.root == Path("root/a")
|
|
33
|
+
assert pipe.facts_path == Path("root/a/facts.json")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_recover(monkeypatch, tmp_path):
|
|
37
|
+
class FakeDec:
|
|
38
|
+
def decompile(self, dll, out_dir, force):
|
|
39
|
+
assert force is True
|
|
40
|
+
Path(out_dir).mkdir(parents=True, exist_ok=True)
|
|
41
|
+
f = Path(out_dir) / "a.decompiled.cs"
|
|
42
|
+
f.write_text("cs")
|
|
43
|
+
return f
|
|
44
|
+
|
|
45
|
+
facts = AssemblyFacts(assembly="Fake", routes=["/x"])
|
|
46
|
+
|
|
47
|
+
class FakeExtractor:
|
|
48
|
+
def extract(self, asm):
|
|
49
|
+
return facts
|
|
50
|
+
|
|
51
|
+
monkeypatch.setattr(P, "IlspyDecompiler", lambda: FakeDec())
|
|
52
|
+
monkeypatch.setattr(P, "DotNetAssembly", _FakeLoader)
|
|
53
|
+
monkeypatch.setattr(P, "StringDeobfuscator", _FakeDeobf)
|
|
54
|
+
monkeypatch.setattr(P, "FactsExtractor", FakeExtractor)
|
|
55
|
+
|
|
56
|
+
pipe = P.DotNetRecoveryPipeline(str(tmp_path / "a.dll"), str(tmp_path / "root"))
|
|
57
|
+
result = pipe.recover(force=True)
|
|
58
|
+
assert result.replacements == 7
|
|
59
|
+
assert result.facts is facts
|
|
60
|
+
assert result.deobfuscated.read_text() == "deobf"
|
|
61
|
+
assert pipe.facts_path.exists()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_load_facts_from_disk(tmp_path):
|
|
65
|
+
pipe = P.DotNetRecoveryPipeline(str(tmp_path / "a.dll"), str(tmp_path / "root"))
|
|
66
|
+
pipe.facts_path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
facts = AssemblyFacts(assembly="Fake", routes=["/x"])
|
|
68
|
+
pipe.facts_path.write_text(facts.to_json())
|
|
69
|
+
assert pipe.load_facts() == facts
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_load_facts_extracts_when_absent(monkeypatch, tmp_path):
|
|
73
|
+
facts = AssemblyFacts(assembly="Fake")
|
|
74
|
+
|
|
75
|
+
class FakeExtractor:
|
|
76
|
+
def extract(self, asm):
|
|
77
|
+
return facts
|
|
78
|
+
|
|
79
|
+
monkeypatch.setattr(P, "DotNetAssembly", _FakeLoader)
|
|
80
|
+
monkeypatch.setattr(P, "FactsExtractor", FakeExtractor)
|
|
81
|
+
pipe = P.DotNetRecoveryPipeline(str(tmp_path / "a.dll"), str(tmp_path / "root"))
|
|
82
|
+
assert pipe.load_facts() is facts
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_verify(tmp_path):
|
|
86
|
+
pipe = P.DotNetRecoveryPipeline(str(tmp_path / "a.dll"), str(tmp_path / "root"))
|
|
87
|
+
pipe.facts_path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
pipe.facts_path.write_text(
|
|
89
|
+
AssemblyFacts(assembly="Fake", strings={"b": {"p": "/x"}}, routes=["/x"]).to_json()
|
|
90
|
+
)
|
|
91
|
+
py = tmp_path / "gen.py"
|
|
92
|
+
py.write_text('"/x"')
|
|
93
|
+
report = pipe.verify(str(py))
|
|
94
|
+
assert report.ok
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from tentod.facts import AssemblyFacts
|
|
2
|
+
from tentod.verify import RecoveryVerifier
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _facts():
|
|
6
|
+
return AssemblyFacts(
|
|
7
|
+
assembly="Fake",
|
|
8
|
+
strings={
|
|
9
|
+
"bank": {
|
|
10
|
+
"p": "/alpha",
|
|
11
|
+
"r": "/beta",
|
|
12
|
+
"U": "keyname",
|
|
13
|
+
"log": "chatty internal message",
|
|
14
|
+
"empty": "",
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
routes=["/alpha", "/beta"],
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_all_routes_present_is_ok():
|
|
22
|
+
py = 'BASE = "..."\n"/alpha"\n"/beta"\n{"keyname": x}\n'
|
|
23
|
+
report = RecoveryVerifier().verify(_facts(), py)
|
|
24
|
+
assert report.ok
|
|
25
|
+
assert not report.missing_routes
|
|
26
|
+
assert "chatty internal message" in report.omitted
|
|
27
|
+
assert "/alpha" in report.matched
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_missing_route_fails():
|
|
31
|
+
py = '"/alpha"\n{"keyname": x}\n' # missing /beta
|
|
32
|
+
report = RecoveryVerifier().verify(_facts(), py)
|
|
33
|
+
assert not report.ok
|
|
34
|
+
assert report.missing_routes == ["/beta"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_empty_strings_ignored():
|
|
38
|
+
report = RecoveryVerifier().verify(_facts(), '"/alpha""/beta""keyname"')
|
|
39
|
+
assert "" not in report.matched
|
|
40
|
+
assert "" not in report.omitted
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_render_ok_verdict():
|
|
44
|
+
py = '"/alpha""/beta""keyname""chatty internal message"'
|
|
45
|
+
text = RecoveryVerifier().verify(_facts(), py).render()
|
|
46
|
+
assert text.endswith("OK")
|
|
47
|
+
assert "missing" not in text
|
|
48
|
+
assert "omitted" not in text # nothing omitted when everything matched
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_render_fail_lists_missing_and_omitted():
|
|
52
|
+
text = RecoveryVerifier().verify(_facts(), "nothing here").render()
|
|
53
|
+
assert "missing (2):" in text
|
|
54
|
+
assert " ! /alpha" in text
|
|
55
|
+
assert "omitted (4):" in text
|
|
56
|
+
assert text.endswith("FAIL")
|