Skip to content

fix(analysis): C++ anonymous namespaces stop aborting the load, and symbolnamerepair backstops the rest (DIV-88) - #335

Merged
mahaloz merged 1 commit into
mainfrom
fix/anon-namespace-scope
Aug 25, 2026
Merged

mahaloz merged 1 commit into
mainfrom
fix/anon-namespace-scope

Conversation

@mahaloz

@mahaloz mahaloz commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

kuna decompile-project ./native_payload.dll (a MinGW-built malware DLL) failed with
error: could not build an architecture ...: Non-global scope has empty name. The binary did
not load at all, by any command. The bug turned out not to be malware-specific: kuna could
not load any binary using a C++ anonymous namespace, which is most unstripped C++.

Root cause

Database::find_create_scope_from_symbol_name nests one Scope per :: component and
attach_scope rejects an EMPTY one. The symbol table is installed inside load file, so that
LowlevelError escapes bootstrap_from_object rather than costing one symbol: decompile-all,
functions and decompile-project all answered could not build an architecture and emitted
nothing. (kuna decompile also produced nothing but printed the generic
(unsupported/!recognized binary), because check_errors maps the subprocess's
Could not create architecture onto that string — which is what made this undiagnosable from
that one surface. Separate driver-tier defect; not fixed here.)

The empty component came from the name-only reduction: Itanium renders _GLOBAL__N_... as
(anonymous namespace), strip_bracket_groups deletes every balanced bracket group, and
leveldb::(anonymous namespace)::HandleDumpCommand therefore reduced to
leveldb::::HandleDumpCommand.

Diagnosed during the #270 leveldb work and recorded there as out of scope; this closes it.

Two independent parts

1. The root cause — no flag (a strict fix; the wrong output was no output).
name_anonymous_namespaces rewrites both spellings — Itanium (anonymous namespace) and MSVC
`anonymous namespace' — to the identifier anonymous_namespace BEFORE the bracket strip,
matching what kuna_itaniumrtti.rs::sanitize_class_name already gives the same construct.

The identifier spelling is forced, not cosmetic: load function splits its argument on
whitespace, so a name with a space is unaddressable from the CLI, the console and every stage
XML; and a scope name reaches emitted C unsanitized, where ( opens a parameter list.
demangle_raw keeps the faithful c++filt text, unrewritten.

2. symbolnamerepair (on|off, default on) — the backstop. A name can be degenerate with no
demangler involved, and symbol-name bytes are attacker-controlled data that no header check
validates: a hostile binary buys a total denial of analysis for a few .strtab bytes. Only the
empty component is treated as degenerate. Loader-tier, so the gate is the KUNA_SYMBOLNAMEREPAIR
env bridge (the relocrebase pattern), exported by both CLI surfaces. Off restores the hard
error, which is what someone investigating a binary's symbol table itself wants.

Evidence

Collateral on the Itanium/ELF world is zero and structurally so: on a binary with no
::-qualified and no mangled symbol neither path executes. decompile-all --json is
byte-identical on 8 real binaries, four of them C++ (faillog, grep, sort, libselinux.so.1,
cpp_mangled_x86_64, cpp_noreturn_x86_64, cppsig_x86_64.so, cxxthrow_noreturn_x86_64).

The one deliberate output change on a binary that already loaded is the MSVC arm:
`anonymous namespace'::Bar::foo → anonymous_namespace::Bar::foo. Verified on a
purpose-built fixture, since no tracked .exe/.obj/.dll in the repo contains a ?A0x
symbol. Unflagged deliberately: the old spelling is not valid C and its embedded space makes the
name unaddressable through load function.

Witnesses that go from zero output to full output:

Binary After
native_payload.dll (MinGW PE32+, 2.7 MB, 1184 _GLOBAL__N_ symbols) 4061 of 4064 functions, 92.3 s, full .c/.h/.asm export
libleveldb.so.1.23.0 3751 functions, 273 anonymous_namespace:: names
leveldbutil 58 functions, 7

Speed: interleaved min-of-9 decompile-all --json on grep, base 13.649 s vs branch
13.751 s (+0.75%) — inside the box's measured noise floor (a null A/B of the same binary against
itself spreads 0.91 s over 9 reps), and necessarily so, since grep is C and reaches neither path.

Gates: make test 675/675 PARITY OK · make test-stages 538/538 PARITY OK ·
make rust-test green · make check-spec green in both lenient and strict mode.

Catalog 118 → 119 settables (analysis tier 42 → 43). Stages corpus 214 → 215 files.

Tests

  • tests/stages/kuna-symbolnamerepair.xml — 6 assertions over the new anon_namespace_x86_64
    fixture: all three anonymous-namespace shapes, the call-site rendering, and a min=0 max=0
    guard that no :::: survives. Default arm only, and that is a real limit rather than an
    oversight: the broken arm is a LOAD failure and option requires a live image, so the gate
    that restores it has to be set before load file through its env bridge, which no script
    command can do. A regression is still loud — a failed load file errors the script out and
    the baseline comparison reports REGRESSED.
  • kuna-console/tests/verify_symbolnamerepair.rs — two-pass. The anonymous-namespace fixture
    loads with the backstop forced OFF, proving the two parts are independent rather than one
    masking the other; hostile_scope_x86_64 (a 4.7 KB ELF whose .symtab names a function
    a::::b, built reproducibly from checked-in assembly, no byte-patching) loads on and restores
    the exact Non-global scope has empty name error off.
  • Four kuna-analysis demangle unit tests and the gate's own env-token test.

Follow-ups found while investigating (not fixed here)

  • kuna decompile masks this whole class of failure via check_errors (above).
  • Other reachable denial-of-analysis primitives in the symbol path: unbounded namespace depth
    amplifies ~1.46 KB of RSS per :: (a 40 MB .strtab ⇒ ~19 GB ⇒ OOM); an st_size whose low
    32 bits are zero fails the analysis commit; a newline or */ in a symbol name lands verbatim
    in emitted C.

🤖 Generated with Claude Code

https://claude.ai/code/session_011FPeR8e2Bhv3Hm8gux8RXb

…ymbolnamerepair backstops the rest (DIV-88)

kuna could not load an unstripped C++ binary that uses an anonymous
namespace -- at all, by any command.

`Database::find_create_scope_from_symbol_name` nests one Scope per `::`
component and `attach_scope` rejects an EMPTY one. The symbol table is
installed inside `load file`, so that `LowlevelError` escapes
`bootstrap_from_object` rather than costing one symbol: `decompile-all`,
`functions` and `decompile-project` all answered `could not build an
architecture for <binary>: Non-global scope has empty name` and emitted
nothing.

The empty component came from the name-only reduction. Itanium renders
`_GLOBAL__N_...` as `(anonymous namespace)`, `strip_bracket_groups`
deletes every balanced bracket group, and
`leveldb::(anonymous namespace)::HandleDumpCommand` therefore reduced to
`leveldb::::HandleDumpCommand`. An anonymous namespace is the ordinary
way C++ gives a definition internal linkage, so this was most unstripped
C++, not an exotic input. Diagnosed during the #270 leveldb work and
recorded there as out of scope; this closes it.

Two independent parts:

1. No flag (a strict fix). `name_anonymous_namespaces` rewrites both
   spellings -- Itanium `(anonymous namespace)` and MSVC
   `` `anonymous namespace' `` -- to the identifier `anonymous_namespace`
   BEFORE the bracket strip, matching what `sanitize_class_name` already
   gives the same construct. The identifier spelling is forced, not
   cosmetic: `load function` splits its argument on whitespace, so a name
   with a space is unaddressable from the CLI, the console and every
   stage XML; and a scope name reaches emitted C unsanitized, where `(`
   opens a parameter list. `demangle_raw` keeps the faithful c++filt
   text, unrewritten.

2. `symbolnamerepair` (on|off, default on). A name can be degenerate with
   no demangler involved, and symbol-name bytes are attacker-controlled
   data that no header check validates -- a hostile binary buys a total
   denial of analysis for a few `.strtab` bytes, which matters given the
   reporting binary was malware. Only the empty component is treated as
   degenerate. Loader-tier, so the gate is the `KUNA_SYMBOLNAMEREPAIR`
   env bridge (the `relocrebase` pattern), exported by both CLI surfaces;
   off restores the hard error, which is what someone investigating a
   binary's symbol table itself wants.

Collateral on the Itanium/ELF world is zero and structurally so: on a
binary with no `::`-qualified and no mangled symbol neither path
executes. `decompile-all --json` is byte-identical on 8 real binaries,
four of them C++. The one deliberate output change on a binary that
already loaded is the MSVC arm (`` `anonymous namespace'::Bar::foo `` ->
`anonymous_namespace::Bar::foo`); the old spelling is not valid C, so it
only ever corrects wrong output.

Witnesses that go from zero output to full output: the reporting
`native_payload.dll` (MinGW PE32+, 1184 `_GLOBAL__N_` symbols) now
exports 4061 of 4064 functions in 92.3 s; `libleveldb.so.1.23.0` 3751
functions / 273 `anonymous_namespace::` names; `leveldbutil` 58 / 7.

Speed: interleaved min-of-9 `decompile-all --json` on grep, +0.75%,
inside the box's measured noise floor (a null A/B of the same binary
against itself spreads 0.91 s over 9 reps).

Tests: `tests/stages/kuna-symbolnamerepair.xml` (6 assertions over the
new `anon_namespace_x86_64` fixture, pinning all three
anonymous-namespace shapes, the call-site rendering, and a min=0 max=0
guard that no `::::` survives) plus
`kuna-console/tests/verify_symbolnamerepair.rs` (two-pass: the
anonymous-namespace fixture loads with the backstop forced OFF, proving
the two parts are independent; `hostile_scope_x86_64` -- a 4.7 KB ELF
whose `.symtab` names a function `a::::b`, built reproducibly from
checked-in assembly -- loads on and restores the exact error off), four
demangle unit tests and the gate's own env-token test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FPeR8e2Bhv3Hm8gux8RXb
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