r2 — realistic body
abstract
продолжение r1: проверка пароля, чьё 51-байтное машинное тело на 100% байтово идентично настоящему std::operator== из libstdc++. все улики r1 сняты — проверка спрятана в call-site.
продолжение серии после R1 (symbol spoofing). в R1 подделка выдавала себя мгновенно: тело 54 байта из strcmp+printf, строки "CORRECT PASSWORD"/"INVALID PASSWORD" в .rodata, global-биндинг и размер, далёкий от ~132 байт настоящего std::operator==.
гипотеза r2: если машинное тело байт-в-байт совпадает с настоящей stdlib-функцией, а символ — её настоящее mangled-имя, то в статическом анализе подделка неотличима от оригинала — независимо от её семантики (семантику определяет вызывающий код).
вопросы исследования: достижима ли байтовая идентичность? что остаётся отличительным даже при идентичном теле? сколько усилий нужно аналитику, чтобы найти проверку пароля?
цель
снять все улики r1:
| улика r1 | статус в r2 |
|---|---|
тело strcmp+puts, 54 байта | дословная копия исходника из заголовка libstdc++ |
| размер 54 против 132 | 51 = 51 |
строки-улики в .rodata | секрет xor-закодирован, strings/izz чисты |
| global-биндинг | weak — как эмитит libstdc++ |
| имена локальных символов | нейтральные (bx) |
методика
- эталон: настоящий
std::operator==из заголовков libstdc++ (gcc 14), собран с-O2 -fkeep-inline-functions, чтобы weak-символ реально эмитировался → бинарникcontrol - подделка r2a: функция
r2_eq— проверка пароля, тело — дословная копия исходникаstd::operator==; символ перенаправлен на mangled-имя оригинала черезextern "C" ... __asm__("_ZSteqIc...")— единственный «не-заголовочный» трюк во всей сборке. global-биндинг, секрет открытым текстом - подделка r2b: r2a +
__attribute__((weak))+ xor-закодированный секрет + нейтральные имена локальных символов - измерения: сравнение дампов байт (
.text),readelf -Ws/-s,objdump -d -C, r2afl/afi/pdf/pdc/axt/agc/izz,strings, exit-коды - варианты-пробы:
-O0(чувствительность к флагам),-g(dwarf-улика),-rdynamic+strip(выживание в dynsym)
ключевой механизм: одинаковый текст исходника + одинаковые флаги компилятора ⇒ одинаковая генерация кода. никакого ручного ассемблера — тело буквально тот же текст, который gcc компилирует для настоящей функции.
результаты
поведение
подделка — настоящий шлюз аутентификации: верный пароль → exit 0, неверный → exit 1:
$ ./r2b s3cr3t-pass; echo "ok=$?" → ok=0
$ ./r2b wrong; echo "wrong=$?" → wrong=1
$ ./control s3cr3t-pass; echo $? → 0
$ ./control wrong; echo $? → 1
таблица символов
оба бинарника содержат идентичный mangled-символ с идентичным размером и биндингом. отличается только адрес (у любых двух честных сборок он тоже разный):
настоящий (control): 147: 0000000000006520 51 FUNC WEAK DEFAULT 14 _ZSteqIcSt11char_traitsIcESaIcEEbRKNSt7__cxx1112basic_stringIT_T0_T1_EESA_
подделка r2b: 147: 00000000000025f0 51 FUNC WEAK DEFAULT 14 _ZSteqIcSt11char_traitsIcESaIcEEbRKNSt7__cxx1112basic_stringIT_T0_T1_EESA_
проверка global-против-weak (улика r1 №3) — теперь совпадает:
r2a (GLOBAL): 146: 00000000000025c0 51 FUNC GLOBAL DEFAULT 14 _ZSteqIc...
r2b (WEAK): 147: 00000000000025f0 51 FUNC WEAK DEFAULT 14 _ZSteqIc...
байтовая идентичность
тела по 51 байту, выгружены из .text. диф: 49/51 с r2a (96.1%) и 51/51 с r2b (100%). единственные различающиеся байты — rel32-дисплейсмент call memcmp@plt — адресная константа линковки, которая различается у любых двух сборок (оба вызова резолвятся в один и тот же PLT-слот 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
^^^^^^^^^^^^ только 2 байта отличаются (rel32 к memcmp@plt)
дизассемблер
полный objdump -d -C обоих тел — инструкция-в-инструкцию; демангл-метка одинаковая, потому что objdump деманглит одну и ту же строку символа:
00000000000025f0 <bool std::operator==<char, std::char_traits<char>, std::allocator<char> >(...)>: ← подделка
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 — идентичная обработка
afl показывает то же method.-имя; метрики afi совпадают по всем строкам, кроме странности stackframe (0 против 8 — стек-анализ r2 эвристический); выводы pdc идентичны после нормализации адресов (diff пуст):
настоящий: 0x00006520 4 48 method.bool_std::operator_char__...const_
подделка: 0x000025f0 4 48 method.bool_std::operator_char__...const_
метрики afi:
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
полный pdc подделки (идентичен настоящему после нормализации):
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;
}
строки
ноль совпадений по pass|secret|s3cr в strings и r2 izz. полный .rodata содержит только штатные диагностические строки libstdc++; секрет присутствует только как 11 xor-закодированных байт:
$ strings -n 8 r2b | grep -icE "pass|secret" → 0
$ r2 -qc 'izz' r2b | grep -icE "pass|secret|s3cr" → 0
математика скрытия секрета
secret : s3cr3t-pass
key : 0x5A
encoded : 0x29 0x69 0x39 0x28 0x69 0x2e 0x77 0x2a 0x3b 0x29 0x29
printable: )i9(i.w*;)) ← неудачный ключ: шифротекст печатный
decode : s3cr3t-pass ← round-trip подтверждён
анатомия обмана
почему это тело каноническое для std::operator==:
- sso-осведомлённый layout:
std::string={char* _M_dataplus; size_t _M_string_length; union {...}— размер по смещению 8, указатель на данные по смещению 0. тело читает[rdi+8]/[rsi+8]для размеров и[rdi]/[rsi]для данных — ровно два load’а в дизассемблере - семантика короткого замыкания: сначала сравниваются размеры,
memcmpтолько при совпадении длин — ветвление (xor/je/ret, затем test/je, затем memcmp) и есть алгоритм _Traits::compare==memcmpдляchar— единственныйcall memcmp@plt- abi-форма: два указателя в rdi/rsi,
boolв eax — одинакова у настоящей функции и подделки - почему одинаковый исходник ⇒ одинаковый код: gcc 14 детерминирован при одинаковом исходнике, флагах и abi. свободные переменные — только константы линковки (plt/got) — те самые 2 байта
fig — where the check lives
r2_eq (подделка) main
┌────────────────────┐ ┌──────────────────────┐
│ std::operator== │ │ xor-декодер 0x5a │ ← проверка здесь
│ байты libstdc++ │ │ input == decoded │
│ size 51, weak │ │ exit 0/1 │
└────────────────────┘ └──────────────────────┘
имя/тело/дек. семантика видна только
неотличимы по call-siteчто всё ещё выдаёт подделку
| # | улика | доказательство | серьёзность |
|---|---|---|---|
| 1 | символ только в .symtab — strip снимает | strip r2b → нет символов; с -rdynamic переживает в .dynsym | высокая |
| 2 | xor-декодер виден в декомпиляции main | pdc main | средняя |
| 3 | dwarf выдаёт честное имя при -g | DW_AT_name: r2_eq | высокая, если есть |
| 4 | байтовая идентичность чувствительна к флагам | -O0 → 100+ байт, plt-вызовы size()/data() | средняя |
| 5 | вызов вместо инлайна | в control вызов инлайнится (нет xref); в r2b — CALL XREF from main | низкая |
| 6 | семантика на call-site: argv[1] против декодированной константы | pdc main: edx ^= 0x5a; ... call std::operator== | фундаментальная |
неудачный xor-ключ
ключ 0x5A превратил шифротекст в печатный ascii ")i9(i.w*;))" — r2 показывает его в декомпиляции main строковым комментарием. ключ, дающий непечатные байты, убрал бы и этот намёк. урок: выбирать ключ так, чтобы cipher[i] = secret[i]^key был непечатным для всех i.
утечка через dwarf
$ 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
одна сборка с -g навсегда противоречит таблице символов: dwarf говорит r2_eq, .symtab говорит std::operator==. любой инструмент, читающий оба источника, мгновенно видит конфликт.
чувствительность к флагам
с -O0 тело становится ~100 байт с plt-вызовами size()/data() — всё ещё легитимный стиль libstdc++ (эталонная сборка с -O0 выглядела бы так же), но оно больше не совпадает с эталоном -O2. заявление о байтовой идентичности справедливо ровно для тех флагов, которыми собрана подделка.
где реально живёт проверка
это единственное место в бинарнике, где видна «проверка пароля». обратите внимание на цикл декодирования (edx ^= 0x5a), константу 0xb (длина секрета 11) и вызов того, что r2 считает 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() ...
}
для рецензента это читается как «декодировать 11-байтную константу, сравнить пользовательский ввод с ней, вернуть инвертированный результат». сама функция чиста; паттерн — на call-site — и input == constant неотличим от легитимного кода без знания, что константа — пароль.
матрица улик r1 vs r2
| улика r1 | r1 (pocB) | r2 (r2b) | статус |
|---|---|---|---|
| тело | strcmp+puts, 54 б | дословный libstdc++, 51 б | устранено |
| байтовая идентичность | нет | 100% (51/51) | устранено |
| размер против оригинала | 54 vs 132 | 51 = 51 | устранено |
| строки | "CORRECT PASSWORD", "s3cr3t-pass" | нет (strings/izz чисты) | устранено |
| биндинг | global | weak | устранено |
| имена локальных символов | kSecretXor в .strtab | _ZL2bx (нейтральное) | устранено |
| декомпиляция | чужое тело | идентична оригиналу | устранено |
xor-цикл в main | n/a (print-based) | виден в pdc main | остаточная |
strip / .dynsym | снимает | снимает без -rdynamic | остаточная |
dwarf (-g) | выдаёт check_password | выдаёт r2_eq | остаточная |
| семантика call-site | очевидна (strcmp+print) | input == decoded-constant | фундаментальная |
практические следствия
- защитный сигнал смещается с «функция выглядит подозрительно» на «call-site выглядит подозрительно» (ввод сравнивается с константой из
.rodata) и на метаданные (strip, dwarf,.dynsym) - для ml/llm-пайплайнов re, не анализирующих call-site, подделка уровня r2 проходит без вопросов: имя, тело и декомпиляция — всё говорит «стандартное сравнение строк». это прямой мостик к r7
- сигнатурные матчеры (flirt/fid/fsdb) сопоставляют байты — тело r2 является настоящей функцией, поэтому матчер скорее подтвердит имя, чем опровергнет (предмет r5)
- для человека-аналитика честный чек-лист: (1) strip бинарника, (2) проверка экспортов
.dynsym, (3) чтение dwarf, (4) поиск call-site со сравнением ввода с константой, (5) пересборка эталона с теми же флагами и диф
следующиe эксперименты
- r3 (dynsym): экспорт подделки → переживание
strip; проверка резолваdlopen/dlsymweak-символа; поведениеpatchelf - r4 (cross-tool): тот же
r2bчерез ghidra headless и objdump — подтвердить, что ни один инструмент не подаёт сигнал - r5 (flirt collision): тело r2 является настоящими байтами libstdc++ — проверить, переименует ли байтовый матчер (fsdb/fid) подделку: имя против байт
- r7 (llm pipelines): подать
r2bв llm-ассистируемый re-пайплайн; измерить, как часто модель принимает «std::operator==» и пропускает его
вывод
гипотеза r2 подтверждена полностью: функция может выполнять проверку пароля, будучи байт-в-байт идентичной настоящей libstdc++-функции — вплоть до последнего байта, включая weak-биндинг. в статическом анализе (r2) подделка и оригинал неразличимы: имя, размер, метрики и декомпиляция совпадают. проверка пароля спрятана не в функции, а в её использовании — самом естественном паттерне кода.
практическое следствие: самые сильные защитные сигналы — (а) метаданные (strip/dwarf/.dynsym), (б) анализ call-site и (в) эталонный побайтовый диф с совпадающими флагами. против пайплайнов, пропускающих эти проверки — особенно llm-ассистируемых — подделка уровня r2 в настоящее время неотличима от настоящего библиотечного кода.
источники
- libstdc++
std::operator==(gcc 14):/usr/include/c++/14/bits/basic_string.h:3755-3766— текст исходника, скопированный дословно - itanium c++ abi §5.1 external names — mangled-имя кодирует полную сигнатуру
- radare2 5.9.8: выводы
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 (предыдущий отчёт серии)
приложение a — исходники
control.cpp — эталон
// Контроль: настоящий std::operator== (weak, из заголовков), те же флаги -O2.
// Тело, эмитированное компилятором для этого вызова, — эталон для сравнения.
#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 — финальная подделка
// 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;
}
приложение б — сборка и воспроизведение
# эталон (настоящий std::operator==)
g++ -O2 -fkeep-inline-functions -o control control.cpp
# подделка, финальная (WEAK + XOR)
g++ -O2 -fkeep-inline-functions -o r2b r2b.cpp
# варианты-пробы
g++ -O0 -o r2b_O0 r2b.cpp # чувствительность к флагам
g++ -O2 -fkeep-inline-functions -g -o r2b_g r2b.cpp # утечка DWARF
g++ -O2 -fkeep-inline-functions -rdynamic -o r2b_rdyn r2b.cpp; strip r2b_rdyn # выживание в dynsym
# поведение
./r2b s3cr3t-pass; echo $? # 0
./r2b wrong; echo $? # 1
# проверка байтовой идентичности
objcopy --dump-section .text=t_real.bin control
objcopy --dump-section .text=t_fake.bin r2b
# сравнить t_real.bin[0x41E0:0x4213] с t_fake.bin[0x02B0:0x02E3]
# r2-представления
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
readelf -Ws r2b | grep Steq
objdump -d -C r2b | sed -n '/25f0/,/2623/p'
strings -n 8 r2b | grep -icE "pass|secret"