Skip to content

feat(labels): demangle MSVC decorated symbol names - #255

Open
r0ny123 wants to merge 6 commits into
danielplohmann:masterfrom
r0ny123:feat/msvc-demangler
Open

feat(labels): demangle MSVC decorated symbol names#255
r0ny123 wants to merge 6 commits into
danielplohmann:masterfrom
r0ny123:feat/msvc-demangler

Conversation

@r0ny123

@r0ny123 r0ny123 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

MSVC-decorated names were the one mangling scheme SMDA carried no demangler for, so a C++ binary built with the Microsoft toolchain reported ?__crt_rotate_pointer_value@@YAIIH@Z where a GCC-built one reported a signature. This adds one and wires it into the PE provider.

Part of a linear stack: #249#250#254#255. They are ordered that way because #249 and #254 both touch RustSymbolProvider and would otherwise conflict in a way that silently breaks import smda if resolved the obvious way, and because #250 creates the NOTICE this PR extends. Merging in that order needs no conflict resolution.

The contract

A name comes back either as the reference implementation spells it, or exactly as it went in. Never as a third spelling.

That matters more here than coverage does. A decorated name is still a usable identity — it can be matched against a symbol server, an import table, or another report. A confidently wrong expansion is worse than no expansion, because it matches neither the decorated form nor the real one. So every construct the parser does not fully model declines rather than guessing, and the tests assert this over the whole corpus rather than sampling it.

Measurements

llvm-undname is the reference. The corpus is the one LLVM tests its own demangler against — 609 unique names drawn from llvm/test/Demangle/ms-*.test (the upstream files repeat 15), which is a compiler stress suite rather than a sample of ordinary binaries: it is full of lambdas, anonymous namespaces, member pointers and deeply nested templates.

count
spelled exactly as llvm-undname spells it 383 (63%)
returned unchanged 226
given a different spelling 0

On the MSVC names carried by real PDBs (the sqlite3 x86/x64 and rust-lld builds): 14 names, 11 exact, 0 wrong. The remaining 3 are local-scope names.

And on a PE built by the toolchain this is meant to read, all four decorated exports come back as signatures. tests/msvc_cxx_pe_xored is that binary, added here because none was available: no PE on hand carries MSVC-decorated names — sqlite3's 277 exports are all C — so this arm of the PE provider had only synthetic symbols behind it. It is a small translation unit built for x86_64-pc-windows-msvc by clang-cl 22.1.7, exporting free functions, a namespace, a class returned by value and an extern "C" name.

Its first real export was one the demangler could not read, and the reason generalised. A return type is the one position carrying a qualifier of its own, and that qualifier is not decoration: every function returning a class by value is spelled with it, ?A being the unqualified case rather than an absent one. Parsing it — at all three places a return type is read — is worth 20 corpus names on its own. Twenty-one corpus names had used the form the whole time; they sat in the declined bucket, where a single aggregate count kept them from reading as a gap.

The corpus ships as tests/msvc_reference_corpus.txt so the demangler is measured against a reference rather than against its own output, and a regression that starts emitting third spellings fails the build rather than going unnoticed.

What it understands

Qualified and nested names, name and argument back-references, the basic and extended type set, return types with their own qualifier, pointers and references with their own and their pointee's qualifiers, __restrict, arrays, function pointers, declarator composition over all of those, variadic parameter lists, class/struct/union/enum types, templates, data symbols with their storage class, constructors, destructors, operators, and the vftable/vbtable family.

A type is built as a tree and spelled around a declarator rather than rendered eagerly, because C nests the two — the name belongs inside its own type. That is what makes int (__stdcall *j)(signed char, unsigned char) and int (* __cdecl f(void))[2] come out right, and it is why a function pointer used as a data declaration, a return type, or the pointee of another pointer are ordinary cases rather than refusals.

What it still declines: name back-references resolved against a table containing a template render (the numbering does not follow the obvious rule and I would rather stop than emit a plausible wrong name), qualified back-references (which the reference implementation does not form either), operator templates, member function pointers, local-scope and lambda names, and RTTI descriptors.

How the grammar was established

From Microsoft's documented decorated-name format, with llvm-undname used to settle every case where the documentation is silent or ambiguous — pointer qualifier composition in particular, where PBQAD and PAQAD both spell char *const *, was derived by probing the reference rather than reasoned about.

Refusing what the grammar does not allow

A corpus of well-formed names only proves the demangler reads what it should. It says nothing about what it does with the rest, and a symbol table holds whatever bytes were written into it. So the corpus was also used as fuzzer seed: 15044 candidates built by truncating, splicing and mutating those 609 names, each fed to both implementations, asserting that wherever this demangler answers it agrees with the reference.

That found 256 shapes it expanded confidently while the reference refused them. Five fixes take it to 4, every one of them refusing more rather than expanding anything new:

  • A parameter list must be closed by its throw specification — fixed lists end @Z, variadic ones ZZ. The terminator was consumed only when the parser was not already at end of input, so any name truncated just past its parameters got a plausible answer: ?a2@@YAHX read as int __cdecl a2(void), and ?f@@YAXHZ as a variadic call whose last byte was never there.
  • _D through _I are not type codes. No Microsoft-compatible mangler emits them — __int8, __int16 and __int32 are spelled with the plain char, short and int codes — so a name carrying one is not MSVC-decorated.
  • A template name is an identifier, not a digit or an operator code.
  • A special name takes a signature or a storage class by which code it is, never both. A vftable with a parameter list and an operator! with a vftable's storage class are equally malformed.
  • A reference cannot carry a qualifier from the type enclosing it — C++ has no such form, and it had been rendering as char const &const volatile *.

Two divergences from the reference are deliberate, and both are cases where copying it would make the output worse:

  • _L and _M (__int128) are spelled from clang's Microsoft mangler, which emits them, because llvm-undname has no reader for what its own compiler produces.
  • ??_X and ??_Y are spelled from Microsoft's table as the placement delete closures. The reference recognises them structurally but emits an empty name — public: int __cdecl Base::(int) — which is not a usable identity.

Bounded against hostile input

Both resources a crafted name can spend are capped. Depth stops below the point where CPython's own recursion limit could be what ends a parse — otherwise the answer would depend on how deep in SMDA the provider happened to be called, which I verified is identical across three caller stack depths. The rendered result is capped too: argument back-references can reuse an earlier rendering repeatedly, which grew a 132-byte name into 496 MB of output (2.5 GB resident, retained by the cache) before the bound; it now declines in 0.02 ms.

Robustness

Every prefix of every corpus name is fed through the demangler in the tests; none may raise. Malformed shapes — empty fragments, out-of-range back-references, unknown calling conventions, trailing bytes, missing terminators — each have a case asserting they come back untouched.

Validation

python -m pytest tests/ -q                  1210 passed, 1 skipped
make lint                                   clean
make typecheck                              exit 0, no new diagnostics
diff-cover coverage.xml --fail-under=100    100%

The new module is at 100% statement and branch coverage.

Follow-up

The PDB provider is untouched here. On master it still gets MSVC names expanded by pdbparse; once #245 replaces that with purepdb, which does not demangle, this is what should fill the gap — but that belongs on top of #245 rather than in this PR.

Demangled names were passed through a port of Ghidra's CondensedString
before being stored, so <Error as core::fmt::Display>::fmt reached the
report as <Error_as_core::fmt::Display>::fmt and A, B became A,B. Ghidra
needs that because a Ghidra symbol name cannot contain spaces; SMDA has
no such constraint, and already stores names with spaces from another
source -- 39 of the 399 names recovered from a PDB on a rust-lld x64
image contain them, because MSVC proc records spell them that way. The
condensation was applying one spelling rule to names from a symbol table
and another to names from a PDB, in the same report.

Removing it exposed a printer bug it had been masking: a function
pointer's ABI ran into its fn, giving unsafe extern "C"fn(*mut u8). The
closing quote was emitted without the trailing space, and comparisons
against the reference implementation could not see it because the
condensation deleted that space from both sides. One existing test
asserted the wrong spelling and is corrected.

Measured against rustc-demangle 0.1.28 on 147 real symbols read out of a
rust-lld x64 image: 93 of 147 matched byte for byte before, 146 after
dropping the condensation, and 147 after the ABI fix.

Cross-checked against the 18 mangled symbols in the reference
implementation's own test corpus: 8 match, 9 raise and so keep the name
the binary gave them, and 1 differs -- a punycode identifier this port
renders as a placeholder, which is pre-existing and left alone here.
src/smda/common/labelprovider/rust_demangler/ is vendored third-party
code and carries no attribution. The commit that introduced it says so
directly -- it vendors the rust_demangler package by Team bi0s, MIT
licensed, with robustness behaviour reimplemented from Ghidra, which is
Apache-2.0. Neither is recorded: LICENSE covers only this project's own
BSD 2-Clause, there is no NOTICE, and the package has no header. The MIT
licence asks that its copyright and permission notice travel with copies
and substantial portions, and this code is redistributed in the wheel
published to PyPI.

Add a NOTICE carrying the MIT copyright line and permission text and a
pointer to Apache-2.0 for the Ghidra-derived parts, a docstring on the
vendored package pointing at it, and a line in the README credits.

The in-code comments this attribution was drawn from named "Ghidra's
rust-demangle.c", and no such file exists: Ghidra's Rust demanglers are
Java, at Ghidra/Features/Rust/src/main/java/ghidra/app/plugin/core/
analysis/rust/demangler/RustDemanglerV0.java and RustDemanglerLegacy.java.
Name those instead, in the NOTICE and in the two comments that were the
source of the error, and record that Ghidra's own V0 demangler is a port
of the rustc-demangle crate, which is the real upstream of the behaviour.

The NOTICE lists every vendored component rather than only the one that
prompted it, so Tarjan.py and DominatorTree.py -- both already credited
in the README paragraph the new line joins -- appear alongside it.

No pyproject change is needed: setuptools' default license-file glob
already matches NOTICE*, and a build confirms it reaches both the wheel
(dist-info/licenses/NOTICE) and the sdist.
Two reasons a PE reached the report with mangled names.

RustSymbolProvider recovered nothing at all from a PE. Its COFF loop
skipped a symbol when Symbol.section was None, and lief never populates
that attribute for PE -- PeSymbolProvider already says so in a comment
and resolves through section_idx instead. On the bundled mingw-linked
Rust fixture, 0 of 4818 symbols carry a section while 4366 carry a
usable section_idx, so the guard rejected every one, the provider
contributed nothing, and PeSymbolProvider's raw spellings won by
default: 2098 names reached the report spelled _RNv... Resolving through
section_idx recovers those 2098 and none of them stay mangled.

PeSymbolProvider never demangled anything. ElfSymbolProvider and
MachoSymbolProvider both route recovered names through a demangler,
while this one stored them verbatim, so a mingw-built C++ PE reported
_ZN12FileExplorerC2Ev rather than a signature. It now applies the same
Itanium helper ElfSymbolProvider uses, which leaves every non-Itanium
name untouched.

Making that COFF loop live also made the gate in front of it matter, and
it was the wrong one. _is_rust_symbol tested the prefixes alone, but
legacy Rust mangling shares _ZN with the C++ Itanium ABI, and this
provider is consulted before the format providers -- so on a Rust binary
that also carries C++ symbols it would have claimed names like
_ZN4test4funcEv and replaced the full Itanium signature "test::func()"
with the "test::func" the Rust legacy demangler degrades it to. It now
uses the shared evidence gate, which parses a name before claiming it.
Recovery on the fixture is unchanged at 2098 names.

No bundled PE carried Itanium C++ symbols to show the second fix on, so
add one: a small C++ translation unit compiled for x86_64-w64-mingw32 by
g++ 16.2.0. Before the change it yields three mangled names and nothing
readable; after it, three readable signatures and nothing mangled.

The mocks in testRustSymbolProvider modelled a PE symbol through
Symbol.section, which no real PE symbol has, so they exercised a path
that could not work on a real binary; they now carry section_idx, and
the provider reads it directly rather than through a getattr default
that would turn a future lief rename back into "skip every symbol".
Several tests used _ZN3foo3barE as their Rust fixture, which has no
17h<hash> suffix and so is Itanium C++ rather than legacy Rust; they now
use a name that really is Rust-legacy, and the PE provider test expects
foo::bar from the C++ demangler.
MSVC decoration was the one mangling scheme with no demangler here, so a
binary built with the Microsoft toolchain reported
?__crt_rotate_pointer_value@@YAIIH@Z where a GCC-built one reported a
signature. Add one, and let the PE provider pick a demangler by the
decoration a name carries.

The contract is that a name comes back either as the reference
implementation spells it or exactly as it went in, never as a third
spelling. That matters more than coverage does: a decorated name is
still a usable identity, matchable against a symbol server or another
report, while a confidently wrong expansion matches neither the
decorated form nor the real one. Every construct the parser does not
fully model therefore declines.

A type is built as a tree and spelled around a declarator rather than
rendered eagerly, because C nests the two: the name belongs inside its
own type. That is what makes int (__stdcall *j)(signed char) and
int (* __cdecl f(void))[2] come out right, and it is why a function
pointer used as a data declaration, a return type, or the pointee of
another pointer are all ordinary cases here instead of refusals.

Measured against llvm-undname on the corpus LLVM tests its own demangler
with -- names from llvm/test/Demangle/ms-*.test, a compiler stress suite
rather than a sample of ordinary binaries -- 363 of 609 are spelled
identically, 246 come back untouched, and none get a third spelling. On
the MSVC names carried by real PDBs: 10 exact of 14, none wrong. The
corpus ships as test data so the demangler is measured against a
reference rather than against its own output.

Understood: qualified and nested names, name and argument
back-references, the basic and extended type set, pointers and
references with their own and their pointee's qualifiers, __restrict,
arrays, function pointers, declarator composition over all of those,
variadic lists, tagged types, templates, data symbols with their storage
class, constructors, destructors, operators and the vftable family.
Declined: back-references resolved against a table holding a template
render, whose numbering does not follow the obvious rule; qualified
back-references, which the reference implementation does not form;
local-scope and lambda names; RTTI descriptors.

The grammar comes from Microsoft's documented format, with llvm-undname
settling the cases the documentation leaves ambiguous -- pointer
qualifier composition, where PBQAD and PAQAD both spell char *const *;
the storage classes 3 and 4, which both render with no prefix; and the
spacing around a declarator, where a pointer abuts a name but is
separated from a nested function declarator.

Both resources a hostile name can spend are bounded. Depth is capped
below the point where CPython's own recursion limit could be what stops
a parse, so the answer cannot depend on how deep the caller already is.
Each rendered type is capped too: argument back-references can reuse an
earlier rendering repeatedly, which grew a 132-byte name into half a
gigabyte of output before the bound.

A symbol table holds whatever bytes were written into it, so every
prefix of every corpus name is fed through in the tests and none may
raise, malformed shapes each have a case asserting they come back
untouched, and a hypothesis target checks that an expansion is printable
and bounded rather than merely a string.
@r0ny123
r0ny123 force-pushed the feat/msvc-demangler branch from 6ea1187 to 415deb2 Compare August 15, 2026 06:28
Differential fuzzing against llvm-undname 22.1.7 - 15044 names derived by
mutating and splicing the reference corpus - found 256 shapes this demangler
expanded confidently while the reference refused them. A decorated name is a
usable identity, so answering where the grammar does not is the one failure
this demangler is built to avoid. Five tightenings take that to 4.

The largest class was a missing terminator. A parameter list is closed by the
throw specification, so a fixed list ends "@z" and a variadic one "ZZ", but the
terminator was consumed only when the parser was not already at end of input.
Any name truncated just past its parameters therefore got a plausible answer:
"?a2@@Yahx" read as "int __cdecl a2(void)" and "?f@@YAXHZ" as a variadic call
whose trailing byte was never there.

The extended integer table carried _D through _I. No Microsoft-compatible
mangler emits those - __int8, __int16 and __int32 are spelled with the plain
char, short and int codes - so a name containing one is not MSVC-decorated.
_L and _M stay: clang's Microsoft mangler does emit them for __int128, which
is also why they are spelled from the mangler's table rather than the
reference's, the reference having no reader for them.

The remaining three each restore a rule the grammar states and the parser did
not check: a template name is an identifier, not a digit or an operator code;
a special name takes a signature or a storage class by which code it is, never
both, so a vftable with a parameter list and an operator with a vftable's
storage class are equally malformed; and a reference cannot carry a qualifier
from the type enclosing it, which had been rendering as "char const &const
volatile *".

Every change refuses more and expands nothing new. The reference corpus is
unchanged at 363 of 609 exact with none given a different spelling, and the
MSVC names carried by real PDBs are unchanged at 11 of 14.
@r0ny123

r0ny123 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 099ac77, which hardens the demangler against names the grammar does not actually allow, and corrects one published number.

The corpus proves the demangler reads well-formed names correctly, but says nothing about what it does with the rest — and a symbol table holds whatever bytes were written into it. So I used the corpus as fuzzer seed instead: 15044 candidates built by truncating, splicing and mutating the 609 reference names, each fed to both implementations, asserting that wherever this demangler answers it agrees with llvm-undname.

256 shapes came back expanded confidently while the reference refused them. The largest class was a missing terminator: a parameter list is closed by its throw specification, but that byte was only required when the parser was not already at end of input, so ?a2@@YAHX — a name truncated mid-encoding — was answered as int __cdecl a2(void). Four smaller rules were missing too: an extended integer table carrying six codes no Microsoft-compatible mangler emits, a template name allowed to begin with a digit, a special name allowed to take both a signature and a storage class, and a reference allowed to carry a qualifier from the type enclosing it (which rendered as char const &const volatile *).

That is 256 → 4, and every change refuses more rather than expanding anything new: the corpus is unchanged at 363 of 609 exact with 0 given a different spelling.

The correction: the real-PDB figure is 11 of 14, not the 10 stated earlier. I re-measured the committed tree before and after this change and both give 11, so it was a measurement error on my side rather than a regression — the remainder is 3 local-scope names, not 4. The body now reflects that.

Two divergences from the reference are deliberate and now documented in the body, because copying it would make the output worse in both: _L/_M (__int128), which clang's Microsoft mangler emits but llvm-undname cannot read back, and ??_X/??_Y, which the reference recognises but spells with an empty name (public: int __cdecl Base::(int)).

A return type is the one position that carries a qualifier of its own, and
that qualifier is not optional decoration: every function returning a class
by value is spelled with it, "?A" being the unqualified case rather than an
absent one. Nothing here parsed the prefix, so those names were refused -
"?combine@geometry@@ya?AUMatrix@@aebu2@NI@Z" among them.

A return type is parsed at three places: a plain function, a function
pointer, and a "$$A6" function type. All three take the prefix, so the three
now share one reader rather than each calling type() directly.

This came out of building a fixture with the toolchain the demangler is meant
to read. No PE available here carries MSVC-decorated names - sqlite3's 277
exports are all C - so the MSVC arm of the PE provider had only synthetic
symbols behind it, and its first real export was one it could not read.
tests/msvc_cxx_pe_xored is that binary: a small translation unit built for
x86_64-pc-windows-msvc by clang-cl 22.1.7, exporting free functions, a
namespace, a class returned by value, and an extern "C" name. All four of its
decorated exports now come back as signatures.

Worth twenty names on the reference corpus, 363 to 383 of 609, none given a
different spelling. Twenty-one corpus names used the form the whole time;
they sat in the declined bucket, where a single aggregate count kept them
from reading as a gap.
@r0ny123

r0ny123 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 407537c, which adds the last missing piece of the type grammar — found by finally building a binary with the toolchain this is meant to read.

No PE available to me carries MSVC-decorated names (sqlite3's 277 exports are all C), so the MSVC arm of the PE provider had only synthetic symbols behind it while the Itanium arm had a real fixture. tests/msvc_cxx_pe_xored closes that: a small translation unit built for x86_64-pc-windows-msvc by clang-cl 22.1.7, exporting free functions, a namespace, a class returned by value and an extern "C" name.

Its very first real export was one the demangler refused — ?combine@geometry@@YA?AUMatrix@@AEBU2@NI@Z — and the reason generalised. A return type is the one position that carries a qualifier of its own, and that qualifier is not decoration: every function returning a class by value is spelled with it, ?A being the unqualified case rather than an absent one. A return type is parsed at three places (a plain function, a function pointer, a $$A6 function type), so all three now share one reader instead of calling type() directly.

Corpus goes 363 → 383 of 609 exact, still 0 given a different spelling, and all four decorated exports of the new fixture come back as signatures.

Worth stating plainly, since it is the part I got wrong: 21 corpus names had used this form the whole time. They sat in the 246-name declined bucket, where a single aggregate count kept a common C++ construct from reading as a gap — a 0-wrong corpus and 100% branch coverage were both green throughout. The differential fuzzing in the previous commit could not have found it either, because it only asks whether a wrong answer is given, never whether a right one is missing.

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.

1 participant