|
Переполнение буфера в стеке. Часть 1 kas1e Xakep, номер #050, стр. 050-062-4 Но вернемся к стеку. Существуют некоторые понятия, определяющие работу стека. Например, lifo и fifo. lifo - last input first output (последний положенный берется первым). fifo - first input first output (первый положенный берется первым). В случае с x86 стек работает по принципу lifo. Т.е. все, что мы в него кладем, будем забирать по принципу "забрать можем тот, который был положен последним". Для работы со стеком в процессоре предусмотрены различные инструкции, которые мы опишем. PUSH - "толкать". Т.е. можно говорить "мы затолкнули в стек данные". POP - "выталкивать". Т.е. "мы выталкиваем данные". Допустим, наш стек начинается на 0xffff адресе. Если мы сделаем, скажем, push %eax (занесем в стек значение регистра eax), указатель стека двинется вперед на 4 байта (так как eax 4 байта), и ESP регистр будет уже указывать на 0xfffс адрес. Если мы сделаем pop %eax, то последние положенные в стек данные вынутся первыми (т.к. lifo). Т.е. мы заберем в eax регистр те данные, которые мы положили в стек, использовав push %eax инструкцию. Из ESP вычтется 4 байта, и он вновь будет указывать на 0xffff адрес (лучше всего посидеть денек-другой за дебаггером и посмотреть различные вариации работы со стеком, и будет тебе счастье :)). На этих и еще двух инструкциях построена работа со стеком в x86 семействе процессоров. Две другие инструкции - это CALL и RET. Рассмотрим пример: скажем, нам нужно перейти в программке на какую-то метку и затем вернуться назад для продолжения выполнения инструкций. Откуда процессор будет знать, куда возвращаться и что выполнять дальше? Для этого мы перед прыжком в функцию сохраняем адрес следующей инструкции во временном регистре (например, в eax); и чтобы затем возвратиться из функции, мы запишем адрес из eax в eip, и процессор продолжит выполнение там, где нам нужно. Однако неприятно постоянно проделывать такие комбинации, но к счастью, процессор предлагает нам 2 инструкции - CALL и RET. СALL записывает следующую инструкцию, которая будет исполнена после возврата из функции (назовем эту инструкцию адресом возврата) в стек, и записывает в eip адрес функции, которую мы вызвали. RET инструкция, наоборот, выталкивает адрес возврата из стека и записывает этот адрес в EIP. Таким образом, мы надежно возвращаемся из функции и продолжаем выполнение программы. Эти 4 инструкции, как и 3 стековых регистра, нам надо понимать, прежде чем двигаться дальше. Язык С и локальные переменные Теперь пришла пора поговорить об очень неплохом языке высокого уровня - языке С. Как я уже говорил, для упрощения написания программ были придуманы высокоуровневые компиляторы. В каждой операционной системе ядро, конечно, написано на ассемблере, а вот дальше все обычно пишется на языках высокого уровня. Unix-подобные системы - не исключение, и для более комфортной работы придумана библиотека (libc), в которой хранятся различные функции и обертки для "ассемблерных системных вызовов". И мы спокойно пишем различные сишные программы, не думая о том, что там на самом деле происходит. Компиляторы проектируются, естественно, по возможности максимально оптимизированными, и, естественно, адреса локальных переменных почти на всех С-компиляторах хранятся в стеке (для простоты и быстроты работы). К счастью или к сожалению, именно эта особенность компиляторов и используется при переполнении стека. Давай посмотрим, как располагаются локальные переменные в стеке. Для этого напишем простую программу: |