Исследование InvalidProgramException из дампа памяти (часть 2 из 3)

В этой серии статей мы рассмотрим, как я отлаживал InvalidProgramException, вызванный ошибкой в ​​профилировщике Datadog, из дампа памяти, отправленного клиентом.

Начнем с небольшого напоминания. Профилировщик работает, переписывая IL интересных методов для внедрения кода инструментария. InvalidProgramException выдается JIT при попытке скомпилировать IL, созданный профилировщиком, который должен быть каким-то образом недействительным. Первая часть заключалась в том, чтобы определить, каким методом было сгенерировано исключение, и в итоге я пришел к выводу, что виновником является Npgsql.PostgresDatabaseInfo.LoadBackendTypes. Вторая часть будет посвящена тому, как найти сгенерированный IL для этого метода.

Нахождение сгенерированного IL

Npgsql.PostgresDatabaseInfo.LoadBackendTypes — асинхронный метод. Логика асинхронного метода хранится в MoveNext методе его конечного автомата, так что это тот, который меня интересовал.

В dotnet-dump есть функция для отображения IL метода: dumpil. Для этого метода требуется MethodDescriptor (MD) целевого метода, поэтому мне нужно было найти его для MoveNext.

Я начал с сброса всех типов в модуль, используя команду dumpmodule -mt, чтобы найти конечный автомат:

Это дало мне MT (MethodTable) типа конечного автомата: 00007fcf16509c10. Затем я передал его команде dumpmt -md, чтобы получить МД:

Команда выводит все методы данного типа, и отсюда мы видим, что наш MD равен 00007FCF16509B58.

К сожалению, команда dumpil вернула исходный IL, а не переписанный. В поисках идей я использовал команду dumpmd, чтобы получить больше информации о методе:

Интересно, что метод был отмечен как несложный. Оглядываясь назад, это имеет смысл. Мы переписываем метод, используя событие JitCompilationStarted API профилировщика. Затем JIT пытается его скомпилировать, но терпит неудачу и отбрасывает перезаписанный IL.

Интересный факт для тех, кто знает о многоуровневой компиляции: вы, возможно, заметили в выходных данных dumpmd, что существует две версии метода, QuickJitted и OptimizedTier1, несмотря на то, что флаг IsJitted является ложным. Мне удалось воспроизвести это в тестовом приложении с профилировщиком, испускающим неверный IL: после вызова метода 30 раз многоуровневый JIT переводит его на уровень 1, даже если метод никогда не был успешно обработан

Тупик? Я действительно не хотел сдаваться после утомительного процесса поиска метода, поэтому решил проявить творческий подход. Точно так же, как мне удалось найти InvalidProgramException в куче, хотя на него больше не ссылались, я понял, что где-то все еще могут быть следы сгенерированного IL.

См. также:  Добро пожаловать в R

Чтобы передать переписанный IL JIT, профилировщик использует SetILFunctionBody API. Интересно то, что буфер, используемый для записи IL, предоставляется собственным распределителем JIT. Цитата из документации:

Use the ICorProfilerInfo::GetILFunctionBodyAllocator method to allocate space for the new method to ensure that the buffer is compatible.

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

Я понятия не имею, как оператор new работает в C ++, поэтому решил последовать другому примеру. Что происходит с этим буфером после передачи его методу SetILFunctionBody? Я не собираюсь показывать реализацию метода, но интересно то, как он вызывает Module::SetDynamicIL. В свою очередь, SetDynamicIL сохраняет тело IL во внутренней таблице (на этот раз я показываю реализацию, потому что это будет важно позже):

fTemporaryOverride в этом кодовом пути ложно, поэтому m_debuggerSpecificData.m_pDynamicILBlobTable используется для хранения IL. Если бы я мог найти адрес этой таблицы в дампе памяти, то, возможно, я смог бы получить сгенерированный IL!

Как я показал в предыдущей статье, можно экспортировать все символы модуля в Linux с помощью команды nm. Я попытался найти m_debuggerSpecificData, но безуспешно:

> nm -C libcoreclr.so | grep m_debuggerSpecificData
>

Как я мог найти эту структуру без символов?

Я твердо верю, что отладка — это творческий процесс. Так что я сделал шаг назад и начал думать. Когда вызывается Module::SetDynamicIL, среда выполнения каким-то образом способна найти эту структуру. Так что ответ, каким бы он ни был, должен быть где-то в ассемблерном коде этого метода.

Кажется, что чтение этой статьи происходит мгновенно, но поиск m_debuggerSpecificData без символов — это результат двух часов проб и ошибок и обмена идеями с моими бывшими коллегами Кристофом Насарром и Грегори Леокади < br /> В процессе я также обнаружил, что ISOSDacInterface7 реализуется для .NET 5, и в нем есть все средства, необходимые для поиска динамического IL. * вздох *

К счастью, этот метод выражен в символах:

> nm -C libcoreclr.so | grep Module::SetDynamicIL
0000000000543da0 t Module::SetDynamicIL(unsigned int, unsigned long, int)

Я использовал gdb для его декомпиляции:

ОК, это много. Особенно если вы, как и я, плохо разбираетесь в нативной разборке. Уловка состоит в том, чтобы сравнить его с исходным кодом (поэтому я опубликовал SetDynamicIL ранее) и сосредоточиться исключительно на том, что вы ищете.

См. также:  КОНСОЛЬ против ТЕРМИНАЛА против ОБОЛОЧКИ, разница между ними.

Во-первых, нам нужно найти параметр this. Объектно-ориентированного программирования не существует на уровне сборки, поэтому указатель this, который мы волшебным образом используем, нужно каким-то образом передать целевой функции. По соглашению при вызове метода экземпляра this является первым аргументом функции.

Далее нам нужно знать, как аргументы передаются функции. Вызвав Википедию на помощь, мы узнаем, что Linux использует соглашение о вызовах System V AMD64 ABI. Согласно этому соглашению первый аргумент функции хранится в регистре rdi.

Теперь нам нужен какой-то «якорь». Четко обозначенная точка в функции, на которой мы можем сосредоточиться. В самом начале SetDynamicIL мы находим это условие:

Это замечательно, потому что он использует m_debuggerSpecificData (поле, которое мы ищем), у него есть условие и он вызывает метод (InitializeDynamicILCrst). Это позволяет легко заметить его при разборке. Теперь мы знаем, что нам нужно сосредоточиться на этой части:

Помните, что this хранится в регистре rdi. Этот регистр копируется в rbx:

Затем мы повторно используем этот регистр здесь:

Этот код считывает память по адресу rbx+0x568, помещает содержимое в регистр r14, затем что-то проверяет: test r14,r14. Тестирование регистра против самого себя — это способ сборки, позволяющий проверить, является ли значение пустым. Это наша if (m_debuggerSpecificData.m_pDynamicILCrst == NULL) проверка! Это означает, что m_debuggerSpecificData.m_pDynamicILCrst находится по смещению 0x568 от адреса экземпляра модуля.

Это здорово, но мне нужен был m_debuggerSpecificData.m_pDynamicILBlobTable, а не m_debuggerSpecificData.m_pDynamicILCrst. Итак, я взглянул на структуру, хранящуюся в поле m_debuggerSpecificData:

Поля хранятся в памяти в том же порядке, в каком они объявлены в коде. Таким образом, m_pDynamicILBlobTable — это указатель, хранящийся сразу после m_pDynamicILCrst в памяти.

Чтобы проверить это, мне сначала нужен адрес модуля, содержащего LoadBackendTypes. Если вы прокрутите назад до того места, где я назвал dumpmd, вы можете найти его в выводе:

Я искал содержимое памяти по смещению 0x568 модуля, поэтому я добавил это в адрес модуля, чтобы получить 0x7FCF13EC67D8 + 0x568 = 0x7FCF13EC6D40

Затем я использовал LLDB для сброса памяти по этому адресу:

(lldb) memory read --count 4 --size 8 --format x 7FCF13EC6D40
0x7fcf13ec6d40: 0x00007fce8c90c820 0x00007fce8c938380
0x7fcf13ec6d50: 0x0000000000000000 0x0000000000000000

Если предположить, что мои рассуждения верны, 0x00007fce8c90c820 будет адресом m_pDynamicILCrst, а 0x00007fce8c938380 адресом m_pDynamicILBlobTable. Не было возможности быть полностью уверенным, но я мог проверить, соответствуют ли значения в памяти макету таблицы в исходном коде:

См. также:  Жизнь в оболочке

Один указатель на таблицу, за которым следуют 4 целых числа, указывающих размер и занятость таблицы.

Я сначала сбросил указатель:

(lldb) memory read --count 2 --size 8 --format x 0x00007fce8c938380
0x7fce8c938380: 0x00007fce8c938a90 0x000000010000000b

0x00007fce8c938a90 наверняка выглядит как указатель на кучу. Затем я проверил целые числа (используя size 4 вместо size 8):

(lldb) memory read --count 8 --size 4 --format x 0x00007fce8c938380
0x7fce8c938380: 0x8c938a90 0x00007fce 0x0000000b 0x00000001
0x7fce8c938390: 0x00000001 0x00000008 0x00000045 0x00000000

Указатель все еще был там (смотрел назад из-за порядка байтов), затем несколько небольших значений. Я сопоставил все с полями таблицы и получил:

m_table         = 0x00007fce8c938a90
m_tableSize     = 0x0b
m_tableCount    = 0x1
m_tableOccupied = 0x1
m_tableMax      = 0x8

Опять же, не было возможности быть уверенным, но значения соответствовали ожидаемому макету и, казалось, указывали на то, что в таблице был сохранен один элемент!

m_table — хэш-таблица, связывающая токены методов и указатели на код IL:

Должен признаться, у меня были небольшие проблемы с выяснением структуры хеш-таблицы из исходного кода (полного шаблонов и другой магии C ++), поэтому я немного схитрил. Из вывода dumpmd я знал, что мой токен метода — 0x6000D24. Итак, я просто сбросил кучу памяти в ячейку памяти хеш-таблицы и поискал это значение:

(lldb) memory read --count 32 --size 4 --format x 0x00007fce8c938a90
0x7fce8c938a90: 0x00000000 0x00007fce 0x00000000 0x00000000
0x7fce8c938aa0: 0x00000000 0x00007fcf 0x00000000 0x00000000
0x7fce8c938ab0: 0x00000000 0x6265645b 0x00000000 0x00000000
0x7fce8c938ac0: 0x00000000 0x6974616c 0x00000000 0x00000000
0x7fce8c938ad0: 0x00000000 0x74636e75 0x00000000 0x00000000
0x7fce8c938ae0: 0x00000000 0x36303437 0x00000000 0x00000000
0x7fce8c938af0: 0x06000d24 0x00000000 0x8c983790 0x00007fce
0x7fce8c938b00: 0x00000000 0x7367704e 0x00000000 0x00000000

Оказалось, что значение было рядом с указателем (0x00007fce8c983790, в обратном направлении), поэтому была большая вероятность, что оно указывало на IL, который я искал!

Как это подтвердить? Каждый метод IL имеет заголовок, поэтому я декомпилировал исходный PostgresDatabaseInfo.LoadBackendTypes метод с помощью dnSpy, чтобы найти примечательное значение. Токен LocalVarSig имел значение 0x11000275:

Затем я сбросил несколько байтов по найденному адресу и поискал значение:

(lldb) memory read --count 8 --size 4 --format x 0x00007fce8c983790
0x7fce8c983790: 0x0002301b 0x000005fe 0x11000275 0x08007b02
0x7fce8c9837a0: 0x020a0400 0x0008037b 0x19060b04 0x0d167936

И, конечно же, совпало!

Следующим и последним шагом было сбросить IL и попытаться понять, почему он вызывает InvalidProgramException. Об этом и пойдет речь в следующей статье.

Понравилась статья? Поделиться с друзьями:
IT Шеф
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: