Skip to content

feat(labels): recover C++ symbols and normalize language metadata - #210

Closed
r0ny123 wants to merge 8 commits into
danielplohmann:masterfrom
r0ny123:feat/labels-symbol-recovery-tier2
Closed

feat(labels): recover C++ symbols and normalize language metadata#210
r0ny123 wants to merge 8 commits into
danielplohmann:masterfrom
r0ny123:feat/labels-symbol-recovery-tier2

Conversation

@r0ny123

@r0ny123 r0ny123 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

This completes the next label-recovery tier for C++ binaries and makes serialized language metadata consistent: every defined ELF dynamic export is recovered (including data objects such as vtables and RTTI), Itanium and MSVC C++ names are demangled in process, language identification is driven by validated symbol evidence instead of prefix guesses, and metadata.language becomes a score-only map. Released as 4.4.0 with Python 3.11+ support.

It builds on #209 (tier-1). Until that merges, this PR's commit range includes those four commits; the new work is 2fcf8bb..cf95489.

Motivation

ELF exports were treated as function-only metadata. On a representative ELF sample that exposed 3 function exports even though the dynamic symbol table holds 26 defined exports: 3 functions and 23 objects, the objects being useful C++ metadata (vtables, typeinfo, typeinfo names).

Three related problems sat on the same path:

  1. host-dependent C++ demangling (c++filt / llvm-cxxfilt subprocesses) did not consistently cover ELF relocation imports and Mach-O symbols, so results varied per machine;
  2. broad _ZN-prefix checks classified ordinary Itanium C++ symbols as Rust;
  3. metadata.language mixed public scores with internal counters and a private guess, and used backend-specific string values.

Changed behavior

ELF export and import recovery

  • xmetadata.exported_symbols is new: every defined ELF dynamic export, including data symbols, keyed by virtual address. Thread-local symbols are excluded because their value is an offset inside the TLS block rather than an address, and SHN_COMMON / SHN_ABS entries are excluded because they are not laid out in the image. Aliases sharing one address (for example the C1/C2 constructor pair) keep the first name the dynamic symbol table spells, so repeated runs agree.
  • xmetadata.exported_functions and the legacy xmetadata.symbols map stay function-only, so data objects never become function-entry candidates.
  • xmetadata.imported_functions is demangled. API references (SmdaFunction.apirefs) deliberately keep the undecorated import name, so they stay comparable with PE and Mach-O imports and with previously produced reports.
  • Undefined dynamic symbols that LIEF reports with a PLT address are no longer recovered as local functions. The same predicate is applied to Rust ELF symbol recovery, which had the identical exposure.
  • A malformed symbol name skips only that entry instead of aborting or truncating collection. This covers ELF exports, dynamic and static symbols, relocation imports, Mach-O exports and symbols, Rust ELF/Mach-O scans, and Visual Basic import inspection.

On the representative ELF sample: 26 / 26 defined exports recovered, split 3 functions and 23 objects; 241 relocation-backed import slots recovered; no Itanium-mangled labels left in the report label maps.

C++ and Rust evidence

  • pycxxfilt's vendored LLVM demangler replaces the host-tool subprocesses, so demangling is deterministic across machines and available on Windows.
  • An Itanium name must demangle successfully before it counts as C++ evidence. MSVC decorated names count too, both functions and decorated data such as class statics.
  • Thiscall-shaped instruction sequences are no longer scored as C++. They are an ABI-level pattern, not language identity, and the old score divided by the function count at a point in analysis where no function existed yet, so a single matching byte pattern inflated it to 1.0.
  • The symbol scan stops as soon as the score saturates, so a large symbol table is not walked in full for a result that cannot change.
  • Fat Mach-O binaries select the active architecture/bitness slice before C++ symbols are scored.
  • Rust evidence is centralized and requires a parseable v0 or hashed-legacy symbol, so ordinary Itanium C++ names no longer read as Rust.

Language report contract

  • metadata.language contains only language -> float score entries. Internal counters and the private guess are gone.
  • To pick one language from the map: highest score, except that go and rust win outright above 0.5, since a build ID, pclntab header, or demangled Rust symbol is conclusive while the other scores are graded evidence. The README documents this.
  • CIL reports use {".net": 1.0} and Dalvik reports use {"dalvik": 1.0} instead of backend-specific strings.
  • SmdaReport.fromDict normalizes legacy string values and removes private legacy keys, so older reports load into the new contract.
  • Go, Delphi, Visual Basic, C++, and Rust scores require stronger format/symbol evidence before activating language-specific recovery. Go pclntab headers are validated structurally (version magic, padding, PC quantum, pointer size) and accepted for big-endian targets as well.

Deliberately unchanged

The Delphi VMT scan stays gated on the TObject marker rather than on a valid PE header, and the Visual Basic runtime lookup is likewise ungated. Memory dumps that start below the MZ header are a primary input for SMDA, and a header gate would silently disable recovery for exactly those samples. The scan result is memoized and shared with Delphi candidate recovery, so a real Delphi image pays for it once either way.

Compatibility

This is a breaking report and runtime change:

  • Python 3.11+ is required.
  • pycxxfilt>=0.1.0 is a new runtime dependency and replaces host-tool demangling.
  • Consumers must treat metadata.language as a score map and must not read _guess, _count_thiscalls, _count_delphi_objects, or delphi_kb_file.
  • ELF C++ import labels are now demangled. API reference names are unchanged.
  • xmetadata.exported_symbols is additive; the existing function-oriented maps keep their meaning.

Validation

  • make lint — clean
  • make test — 701 passed, 1 skipped
  • python -m pip check — clean
  • source distribution and wheel build — successful
  • representative ELF analysis — 26 exports, 241 import slots, zero remaining mangled export/import labels

The large AArch64 Mach-O corpus test previously used a 20-second per-sample wall-clock guard that tripped twice on contended runners. Profiling showed the C++ symbol scorer costs roughly 1 ms per pass on the largest corpus sample, so it was not the cause and a sampled scorer was rejected as inaccurate. The guard is now a 45-second default, raisable through SMDA_TEST_CORPUS_TIMEOUT on a slow runner. Production timeout behavior is unchanged.

r0ny123 added 8 commits July 24, 2026 19:55
DelphiPythiaProvider was only used by LanguageAnalyzer to seed candidate
addresses; its recovered VMT/method-table names were discarded because it
was never registered in RecursiveDisassembler._addLabelProviders. Register
it as a symbol provider (mirroring DelphiReSymProvider, which is used in
both places) so resolveSymbol applies its names to functions.

Validation: make lint, pytest tests/testDelphiPythiaProvider.py
The unrecognized version/bitness ValueError messages interpolated the raw
integer after a '0x' prefix, rendering the marker in decimal (e.g.
'0x4294967281'). Use hex format specifiers so the diagnostic matches the
actual pclntab magic. No behavior change; the error is already caught by
the caller's reraise_non_operational_exception path.
Resolve far more import-by-ordinal references to real API names, improving
readability and cross-sample fingerprint stability. Only explicitly-assigned
(frozen) export ordinals are added, sourced from the ReactOS .spec files that
mirror the Windows ordinal ABI:

- ws2_32.dll: Winsock classic set (1-23, 51-57, 101-116, 151)
- wsock32.dll: full frozen legacy set incl. MSWSOCK forwarders (AcceptEx,
  TransmitFile, GetAcceptExSockaddrs)
- oleaut32.dll: complete ordinal-only export table (Var*/SafeArray*/TypeLib)

Drops the previously-incorrect ws2_32 97/98/99 entries (getaddrinfo/
freeaddrinfo/getnameinfo are by-name exports, not stable ordinals). System
DLLs (kernel32/user32/advapi32/...) are intentionally excluded: they export
by name with non-stable ordinals.

Validation: make lint, pytest tests/testDefinitionsExpansion.py
Address PR review:
- Move _updateLabelProviders() to after bitness is finalized so bitness-keyed
  providers (DelphiPythiaProvider) parse correctly for raw buffers whose bitness
  is only known after probeBitness() (P2).
- Revert the expanded OrdinalHelper comment block to its original two lines, per
  the repo no-comments convention (P1).

Validation: make lint, make test (668 passed, 1 skipped)
Recover and demangle all defined ELF exports and relocation imports, keep data exports separate from function candidates, and make C++/Rust language evidence symbol-aware.

Normalize report language metadata to score-only maps, preserve legacy report loading, switch to pycxxfilt, and raise the supported Python floor to 3.11.

Pattern: PAT-SMDA-030

Validation: make lint, make test (692 passed, 1 skipped), package build

BREAKING CHANGE: metadata.language no longer serializes private guess/count keys or backend string values, and SMDA now requires Python 3.11+.
Guard VB import-library normalization against malformed LIEF names and select the active thin slice before scanning C++ symbol evidence in fat Mach-O binaries.

Document pycxxfilt double-underscore Mach-O support with a direct regression test.

Patterns: PAT-SMDA-003, PAT-SMDA-030

Validation: make lint, make test (695 passed, 1 skipped)
The functional Mach-O corpus can exceed a 20-second wall-clock budget on
contended Python 3.14 runners even though the same commit passes on another
runner. Align its guard with the existing 120-second AArch64 corpus convention
without changing production timeout behavior.

Validation: Ruff check/format, focused corpus suite, full suite (695 passed, 1 skipped)
Address review findings on the C++ symbol recovery change.

ELF relocation imports stay undecorated at the source, so API references
keep matching PE and Mach-O import names; demangling moves up into
ElfSymbolProvider.parseImports, which only feeds the label view.

Exported symbol recovery now rejects entries whose value is not a virtual
address: thread-local symbols carry a TLS-block offset, and SHN_COMMON /
SHN_ABS are not laid-out definitions. Address aliases keep the first name
the dynamic symbol table spells so repeated runs agree. The defined-symbol
predicate is shared with Rust ELF symbol recovery, which had the same
undefined-dynsym exposure.

The language score map stays score-only. Picking a single language from it
is not a plain maximum, so the README documents the rule instead: go and
rust win outright above 0.5, because a build ID, pclntab header, or
demangled Rust symbol is conclusive while the other scores are graded.

Also: stop the C++ symbol scan once the score saturates, recognize MSVC
decorated data names, accept big-endian Go pclntab headers, and make the
Mach-O corpus per-sample timeout a 45s default tunable by environment
instead of a blanket 120s.
@r0ny123 r0ny123 changed the title feat(labels)!: recover C++ symbols and normalize language metadata feat(labels): recover C++ symbols and normalize language metadata Jul 27, 2026
@danielplohmann

Copy link
Copy Markdown
Owner

Superseded by #211, which rebases the #210 work (its four commits cherry-picked onto master after #209 landed) so it merges cleanly without the duplicate fork commits.

danielplohmann added a commit that referenced this pull request Jul 28, 2026
…ry-tier2-rebased

feat(labels): recover C++ symbols and normalize language metadata (rebased #210)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants