vb6decompiler 0.2.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.
vb6decompiler/pe.py ADDED
@@ -0,0 +1,387 @@
1
+ """PE surface of a VB6 install: what a program imports, exports and drags in.
2
+
3
+ Why this exists
4
+ ---------------
5
+ A VB6 application is almost never one file. The executable imports only
6
+ ``MSVBVM60.DLL`` and reaches everything else (ODBC, DAO, RDO, XML, mail, sockets)
7
+ through COM created at run time. Reading the import table therefore tells you
8
+ what the *runtime* needs, not what the program does — and the components it really
9
+ uses show up only as sibling DLLs/OCXs, ActiveX registrations and type libraries.
10
+
11
+ Two measured traps are encoded here:
12
+
13
+ * **Both thunk arrays must be walked.** In an import descriptor
14
+ ``OriginalFirstThunk`` and ``FirstThunk`` sit in different address ranges; naming
15
+ only ``OriginalFirstThunk`` leaves every ``call dword ptr [0x40xxxx]`` target
16
+ anonymous in the caller's disassembly. Walking both raised named call sites from
17
+ ~135k to ~267k on the reference target.
18
+ * **Do not hand-roll the export table.** A hand-written export reader produced
19
+ ``NumberOfFunctions ≈ 761,000`` for a 1 MB OCX; ``pefile`` read the same file as
20
+ four standard COM registration exports. Use :func:`exports` here (bounded and
21
+ validated) or ``pefile`` — never a quick struct.unpack.
22
+
23
+ Nothing in this module needs a third-party package.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import struct
30
+ from dataclasses import dataclass, field
31
+ from typing import Iterable
32
+
33
+ #: Imports that identify a VB6-built binary. ``MSVBVM60`` = VB6, ``MSVBVM50`` = VB5.
34
+ VB6_RUNTIMES = {"MSVBVM60.DLL", "MSVBVM50.DLL"}
35
+
36
+ #: The four exports every self-registering COM component carries.
37
+ COM_REGISTRATION_EXPORTS = {
38
+ "DllRegisterServer",
39
+ "DllUnregisterServer",
40
+ "DllGetClassObject",
41
+ "DllCanUnloadNow",
42
+ }
43
+
44
+ #: Data directory indices used below (PE spec order).
45
+ _DIR_EXPORT = 0
46
+ _DIR_IMPORT = 1
47
+ _DIR_RESOURCE = 2
48
+
49
+
50
+ class PEError(Exception):
51
+ """The file is not a PE image this module can read."""
52
+
53
+
54
+ @dataclass
55
+ class Section:
56
+ name: str
57
+ virtual_address: int
58
+ virtual_size: int
59
+ raw_offset: int
60
+ raw_size: int
61
+ characteristics: int
62
+
63
+ @property
64
+ def contains(self) -> bool:
65
+ return bool(self.characteristics & 0x80000000) # IMAGE_SCN_MEM_WRITE
66
+
67
+ def va_to_offset(self, va: int) -> int | None:
68
+ delta = va - self.virtual_address
69
+ if 0 <= delta < max(self.raw_size, self.virtual_size):
70
+ return self.raw_offset + delta
71
+ return None
72
+
73
+
74
+ @dataclass
75
+ class Import:
76
+ dll: str
77
+ functions: list[str] = field(default_factory=list)
78
+ unnamed: int = 0
79
+ is_ordinal_only: int = 0
80
+
81
+ @property
82
+ def is_vb6_runtime(self) -> bool:
83
+ return self.dll.upper() in VB6_RUNTIMES
84
+
85
+
86
+ @dataclass
87
+ class PEImage:
88
+ path: str
89
+ machine: int
90
+ image_base: int
91
+ entry_point_rva: int
92
+ sections: list[Section]
93
+ imports: list[Import]
94
+ exports: list[tuple[str | None, int]] # (name or None, ordinal)
95
+ size: int
96
+
97
+ # -- addressing ---------------------------------------------------------
98
+
99
+ @property
100
+ def bits(self) -> int:
101
+ return 64 if self.machine in (0x8664, 0xAA64) else 32
102
+
103
+ @property
104
+ def is_vb6(self) -> bool:
105
+ return any(i.is_vb6_runtime for i in self.imports)
106
+
107
+ @property
108
+ def entry_point(self) -> int:
109
+ """Absolute virtual address of the entry point (ImageBase + RVA)."""
110
+ return self.image_base + self.entry_point_rva
111
+
112
+ def va_to_offset(self, va: int) -> int | None:
113
+ """Absolute VA -> file offset.
114
+
115
+ VB6 native builds use a fixed ``ImageBase`` of 0x400000, so
116
+ ``offset = VA - ImageBase`` works for the common case; this function is
117
+ the honest version: it walks the section table and returns ``None`` for
118
+ addresses that live outside every raw section (BSS, headers).
119
+ """
120
+ if va < self.image_base:
121
+ return None
122
+ rva = va - self.image_base
123
+ for sec in self.sections:
124
+ off = sec.va_to_offset(rva)
125
+ if off is not None:
126
+ return off
127
+ return None
128
+
129
+ def section_of(self, rva: int) -> Section | None:
130
+ for sec in self.sections:
131
+ if sec.virtual_address <= rva < sec.virtual_address + max(
132
+ sec.virtual_size, sec.raw_size
133
+ ):
134
+ return sec
135
+ return None
136
+
137
+ # -- convenience --------------------------------------------------------
138
+
139
+ @property
140
+ def import_dlls(self) -> list[str]:
141
+ return [i.dll for i in self.imports]
142
+
143
+ def summary(self) -> dict:
144
+ return {
145
+ "path": self.path,
146
+ "bits": self.bits,
147
+ "image_base": hex(self.image_base),
148
+ "entry_point": hex(self.entry_point),
149
+ "vb6": self.is_vb6,
150
+ "import_dlls": len(self.imports),
151
+ "import_functions": sum(len(i.functions) + i.unnamed for i in self.imports),
152
+ "exports": len(self.exports),
153
+ "com_registration": sorted(
154
+ n for n, _ in self.exports if n in COM_REGISTRATION_EXPORTS
155
+ ),
156
+ }
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # parsing
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ def _u16(b: bytes, off: int) -> int:
165
+ return struct.unpack_from("<H", b, off)[0]
166
+
167
+
168
+ def _u32(b: bytes, off: int) -> int:
169
+ return struct.unpack_from("<I", b, off)[0]
170
+
171
+
172
+ def read(path: str) -> PEImage:
173
+ """Parse a PE file. Raises :class:`PEError` for anything else."""
174
+ with open(path, "rb") as fh:
175
+ data = fh.read()
176
+
177
+ if data[:2] != b"MZ":
178
+ raise PEError(f"{os.path.basename(path)}: not an MZ image")
179
+ pe_off = _u32(data, 0x3C)
180
+ if data[pe_off : pe_off + 4] != b"PE\0\0":
181
+ raise PEError(f"{os.path.basename(path)}: no PE signature")
182
+
183
+ coff = pe_off + 4
184
+ machine = _u16(data, coff)
185
+ n_sections = _u16(data, coff + 2)
186
+ opt_size = _u16(data, coff + 16)
187
+ opt = coff + 20
188
+ magic = _u16(data, opt)
189
+ if magic == 0x10B:
190
+ bits = 32
191
+ image_base = _u32(data, opt + 28)
192
+ directory_count = _u32(data, opt + 92)
193
+ directory_off = opt + 96
194
+ elif magic == 0x20B:
195
+ bits = 64
196
+ image_base = struct.unpack_from("<Q", data, opt + 24)[0]
197
+ directory_count = _u32(data, opt + 108)
198
+ directory_off = opt + 112
199
+ else:
200
+ raise PEError(f"{os.path.basename(path)}: unknown optional header magic 0x{magic:X}")
201
+ entry_point_rva = _u32(data, opt + 16)
202
+
203
+ sections: list[Section] = []
204
+ sec_off = opt + opt_size
205
+ for i in range(n_sections):
206
+ base = sec_off + i * 40
207
+ name = data[base : base + 8].split(b"\0")[0].decode("latin1", "replace")
208
+ sections.append(
209
+ Section(
210
+ name=name,
211
+ virtual_size=_u32(data, base + 8),
212
+ virtual_address=_u32(data, base + 12),
213
+ raw_size=_u32(data, base + 16),
214
+ raw_offset=_u32(data, base + 20),
215
+ characteristics=_u32(data, base + 36),
216
+ )
217
+ )
218
+
219
+ def directory(index: int) -> tuple[int, int]:
220
+ if index >= directory_count:
221
+ return (0, 0)
222
+ off = directory_off + index * 8
223
+ return (_u32(data, off), _u32(data, off + 4))
224
+
225
+ def rva_to_off(rva: int) -> int | None:
226
+ for sec in sections:
227
+ if sec.virtual_address <= rva < sec.virtual_address + max(
228
+ sec.virtual_size, sec.raw_size
229
+ ):
230
+ delta = rva - sec.virtual_address
231
+ if delta < sec.raw_size:
232
+ return sec.raw_offset + delta
233
+ return None # inside .bss: no file bytes
234
+ return None
235
+
236
+ imports = _read_imports(data, directory(_DIR_IMPORT), rva_to_off, bits)
237
+ exports = _read_exports(data, directory(_DIR_EXPORT), rva_to_off)
238
+
239
+ return PEImage(
240
+ path=path,
241
+ machine=machine,
242
+ image_base=image_base,
243
+ entry_point_rva=entry_point_rva,
244
+ sections=sections,
245
+ imports=imports,
246
+ exports=exports,
247
+ size=len(data),
248
+ )
249
+
250
+
251
+ def _read_imports(data: bytes, directory: tuple[int, int], rva_to_off, bits: int):
252
+ rva, size = directory
253
+ if not rva:
254
+ return []
255
+ off = rva_to_off(rva)
256
+ if off is None:
257
+ return []
258
+ out: list[Import] = []
259
+ # 5 dwords per descriptor; a zeroed descriptor terminates the array.
260
+ while off + 20 <= len(data):
261
+ oft, _ts, _fwd, name_rva, ft = struct.unpack_from("<IIIII", data, off)
262
+ if not (oft or name_rva or ft):
263
+ break
264
+ name_off = rva_to_off(name_rva)
265
+ dll = ""
266
+ if name_off is not None:
267
+ end = data.find(b"\0", name_off)
268
+ dll = data[name_off:end].decode("latin1", "replace")
269
+ entry = Import(dll=dll)
270
+
271
+ # Both thunk arrays: OriginalFirstThunk holds the name RVA table,
272
+ # FirstThunk holds the loaded addresses. Some linkers leave OFT empty, so
273
+ # the walk is done on each and functions are de-duplicated.
274
+ seen: set[int] = set()
275
+ for thunk_rva in (oft, ft):
276
+ if not thunk_rva:
277
+ continue
278
+ t_off = rva_to_off(thunk_rva)
279
+ if t_off is None:
280
+ continue
281
+ width = 4 if bits == 32 else 8
282
+ step = width
283
+ for n in range(4096): # bounded: a corrupt table must not loop
284
+ pos = t_off + n * step
285
+ if pos + step > len(data):
286
+ break
287
+ value = struct.unpack_from("<I" if width == 4 else "<Q", data, pos)[0]
288
+ if value == 0:
289
+ break
290
+ if value & (1 << (31 if width == 4 else 63)):
291
+ ordinal = value & 0xFFFF
292
+ marker = 0x80000000 | ordinal
293
+ if marker in seen:
294
+ continue
295
+ seen.add(marker)
296
+ entry.unnamed += 1
297
+ entry.is_ordinal_only += 1
298
+ continue
299
+ hint_off = rva_to_off(value)
300
+ if hint_off is None:
301
+ continue
302
+ if hint_off in seen:
303
+ continue
304
+ seen.add(hint_off)
305
+ end = data.find(b"\0", hint_off + 2)
306
+ fname = data[hint_off + 2 : end].decode("latin1", "replace")
307
+ if fname:
308
+ entry.functions.append(fname)
309
+ out.append(entry)
310
+ off += 20
311
+ if len(out) > 512:
312
+ break
313
+ return out
314
+
315
+
316
+ def _is_x86(data: bytes) -> bool:
317
+ pe_off = _u32(data, 0x3C)
318
+ return _u16(data, pe_off + 4) in (0x14C,)
319
+
320
+
321
+ def _read_exports(data: bytes, directory: tuple[int, int], rva_to_off):
322
+ rva, size = directory
323
+ if not rva:
324
+ return []
325
+ off = rva_to_off(rva)
326
+ if off is None or off + 40 > len(data):
327
+ return []
328
+ n_functions = _u32(data, off + 20)
329
+ n_names = _u32(data, off + 24)
330
+ addr_funcs = _u32(data, off + 28)
331
+ addr_names = _u32(data, off + 32)
332
+ addr_ordinals = _u32(data, off + 36)
333
+ # Bound both tables by the export directory's own size: a corrupt count is
334
+ # what produced the 761,000-export reading in the hand-written reader.
335
+ if n_names > 65535 or n_functions > 65535:
336
+ raise PEError(
337
+ f"export table claims {n_names} names / {n_functions} functions; refusing to read"
338
+ )
339
+ out: list[tuple[str | None, int]] = []
340
+ names_by_ordinal: dict[int, str] = {}
341
+ names_off = rva_to_off(addr_names)
342
+ ords_off = rva_to_off(addr_ordinals)
343
+ if names_off is not None and ords_off is not None:
344
+ for i in range(min(n_names, 8192)):
345
+ name_rva = _u32(data, names_off + i * 4)
346
+ ordinal_index = _u16(data, ords_off + i * 2)
347
+ name_off = rva_to_off(name_rva)
348
+ if name_off is None:
349
+ continue
350
+ end = data.find(b"\0", name_off)
351
+ names_by_ordinal[ordinal_index] = data[name_off:end].decode("latin1", "replace")
352
+ funcs_off = rva_to_off(addr_funcs)
353
+ count = min(n_functions, 8192) if funcs_off is not None else n_names
354
+ for i in range(count):
355
+ out.append((names_by_ordinal.get(i), i + 1))
356
+ return out
357
+
358
+
359
+ # ---------------------------------------------------------------------------
360
+ # directory-level helpers
361
+ # ---------------------------------------------------------------------------
362
+
363
+
364
+ def scan_directory(path: str, recursive: bool = False) -> list[dict]:
365
+ """PE summary for every ``.exe/.dll/.ocx`` under a path (or one file)."""
366
+ if os.path.isfile(path):
367
+ files = [path]
368
+ else:
369
+ files = []
370
+ for root, _dirs, names in os.walk(path):
371
+ for n in names:
372
+ if n.lower().endswith((".exe", ".dll", ".ocx")):
373
+ files.append(os.path.join(root, n))
374
+ if not recursive:
375
+ break
376
+ out = []
377
+ for f in sorted(files):
378
+ try:
379
+ out.append(read(f).summary())
380
+ except (PEError, OSError, struct.error) as exc:
381
+ out.append({"path": f, "error": str(exc)})
382
+ return out
383
+
384
+
385
+ def vb6_binaries(images: Iterable[dict]) -> list[str]:
386
+ """Which of these images import a VB6 runtime (i.e. carry VB6 code)."""
387
+ return [i["path"] for i in images if i.get("vb6")]