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 tell | status in r2 |
|---|---|
body strcmp+puts, 54 bytes | verbatim copy of the libstdc++ header source |
| size 54 vs 132 | 51 = 51 |
telltale strings in .rodata | secret xor-encoded, strings/izz clean |
| global binding | weak — exactly how libstdc++ emits it |
| local symbol names | neutral (bx) |
methodology
- reference: genuine
std::operator==from libstdc++ headers (gcc 14), compiled with-O2 -fkeep-inline-functionsso the weak symbol is actually emitted → binarycontrol - fake r2a: function
r2_eq— a password checker whose body is a verbatim copy of thestd::operator==source; the symbol is redirected to the original’s mangled name viaextern "C" ... __asm__("_ZSteqIc...")— the only non-header trick in the whole build. global binding, plaintext secret - fake r2b: r2a +
__attribute__((weak))(matches libstdc++ emission) + xor-encoded secret (no string tells) + neutral local symbol names (bx) - measurement: byte dump comparison (
.text),readelf -Ws/-s(binding, size),objdump -d -C, r2afl/afi/pdf/pdc/axt/agc/izz,strings, behavior (exit codes) - 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==:
- 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 - short-circuit semantics: sizes compared first (cheap),
memcmponly when lengths match — the branch structure is the algorithm, not a variant _Traits::compare==memcmpforchar— the singlecall memcmp@plt- abi shape: two pointers in rdi/rsi,
boolin eax — identical for the genuine function and our fake - 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 sitewhat still gives it away
| # | tell | evidence | severity |
|---|---|---|---|
| 1 | .symtab-only symbol — strip kills it | strip r2b → no symbols; with -rdynamic it survives in .dynsym | high |
| 2 | xor decode loop visible in main’s decompilation | pdc main | medium |
| 3 | dwarf leaks real name with -g | DW_AT_name: r2_eq | high if present |
| 4 | byte identity is flag-sensitive | -O0 → 100+ bytes, plt calls to size()/data() | medium |
| 5 | call instead of inlining | in control the -O2 call is inlined (no xref); in r2b there is CALL XREF from main | low |
| 6 | semantics at call-site: argv[1] vs decoded constant | pdc 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 tell | r1 (pocB) | r2 (r2b) | status |
|---|---|---|---|
| body | strcmp+puts, 54 b | verbatim libstdc++, 51 b | fixed |
| byte identity | none | 100% (51/51) | fixed |
| size vs genuine | 54 vs 132 | 51 = 51 | fixed |
| strings | "CORRECT PASSWORD", "s3cr3t-pass" | none (strings/izz clean) | fixed |
| binding | global | weak | fixed |
| local symbol names | kSecretXor visible in .strtab | _ZL2bx (neutral) | fixed |
| decompilation | alien body | identical to genuine | fixed |
xor loop in main | n/a (print-based) | visible in pdc main | residual |
strip / .dynsym | kills | kills unless -rdynamic | residual |
dwarf (-g) | leaks check_password | leaks r2_eq | residual |
| call-site semantics | obvious (strcmp+print) | input == decoded-constant | fundamental |
practical implications
- the defensive signal moves from “the function looks suspicious” to “the call site looks suspicious” (input compared to a
.rodataconstant) 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
.dynsymexports, (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) → survivesstrip; verifydlopen/dlsymresolve the weak symbol; checkreadelf --version-info/patchelfbehavior - r4 (cross-tool): same
r2bthrough 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
r2bto 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"