tentod 0.1.0__py3-none-any.whl
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/GUIDE.md +53 -0
- tentod/__init__.py +1 -0
- tentod/__main__.py +81 -0
- tentod/_runtime.py +21 -0
- tentod/assembly.py +134 -0
- tentod/decompiler.py +59 -0
- tentod/deobfuscator.py +50 -0
- tentod/facts.py +51 -0
- tentod/pipeline.py +61 -0
- tentod/verify.py +45 -0
- tentod-0.1.0.dist-info/METADATA +37 -0
- tentod-0.1.0.dist-info/RECORD +14 -0
- tentod-0.1.0.dist-info/WHEEL +5 -0
- tentod-0.1.0.dist-info/top_level.txt +1 -0
tentod/GUIDE.md
ADDED
|
@@ -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.
|
tentod/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""tentod."""
|
tentod/__main__.py
ADDED
|
@@ -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()()
|
tentod/_runtime.py
ADDED
|
@@ -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
|
tentod/assembly.py
ADDED
|
@@ -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
|
tentod/decompiler.py
ADDED
|
@@ -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]
|
tentod/deobfuscator.py
ADDED
|
@@ -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
|
tentod/facts.py
ADDED
|
@@ -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
|
+
)
|
tentod/pipeline.py
ADDED
|
@@ -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)
|
tentod/verify.py
ADDED
|
@@ -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,14 @@
|
|
|
1
|
+
tentod/GUIDE.md,sha256=79-M-TsopO0Nukalfg2NZRQsPETLE4ZicRKUHrhRGSk,2706
|
|
2
|
+
tentod/__init__.py,sha256=DT_m3tcHQluSwE01_2q8ksVcfwCXDcVO4OtrykDXX-w,14
|
|
3
|
+
tentod/__main__.py,sha256=rJ6tc5paiElOsl2UUsAKcUbgEwrGEGglGMN09XvG6W8,2267
|
|
4
|
+
tentod/_runtime.py,sha256=jxmXbZZyvvQ35UM7kPd6ahEz4Qj0qxl-aHxDlR4LE8M,471
|
|
5
|
+
tentod/assembly.py,sha256=CqibMBrYanSDGhNKBi6fuYEg0Dq5SbBzvdmUt1R3kdI,4047
|
|
6
|
+
tentod/decompiler.py,sha256=HvU5gW8zd5NXE6GVS-CaLAUVOqj7EDS9l2Dm-P3ljnU,1866
|
|
7
|
+
tentod/deobfuscator.py,sha256=JTMCwe3WwXkT2YOHih7Jvfrf5P_DgDMvW2028Tv9d2A,1613
|
|
8
|
+
tentod/facts.py,sha256=RN4v7ut3kWDoyjykQe37lGhJqp1l7FGJ4q5eGVGDST0,1439
|
|
9
|
+
tentod/pipeline.py,sha256=Kkg9cx8ZtlRBgvH6NMKyVXAsaHvOkaX2kLHzAXxMDJM,1958
|
|
10
|
+
tentod/verify.py,sha256=FcYNzWNKstZl9To3tGiTKmk2RzFaF43YwyKwD6UddTw,1499
|
|
11
|
+
tentod-0.1.0.dist-info/METADATA,sha256=Edhm3FsPg0DWPyN6XIOfDLSo39Olb62C31Ao9A3y6Vw,951
|
|
12
|
+
tentod-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
13
|
+
tentod-0.1.0.dist-info/top_level.txt,sha256=rrntAvE4_6vnipt8jAnAiuSVEkCAfGloWl0diEKK010,7
|
|
14
|
+
tentod-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tentod
|