Skip to content

fix(labels): demangle the names a PE actually carries - #254

Open
r0ny123 wants to merge 3 commits into
danielplohmann:masterfrom
r0ny123:fix/pe-symbol-demangling
Open

fix(labels): demangle the names a PE actually carries#254
r0ny123 wants to merge 3 commits into
danielplohmann:masterfrom
r0ny123:fix/pe-symbol-demangling

Conversation

@r0ny123

@r0ny123 r0ny123 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Two reasons a PE reaches the report with mangled symbol names, both measured against real binaries.

Part of a linear stack: #249#250#254#255. This branch and #249 both touch RustSymbolProvider and do not merge cleanly on their own; resolving that conflict in this branch's favour would leave an import of a module #249 deletes, an ImportError on import smda that no test on either branch would catch. Stacked, the order resolves it.

RustSymbolProvider recovered nothing at all from a PE

Its COFF loop skipped a symbol when symbol.section is None. lief never populates that attribute for PE — which this repo already knows, because PeSymbolProvider carries a comment saying so and resolves through section_idx instead, fixed under #229. The identical sibling here was not swept at the time.

Measured on the bundled rust_pe_gnu_xored fixture (a mingw-linked Rust build):

COFF symbols                      4818
  with symbol.section set            0
  with a usable section_idx       4366

So the guard rejected every symbol, the provider contributed zero names, and PeSymbolProvider's raw spellings won by default. The report carried 2098 names spelled _RNv…. With the guard resolving through section_idx, the provider recovers 2098 names and none of them are mangled.

PeSymbolProvider never demangled anything

ElfSymbolProvider and MachoSymbolProvider both route recovered names through a demangler; the PE provider stored them verbatim, so a mingw-built C++ PE reported _ZN12FileExplorerC2Ev rather than a signature. It now applies the same demangle_itanium_symbol helper ElfSymbolProvider uses, which leaves non-Itanium names — including Rust ones, which belong to RustSymbolProvider — untouched.

There was no bundled PE carrying Itanium C++ symbols to show this on: the Rust fixture's names are all Rust-mangled and the other PEs have no symbol table. tests/cxx_pe_gnu_xored is a small C++ translation unit compiled for x86_64-w64-mingw32 by g++ 16.2.0, added so the path has a real binary behind it. Before and after, on that file:

                 mangled   readable
pre-fix                3          0
with the fix           0          3

giving demo::Widget::Widget(), demo::Widget::~Widget() and the full measure(std::__cxx11::basic_string<…> const&, double) const signature.

The gate in front of the revived loop

Bringing that COFF loop to life also made the test in front of it matter, and it was the wrong one. _is_rust_symbol checked the prefixes alone, but legacy Rust mangling shares _ZN with the C++ Itanium ABI, and RustSymbolProvider is consulted before the format providers. On a Rust binary that also carries C++ symbols it would therefore have claimed names like _ZN4test4funcEv and replaced the correct test::func() with the test::func its legacy demangler degrades that name to — shadowing the very demangling this PR adds. It now uses is_rust_language_evidence, which parses a name before claiming it. Recovery on the fixture is unchanged at 2098 names, so the stricter gate costs nothing here.

Scope of the sweep

The first bug's class is a PE symbol resolved through Symbol.section rather than section_idx. After this change no such site remains in the tree — the only surviving mention of .section in the label providers is the comment explaining why not to use it.

The second bug's class is a provider that stores a recovered name without routing it through the demangler for its format. ELF, Mach-O and now PE all demangle; the Go, Delphi and CIL providers handle name spaces that are not mangled in this sense.

Validation

Real binaries only, no synthetic symbol names:

binary what it proves
rust_pe_gnu_xored (mingw Rust) 0 → 2098 Rust names recovered and demangled
cxx_pe_gnu_xored (mingw C++, new) 3 Itanium C++ names demangled, 0 left mangled
komplex_xored (Mach-O C++) the Mach-O path already demangles 90 of 181 names — unchanged here
python -m pytest tests/ -q                  1190 passed, 1 skipped
make lint                                   clean
make typecheck                              exit 0, no new diagnostics

Still open after this

MSVC decoration (?name@@YAXXZ) is not demangled by this PR; #255, stacked on top, adds that.

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.
@r0ny123
r0ny123 force-pushed the fix/pe-symbol-demangling branch from 942356f to f33431a Compare August 15, 2026 05:22
@r0ny123

r0ny123 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto a linear stack after a review pass found that this branch and #249 both touch RustSymbolProvider and do not merge cleanly. That mattered more than an ordinary conflict: #249 deletes rust_demangler/utils.py and its import, while this branch edits lines whose surrounding context is one of that helper's call sites, so resolving the conflict in this branch's favour leaves an import of a module that no longer exists — an ImportError on import smda that no test on either branch would have caught.

The order is now #249#250#254#255, and every pair of the open branches merges cleanly.

Two behaviour findings from the same pass are fixed here:

  • The COFF loop this PR brings to life was guarded by a prefix-only Rust test. Legacy Rust mangling shares _ZN with the C++ Itanium ABI, and RustSymbolProvider 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 correct test::func() with the test::func its legacy demangler degrades that name to. It now uses the shared evidence gate, which parses a name before claiming it. Recovery on the bundled fixture is unchanged at 2098 names.
  • Several tests used _ZN3foo3barE as their Rust fixture, which has no 17h<hash> suffix and so is Itanium C++ rather than legacy Rust — the same distinction this PR's own description argues. They now use a name that really is Rust-legacy.

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