Хороший стек читается за секунду: команда k в WinDbg выводит десяток осмысленных кадров, и вся история падения видна сразу. Плохой стек начинается с непонятного адреса и обрывается на второй строке, а отладчик честно сообщает, что дальше раскрутить не может, потому что указатель кадра указывает в никуда. Такое случается при повреждении стека переполнением, при исполнении чужого кода из-за битого указателя на функцию, при смене стека на DPC и при ручном ассемблере, который не соблюдает соглашения о вызовах. Умение восстановить цепочку вызовов из обломков отличает аналитика, который застрял, от аналитика, который довёл дамп до вердикта. Разберём приёмы такого восстановления.
Почему на современных системах цепочки кадров рвутся
В старые времена 32-разрядной архитектуры почти каждая функция начиналась прологом push ebp и mov ebp, esp, и указатели кадров образовывали связный список: по адресу в ebp лежал предыдущий ebp, рядом адрес возврата. Раскрутка стека сводилась к обходу этого списка, и она была уязвима ровно настолько, насколько уязвима сама память: стоило переполнению буфера перезаписать ebp, и цепочка становилась шумом.
На 64-разрядной архитектуре Windows пошла другим путём. Кадров как списка нет: компилятор не заводит регистр указателя кадра у большинства функций, а вместо этого в модуль встраиваются таблицы раскрутки. Каталог .pdata описывает каждую функцию диапазоном адресов, а соответствующая запись .xdata объясняет, на сколько менялся rsp в прологе и какие регистры куда сохранялись. Отладчик проходит стек не по памяти, а по этим таблицам, что быстрее и надёжнее. Но цена перехода видна именно в битых дампах: если сами таблицы недоступны, потому что модуль не опознан, или исполнялся код, сгенерированный на лету, или rsp испорчен так, что не попадает ни в одну запись, автоматическая раскрутка останавливается, и начинается ручная работа.
Третья причина обрыва - смена стеков. Ядро Windows активно меняет стек в потоке: при обработке прерывания берётся стек прерываний, при DPC - стек DPC, при двойной ошибке - аварийный стек IST. Каждый переход фиксируется кадром-ловушкой KTRAP_FRAME, и если аналитик не распознаёт эти швы, ему кажется, что стек оборван, хотя на самом деле он просто продолжается на другом массиве памяти.
Начало восстановления с того, что сохранилось
Даже в самом грязном дампе остаются три якоря: регистры, содержимое стека как набор машинных слов и таблица загруженных модулей. Отладчик показывает регистры командой r, и первое, что проверяет инженер, это значение rsp и rip:
kd> r
rax=0000000000000000 rbx=ffffd10a`12345670 rcx=0000000000000080
rdx=ffffd10a`0a112233 rsi=ffffd10a`0b1f4080 rdi=0000000000000000
rip=fffff807`2a4112f4 rsp=ffffc98a`01b3f4a0 rbp=0000000000000000
eflags=00010246
kd> !thread 0 1f
THREAD ffffd10a`0a2b1080 Cid 1c48.1d02 Teb: 0000000000000000
StackBase ffffc98a`01b43000
StackLimit ffffc98a`01b3d000
Проверка простая арифметикой: rsp обязан лежать между StackLimit и StackBase. В примере диапазон составляет 24 килобайта, что соответствует штатному размеру стека ядра на x64, а значение rsp находится примерно в середине, то есть обрыв не катастрофичен. Если rsp вывалился за пределы, это уже диагноз сам по себе: переполнение стека или испорченный контекст.
Второй якорь - ручное чтение стека:
kd> dps @rsp L200
ffffc98a`01b3f4a0 fffff807`2a4118a3 myfilter!FltWrite+0x63
ffffc98a`01b3f4a8 ffffd10a`0b1f4080
ffffc98a`01b3f4b0 fffff803`5f6b2c11 nt!IofCallDriver+0x21
ffffc98a`01b3f4b8 0000000000000000
ffffc98a`01b3f4c0 fffff807`2a410f44 myfilter!FilterDispatchWrite+0x144
Буква s в команде означает, что каждое слово памяти проверяется на похожесть на указатель и снабжается именем символа, если попадает в код какого-либо модуля. В потоке таких слов почти всегда мелькают адреса возврата, сохранённые вызывающими функциями. Инженер выписывает их сверху вниз и получает эскиз цепочки: пусть без аккуратных имён кадров, зато с реальным порядком вызовов. Эта техника называется сканированием сырого стека и работает даже там, где k сдаётся.
Третий якорь - команда dps с привязкой к границам. Если стек потока занимает диапазон от StackLimit до StackBase, имеет смысл отсканировать его целиком, а потом вручную отсеять мусор:
kd> dps ffffc98a`01b3d000 ffffc98a`01b43000
kd> s -q ffffc98a`01b3d000 L?0x6000 fffff807`2a410000
Второй поиск находит все слова, равные базе модуля, а первый печатает весь стек с привязкой к символам. Адреса возврата узнаются по трём признакам: они лежат в страницах кода имеющихся модулей, рядом с ними иногда сохраняются аргументы, и они идут, как правило, нарастающими адресами стека по мере возврата из глубины. Десять минут такого сканирования обычно восстанавливают семьдесят процентов цепочки.
Цепочки ebp на 32-разрядных системах и их проверка
Хотя мир давно на x64, в дампах встраиваемых и легаси-систем встречается 32-разрядный вариант, и там главным инструментом остаётся список ebp. Валидация такого списка вручную подчиняется трём правилам. Первое: адрес предыдущего кадра должен быть больше текущего ebp, потому что стек растёт вниз, а старшие кадры лежат по большим адресам. Второе: адрес возврата, лежащий по ebp+4, обязан попадать внутрь кода какого-либо модуля, что проверяется сравнением с диапазонами из lm. Третье: разница между соседними ebp не бывает отрицательной и почти никогда не превышает нескольких килобайт для обычных функций.
Если одно из правил ломается, цепочка дальше не читается, и дальше работает только сканирование сырого стека. Забавный побочный эффект переполнений: перезаписанный ebp часто содержит не мусор, а старшие байты текстовой строки, залитой в буфер. Отладчик послушно показывает кадр с адресом вроде 0x41414141 или фрагментами пути к файлу, и это уже не заглушка, а улика: точное смещение, на котором порвётся цепочка, говорит, какая локальная переменная переполнилась. Сопоставление смещения со структурой кадра из дизассемблера приводит к функции, где объявлен пострадавший массив.
Механика таблиц раскрутки на x64 под микроскопом
Когда инженеру приходится спорить с автоматической раскруткой, он спорит не с чем-то необъяснимым, а с двумя вполне конкретными каталогами. Каждая запись RUNTIME_FUNCTION в каталоге pdata содержит три поля: начало функции, конец функции и смещение записи UNWIND_INFO, которая в свою очередь перечисляет операции пролога в обратном порядке. Отладчик, получив rip произвольного кадра, ищет запись, в чей диапазон попадает адрес, прокручивает описание пролога до нужного места внутри функции и вычисляет, где в текущей точке лежал rsp на входе, то есть адрес возврата.
Практический вывод из устройства таблиц: раскрутка ломается в трёх местах. Если модуль из списка lm отсутствует или не загружены его символы, запись читается вслепую, и помогает принудительное указание диапазона модуля: