Хороший стек читается за секунду: команда 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 отсутствует или не загружены его символы, запись читается вслепую, и помогает принудительное указание диапазона модуля:

CODE3 Если функция создана кодом во время исполнения, записей для неё нет вообще, и единственная надежда - chained unwind info, которую заботливые генераторы кода регистрируют через RtlAddFunctionTable, либо ручной анализ. Если испорчен rsp сам по себе, есть смысл восстановить его обратным счётом: прочесть сохранённые регистры в домашней области родителя и по описанию пролога пересчитать указатель стека на момент вызова.

Рассмотренная механика лучше всего видна на живом примере. Сцена из практики: дамп с кодом 0x7E SYSTEM_THREAD_EXCEPTION_NOT_HANDLED, вершина стека указывает на код внутри стороннего драйвера, а дальше пустота - отладчик пишет, что раскрутка невозможна. Аналитик начинает с регистров: rsp лежит внутри ядерного стека потока, обрыв не катастрофичен. Дальше dps rsp L300 выводит триста слов, и среди них выделяются шесть адресов из диапазонов nt и драйвера фильтра. Первый адрес садится на инструкцию сразу после call в обработчике IRP_MJ_WRITE, второй - на вызов из диспетчера верификатора, третий - на IofCallDriver. Уже на этом эскиз становится стеком.

Каждый найденный кадр подтверждается дизассемблированием:

kd> ub fffff807`2a4118a3 L5
myfilter!FltWrite+0x55:
fffff807`2a411895 488b5c2430      mov     rbx,qword ptr [rsp+30h]
fffff807`2a41189a 488b0d4f120100  mov     rcx,qword ptr [myfilter!FltGlobals]
fffff807`2a4118a1 e89af6ffff      call    myfilter!FltPrepareWrite (fffff807`2a410f40)

Команда ub показывает инструкции перед адресом, и если последняя из них это call на функцию из кадра ниже, звено настоящее. Одно звено в примере не подтверждается: перед адресом стоит jmp, а не call. Аналитик вычёркивает строку и понимает, что нашёл не адрес возврата, а хвостовой переход, поэтому в цепочке функция отсутствует сама по себе. Итоговая цепочка из пяти кадров объясняет механику крэша полностью: обработчик записи передал буфер, длина которого не сходится с описанием MDL, и глубокий вызов ушёл исключением вверх. Чинить пришлось не отладчик, а одну строку с расчётом длины.

Поучительное наблюдение из этого и подобных случаев: полное восстановление почти никогда не требуется. Достаточно трёх-четырёх достоверных кадров, чтобы увидеть схему, и ещё одного подтверждения из ETW или лога, чтобы закрыть вопрос. Поэтому главный совет звучит просто - не бросать дамп в тот момент, когда автоматическая раскрутка остановилась, а считать её результат началом ручной работы, а не её концом.

Швы между стеками и кадры-ловушки

Ядерный стек полон официальных переходов, и умение их читать превращает "оборванный" стек в двухчастный рассказ. Когда происходит прерывание или системный вызов, процессор сохраняет регистры в кадр KTRAP_FRAME, адрес которого отладчик показывает в скобках после верхнего кадра при команде kv:

kd> kv
 # Child-SP          RetAddr           : Args to Child
 0 ffffc98a`01b3f4a0 fffff803`5f6b2c11 : nt!KiPageFault+0x465
 1 ffffc98a`01b3f640 fffff807`2a4118a3 : nt!NtWriteFile+0x9 (TrapFrame @ ffffc98a`01b3f6a0)
 2 ffffc98a`01b3f6e0 00007ffd`21a4c1e4 : myfilter!FltWrite+0x63

kd> .trap ffffc98a`01b3f6a0
kd> r
kd> u @rip
kd> .trap

Команда .trap с адресом переключает контекст на момент входа в ядро, и перед инженером открывается пользовательский стек или предыдущее состояние потока в том виде, в каком его застигло событие. Повторный .trap без аргумента возвращает исходный контекст.

Смена стека на DPC видна по признакам в данных: стек DPC живёт по фиксированным ядерным адресам, и область управления процессором хранит его границы явно:

kd> !pcr
KPCR for Processor 0 at fffff803`5f5a4000:
    Major 1 Minor 1
               Prcb: fffff803`5f5a4180
               Irql: 0000000000000002

kd> dt nt!_KPCR fffff803`5f5a4000 PrcbData
kd> dt nt!_KPRCB fffff803`5f5a4180 DpcStack DpcRoutineActive
   +0x1e90 DpcStack         : 0xffffc98a`01b47000 Void
   +0x1e98 DpcRoutineActive : 0n1

Если раскрутка упёрлась в адрес, лежащий вне стека потока, но внутри DpcStack, значит поток в момент сбоя исполнял отложенный вызов, и читать нужно именно этот стек. Аналогично работает анализ двойных ошибок: для стека IST ядро заводит отдельные страницы, и находящийся там KTRAP_FRAME указывает на оригинальный стек, с которого всё началось.

Отдельная история - вызовы, где код сгенерирован в обход таблиц раскрутки: самомодифицирующиеся участки защитных систем, обфусцированные упаковщики, ручной ассемблер легаси-драйверов. Здесь не помогает ни k, ни .trap, потому что ни таблиц, ни честных прологов нет по определению. Выручает дизассемблирование вокруг текущего rip командой u @rip и чтение кода глазами: параметры настройки кадра видны сразу, и по ним вручную вычисляется адрес возврата.

Инструментальные приёмы отладчика для трудных стеков

В арсенале WinDbg есть несколько малоизвестных команд, которые спасают именно в битых сценариях:

kd> k = ffffd10a`00000000 ffffc98a`01b3f4a0 fffff807`2a4112f4
kd> .exr ffffc98a`01b3f300
kd> .cxr ffffc98a`01b3f380
kd> .ecxr
kd> ub fffff807`2a4118a3 L8
kd> uf myfilter!FltWrite
kd> dt nt!_KPRCB fffff803`5f5a4180 DpcStack

Каждая строка этого набора решает свою задачу, и порядок их применения складывается в короткий список:

  1. Команда k с параметрами BasePtr StackPtr InstructionPtr, то есть ручной запуск раскрутки с указанием стартовых значений, когда отладчик сам выбрал неверный кадр;
  2. Команда .exr адрес для переключения на запись исключения EXCEPTION_RECORD, когда расследуется вложенное исключение и текущий контекст уже затёрт обработкой;
  3. Команды .cxr и .ecxr для поднятия сохранённого CONTEXT, который система откладывает при диспетчеризации исключений и который содержит регистры на момент сбоя;
  4. Команды ub и uf вокруг подозрительных адресов, чтобы по соседним инструкциям понять соглашение о вызовах и вычислить реальный размер кадра;
  5. Чтение поля DpcStack из структуры _KPRCB для явного просмотра стека отложенных вызовов, полезное при подозрении на DPC или прерывание.

К каждому из приёмов прилагается одна и та же добрая привычка: сверять результат с дизассемблированием. Отладчик может честно нарисовать чепуху из испорченных данных, и единственный способ отличить восстановленный стек от красивой фантазии - подтвердить каждый кадр кодом вызывающей стороны. Если перед адресом возврата стоит call на найденную функцию и параметры сходятся, кадр настоящий. Если нет - строка вычёркивается.

Проверка восстановленной цепочки и восстановление логики событий

Собранный вручную стек подлежит аудиту. Проверяется логика переходов: вызовы должны уменьшать привилегии в одну сторону, обработчики прерываний не должны оказаться между двумя пользовательскими кадрами без кадра-ловушки, а адреса модулей должны совпадать с их диапазонами из вывода lm. Потом восстанавливается история аргументов. Даже когда регистры потеряны, копии параметров лежат в домашних областях стека на x64: вызывающая сторона резервирует по 32 байта под параметры callee, и эти ячейки часто сохраняют исходные значения, что позволяет прочитать указатели на IRP, объекты устройств и буферы даже в полностью битом кадре.

Финальное испытание - сведение восстановленной цепочки с фактом порчи. Если переполнение стека подозревается как причина, размер сконструированных кадров суммируется и сравнивается с доступным запасом. Считается он вычитанием из полного размера стека:

Размер стека ядра на x64:   24576 байт
StackBase  - StackLimit  =  ffffc98a`01b43000 - ffffc98a`01b3d000 = 24576 байт
Занято до обрыва         =  StackBase - rsp = ffffc98a`01b43000 - ffffc98a`01b3f4a0
                         = 14688 байт, то есть 60 процентов стека
Глубина одного кадра рекурсии = 14688 / 47 кадров = 312 байт
Прогноз точки переполнения     = 24576 / 312 = 78 кадров

Цепочка из глубокой рекурсии драйвера фильтрации файловой системы с суммой в 22 килобайта сама объясняет случившееся лучше любой инструкции. На x86 запас вдвое меньше, 12 килобайт, поэтому там та же рекурсия падает при вдвое меньшей глубине.

Умение восстанавливать стек - ремесло с быстрой обратной связью. Каждый разобранный дамп тренирует глаз, и через пару десятков случаев аналитик различает швы стеков, адреса возврата и мусор почти мгновенно. Обрубок стека перестаёт быть приговором и превращается в головоломку, которая, как и всякая головоломка, поддаётся терпению, методике и знанию того, как устроена память ядра Windows.