Многие специалисты, по роду своей деятельности сталкивающиеся с исследованием различных модулей, анализом причин тех или иных сбоев и проводящие иную аналогичную работу, в своей практике часто сталкиваются с таким понятием как стек вызовов. Очевидно, что он фактически представляет собой очередность вызова функций в контексте выполняющегося потока. С первого взгляда может показаться, что стек вызовов достаточно тривиален, тем не менее, в нем далеко не все так очевидно, как хотелось бы, и поэтому я решил набросать (прежде всего для себя самого) небольшую шпаргалку относительно некоторых важных деталей. Помимо последовательности вызова функций, так же неплохо было бы обсудить формат описания функций в стеке, да и некоторые другие нюансы. Тем, кому доводилось работать с различного рода задачами в отладчиках, к примеру, над изучением различного рода дампов памяти, или отладкой приложения, понимали, что одним из центральных аспектов является так называемый стек вызовов потока, который представляет собой список, состоящий из записей вида:
portcls!PcHandlePropertyWithTable+0x1b
Очевидно, что приведен всего-лишь общий вид записи, но нам интересна сегодня, в первую очередь, сама структура данной записи. В приведенном выше формате отладчик выводит данные о вызываемых в ходе выполнения кода функциях. Формат записи в стеке вызовов следующий:
Как можно увидеть по рисунку, формат записи таков: имя_модуля!имя_функции+смещение, где:
- имя_модуля -- имя библиотеки (исполняемого файла), содержащей функцию;
- имя_функции -- наименование (имя) функции, в составе которой размещается инструкция, вызывающая вышестоящую (следующую по стеку вызовов) функцию;
- смещение -- количество байт (в шестнадцатеричной системе счисления) от адреса точки входа функции до начала инструкции, следующей за инструкцией, вызвавшей вышестоящую (по стеку вызовов) функцию. Смещение высчитывается от стартового адреса функции (от первого её байта).
В ряде ситуаций (при отсутствии символов) имя функции может отсутствовать, тогда адрес отображается не вполне корректно: имя_модуля+смещение либо имя_модуля!имя_ближайшей_определенной_функции+смещение.
По большому счету, как раз совокупность подобных записей и составляет собой стек вызовов.
По идее, из названия следует, что стек вызовов должен представлять собой только часть (отдельные ячейки) традиционного стека потока, хранящую исключительно адреса возврата (информацию для возврата) управления в вышестоящую (вызывающую, вызвавшую) подпрограмму. Тем не менее, поскольку в современном программировании на языках высокого уровня подпрограммы (функции) оперируют стековыми фреймами, то подразумевается, что стек вызовов хранит всю информацию о подпрограммах (локальные данные, параметры, значения регистров и прч.).
То есть стек вызовов это не весь стек (потока), поскольку в последнем могут храниться "сторонние" данные (локальные переменные функций, значения регистров и прч.), а исключительно информация, относящаяся к вызываемым подпрограммам (функциям). Эти значения формируют своего рода последовательность (цепочку) вложенных вызовов подпрограмм, которая является достаточно важной частью при исследовании процесса выполнения кода потока (при различного рода сбоях), поскольку по цепочке вызовов можно определить как именно код вел себя на протяжении всего времени выполнения (до момента сбоя/контролируемого останова) и даже определить области кода, где предположительно произошла ошибка. Поэтому, для реверсивного инженера чрезвычайно важно как можно подробнее изучить последовательность вызова функций при выполнении кода.
Последовательность вызова функций, как уже говорилось, можно наблюдать в стеке вызовов. Давайте посмотрим, как именно стек вызовов выглядит в отладчике Windbg, для этого в программе присутствует класс команд k*
(например: knL
). Вот типичный пример:
1 2 3 4 5 6 7 8 9 10 11 12 |
Child-SP RetAddr Call Site fffff880`021ab748 fffff800`02cedd42 nt!KeBugCheckEx fffff880`021ab750 fffff800`02ca065f nt! ?? ::FNODOBFM::`string'+0x3132a fffff880`021ab830 fffff800`02c868e9 nt!MiIssueHardFault+0x28b fffff880`021ab900 fffff800`02c773ee nt!MmAccessFault+0x1399 fffff880`021aba60 fffff960`00212cd0 nt!KiPageFault+0x16e fffff880`021abbf8 fffff800`0301b4fb win32k!UserPowerInfoCallout fffff880`021abc00 fffff800`03021a52 nt!PopNotifyConsoleUserPresent+0x6b fffff880`021abc70 fffff800`02c82265 nt!PopUserPresentSetWorker+0x62 fffff880`021abcb0 fffff800`02f10f06 nt!ExpWorkerThread+0x111 fffff880`021abd40 fffff800`02c6a686 nt!PspSystemThreadStartup+0x5a fffff880`021abd80 00000000`00000000 nt!KiStartSystemThread+0x16 |
Чем вам не последовательность? Правда читается она достаточно своеобразно: снизу вверх. Объясняется это принципами работы самого стека: последним пришел, первым вышел (Last In First Out), или иначе первым пришел, последним вышел. То есть, самый первый вызов процедуры помещает в стек адрес возврата первым (на самую вершину стека), затем следующий вторым и так далее. В итоге первый адрес оказывается как бы "на дне" стека. Стек растет в сторону уменьшения адресов. Таким образом, со временем в стеке получается эдакая своеобразная "башня" из находящихся друг над другом адресов возврата (можно отследить по столбцу Child-SP).
В блоке можно наблюдать столбец с именем Call site. Это обозначение можно перевести как Область вызовов, и оно отражает символическое, осмысленное имя, соответствующее адресу возврата (указанного в колонке RetAddr), сформированное с использованием (если доступны) символов для представления более осмысленного вывода. Каждая запись области вызовов содержит в себе наименование модуля, функции и смещение, для упрощенной идентификации как самой вызывающей функции, так и входящей в ее состав инструкции. Грубо говоря она представляет собой точный указатель (адрес) на инструкцию внутри функции. Для лучшего понимания уместно провести провести аналогию с почтовым адресом: имя модуля это город, имя функции - улица, и смещение - это адрес дома. Эта абстракция заметно упрощает понимание принципов адресации той или иной инструкции в адресном пространстве исследуемого процесса.
Теперь давайте вернемся к базовым принципам. В операционной системе присутствует такое понятие как поток выполнения, в котором выполняются разнообразные операции, или, говоря простыми словами, код.
В каждом потоке имеется начальная (главная, стартовая) функция, код которой размещается с точки входа, и начинает выполняться при старте потока (приложения). Вы думаете, что этой главной функцией все в программе и ограничивается, то есть приложение состоит из единственной функции? Нет же, не существует такой одной единой, "волшебной" функции, которая выполнила бы всю необходимую любому приложению работу (исключение составляют лишь крайне простые приложения). Очевидно, что для выполнения даже самых простейших задач этой функции необходима помощь множества других внутренних или внешних (системных) функций. Из этого следует, что:
Для того, что бы не порождать хаос, наборы упомянутых функций классифицированы в разнообразные модули (файлы), называемые библиотеками и систематизированы по роду деятельности. И вот эти то самые функции постоянно вызываются выполняемым в данный момент на процессоре кодом.
Например, ваша программа хочет вывести на рабочий стол окно. Уже представили насколько сложна эта задача на уровне системы и на сколько подзадач она "подразделяется"? Вы думали, что вызовется одна единственная функция, которая спокойно нарисует окно и вернет управление? Не тут то было!! Подобная задача разбивается на множество составляющих (подзадач). Соответственно, наша начальная функция вызывает другую функцию что бы сделать какую-то часть общей работы, например указать библиотеке DirectX отрисовать окно. Функция DirectX получает управление и в ходе своей работы обращается к какой-либо другой функции, потом та, в свою очередь, обращается к следующей, что бы сделать уже требуемую именно ей на произвольном этапе выполнения, работу и так далее и так далее. Получается такая своеобразная "матрешка" из вложенных (или вызывающих) друг в друга функций.
Однако, давайте опять посмотрим на стек вызовов, который мы привели выше. Нужно помнить, что:
Но тут не все так просто, как кажется, надо понимать как именно она это делает. Дьявол, как всегда, кроется в деталях. Реализует она это не со смещения, которое указано в столбце Call site (вызовы), и не с адреса, который указан в столбце RetAddr (адрес возврата). На самом деле, нижестоящая функция просто вызывает "вышестоящую", а это означает, что управление передается в самое начало, то есть на самый первый байт вызываемой функции. Это регламентирует базовый принцип вызова функций (или подпрограмм), который испокон веков применяется в программировании. Теперь понятно, почему смещения, указанные сразу за именем модуля/функции в списке вызовов (столбец Call site), могут ввести в заблуждение?
Поэтому:
- смещение от начала модуля/функции в списке вызовов (столбец Call site) показывает исключительно смещение (в байтах, от начала) инструкции, с которой продолжится выполнение, если вышестоящая функция вернет это самое управление обратно.
- адрес возврата (столбец RetAddr) показывает адрес, куда вернется управление в нижестоящую функцию, когда текущая функция отработает и вернет управление.
Повторение - мать учения, закрепим:
Эдакие своеобразные перекрестные ссылки на соседа в цепочке вызовов. Да, в этом и заключается некоторая неинтуитивность списка, и не все так просто, как хотелось бы, но со временем вы осознаете, что все выстроено выполнено логично. И вот еще, что значит выражение "функция вернула управление"? Это означает ровно то, что функция выполнила ожидаемую от неё работу, и затем управление вернулось (чаще при помощью подкласса команд ret
) в функцию ниже по списку в стеке вызовов, которая и продолжила выполнение, и так далее.
Пример
Давайте рассмотрим конкретный пример. Я возьму произвольный файл дампа памяти, созданный при возникновении критической системной ошибки (BSOD). Открою его в отладчике Windbg, введу команду knL
и получу такой вот вывод:
1 2 3 4 5 6 7 8 9 10 11 12 |
Child-SP RetAddr Call Site fffff880`021ab748 fffff800`02cedd42 nt!KeBugCheckEx fffff880`021ab750 fffff800`02ca065f nt! ?? ::FNODOBFM::`string'+0x3132a fffff880`021ab830 fffff800`02c868e9 nt!MiIssueHardFault+0x28b fffff880`021ab900 fffff800`02c773ee nt!MmAccessFault+0x1399 fffff880`021aba60 fffff960`00212cd0 nt!KiPageFault+0x16e fffff880`021abbf8 fffff800`0301b4fb win32k!UserPowerInfoCallout fffff880`021abc00 fffff800`03021a52 nt!PopNotifyConsoleUserPresent+0x6b fffff880`021abc70 fffff800`02c82265 nt!PopUserPresentSetWorker+0x62 fffff880`021abcb0 fffff800`02f10f06 nt!ExpWorkerThread+0x111 fffff880`021abd40 fffff800`02c6a686 nt!PspSystemThreadStartup+0x5a fffff880`021abd80 00000000`00000000 nt!KiStartSystemThread+0x16 |
Рассмотрение потока выполнения мы начнем, традиционно, снижу вверх. Системный поток стартует с функции nt!KiStartSystemThread
, видите её в самом низу стека вызовов? Эта функция ядра и предназначена для создания потока и инициирования его выполнения. Затем, в ходе выполнения стартовой функции nt!KiStartSystemThread
, ей вдруг потребовалось вызвать функцию, находящуюся в стеке вызовов выше по списку. Это тоже ядерная функция, имеющая имя nt!PspSystemThreadStartup
. Честно говоря, не сильно я пока разбираюсь в функциональных особенностях инициализации системных потоков, но могу предположить, что она тоже выполняет какие-то подготовительные действия для создания и выполнения потока. Затем уже функция nt!PspSystemThreadStartup
и вызывает функцию nt!ExpWorkerThread
, ну и так далее по списку.
Теперь отступим 3 стековых фрейма от начала (снизу) списка и остановимся на рассмотрении функции nt!PopUserPresentSetWorker
. Для начала выведем на экран её содержимое в дизассемблированном виде. С этой целью я открою окно дизассемблирования и просто вставлю название функции, ну или введу команду:
uf nt!PopUserPresentSetWorker
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
nt!PopUserPresentSetWorker: fffff800`030219f0 fff3 push rbx fffff800`030219f2 4883ec30 sub rsp,30h fffff800`030219f6 33db xor ebx,ebx fffff800`030219f8 381d43d2dfff cmp byte ptr [nt!PopPowerSettingValues+0xf1 (fffff800`02e1ec41)],bl fffff800`030219fe 744b je nt!PopUserPresentSetWorker+0x5b (fffff800`03021a4b) fffff800`03021a00 8d4301 lea eax,[rbx+1] fffff800`03021a03 4c8d053ed2dfff lea r8,[nt!PopPowerSettingValues+0xf8 (fffff800`02e1ec48)] fffff800`03021a0a 488d150f8eceff lea rdx,[nt!PopAwayModeUserPresenceDpc (fffff800`02d0a820)] fffff800`03021a11 870531d2dfff xchg eax,dword ptr [nt!PopPowerSettingValues+0xf8 (fffff800`02e1ec48)] fffff800`03021a17 488d0dc2c2e0ff lea rcx,[nt!PopAwayModeUserPresenceDpcObject (fffff800`02e2dce0)] fffff800`03021a1e e8d152c3ff call nt!KeInitializeDpc (fffff800`02c56cf4) fffff800`03021a23 48894c2420 mov qword ptr [rsp+20h],rcx fffff800`03021a28 488d0d91d0dfff lea rcx,[nt!PopAwayModeUserPresenceTimer (fffff800`02e1eac0)] fffff800`03021a2f 4533c9 xor r9d,r9d fffff800`03021a32 4533c0 xor r8d,r8d fffff800`03021a35 48c7c2803c36fe mov rdx,0FFFFFFFFFE363C80h fffff800`03021a3c e84f1ec6ff call nt!KiSetTimerEx (fffff800`02c83890) fffff800`03021a41 b980000000 mov ecx,80h fffff800`03021a46 e86994c0ff call nt!PopSetNotificationWork (fffff800`02c2aeb4) fffff800`03021a4b 33c9 xor ecx,ecx fffff800`03021a4d e83e9affff call nt!PopNotifyConsoleUserPresent (fffff800`0301b490) fffff800`03021a52 448bdb mov r11d,ebx fffff800`03021a55 33c0 xor eax,eax fffff800`03021a57 44871d52c2e0ff xchg r11d,dword ptr [nt!PopUserPresentSetStatus (fffff800`02e2dcb0)] fffff800`03021a5e f00fb11dded1dfff lock cmpxchg dword ptr [nt!PopPowerSettingValues+0xf4 (fffff800`02e1ec44)],ebx fffff800`03021a66 7411 je nt!PopUserPresentSetWorker+0x89 (fffff800`03021a79) |
В блоке кода выше маркером я пометил инструкцию, куда ссылается указатель nt!PopUserPresentSetWorker+0x62. Эта инструкция отстоит на 0x62 (62 в шестнадцатеричной, или 98 в десятичной) байт от начала функции nt!PopUserPresentSetWorker
. Давайте посмотрим на инструкцию, которая предшествует той, которую мы только что рассмотрели. Это инструкция вызова функции nt!PopNotifyConsoleUserPresent
(строка 22
), которую вы видите в общем списке вызовов следующей (выше по списку). Так что же происходит? При вызове nt!PopUserPresentSetWorker
она получает управление, выполняется до определенного момента, затем в какой то момент времени заявляет, что "мне необходимо вызвать nt!PopNotifyConsoleUserPresent потому как мне нужно с помощью неё что-то сделать", и, соответственно, вызывает её. Теперь посмотрим на список вызова. Неужели наша функция передает управление по адресу nt!PopNotifyConsoleUserPresent+0x6b, как у нас обозначено в стеке вызовов? Нет!! Вместо этого, она передает управление строго в начало функции PopNotifyConsoleUserPresent
, а именно по адресу fffff800`0301b490. Давайте в этом убедимся. Дизассемблируем код по указанному только что адресу:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
nt!PopNotifyConsoleUserPresent: fffff800`0301b490 48895c2408 mov qword ptr [rsp+8],rbx fffff800`0301b495 57 push rdi fffff800`0301b496 4883ec60 sub rsp,60h fffff800`0301b49a 48833d8e39e0ff00 cmp qword ptr [nt!PopWin32InfoCallout (fffff800`02e1ee30)],0 fffff800`0301b4a2 408af9 mov dil,cl fffff800`0301b4a5 7469 je nt!PopNotifyConsoleUserPresent+0x80 (fffff800`0301b510) fffff800`0301b4a7 48b8d802000080f7ffff mov rax,0FFFFF780000002D8h fffff800`0301b4b1 8b08 mov ecx,dword ptr [rax] fffff800`0301b4b3 83f9ff cmp ecx,0FFFFFFFFh fffff800`0301b4b6 7458 je nt!PopNotifyConsoleUserPresent+0x80 (fffff800`0301b510) fffff800`0301b4b8 e8af26c3ff call nt!MmGetSessionById (fffff800`02c4db6c) fffff800`0301b4bd 488bd8 mov rbx,rax fffff800`0301b4c0 4885c0 test rax,rax fffff800`0301b4c3 744b je nt!PopNotifyConsoleUserPresent+0x80 (fffff800`0301b510) |
Вот вам и весомое доказательство. Адрес fffff800`0301b490 соответствует первой инструкции функции PopNotifyConsoleUserPresent
, но никак не тому, что у нас указано в стеке вызовов: nt!PopNotifyConsoleUserPresent+0x6b, и нет тут никакого смещения, поскольку по смещению 0x6b от начала функции находится совершенно другая инструкция. Что у нас тут происходит, функция PopNotifyConsoleUserPresent
делает какую-то свою работу, пока она, в свою очередь, не захочет вызвать другую, потребовавшуюся ей для каких-то там своих нужд, функцию. Давайте проверим, куда ведет указатель nt!PopNotifyConsoleUserPresent+0x6b, расположенный в стеке вызовов в столбце Call site:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
nt!PopNotifyConsoleUserPresent+0x46: fffff800`0301b4d6 488364242800 and qword ptr [rsp+28h],0 fffff800`0301b4dc 8364242000 and dword ptr [rsp+20h],0 fffff800`0301b4e1 b901000000 mov ecx,1 fffff800`0301b4e6 4c8d4c2478 lea r9,[rsp+78h] fffff800`0301b4eb 448bc1 mov r8d,ecx fffff800`0301b4ee 33d2 xor edx,edx fffff800`0301b4f0 40887c2478 mov byte ptr [rsp+78h],dil fffff800`0301b4f5 ff153539e0ff call qword ptr [nt!PopWin32InfoCallout (fffff800`02e1ee30)] fffff800`0301b4fb 488d542430 lea rdx,[rsp+30h] fffff800`0301b500 488bcb mov rcx,rbx fffff800`0301b503 e8d426c3ff call nt!MmDetachSession (fffff800`02c4dbdc) fffff800`0301b508 488bcb mov rcx,rbx fffff800`0301b50b e82072c6ff call nt!ObfDereferenceObject (fffff800`02c82730) |
Ну, смещение 6b указывает на некую инструкцию. А что стоит непосредственно перед ней? А тут опять идет инструкция вызова следующей функции. Таким образом, далее мы попадаем на очередную функцию в стеке с именем nt!PopWin32InfoCallout
.
nt!PopWin32InfoCallout
, а в списке вызовов уже именуется как win32k!UserPowerInfoCallout
(к тому же без смещения инструкции). Скорее всего это объясняется тем, что nt!PopWin32InfoCallout
является "переходником".Что же происходит, когда последняя функция win32k!UserPowerInfoCallout
возвращает управление? Вот тогда управление возвращается по адресу nt!PopNotifyConsoleUserPresent+0x6b, затем возвращается к nt!PopUserPresentSetWorker+0x62 и так далее обратно по стеку.