A native-compiled Visual Basic 6 program is an odd artifact. It is an ordinary 32-bit PE file, so every disassembler on earth reads its headers correctly, and yet almost none of them know anything useful about it. I have spent the last few months building a small toolkit against that gap, on a real program rather than a toy: a 111 MB VB6 executable whose source tree is gone, 802 declared objects, roughly 34,000 procedures, plus 8 sibling DLLs that carry its reports.
Below is what is recoverable from a binary like that by static means, and what is not. Numbers come from runs I made. The program stays unnamed here, and the toolkit takes no path, host or database name from anywhere but the environment.
The two walls
Commercial VB6 decompilers stop at one of two places. They give you the forms and withhold the code, or they give you code and withhold the semantics that would make it portable. Both are honest outcomes for tools aimed at "recover my source", because a native VB6 build has no source to recover. What it has is structure, and structure is measurable.
That reframe did most of the work for me. Once the question is "what can I prove from these bytes", the tooling conversation gets a lot shorter.
Anchor on the runtime first
MSVBVM60.DLL is the whole API surface of the program. On the reference build, the import table has 276 entries, and everything beyond those 276 goes through COM vtable dispatch. That is 131,460 call sites with no statically resolvable target. A tool that reports "unknown function called" 130k times is not failing; it is telling you the truth about VB6.
Everything else follows from a few anchors:
- the
VB5!magic that points at theVBHeader, which points at theProjectInfo, which points at the object table; - the runtime prologue,
55 8B EC, which is how you tell a real procedure entry from a data reference; - the fact that literals are
BSTRs: length-prefixed UTF-16LE, no terminator, stored inside.text.
Get those three right and a lot of what looks like magic becomes arithmetic.
The procedure table is the cheap win
Walking ProjectInfo into per-object method counts takes 0.4 seconds on a 111 MB file: 834 objects, 34,415 methods. That is a complete inventory of the program's surface before any decompiler runs, and it costs less than a coffee.
The honest gap: in a native build the method entry addresses are not what the header claims, and names do not exist at all. So I validate each entry point by its prologue instead of trusting a table, and I treat every name that a decompiler offers as a hint with a footnote. One form in the reference build has 267 bodies that all got named FisButton_Click. The address is the fact; the name is a label someone guessed.
Strings are not where the manuals say they are
This is the result I keep repeating to people. The reference build has a 4 KB .data section and a 106 MB .text. VB6 put its string literals inside .text as UTF-16LE BSTRs. A scanner configured for "ASCII in the data sections" reports a fraction of a percent of the program's text and concludes the program has no strings.
Measured on the same file:
| Reader | Strings found | SQL found |
|---|---|---|
| Generic RE engine, data sections, ASCII | 59, all from .rsrc
|
0 |
This toolkit, BSTR scan of .text
|
167,564 (4.6 s) | 6,171 literals carrying a SQL verb |
The same generic engine was asked to search for a table name that appears 260 times in the file. It returned 0 hits. Its scanner was never looking at UTF-16LE. Then I sampled 200 procedures it had missed and 199 of them started with the VB6 prologue: real code, invisible to a ceiling it hit on its own instruction budget (6,875 functions of 34,159, and its own output said so).
If a tool tells you a VB6 binary contains no SQL, the first question is whether it reads UTF-16LE at all. Not whether the program is "obfuscated".
Stitching SQL, and keeping the gap honest
Literal ownership is the interesting part: a literal is worth something when you know which procedure references it. With addresses and procedure bounds, fragments start assembling into whole statements. On the reference build that went from 10,691 statement starts to 8,745 closed ones across 4,109 procedures, naming 494 tables.
The remaining ones stayed open, and they stayed open on purpose. SQL built by concatenating a variable at run time is not statically closable, and a cleverer regex will not close it either. The stage classifies each open statement by why it is open (OPEN_QUOTE means a run-time value is missing). A number that hides its own denominator is worse than a smaller number that does not.
Forms that only exist as bytes
Semi-VB-Decompiler recovered 790 .frm files out of 802 declared objects. Which means some forms exist only as the serialized property blobs inside the executable, and a migration has to know their geometry anyway.
Those blobs are decodable, and the way to trust them is calibration rather than confidence: read the geometry from the binary, then diff against .frm ground truth for the forms that have one. On the reference build that came out at 801 blobs, 24,967 of 24,967 controls matched for calibrated forms, and 367 of 369 forms matched. Where the calibration does not hold, the tool says so per form instead of averaging it away.
Three-state flags are the trap here. Visible and Locked have defaults that look like explicitly written values in the blob, so a reader that is not calibrated reports "visible: true" for controls that were never written. That class of bug cost me days, and it is only visible if you diff against something you already trust.
Reports live in other files
437 report designs on the reference build: 413 inside the report DLLs as DataReport blobs, and 24 embedded in the executable itself. A scan restricted to rpt*.dll quietly loses 5% of the reporting layer. Cheap to get right, expensive to discover late, when somebody asks why four printed documents went missing from the new system.
The second opinion, and its real cost
Generic decompilers are worth running, on a list you validated, for the control flow they rebuild. I ran a Ghidra 12.2 pilot (PyGhidra, auto-analysis off, procedure bounds and names supplied by the toolkit's own inventory) over 1,395 targeted procedures.
The numbers I expected to be different from the brochure:
- 1,369 of 1,395 decompiled, 26 failed, in 5 h 43 min wall time across two workers. Average 9.6 s per solved procedure, 13,107 s of decompiler time.
- 626,845 lines of pseudo-C across 252 modules. Goto density 4.38 per 1,000 lines, against 35.1 per 1,000 for the same class of bodies from the VB6 front end. Where the front end says
GoTo loc_, Ghidra rebuilds the branch, which is the one thing it does better than the VB6 front end. - Zero
forloops across the whole corpus, with loop analysis switched on. Cause not established. I do not know how to feel about a decompiler that never emits one. - The failures were ceiling problems, not time problems. Raising
maxPayloadMBytesandmaxInstructionsto 128 MB / 20M turned the same 39-target set from 31/39 into 36/39. Raising only the per-function timeout to 900 s fixed nothing. - 26 failures dominate wall time: 81% of one worker's slice went to the tail. Extrapolating the campaign to all 34,159 procedures was not worth attempting, and I say that as somebody who already paid for the pilot.
- Ghidra prints
DAT_16c3f10,PTR_DAT_16c3f10,PTR_PTR_16c3f10and never the string. Over campaign slices, 59% of literals our graph attributes to a procedure became visible in some form; for SQL-flagged literals it was 87%. I attach the text at the output level and keep the indirection prefix, because Ghidra's label and plate-comment APIs produced no change in pseudo-C when I tried them. - Ghidra 12.x needs JDK 25. JDK 17 and 21 are rejected by the launcher before anything interesting happens.
So the honest role of a generic engine on a VB6 target: a second pair of eyes on 1-2k procedures you have already bounded, at hours not minutes. Not a replacement, and not a whole-program pass.
Traps that cost days
Short version, since the full list with measurements lives in the repo:
- A free OSS decompiler hit
Error 6: Overflowabove 32,767 procedures. Its procedure counter was anInteger. Patch it toLong, rebuild, and the same run finishes with 30,898 bodies, none of them empty. - The same tool stalled at around 2 GB of address space because the VB6 linker never sets Large Address Aware. That is a property of the file, not of the tool's competence: flip the bit on a copy.
- Its output folder stayed empty for roughly 13 CPU hours, then wrote everything at once. If you monitor progress by folder size you will kill a run that was fine.
- Every body inside a form gets labelled with one event name, and the recovered bodies are pseudo-source:
var_64,arg_C,global_06DE02D4. Names, comments and locals are gone from the binary, not hidden by the tool. - A vendor GUI tool phoned home over TLS at startup. When that route was blocked, the modal never resolved and the process exited. Two headless runs died the same way before I stopped treating the network as an afterthought.
- VBReFormer never opened the file. The free tier refused the 111 MB executable with a transport failure (HTTP 413) and no retry path, so I paid the 79 euro for a licence and it still would not open the same file. Over 100 MB is out of scope for that service, licence or not. Being a paying customer bought me no technical support either, and that is the part that stings more than the refusal.
What is not recoverable
Worth stating plainly, because it is where expectations break:
- Procedure names. Addresses, yes. Names, only whatever a decompiler guessed.
- Expression-level VB6 source. Every free body recovery I measured produces pseudo-source. Compilable output is a commercial feature, and a licensed copy of one commercial tool could not ingest the file at all.
- Indirect calls through an object global. A
(*DAT_x)[slot]call has no static target, because the bytes at such a global are 0 or a small token, never a vtable pointer. You rank them and trace them at run time. You do not close them with static analysis. - SQL assembled at run time, for the same reason.
- Geometry for forms that changed after the
.frmin hand was written. You calibrate, and you report the calibration.
None of that is a defect in the toolkit. It is the shape of the problem, and I would rather write it down than have somebody discover it in week three of a migration.
Try it in thirty seconds
pip install vb6decompiler gives a vb6d command, standard library only, one module per stage. Three of the stages need no target at all, which I added because I kept getting asked for a demo I could run without handing over a customer's binary:
$ vb6d frm tests/fixtures
# 4 files: 3 parsed, 1 without a design block, 0 failed
fSynthArray (6000, 3000) controls=8
fSynthTab (4000, 2000) controls=4
fKesik unknown controls=1
The repo is 47Hunter47/vb6decompiler. docs/tool-matrix.md has every number above plus the command that produced it, docs/formats.md has the structures down to the offsets, docs/pitfalls.md has the traps with the measurement that proved each one. If you are sitting on a native VB6 build whose source you lost, and somebody is quoting you a vendor recovery percentage, ask for the procedure count first. It takes half a second to produce, and it turns out to be the number most of the other claims depend on.
Top comments (0)