вторник, 9 декабря 2008 г.

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

Это перевод How can a program survive a corrupted stack? Автор: Реймонд Чен.

Продолжение вчерашнего разговора:
Архитектура x86 традиционно использует регистр EBP для установки стекового фрейма. Типичный пролог функции выглядит вот так:
  push ebp       ; сохранить старый ebp
  mov  ebp, esp  ; установить новый ebp
  sub  esp, nn*4 ; выделить место под локальные переменные
  push ebx       ; надо сохранить для вызывающего
  push esi       ; надо сохранить для вызывающего
  push edi       ; надо сохранить для вызывающего
Этот код создаёт новый стековый фрейм, которой для stdcall-функции с двумя параметрами выглядит вот так:
.. другие данные ..
параметр_2
параметр_1
адрес_возврата
сохранённый EBP           <- EBP
локальная_переменная_1
локальная_переменная_2
...
локальная_переменная_nn
сохранённый EBX
сохранённый ESI
сохранённый EDI           <- ESP
К параметрам функции можно получить доступ, используя положительные смещения от EBP; например, параметр_1 лежит по адресу [ebp+8]. Локальные переменные имеют отрицательное смещение от EBP; например, локальная_переменная_2 - это [ebp-8].

Теперь предположим, что произошла путаница соглашений вызова, так что в стеке остаётся лишний мусор:
.. другие данные ..
параметр_2
параметр_1
адрес_возврата
сохранённый EBP           <- EBP
локальная_переменная_1
локальная_переменная_2
...
локальная_переменная_nn
сохранённый EBX
сохранённый ESI
сохранённый EDI
мусор
мусор                     <- ESP
Сама функция не ощущает никаких изменений. Параметры всё так же доступны по тем же положительным смещениям от EBP, а локальные переменные всё ещё доступны по тем же отрицательным смещениям.

Настоящие проблемы начнут возникать, когда придёт время очистки и выхода. Посмотрим на эпилог функции:
  pop  edi       ; восстановим для вызывающего
  pop  esi       ; восстановим для вызывающего
  pop  ebx       ; восстановим для вызывающего
  mov  esp, ebp  ; удаляем локальные переменные
  pop  ebp       ; восстановим для вызывающего
  retd 8         ; возврат из функции с очисткой стека
В нормальном стеке три инструкции "pop" соответствуют данным, размещённым в стеке тремя последними инструкциями "push" и никто при этом не пострадает. Но сейчас у нас в стеке лежит мусор, поэтому "pop edi" на самом деле загрузит в регистр EDI какой-то мусор, так же, как и "pop esi". А инструкция "pop ebx" (которая считает, что она восстанавливает значение EBX) на самом деле загрузит исходное значение, которое было в EDI в регистр EBX. Но потом инструкция "mov esp, ebp" исправит стек, поэтому команды "pop ebp" и "retd" выполнятся нормально - так же, как и при корректном стеке.

Что сейчас у нас произошло? Кажется, что все вещи вернулись на свои места. Ну за исключением того, что регистры ESI, EDI и EBX оказались испорченными. Если вам повезло (прим. пер.: или не повезло - это ещё как посмотреть), то значения в ESI, EDI и EBX не были важны вызывающему, и выполнение продолжится как ни в чём не бывало. Или вызывающему было важно только, не ноль ли значение регистра, а вы заменили одно не нулевое значение на другое. В любом случае, порча этих трёх регистров не становится видной сразу, и вы никогда не осознаете, что же вы сделали не так.

Может быть, повреждение регистров окажет побочный эффект (к примеру, вы изменили значение с нуля на не ноль, что привело к тому, что вызывающий пошёл не по тому пути выполнения), но настолько смазанный, что вы его не заметите, поэтому вы выбрасываете программу на рынок, закатываете вечеринку и переходите к следующему проекту.

А потом выходит новый компилятор, например такой, который поддерживает оптимизацию FPO.

FPO расшифровывается как "frame pointer omission" (пропуск фреймового указателя) - функция перестаёт использовать регистр EBP как указатель фрейма и вместо этого использует его, как любой другой регистр. На x86, который имеет относительно мало регистров, дополнительный арифметический регистр будет большим бонусом.

С FPO пролог функции теперь выглядит вот так:
  sub  esp, nn*4 ; локальные переменные
  push ebp       ; надо сохранить для вызывающего
  push ebx       ; надо сохранить для вызывающего
  push esi       ; надо сохранить для вызывающего
  push edi       ; надо сохранить для вызывающего
Итоговый стек выглядит вот так:
.. другие данные ..
параметр_2
параметр_1
адрес_возврата
локальная_переменная_1
локальная_переменная_2
...
локальная_переменная_nn
сохранённый EBP
сохранённый EBX
сохранённый ESI
сохранённый EDI           <- ESP
Теперь все данные (параметры и локальные переменные) доступны по смещениям относительно регистра ESP. Например, локальная_переменная_nn это [esp+$10].

В таких условиях мусор в стеке становится гораздо более фатальным. Эпилог функции будет таким:
  pop  edi       ; восстановим для вызывающего
  pop  esi       ; восстановим для вызывающего
  pop  ebx       ; восстановим для вызывающего
  pop  ebp       ; восстановим для вызывающего
  add  esp, nn*4 ; удаляем локальные переменные
  retd 8         ; возврат из функции с очисткой стека
Если в стеке есть мусор, то четыре инструкции "pop", как и ранее, восстановят неверные значения, но в этот раз очистка локальных переменных ничего не исправит. Команда "add esp, nn*4" подправит стек на значение, которое функция считает правильным, но поскольку в стеке лежит мусор, то в итоге указатель стека будет неверен:
.. другие данные ..
параметр_2
параметр_1
адрес_возврата
локальная_переменная_1
локальная_переменная_2    <- ESP (упс!)
Инструкция "retd 8" теперь попробует передать управление вызывающему, но вместо этого она перейдёт по адресу, который записан в локальная_переменная_2, который, вероятно, не будет являться указателем на корректный код.

Итак, вот вам и пример, когда оптимизация вашего кода (вызываемой функции) выявляет ошибки других людей (неверная сигнатура у вызывающего).

В следующий раз я дам более тонкий пример того, что может пойти не так, если вы используете неверную сигнатуру callback-функции.

Комментариев нет:

Отправить комментарий

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку (поддерживается OpenID).

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

Ваше сообщение может быть помечено как спам спам-фильтром - не волнуйтесь, оно появится после проверки администратором.