miasma

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

ru/en

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 против 13251 = 51
строки-улики в .rodataсекрет xor-закодирован, strings/izz чисты
global-биндингweak — как эмитит libstdc++
имена локальных символовнейтральные (bx)

методика

  1. эталон: настоящий std::operator== из заголовков libstdc++ (gcc 14), собран с -O2 -fkeep-inline-functions, чтобы weak-символ реально эмитировался → бинарник control
  2. подделка r2a: функция r2_eq — проверка пароля, тело — дословная копия исходника std::operator==; символ перенаправлен на mangled-имя оригинала через extern "C" ... __asm__("_ZSteqIc...") — единственный «не-заголовочный» трюк во всей сборке. global-биндинг, секрет открытым текстом
  3. подделка r2b: r2a + __attribute__((weak)) + xor-закодированный секрет + нейтральные имена локальных символов
  4. измерения: сравнение дампов байт (.text), readelf -Ws/-s, objdump -d -C, r2 afl/afi/pdf/pdc/axt/agc/izz, strings, exit-коды
  5. варианты-пробы: -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==:

  1. sso-осведомлённый layout: std::string = {char* _M_dataplus; size_t _M_string_length; union {...} — размер по смещению 8, указатель на данные по смещению 0. тело читает [rdi+8]/[rsi+8] для размеров и [rdi]/[rsi] для данных — ровно два load’а в дизассемблере
  2. семантика короткого замыкания: сначала сравниваются размеры, memcmp только при совпадении длин — ветвление (xor/je/ret, затем test/je, затем memcmp) и есть алгоритм
  3. _Traits::compare == memcmp для char — единственный call memcmp@plt
  4. abi-форма: два указателя в rdi/rsi, bool в eax — одинакова у настоящей функции и подделки
  5. почему одинаковый исходник ⇒ одинаковый код: 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символ только в .symtabstrip снимаетstrip r2b → нет символов; с -rdynamic переживает в .dynsymвысокая
2xor-декодер виден в декомпиляции mainpdc mainсредняя
3dwarf выдаёт честное имя при -gDW_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

улика r1r1 (pocB)r2 (r2b)статус
телоstrcmp+puts, 54 бдословный libstdc++, 51 бустранено
байтовая идентичностьнет100% (51/51)устранено
размер против оригинала54 vs 13251 = 51устранено
строки"CORRECT PASSWORD", "s3cr3t-pass"нет (strings/izz чисты)устранено
биндингglobalweakустранено
имена локальных символовkSecretXor в .strtab_ZL2bx (нейтральное)устранено
декомпиляциячужое телоидентична оригиналуустранено
xor-цикл в mainn/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/dlsym weak-символа; поведение 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"