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/__init__.py +11 -0
- vb6decompiler/blob.py +1067 -0
- vb6decompiler/cli.py +292 -0
- vb6decompiler/datareport.py +886 -0
- vb6decompiler/frm.py +1107 -0
- vb6decompiler/literals.py +275 -0
- vb6decompiler/mask.py +192 -0
- vb6decompiler/pe.py +387 -0
- vb6decompiler/procs.py +444 -0
- vb6decompiler/sqlstitch.py +227 -0
- vb6decompiler-0.2.0.dist-info/METADATA +279 -0
- vb6decompiler-0.2.0.dist-info/RECORD +16 -0
- vb6decompiler-0.2.0.dist-info/WHEEL +5 -0
- vb6decompiler-0.2.0.dist-info/entry_points.txt +2 -0
- vb6decompiler-0.2.0.dist-info/licenses/LICENSE +21 -0
- vb6decompiler-0.2.0.dist-info/top_level.txt +1 -0
vb6decompiler/procs.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""The VB6 procedure table, read from the executable itself.
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
A native VB6 executable carries its own metadata: a ``VB5!`` header points at a
|
|
6
|
+
``ProjectInfo``, which points at an ``ObjectTable``, which lists every form,
|
|
7
|
+
module and class with a pointer to its method table — and each method table entry
|
|
8
|
+
is the entry address of compiled code. No symbols, no PDB, no decompiler needed.
|
|
9
|
+
|
|
10
|
+
Measured on a 111 MB target: **834 objects / 34,415 methods** read from the header
|
|
11
|
+
chain in 0.4 s, with zero decompiler involvement. A generic recursive-descent
|
|
12
|
+
engine on the same file found 6,875 functions (20 % of the 34,159 entries a
|
|
13
|
+
decompiler listing gave) and stopped at its instruction cap; the engine said so
|
|
14
|
+
itself.
|
|
15
|
+
|
|
16
|
+
**A native build does not expose method entry addresses here.** In the same target
|
|
17
|
+
the ``ObjectInfo`` block resolves, but its documented ``lpMethods`` slot holds no
|
|
18
|
+
table of code addresses (measured: every candidate pointer in the block pointed at
|
|
19
|
+
code bytes, not at an array of entry points, and none of the 9 known entries of a
|
|
20
|
+
test form appeared anywhere in the block). Object and method *counts* are evidence;
|
|
21
|
+
the addresses are not, so :class:`Method` keeps ``address=None`` instead of
|
|
22
|
+
inventing one. Entry addresses come from a decompiler listing's ``'VA`` comments —
|
|
23
|
+
see :func:`merge_listing_names` — and are marked as such.
|
|
24
|
+
|
|
25
|
+
Chain (offsets are the documented VB6 layout used by vb6rs and Alex Ionescu's
|
|
26
|
+
notes; each was verified by resolving a field twice and comparing):
|
|
27
|
+
|
|
28
|
+
VBHeader ("VB5!" — note: VB5 and VB6 share the magic)
|
|
29
|
+
+0x1C lpProjectData -> ProjectInfo (dwVersion 0x1F4)
|
|
30
|
+
ProjectInfo+0x04 -> ObjectTable
|
|
31
|
+
ObjectTable+0x2A total_objects (u16)
|
|
32
|
+
ObjectTable+0x2C compiled_objects(u16)
|
|
33
|
+
ObjectTable+0x30 lpObjectArray -> 0x30-byte records
|
|
34
|
+
ObjectTable+0x40 project name VA
|
|
35
|
+
ObjectTable+0x50 identifier (GUID-ish, hex)
|
|
36
|
+
record+0x00 lpObjectInfo -> ObjectInfo (+0x20 wMethodCount, +0x24 lpMethods,
|
|
37
|
+
+0x28 constants count)
|
|
38
|
+
record+0x1C name VA, +0x20 method_count, +0x24 lpMethodNames, +0x28 fObjectType
|
|
39
|
+
|
|
40
|
+
Addresses inside these structures are VA when ``>= ImageBase``, RVA otherwise, and
|
|
41
|
+
occasionally a raw file offset. :func:`_Resolver.read` tries all three in that
|
|
42
|
+
order; a field that resolves under two interpretations is a bug in the caller, not
|
|
43
|
+
in the file.
|
|
44
|
+
|
|
45
|
+
What is *not* recoverable: method names for modules (VB6 stores them for classes
|
|
46
|
+
and forms only, and only as the designer's names), and the concept of "event
|
|
47
|
+
handler" — a form method is just a method whose name happens to look like
|
|
48
|
+
``Command1_Click``. Names from a decompiler's listing can be merged in with
|
|
49
|
+
:func:`merge_listing_names`; they are hints, not evidence.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import os
|
|
55
|
+
import re
|
|
56
|
+
import struct
|
|
57
|
+
from dataclasses import dataclass, field
|
|
58
|
+
|
|
59
|
+
#: fObjectType values measured in one real native build (834 objects). The value is
|
|
60
|
+
#: not a small enum: the same build reported 0x18001 for modules, 0x18083 for forms,
|
|
61
|
+
#: 0x118003 for a class and 0x1018083 for the 24 embedded DataReport objects, so a
|
|
62
|
+
#: bit-mask guess would mislabel them. Unknown values are printed as hex, never
|
|
63
|
+
#: mapped to the nearest known type.
|
|
64
|
+
OBJECT_TYPES = {
|
|
65
|
+
0x18001: "module",
|
|
66
|
+
0x18083: "form",
|
|
67
|
+
0x118003: "class",
|
|
68
|
+
0x1018083: "datareport",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#: A VB6 procedure starts with ``push ebp; mov ebp, esp`` (55 8B EC). The bytes
|
|
72
|
+
#: after that vary (``83 EC``, ``6A``, ``E8``). Used to prove that a table entry
|
|
73
|
+
#: really is a procedure entry and not a stub or a data pointer.
|
|
74
|
+
PROLOGUE = b"\x55\x8b\xec"
|
|
75
|
+
|
|
76
|
+
#: Header of a procedure in a decompiler listing, e.g.
|
|
77
|
+
#: ``Private Sub Command1_Click() '36FB300`` — the trailing hex is the VA and is
|
|
78
|
+
#: the only reliable identity (names repeat within a form: one target had 267
|
|
79
|
+
#: bodies in a form all labelled ``FisButton_Click``).
|
|
80
|
+
LISTING_HEADER = re.compile(
|
|
81
|
+
r"^\s*(?:Private|Public|Friend|Static)?\s*"
|
|
82
|
+
r"(?:Sub|Function|Property\s+(?:Get|Let|Set))\s+([A-Za-z_]\w*)?\s*\(?.*?'\s*([0-9A-Fa-f]{6,8})\s*$"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class Method:
|
|
88
|
+
index: int
|
|
89
|
+
address: int | None # verified entry VA (prologue present); None otherwise
|
|
90
|
+
file_offset: int | None
|
|
91
|
+
in_code: bool
|
|
92
|
+
name: str | None = None
|
|
93
|
+
raw_pointer: int | None = None # what the table actually held, for diagnostics
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class VBObject:
|
|
98
|
+
index: int
|
|
99
|
+
name: str
|
|
100
|
+
object_type: str
|
|
101
|
+
raw_type: int
|
|
102
|
+
method_count: int
|
|
103
|
+
methods: list[Method] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def method_addresses(self) -> list[int]:
|
|
107
|
+
return [m.address for m in self.methods if m.address is not None]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class VBError(Exception):
|
|
111
|
+
"""The file has no readable VB6 header."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _Resolver:
|
|
115
|
+
"""Pointer field -> file bytes, trying VA, then RVA, then raw offset."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, data: bytes, image_base: int, sections: list[dict]):
|
|
118
|
+
self.data = data
|
|
119
|
+
self.base = image_base
|
|
120
|
+
self.sections = sections
|
|
121
|
+
|
|
122
|
+
def rva_to_off(self, rva: int) -> int | None:
|
|
123
|
+
for s in self.sections:
|
|
124
|
+
span = max(s["vsize"], s["rawsize"])
|
|
125
|
+
if s["rva"] <= rva < s["rva"] + span:
|
|
126
|
+
delta = rva - s["rva"]
|
|
127
|
+
if delta < s["rawsize"]:
|
|
128
|
+
return s["rawptr"] + delta
|
|
129
|
+
return None
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
def va_to_off(self, va: int) -> int | None:
|
|
133
|
+
if va >= self.base:
|
|
134
|
+
return self.rva_to_off(va - self.base)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
def read(self, ptr: int, size: int) -> tuple[bytes | None, int | None]:
|
|
138
|
+
for cand in (self.va_to_off(ptr), self.rva_to_off(ptr)):
|
|
139
|
+
if cand is not None and 0 <= cand and cand + size <= len(self.data):
|
|
140
|
+
return self.data[cand : cand + size], cand
|
|
141
|
+
if 0 <= ptr and ptr + size <= len(self.data):
|
|
142
|
+
return self.data[ptr : ptr + size], ptr
|
|
143
|
+
return None, None
|
|
144
|
+
|
|
145
|
+
def cstr(self, ptr: int, limit: int = 256) -> str | None:
|
|
146
|
+
raw, _ = self.read(ptr, limit)
|
|
147
|
+
if raw is None:
|
|
148
|
+
return None
|
|
149
|
+
cut = raw.find(b"\0")
|
|
150
|
+
if cut < 0:
|
|
151
|
+
return None
|
|
152
|
+
return decode_cp1254(raw[:cut])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def decode_cp1254(raw: bytes) -> str:
|
|
156
|
+
"""VB6 strings are CP1254 (Turkish). Never latin-1: it silently mangles İ/ş/ğ."""
|
|
157
|
+
try:
|
|
158
|
+
return raw.decode("cp1254")
|
|
159
|
+
except UnicodeDecodeError:
|
|
160
|
+
return raw.decode("latin1", "replace")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _sections(data: bytes) -> tuple[list[dict], int]:
|
|
164
|
+
pe_off = struct.unpack_from("<I", data, 0x3C)[0]
|
|
165
|
+
coff = pe_off + 4
|
|
166
|
+
machine = struct.unpack_from("<H", data, coff)[0]
|
|
167
|
+
nsec = struct.unpack_from("<H", data, coff + 2)[0]
|
|
168
|
+
optsize = struct.unpack_from("<H", data, coff + 16)[0]
|
|
169
|
+
opt = coff + 20
|
|
170
|
+
is32 = machine == 0x14C
|
|
171
|
+
image_base = (
|
|
172
|
+
struct.unpack_from("<I", data, opt + 28)[0]
|
|
173
|
+
if is32
|
|
174
|
+
else struct.unpack_from("<Q", data, opt + 24)[0]
|
|
175
|
+
)
|
|
176
|
+
out = []
|
|
177
|
+
so = opt + optsize
|
|
178
|
+
for i in range(nsec):
|
|
179
|
+
b = so + i * 40
|
|
180
|
+
name = data[b : b + 8].rstrip(b"\0").decode("latin1", "replace")
|
|
181
|
+
vsize, va, rawsize, rawptr = struct.unpack_from("<IIII", data, b + 8)
|
|
182
|
+
out.append({"name": name, "rva": va, "vsize": vsize, "rawptr": rawptr, "rawsize": rawsize})
|
|
183
|
+
return out, image_base
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _vb_header(data: bytes) -> dict | None:
|
|
187
|
+
off = data.find(b"VB5!")
|
|
188
|
+
if off < 0 or off + 104 > len(data):
|
|
189
|
+
return None
|
|
190
|
+
keys = [
|
|
191
|
+
"magic", "runtime_build", "lang_dll", "seclang_dll", "runtime_rev",
|
|
192
|
+
"lcid", "seclcid", "lpSubMain", "lpProjectData", "fMdlIntCtls",
|
|
193
|
+
"fMdlIntCtls2", "thread_flags", "thread_count", "form_count",
|
|
194
|
+
"external_count", "thunk_count", "lpGuiTable", "lpExternalTable",
|
|
195
|
+
"lpComRegisterData", "desc_selfrel", "exe_selfrel", "help_selfrel", "name_selfrel",
|
|
196
|
+
]
|
|
197
|
+
vals = struct.unpack_from("<4sH14s14sH" + "I" * 8 + "HH" + "I" * 8, data, off)
|
|
198
|
+
hdr = dict(zip(keys, vals))
|
|
199
|
+
hdr["offset"] = off
|
|
200
|
+
return hdr
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass
|
|
204
|
+
class VBProject:
|
|
205
|
+
path: str
|
|
206
|
+
image_base: int
|
|
207
|
+
project_name: str | None
|
|
208
|
+
project_description: str | None
|
|
209
|
+
runtime_build: int
|
|
210
|
+
runtime_rev: int
|
|
211
|
+
form_count: int
|
|
212
|
+
object_count: int
|
|
213
|
+
compiled_count: int
|
|
214
|
+
identifier: str
|
|
215
|
+
objects: list[VBObject]
|
|
216
|
+
|
|
217
|
+
# -- derived facts ------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def methods(self) -> list[tuple[VBObject, Method]]:
|
|
221
|
+
return [(o, m) for o in self.objects for m in o.methods]
|
|
222
|
+
|
|
223
|
+
def procedure_addresses(self) -> list[int]:
|
|
224
|
+
"""Unique method entry addresses *if the build exposed any* (often none)."""
|
|
225
|
+
return sorted({a for o in self.objects for a in o.method_addresses})
|
|
226
|
+
|
|
227
|
+
def methods_by_address(self) -> list[tuple[VBObject, "Method"]]:
|
|
228
|
+
"""Only the (object, method) pairs whose address a prologue verified."""
|
|
229
|
+
return [(o, m) for o in self.objects for m in o.methods if m.address is not None]
|
|
230
|
+
|
|
231
|
+
def object_of(self, va: int) -> VBObject | None:
|
|
232
|
+
for o in self.objects:
|
|
233
|
+
if va in o.method_addresses:
|
|
234
|
+
return o
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
def stats(self) -> dict:
|
|
238
|
+
addrs = self.procedure_addresses()
|
|
239
|
+
return {
|
|
240
|
+
"name": self.project_name,
|
|
241
|
+
"objects": len(self.objects),
|
|
242
|
+
"declared_objects": self.object_count,
|
|
243
|
+
"methods": sum(o.method_count for o in self.objects),
|
|
244
|
+
"method_entries_read": sum(len(o.methods) for o in self.objects),
|
|
245
|
+
"unique_entries": len(addrs),
|
|
246
|
+
"methods_without_address": sum(
|
|
247
|
+
1 for o in self.objects for m in o.methods if m.address is None
|
|
248
|
+
),
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def read(path: str) -> VBProject:
|
|
253
|
+
"""Decode the VB6 header chain of one executable or DLL."""
|
|
254
|
+
with open(path, "rb") as fh:
|
|
255
|
+
data = fh.read()
|
|
256
|
+
if data[:2] != b"MZ":
|
|
257
|
+
raise VBError(f"{os.path.basename(path)}: not an MZ image (pass design text to 'vb6d frm', compiled binaries here)")
|
|
258
|
+
try:
|
|
259
|
+
sections, image_base = _sections(data)
|
|
260
|
+
except struct.error as exc:
|
|
261
|
+
# A text file or a stub with junk at 0x3C computes offsets past the buffer;
|
|
262
|
+
# say what the input is, not what unpack_from wanted (same trio as blob.py).
|
|
263
|
+
raise VBError(f"{os.path.basename(path)}: PE header did not parse (truncated, or not a compiled binary)") from exc
|
|
264
|
+
hdr = _vb_header(data)
|
|
265
|
+
if hdr is None:
|
|
266
|
+
raise VBError(f"{os.path.basename(path)}: no 'VB5!' header (not a VB5/VB6 build)")
|
|
267
|
+
res = _Resolver(data, image_base, sections)
|
|
268
|
+
|
|
269
|
+
def selfrel(rel: int, limit: int = 400) -> str | None:
|
|
270
|
+
if not rel:
|
|
271
|
+
return None
|
|
272
|
+
o = hdr["offset"] + rel # bSZ* fields are relative to the VBHeader itself
|
|
273
|
+
if not (0 <= o < len(data)):
|
|
274
|
+
return None
|
|
275
|
+
cut = data.find(b"\0", o, o + limit)
|
|
276
|
+
return decode_cp1254(data[o:cut]) if cut > 0 else None
|
|
277
|
+
|
|
278
|
+
raw, _ = res.read(hdr["lpProjectData"], 0x23C)
|
|
279
|
+
if raw is None:
|
|
280
|
+
raise VBError(f"{os.path.basename(path)}: ProjectInfo pointer did not resolve")
|
|
281
|
+
lp_object_table = struct.unpack_from("<I", raw, 4)[0]
|
|
282
|
+
|
|
283
|
+
raw, _ = res.read(lp_object_table, 0x54)
|
|
284
|
+
if raw is None:
|
|
285
|
+
raise VBError(f"{os.path.basename(path)}: ObjectTable pointer did not resolve")
|
|
286
|
+
total_objects = struct.unpack_from("<H", raw, 0x2A)[0]
|
|
287
|
+
compiled = struct.unpack_from("<H", raw, 0x2C)[0]
|
|
288
|
+
lp_object_array = struct.unpack_from("<I", raw, 0x30)[0]
|
|
289
|
+
name_va = struct.unpack_from("<I", raw, 0x40)[0]
|
|
290
|
+
identifier = struct.unpack_from("<I", raw, 0x50)[0]
|
|
291
|
+
|
|
292
|
+
raw, _ = res.read(lp_object_array, max(1, total_objects) * 0x30)
|
|
293
|
+
if raw is None:
|
|
294
|
+
raise VBError(f"{os.path.basename(path)}: object array did not resolve")
|
|
295
|
+
|
|
296
|
+
objects: list[VBObject] = []
|
|
297
|
+
for i in range(total_objects):
|
|
298
|
+
rec = raw[i * 0x30 : (i + 1) * 0x30]
|
|
299
|
+
if len(rec) < 0x30:
|
|
300
|
+
break
|
|
301
|
+
(lp_objinfo, _reserved, _lp_pub, _lp_stat, _lp_modpub, _lp_modstat,
|
|
302
|
+
name_ptr, method_count, method_names_va, _o_stat, f_type, _null) = struct.unpack(
|
|
303
|
+
"<12I", rec
|
|
304
|
+
)
|
|
305
|
+
obj = VBObject(
|
|
306
|
+
index=i,
|
|
307
|
+
name=res.cstr(name_ptr) or f"<object {i}>",
|
|
308
|
+
object_type=OBJECT_TYPES.get(f_type, hex(f_type)),
|
|
309
|
+
raw_type=f_type,
|
|
310
|
+
method_count=method_count,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
info, _ = res.read(lp_objinfo, 0x38)
|
|
314
|
+
method_ptrs: list[int] = []
|
|
315
|
+
if info:
|
|
316
|
+
lp_methods = struct.unpack_from("<I", info, 0x24)[0]
|
|
317
|
+
w_methods = struct.unpack_from("<H", info, 0x20)[0]
|
|
318
|
+
n = method_count or w_methods
|
|
319
|
+
m_raw, _ = res.read(lp_methods, max(1, n) * 4)
|
|
320
|
+
if m_raw:
|
|
321
|
+
for j in range(n):
|
|
322
|
+
method_ptrs.append(struct.unpack_from("<I", m_raw, j * 4)[0])
|
|
323
|
+
|
|
324
|
+
names: list[str | None] = []
|
|
325
|
+
if method_names_va:
|
|
326
|
+
nm, _ = res.read(method_names_va, max(1, method_count) * 4)
|
|
327
|
+
if nm:
|
|
328
|
+
for j in range(method_count):
|
|
329
|
+
names.append(res.cstr(struct.unpack_from("<I", nm, j * 4)[0], 128))
|
|
330
|
+
|
|
331
|
+
for j, ptr in enumerate(method_ptrs):
|
|
332
|
+
off = res.va_to_off(ptr) if ptr else None
|
|
333
|
+
if off is None and ptr:
|
|
334
|
+
off = res.rva_to_off(ptr)
|
|
335
|
+
# A pointer only counts as a method entry if the bytes there are a VB6
|
|
336
|
+
# prologue. In the reference native build the slot holds small integers
|
|
337
|
+
# (0x4, 0x7), which are not addresses at all: accepting them produced
|
|
338
|
+
# 208 "methods" of which 0 were code.
|
|
339
|
+
is_code = bool(
|
|
340
|
+
off is not None
|
|
341
|
+
and 0 <= off < len(data) - 3
|
|
342
|
+
and data[off : off + 3] == PROLOGUE
|
|
343
|
+
)
|
|
344
|
+
obj.methods.append(
|
|
345
|
+
Method(
|
|
346
|
+
index=j,
|
|
347
|
+
address=ptr if is_code else None,
|
|
348
|
+
file_offset=off,
|
|
349
|
+
in_code=is_code,
|
|
350
|
+
name=names[j] if j < len(names) else None,
|
|
351
|
+
raw_pointer=ptr,
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
objects.append(obj)
|
|
355
|
+
|
|
356
|
+
return VBProject(
|
|
357
|
+
path=path,
|
|
358
|
+
image_base=image_base,
|
|
359
|
+
project_name=res.cstr(name_va),
|
|
360
|
+
project_description=selfrel(hdr["desc_selfrel"]),
|
|
361
|
+
runtime_build=hdr["runtime_build"],
|
|
362
|
+
runtime_rev=hdr["runtime_rev"],
|
|
363
|
+
form_count=hdr["form_count"],
|
|
364
|
+
object_count=total_objects,
|
|
365
|
+
compiled_count=compiled,
|
|
366
|
+
identifier=f"{identifier:08X}",
|
|
367
|
+
objects=objects,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
# verification helpers
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def prologue_report(path: str, project: VBProject | None = None, sample: int | None = None) -> dict:
|
|
377
|
+
"""How many table entries really start with the VB6 prologue.
|
|
378
|
+
|
|
379
|
+
A table entry that is *not* a prologue is usually a wrapper, a thunk or a
|
|
380
|
+
designer-only method with no code — count it separately, do not call it "code
|
|
381
|
+
we missed".
|
|
382
|
+
"""
|
|
383
|
+
project = project or read(path)
|
|
384
|
+
with open(path, "rb") as fh:
|
|
385
|
+
data = fh.read()
|
|
386
|
+
addrs = project.procedure_addresses()
|
|
387
|
+
if sample:
|
|
388
|
+
addrs = addrs[:sample]
|
|
389
|
+
ok, bad = 0, []
|
|
390
|
+
for va in addrs:
|
|
391
|
+
off = va - project.image_base
|
|
392
|
+
if 0 <= off < len(data) - 3 and data[off : off + 3] == PROLOGUE:
|
|
393
|
+
ok += 1
|
|
394
|
+
else:
|
|
395
|
+
bad.append(va)
|
|
396
|
+
return {
|
|
397
|
+
"checked": len(addrs),
|
|
398
|
+
"prologue": ok,
|
|
399
|
+
"not_prologue": len(bad),
|
|
400
|
+
"examples_not_prologue": [hex(a) for a in bad[:10]],
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def body_bounds(addresses: list[int]) -> dict[int, int]:
|
|
405
|
+
"""Procedure body end = next procedure start in address order.
|
|
406
|
+
|
|
407
|
+
This is the honest bound for a *contiguous* method table. It is wrong for any
|
|
408
|
+
code the table does not describe (shared helpers, generated thunks), which is
|
|
409
|
+
why callers must not treat the result as a parsed body: the bytes between two
|
|
410
|
+
entries can belong to a helper that has no table entry.
|
|
411
|
+
"""
|
|
412
|
+
ordered = sorted(set(addresses))
|
|
413
|
+
return {va: (ordered[i + 1] if i + 1 < len(ordered) else None) for i, va in enumerate(ordered)}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def parse_listing(path: str) -> tuple[str, dict[int, str]]:
|
|
417
|
+
"""Names from a decompiler listing: ``{'obj':..., va: name}``.
|
|
418
|
+
|
|
419
|
+
Only the trailing VA is trusted. One measured decompiler gave 267 bodies in a
|
|
420
|
+
single form the same name; keying by name merges them, keying by VA does not.
|
|
421
|
+
"""
|
|
422
|
+
obj = os.path.basename(path).rsplit(".", 1)[0]
|
|
423
|
+
names: dict[int, str] = {}
|
|
424
|
+
with open(path, encoding="cp1254", errors="replace") as fh:
|
|
425
|
+
for line in fh:
|
|
426
|
+
m = LISTING_HEADER.match(line)
|
|
427
|
+
if m:
|
|
428
|
+
names.setdefault(int(m.group(2), 16), m.group(1) or f"proc_{m.group(2).lower()}")
|
|
429
|
+
return obj, names
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def merge_listing_names(listing_dir: str) -> dict[int, list[str]]:
|
|
433
|
+
"""VA -> every name any listing gave it (conflicts are kept, not resolved)."""
|
|
434
|
+
out: dict[int, list[str]] = {}
|
|
435
|
+
for root, _dirs, files in os.walk(listing_dir):
|
|
436
|
+
for name in files:
|
|
437
|
+
if not name.lower().endswith((".frm", ".bas", ".cls")):
|
|
438
|
+
continue
|
|
439
|
+
_obj, names = parse_listing(os.path.join(root, name))
|
|
440
|
+
for va, n in names.items():
|
|
441
|
+
bucket = out.setdefault(va, [])
|
|
442
|
+
if n not in bucket:
|
|
443
|
+
bucket.append(n)
|
|
444
|
+
return out
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Joining literal fragments back into whole SQL statements — and saying where it stops.
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
Native VB6 does not keep SQL in one piece. The compiler stores each literal
|
|
6
|
+
separately and the program concatenates them at run time with ``__vbaStrCat``:
|
|
7
|
+
on the reference target 8,597 calls, 262,162 literals, of which only 1,298 were
|
|
8
|
+
whole statements. Everything else is a fragment like ``"SELECT ... FROM tOrder
|
|
9
|
+
WHERE HOrderDep = '"`` followed by a value the user typed.
|
|
10
|
+
|
|
11
|
+
So "which table does this screen write" cannot be answered by reading literals: it
|
|
12
|
+
has to be answered by *stitching* neighbours and then being honest about what
|
|
13
|
+
closed. Measured on 1,273 write-candidate procedures:
|
|
14
|
+
|
|
15
|
+
* 108 statements closed completely (26 exact, 35 with a shape warning, 47 by
|
|
16
|
+
splitting two statements that were glued together),
|
|
17
|
+
* 699 stayed open inside a quote, 119 ended on a dangling connector, 112 carried
|
|
18
|
+
escaped human text,
|
|
19
|
+
* 134 of 247 writing procedures produced no candidate at all.
|
|
20
|
+
|
|
21
|
+
This module reproduces that classification. It never fills a gap with a guess: a
|
|
22
|
+
fragment that needs a run-time value is reported as ``RUNTIME_GAP``, which is the
|
|
23
|
+
honest answer and tells you a live trace is required.
|
|
24
|
+
|
|
25
|
+
Two measured traps are encoded in the gates:
|
|
26
|
+
|
|
27
|
+
1. **``lit.addr`` is the address of the text, not of the instruction.** Sorting
|
|
28
|
+
fragments by it approximates program order; it is not proof of it. Callers that
|
|
29
|
+
need order must supply it (see the ``order`` field) and say where it came from.
|
|
30
|
+
2. **A verb inside quotes is not a verb.** ``WHERE X = 'UPDATE t SET ...'`` is one
|
|
31
|
+
statement assigning a string, not two. :func:`statements` counts verbs outside
|
|
32
|
+
quotes only, which is what stopped the count from doubling.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import re
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
|
|
40
|
+
from .literals import Literal
|
|
41
|
+
|
|
42
|
+
#: How many following fragments to try when closing a statement.
|
|
43
|
+
MAX_MORE = 6
|
|
44
|
+
|
|
45
|
+
_SQL_HINT = re.compile(
|
|
46
|
+
r"\b(select|from|where|and|or|order|group|values|set|insert|update|delete|union|like|in)\b",
|
|
47
|
+
re.I,
|
|
48
|
+
)
|
|
49
|
+
_TARGET = re.compile(r"\b(from|into|update)[ \t]+([A-Za-z_][A-Za-z0-9_$]*)", re.I)
|
|
50
|
+
|
|
51
|
+
#: Words that follow `from`/`into`/`update` in real text without being a table. The
|
|
52
|
+
#: reference corpus produced 217 "tables" named ``where`` before this list existed.
|
|
53
|
+
_NOT_A_TABLE = {
|
|
54
|
+
"where", "set", "select", "values", "value", "table", "and", "or", "on",
|
|
55
|
+
"join", "as", "the", "this", "it", "a", "an", "null", "not", "into", "from",
|
|
56
|
+
}
|
|
57
|
+
_ARITH = re.compile(r"^[()\s]*[A-Za-z_][A-Za-z0-9_().\s]*(<>|>=|<=|=|<|>|\+|-|\*|,)")
|
|
58
|
+
_NUMERIC = re.compile(r"^[\s()\d.,'<>+=-]+$")
|
|
59
|
+
|
|
60
|
+
#: "Escaped human text": a message glued onto SQL. A closed empty quote followed by
|
|
61
|
+
#: a letter, a date glued to a parenthesis, or a sentence with !/? in it.
|
|
62
|
+
ESCAPE = re.compile(r"''[^\s'()=,<>+\-*0-9]|\)[0-9]{2}\.[0-9]{2}|[!?]")
|
|
63
|
+
#: A statement ending on an operator or connector is a fragment, not a statement.
|
|
64
|
+
#: A trailing quote is deliberately NOT in this set: a balanced statement legitimately
|
|
65
|
+
#: ends with one (``... = 'E1'``), and :func:`balanced` already catches the open case.
|
|
66
|
+
_DANGLING = re.compile(r"(and|or|,|<|>|=|\+|-|\blike\b|\bin\b|\()\s*$", re.I)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalise(text: str) -> str:
|
|
70
|
+
return re.sub(r"\s+", " ", (text or "")).strip().lower()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def key(text: str) -> str:
|
|
74
|
+
"""Comparison key: quotes and spacing removed, case folded."""
|
|
75
|
+
return re.sub(r"['\"]", "", normalise(text)).strip()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def statements(text: str) -> int:
|
|
79
|
+
"""Verbs outside quotes. A verb inside a string value is not a statement.
|
|
80
|
+
|
|
81
|
+
The verb is matched after a non-letter, not after a word boundary: a VB6 program
|
|
82
|
+
concatenates data straight onto SQL, so the text can read ``…02SELECT ContactKey…``
|
|
83
|
+
and ``\\b`` sees no boundary between ``2`` and ``S``. With ``\\b`` that string
|
|
84
|
+
counted as one statement instead of two and a two-statement join was reported
|
|
85
|
+
CLOSED.
|
|
86
|
+
"""
|
|
87
|
+
in_quote, buf = False, []
|
|
88
|
+
for ch in text:
|
|
89
|
+
if ch == "'":
|
|
90
|
+
in_quote = not in_quote
|
|
91
|
+
buf.append(" ")
|
|
92
|
+
elif not in_quote:
|
|
93
|
+
buf.append(ch)
|
|
94
|
+
flat = "".join(buf)
|
|
95
|
+
return len(
|
|
96
|
+
re.findall(r"(?:^|[^A-Za-z_])(select|insert|update|delete)\b", flat, re.I)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def starts_statement(text: str) -> bool:
|
|
101
|
+
"""Does this candidate begin where a statement can begin?
|
|
102
|
+
|
|
103
|
+
Without this gate, a chain starting in the middle of an expression closes
|
|
104
|
+
perfectly well — measured output included ``AltStockCode' AND AltStockType = '…'``
|
|
105
|
+
reported as CLOSED. A fragment before the verb belongs to the previous statement.
|
|
106
|
+
"""
|
|
107
|
+
return bool(re.match(r"\s*(select|insert|update|delete)\b", text, re.I))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def balanced(text: str) -> bool:
|
|
111
|
+
return text.count("'") % 2 == 0 and text.count("(") == text.count(")")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def is_sql_fragment(text: str) -> bool:
|
|
115
|
+
"""Is this fragment SQL/arithmetic rather than a message that got concatenated?"""
|
|
116
|
+
if not text or not text.strip():
|
|
117
|
+
return True
|
|
118
|
+
if _SQL_HINT.search(text):
|
|
119
|
+
return True
|
|
120
|
+
stripped = text.strip()
|
|
121
|
+
return bool(_NUMERIC.match(stripped) or _ARITH.match(stripped))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class Candidate:
|
|
126
|
+
text: str
|
|
127
|
+
parts: int
|
|
128
|
+
status: str # CLOSED / OPEN_QUOTE / ESCAPE / DANGLING / MULTI / STUCK
|
|
129
|
+
reason: str
|
|
130
|
+
tables: list[str] = field(default_factory=list)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def closed(self) -> bool:
|
|
134
|
+
return self.status == "CLOSED"
|
|
135
|
+
|
|
136
|
+
def as_row(self) -> dict:
|
|
137
|
+
return {
|
|
138
|
+
"status": self.status,
|
|
139
|
+
"parts": self.parts,
|
|
140
|
+
"reason": self.reason,
|
|
141
|
+
"tables": ";".join(self.tables),
|
|
142
|
+
"sql": self.text,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def chains(literals: list[Literal]) -> dict[int, list[Literal]]:
|
|
147
|
+
"""Group literals by procedure, preserving the order the source gave them."""
|
|
148
|
+
out: dict[int, list[Literal]] = {}
|
|
149
|
+
for lit in literals:
|
|
150
|
+
if lit.proc_va is None:
|
|
151
|
+
continue
|
|
152
|
+
out.setdefault(lit.proc_va, []).append(lit)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def stitch(chain: list[Literal], start: int, max_more: int = MAX_MORE) -> list[Candidate]:
|
|
157
|
+
"""Every candidate beginning at ``chain[start]``, longest first, classified.
|
|
158
|
+
|
|
159
|
+
A chain may only *start* at a fragment that begins a statement. Starting
|
|
160
|
+
everywhere produced thousands of balanced mid-expression joins that passed every
|
|
161
|
+
other gate; a statement boundary in VB6 is where the literal itself opens with a
|
|
162
|
+
verb.
|
|
163
|
+
"""
|
|
164
|
+
if not starts_statement(chain[start].text):
|
|
165
|
+
return []
|
|
166
|
+
out: list[Candidate] = []
|
|
167
|
+
for n in range(1, max_more + 1):
|
|
168
|
+
parts = chain[start : start + n]
|
|
169
|
+
if len(parts) < n:
|
|
170
|
+
break
|
|
171
|
+
text = "".join(p.text for p in parts)
|
|
172
|
+
if not _SQL_HINT.search(text):
|
|
173
|
+
continue
|
|
174
|
+
out.append(_classify(text, parts))
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _classify(text: str, parts: list[Literal]) -> Candidate:
|
|
179
|
+
stmts = statements(text)
|
|
180
|
+
tables = sorted(
|
|
181
|
+
{m.group(2) for m in _TARGET.finditer(text) if m.group(2).lower() not in _NOT_A_TABLE}
|
|
182
|
+
)
|
|
183
|
+
if not starts_statement(text):
|
|
184
|
+
return Candidate(text, len(parts), "NOT_START", "begins mid-statement", tables)
|
|
185
|
+
if ESCAPE.search(text):
|
|
186
|
+
return Candidate(text, len(parts), "ESCAPE", "human text glued onto SQL", tables)
|
|
187
|
+
if not balanced(text):
|
|
188
|
+
open_q = text.count("'") % 2 == 1
|
|
189
|
+
return Candidate(
|
|
190
|
+
text,
|
|
191
|
+
len(parts),
|
|
192
|
+
"OPEN_QUOTE" if open_q else "OPEN_PAREN",
|
|
193
|
+
"run-time value or missing tail" if open_q else "unbalanced parentheses",
|
|
194
|
+
tables,
|
|
195
|
+
)
|
|
196
|
+
if stmts > 1:
|
|
197
|
+
return Candidate(text, len(parts), "MULTI", f"{stmts} statements glued together", tables)
|
|
198
|
+
if _DANGLING.search(text.strip()):
|
|
199
|
+
return Candidate(text, len(parts), "DANGLING", "ends on a connector", tables)
|
|
200
|
+
if not any(is_sql_fragment(p.text) for p in parts[1:]):
|
|
201
|
+
return Candidate(text, len(parts), "STUCK", "continuation is not SQL", tables)
|
|
202
|
+
return Candidate(text, len(parts), "CLOSED", "balanced, single statement", tables)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def stitch_all(literals: list[Literal], max_more: int = MAX_MORE) -> list[Candidate]:
|
|
206
|
+
"""Best closed candidate per procedure, plus the count of open fragments."""
|
|
207
|
+
out: list[Candidate] = []
|
|
208
|
+
for _proc, chain in chains(literals).items():
|
|
209
|
+
best: Candidate | None = None
|
|
210
|
+
for i in range(len(chain)):
|
|
211
|
+
for cand in stitch(chain, i, max_more=max_more):
|
|
212
|
+
if cand.closed and (best is None or len(cand.text) > len(best.text)):
|
|
213
|
+
best = cand
|
|
214
|
+
if best is not None:
|
|
215
|
+
out.append(best)
|
|
216
|
+
return out
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def summarise(candidates: list[Candidate]) -> dict:
|
|
220
|
+
counts: dict[str, int] = {}
|
|
221
|
+
for c in candidates:
|
|
222
|
+
counts[c.status] = counts.get(c.status, 0) + 1
|
|
223
|
+
return {
|
|
224
|
+
"candidates": len(candidates),
|
|
225
|
+
"closed": counts.get("CLOSED", 0),
|
|
226
|
+
"by_status": counts,
|
|
227
|
+
}
|