Pull to refresh

Привет из свободного от libc мира! (Часть 1)

Reading time5 min
Views28K
Original author: Jessica McKellar
В качестве упражнения я хочу написать программу на С. Достаточно простую для того, чтобы дизассемблировать ее и объяснить весь код самой себе.

Звучит несложно, правильно?

У читателя предполагается наличие опыта компиляции программ и работы в Линуксе. Небольшое умение читать ассемблерный код тоже пригодится.

Итак, вот наш простейший хелловорлд:

jesstess@kid-charlemagne:~/c$ cat hello.c
#include <stdio.h>

int main()
{
	printf("Hello World\n");
	return 0;
}

Скомпилируем его и посчитаем количество символов:

jesstess@kid-charlemagne:~/c$ gcc -o hello hello.c
jesstess@kid-charlemagne:~/c$ wc -c hello
10931 hello

Фигасе! Откуда берутся эти 11 килобайт? objdump -t hello показывает 79 записей в таблице идентификаторов, за большинство из которых ответственна стандартная библиотека.

Так что мы не будем ее использовать. И printf мы тоже не будем использовать, чтобы избавиться от инклюда:

jesstess@kid-charlemagne:~/c$ cat hello.c
int main()
{
	char *str = "Hello World";
	return 0;
}

Перекомпилируем и пересчитаем количество символов:

jesstess@kid-charlemagne:~/c$ gcc -o hello hello.c
jesstess@kid-charlemagne:~/c$ wc -c hello
10892 hello

Почти ничего не изменилось? Ха!

Проблема в том, что gcc все ещё использует startup files (?) во время линкования. Доказательства? Скомпилируем с ключом -nostdlib, после чего (в соответствии с документацией) gcc «не будет использовать при линковании системные библиотеки и startup files. Использоваться будут только явно переданные линкеру файлы».

jesstess@kid-charlemagne:~/c$ gcc -nostdlib -o hello hello.c
/usr/bin/ld: warning: cannot find entry symbol _start; defaulting to 00000000004000e8

Всего лишь предупреждение, все равно попробуем:

jesstess@kid-charlemagne:~/c$ wc -c hello
1329 hello

Выглядит неплохо! Мы уменьшили размер до значительно более вменяемого (аж на целый порядок!)…

jesstess@kid-charlemagne:~/c$ ./hello
Segmentation fault

…и заплатили за это сегфолтом. Блин.

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

Что же делает символ _start, который похоже нужен для запуска программы? Где он обычно определяется при использовании libc?

По умолчанию с точки зрения линкера именно _start, а не main, является настоящей точкой входа в программу. Обычно _start определяется в перемещаемом ELF crt1.o. Убедимся в этом, слинковав хелловорлд c crt1.o и заметив, что _start теперь обнаруживается (но взамен появились другие проблемы из-за того, что не определены другие startup symbols libc):

# компилируем исходники не линкуя
jesstess@kid-charlemagne:~/c$ gcc -Os -c hello.c
# теперь попытаемся слинковать
jesstess@kid-charlemagne:~/c$ ld /usr/lib/crt1.o -o hello hello.o
/usr/lib/crt1.o: In function `_start':
/build/buildd/glibc-2.9/csu/../sysdeps/x86_64/elf/start.S:106: undefined reference to `__libc_csu_fini'
/build/buildd/glibc-2.9/csu/../sysdeps/x86_64/elf/start.S:107: undefined reference to `__libc_csu_init'
/build/buildd/glibc-2.9/csu/../sysdeps/x86_64/elf/start.S:113: undefined reference to `__libc_start_main'

Проверка сообщила, что на этом компьютере_start живет в исходнике libc: sysdeps/x86_64/elf/start.S. Этот восхитительно комментированный файл экспортирует символ _start, инициализирует стек, некоторые регистры и вызывает __libc_start_main. Если посмотреть в самый низ csu/libc-start.c, можно увидеть вызов _main нашей программы:

/* Ничего особенного, просто вызвать функцию  */
result = main (argc, argv, __environ MAIN_AUXVEC_PARAM);

… и поехало.

Так вот зачем нужен _start. Для удобства подытожим происходящее между _start и вызовом main: инициализировать кучу вещей для libc и вызвать main. А раз libc нам не нужен, экспортируем собственный символ _start, который только и умеет, что вызывать main, и слинкуем с ним:

jesstess@kid-charlemagne:~/c$ cat stubstart.S
.globl _start

_start:
	call main

Скомпилируем и выполним хелловорлд с ассемблерной заглушкой _start:

jesstess@kid-charlemagne:~/c$ gcc -nostdlib stubstart.S -o hello hello.c
jesstess@kid-charlemagne:~/c$ ./hello
Segmentation fault

Ура, с компиляцией проблем больше нет. Но сегфолт никуда не делся. Почему? Скомпилируем с отладочной информацией и заглянем в gdb. Установим брейкпоинт на main и пошагово исполним программу до сегфолта:

jesstess@kid-charlemagne:~/c$ gcc -g -nostdlib stubstart.S -o hello hello.c
jesstess@kid-charlemagne:~/c$ gdb hello
GNU gdb 6.8-debian
Copyright (C) 2008 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu"...
(gdb) break main
Breakpoint 1 at 0x4000f4: file hello.c, line 3.
(gdb) run
Starting program: /home/jesstess/c/hello

Breakpoint 1, main () at hello.c:5
5	  char *str = "Hello World";
(gdb) step
6	  return 0;
(gdb) step
7	}
(gdb) step
0x00000000004000ed in _start ()
(gdb) step
Single stepping until exit from function _start,
which has no line number information.
main () at helloint.c:4
4	{
(gdb) step

Breakpoint 1, main () at helloint.c:5
5	  char *str = "Hello World";
(gdb) step
6	  return 0;
(gdb) step
7	}
(gdb) step

Program received signal SIGSEGV, Segmentation fault.
0x0000000000000001 in ?? ()
(gdb)

Что? main исполняется два раза? …Пришло время взяться за ассемблер:

jesstess@kid-charlemagne:~/c$ objdump -d hello
hello:     file format elf64-x86-64
Disassembly of section .text:

00000000004000e8 <_start>:
  4000e8:	e8 03 00 00 00       	callq  4000f0
  4000ed:	90                   	nop
  4000ee:	90                   	nop
  4000ef:	90                   	nop    

00000000004000f0 :
  4000f0:	55                   	push   %rbp
  4000f1:	48 89 e5             	mov    %rsp,%rbp
  4000f4:	48 c7 45 f8 03 01 40 	movq   $0x400103,-0x8(%rbp)
  4000fb:	00
  4000fc:	b8 00 00 00 00       	mov    $0x0,%eax
  400101:	c9                   	leaveq
  400102:	c3                   	retq

Хех! Подробный разбор ассемблера оставим на потом, отметив вкратце следующее: после возврата из callq в main мы исполняем несколько nop и возвращаемся прямо в main. Поскольку повторный вход в main был осуществлен без установки указателя инструкции возврата на стеке (как части стандартной подготовки к вызову функции), второй вызов retq пытается достать из стека фиктивный указатель инструкции возврата и программа вылетает. Нужен способ завершения.

Буквально. После возврата из callq в %eax делается push 1, код системного вызова sys_exit, и т.к. нужно сообщить о правильном завершении кладем в %ebx 0, единственный аргумент SYS_exit. Теперь входим в ядро с прерыванием int $0x80.

jesstess@kid-charlemagne:~/c$ cat stubstart.S
.globl _start

_start:
	call main
	movl $1, %eax
	xorl %ebx, %ebx
	int $0x80
jesstess@kid-charlemagne:~/c$ gcc -nostdlib stubstart.S -o hello hello.c
jesstess@kid-charlemagne:~/c$ ./hello
jesstess@kid-charlemagne:~/c$

Ура! Программа компилируется, запускается, при прогоне через gdb даже нормально завершается.

Привет из свободного от libc мира!

Оставайтесь со мной, во второй части разберем ассемблерный код подробно, посмотрим что случится, если сделать программу более сложной, и еще немного разберемся в линковании, соглашениях о вызовах и структуре двоичного ELF файл в х86 архитектуре.
Tags:
Hubs:
Total votes 138: ↑126 and ↓12+114
Comments61

Articles