ВПЕРЁД

НАЗАД



Просмотр манглированных имён функций в объектном файле

Исходный код программы:

name_mangling.cpp

void hello() {}
 
void linus(int arg) {}
 
int torvalds() {
	return 0;
}
 
int tux(int arg) {
	return arg;
}
 
void fox(int arg1, char arg2) {}
 
int main() {
	hello();
	linus(1);
	torvalds();
	tux(2);
	fox(3, 4);
}

Соберём объектный файл программы и просмотрим в нём таблицу символов, содержащую интересующие нас манглированные имена функций. В этот раз эксперименты будем проводить в Linux-системе, для сборки объектного файла будем использовать GNU-компилятор g++ (GNU C++ Compiler).

Почему g++, а не gcc?

Оба компилятора входят в набор GNU Compiler Collection и позволяют собирать программы, написанные на языке C++, но между этими компиляторами имеется ряд различий.

Компилируемый язык по-умолчанию:

  • gcc по умолчанию компилирует .c файлы как C, а .cpp файлы как C++.
  • g++ обрабатывает все файлы как C++ вне зависимости от расширения.

Компоновка (линковка):

  • g++ автоматически подключает стандартную библиотеку C++ (libstdc++).
  • gcc подключает стандартную библиотеку C++ (libstdc++), только если при вызове указать -lstdc++. Это наиболее важное практическое отличие: компиляция C++ через gcc зачастую проходит успешно, но линковка завершится ошибкой без явного указания флагов.

Предопределённые макросы:

  • g++ определяет макрос __cplusplus.
  • gcc не определяет макрос __cplusplus (кроме случаев компиляции .cpp файлов).

Рекомендации по использованию компиляторов:

  • gcc лучше использовать для проектов на C.
  • Для проектов на C++ лучше использовать g++, так как это избавляет от необходимости вручную подключать стандартную библиотеку и обеспечивает правильные настройки по-умолчанию.

Поскольку нас интересуют только программы на языке C++, то есть смысл упростить себе задачу: не заниматься ручной настройкой параметров, требуемых компилятором gcc, а использовать более удобную для нас альтернативу в лице компилятора g++.

$ g++ -c name_mangling.cpp -o name_mangling.o # компиляция без линковки
$ nm name_mangling.o # просмотр таблицы символов объектного файла
000000000000004b T main
0000000000000038 T _Z3foxic
0000000000000028 T _Z3tuxi
0000000000000000 T _Z5hellov
000000000000000b T _Z5linusi
0000000000000019 T _Z8torvaldsv

Компиляция без линковки (создание объектного файла)

$ g++ -c name_mangling.cpp -o name_mangling.o

Флаг -c указывает компилятору на необходимость выполнить только компиляцию (compile), но не линковку (link). Результат - объектный файл .o (object file), который содержит:

  1. Машинный код функций в секции .text
  2. Таблицу символов с именами функций и их адресами
  3. Релокации - метки мест, куда нужно подставлять адреса при линковке
  4. Метаданные для отладчика (если компилировали с опцией -g)

Объектный файл не является исполняемым. Это промежуточное представление перед финальной сборкой программы.

Просмотр таблицы символов (nm - name list)

$ nm name_mangling.o
000000000000004b T main
0000000000000038 T _Z3foxic
0000000000000028 T _Z3tuxi
0000000000000000 T _Z5hellov
000000000000000b T _Z5linusi
0000000000000019 T _Z8torvaldsv

Анализ адресов

Адрес  Символ           Функция исходника
0x00   _Z5hellov        void hello() {}
0x0b   _Z5linusi        void linus(int arg) {}
0x19   _Z8torvaldsv     int torvalds() { return 0; }
0x28   _Z3tuxi          int tux(int arg) { return arg; }
0x38   _Z3foxic         void fox(int arg1, char arg2) {}
0x4b   main             int main() { ... }

Расчёт размеров функций

(Размер текущей функции) = (адрес следующей функции) - (адрес текущей функции):

ФункцияНачалоКонецРазмер (байт)Размер (hex)
_Z5hellov0x000x0b110x0b
_Z5linusi0x0b0x19140x0e
_Z8torvaldsv0x190x28150x0f
_Z3tuxi0x280x38160x10
_Z3foxic0x380x4b190x13
main0x4b---------

Для последней функции (main) размер можно узнать через:

$ nm -S name_mangling.o | grep main
000000000000004b 000000000000003c T main

Флаг -S добавляет размер: 0x3c = 60 байт.

Как выполнять деманглирование для повышения уровня читаемости?

Деманглированное представление таблицы символов ускоряет процесс понимания разработчиком сигнатур функций, используемых в объектном файле.

$ nm -C name_mangling.o
000000000000004b T main
0000000000000038 T fox(int, char)
0000000000000028 T tux(int)
0000000000000000 T hello()
000000000000000b T linus(int)
0000000000000019 T torvalds()

То же самое, но с использованием c++filt (утилиты деманглирования, то есть утилиты преобразования манглированных C++ имён в человекочитаемый вид):

$ nm name_mangling.o | c++filt
000000000000004b T main
0000000000000038 T fox(int, char)
0000000000000028 T tux(int)
0000000000000000 T hello()
000000000000000b T linus(int)
0000000000000019 T torvalds()

Также в вызове c++filt можно сразу же вручную указать (для выполнения деманглирования) интересующее нас манглированное имя, не привязывая вызов утилиты к конкретному объектному файлу:

$ c++filt _Z3foxic
fox(int, char)

Почему размеры функций различаются?

Размер машинного кода функции зависит от:

  1. Количества инструкций (больше логики = больше байт)
  2. Сохранения параметров на стек (mov [rbp-4], edi и т.д.)
  3. Пролога/эпилога (push rbp, mov rbp, rsp, pop rbp, ret)
  4. Выравнивания (компилятор может добавить nop для кратности 16 байт)

Для выполнения подробного анализа, из чего складывается размер функций в объектном файле, воспользуемся утилитой просмотра машинного кода objdump.

Просмотр машинного кода при помощи утилиты objdump

Вызов утилиты objdump для вывода реального ассемблерного кода внутри .o файла:

$ objdump -d --disassembler-options=intel name_mangling.o
 
name_mangling.o:     file format elf64-x86-64
 
 
Disassembly of section .text:
 
0000000000000000 <_Z5hellov>:
   0:	f3 0f 1e fa          	endbr64
   4:	55                   	push   rbp
   5:	48 89 e5             	mov    rbp,rsp
   8:	90                   	nop
   9:	5d                   	pop    rbp
   a:	c3                   	ret
 
000000000000000b <_Z5linusi>:
   b:	f3 0f 1e fa          	endbr64
   f:	55                   	push   rbp
  10:	48 89 e5             	mov    rbp,rsp
  13:	89 7d fc             	mov    DWORD PTR [rbp-0x4],edi
  16:	90                   	nop
  17:	5d                   	pop    rbp
  18:	c3                   	ret
 
0000000000000019 <_Z8torvaldsv>:
  19:	f3 0f 1e fa          	endbr64
  1d:	55                   	push   rbp
  1e:	48 89 e5             	mov    rbp,rsp
  21:	b8 00 00 00 00       	mov    eax,0x0
  26:	5d                   	pop    rbp
  27:	c3                   	ret
 
0000000000000028 <_Z3tuxi>:
  28:	f3 0f 1e fa          	endbr64
  2c:	55                   	push   rbp
  2d:	48 89 e5             	mov    rbp,rsp
  30:	89 7d fc             	mov    DWORD PTR [rbp-0x4],edi
  33:	8b 45 fc             	mov    eax,DWORD PTR [rbp-0x4]
  36:	5d                   	pop    rbp
  37:	c3                   	ret
 
0000000000000038 <_Z3foxic>:
  38:	f3 0f 1e fa          	endbr64
  3c:	55                   	push   rbp
  3d:	48 89 e5             	mov    rbp,rsp
  40:	89 7d fc             	mov    DWORD PTR [rbp-0x4],edi
  43:	89 f0                	mov    eax,esi
  45:	88 45 f8             	mov    BYTE PTR [rbp-0x8],al
  48:	90                   	nop
  49:	5d                   	pop    rbp
  4a:	c3                   	ret
 
000000000000004b <main>:
  4b:	f3 0f 1e fa          	endbr64
  4f:	55                   	push   rbp
  50:	48 89 e5             	mov    rbp,rsp
  53:	e8 00 00 00 00       	call   58 <main+0xd>
  58:	bf 01 00 00 00       	mov    edi,0x1
  5d:	e8 00 00 00 00       	call   62 <main+0x17>
  62:	e8 00 00 00 00       	call   67 <main+0x1c>
  67:	bf 02 00 00 00       	mov    edi,0x2
  6c:	e8 00 00 00 00       	call   71 <main+0x26>
  71:	be 04 00 00 00       	mov    esi,0x4
  76:	bf 03 00 00 00       	mov    edi,0x3
  7b:	e8 00 00 00 00       	call   80 <main+0x35>
  80:	b8 00 00 00 00       	mov    eax,0x0
  85:	5d                   	pop    rbp
  86:	c3                   	ret

Анализ размеров функций по результатам objdump

Сравнение размеров из nm и реального кода

Вспомним таблицу размеров из nm:

ФункцияАдрес началаАдрес концаРазмер (hex, байт)Размер (dec, байт)
_Z5hellov0x000x0b0x0b11
_Z5linusi0x0b0x190x0e14
_Z8torvaldsv0x190x280x0f15
_Z3tuxi0x280x380x1016
_Z3foxic0x380x4b0x1319
main0x4b0x870x3c60

Теперь разберём реальный машинный код каждой функции из вывода objdump.

_Z5hellov - void hello() {}

Содержимое объектного кода

0000000000000000 <_Z5hellov>:
   0:	f3 0f 1e fa          	endbr64        # 4 байта
   4:	55                   	push   rbp     # 1 байт
   5:	48 89 e5             	mov    rbp,rsp # 3 байта
   8:	90                   	nop            # 1 байт
   9:	5d                   	pop    rbp     # 1 байт
   a:	c3                   	ret            # 1 байт
   
# Следующая функция начинается с адреса 0x0b

Подсчёт размера функции

  • endbr64: 4 байта (адреса 0x0–0x3)
  • push rbp: 1 байт (адрес 0x4)
  • mov rbp, rsp: 3 байта (адреса 0x5–0x7)
  • nop: 1 байт (адрес 0x8)
  • pop rbp: 1 байт (адрес 0x9)
  • ret: 1 байт (адрес 0xa)

Итого: 11 байт (от 0x0 до 0xa включительно, следующая функция с 0xb)

Объяснение инструкций

  • endbr64 - инструкция End Branch 64-bit, принадлежащая аппаратной технологии безопасности Indirect Branch Tracking (IBT), предназначенной для защиты от атак ROP (Return-Oriented Programming) и JOP (Jump-Oriented Programming). Добавляется компилятором автоматически при включённом флаге -fcf-protection. Это часть Intel CET (Control-flow Enforcement Technology), поддерживаемой современными процессорами Intel и AMD. Инструкция размещается в начале каждой функции в качестве защитного элемента и усложняет злоумышленникам задачу использования существующего кода программы не по назначению. В атаках ROP/JOP злоумышленник не добавляет свой код, а переиспользует короткие фрагменты легитимных инструкций (gadget’ы) в неправильной последовательности. Обычно такая подмена выполняется за счёт уязвимостей (например, переполнения буфера), приводящих к повреждению стека, что позволяет перезаписывать адреса возврата и указатели на функции. endbr64 выступает маркером легального места входа в функцию. При косвенном переходе (через call, jmp или ret) процессор проверяет, что по целевому адресу первой инструкцией идёт именно endbr64. Если она отсутствует, выполнение прерывается с исключением. Это не гарантирует безопасность самой функции - злоумышленник может перенаправить выполнение на легитимную, но опасную функцию, которая тоже содержит endbr64. Однако инструкция резко сокращает количество доступных точек входа: злоумышленник больше не может произвольно прыгать в середину функций или между инструкциями, где расположены полезные для атаки gadget’ы. Таким образом, инструкция endbr64 не гарантирует полную защиту: IBT не останавливает инъекцию исполняемого кода и эффективна только в комбинации с другими технологиями (DEP/NX, ASLR, Shadow Stack). Однако она значительно усложняет атаку, сокращая число возможных точек входа с миллионов произвольных адресов до ограниченного набора начал функций.
  • nop - инструкция “холостого хода” (nop - no operation). Инструкция nop в данном случае нужна не для выравнивания, а как маркер конца тела функции (перед эпилогом). Подобный артефакт кодогенерации GNU-компилятором на уровне -O0 (без оптимизации) всегда добавляется в конце тела любой функции типа void (то есть не возвращающей значение), независимо от того, пустое тело или нет.
endbr64        # защита IBT
push rbp       # \
mov rbp, rsp   # / пролог (сохранение фрейм-указателя)
nop            # ← тело функции (пустое!)
pop rbp        # \
ret            # / эпилог (восстановление и возврат)

_Z5linusi - void linus(int arg) {}

Содержимое объектного кода

000000000000000b <_Z5linusi>:
   b:	f3 0f 1e fa          	endbr64                        # 4 байта
   f:	55                   	push   rbp                     # 1 байт
  10:	48 89 e5             	mov    rbp,rsp                 # 3 байта
  13:	89 7d fc             	mov    DWORD PTR [rbp-0x4],edi # 3 байта
  16:	90                   	nop                            # 1 байт
  17:	5d                   	pop    rbp                     # 1 байт
  18:	c3                   	ret                            # 1 байт
 
# Следующая функция начинается с адреса 0x19

Подсчёт размера функции

  • endbr64: 4 байта (0xb–0xe)
  • push rbp: 1 байт (0xf)
  • mov rbp, rsp: 3 байта (0x10–0x12)
  • mov [rbp-0x4], edi: 3 байта (0x13–0x15)
  • nop: 1 байт (0x16)
  • pop rbp: 1 байт (0x17)
  • ret: 1 байт (0x18)

Итого: 14 байт (от 0xb до 0x18 включительно)

Объяснение инструкций

  • mov DWORD PTR [rbp-0x4], edi - сохранение параметра int arg из сигнатуры функции на стек (параметр на момент вызова подпрограммы _Z5linusi расположен в edi).
  • nop - в данном случае эта инструкция служит маркером конца тела функции на адресе 0x16 (перед эпилогом). Подобный артефакт кодогенерации GNU-компилятором на уровне -O0 (без оптимизации) всегда добавляется в конце тела любой функции типа void (то есть не возвращающей значение), независимо от того, пустое тело или нет.

_Z8torvaldsv - int torvalds() { return 0; }

Содержимое объектного кода

0000000000000019 <_Z8torvaldsv>:
  19:	f3 0f 1e fa          	endbr64            # 4 байта
  1d:	55                   	push   rbp         # 1 байт
  1e:	48 89 e5             	mov    rbp,rsp     # 3 байта
  21:	b8 00 00 00 00       	mov    eax,0x0     # 5 байт
  26:	5d                   	pop    rbp         # 1 байт
  27:	c3                   	ret                # 1 байт
 
# Следующая функция начинается с адреса 0x28

Подсчёт размера функции

  • endbr64: 4 байта (0x19–0x1c)
  • push rbp: 1 байт (0x1d)
  • mov rbp, rsp: 3 байта (0x1e–0x20)
  • mov eax, 0x0: 5 байт (0x21–0x25)
  • pop rbp: 1 байт (0x26)
  • ret: 1 байт (0x27)

Итого: 15 байт (от 0x19 до 0x27 включительно)

Объяснение инструкций

  • Обратите внимание: mov eax, 0x0 занимает 5 байт, хотя обнуление через XOR (xor eax, eax) заняло бы всего 2 байта. Это следствие компиляции без оптимизации (-O0). При -O2 компилятор заменит инструкцию mov на xor.
  • nop отсутствует, поскольку функция torvalds() возвращает значение типа int. Инструкция mov eax, 0x0 явно завершает тело и служит естественной границей перед эпилогом. Поэтому дополнительный маркер nop не требуется.

_Z3tuxi - int tux(int arg) { return arg; }

Содержимое объектного кода

0000000000000028 <_Z3tuxi>:
  28:	f3 0f 1e fa          	endbr64                        # 4 байта
  2c:	55                   	push   rbp                     # 1 байт
  2d:	48 89 e5             	mov    rbp,rsp                 # 3 байта
  30:	89 7d fc             	mov    DWORD PTR [rbp-0x4],edi # 3 байта
  33:	8b 45 fc             	mov    eax,DWORD PTR [rbp-0x4] # 3 байта
  36:	5d                   	pop    rbp                     # 1 байт
  37:	c3                   	ret                            # 1 байт
 
# Следующая функция начинается с адреса 0x38

Подсчёт размера функции

  • endbr64: 4 байта (0x28–0x2b)
  • push rbp: 1 байт (0x2c)
  • mov rbp, rsp: 3 байта (0x2d–0x2f)
  • mov [rbp-0x4], edi: 3 байта (0x30–0x32) - сохранить arg на стек
  • mov eax, [rbp-0x4]: 3 байта (0x33–0x35) - прочитать arg обратно в eax для возврата
  • pop rbp: 1 байт (0x36)
  • ret: 1 байт (0x37)

Итого: 16 байт (от 0x28 до 0x37 включительно)

Объяснение инструкций

Функция сначала сохраняет параметр на стек, затем читает его из стека. Это выглядит избыточно (можно было просто вызвать mov eax, edi), но так работает уровень без оптимизации -O0 - буквальное следование исходному коду

_Z3foxic - void fox(int arg1, char arg2) {}

Содержимое объектного кода

0000000000000038 <_Z3foxic>:
  38:	f3 0f 1e fa          	endbr64                        # 4 байта
  3c:	55                   	push   rbp                     # 1 байт
  3d:	48 89 e5             	mov    rbp,rsp                 # 3 байта
  40:	89 7d fc             	mov    DWORD PTR [rbp-0x4],edi # 3 байта
  43:	89 f0                	mov    eax,esi                 # 2 байта
  45:	88 45 f8             	mov    BYTE PTR [rbp-0x8],al   # 3 байта
  48:	90                   	nop                            # 1 байт
  49:	5d                   	pop    rbp                     # 1 байт
  4a:	c3                   	ret                            # 1 байт
 
# Следующая функция (main) начинается с адреса с 0x4b

Подсчёт размера функции

  • endbr64: 4 байта (0x38–0x3b)
  • push rbp: 1 байт (0x3c)
  • mov rbp, rsp: 3 байта (0x3d–0x3f)
  • mov [rbp-0x4], edi: 3 байта (0x40–0x42) - сохранить int arg1
  • mov eax, esi: 2 байта (0x43–0x44) - скопировать char arg2 из esi в eax
  • mov [rbp-0x8], al: 3 байта (0x45–0x47) - сохранить младший байт al (это и есть char)
  • nop: 1 байт (0x48)
  • pop rbp: 1 байт (0x49)
  • ret: 1 байт (0x4a)

Итого: 19 байт (от 0x38 до 0x4a включительно)

Объяснение инструкций

По ABI, второй аргумент передаётся через esi (32 бита), даже если это char (1 байт). Компилятор выполняет следующие действия:

  1. Копирует весь esieax (промежуточный регистр).
  2. Извлекает младший байт al (8 бит) и сохраняет на стек.

Это можно было сделать напрямую через инструкцию movzx (MOV with Zero-eXtend, которая копирует значение меньшего размера в регистр большего размера с заполнением старших битов нулями), или через использование sil (младший байт rsi), но уровень -O0 (без оптимизации) делает буквальные операции.

nop на адресе 0x48 появляется по той же причине, что и в предыдущих примерах - это маркер конца тела функции типа void при компиляции с опцией -O0 (без оптимизации).

main - полная функция

Содержимое объектного кода

000000000000004b <main>:
  4b:	f3 0f 1e fa          	endbr64               # 4 байта
  4f:	55                   	push   rbp            # 1 байт
  50:	48 89 e5             	mov    rbp,rsp        # 3 байта
  53:	e8 00 00 00 00       	call   58 <main+0xd>  # 5 байт (hello)
  58:	bf 01 00 00 00       	mov    edi,0x1        # 5 байт
  5d:	e8 00 00 00 00       	call   62 <main+0x17> # 5 байт (linus)
  62:	e8 00 00 00 00       	call   67 <main+0x1c> # 5 байт (torvalds)
  67:	bf 02 00 00 00       	mov    edi,0x2        # 5 байт
  6c:	e8 00 00 00 00       	call   71 <main+0x26> # 5 байт (tux)
  71:	be 04 00 00 00       	mov    esi,0x4        # 5 байт
  76:	bf 03 00 00 00       	mov    edi,0x3        # 5 байт
  7b:	e8 00 00 00 00       	call   80 <main+0x35> # 5 байт (fox)
  80:	b8 00 00 00 00       	mov    eax,0x0        # 5 байт
  85:	5d                   	pop    rbp            # 1 байт
  86:	c3                   	ret                   # 1 байт
  
  # Всего 60 байт

Объяснение инструкций

Здесь наибольший интерес представляют инструкции call. Рассмотрим для примера инструкцию вызова функции torvalds:

62:	e8 00 00 00 00       	call   67 <main+0x1c> # 5 байт (torvalds)
67:	bf 02 00 00 00       	mov    edi,0x2        # 5 байт

Структура инструкции call (байтовое представление):

Адрес: 0x62
Байты: e8 00 00 00 00
       ^^ ^^^^^^^^^^^
       │  └─ операнд (4 байта)
       └─ опкод

e8 - код операции для инструкции call с относительным 32-битным смещением.

00 00 00 00 - плейсхолдер для относительного смещения, которое будет вычислено линковщиком. На этапе компиляции (создания объектного файла) относительное смещение до функции torvalds ещё не известно, так как её финальный адрес в памяти будет определён только при линковке. Поэтому компилятор оставляет плейсхолдер 00 00 00 00, который линковщик заменит на реальное смещение.

В аннотации objdump для инструкции call показывается адрес возврата - адрес следующей инструкции, куда вернётся выполнение после завершения вызова.

Что означает 67 <main+0x1c>?

0x67 - это адрес следующей инструкции после call, то есть адрес возврата.

Адрес  Байт          Описание
-----  -----------   ----------------------------------
0x62:  e8            опкод call
0x63:  00            байт 0 смещения (младший)
0x64:  00            байт 1 смещения
0x65:  00            байт 2 смещения
0x66:  00            байт 3 смещения (старший)
0x67:  bf            начало следующей инструкции (mov)

Адрес 0x67 = 0x62 (адрес call) + 0x5 (размер инструкции)

<main+0x1c> - это символическое смещение относительно начала функции main.

main начинается с адреса: 0x4b
текущий адрес возврата:   0x67

смещение = 0x67 - 0x4b = 0x1c

Читается как: “адрес возврата находится на смещении 0x1c (28 байт) от начала main

objdump показывает оба варианта, чтобы было понятно:

  1. Куда вернётся выполнение (абсолютный адрес 0x67)
  2. Где это относительно функции (смещение +0x1c от начала main)
call   67 <main+0x1c>
       │  └─────────┘
       │       │
       │       └─ символическое представление:
       │          "начало main + 28 байт"
       │
       └─ абсолютный адрес возврата: 0x67


ВПЕРЁД

НАЗАД