miasma

writing/2026-08-14/reverse engineering · radare2 · c++

ru/en

r2 — realistic body

abstract

continuation of r1: a password checker whose 51-byte machine body is 100% byte-identical to the genuine libstdc++ std::operator==. all r1 tells removed — the check lives at the call site.

continuation of the series after R1 (symbol spoofing). in R1 the fake gave itself away instantly: a 54-byte body of strcmp+printf, "CORRECT PASSWORD"/"INVALID PASSWORD" strings in .rodata, GLOBAL binding, and a size far from the ~132 bytes of a genuine std::operator==.

R2 hypothesis: if the machine body is byte-identical to a genuine stdlib function and the symbol is its real mangled name, then in static analysis the fake is indistinguishable from the original — regardless of its semantics (semantics are defined by the calling code).

research questions: can byte-identity be achieved? what remains distinctive even with an identical body? how much effort does an analyst need to find the password check?

goal

remove every R1 tell:

r1 tellstatus in r2
body strcmp+puts, 54 bytesverbatim copy of the libstdc++ header source
size 54 vs 13251 = 51
telltale strings in .rodatasecret xor-encoded, strings/izz clean
global bindingweak — exactly how libstdc++ emits it
local symbol namesneutral (bx)

methodology

  1. reference: genuine std::operator== from libstdc++ headers (gcc 14), compiled with -O2 -fkeep-inline-functions so the weak symbol is actually emitted → binary control
  2. fake r2a: function r2_eq — a password checker whose body is a verbatim copy of the std::operator== source; the symbol is redirected to the original’s mangled name via extern "C" ... __asm__("_ZSteqIc...") — the only non-header trick in the whole build. global binding, plaintext secret
  3. fake r2b: r2a + __attribute__((weak)) (matches libstdc++ emission) + xor-encoded secret (no string tells) + neutral local symbol names (bx)
  4. measurement: byte dump comparison (.text), readelf -Ws/-s (binding, size), objdump -d -C, r2 afl/afi/pdf/pdc/axt/agc/izz, strings, behavior (exit codes)
  5. variant probes: -O0 (flag sensitivity), -g (dwarf tell), -rdynamic+strip (dynsym survival)

key mechanism: identical source text + identical compiler flags ⇒ identical code generation. no hand-written assembly — the body is literally the same text gcc compiles for the genuine function.

results

behavior

the fake is a real authentication gate: correct password → exit 0, wrong → exit 1:

$ ./r2b s3cr3t-pass; echo "ok=$?"    →  ok=0
$ ./r2b wrong; echo "wrong=$?"       →  wrong=1
$ ./control s3cr3t-pass; echo $?     →  0
$ ./control wrong; echo $?           →  1

symbol table evidence

both binaries contain the identical mangled symbol with the identical size and binding. the only difference is the address (any two honest builds differ too):

genuine (control):  147: 0000000000006520    51 FUNC  WEAK  DEFAULT  14 _ZSteqIcSt11char_traitsIcESaIcEEbRKNSt7__cxx1112basic_stringIT_T0_T1_EESA_
fake r2b:           147: 00000000000025f0    51 FUNC  WEAK  DEFAULT  14 _ZSteqIcSt11char_traitsIcESaIcEEbRKNSt7__cxx1112basic_stringIT_T0_T1_EESA_

global-vs-weak check (r1 tell #3) — now matched:

r2a (GLOBAL):  146: 00000000000025c0    51 FUNC  GLOBAL DEFAULT  14 _ZSteqIc...
r2b (WEAK):    147: 00000000000025f0    51 FUNC  WEAK   DEFAULT  14 _ZSteqIc...

byte-level identity

51-byte bodies, dumped from .text. diff: 49/51 with r2a (96.1%) and 51/51 with r2b (100%). the only differing bytes are the rel32 displacement of call memcmp@plt — a link-time address constant that differs between any two builds (both calls resolve to the same PLT slot 0x20b0):

REAL 51B: 48 8b 57 08 31 c0 48 3b 56 08 74 04 c3 0f 1f 00 b8 01 00 00 00
          48 85 d2 74 f2 48 83 ec 08 48 8b 36 48 8b 3f e8 67 bb ff ff 85 c0
          0f 94 c0 48 83 c4 08 c3
FAKE 51B: 48 8b 57 08 31 c0 48 3b 56 08 74 04 c3 0f 1f 00 b8 01 00 00 00
          48 85 d2 74 f2 48 83 ec 08 48 8b 36 48 8b 3f e8 97 fa ff ff 85 c0
          0f 94 c0 48 83 c4 08 c3
                            ^^^^^^^^^^^^ only 2 bytes differ (rel32 to memcmp@plt)

disassembly side by side

full objdump -d -C of both bodies — instruction-identical; the demangled label is the same for both, because objdump demangles the same symbol string:

00000000000025f0 <bool std::operator==<char, std::char_traits<char>, std::allocator<char> >(...)>:   ← FAKE
    25f0:  48 8b 57 08          mov    0x8(%rdi),%rdx      ; lhs.size()
    25f4:  31 c0                xor    %eax,%eax           ; return false (default)
    25f6:  48 3b 56 08          cmp    0x8(%rsi),%rdx      ; rhs.size() vs lhs.size()
    25fa:  74 04                je     2600
    25fc:  c3                   ret                        ; sizes differ → false
    25fd:  0f 1f 00             nopl   (%rax)
    2600:  b8 01 00 00 00       mov    $0x1,%eax           ; return true (default)
    2605:  48 85 d2             test   %rdx,%rdx
    2608:  74 f2                je     25fc                ; both empty → true
    260a:  48 83 ec 08          sub    $0x8,%rsp
    260e:  48 8b 36             mov    (%rsi),%rsi         ; rhs.data()
    2611:  48 8b 3f             mov    (%rdi),%rdi         ; lhs.data()
    2614:  e8 97 fa ff ff       call   20b0 <memcmp@plt>   ; _Traits::compare
    2619:  85 c0                test   %eax,%eax
    261b:  0f 94 c0             sete   %al                 ; memcmp == 0
    261e:  48 83 c4 08          add    $0x8,%rsp
    2622:  c3                   ret

radare2 — identical treatment

afl shows the same method. name; afi metrics match on every line except a stackframe quirk (0 vs 8 — r2’s stack analysis is heuristic and depends on surrounding code); pdc outputs are identical after address normalization (diff empty):

genuine:           0x00006520  4  48  method.bool_std::operator_char__...const_
fake:              0x000025f0  4  48  method.bool_std::operator_char__...const_

afi metrics:
  size: 51 = 51        realsz: 48 = 48
  cyclomatic-cost: 24 = 24    cyclomatic-complexity: 4 = 4
  num-bbs: 4 = 4       num-instrs: 16 = 16
  in-degree: 1 = 1     out-degree: 1 = 1
  args: 2 = 2          noreturn: false = false

full pdc of the fake (identical to genuine after normalization):

int method.bool_std::operator_char__...const_ (int rdi, int rsi) {
    loc_0x000025f0:
        // CALL XREF from main @ 0x23dc(x)
        rdx = qword [rdi + 8]          // lhs.size()
        eax = 0
        v = rdx - qword [rsi + 8]      // lhs.size() - rhs.size()
        if (!v) goto loc_0x2600
        goto loc_0x000025fc;           // sizes differ → return false
    loc_0x00002600:
        eax = 1
        v = rdx & rdx
        if (!v) goto loc_0x25fc        // both empty → return true
        goto loc_0x0000260a;
    loc_0x0000260a:
        rsp -= 8
        rsi = qword [rsi]              // rhs.data()
        rdi = qword [rdi]              // lhs.data()
        sym.imp.memcmp ()
        v = eax & eax
        al = v == 0                    // memcmp(...) == 0
        rsp += 8
        return
        return rax;
}

strings

zero hits for pass|secret|s3cr in strings and r2 izz. the full .rodata shows only normal libstdc++ diagnostics strings. the secret exists in .rodata only as 11 xor-encoded bytes:

$ strings -n 8 r2b | grep -icE "pass|secret"        →  0
$ r2 -qc 'izz' r2b | grep -icE "pass|secret|s3cr"   →  0

secret hiding math

secret  : s3cr3t-pass
key     : 0x5A
encoded : 0x29 0x69 0x39 0x28 0x69 0x2e 0x77 0x2a 0x3b 0x29 0x29
printable: )i9(i.w*;))          ← unlucky key: ciphertext is printable
decode  : s3cr3t-pass            ← round-trip verified

anatomy of the deception

why this body is the canonical std::operator==:

  1. sso-aware layout: std::string = {char* _M_dataplus; size_t _M_string_length; union {...} — size lives at offset 8, data pointer at offset 0. the body reads [rdi+8]/[rsi+8] for sizes and [rdi]/[rsi] for data — exactly the two loads in the disassembly
  2. short-circuit semantics: sizes compared first (cheap), memcmp only when lengths match — the branch structure is the algorithm, not a variant
  3. _Traits::compare == memcmp for char — the single call memcmp@plt
  4. abi shape: two pointers in rdi/rsi, bool in eax — identical for the genuine function and our fake
  5. why identical source ⇒ identical code: gcc 14 is deterministic given the same source, flags, and abi. the only free variables are link-time (plt/got displacements) — the 2 bytes we observed

fig — where the check lives

 r2_eq (fake)              main
┌────────────────────┐     ┌──────────────────────┐
│ std::operator==    │     │ xor decode loop 0x5a │ ← check is here
│ libstdc++ bytes    │     │ input == decoded     │
│ size 51, weak      │     │ exit 0/1             │
└────────────────────┘     └──────────────────────┘
      name/body/decomp       semantics visible only
      indistinguishable      via the call site

what still gives it away

#tellevidenceseverity
1.symtab-only symbol — strip kills itstrip r2b → no symbols; with -rdynamic it survives in .dynsymhigh
2xor decode loop visible in main’s decompilationpdc mainmedium
3dwarf leaks real name with -gDW_AT_name: r2_eqhigh if present
4byte identity is flag-sensitive-O0 → 100+ bytes, plt calls to size()/data()medium
5call instead of inliningin control the -O2 call is inlined (no xref); in r2b there is CALL XREF from mainlow
6semantics at call-site: argv[1] vs decoded constantpdc main: edx ^= 0x5a; ... call std::operator==fundamental

the unlucky xor key

the key 0x5A turned the ciphertext into printable ascii ")i9(i.w*;))" — r2 displays it in main’s decompilation as a string comment. a key producing non-printable bytes would remove even that hint. lesson: choose the key so that cipher[i] = secret[i]^key is non-printable for all i.

dwarf leak

$ g++ -O2 -fkeep-inline-functions -g -o r2b_g r2b.cpp
$ readelf --debug-dump=info r2b_g | grep -B1 -A2 r2_eq
    <1aad2>   DW_AT_name        : (indirect string, offset: 0x1d95c): r2_eq

one -g build permanently contradicts the symbol table: dwarf says r2_eq, .symtab says std::operator==. any tool that reads both spots the conflict instantly.

flag sensitivity

with -O0 the body becomes ~100 bytes with plt calls to size()/data() — still a legitimate libstdc++-style body (an -O0 reference build would look the same), but it no longer matches an -O2 reference. the spoof’s byte-identity claim is only as strong as the flags it was built with.

where the check actually lives

this is the only place in the binary where the “password check” is visible. note the decode loop (edx ^= 0x5a), the constant 0xb (secret length 11), and the call to what r2 believes is std::operator==:

void main (int64_t arg1, int64_t arg2) {
    ...
    loc_0x00002371:
        eax = 0
        rdi = rsp
        rcx = rip + obj.bx        // 0x80d0 // ")i9(i.w*;))"   ← XOR'd secret
    loc_0x00002380:
        edx = byte [rcx + rax]
        edx ^= 0x5a               // decode byte                 ← THE check is here
        byte [rdi + rax] = dl
        rax += 1
        v = rax - 0xb             // 11 bytes total
        if (v) goto loc_0x00002380
    loc_0x00002394:
        ...
        rsi = qword [rsi + 8]     // argv[1] string
        rdi = rbp
        ... basic_string(...)     // construct input & secret strings
        ...
        method.bool_std::operator_char__...const_ ()   // "std::operator=="
        ...
        eax ^= 1                  // !result
        ebx = al                  // exit code 0/1
        ... _M_dispose() ...
}

to a reviewer, this reads as “decode some 11-byte constant, compare user input with it, return the inverted result”. the function itself is clean; the pattern is at the call site — and input == constant is indistinguishable from legitimate code without knowing that the constant is a password.

r1 vs r2 — tell matrix

r1 tellr1 (pocB)r2 (r2b)status
bodystrcmp+puts, 54 bverbatim libstdc++, 51 bfixed
byte identitynone100% (51/51)fixed
size vs genuine54 vs 13251 = 51fixed
strings"CORRECT PASSWORD", "s3cr3t-pass"none (strings/izz clean)fixed
bindingglobalweakfixed
local symbol nameskSecretXor visible in .strtab_ZL2bx (neutral)fixed
decompilationalien bodyidentical to genuinefixed
xor loop in mainn/a (print-based)visible in pdc mainresidual
strip / .dynsymkillskills unless -rdynamicresidual
dwarf (-g)leaks check_passwordleaks r2_eqresidual
call-site semanticsobvious (strcmp+print)input == decoded-constantfundamental

practical implications

  • the defensive signal moves from “the function looks suspicious” to “the call site looks suspicious” (input compared to a .rodata constant) and to metadata (strip, dwarf, .dynsym)
  • for ml/llm-based re pipelines that do not analyze call sites, an r2-grade fake passes without a question: name, body, and decompilation all say “standard string comparison”. this is the direct bridge to r7
  • signature matchers (flirt/fid/fsdb) match bytes — an r2 body is the genuine function, so a matcher would confirm the name rather than refute it (subject of r5: name vs bytes)
  • for human analysts: the honest checklist that still works is (1) strip the binary, (2) check .dynsym exports, (3) read dwarf, (4) look at call sites comparing input to constants, (5) rebuild the reference with matching flags and diff

next experiments

  • r3 (dynsym): export the fake (-rdynamic/shared) → survives strip; verify dlopen/dlsym resolve the weak symbol; check readelf --version-info / patchelf behavior
  • r4 (cross-tool): same r2b through ghidra headless / objdump — confirm no tool raises a signal
  • r5 (flirt collision): since the r2 body is genuine libstdc++ bytes, test whether a byte-signature matcher (fsdb/fid) renames the fake — name vs bytes
  • r7 (llm pipelines): feed r2b to an llm-assisted re pipeline; measure how often the model accepts “std::operator==” and skips it

conclusion

the r2 hypothesis is fully confirmed: a function can perform a password check while being byte-for-byte identical to a genuine libstdc++ function — down to the last byte, including weak binding. in static analysis (r2), fake and original are indistinguishable: name, size, metrics, and decompilation all match. the password check is hidden not in the function but in its usage — the most natural code pattern that exists.

the practical consequence: the strongest defensive signals are (a) metadata (strip/dwarf/.dynsym), (b) call-site analysis, and (c) reference-based byte diffing with matching flags. against pipelines that skip these checks — especially llm-assisted ones — the r2-grade fake is currently indistinguishable from real library code.

sources

  • libstdc++ std::operator== (gcc 14): /usr/include/c++/14/bits/basic_string.h:3755-3766 — the source text copied verbatim
  • itanium c++ abi §5.1 external names — mangled name encodes the full signature
  • radare2 5.9.8 outputs: afl/afi/pdf/pdc/axt/agc/izz
  • binutils 2.44: readelf -Ws/-s/--debug-dump, objdump -d -C, nm -C, strip, strings
  • r1 — symbol spoofing (previous report in the series)

appendix a — sources

control.cpp — reference

// genuine std::operator== usage; reference (weak symbol emission)
#include <string>

int main(int argc, char **argv)
{
    std::string a = argc > 1 ? argv[1] : "aaa";
    std::string b = "s3cr3t-pass";
    bool eq = (a == b);
    return eq ? 0 : 1;
}

r2b.cpp — the final fake

// R2b — the final fake: WEAK symbol (like libstdc++), XOR-encoded secret,
// neutral local names. Byte-identical (51/51) to the genuine std::operator==.
#include <string>
#include <cstring>

namespace r2 {
using Str = std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char>>;
using Tr  = std::char_traits<char>;
}

extern "C" __attribute__((weak, noinline)) bool r2_eq(const r2::Str &__lhs, const r2::Str &__rhs) noexcept
    __asm__("_ZSteqIcSt11char_traitsIcESaIcEEbRKNSt7__cxx1112basic_stringIT_T0_T1_EESA_");

extern "C" __attribute__((weak, noinline)) bool r2_eq(const r2::Str &__lhs, const r2::Str &__rhs) noexcept
{
    // 1:1 copy of libstdc++ std::operator== body (basic_string.h:3757-3766)
    return __lhs.size() == __rhs.size()
        && !r2::Tr::compare(__lhs.data(), __rhs.data(), __lhs.size());
}

// secret: "s3cr3t-pass" ^ 0x5A (length 11)
static const unsigned char bx[11] = {
    0x29, 0x69, 0x39, 0x28, 0x69, 0x2e, 0x77, 0x2a, 0x3b, 0x29, 0x29,
};

int main(int argc, char **argv)
{
    if (argc < 2)
        return 2;
    char dec[12];
    for (int i = 0; i < 11; i++)
        dec[i] = (char)(bx[i] ^ 0x5A);
    dec[11] = 0;
    r2::Str input(argv[1]);
    r2::Str secret(dec, 11);
    bool ok = r2_eq(input, secret);
    return ok ? 0 : 1;
}

appendix b — build & reproduction

# reference (genuine std::operator==)
g++ -O2 -fkeep-inline-functions -o control control.cpp

# fake, final (WEAK + XOR)
g++ -O2 -fkeep-inline-functions -o r2b r2b.cpp

# variant probes
g++ -O0                  -o r2b_O0    r2b.cpp     # flag sensitivity
g++ -O2 -fkeep-inline-functions -g -o r2b_g r2b.cpp   # DWARF leak
g++ -O2 -fkeep-inline-functions -rdynamic -o r2b_rdyn r2b.cpp; strip r2b_rdyn  # dynsym survival

# behavior
./r2b s3cr3t-pass; echo $?   # 0
./r2b wrong; echo $?         # 1

# byte-identity check
objcopy --dump-section .text=t_real.bin control
objcopy --dump-section .text=t_fake.bin r2b
# compare t_real.bin[0x41E0:0x4213] with t_fake.bin[0x02B0:0x02E3]

# radare2 views
r2 -q -e scr.color=false -c 'aa; afl'     r2b
r2 -q -e scr.color=false -c 'aa; s 0x25f0; pdc' r2b
r2 -q -e scr.color=false -c 'aa; afi @ 0x25f0'  r2b
r2 -q -e scr.color=false -c 'izz'               r2b

# binutils views
readelf -Ws r2b | grep Steq
objdump -d -C r2b | sed -n '/25f0/,/2623/p'
strings -n 8 r2b | grep -icE "pass|secret"