Почему ошибка сегментации не возникает с меньшей границей стека? - PullRequest
1 голос
/ 12 июля 2020

Я пытаюсь понять разницу в поведении кода, скомпилированного с опцией G CC -mpreferred-stack-boundary=2, и значением по умолчанию -mpreferred-stack-boundary=4.

Я уже прочитал много Q / A об этой опции, но я не могу понять случай, который описан ниже.

Давайте рассмотрим этот код:

#include <stdio.h>
#include <string.h>

void dumb_function() {}

int main(int argc, char** argv) {
    dumb_function();

    char buffer[24];
    strcpy(buffer, argv[1]);

    return 0;
}

На моих 64 битах архитектуры, я хочу скомпилировать ее для 32-битной версии, поэтому я буду использовать параметр -m32. Итак, я создаю два двоичных файла, один с -mpreferred-stack-boundary=2, один со значением по умолчанию:

sysctl -w kernel.randomize_va_space=0
gcc -m32 -g3 -fno-stack-protector -z execstack -o default vuln.c
gcc -mpreferred-stack-boundary=2 -m32 -g3 -fno-stack-protector -z execstack -o align_2 vuln.c

Теперь, если я выполню их с переполнением двух байтов, у меня будет ошибка сегментации для выравнивания по умолчанию, но не в другом случае:

$ ./default 1234567890123456789012345
Segmentation fault (core dumped)
$ ./align_2 1234567890123456789012345
$

Я пытаюсь покопаться, почему такое поведение с default. Вот дизассемблер основной функции:

08048411 <main>:
 8048411:   8d 4c 24 04             lea    0x4(%esp),%ecx
 8048415:   83 e4 f0                and    $0xfffffff0,%esp
 8048418:   ff 71 fc                pushl  -0x4(%ecx)
 804841b:   55                      push   %ebp
 804841c:   89 e5                   mov    %esp,%ebp
 804841e:   53                      push   %ebx
 804841f:   51                      push   %ecx
 8048420:   83 ec 20                sub    $0x20,%esp
 8048423:   89 cb                   mov    %ecx,%ebx
 8048425:   e8 e1 ff ff ff          call   804840b <dumb_function>
 804842a:   8b 43 04                mov    0x4(%ebx),%eax
 804842d:   83 c0 04                add    $0x4,%eax
 8048430:   8b 00                   mov    (%eax),%eax
 8048432:   83 ec 08                sub    $0x8,%esp
 8048435:   50                      push   %eax
 8048436:   8d 45 e0                lea    -0x20(%ebp),%eax
 8048439:   50                      push   %eax
 804843a:   e8 a1 fe ff ff          call   80482e0 <strcpy@plt>
 804843f:   83 c4 10                add    $0x10,%esp
 8048442:   b8 00 00 00 00          mov    $0x0,%eax
 8048447:   8d 65 f8                lea    -0x8(%ebp),%esp
 804844a:   59                      pop    %ecx
 804844b:   5b                      pop    %ebx
 804844c:   5d                      pop    %ebp
 804844d:   8d 61 fc                lea    -0x4(%ecx),%esp
 8048450:   c3                      ret    
 8048451:   66 90                   xchg   %ax,%ax
 8048453:   66 90                   xchg   %ax,%ax
 8048455:   66 90                   xchg   %ax,%ax
 8048457:   66 90                   xchg   %ax,%ax
 8048459:   66 90                   xchg   %ax,%ax
 804845b:   66 90                   xchg   %ax,%ax
 804845d:   66 90                   xchg   %ax,%ax
 804845f:   90                      nop

Благодаря инструкции sub $0x20,%esp мы можем узнать, что компилятор выделяет 32 байта для стека, который согласован как вариант -mpreferred-stack-boundary=4: 32 - кратное из 16.

Первый вопрос: почему, если у меня есть стек из 32 байтов (24 байта для буфера и остальной мусор), я получаю ошибку сегментации с переполнением всего одного байта?

Давайте посмотрим, что происходит с gdb:

$ gdb default
(gdb) b 10
Breakpoint 1 at 0x804842a: file vuln.c, line 10.

(gdb) b 12
Breakpoint 2 at 0x8048442: file vuln.c, line 12.

(gdb) r 1234567890123456789012345
Starting program: /home/pierre/example/default 1234567890123456789012345

Breakpoint 1, main (argc=2, argv=0xffffce94) at vuln.c:10
10      strcpy(buffer, argv[1]);

(gdb) i f
Stack level 0, frame at 0xffffce00:
 eip = 0x804842a in main (vuln.c:10); saved eip = 0xf7e07647
 source language c.
 Arglist at 0xffffcde8, args: argc=2, argv=0xffffce94
 Locals at 0xffffcde8, Previous frame's sp is 0xffffce00
 Saved registers:
  ebx at 0xffffcde4, ebp at 0xffffcde8, eip at 0xffffcdfc

(gdb) x/6x buffer
0xffffcdc8: 0xf7e1da60  0x080484ab  0x00000002  0xffffce94
0xffffcdd8: 0xffffcea0  0x08048481

(gdb) x/x buffer+36
0xffffcdec: 0xf7e07647

Непосредственно перед вызовом strcpy мы видим, что сохраненный eip равен 0xf7e07647. Мы можем найти эту информацию по адресу буфера (32 байта для стека стека + 4 байта для esp = 36 байтов).

Давайте продолжим:

(gdb) c
Continuing.

Breakpoint 2, main (argc=0, argv=0x0) at vuln.c:12
12      return 0;

(gdb) i f
Stack level 0, frame at 0xffff0035:
 eip = 0x8048442 in main (vuln.c:12); saved eip = 0x0
 source language c.
 Arglist at 0xffffcde8, args: argc=0, argv=0x0
 Locals at 0xffffcde8, Previous frame's sp is 0xffff0035
 Saved registers:
  ebx at 0xffffcde4, ebp at 0xffffcde8, eip at 0xffff0031

(gdb) x/7x buffer
0xffffcdc8: 0x34333231  0x38373635  0x32313039  0x36353433
0xffffcdd8: 0x30393837  0x34333231  0xffff0035

(gdb) x/x buffer+36
0xffffcdec: 0xf7e07647

Мы видим переполнение следующими байтами после буфера: 0xffff0035. Кроме того, там, где хранится eip, ничего не изменилось: 0xffffcdec: 0xf7e07647, потому что переполнение составляет всего два байта. Однако сохраненный eip, указанный в info frame, изменился: saved eip = 0x0, и если я продолжу, произойдет ошибка сегментации:

(gdb) c
Continuing.

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

Что происходит? Почему мой сохраненный eip изменился, когда переполнение составляет всего два байта?

Теперь давайте сравним это с двоичным файлом, скомпилированным с другим выравниванием:

$ objdump -d align_2
...
08048411 <main>:
...
 8048414:   83 ec 18                sub    $0x18,%esp
...

Размер стека составляет ровно 24 байта. Это означает, что переполнение в 2 байта переопределит esp (но все же не eip). Давайте проверим это с помощью gdb:

(gdb) b 10
Breakpoint 1 at 0x804841c: file vuln.c, line 10.

(gdb) b 12
Breakpoint 2 at 0x8048431: file vuln.c, line 12.

(gdb) r 1234567890123456789012345
Starting program: /home/pierre/example/align_2 1234567890123456789012345

Breakpoint 1, main (argc=2, argv=0xffffce94) at vuln.c:10
10      strcpy(buffer, argv[1]);

(gdb) i f
Stack level 0, frame at 0xffffce00:
 eip = 0x804841c in main (vuln.c:10); saved eip = 0xf7e07647
 source language c.
 Arglist at 0xffffcdf8, args: argc=2, argv=0xffffce94
 Locals at 0xffffcdf8, Previous frame's sp is 0xffffce00
 Saved registers:
  ebp at 0xffffcdf8, eip at 0xffffcdfc

(gdb) x/6x buffer
0xffffcde0: 0xf7fa23dc  0x080481fc  0x08048449  0x00000000
0xffffcdf0: 0xf7fa2000  0xf7fa2000

(gdb) x/x buffer+28
0xffffcdfc: 0xf7e07647

(gdb) c
Continuing.

Breakpoint 2, main (argc=2, argv=0xffffce94) at vuln.c:12
12      return 0;

(gdb) i f
Stack level 0, frame at 0xffffce00:
 eip = 0x8048431 in main (vuln.c:12); saved eip = 0xf7e07647
 source language c.
 Arglist at 0xffffcdf8, args: argc=2, argv=0xffffce94
 Locals at 0xffffcdf8, Previous frame's sp is 0xffffce00
 Saved registers:
  ebp at 0xffffcdf8, eip at 0xffffcdfc

(gdb) x/7x buffer
0xffffcde0: 0x34333231  0x38373635  0x32313039  0x36353433
0xffffcdf0: 0x30393837  0x34333231  0x00000035

(gdb) x/x buffer+28
0xffffcdfc: 0xf7e07647

(gdb) c
Continuing.
[Inferior 1 (process 6118) exited normally]

Как и ожидалось, здесь нет ошибки сегментации, потому что я не переопределяю eip.

Я не понимаю этой разницы в поведении. В обоих случаях eip не отменяется. Единственное отличие - размер стопки. Что происходит?

Дополнительная информация:

  • Это поведение не происходит, если dumb_function отсутствует
  • Я использую следующую версию G CC:
$ gcc -v
gcc version 5.4.0 20160609 (Ubuntu 5.4.0-6ubuntu1~16.04.12)
  • Некоторая информация о моей системе:
$ uname -a
Linux pierre-Inspiron-5567 4.15.0-107-generic #108~16.04.1-Ubuntu SMP Fri Jun 12 02:57:13 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux

1 Ответ

3 голосов
/ 13 июля 2020

Вы не перезаписываете сохраненный eip, это правда. Но вы перезаписываете указатель, который функция использует для поиска сохраненного eip. Вы действительно можете увидеть это в вашем i f выводе; посмотрите на «SP предыдущего кадра» и обратите внимание на то, что два младших байта имеют размер 00 35; ASCII 0x35 - это 5, а 00 - завершающий ноль. Таким образом, хотя сохраненный eip совершенно не поврежден, машина получает свой адрес возврата откуда-то еще, таким образом, cra sh.

Более подробно:

G CC по-видимому, не доверяет коду запуска для выравнивания стека до 16 байт, поэтому он берет дело в свои руки (and $0xfffffff0,%esp). Но ему необходимо отслеживать предыдущее значение указателя стека, чтобы он мог найти его параметры и адрес возврата при необходимости. Это lea 0x4(%esp),%ecx, который загружает ecx с адресом двойного слова, выше сохраненного eip в стеке. gdb называет этот адрес «sp предыдущего кадра», я полагаю, потому что это было значение указателя стека сразу до вызывающая сторона выполнила свою команду call main. Я для краткости назову его P.

После выравнивания стека компилятор выталкивает -0x4(%ecx), который является параметром argv из стека, для облегчения доступа, поскольку он понадобится ему позже. Затем он устанавливает свой стековый фрейм с push %ebp; mov %esp, %ebp. С этого момента мы можем отслеживать все адреса относительно %ebp, как обычно делают компиляторы, когда не оптимизируют.

push %ecx парой строк вниз сохраняет адрес P в стеке со смещением -0x8(%ebp). sub $0x20, %esp делает еще 32 байта пространства в стеке (заканчивающимся на -0x28(%ebp)), но вопрос в том, где в этом пространстве помещается buffer? Мы видим, что это происходит после звонка на dumb_function с lea -0x20(%ebp), %eax; push %eax; это первый аргумент для проталкивания strcpy, то есть buffer, поэтому на самом деле buffer находится в -0x20(%ebp), а не в -0x28, как вы могли догадаться. Поэтому, когда вы записываете туда 24 (= 0x18) байта, вы перезаписываете два байта в -0x8(%ebp), который является нашим сохраненным указателем P.

Отсюда все идет под гору. Поврежденное значение P (назовем его Px) вставляется в ecx, и непосредственно перед возвратом мы делаем lea -0x4(%ecx), %esp. Теперь %esp - это мусор и указывает на что-то плохое, поэтому следующий ret обязательно приведет к проблемам. Возможно, Px указывает на неотображенную память, и простая попытка получить оттуда адрес возврата вызывает ошибку. Возможно, он указывает на читаемую память, но адрес, полученный из этого места, не указывает на исполняемую память, поэтому передача управления не выполняется. Возможно, последний указывает на исполняемую память, но инструкции, расположенные там, не те, которые мы хотим выполнять.

Если вы откроете вызов dumb_function(), макет стека немного изменится. Больше нет необходимости использовать pu sh ebx вокруг вызова dumb_function(), поэтому указатель P из ecx теперь заканчивается на -4(%ebp), остается 4 байта неиспользуемого пространства (для поддержания выравнивания), а затем buffer находится на -0x20(%ebp). Таким образом, ваше двухбайтовое переполнение переходит в пространство, которое вообще не используется, следовательно, никакого cra sh.

И здесь - это сгенерированная сборка с -mpreferred-stack-boundary=2. Теперь нет необходимости повторно выравнивать стек, потому что компилятор доверяет стартовому коду для выравнивания стека по крайней мере до 4 байтов (было бы немыслимо, чтобы это было не так). Макет стека проще: pu sh ebp, и вычесть еще 24 байта для buffer. Таким образом, ваше переполнение перезаписывает два байта сохраненного EBP. В конечном итоге он выталкивается из стека обратно в ebp, и поэтому main возвращается вызывающей стороне со значением в ebp, которое отличается от значения при входе. Это непослушно, но так случается, что код запуска системы ни для чего не использует значение в ebp (действительно, в моих тестах оно установлено на 0 при входе в main, вероятно, для отметки вершины стека для трассировки) и так что ничего страшного потом не случится.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...