Skip to content

Read the Go pclntab on Mach-O and PE, not only ELF - #808

Merged
ltfish merged 1 commit into
masterfrom
sharpen/go-pclntab-macho-pe
Sep 3, 2026
Merged

Read the Go pclntab on Mach-O and PE, not only ELF#808
ltfish merged 1 commit into
masterfrom
sharpen/go-pclntab-macho-pe

Conversation

@zardus

@zardus zardus commented Sep 3, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

cle reads a Go binary's pclntab only when the binary is an ELF. On Mach-O and
PE it reads nothing, so a Go binary arrives with whatever handful of names its
symbol table happens to carry. tests/x86_64/windows/langdetect_go.exe is the
repository's own cross-compiled Go program, and its pclntab names 1,898
functions:

>>> obj = cle.Loader("tests/x86_64/windows/langdetect_go.exe", auto_load_libs=False).main_object
>>> len(obj.symbols)
85
>>> obj.get_symbol("main.main"), obj.get_symbol("runtime.gopanic")
(None, None)

tests/aarch64/langdetect_go.macho behaves the same way, and it has a section
called __gopclntab.

Two things downstream lose by this. CFGFast on the stripped Go PE
tests/x86_64/windows/131252a8059fdbb12d77cd4711e597c45bb48e6d4bc3ddc808697a5e0488ff2c
finds 1,783 of that binary's 1,821 Go functions and can name six of them. And
Project.is_go_binary is "go" in self.languages() and self.language_is_certain,
and language_is_certain accepts only None or "high". With no runtime.*
symbol and no section name LanguageDetector recognises, a Go Mach-O or PE is
detected as Go at low confidence, so the Go calling convention never engages
on either format.

Root cause

cle/backends/gopclntab.py was written for all three formats.
PCLNTAB_SECTION_NAMES lists __gopclntab and __go_pclntab beside the ELF
spellings, and _EMBEDDING_SECTION_NAMES lists .rdata, .rodata, __rodata,
__const and __DATA_CONST for the formats that bury the table in a generic
read-only section. But register_gopclntab_symbols has exactly one caller,
cle/backends/elf/elf.py:252 at 2f7657fd.

Mach-O and PE do not fail for the same reason, which is why fixing one would not
have fixed the other.

Mach-O has the __gopclntab section, so load_gopclntab finds and parses the
table without complaint. Nothing calls it.

PE carries no Go-named section, so the table has to be found by its magic inside
.rdata. That scan sat behind a gate:

def _looks_like_go(backend: Backend) -> bool:
    return any(sec.name in _GO_MARKER_SECTION_NAMES or sec.name.startswith(".go.") for sec in backend.sections)

_GO_MARKER_SECTION_NAMES is {".gosymtab", ".typelink", ".itablink", ".noptrdata", ".noptrbss"}, and no Go PE has any of them. Thirteen Go PEs in
angr/binaries at a2eb7cf load, between them showing six different
section-name sets; all thirteen carry .text, .rdata, .data, .idata,
.reloc and .symtab, twelve add .zdebug_* and two add .pdata and
.xdata, and not one carries any of those five names or a name starting
.go.. So the gate is False for every Go PE and the scan never runs.

Fix

Call register_gopclntab_symbols from the Mach-O and PE backends, where the ELF
backend already calls it, and declare gopclntab on Backend so every object
answers the question rather than only some. elf.py is left alone.

Delete the gate rather than repair it, and let GoPclntab.parse decide. That is
what validates a candidate anyway: it checks the header padding, the pointer
size, the minimum instruction length, the five sub-table offsets, that the
function entries increase monotonically, and that every name is terminated
inside the table. A repaired gate is possible — of the 106 PE paths in
angr/binaries at a2eb7cf (105 distinct blobs; the one duplicated pair is not
Go), the thirteen with a .symtab section are exactly the thirteen Go ones —
but that is a Go linker detail rather than a documented marker. What it saves is
a bytes.find for two four-byte magics over an object's non-executable
read-only sections, and across that whole corpus those sections declare 36.2 MB
on the 862 objects the gate was keeping the scan away from.

Calling this from the Mach-O backend turned up three places where that backend
narrows Backend.symbols to AbstractMachOSymbol without saying so.
SymbolList.add indexes every symbol by library_ordinal, get_symbol reads
is_stab, and get_symbol_by_address_fuzzy reads bind_xrefs and
symbol_stubs. Those attributes are declared on AbstractMachOSymbol and on
nothing else, so any plain Symbol added by shared loader code raised
AttributeError. The three sites are guarded instead of teaching a
format-neutral symbol about Mach-O; they are the only reads of a Mach-O-only
symbol attribute over self.symbols anywhere in cle.

Not done here: at 2f7657fd no Mach-O symbol is reported as a function, so the
address-based deduplication in register_gopclntab_symbols suppresses nothing
on that format and every pclntab entry is added beside the underscore-prefixed
name the Mach-O symbol table already carries. The pull request #796 changes
that. With both applied the dedupe, which compares addresses only, covers all
1,888 pclntab addresses on the Mach-O fixture and keeps _main.main over
main.main, so it will need to compare names as well as addresses.

Testing

Five new tests in tests/test_gopclntab.py assert the recovered table and
symbols on a Go PE, a stripped Go PE and a Go Mach-O, and the existing non-Go
test is widened to three binaries, one per format, each with a read-only section
the scan now reaches. Reverting the four changed production files to 2f7657fd
fails exactly those six and leaves the other seven passing.

The Mach-O fixture is new. It arrives in angr/binaries#224, which this
needs.

Validation: #808 (comment)

session: sharpen

@zardus

zardus commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 86cceb2dc3984bab00935cb6036f6a74af5afd9e against baseline 2f7657fda2a657ec76a201d5c63245c6262f60b7, with angr/binaries at
6b8841aa4c2d87144d3ca2f2a7564ecdd3d5d2c7, whose parent is a2eb7cf.

The branch was rebased onto baseline a96c36c1, which differs from 2f7657fd
only by the 9.3.5.dev0 version bump, and then amended to bd92d8a8 to fix the
Typecheck job. git diff --stat 86cceb2d bd92d8a8 is exactly cle/__init__.py,
pyproject.toml and tests/test_gopclntab.py, so cle/backends/backend.py,
cle/backends/gopclntab.py, cle/backends/macho/macho.py and
cle/backends/pe/pe.py are byte for byte the ones everything below measures.

Suite. python -m pytest tests/ -q -p no:randomly from the checkout: 259
passed, 9 skipped on the head; 254 passed, 9 skipped on the baseline.
tests/test_gopclntab.py goes from 8 tests to 13. bd92d8a8 gives the same
259 passed, 9 skipped.

The new tests are load-bearing. Reverting the four changed production files
cle/backends/backend.py, cle/backends/gopclntab.py,
cle/backends/macho/macho.py, cle/backends/pe/pe.py — to the baseline while
keeping the test file gives 6 failed, 7 passed: test_pe_binary,
test_pe_binary_supplies_the_function_symbols, test_stripped_pe_binary,
test_macho_binary, test_macho_binary_supplies_the_function_symbols and
test_non_go_binaries. Five of those are new; the sixth is the existing
test_non_go_binary widened to a PE and a Mach-O beside the ELF, each with a
read-only section the scan reaches (.rodata 99 B, .rdata 12,134 B, two
__const totalling 834 B), so none of the three arms is a vacuous pass.

Merge-base lint and typecheck. Pylint, the comparison the hosted Lint job
makes against the baseline: 9.97 to 9.97 on backend.py, 10.00 to 10.00 on
gopclntab.py, macho.py and pe.py, 9.16 to 9.18 on the test file.

The pyright figures first recorded here were the wrong measurement, and the
Typecheck job on 9167cb72 caught it. They were per-line badness scores,
(errors * 10 + warnings) / lines, which is what the hosted job compared until
angr/ci-settings b23d782 replaced it with a per-file error count on
2026-09-01. Under the count tests/test_gopclntab.py went from 10 errors at the
baseline to 16 at 9167cb72. Nine of those 16 are on lines the change adds. The
fix is in the test file only: no asserted value changed. The file is now at 7
errors against the baseline's 10, and the four production files stay flat as
they already did on 9167cb72.

cle/backends/elf/elf.py is untouched, which leaves ELF.__init__ declaring
gopclntab beside the new declaration on Backend. ruff check cle tests
passes and black --check --line-length 120 cle tests reports 153 files
unchanged.

Corpus A/B. The corpus is every tracked file in angr/binaries at the
revision above: 1,841 paths and 1,807 distinct blobs, of which 1,087 load when
cle is given all the memory it asks for. The A/B harness caps one load at 4 GiB
of address space, under which tests/x86_64/ALLSTAR_aces3_xaces3 raises
MemoryError, so each of its two arms loaded the same 1,086 and those are the
objects compared below.

  • The set of objects that load is identical, and no object changes its exception
    class, its exception message, its backend class or its section list.
  • Twelve objects change, and every one is Go: nine cross-compiled
    langdetect_go PEs (x86_64, i386 and aarch64, several Go releases), the two
    Go PEs from the wild, and the new tests/aarch64/langdetect_go.macho. Every
    symbol any of them gains is a GoSymbol; no object loses one.
  • 862 objects had the scan kept away from them by the removed gate — every
    object whose backend inherits the call, which has a non-executable read-only
    section the scan would search, and which carries no dedicated pclntab section
    of its own. Counted with no memory cap, that is 661 ELF, 94 CGC (CGC
    subclasses ELF, so it inherits the call too), 93 PE and 14 Mach-O. The
    scan now finds a table in 11 of them, all PE, and rejects the other 851.
  • Of the 23 loadable Mach-O-backed objects — 22 MachO and one Universal2
    22 have byte-identical symbol sets across the two arms, and the one that
    differs is the Go Mach-O fixture. So the three isinstance guards are
    answer-preserving.
  • The harness prints the expected differences on the known-positive Go PEs, so
    its nulls elsewhere are not an artefact of a broken comparison.

Cost of dropping the gate. The scan is a bytes.find for two four-byte
magics over each object's non-executable read-only sections. Across the 862
those 872 sections declare 36,199,957 bytes — 36.2 MB — of memsize, and
that byte count is the durable figure. CPU totals for the whole 862 measured
inside one process ranged from about 50 ms to about 160 ms across repeated
runs. No timing bound
from this machine is worth relying on.

CFG recovery, CFGFast(normalize=False) with the same angr on both sides,
scored against each binary's own pclntab function list:

object recall before recall after named before named after
x86_64/windows/131252a8…ff2c 1783/1821 (97.9%) 1821/1821 (100%) 6 1,826
x86_64/windows/059ef54d…d9e3 1148/1171 (98.0%) 1171/1171 (100%) 24 1,172

The total function count falls at the same time, 3,816 to 2,496 and 2,342 to
1,538.
Recall against the ground-truth set rises while that happens, but read that drop as
unexplained rather than as an improvement.

Downstream. The three angr tests that decompile these two Go PEs are
tests/analyses/decompiler/test_decompiler.py::TestDecompiler::test_decompiling_phoenix_natural_loop_region_head_in_body,
tests/analyses/decompiler/test_register_save_area_simplifier_adv.py (three
tests, one of which uses a Go PE) and
tests/analyses/decompiler/test_narrowing_exprs.py::TestNarrowingExpressions::test_narrowing_expressions_after_making_callsite_only.
That selection is five tests, and all five pass under both cle revisions. One
angr checkout was used for both sides; it is not an ancestor of angr master and
carries neither Project.is_go_binary nor Project.language_is_certain, so
this says only that the extra symbols do not disturb those decompilations and is
not evidence about the ABI selection. Its angr/analyses/language_detector.py
is blob d508a9c071cac16f0911bf1c9e4c69b8d68cd28c.

Language detection. LanguageDetector reports go under both revisions on
tests/aarch64/langdetect_go.macho, tests/x86_64/windows/langdetect_go.exe and
tests/x86_64/windows/131252a8…ff2c. Its confidence moves from low to high
on all three, on the strength of the recovered runtime.* symbols.
Project.is_go_binary requires language_is_certain, which accepts only None
or "high".

Not covered. Two of the thirteen Go PEs, the go1.17.13 builds, get no
table: their pclntab predates the go1.18 header layout this module supports, and
that is unchanged by this pull request.

session: sharpen

@zardus

zardus commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

What cle reports for the three Go fixtures the new tests use, before and after
this change. LanguageDetector is angr's, run over the same load; it is here
because Project.is_go_binary is gated on its confidence.

Before — no pclntab is read on either format, so no Go function has a name
and the only evidence of the language is a version string:

cle master
tests/x86_64/windows/langdetect_go.exe
    # PE, cross-compiled by build_go_cross.sh
    main_object.gopclntab         <the attribute does not exist>
    len(main_object.symbols)      85
    get_symbol('main.main')       None
    get_symbol('runtime.main')    None
    get_symbol('runtime.gopanic') None
    LanguageDetector              go / gc / low
        string: Go version/build marker

tests/x86_64/windows/131252a8059fdbb12d77cd4711e597c45bb48e6d4bc3ddc808697a5e0488ff2c
    # PE, stripped, from the wild
    main_object.gopclntab         <the attribute does not exist>
    len(main_object.symbols)      40
    get_symbol('main.main')       None
    get_symbol('runtime.main')    None
    get_symbol('runtime.gopanic') None
    LanguageDetector              go / gc / low
        string: Go version/build marker

tests/aarch64/langdetect_go.macho
    # Mach-O, cross-compiled by build_go_cross.sh
    main_object.gopclntab         <the attribute does not exist>
    len(main_object.symbols)      2491
    get_symbol('main.main')       None
    get_symbol('runtime.main')    None
    get_symbol('runtime.gopanic') None
    LanguageDetector              go / gc / low
        string: Go version/build marker

After — every pclntab entry becomes a function symbol, and the recovered
runtime.* names carry the language detection from low to high:

with this change
tests/x86_64/windows/langdetect_go.exe
    # PE, cross-compiled by build_go_cross.sh
    main_object.gopclntab         <GoPclntab: 1898 functions, text at 0x140001000>
    len(main_object.symbols)      1960
    get_symbol('main.main')       0x1400a73c0
    get_symbol('runtime.main')    0x140047e20
    get_symbol('runtime.gopanic') 0x1400783a0
    LanguageDetector              go / gc / high
        go_symbol: runtime.mapaccess2
        go_symbol: runtime.mapassign
        go_symbol: runtime.mapaccess1_fast32
        string: Go version/build marker

tests/x86_64/windows/131252a8059fdbb12d77cd4711e597c45bb48e6d4bc3ddc808697a5e0488ff2c
    # PE, stripped, from the wild
    main_object.gopclntab         <GoPclntab: 1821 functions, text at 0x401000>
    len(main_object.symbols)      1861
    get_symbol('main.main')       0x4b75c0
    get_symbol('runtime.main')    0x4374e0
    get_symbol('runtime.gopanic') 0x434760
    LanguageDetector              go / gc / high
        go_symbol: runtime.cmpstring
        go_symbol: runtime.memequal
        go_symbol: runtime.memequal_varlen
        string: Go version/build marker

tests/aarch64/langdetect_go.macho
    # Mach-O, cross-compiled by build_go_cross.sh
    main_object.gopclntab         <GoPclntab: 1888 functions, text at 0x100001000>
    len(main_object.symbols)      4379
    get_symbol('main.main')       0x10009d340
    get_symbol('runtime.main')    0x10003ff30
    get_symbol('runtime.gopanic') 0x100070660
    LanguageDetector              go / gc / high
        go_symbol: runtime.mapaccess2
        go_symbol: runtime.mapassign
        go_symbol: runtime.mapaccess1_fast32
        string: Go version/build marker

@zardus
zardus force-pushed the sharpen/go-pclntab-macho-pe branch from 86cceb2 to 9167cb7 Compare September 3, 2026 13:15
@angr-bot

angr-bot commented Sep 3, 2026

Copy link
Copy Markdown
Member

Corpus decompilation diffs can be found at angr/dec-snapshots@master...angr/cle_808

cle/backends/gopclntab.py was written for all three container formats.
PCLNTAB_SECTION_NAMES lists __gopclntab and __go_pclntab beside the ELF
spellings, _EMBEDDING_SECTION_NAMES lists .rdata, .rodata, __rodata, __const and
__DATA_CONST for the formats that bury the table in a generic read-only section,
and the module docstring names ELF, Mach-O and PE. Only the ELF backend ever
called register_gopclntab_symbols, so a Go binary built for darwin or windows
reached the loader with whatever handful of function symbols its symbol table
happened to carry. On tests/x86_64/windows/langdetect_go.exe that is 85 symbols
against 1898 Go functions.

Mach-O and PE were not failing for the same reason:

  - Mach-O has a section named __gopclntab, which the name list already matched,
    so load_gopclntab found and parsed the table. Nothing called it.
  - PE has no section a Go-marker name test can recognise. Thirteen Go PEs in
    the corpus load, between them showing six different section-name sets; all
    of them carry .text, .rdata, .data, .idata, .reloc and .symtab, and not one
    carries a name in _GO_MARKER_SECTION_NAMES or one starting ".go.". The
    pclntab sits inside .rdata and has to be found by its magic, and
    _looks_like_go gated that scan on exactly those names, so it never ran. Drop
    the gate and let GoPclntab.parse decide, which is what validates a candidate
    anyway: it checks the header padding, the pointer size, the minimum
    instruction length, the five sub-table offsets, that the function entries
    increase monotonically, and that every name is terminated inside the table.

Calling register_gopclntab_symbols from the Mach-O backend exposed three places
where that backend narrows Backend's symbols list to AbstractMachOSymbol without
saying so: SymbolList.add indexes every symbol by library_ordinal, get_symbol
reads is_stab, and get_symbol_by_address_fuzzy reads bind_xrefs and
symbol_stubs. Those four attributes are declared on AbstractMachOSymbol and on
nothing else, so any plain Symbol added by shared loader code hits an
AttributeError. Guard the three sites instead of teaching a format-neutral
symbol about Mach-O.

Declare gopclntab on Backend so every object answers the question rather than
only some.
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.

3 participants