miasma

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

r1 — symbol spoofing

abstract

обман дизассемблера через подмену имени символа. password-checker, который radare2 именует как std::operator==. разбор того, что реально обмануто, а что нет.

эксперимент из серии отчётов R1: взять обычную функцию-проверку пароля, переименовать её в mangled-имя std::operator== из libstdc++ и посмотреть, что reverse-инструменты покажут аналитику. окружение: debian 12 (x86-64), gcc 14.2.0, binutils 2.44, radare2 5.9.8 (без root). все poc-исходники и команды сборки — в этом же файле ниже.

tl;dr

  • техника работает, но обманут не «дизассемблер» и не анализ — обманут слой именования (naming layer): radare2 деманглит строку из .symtab/.dynsym и верит ей как метаданным.
  • функция, реально являющаяся password-checker’ом, отображается в r2 как method.bool_std::operator<char...>(...)идентично тому, как отображается настоящий std::operator== в честном бинарнике (имена совпадают байт-в-байт).
  • при этом типы аргументов, возвращаемое значение, вызовы, call graph и pseudo-c код не пострадали: r2 не выводит типы из демангл-сигнатуры. в pdc параметры — int rdi, int rsi, выведенные из дизассемблера, а поддельная сигнатура появляется только в комментарии.
  • «обман» в 100% случаев — это spoofing метаданных (имя символа), а не подмена семантики анализа. инструменты, использующие сигнатуры по байтам (FLIRT, ghidra function id), этой техникой не обманываются.
  • техника полностью снимается strip’ом, если символ не экспортирован; переживает strip только в .dynsym (shared library или -rdynamic), и тогда её видно в readelf -Ws как экспорт с аномальным размером.
  • опытный реверсер замечает подмену за минуты: несоответствие размера функции, вызовы strcmp/puts вместо vtable-логики basic_string, строки "CORRECT PASSWORD", dwarf.

экспериментальная база

собраны и проанализированы (radare2 5.9.8 + binutils):

pocприёмрезультат в r2
pocAв исходнике функция названа mangled-именем _ZSteqIc...gcc пере-мангалит имя в _Z74_ZSteq..., потому что компилятор не знает, что идентификатор уже заманлен. в r2 — имя функции-идентификатор, а не std::operator==
pocBобычная функция check_password, переименована через objcopy --redefine-symr2: method.bool_std::operator_char__...воспроизведено. pdc: сигнатура подделки — только комментарий; параметры из дизассемблера
pocB.spoofed + stripполный stripсимвол удалён (его нет в .dynsym) → r2: fcn.00001159. spoof полностью снят
pocB.so.spoofed / .rdyn.spoofedshared lib / -rdynamicobjcopy --redefine-sym не переименовывает .dynsym — только .symtab! после strip остаётся честное check_password
pocE (+.rdyn, .strip)ассемблер: символ .globl _ZSteq... с самого началав -rdynamic варианте имя живёт в .dynsym и переживает strip; r2 показывает std::operator==
pocC2локальные определения strcmp/strlen/memcpy/malloc/free (forwarding через dlsym(RTLD_NEXT))r2: sym.strcmp и т.д. — локальные функции; ii не показывает импорты
pocCто же без forwardingбинарь падает (SIGSEGV): glibc интерпозирует свои внутренние вызовы malloc на поддельные
pocD4 сложных имени: std::max<int>, std::operator<<, basic_string::compare(...) const, std::operator==все демангл-ятся корректно. ошибочное имя ...EE9compare... (без K) не деманглится ни c++filt, ни r2
pocF.symver — экспорт strcmp@GLIBC_2.2.5 с кастомным теломr2: sym.strcmp_GLIBC_2.2.5 (defined), sym.imp.strcmp остаётся отдельным импортом
pocG__attribute__((alias)): _ZSteq... как alias check_passwordоба имени на одном адресе; r2 берёт method.-имя; в pdf видны оба комментария — палевная улика
control_realнастоящий std::operator== из libstdc++ (weak, header)r2 показывает то же самое имя, что и подделка. отличие — только размер (132 против 54 байта) и тело

ключевой контрольный результат — имена совпадают, размер и тело — нет:

# настоящий std::operator== (control_real)
0x00008da6 5 132 method.bool_std::operator_char__...const_
# подделка (pocB.spoofed)
0x00001159 4  54 method.bool_std::operator_char__...const_

как это работает на уровне elf

elf symbol table

по gABI (§4.4 «Symbol Table»):

typedef struct {
    Elf64_Word  st_name;   // индекс в .strtab — НИКАКОЙ семантики, просто строка
    Elf64_Addr  st_value;
    Elf64_Xword st_size;
    unsigned char st_info; // bind/type: STB_GLOBAL/STT_FUNC
    unsigned char st_other; // visibility
    Elf64_Half  st_shndx;   // секция
} Elf64_Sym;

st_name — это индекс в строковой таблице; формат файла не накладывает на имя никаких ограничений — это произвольная строка. инструменты, которые «деманглят» имя, применяют к этой строке внешний по отношению к ELF стандарт (Itanium C++ ABI §5.1).

следствия:

  • символ в .symtab может называться как угодно; ELF-валидность не нарушается
  • objcopy --redefine-sym меняет имя только в .symtab — потому что .dynsym генерируется линкером, а objcopy патчит строку в .strtab
  • strip удаляет .symtab целиком; остаются только динамические символы (экспорты)

c++ abi: имя = сериализованный тип

Itanium C++ ABI §5.1 «External Names»: mangled-имя кодирует полную сигнатуру функции. пример из эксперимента:

_ZSteqIcSt11char_traitsIcESaIcEEbRKNSt7__cxx1112basic_stringIT_T0_T1_EESA_
= bool std::operator==<char, std::char_traits<char>, std::allocator<char>>(
    std::__cxx11::basic_string<...> const&, std::__cxx11::basic_string<...> const&)

деманглер — это парсер этого кодирования. он принимает любую строку и пытается её распарсить; если строка валидна — выдаёт «красивую» сигнатуру. никакой сверки с байтами функции не существует в принципе: у ELF нет механизма проверить соответствие имени телу.

подмена mangled-имени — это подмена «типа» для любого инструмента, который доверяет имени. проблема не в конкретном баге инструмента, а в архитектуре доверия: имя в ELF непроверяемо, а деманглер обязан ему верить — это его контракт.

plT/got и импорты

если функция импортируется, имя приходит из .dynstr и тоже непроверяемо. определение локального символа с именем либсишной функции (pocC2) вызывает symbol interposition: динамический линкер резолвит вызовы strcmp из любого модуля в нашу локальную версию. работает и в обратную сторону: glibc внутри себя вызывает наши поддельные malloc/free/strlen — что и вызвало SIGSEGV в pocC.

символьные версии

.symver добавляет к имени версию (strcmp@GLIBC_2.2.5) — тоже просто строка. pocF показал: r2 честно различает sym.strcmp_GLIBC_2.2.5 (наше определение) и sym.imp.strcmp (настоящий импорт).

почему radare2 «поверил»

полная цепочка из исходников r2 5.9.8 (фрагменты — в приложении ниже):

  1. чтение символа. libr/bin/format/elf/elf.c → символы из .symtab/.dynsym; имя копируется из строковой таблицы без проверок.
  2. деманглинг и split class::method. libr/bin/mangling/cxx.c (r_bin_demangle_cxx): деманглит через форк libiberty; затем эвристика — ищет последний :: перед первой ( в демангл-строке, режет строку на «class» и «method», регистрирует через r_bin_file_add_method. для подделки это даёт class = std::operator==<char, std::char_traits<char>, std::allocator — отсюда префикс method.. это не семантическая классификация «метод», а побочный эффект эвристики разбиения.
  3. именование функций. libr/core/canal.c get_function_name(): если на адресе есть символ, зарегистрированный как method — имя = method.<class>.<method>. иначе — флаг sym.<...> из libr/core/cbin.c (snInit).
  4. отображение. r_core_anal_fcn_name деманглит fcn->name при выводе afl/pdf/pdc; демангл-сигнатура печатается как комментарий.
  5. анализ — не зависит от имени. bb/edge/xrefs считаются по байтам (libr/anal/*). подтверждено эмпирически: afiargs: 0; pdc(int rdi, int rsi); тип возврата из eax-анализа.

fig — naming chain in r2

 .symtab ──> st_name ──> demangler ──> split :: ──> method.<class>.<method>
                                 │
                                 └──> комментарий-сигнатура в pdc/pdf
 анализ (afl, pdc, xrefs) ── по байтам, имя не используется

важно: для настоящего std::operator== r2 делает ровно то же самое — потому что имя — единственный вход деманглера. на уровне именования подделка и оригинал неразличимы в принципе.

что реально было обмануто

слойобманут?доказательство
symbol resolver (чтение symtab)да, тривиальноis/iS показывают поддельное имя
деманглердаr2/c++filt выдают std::operator==<...> для валидной строки
function naming / ui (afl, pdf, графы)даafl, axt, agc, pdf-заголовки
call graph / xrefsчастичнорёбра считаются по байтам (не обманут), но подписи рёбер показывают поддельное имя
типы аргументовнетargs: 0; pdc: int rdi, int rsi — из дизассемблера
возвращаемый типнетpdc: int, из eax
decompiler output (r2 pdc)нет (кроме имени)сигнатура — комментарий; параметры из анализа
library function identification (FLIRT/FSDB)нетr2 fc использует байтовые сигнатуры FSDB, не имена
типы на call-siteнетcallee-прототип не применяется к вызову

вывод: обманут слой «имя-и-подпись», который потребляет строку из ELF. анализ байтов, контрольный поток, data-flow — не обмануты, потому что они по именам не работают.

сравнение инструментов

radare2 (проверено эмпирически, 5.9.8)

  • именование: обманут (method./sym.-имена, комментарии-сигнатуры)
  • анализ: не обманут (args: 0, pdc-параметры из дизассемблера)
  • -e bin.demangle=false — показывает сырое имя (спуфинг виден)
  • fc (FSDB-сигнатуры) — байтовые, именами не ведётся
  • деманглер r2 — собственный форк; не все валидные имена деманглит (проверено на 9compare)

ghidra (по документации; эмпирика не выполнялась — нет JRE в окружении)

  • имена применяются к функциям при импорте; деманглер c++ на борту
  • function id (FID) — хэши по инструкциям, не имена; по документации FID не переименовывает функции, у которых уже есть имя из символов: поддельное имя останется, FID ничего не заподозрит
  • декомпилятор: параметры/типы из анализа байтов; вероятный исход — имя обмануто, типы — нет
  • dwarf-импорт с честным check_password — улика

ida / hex-rays (по документации; эмпирика недоступна без лицензии)

  • FLIRT: байтовые сигнатуры. цитата из документации hex-rays: «FLIRT matches byte-based patterns, not symbol names… Function names are metadata». поддельное имя не влияет на FLIRT-сопоставление
  • именование: ida доверяет symtab и деманглит — подделка отобразится как std::operator==<...>
  • типы: hex-rays применяет прототипы из FLIRT-сигнатур и локальных типов; автоматического применения типа из произвольного демангл-имени — нет. ожидаемый исход: имя обмануто, декомпиляция — нет

binary ninja (по документации)

  • символы применяются при импорте, деманглинг встроен — имя обмануто
  • анализ (LLIL/MLIL/HLIL) — по байтам; параметры и типы не из имени
  • type libraries — по пользовательским сигнатурам, не по именам

binutils (objdump/readelf/nm) — проверено

  • не обмануты, потому что не анализируют: печатают имя как строку. objdump -d -C показывает демангл-имя в метке — выглядит «по-настоящему», но это просто подстановка строки. именно эти инструменты помогают разоблачить спуфинг

gdb

  • деманглит имена для display; break _ZSteq... сработает на поддельную функцию (не проверялся — gdb отсутствует в окружении; поведение следует из binutils-конвенции)

prior art

прямого канонического prior art с этим названием не найдено — техника существует в виде фольклора, и есть близкие академические работы:

  1. RARE — «When Binaries Talk Back: Representation-Confusion Attacks on LLM-Assisted Reverse Engineering» (Igor Santos-Grueiro, arXiv:2607.12507, июль 2026). ближайший современный аналог: атакующий контролирует содержимое бинарника, и LLM-конвейеры RE продвигают «наблюдение» (например, имя символа) в ранг «доверенного анализа». техника из R1 — частный случай этого класса для классических инструментов.
  2. FLIRT / FSDB / ghidra FID — байтовые сигнатуры. они же являются и защитой: «FLIRT evasion» (модификация тела) — это другая техника; подделка имени FLIRT не обходит (она и не пытается).
  3. symbol interposition (LD_PRELOAD, .symver-хаки) — смежная, хорошо документированная тема; pocC2/pocC демонстрируют её побочные эффекты.
  4. PDB/DWARF spoofing — известная практика, родственная: подделка debug-метаданных.
  5. malware-фольклор: выпуск бинарников «с символами, но с вводящими в заблуждение именами» — обычная практика без устоявшегося термина. ближайший термин — symbol spoofing / symbol name confusion.

если у техники и есть «настоящее имя» — это representation confusion / metadata spoofing, ближайшая публикация — RARE. прямых аналогов с таким же приёмом (подмена mangled-имени stdlib-функции для обмана демангл-слоя) не найдено.

ограничения техники

  1. strip убивает: в обычном исполняемом файле символ живёт только в .symtab; для выживания нужно, чтобы имя попало в .dynsym (shared library / -rdynamic) — а это уже видимый экспорт
  2. objcopy не трогает .dynsym: переименование динамических символов требует ручной правки .dynstr/.dynsym или ассемблерного определения имени
  3. компилятор пере-мангалит: писать готовое mangled-имя в исходнике c++ нельзя — gcc пере-закодирует его (pocA). реалистичные пути: ассемблерный символ, --redefine-sym, alias, патч .strtab, .symver
  4. имя должно быть валидным mangled-именем: ошибка в кодировке → деманглер молча показывает сырую строку (вариант 9compare) → spoof раскрыт
  5. тело должно быть ABI-совместимо с сигнатурой: несоответствие числа/типа аргументов и возврата видно в любом декомпиляторе
  6. размер: r2/ida показывают размер функции из анализа; настоящий std::operator== — 132 байта, подделка — 54. сильное несоответствие — красный флаг
  7. побочные эффекты интерпозиции: определение символов с именами libc ломает рантайм (pocC, SIGSEGV)
  8. dwarf-конфликт: -g оставляет честное DW_AT_name: check_password — мгновенное разоблачение
  9. lto: без экспорта функция инлайнится и символ исчезает вовсе
  10. FLIRT/FID: байтовые матчеры технику не поддерживают и не подтверждают
  11. статическая линковка: работает, но рядом видны настоящие __strcmp_avx2/ifunc-резолверы glibc — аномально для «std::operator==»

как реверсер обнаруживает spoofing

  1. readelf -Ws — сырое имя + st_size/st_info/st_shndx: аномальный размер, func в неожиданной секции
  2. сравнение с эталоном: демангл-имя std::operator== → ожидаемое тело — vtable-логика basic_string (data()/size()/memcmp), 100+ байт. вместо этого — strcmp/puts/строки
  3. строки: rabin2 -zz / strings показывают "CORRECT PASSWORD", "INVALID PASSWORD", "s3cr3t-pass" в .rodata рядом с функцией
  4. dwarf: readelf --debug-dump=info — честное имя
  5. strip: если символ не в .dynsym — подделка исчезает; если в .dynsym — виден как подозрительный экспорт mangled-имени
  6. bin.demangle=false в r2 — сырое имя
  7. два имени на одном адресе (alias-вариант, pocG) — оба комментария в pdf
  8. нормализация через nm -C: сигнатура + сравнение с декомпиляцией вскрывает расхождение (signature vs body mismatch)
  9. fc/FSDB, FLIRT, FID: матчеры не находят соответствия — «std::operator==» с байтами, не похожими на libstdc++, получает статус unknown → сигнал

практически: минуты, а не часы. техника не переживает контакта с аналитиком, который хотя бы раз видел настоящий libstdc++.

практическая ценность

  • реальная ценность — низкая–средняя, и только в тайминге: приём «называй кастомную функцию как stdlib» работает против невнимательного анализа и против первого прохода автоинструментов, которые группируют «известные» функции
  • защита от машинного анализа — слабая: FLIRT/FID/FSDB и ML-классификаторы опираются на байты; имена — лишь одна из фич. (LLM-ассистенты — да, уязвимы: это и есть предмет RARE-бумаги)
  • сочетание с другими техниками повышает эффективность:
    • реалистичное тело — настоящий копипаст std::operator== из libstdc++ со встроенной проверкой: размер и структура правдоподобны, «секретная» логика спрятана в знакомом коде
    • настоящие символы вокруг (подлинные sym.imp.strcmp и т.д.), чтобы symtab выглядел обычно
    • переживающий strip вариант: -rdynamic/shared — экспорт в .dynsym
    • убрать строки (шифрование убирает "CORRECT PASSWORD" — главную улику) и убрать dwarf
  • для обмана человека работает через психологию: «это же просто operator==» — скип. поэтому это «спуфинг метаданных», а не «обман анализа»

следующиe эксперименты r2–r7

  • r2 — реалистичное тело: настоящий код std::operator== из libstdc++ (~130 байт) с внедрённой скрытой проверкой; измерить, сколько времени уходит на функцию, которая выглядит как stdlib
  • r3 — dynsym-спуфинг без -rdynamic: вручную пропатчить .dynstr/.dynsym, чтобы подделка переживала strip; проверить readelf --version-info и dlopen/dlsym
  • r4 — меж-инструментальная сверка: один бинарник через r2/ghidra/ida, матрица имя/размер/прототип/декомпиляция
  • r5 — FLIRT-коллизия: функция, байт-в-байт идентичная экспорту libc, но с другим именем — переименует ли инструмент её в настоящее libc-имя (что сильнее: имя или байты)
  • r6 — msvc-мангинг: те же идеи на ?operator==@ в PE/COFF
  • r7 — llm-конвейеры: прогнать spoofed-бинарник через LLM-assisted RE-пайплайны и измерить, сколько раз модель принимает поддельное имя за истину

вывод

  1. эксперимент воспроизведён и подтверждён: r2 именует password-checker как std::operator==<char,...> — точно так же, как настоящий. но это обман слоя именования, а не анализа.
  2. причина — архитектурное доверие: ELF-имя — произвольная строка, а деманглер обязан её распарсить и показать. проверки соответствия имени телу не существует ни в одном инструменте.
  3. ни один слой байтового анализа не обманут: аргументы, типы, возврат, декомпиляция, FLIRT/FID — всё работает по коду. ответ на вопрос «дизассемблер неправильно понял семантику или показал ложное имя»: только второе.
  4. техника снимается strip, ломается невалидным mangling’ом, вскрывается размером/строками/dwarf и декомпиляцией. ценность — тайминг и психология, не криптографическая стойкость.
  5. наиболее перспективные продолжения — r2 (реалистичное тело), r3 (dynsym-переживание strip), r5 (FLIRT-коллизия), r7 (LLM-ассистенты, где доверие к именам реально опасно — см. RARE).

источники


приложение a — ключевые poc

pocB — objcopy-переименование (основной вариант)

// password checker compiled normally, then symbol renamed at ELF level
// via objcopy --redefine-sym to the real mangled name of
//   std::operator==<char, std::char_traits<char>, std::allocator<char>>
#include <cstring>
#include <cstdio>

extern "C" bool check_password(const char *a, const char *b)
{
    int r = strcmp(a, b);
    if (r == 0) {
        printf("CORRECT PASSWORD\n");
        return true;
    }
    printf("INVALID PASSWORD\n");
    return false;
}

int main(int argc, char **argv) {
    const char *secret = "s3cr3t-pass";
    if (argc < 2) {
        printf("usage: %s <password>\n", argv[0]);
        return 1;
    }
    bool ok = check_password(secret, argv[1]);
    return ok ? 0 : 1;
}

pocE — ассемблерный символ (переживает strip при -rdynamic)

# assembly definition of the spoofed symbol: bool(const char*, const char*)
    .text
    .globl _ZSteqIcSt11char_traitsIcESaIcEEbRKNSt7__cxx1112basic_stringIT_T0_T1_EESA_
    .type _ZSteqIcSt11char_traitsIcESaIcEEbRKNSt7__cxx1112basic_stringIT_T0_T1_EESA_, @function
_ZSteqIcSt11char_traitsIcESaIcEEbRKNSt7__cxx1112basic_stringIT_T0_T1_EESA_:
    push %rbp
    mov  %rsp, %rbp
    sub  $8, %rsp
    lea  str_secret(%rip), %rdi
    call strcmp@PLT
    test %eax, %eax
    je   .Lcorrect
    lea  str_invalid(%rip), %rdi
    call puts@PLT
    mov  $0, %eax
    jmp  .Ldone
.Lcorrect:
    lea  str_correct(%rip), %rdi
    call puts@PLT
    mov  $1, %eax
.Ldone:
    leave
    ret
    .size _ZSteqIcSt11char_traitsIcESaIcEEbRKNSt7__cxx1112basic_stringIT_T0_T1_EESA_, .-_ZSteqIcSt11char_traitsIcESaIcEEbRKNSt7__cxx1112basic_stringIT_T0_T1_EESA_

    .section .rodata
str_secret:
    .string "s3cr3t-pass"
str_correct:
    .string "CORRECT PASSWORD"
str_invalid:
    .string "INVALID PASSWORD"
    .section .note.GNU-stack,"",@progbits

контрольный пример — настоящий std::operator==

#include <string>
#include <cstdio>
int main(int argc, char **argv) {
    std::string a = "aaa", b = argc > 1 ? argv[1] : "bbb";
    bool eq = (a == b);
    printf("eq=%d\n", (int)eq);
    return 0;
}

приложение б — сборка и анализ

# poc B — objcopy --redefine-sym (работает для .symtab)
g++ -O1 -o bins/pocB src/pocB.cpp
objcopy --redefine-sym check_password=_ZSteqIcSt11char_traitsIcESaIcEEbRKNSt7__cxx1112basic_stringIT_T0_T1_EESA_ bins/pocB bins/pocB.spoofed

# poc C/C2 — локальные определения с именами libc (interposition)
g++ -O0 -o bins/pocC src/pocC.cpp          # падает: glibc интерпозирует malloc
g++ -O0 -ldl -o bins/pocC2 src/pocC2.cpp   # рабочий вариант с dlsym(RTLD_NEXT)

# poc E — ассемблерный символ; имя переживает strip при -rdynamic
g++ -O1 -rdynamic -o bins/pocE.rdyn src/pocE_main.cpp src/pocE_asm.S
strip bins/pocE.rdyn   # имя остаётся в .dynsym

# poc F — symbol versioning (.symver); poc G — alias
g++ -O0 -o bins/pocF src/pocF.cpp
g++ -O1 -o bins/pocG src/pocG.cpp

# control — настоящий std::operator== из libstdc++
g++ -O0 -fkeep-inline-functions -o bins/control_real src/control_real.cpp
# анализ
r2 -q -e scr.color=false -c 'aa; afl' bins/pocB.spoofed
r2 -q -e scr.color=false -c 'aa; s 0x1159; pdc' bins/pocB.spoofed
r2 -q -e scr.color=false -c 'aa; s 0x1159; afi' bins/pocB.spoofed
readelf -Ws bins/pocE.rdyn.strip
objdump -d -C bins/pocE.rdyn

приложение в — фрагмент r2-исходников: split class::method

ключевая эвристика деманглера r2 (libr/bin/mangling/cxx.c, r_bin_demangle_cxx) — ищет последний :: перед первой ( и режет строку:

char *out = cplus_demangle_v3 (p, flags);
if (out) {
    char *sign = (char *)strchr (out, '(');
    if (sign) {
        char *str = out;
        char *ptr = NULL;
        char *nerd = NULL;
        for (;;) {
            ptr = strstr (str, "::");
            if (!ptr || ptr > sign) break;
            nerd = ptr;
            str = ptr + 1;
        }
        if (R_STR_ISNOTEMPTY (nerd)) {
            *nerd = 0;
            if (bf) {
                RBinSymbol *sym = r_bin_file_add_method (bf, out, nerd + 2, 0);
                /* ... */
            }
            *nerd = ':';
        }
    }
}
return out;

а get_function_name (libr/core/canal.c) превращает это в имя функции:

RBinSymbol *sym = ht_up_find (bf->bo->addr2klassmethod, addr, NULL);
if (sym && sym->classname && sym->name) {
    return r_str_newf ("method.%s.%s", sym->classname, sym_name);
}