Reference version 1.0
VB6 file format — the structures a VB5/6 image carries
When you compile a Visual Basic 5 or 6 project, the IDE lays a graph of fixed structures into the PE, rooted at the VBHeader, that the MSVBVM runtime walks at load. This page documents those on-disk structures field by field: offset, size, type, meaning, and the gotchas that bite. It covers both P-code and native builds — the structures below are identical in both; only the method-dispatch layer and a few flags differ.
Offsets, sizes and the example values are captured from vbdec itself (its
dump() and structIDC output over a loaded sample, PDFStreamDumper); field types and notes
are drawn from the vbdec struct sources. Companion references: the
P-code opcode reference (and its VB5 vs VB6 section),
UDT_DESCRIPTORS.md, VB6_events and RTMI_FIELDS.md for the deeper layers.
Nesting map
How the structures reference each other (blue = the pointer/offset field that reaches the child):
push <VBHeader>; call ThunRTMainHow the loader walks it
- The PE entry point is a tiny stub that pushes the VBHeader VA and calls the runtime's
ThunRTMain. That pushed pointer is how you find the VBHeader without guessing. - From VBHeader the runtime reaches ProjectInfo (
aProjectInfo), then the ObjectTable (ProjectInfo.aObjectTable). - The ObjectTable's
lpObjectArrayis an array of tObject descriptors (stride0x30), one per compiled object — not the ObjectInfo records themselves. EachtObject+0x00(aObjectInfo) points to that object's ObjectInfo. Each ObjectInfo'slpMethodsgives its procedures, and designer objects carry an OptionalObjectInfo right after the ObjectInfo with their controls and events. - Visual objects are also listed in the GuiTable (
VBHeader.aGuiTable), whoseaFormPointerreaches the GUIObjectInfo and the serialized control-property blob. - External OCX/typelib references live in the external-component table (
aExternalComponentTable), and COM-exposed projects carry ComRegData/ComRegInfo (aComRegisterData).
VBHeader (EXEPROJECTINFO)
Reached via PE entry point: the OEP stub is push <VBHeader> ; call ThunRTMain — the pushed pointer IS the VBHeader VA.
The root of every VB5/6 image. Fixed-size header that anchors the whole VB object graph: it points at the ProjectInfo, the GUI table, the external-component table and the COM-registration data, and carries the offsets (relative to its own VA) of the project's name/title/help/exe strings.
VB5! in both VB5 and VB6 — there is no VB6!. aSubMain == 0 means the startup is a form load, not a Sub Main. The o* fields are offsets relative to the VBHeader VA, not absolute pointers like the a* fields.| Off | Size | Type | Field | Notes | e.g. |
|---|---|---|---|---|---|
| 0x00 | 4 | String | signature | VB5! identifier "VB5!" | VB5! |
| 0x04 | 2 | Integer | RuntimeBuild | 1FF0 | |
| 0x06 | 14 | String | LanguageDLL | Language DLL name 0x2A meaning default or null terminated string. | * |
| 0x14 | 14 | String | BackupLanguageDLL | Backup Language DLL name. 0x7F meaning default or null terminated string. Changing values do not effect working status of an exe. | ~ |
| 0x22 | 2 | Integer | RuntimeDLLVersion | Run-time DLL version | A |
| 0x24 | 4 | Long | LanguageID | 409 | |
| 0x28 | 4 | Long | BackupLanguageID | Backup Language ID (only when Language DLL exists) | 0 |
| 0x2C | 4 | Long | aSubMain | Address to Sub Main() code (If 0000 0000 then it's a load form call) | 0 |
| 0x30 | 4 | Long | aProjectInfo | 40540C | |
| 0x34 | 4 | Long | fMDLIntObjs | 38FFFF | |
| 0x38 | 4 | Long | fMDLIntObjs2 | FFFFFF00 | |
| 0x3C | 4 | Long | ThreadFlags | 8 | |
| 0x40 | 4 | Long | ThreadCount | 1 | |
| 0x44 | 2 | Integer | FormCount | 10 | |
| 0x46 | 2 | Integer | ExternalComponentCount | Number of external components (eg. winsock) referenced | A |
| 0x48 | 4 | Long | ThunkCount | E9 | |
| 0x4C | 4 | Long | aGuiTable | 4078D4 | |
| 0x50 | 4 | Long | aExternalComponentTable | 40E390 | |
| 0x54 | 4 | Long | aComRegisterData | 401344 | |
| 0x58 | 4 | Long | oProjectExename | 78 | |
| 0x5C | 4 | Long | oProjectTitle | 8A | |
| 0x60 | 4 | Long | oHelpFile | 9A | |
| 0x64 | 4 | Long | oProjectName | 9B |
ProjectInfo
Reached via VBHeader.aProjectInfo
The compile-time project record: where code starts/ends, the VBA exception handler, the native-code pointer, and the project path string. Most of its address fields are only meaningful at runtime.
OriginalPathName is a fixed 521-byte reserved path buffer (vbdec reads MAX_PATH*2+1 bytes at +0x2A, up to the NullSpacer at +0x233) — vbdec's own source carries a TODO that this field's exact element width differs in the vbparser reference, so treat the WCHAR[261] reading as approximate. It is empty before SP6 and, when present, is a classic compiler-leak of the build machine's project path. aNativeCode is the hinge between P-code and native builds.| Off | Size | Type | Field | Notes | e.g. |
|---|---|---|---|---|---|
| 0x00 | 4 | Long | signature | 1F4 | |
| 0x04 | 4 | Long | aObjectTable | 41AA5C | |
| 0x08 | 4 | Long | Null1 | 0 | |
| 0x0C | 4 | Long | aStartOfCode | 47B530 | |
| 0x10 | 4 | Long | aEndOfCode | 47B540 | |
| 0x14 | 4 | Long | Flag1 | 91A4 | |
| 0x18 | 4 | Long | ThreadSpace | 47C000 | |
| 0x1C | 4 | Long | aVBAExceptionhandler | 40129A | |
| 0x20 | 4 | Long | aNativeCode | 0 | |
| 0x24 | 2 | Integer | oProjectLocation | 2A | |
| 0x26 | 2 | Integer | Flag2 | 5C | |
| 0x28 | 2 | Integer | Flag3 | 41 | |
| 0x2A | 521 | String | OriginalPathName | D:\_code\pdf_stream_dumper\_Project1.vbp | |
| 0x233 | 1 | Byte | NullSpacer | 0 | |
| 0x234 | 4 | Long | aExternalTable | 404FF8 | |
| 0x238 | 4 | Long | ExternalCount | 3E |
ObjectTable
Reached via ProjectInfo.aObjectTable
The catalog of every compiled object (form/class/module/UserControl/UserDocument). lpObjectArray points at an array of tObject descriptors (stride 0x30), not at the ObjectInfo records directly — each tObject+0x00 (aObjectInfo) is the VA of this object's ObjectInfo; ObjectCount1 says how many.
uuidObj is a 16-byte GUID that vbdec historically parsed as four separate flag longs (per vb.idc). iObjectsInUse drifts up/down in the IDE as modules load/unload but equals iCompiledObjects in a compiled exe.| Off | Size | Type | Field | Notes | e.g. |
|---|---|---|---|---|---|
| 0x00 | 4 | Long | lNull1 | 0 | |
| 0x04 | 4 | Long | aExecProj | Pointer to VB Project Exec COM Object. | 47C2E8 |
| 0x08 | 4 | Long | aProjectInfo2 | Secondary Project Information | 475854 |
| 0x0C | 4 | Long | Const1 | Always set to -1 after compiling. Unused | FFFFFFFF |
| 0x10 | 4 | Long | Null2 | 0 | |
| 0x14 | 4 | Long | lpProjectObject | Pointer to in-memory Project Data. | 47C2D8 |
| 0x18 | 16 | String | uuidObj | GUID of the Object Table. | {98529654-E4C9-4A84-9C03-1D803895DFC7} |
| 0x28 | 2 | Integer | fCompileType | Internal flag used during compilation. | A |
| 0x2A | 2 | Integer | ObjectCount1 | Total objects present in Project. | 23 |
| 0x2C | 2 | Integer | iCompiledObjects | Equal to above after compiling. | 23 |
| 0x2E | 2 | Integer | iObjectsInUse | 23 | |
| 0x30 | 4 | Long | lpObjectArray | Pointer to Object Descriptors | 41AAB0 |
| 0x34 | 4 | Long | fIdeFlag | Flag/Pointer used in IDE only. | 0 |
| 0x38 | 4 | Long | lpIdeData | Flag/Pointer used in IDE only. | 0 |
| 0x3C | 4 | Long | lpIdeData2 | Flag/Pointer used in IDE only. | 0 |
| 0x40 | 4 | Long | aProjectName | Pointer to Project Name | 41BBAC |
| 0x44 | 4 | Long | LangID1 | 409 | |
| 0x48 | 4 | Long | LangID2 | 409 | |
| 0x4C | 4 | Long | lpIdeData3 | Flag/Pointer used in IDE only. | 0 |
| 0x50 | 4 | Long | dwIdentifier | Template Version of Structure. | 2 |
ProjectInfo2
Reached via ObjectTable.aProjectInfo2
Secondary project info: a back-pointer to the ObjectTable, a pointer to the object-descriptor list, and the project description / help-file / help-context.
-1 or never read after compilation.| Off | Size | Type | Field | Notes | e.g. |
|---|---|---|---|---|---|
| 0x00 | 4 | Long | lNull1 | 0 | |
| 0x04 | 4 | Long | lpObjectTable | Back-Pointer to the Object Table. | 41AA5C |
| 0x08 | 4 | Long | lConst1 | Always set to -1 after compiling. Unused | FFFFFFFF |
| 0x0C | 4 | Long | lNull2 | Not written or read in any case. | 0 |
| 0x10 | 4 | Long | lpObjectList | Pointer to Object Descriptor Pointers. | 441580 |
| 0x14 | 4 | Long | dwUnused2 | Not written or read in any case. | 0 |
| 0x18 | 4 | Long | szProjectDescription | Pointer to Project Description | 0 |
| 0x1C | 4 | Long | szProjectHelpFile | Pointer to Project Help File | 0 |
| 0x20 | 4 | Long | dwReserved2 | Always set to -1 after compiling. Unused | FFFFFFFF |
| 0x24 | 4 | Long | lHelpContextID | Project Help Context ID | 0 |
ObjectInfo (epiModule)
Reached via ObjectTable.lpObjectArray → tObject[i] → +0x00 aObjectInfo
The per-object record (vbdec/runtime name epiModule). It carries the method count, the pointer to this object's methods, the constant pool, and — if the object has a designer/controls — is immediately followed by the OptionalObjectInfo.
lpMethods (+0x24) is, in VB6, a static flat array of NumberOfProcs ProcDscInfo VAs; in VB5 it is a runtime-only heap pointer (garbage on disk) — see the VB5/VB6 section. Also: NumberOfProcs counts user routines + API declares for P-code, but API declares only for native. lpConstantPool can be immediately followed by the OptionalObjectInfo.| Off | Size | Type | Field | Notes | e.g. |
|---|---|---|---|---|---|
| 0x00 | 2 | Integer | wRefCount | Always 1 after compilation. | 1 |
| 0x02 | 2 | Integer | ObjectIndex | 0 | |
| 0x04 | 4 | Long | aObjectTable | Pointer to the Object Table | 41AA5C |
| 0x08 | 4 | Long | lpIdeData | Zero after compilation. Used in IDE only. | 0 |
| 0x0C | 4 | Long | lpPrivateObject | Pointer to Private Object Descriptor. | 43D964 |
| 0x10 | 4 | Long | dwReserved | FFFFFFFF | |
| 0x14 | 4 | Long | Null2 | 0 | |
| 0x18 | 4 | Long | aObject | 41AAB0 | |
| 0x1C | 4 | Long | lpProjectData | 47C000 | |
| 0x20 | 2 | Integer | NumberOfProcs | native = api declares only, pcode = user func + api (in this code unit) | 77 |
| 0x22 | 2 | Integer | wMethodCount2 | Zeroed out after compilation. IDE only. | 0 |
| 0x24 | 4 | Long | lpMethods | 4175BC | |
| 0x28 | 2 | Integer | iConstantsCount | Number of Constants | 1D4 |
| 0x2A | 2 | Integer | iMaxConstants | Maximum Constants to allocate. | 200 |
| 0x2C | 4 | Long | lpIdeData2 | 1 | |
| 0x30 | 4 | Long | lpIdeData3 | 3BCEF74 | |
| 0x34 | 4 | Long | lpConstantPool | 416E6C |
OptionalObjectInfo
Reached via Immediately follows ObjectInfo when tObject.ObjectType AND 0x80
Present only for objects with a designer surface (forms, UserControls, classes with events). Holds the object's GUIDs, the controls array, the P-code count, the init/terminate event offsets, and the aMethodLinkTable.
aMethodLinkTable (+0x30, i.e. ObjectInfo+0x68) is the static array of x86 method-link thunks used to recover procedure descriptor VAs (standard modules have no OptionalObjectInfo, so their thunks are found by scanning .text).| Off | Size | Type | Field | Notes | e.g. |
|---|---|---|---|---|---|
| 0x00 | 4 | Long | dwObjectGuids | How many GUIDs to Register. 2 = Designer | 1 |
| 0x04 | 4 | Long | lpObjectGuid | Unique GUID of the Object | 41BCA0 |
| 0x08 | 4 | Long | Null1 | 0 | |
| 0x0C | 4 | Long | lpuuidObjectTypes | Pointer to Array of Object Interface GUIDs | 417798 |
| 0x10 | 4 | Long | dwObjectTypeGuids | How many GUIDs in the Array above. | 1 |
| 0x14 | 4 | Long | lpControls2 | Usually the same as lpControls. | 4177A0 |
| 0x18 | 4 | Long | dwNull2 | 0 | |
| 0x1C | 4 | Long | lpObjectGuid2 | Pointer to Array of Object GUIDs. | 41779C |
| 0x20 | 4 | Long | ControlCount | Number of Controls in array below. | 72 |
| 0x24 | 4 | Long | lpControls | Pointer to Controls Array. | 4177A0 |
| 0x28 | 2 | Integer | iMethodLinkCount | Number of Events in Event Array. | 86 |
| 0x2A | 2 | Integer | iPCodeCount | Number of P-Codes used by this Object. | 1B7 |
| 0x2C | 2 | Integer | oInitializeEvent | Offset to Initialize Event from aMethodLinkTable | 68 |
| 0x2E | 2 | Integer | oTerminateEvent | Offset to Terminate Event from aMethodLinkTable | 6C |
| 0x30 | 4 | Long | aMethodLinkTable | Pointer to pointers of MethodLink | 418970 |
| 0x34 | 4 | Long | aBasicClassObject | Pointer to the in-memory class object. | 483E7C |
| 0x38 | 4 | Long | Null3 | 0 | |
| 0x3C | 4 | Long | lpIdeData | usually null | 31A9BD8 |
GuiTable
Reached via VBHeader.aGuiTable → one per form / UserControl
Designer record for a visual object: its ObjectGUI UUID, OLEMisc flags, object CLSID, and the pointer (aFormPointer) to the GUIObjectInfo + control property blob.
cco.GUIObj Is Nothing). dataSize measures from aFormPointer to the end of this object's property blob. A fuller field map (control flags, the two extra GUIDs) is in the old sandsprite decompiler forum writeups.| Off | Size | Type | Field | Notes | e.g. |
|---|---|---|---|---|---|
| 0x00 | 4 | Long | lStructSize | Total size of this structure | 50 |
| 0x04 | 16 | String | uuidObjectGUI | UUID of Object GUI | {6F5B5966-8CF6-479F-AA43-1083598DFB27} |
| 0x14 | 4 | Long | Unknown1 | 0 | |
| 0x18 | 4 | Long | Unknown2 | 0 | |
| 0x1C | 4 | Long | Unknown3 | 0 | |
| 0x20 | 4 | Long | Unknown4 | 0 | |
| 0x24 | 4 | Long | lObjectID | Current Object ID in the Project | 0 |
| 0x28 | 4 | Long | Unknown5 | 3810 | |
| 0x2C | 4 | Long | fOLEMisc | OLEMisc Flags | 0 |
| 0x30 | 16 | String | uuidObject | UUID of Object | {00000000-0000-0000-0000-000000000000} |
| 0x40 | 4 | Long | dataSize | end offset is FormPointer + dataSize | 336C |
| 0x44 | 4 | Long | Unknown7 | 0 | |
| 0x48 | 4 | Long | aFormPointer | Pointer to GUI Object Info | 401404 |
| 0x4C | 4 | Long | Unknown8 | 4C |
GUIObjectInfo
Reached via GuiTable.aFormPointer
Header of the control/property blob for a visual object: the control count and the three defining GUIDs (ObjectGUI, DefaultIID, COMEventsIID), followed by the total length of the serialized properties.
bControlsCount is a single byte and is the cheapest reliable “did the form's controls change?” signal in a binary diff. The GUIDs regenerate on every recompile — expected churn, not a real change.| Off | Size | Type | Field | Notes | e.g. |
|---|---|---|---|---|---|
| 0x00 | 4 | Long | lSignature | 31CCFF | |
| 0x04 | 1 | Byte | bControlsCount | Number of controls of this Object | 70 |
| 0x05 | 16 | String | guidObjectGUI | GUID of this ObjectGUI | {6F5B5966-8CF6-479F-AA43-1083598DFB27} |
| 0x15 | 16 | String | guidDefaultIID | GUID of this object DefaultIID | {CAE46972-3F84-4FA7-898E-E6D0BC241199} |
| 0x25 | 16 | String | guidCOMEventsIID | GUID of this object EventsIID | {33AD4F3A-6699-11CF-B70C-00AA0060D393} |
| 0x35 | 4 | Long | lUnknown1 | 0 | |
| 0x39 | 4 | Long | lUnknown2 | 0 | |
| 0x3D | 4 | Long | lUnknown3 | 0 | |
| 0x41 | 4 | Long | lUnknown4 | 0 | |
| 0x45 | 4 | Long | lUnknown5 | 0 | |
| 0x49 | 4 | Long | lUnknown6 | 0 | |
| 0x4D | 4 | Long | lUnknown7 | 0 | |
| 0x51 | 4 | Long | lUnknown8 | 0 | |
| 0x55 | 4 | Long | lUnknown9 | 0 | |
| 0x59 | 4 | Long | lPropertiesLength | Total Length of Properties | 3313 |
ComRegData
Reached via VBHeader.aComRegisterData
Root of the COM-registration block (only present when the project exposes COM objects). Points at the COM-interface info and carries the project/typelib CLSID, LCID and typelib version.
baseOffset = VBHeader.aComRegisterData + oRegInfo). uuidProjectClsId is regenerated each compile.| Off | Size | Type | Field | Notes | e.g. |
|---|---|---|---|---|---|
| 0x00 | 4 | Long | oRegInfo | Offset to COM Interfaces Info | 48 |
| 0x04 | 4 | Long | lpszProjectName | Offset to Project/Typelib Name | 30 |
| 0x08 | 4 | Long | lpszHelpDirectory | Offset to Help Directory | 40 |
| 0x0C | 4 | Long | lpszProjectDescription | Offset to Project Description | 0 |
| 0x10 | 16 | String | uuidProjectClsId | CLSID of Project/Typelib | {9F5E4DAC-AC08-4449-8314-54B2DFD363A7} |
| 0x20 | 4 | Long | lTlbLcid | LCID of Type Library | 0 |
| 0x24 | 2 | Integer | iPadding1 | 0 | |
| 0x26 | 2 | Integer | iTlbVerMajor | Typelib Major Version | 1 |
| 0x28 | 2 | Integer | iTlbVerMinor | Typelib Minor Version | 0 |
| 0x2A | 2 | Integer | iPadding2 | 0 | |
| 0x2C | 4 | Long | lPadding3 | 0 |
ComRegInfo
Reached via ComRegData.oRegInfo → linked list via oNextObject
One record per COM-exposed object: its name/description offsets, instancing mode, object & control CLSIDs, OLEMisc status, and the Object-Type flag.
fObjectType identifies the kind of object: 0x02 Designer, 0x10 Class Module, 0x20 User Control (OCX), 0x80 User Document. Records chain via oNextObject (offset, not pointer; 0 = end).| Off | Size | Type | Field | Notes | e.g. |
|---|---|---|---|---|---|
| 0x00 | 4 | Long | oNextObject | Offset to COM Interfaces Info | 0 |
| 0x04 | 4 | Long | oObjectName | Offset to Object Name | 90 |
| 0x08 | 4 | Long | oObjectDescription | Offset to Object Description | 0 |
| 0x0C | 4 | Long | lInstancing | Instancing Mode | 2 |
| 0x10 | 4 | Long | lObjectID | Current Object ID in the Project | 17 |
| 0x14 | 16 | String | uuidObjectClsID | CLSID of Object | {B628C6ED-013E-45F2-83B3-796074881630} |
| 0x24 | 4 | Long | fIsInterface | Specifies if the next CLSID is valid | 1 |
| 0x28 | 4 | Long | oObjectClsID | Offset to CLSID of Object Interface | A0 |
| 0x2C | 4 | Long | oControlClsID | Offset to CLSID of Control Interface | B0 |
| 0x30 | 4 | Long | fIsControl | Specifies if the CLSID above is valid | 1 |
| 0x34 | 4 | Long | lMiscStatus | OLEMISC Flags (see MSDN docs) | 0 |
| 0x38 | 1 | Byte | fClassType | Class Type | 1 |
| 0x39 | 1 | Byte | fObjectType | Flag identifying the Object Type | 20 |
| 0x3A | 2 | Integer | iToolboxBitmap32 | Control Bitmap ID in Toolbox | 0 |
| 0x3C | 2 | Integer | iDefaultIcon | Minimized Icon of Control Window | 0 |
| 0x3E | 2 | Integer | fIsDesigner | Specifies whether this is a Designer | 0 |
| 0x40 | 4 | Long | oDesignerData | Offset to Designer Data | 0 |
Form controls — the serialized property blob
The visual designer packs every control on a form / UserControl into a serialized blob.
GuiTable.aFormPointer points at it, it is GuiTable.dataSize bytes long, and parsing
starts at aFormPointer + 0x5E (after the GUIObjectInfo header). Inside
is a sequence of per-control records, each followed by a dynamic run of 1-byte “control codes”
that describe the parent/child control tree. Fair warning from the source: this is the hairiest corner of the whole
format — the control-code state machine, not the fixed header, is what makes it hard. (From David Zimmer's
Parsing VB6 Form Controls.)
The control header
Each control begins with a fixed header, then its (variable-size) property data to length:
| Field | Type | Notes |
|---|---|---|
length | Long | offset to the end of this control's struct. Bit 31 (0x80000000) is the isArray flag; the real length is length & 0x7FFFFFFF |
cId | Byte | control id |
(arrayIndex) | Integer | present only for control-array elements (when the isArray bit is set) |
cname | String | length-prefixed, e.g. 00 05 "Text1" |
null | Byte | string null terminator |
cType | Byte | the control type (table below) |
To tell a normal header from an array header, read the high byte of length (the 4th byte): if
(b & 0x80) it's an array element. (The “aha” from the paper: it's not a weird 3-byte
length + flag byte — it's a 32-bit length that steals the sign bit, which would otherwise be unused, as the
isArray flag.)
cType — control type byte
| # | Control | # | Control | # | Control |
|---|---|---|---|---|---|
| 0 | PictureBox | 8 | ListBox | 19 | Menu |
| 1 | Label | 9 | HScrollBar | 20 | MDIForm |
| 2 | TextBox | 10 | VScrollBar | 22 | Shape |
| 3 | Frame | 11 | Timer | 23 | Line |
| 4 | CommandButton | 13 | Form | 24 | Image |
| 5 | CheckBox | 16 | DriveListBox | 37 | Data |
| 6 | OptionButton | 17 | DirListBox | 38 | OLE |
| 7 | ComboBox | 18 | FileListBox | 40 | UserControl |
| 41 | PropertyPage | 42 / 0xFF | UserDocument / External |
The numbering has gaps (12, 14–15, 21, 25–36, 39) — the runtime's own table has them too.
Control codes (the hairy part)
After a control's fixed struct comes a dynamic, variable-length run of 1-byte codes describing the control tree:
| Code | Name | Meaning |
|---|---|---|
| 1 | BeginNewChild | a new child control inside the current parent (p-code form: FF01) |
| 2 | EndCurrentControlParent | end the parent and all its children (FF02) |
| 3 | ContinueCurrentControlParent | another child follows in the same parent (FF03) |
| 4 | FinalEndConfigBlock | end of the whole blob |
| 5 | StartOfMenus | the menu section follows |
Valid sequences differ between ordinary controls and menus. Hard-won rules from bulk analysis:
03 01 is never valid; 03 03 is valid for a menu but not other controls;
03 04 is valid only when bytesLeft == 0.
length/property bytes can contain values that
look like valid control codes, so a naive parser gobbles into the next control's header and every control
after it is garbage. vbdec's defence is a rewind heuristic — detect an implausible header length, seek
back one byte, and retry — plus per-control tracking of control-codes, rewindCount,
lateRewinds and bytesNotProcessed. A clean parse ends with bytesNotProcessed == 0;
anything else flags a control whose extraction drifted. (vbdec: Explore → Form Object Properties; the
CControlProperties accumulator exposes all of it, and gui_scan.js bulk-exports it to SQLite
for finding sequences that break the parser.)Beyond locating the blob: the individual properties
Finding each control's blob is only half the battle; pulling the actual property values (.Caption,
.Text, .Height…) is a separate per-control-type slog: the embedded property opcode
is memberID − 0x10000, values are variable-size and must be walked in order (you can't
jump to the one you want), and the layout differs for every control. vbdec deliberately does not do full form
reconstruction; it surfaces the parsed blobs plus an image extractor, an Extract Strings option, and the
OLB Viewer (which decodes memberID − 0x10000 to help read the hex by hand).
3rd-party (OCX / ActiveX) controls
For a 3rd-party control the VB header's cType is 0xFF (External) and the real type is a
length-prefixed ProgID string at the start of the property data (e.g. "MSComctlLib.ListView"):
[ VB control header: length, cId, cname, null, cType=0xFF ]
[ u16-length ProgID string e.g. "MSComctlLib.ListView" ]
[ property opcode stream ... to control.length ]
Reversing MSVBVM60 (symboled build) shows the runtime loads every control, intrinsic and OCX alike, through
one routine, PropLoadProp(CTL*, FILEREF*) (header via LoadHctlHeader). For intrinsic controls
that opcode stream carries all the property values. For an OCX it carries only the VB wrapper (the
extender/stock props) plus one object-data opcode whose value is the control's own serialized blob — so the
control's own properties are not in this opcode format (see “Full typed decode” below, proven
by compile-diff). The mechanism below is therefore exact for intrinsic controls and for the OCX wrapper:
How the runtime parses it — PropLoadProp
- Read a 1-byte property opcode;
0xFFends the control (the trailingFFin every blob). Opcodes ascend; a gap triggers_PropSetDefaultsfor the skipped properties. (This stream-terminator0xFFis unrelated to the header'scType=0xFF— same byte value, different field: one marks “external control”, the other marks “end of property stream”.) - The opcode indexes the control's property-descriptor table — an array of
tagPROPbuilt from the control's type info at load:prop = table[opcode]. tagPROP+4is a type+flags dword; its low 6 bits (the base type) decide how the value is read:
| type&0x3F | value in the stream |
|---|---|
| 1 | length-prefixed string (FrefFastReadSz) |
| 2 | 2-byte Integer |
| 3–7 | 1- or 4-byte scalar (Byte / Boolean / Enum / OLE_COLOR via OleTranslateColor) |
| 8–11 | 4-byte coordinate, DPI-scaled from twips (PosX / SizeX / PosY / SizeY) |
| 13 (0x0D) | BSTR (length + chars) |
| 17 (0x11) | packed bitfield (shift/mask from tagPROP+10) |
| 21 (0x15) | Picture — an inline StdPicture / IPicture |
| 33 (0x21) | string |
| other | an inline IPersistStream OLE object (e.g. StdFont, StdDataFormat) |
Then PropSetControlProperty(CTL*, opcode, value, tagPROP*) applies it: it stores straight into the control's
instance-data slot at tagPROP+0x10 (with the right coercion — BSTR alloc, bitfield pack, AddRef for an
object, PictSetPicture for a picture) or, when the flag bits say so, calls a control method through its
vtable (offset +0x20 / +0x24). That is exactly the "vtable offset to call + property type"
model — the type tells the loader how to read the bytes, the descriptor says where/how to apply them.
So the ListView landmarks fit perfectly: the embedded StdFont {0BE35203-8F91-11CE-9DE3-00AA004BB851} is just
the Font property's value — an inline IPersistStream-serialized StdFont (its sub-stream holds
"Courier New"); a column caption is a BSTR value; GridLines is a one-byte scalar. The whole stream
is walkable given the control's property-index→type map. vbdec has that map for intrinsic controls (from
vb6.olb); for an OCX you rebuild it from the control's own typelib (TLBINF32 / the OLB Viewer — see
tlb_dump.vbs: ListView → IListView, View=dispid 0x12, GridLines=0x1B,
stock Font=−512).
Validation (vs source). Decoding the compiled lvDebug blob in PDFStreamDumper reproduces its
Form1.frm source exactly: _ExtentX=14949, _ExtentY=2566, _Version=393217,
the Font (inline StdFont: "Courier New", 8.25pt, Weight 400), and
ColumnHeader(1).Text="Message" all round-trip byte-for-byte — confirming the
PropLoadProp type model on a real 3rd-party control.
Is it a VB6 PropertyBag dump? No. A PropertyBag.Contents blob begins with the magic
93 B2 00 00 (0xB293) and stores every property name as a UTF-16LE string (gridlines,
view, caption… plainly readable). The control stream has no property-name strings — it
is index-keyed via tagPROP, not name-keyed. (A control implementing only IPersistPropertyBag would
persist as a name-keyed bag; most intrinsic controls and MSComctlLib use the PropLoadProp stream.)
Full typed decode — the mechanism (how to name every property)
The opcode is an index into the control's property table — an array of tagPROP /
tagPROPDEF / tagPROPSHORT pointers in opcode order (each descriptor carries the
+4 type/flags, the +0x10 member offset, etc.). Two sources:
- Intrinsic controls: the table is a static array in MSVBVM60 (e.g. the ListBox table is
Std_propName, Std_propIndex, BackColor, ForeColor, Left, Top, Width, Height, Enabled, Visible, MousePointer, FontName…, TabIndex, LBox_propList, LBox_propSorted, LBox_propText…). vbdec already encodes this viavb6.olb— which is why intrinsic property values decode today. - OCX controls: VB's table covers only the wrapper — the extender props
(
Name, Left, Top, Width, Height, _ExtentX, _ExtentY, _Version…) plus a single object-data property whose value is the control's ownIPersistStreamInit::Saveblob (loaded viaIPersistStream::Load). The control's own properties live inside that blob, in the control's private format — which is not a VB opcode stream at all.
Proven by compile-diff (the definitive answer). Compiling a minimal ListView form and flipping one property
at a time shows the control's props are bit-packed with a checksum, not stored as (opcode,value) pairs:
View 3→1 changed exactly one data byte (+0x5B: 0x23→0x21, i.e. bits 0–1) plus a
3-byte checksum near the end; LabelEdit 1→0 cleared bit 5 of the same byte
(0x23→0x03) and moved the checksum again. So +0x5B holds View (bits 0–1) and
LabelEdit (bit 5), MSComctlLib-style. There is therefore no universal OCX property namer — each control
family (MSComctlLib, RichTextBox, …) defines its own IPersistStream packing (bitfields, extents,
embedded StdFont/items, a checksum), and naming its values means reversing that control's format (readily done
by compile-diff, one property at a time). Intrinsic controls remain fully decodable via vb6.olb; for OCX,
the universal, format-agnostic answer is the coverage-complete dump (ProgID/Font/Picture/strings + every other
byte shown as (unparsed) with its code and raw hex).
So a full OCX property namer needs that per-control ordered table. Build it statically (per the malware
rule, never load the sample's control): take VB's fixed stock/extender set + the control's dispinterface property
order from a system-registered typelib (or the sample's embedded typelib bytes read statically; see
tlb_dump.vbs), map each property's VARTYPE to the PropLoadProp type codes above, then walk the
opcode stream. Cache the result like vb6.olb. Until then, vbdec's OCX dumper is coverage-complete —
it decodes ProgID / Font / Picture / strings and shows every remaining byte as an (unparsed) entry with its
code byte and raw hex, so nothing is hidden.
PropLoadProp, LoadHctlHeader,
PropSetControlProperty, _PropLoadPicture, _PropSetDefaults, FrefFastReadSz,
_ErrLoadBstr; structs CTL, FILEREF, tagPROP, tagCTLHDR. The
opcode is the property's index in the control's table, not a raw dispid — vbdec's
opcode = memberID − 0x10000 is just how it rebuilds that index from the vb6.olb member list for
intrinsic controls. Full OCX value decode is tractable, but only once you rebuild the exact tagPROP order and
types from the control's type info.Prototype & type descriptors (function-signature recovery)
This is one of VB6's most useful — and least known — properties: every public COM object (form, class, UserControl) carries complete type information for its public members, baked in at compile time, with no type library required and no way to disable it. That means the full prototype of every public Sub / Function / Property / Event — argument count, per-argument type, ByRef/ByVal/Optional/Array, return type — plus every public variable's type and in-object offset, can be recovered straight from the binary. (Based on David Zimmer's paper, Recovery of Function Prototypes in Visual Basic 6 Executables.)
The chain: ObjectInfo → lpPrivateObject → PrivateObj, whose three
pointers reach the arrays of FuncTypeDesc, PubVarDesc and EventDesc records. A function/event
record is a fixed header followed by a packed buffer of type bytes, one per argument (plus one for the
return value on a Function / Property Get).
The type byte
Each argument's type is a single byte: a base type from an internal enumeration (not VARENUM), OR-ed with modifier bits. COM/internal types are followed by a 32-bit offset to a reference struct (library GUID, CLSID, name, DLL path).
| Base type (hex) | VB type |
|---|---|
| 0x03 | Boolean |
| 0x05 | Byte |
| 0x06 | Integer |
| 0x08 | Long |
| 0x0A | Single |
| 0x0B | Double |
| 0x0C | Date |
| 0x0D | Currency |
| 0x0F | Variant |
| 0x10 | String |
| 0x13 | internal VB object (+32-bit ref offset) |
| 0x1B | Object |
| 0x1C | COM interface (+32-bit ref offset) |
| 0x1D | COM object (+32-bit ref offset) |
| 0x1E | HRESULT (internal) |
| Modifier bit | Meaning |
|---|---|
| 0x20 | ByRef |
| 0x40 | Array |
| 0x80 | Optional |
argSize packs the argument count in its low bits (argCount × 4) and the property
kind (Get/Let/Set) in its top 3 bits. flags bit 1 flags a return value. optionalVals
set means Optional defaults follow (parsed into the COptParamDefVal records; note an Optional typed as
an enum is saved only as its underlying Long). Everything is 32-bit aligned, and null padding can appear
between a type byte and its offset and between type bytes — a parser must tolerate it.
PubVarDesc.dataOffset turns an anonymous [pr+0xNN] field access back into
Me.<field>. The records sit in .text; user code lies between
ProjectInfo.aStartOfCode and aEndOfCode.PrivateObj
Reached via ObjectInfo.lpPrivateObject
The hub for type/prototype recovery. Carries the public-variable and event counts and the pointers to the three descriptor arrays — functions (lpFuncTypeInfo), public variables (lpPublicVars) and events (lpEventsTypeInfo) — plus a back-link to the owning ObjectInfo.
ProcNamesArray / ObjectInfo.NumberOfProcs (a null name = a private function with no descriptor). Only cntPublicVars and cntEvents live in PrivateObj. Standard .bas modules have no PrivateObj (not COM objects internally).| Off | Size | Type | Field | Notes | e.g. |
|---|---|---|---|---|---|
| 0x00 | 4 | Long | nul1 | 0 | |
| 0x04 | 4 | Long | lpParentLink | 416DF4 | |
| 0x08 | 4 | Long | unk1 | FFFFFFFF | |
| 0x0C | 4 | Long | nul2 | 0 | |
| 0x10 | 2 | Integer | cntPublicVars | 1A | |
| 0x12 | 2 | Integer | cntEvents | 0 | |
| 0x14 | 4 | Long | nul3 | 0 | |
| 0x18 | 4 | Long | lpFuncTypeInfo | 454CE4 | |
| 0x1C | 4 | Long | nul4 | 0 | |
| 0x20 | 4 | Long | lpPublicVars | 43F904 | |
| 0x24 | 4 | Long | lpEventsTypeInfo | 43A7D0 | |
| 0x28 | 4 | Long | lpNull | 43A7D0 | |
| 0x2C | 4 | Long | nul5 | 0 | |
| 0x30 | 4 | Long | nul6 | 0 | |
| 0x34 | 4 | Long | nul7 | 0 | |
| 0x38 | 4 | Long | unk3 | 268 | |
| 0x3C | 4 | Long | unk4 | 4 |
FuncTypeDesc (public function/property)
Reached via PrivateObj.lpFuncTypeInfo → array, indexed in ProcNamesArray order
One per public Sub/Function/Property. A fixed header followed by a variable-length buffer of per-argument type bytes. argSize encodes the argument count and the property kind; vOff is the vtable offset; lpAryArgNames points at the argument-name strings.
argSize: the low bits are argCount×4 (so an Event with 1 arg shows 4); the top 3 bits are the property kind (111 Set, 010 Let, 001 Get). flags bit 1 set = the last type entry is a return value (Function / Property Get). vOff's low bit is a runtime flag — clear it. lpAryArgNames is NOT null-terminated: compute the count first, then walk it. Up to 59 user args.| Off | Size | Type | Field | Notes | e.g. |
|---|---|---|---|---|---|
| 0x00 | 1 | Byte | argSize | 0 (0 args) | |
| 0x01 | 1 | | flags | 0 | |
| 0x02 | 2 | Integer | vOff | 0 | |
| 0x04 | 2 | Integer | constFFFF | 0 | |
| 0x06 | 2 | Integer | nul1 | 0 | |
| 0x08 | 4 | Long | optionalVals | 0 | |
| 0x0C | 4 | Long | memberID | 0 | |
| 0x10 | 4 | Long | lpAryArgNames | last array entry null | 0 |
| 0x14 | 4 | Long | lpFuncDesc | 0 | |
| 0x18 | 4 | Long | nul3 | 0 | |
| 0x1C | 4 | Long | helpID | 0 | |
| 0x20 | 1 | Byte | unk1 | 0 |
PubVarDesc (public variable)
Reached via PrivateObj.lpPublicVars → array of cntPublicVars
One per public (module-level) variable. VB auto-generates Get/Let (and Set for object types) property accessors for these — vOff gives all three vtable slots. dataOffset is the variable's location relative to the object pointer (ObjPtr()+dataOffset).
dataOffset is the key to member-field name recovery: it maps a disassembly [pr+0xNN] reference back to Me.<field> (vbdec's field annotator uses exactly this). A type byte of 0x1D (COM object) is followed by a 32-bit offset to a reference struct with the library GUID / CLSID / name / DLL path (see the live example: Public selli As IListItem resolving to MSComctlLib).| Off | Size | Type | Field | Notes | e.g. |
|---|---|---|---|---|---|
| 0x00 | 4 | Long | lpName | 47593C -> Public selli As IListItem | |
| 0x04 | 4 | Long | nul1 | 0 | |
| 0x08 | 4 | Long | nul2 | 0 | |
| 0x0C | 2 | Integer | index | 1 | |
| 0x0E | 1 | Byte | unk1 | 3 | |
| 0x0F | 1 | Byte | unk2 | 40 | |
| 0x10 | 2 | Integer | unk3 | 2 | |
| 0x12 | 2 | Integer | vOff | 6F8 [Get 6F8, Let 6FC, Set 700] | |
| 0x14 | 4 | Long | dataOffset | from objPtr | 34 |
EventDesc
Reached via PrivateObj.lpEventsTypeInfo → array of cntEvents
One per declared event. Mirrors the FuncTypeDesc layout (events are prototypes that are only ever raised, never implemented in the defining object). The captured example recovers Event event_1(stream As CPDFStream).
event_<dispid> (the memberID). Events never take a ParamArray.| Off | Size | Type | Field | Notes | e.g. |
|---|---|---|---|---|---|
| 0x00 | 1 | Byte | argSize | 4 (1 args) | |
| 0x01 | 1 | Byte | isFunc | note: Event defs do not support ParamArray so this is ok... | 0 |
| 0x02 | 4 | Long | constFFFF | FFFFFFFF | |
| 0x06 | 4 | Long | nul1 | 0 | |
| 0x0A | 2 | Integer | nul2 | 0 | |
| 0x0C | 4 | Long | memberID | 1 | |
| 0x10 | 4 | Long | lpAryArgNames | last array entry not null, use argSize /4 | 43A80C |
| 0x14 | 4 | Long | lpFuncDesc | 0 | |
| 0x18 | 4 | Long | nul3 | 0 | |
| 0x1C | 4 | Long | helpID | 0 | |
| 0x20 | 1 | Byte | unk1 | 0 |
ProcDscInfo / RTMI — the per-procedure frame record
Every procedure carries a small fixed record the runtime calls RTMI (vbdec: ProcDscInfo), placed
immediately after its p-code body — so code start = RTMI − cbCode. The runtime
keeps the RTMI pointer in ebx through the prolog and uses it for stdcall cleanup, frame allocation,
Erl line lookup, Resume, and destroying the frame's locals on exit. (Full field
reference: RTMI_FIELDS.md — derived from the 1998 MSVBVM60 symbols plus compile-diff probes.)
| Off | Size | Field | Meaning |
|---|---|---|---|
| +0x00 | u32 | pObjectInfo | owning ObjectInfo |
| +0x04 | u16 | cbArgs | stdcall bytes to pop at return: 4 + 4/arg (class methods add this; a Function adds 4 for the hidden return-value pointer; Optional/ParamArray each count as one arg) |
| +0x06 | u16 | cbFrame | local frame size (bytes below EBP), checked against the thread stack limit |
| +0x08 | u16 | cbCode | p-code length; the code starts at RTMI − cbCode |
| +0x0A | u16 | cbHead | 0x18 + frame-RESDESCTBL size = offset to the first byte past the table |
| +0x0C | u16 | flags | 0x10 = Friend (prolog checks the caller's this belongs to this project); 0x20 = Class_Initialize (AddRef of this skipped); 0 otherwise |
| +0x0E | u16 | offErlTable | 0, or offset to the line-number table (u16 count, then count × {u16 pcodeOff, u16 Erl}), binary-searched to answer Erl |
| +0x10 | u16 | offLongStmtTable | 0, or offset to the long-statement length table; a BoS marker 02 nn ("statement too long for the inline byte") indexes entry nn here. Used by Resume / Resume Next |
| +0x12 | u16 | (compiler/IDE) | usually 0x19; 0 exactly when the proc has an Optional argument with a default or a For Each loop. No runtime reader found |
| +0x14 | u16 | ? | 0 in all probes |
| +0x16 | u16 | ? | 0 in all probes |
| +0x18 | — | frame RESDESCTBL | destructible locals (see Record descriptors): cbDesc ≥ 13 → DestructInstExceptSpecial on exit; the table's construct count → ConstructInst in the prolog |
+0x18 is a RESDESCTBL whose cbRecord is 0 and whose
flags bit 0x01 marks the function return value as the first entry — it is skipped by
teardown so the return value survives. vbdec's older ProcDscInfo dump mislabeled some fields: field_A is
cbHead, field_C is flags, field_10 is offLongStmtTable, and the
test [ecx+0Bh],1 the runtime does is the RESDESCTBL flags byte (RTMI+0x23), not RTMI+0x0B.Binary reuse. To relocate and re-run a single p-code function outside its binary (David Zimmer's
Binary reuse of VB6 P-code functions), you patch RTMI.pObjectInfo (+0x00) to your own object-info,
rebuild the function's constant pool, and enter through the runtime's ProcCallEngine
with the ProcDscInfo address in EDX — after initializing the runtime via CreateIExprSrvObj
(ThunRTMain works but misbehaves on teardown). A missing return type defaults to a COM Variant
the caller must push; simple numeric returns come back in EAX; module globals need
codeObj.aModulePublic pointed at writable memory.
Record (UDT) descriptors & RESDESCTBL — one grammar, four uses
VB6 uses a single binary format for every list of “things that need construction, destruction or
marshalling at known offsets.” A UDT's record descriptor, a module's data table, a class/form instance table,
and a procedure's frame table are all the same structure — the runtime's ConstructItem /
DestructItem walk the same entry list. (Full spec incl. the SAFEARRAY template, the ANSI translation
layout, the Put/Get file-serialization program and external-typelib record blocks: UDT_DESCRIPTORS.md.)
| Use | Located at | Offsets relative to |
|---|---|---|
| Record descriptor (one per UDT type) | const-pool udtPool:N operand; pointers inside other descriptors | start of the record |
| Module data RESDESCTBL | ObjectInfo.aPublicBytes (Dim/Public), aStaticBytes (Static) | module data block |
| Class / Form instance RESDESCTBL | ObjectInfo.aPublicBytes | the object; member data starts at +0x34 |
| Procedure frame table | ProcDscInfo (RTMI) +0x18 | signed 16-bit EBP offset |
Header (12 bytes)
| Off | Size | Field | Meaning |
|---|---|---|---|
| +0x00 | u16 | cbDesc | total descriptor size incl. header |
| +0x02 | u16 | cbRecord | LenB of the UDT (data size for module/class tables); 0 in frame tables |
| +0x04 | u16 | cConstruct | entries constructed at procedure entry (local fixed arrays); 0 elsewhere |
| +0x06 | u16 | cEntries | entry count |
| +0x08 | u16 | cbAnsi | size of the ANSI layout for Declare marshalling; 0 in RESDESCTBLs |
| +0x0A | u8 | align | max member alignment (capped at 4 for VB types); 0 in RESDESCTBLs |
| +0x0B | u8 | flags | see below |
flags: 0x01 (frame tables) first entry is the function return value, skipped by teardown;
0x04 always set on record descriptors; 0x08 has members needing destruction (String/Variant/
Object/dynamic array/nested-record-needing-it); 0x10 has an ANSI translation table; 0x20
contains string data. Examples: 0x04 plain UDT, 0x0C Variant/Object, 0x2C String,
0x34 fixed-length strings.
Entries
Packed with no padding, in declaration order (frame tables unsorted). Every entry starts u16 off, u16 type,
then type-dependent fields. Type codes (low 5 bits = base type, higher bits = modifiers):
| Code | Meaning |
|---|---|
| 0x0001 / 0x0002 / 0x0003 | String / Variant / Object (user classes, IUnknown, Collection, StdFont all look the same here) |
| 0x0005 | Array — +0x0080 fixed/embedded, +0x0100 dynamic |
| 0x0009 | Record (nested UDT) — +0x20 inner needs destruction, +0x40 inner has string data |
| 0x000A | fixed-length string |
| 0x000B | plain bytes (memcpy block in the ANSI translation table) |
| 0x0004 | fixed-length-String local > 64 chars (a BSTR alloc'd at entry, freed at exit) |
| 0x0006 | array lock (released with SafeArrayUnlock) |
| +0x0200 / +0x1000 / +0x2000 | constructed-at-entry / ANSI-form record / carries an explicit ANSI offset (translated) |
0x2000-flagged
entry with an ANSI offset (0xFFFF in RESDESCTBLs). So the entry list is not a complete field map unless you
account for both rules.What consumes it
| Opcode | Uses |
|---|---|
AssignRecord (FE F5) / DestructRecord (FF 2A) / EraseDestruct (FE 60) | deep copy / destruct / Erase of a record (array) |
CRec2Ansi / CRec2Uni (1F / 20) | Declare marshalling via the translation table |
Get/PutRecOwn3/4 (FF 16–19) | not the descriptor — a separate Put/Get file-serialization program (see the md) |
StAryRecMove/Copy (FF 03/04) | assignment of arrays of records |
AryInRecLdPr/LdRf (FF 06/07) | a standalone SAFEARRAY template (fixed array inside a UDT) |
Plain UDTs (no refs, no strings) are copied with CopyBytes — the descriptor is emitted but
nothing in p-code references it. Real-world check: all 647 descriptor tables in a 516 KB production binary
(PDFStreamDumper) decode to exactly their declared size, independent VB and Python decoders agreeing on every one.
The constant pool
This one trips people up, so it's worth being precise. Each compilation unit (module, class, form) has
one constant pool, shared by all of that unit's procedures and built as the compiler walks the file. It is a
flat array of DWORD slots at ObjectInfo.lpConstantPool (+0x34),
iConstantsCount (+0x28) entries long (iMaxConstants +0x2A reserved). P-code operands index it
by slot number: a string load points at a BSTR slot, an ImpAdCall* calls through a native-stub slot, a
record op points at a descriptor slot, and so on.
The catch: a slot is just a DWORD. Its meaning comes entirely from what it points at and which opcode uses it — there is no type tag. vbdec classifies each slot by probing its target (Explore → Constant Pool):
| vbdec tag | What the slot is | Used by |
|---|---|---|
UniStr | a real BSTR string literal (SysAllocString'd Unicode) | string loads; late-bound member names |
ExtApi. | an API-import native-call stub (a Declare) | ImpAdCall* to an API |
| (VB method stub) | a native-call stub for a VB method in another unit | ImpAdCall* to a VB proc |
CodeObj | a CodeObject base offset | object / member access |
GUID | a raw 16-byte GUID | CreateObject, etc. |
IID/CLSID Struct | a COMDEF {clsid, iid} pair | early-bound COM (late binding uses a UniStr member name instead) |
ControlIndex | a control index | control access |
VarAddr | a variable address | data loads/stores |
ResDescTbl Addr | a record/UDT descriptor address | AssignRecord / DestructRecord / … |
| (internal runtime COM) | a pointer to an internal runtime COM object | — (not generally recoverable) |
UniStr; a 16-byte GUID shape
→ GUID; a GUID preceded by a second GUID → a COMDEF IID/CLSID; an import/.text thunk
→ ExtApi…). The same slot index means different things in different units, since every unit has
its own pool.Why it matters — binary reuse. To re-run a p-code function outside its binary you must rebuild its unit's
pool: real BSTRs via SysAllocString, &guid byte arrays, COMDEF structs, API
stubs, and codeobject bases — then point the function's RTMI unit at it. vbdec's Constant Pool
viewer will even emit the C or VB rebuild code (constPool[N] = (int)SysAllocString(L"…"),
&guid_N, the COMDEF setup) to bootstrap exactly that.
Native thunks (method-link stubs)
Even a pure P-code VB6 exe is full of tiny hand-rolled x86 stubs in .text. They are the glue
that lets native call sites — the vtable, AddressOf callbacks, event sinks, the PE import table,
Declares — jump into the P-code engine (or an API). vbdec lists them under Explore → Native
Stubs (frmNativeStubs); each is a CNativeStub with a VA, owning code object, type, and the
target it resolves to. (These are the general case of the VB5 method-link thunks.)
Canonical shapes
The operands are the per-stub target VA and the runtime engine entry (e.g. MethCallEngine /
ProcCallEngine):
P-code method (form/class): 33 C0 xor eax, eax
BA <ProcDscInfo> mov edx, <target>
68 <engine> push <MethCallEngine>
C3 ret
P-code method (module): BA <ProcDscInfo> mov edx, <target>
B9 <engine> mov ecx, <engine>
FF E1 jmp ecx
Event thunk (multiplexed): B8 <idx> mov eax, <event index> ; see gotcha
66 3D C0 33 cmp ax, 0C033h
BA <target> mov edx, <target>
68 <engine> push <engine>
C3 ret
API Declare / import jump: FF 25 / jmp dword ptr [import] (or a push+jmp stub into the API)
Types — and where vbdec recovers each
| # | stubType | What it is | Recovered from |
|---|---|---|---|
| 0 | st_unknown | unclassified | — |
| 1 | st_callback | an AddressOf code pointer, discovered as the disassembler sees it pushed | modPCode2.MakeAddrToVB |
| 2 | st_jmpImportApi | a jump into the PE import table | PE imports |
| 3 | st_property | a property accessor thunk (auto or explicit Get/Let/Set) | CUserProp / method-link table |
| 4 | st_pcodeMethod | a P-code procedure entry thunk (incl. Sub Main) | modPeSkeleton, CUserProp, method links |
| 5 | st_apiDeclare | a Declared-API thunk | CVBProject.AddDeclare |
| 6 | st_controlEvent | a control event-handler thunk | COptObjInfo (optional object info) |
| 7 | st_propNewObj | a Public X As New … object-property thunk | CUserProp / method links |
| 8 | st_setEvent | an event-sink (WithEvents) thunk | CUserProp / method links |
CNativeStub also carries memOffset (ObjPtr()+memOffset for
setEvent/propNewObj/property), the owner code object, and the CMethodLink it came from.
mov eax, 3Ch / cmp ax, 0C033h deliberately embeds 33 C0 (xor eax, eax) so
the same bytes are also the start of an overlapping P-code-method stub. A linear disassembler cannot render
both correctly — it's an inherent, unfixable disasm artifact (and it breaks naive IDC struct scripting), not a
vbdec bug. Treat overlapping stub VAs as expected.Live memory: reading a running VB6 process
Every structure above also exists in the running process — and reading them live (via
ReadProcessMemory, or in-process over the ROT) unlocks what the file can't give you: the instances
that actually exist, their data member values, reference counts, and (on VB5) the runtime-filled
lpMethods. This is what vbdec's Live Classes / Live Memory viewers do. Three moves
cover most of it. (Background: David Zimmer's VB6 Variable Initialization and Teardown; the ROT plumbing
is CRemotelyScriptable.cls.)
1 — From a live instance back to the whole object model
A VB6 object reference is a pointer; the ObjectInfo pointer sits one DWORD before the vtable, so
from any live ObjPtr you can climb all the way back to the ObjectTable:
p = ObjPtr(obj) ' the instance address
pVtbl = [p] ' +0x00 : the vtable pointer
pObjInfo = [pVtbl - 4] ' the ObjectInfo for this class (stored just before the vtable)
pTObject = [pObjInfo + 0x18] ' ObjectInfo.aObject -> this class's tObject (Object descriptor)
pObjTable= [pObjInfo + 0x04] ' ObjectInfo.aObjectTable -> the project ObjectTable
The tObject descriptor (the lpObjectArray entries, stride 0x30) carries the
runtime names and flags: +0x18 aObjectName, +0x1C ProcCount,
+0x20 aProcNamesArray (the public method-name strings; a null entry = a private proc),
+0x28 ObjectType flags.
2 — Enumerate live instances and walk their data members
Each class's run-time instance manager (RUN_INSTMGR) is reachable from its (static) ObjectInfo. This is
the runtime's own list — the same linked list RcmResetModulesPrepass/ExecuteInitTerm
walk at shutdown to tear every instance down — so enumerating it finds every live object:
pRunMgr = [ ObjectInfo.lpProjectData ] ' lpProjectData = ObjectInfo + 0x1C
count = [ pRunMgr + 0x0C ] ' how many live instances
objPtr = [ pRunMgr + 0x00 ] ' first instance
' per instance: refCount = [objPtr + 0x20] ; nextInstance = [objPtr + 0x04]
Then each public variable's value lives at objPtr + PubVarDesc.dataOffset, read according to the
descriptor's base type:
| Base type | Read |
|---|---|
| String | DWORD at objPtr+dataOffset = BSTR ptr; length = [BSTR-4] & 0xFFFF; then read the bytes |
| Date / Double / Currency | 8 bytes |
| Single | 4 bytes |
| Integer / Boolean | 2 bytes |
| Byte | 1 byte |
| Variant | 16 bytes |
| Object / other | 4-byte pointer |
Module-level / static variables work the same but their dataOffset is an absolute address, not
objPtr-relative. (This is the same PubVarDesc.dataOffset the field annotator
uses — static type info + a live pointer = typed, named member values.)
3 — Private variables & resource teardown (RESDESCTBL)
The IDispatch descriptors above cover only public members. Private module/class variables that
require cleanup are described by a second structure the runtime uses for init and tear-down: RESDESCTBL,
pointed to by Object.aPublicBytes. It exists so a terminating object can free its strings/objects/arrays
(RESDESCTBL::DestructItem) and so pre-dimensioned arrays are ready on first use
(RESDESCTBL::ConstructItem).
Header (tResDescTbl): size, memDataSz (end-marker of the class data block,
0xABABABAB), reqAlloc, entries, unk1 — followed by per-variable
entries of { dataOffset, typeId } (e.g. typeId 1 = String, 3 = Object; it also tracks Variants,
Arrays and UDTs whose subtype needs cleanup). For instanced objects the member is at ObjPtr + dataOffset;
for BAS modules the data lives at Object.aModulePublic (absolute). Pre-dimensioned array (SafeArray)
definitions live here too.
Private b As Long never appears (nothing to free), so it is NOT a complete private-variable list. It is
coarser than the IDispatch type info, but it is the one source that reveals private variable offsets/types.
vbdec surfaces it under Explore → Resource Descriptors; the full type grammar is in the UDT / record-descriptor
companion write-up.4 — Make a class public and reach it over the ROT
Normally only forms/public objects are externally reachable. You can flip a private class
in memory by setting bit 0x800 in its tObject.objectType (+0x28),
then register the instance in the Running Object Table so a script can GetObject it:
' per class: locate tObject (via the chain above, or walk ObjectTable.lpObjectArray [+0x30], stride 0x30,
' count at +0x2A), then:
VirtualProtect va+0x28, 4, PAGE_EXECUTE_READWRITE, old
PutMem4 va+0x28, objectType Xor 0x800 ' set the "public" bit
VirtualProtect ... restore
' then publish it:
GetRunningObjectTable 0, pROT
CreateFileMoniker StrPtr("myapp.obj"), pMoniker
pROT.Register ROTFLAGS_REGISTRATIONKEEPSALIVE, ObjPtr(obj), ObjPtr(pMoniker), VarPtr(cookie)
' -> a client does: Set o = GetObject("myapp.obj")
Reads/writes use the undocumented MSVBVM60 helpers GetMem4/PutMem4 (and
CopyMemory); verify the target really is a VB6 object first (__vbaCheckType with the VB6
object IID). This is exactly how vbdec exposes vbdec.frmMain/vbdec.vbp for the ROT
automation used throughout these tools.
TypeName() of a VB6 class returns "Object" (use TypeOf, or ask the target
process itself via an in-process query).Gotchas & version notes (quick reference)
- VB5 vs VB6 is a minor delta. Same
VB5!signature, same structures, same opcode set (VB6 only appends). The one real structural change is where a method's code-descriptor VA lives (ObjectInfo.lpMethods flat array in VB6 vsmov edx,<ProcDscInfo>method-link thunks in VB5). Full detail in the opcode reference's VB5 vs VB6 section. - P-code vs native share every structure here. Differences:
ProjectInfo.aNativeCodeis set for native;ObjectInfo.NumberOfProcscounts user procs + API declares for P-code but API declares only for native; per-method bodies are P-code vs x86. - Offsets vs pointers.
a*fields are absolute VAs;o*fields are offsets relative to a base (usually the owning struct's VA). Mixing them up lands you in the weeds. - Recompile churn to ignore in diffs: all per-object GUIDs and the project/object CLSIDs regenerate every
build;
ProjectInfo.OriginalPathNamereflects only the build machine; most VAs shift. - Deeper layers (not repeated here): per-procedure
ProcDscInfo/RTMI frame tables, UDT/record descriptors, the control property blob grammar, and events each have their own companion write-up.
Generated from structs_sample.txt + the vbdec struct sources. This documents the
structures as vbdec parses them; field names follow vbdec's naming, which in turn builds on the VB-reversing
community's decades of work on the format.
Runtime symbol reference (MSVBVM60)
Jump-off points for your own reversing. Addresses are in the symboled MSVBVM60.DLL v6.00.8268 (linked
1998-09-26, imagebase 0x66000000) — the same build vbdec ships as its target.runtime;
names are the runtime's own (C++-mangled in the DLL, shown demangled). Offsets drift between builds, so match by name.
msvbvm60.dbg) are archived at
sandsprite.com/vb-reversing/files/msvbvm60.zip. SHA-256:
MSVBVM60.DLL(1,409,024 bytes, v6.00.8268)e86cbb17c8fcd97bb04d9e0b7fb3ef7a33c6896e681705fc95405f14008737cdmsvbvm60.dbg(2,826,996 bytes)ee600ce25f6f3331c55a7f8e41088b11fd85f4ecc6d61a7679b33edd150058d0
.dbg alongside the DLL (IDA/WinDbg) to get the named symbols below verbatim.Control & property load
| Addr | Symbol | Role |
|---|---|---|
| 0x66065D92 | RbyLoadHctl | load one hosted control (the dispatcher: header → create → props) |
| 0x66065E5F | LoadHctlHeader | read the control header (length, cId, name, cType / ProgID) |
| 0x66065EFD | PropLoadProp | walk the 1-byte-opcode property stream |
| 0x66006391 | PropSetControlProperty | apply one property (store at tagPROP+0x10, or vtable call +0x20/+0x24) |
| 0x66067262 | _PropLoadPicture | load a Picture property |
| 0x660661DF | _PropSetDefaults | default the properties an ascending opcode skipped |
| 0x66065EDC | FrefFastReadSz | read a length-prefixed string from the stream |
| 0x66041780 | _ErrLoadBstr | load a BSTR value |
| 0x66064EF0 | CCreErrCreateCtlStruct | create the control struct (builds its tagPROP table from type info) |
| 0x6604236D | CioErrLoadFormNtable | load a form's control table |
Records / RESDESCTBL / UDT
| Addr | Symbol |
|---|---|
| 0x66061D6B | RESDESC::CbResdesc (entry size) |
| 0x66061E42 | RESDESCTBL::DestructItem (teardown) |
| 0x660E9A07 | RESDESCTBL::ConstructInst (init, incl. pre-dim arrays) |
| 0x66012479 | RESDESCTBL::DestructInstExceptSpecial |
Procedure / RTMI, instance teardown, scaling
| Addr | Symbol |
|---|---|
| 0x66102CED | ErlOfOtex (Erl line lookup) |
| 0x66102D06 | PtexBosNext (BoS / long-statement) |
| 0x6610B10C | LongResume (Resume / Resume Next) |
| — | RcmResetModulesPrepass / ExecuteInitTerm (walk the RUN_INSTMGR instance list at shutdown) |
| 0x6606428A… | GrafR4ScaleTwipsPosX/SizeX/PosY/SizeY (twips↔pixel for coordinate properties) |
Structures named by these routines: CTL, FILEREF, tagPROP,
tagCTLHDR, Projitem / Project, RTMI, RESDESCTBL /
RESDESC, RUN_INSTMGR. Pair with a stock-symbol MSVBVM60.DLL (or vbdec's
target.runtime) in IDA to follow them live.
Credits
This reference was created by David Zimmer (sandsprite.com), working with Claude (Anthropic).
On the shoulders of others
None of this starts from zero. This reference stands on the shoulders of several generations of VB reversers, who mapped the runtime, the file format and the P-code engine long before AI tools existed, and who shared what they found.
Credits include the VB Decompiling community, Sarge, Napalm, VbGamer45, Mr. Unleaded, Moogman, _aLfa_, Alex Ionescu, Warning, josephCo, Darker, Trick, Firehacker, dodi, sysenter-eip and others.
Special thanks go out to:
- VbGamer45 for his work on Semi VB Decompiler
- Mr. Unleaded for allowing the old forums to be rebuilt
- Mr Silver and Mr Snow for their excellent work on the WKTVBDebugger
- Darker for his excellent work on p32Dasm
- Reginald Wong for his vb.idc script
- The OG wizards who created VB6