Как спасти сломанную трассировку стека: восстановление цепочки значений EBP
Во время отладки вы можете обнаружить, что трассировка стека рассыпалась на части:
ChildEBP RetAddr 001af118 773806a0 ntdll!KiFastSystemCallRet 001af11c 7735b18c ntdll!ZwWaitForSingleObject+0xc 001af180 7735b071 ntdll!RtlpWaitOnCriticalSection+0x154 001af1a8 2f6db1a9 ntdll!RtlEnterCriticalSection+0x152 001af1b4 2fe8d533 ABC!CCriticalSection::Lock+0x12 001af1d0 2fe8d56a ABC!CMessageList::Lock+0x24 001af234 2f6e47ac ABC!CMessageWindow::UpdateMessageList+0x231 001af274 2f6f040e ABC!CMessageWindow::UpdateContents+0x84 001af28c 2f6e4474 ABC!CMessageWindow::Refresh+0x1a8 001af360 2f6e4359 ABC!CMessageWindow::OnChar+0x4c 001af384 761a1a10 ABC!CMessageWindow::WndProc+0xb31 00000000 00000000 USER32!GetMessageW+0x6e
Это явно не полный стек. В смысле, где процедура потока? Она должна быть на вершине стека в любом потоке.
Дело в том, что цепочка значений EBP каким-то образом прервалась и отладчик не может перемещаться дальше по стеку. Если код был скомпилирован с оптимизацией указателя фрейма (FPO), то компилятор не будет создавать фреймы EBP и позволит вместо этого использовать EBP как регистр общего назначения. Это отличное преимущество для оптимизации, но такое поведение вызывает проблемы для отладчика, когда он пытается получить трассировку стека из кода, скомпилированного с FPO, в котором нет необходимой информации для декодирования этих типов стеков.
Начало лирического отступления. Традиционно, код каждой функции начинался с последовательности:
push ebp ;; сохраняем значение регистра EBP вызывающей функции mov ebp, esp ;; устанавливаем наше значение EBP, указывающее на этот «фрейм» sub esp, n ;; резервируем место для локальных переменных
и заканчивалась такой последовательностью:
mov esp, ebp ;; отбрасываем локальные переменные pop ebp ;; восстанавливаем значение EBP вызывающей функции ret n
Этот шаблон стал настолько распространенным, что в x86 для него выделили отдельные инструкции. Инструкция ENTER n,0 выполняет операции push / mov / sub, а инструкция LEAVE выполняет mov / pop. (В C/C++ значение после запятой всегда равно 0).
Если вы посмотрите на то, как это влияет на стек, вы увидите, что такое поведение создает связанный список того, что называется фреймами (или кадрами) EBP. Предположим, у вас есть следующий фрагмент кода:
void Top(int a, int b) { int toplocal = b + 5; Middle(a, local); } void Middle(int c, int d) { Bottom(c+d); } void Bottom(int e) { int bottomlocal1, bottomlocal2; ... }
Когда выполнение доходит до строки «...» внутри функции Bottom, стек выглядит примерно таким (я разместил старшие адреса выше, стек растет вниз; также я предполагаю, что используется соглашение вызовов __stdcall и код скомпилирован абсолютно без каких-либо оптимизаций):
Фрейм стека функции Top | 0040F8F8 | параметр b переданный в Top | ||
0040F8F4 | параметр a переданный в Top | |||
0040F8F0 | адрес возврата функции, вызывающей Top | Во время выполнения Top | ||
0040F8EC | EBP функции, вызывающей Top | ←EBP = 0040F8EC | ||
0040F8E8 | toplocal | |||
Фрейм стека функции Middle | 0040F8E4 | параметр d переданный в Middle | ||
0040F8E0 | параметр c переданный в Middle | |||
0040F8DC | адрес возврата функции, вызывающей Middle | Во время выполнения Middle | ||
0040F8D8 | 0040F8EC = EBP функции, вызывающей Middle | ←EBP = 0040F8D8 | ||
Фрейм стека функции Bottom | 0040F8D4 | параметр e переданный в Bottom | ||
0040F8D0 | адрес возврата функции, вызывающей Bottom | Во время выполнения Bottom | ||
0040F8CC | 0040F8D8 = EBP функции, вызывающей Bottom | ←EBP = 0040F8CC | ||
0040F8C8 | bottomlocal1 | |||
0040F8C4 | bottomlocal2 |
Каждый фрейм стека идентифицируется по значению EBP, которое функция использует в ходе своего выполнения.
Поэтому структура каждого фрейма стека такова:
[ebp+n] | По смещению более 4 находятся параметры |
[ebp+4] | По смещению 4 находится адрес возврата |
[ebp+0] | По нулевому смещению находится EBP вызывающей функции |
[ebp-n] | По отрицательному смещению расположены локальные переменные |
И таким образом фреймы стека соединены друг с другом в виде связанного значениями EBP списка. Этот связанный список известен как цепочка значений EBP. Конец лирического отступления.
Чтобы восстановить разорванную цепочку значений EBP, создайте дамп стека немного ранее того места, где все пошло не так (в данном случае я бы начал с адресов 001af384-80) и поищите что-нибудь похожее на корректный фрейм стека. Поскольку параметры и локальные переменные могут быть абсолютно чем угодно, все, что вам остается, это работать со значением EBP и адресом возврата. Другими словами, вы ищете пары значений в форме:
«указатель на место в стеке немного выше» «указатель на место в коде»
В моем случае мне повезло, и мне не пришлось искать слишком далеко:
001af474 00000000 -001af478 001af494 / 001af47c 14f4fba8 DEF!SubclassBase::CallOriginalWndProc+0x1a | 001af480 2f6e4317 ABC!CMessageWindow::WndProc | 001af484 00970338 | 001af488 0000000f | 001af48c 00000000 \ 001af490 00000000 >001af494 001af4f0 001af498 14f4fcd6 DEF!SubclassBase::ForwardMessage+0x23 001af49c 00970338 001af4a0 0000000f 001af4a4 00000000 001af4a8 00000000 001af4ac 00000000 001af4b0 2f6e4317 ABC!CMessageWindow::WndProc 001af4b4 ed758311 001af4b8 00000000 001af4bc 15143f70 001af4c0 00000000 001af4c4 14f4fb8e DEF!CView::SortItems+0x96 001af4c8 00000000 001af4cc 2f6e4317 ABC!CMessageWindow::WndProc 001af4d0 00000000
В стеке по адресу 001af478 мы видим указатель на область памяти выше по стеку, следом за которым идет указатель на место в коде. Если вы проследуете по первому указателю, то увидите, что он указывает на другой участок памяти с таким же шаблоном: указатель на область памяти выше по стеку и следующий за ним указатель на место в коде.
Как только вы нашли место, где цепочка значений EBP возобновляется, вы можете попросить отладчик продолжить трассировку стека с этого места с аргументом =n для команды k.
0:000> k=001af478 ChildEBP RetAddr 001af478 14f4fba8 ntdll!KiFastSystemCallRet 001af494 14f4fcd6 DEF!SubclassBase::CallOriginalWndProc+0x1a 001af4f0 14f4fc8b DEF!SubclassBase::ForwardMessage+0x23 001af514 14f32dd1 DEF!SubclassBase::ForwardChar+0x59 001af530 14f4fcd6 DEF!SubclassBase::OnChar+0x3c 001af58c 14f4fd76 DEF!HelpSubclass::WndProc+0x51 001af5e4 761a1a10 DEF!SubclassBase::s_WndProc+0x1b 001af610 761a1ae8 USER32!GetMessageW+0x6e 001af688 761a1c03 USER32!GetMessageW+0x146 001af6e4 761a3656 USER32!GetMessageW+0x261 001af70c 77380e6e USER32!OffsetRect+0x4d 001af784 761a2a98 ntdll!KiUserCallbackDispatcher+0x2e 001af794 698fd0aa USER32!DispatchMessageW+0xf 001af7a4 2f7bf15c ABC!CThread::DispatchMessageW+0x23 001af7e0 2f7befc9 ABC!CMessageWindow::MessageLoop+0x3a2 001af808 2ff56d20 ABC!CMessageWindow::ThreadProc+0x9f 001af898 75c2384b ABC!CMessageWindow::s_ThreadProc+0x10 001af8a4 7735a9bd kernel32!BaseThreadInitThunk+0x12 001af8e4 00000000 ntdll!LdrInitializeThunk+0x4d
После того, как вы это сделаете, убедитесь, что вы игнорируете первую строку возобновленного стека, поскольку она основывается на текущем значении EIP, а не на адресе возврата, находящегося во фрейме стека.
Сегодня мы лишь разогревались для другой техники отладки, статью о которой я еще не дописал, поэтому вам придется подождать еще пару лет или около того, однако если вы присутствовали на моем докладе на TechEd China, вы уже знаете некоторые детали.
Дополнительные материалы для ознакомления: во второй части цикла из двух статей Райана Манджипано о переполнении стека в режиме ядра есть некоторая информация о поиске цепочки значений EBP. (Вы также можете почитать и первую часть, равно как и более раннюю статью о переполнениях стека).