Разбираться с падениями программы у конечных пользователей — дело важное, но довольно тяжкое. Доступа к машине клиента обычно нет; если есть доступ, то нет отладчика; когда есть отладчик, оказывается, что проблема не воспроизводится и т.п. Что делать, когда нет даже возможности собрать специальную версию приложения и установить её клиенту? Тогда добро пожаловать под кат!
Итак, в терминах ТРИЗ имеем техническое противоречие: нам необходимо изменить программу, чтобы она писала логи/отправляла крэшрепорты, но возможности изменить программу нет. Уточним, нет возможности изменить её естественным путём, добавить нужный функционал, пересобрать и установить клиенту. Поэтому, мы, следуя заветам гуру терморектального криптоанализа, изменим её противоестественным путём!
Встроим в программу свой крэш-репортер, в том числе для таких сложных случаев и писали. Разумеется, никто не мешает использовать приведённые далее подходы для внедрения в программу другого кода, изначально непредусмотренного разработчиками.
Итак, нам надо, чтобы managed приложение само, каким-то «волшебным образом», загрузило необходимые сборки и выполнило код инициализации:
LogifyAlert client = LogifyAlert.Instance;
client.ApiKey = "my-api-key";
client.StartExceptionsHandling();
Что-ж, погнали.
Необходимая нам, «волшебная» технология существует и называется DLL-injection, и будет представлять из себя загрузчик, который запустит приложение (или приаттачится к уже запущенному), и внедрит в процесс приложения нужную нам DLL.
Выгдядит это следующим образом
[DllImport("kernel32.dll")]
static extern IntPtr OpenProcess(int dwDesiredAccess, bool bInheritHandle, int dwProcessId);
[DllImport("kernel32.dll", CharSet = CharSet.Auto)]
static extern IntPtr GetModuleHandle(string lpModuleName);
[DllImport("kernel32", CharSet = CharSet.Ansi, ExactSpelling = true, SetLastError = true)]
static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
[DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)]
static extern IntPtr VirtualAllocEx(IntPtr hProcess, IntPtr lpAddress, uint dwSize, AllocationType flAllocationType, uint flProtect);
[DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)]
static extern bool VirtualFreeEx(IntPtr hProcess, IntPtr lpAddress, uint dwSize, AllocationType dwFreeType);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, uint nSize, out UIntPtr lpNumberOfBytesWritten);
[DllImport("kernel32.dll")]
static extern IntPtr CreateRemoteThread(IntPtr hProcess,
IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId);
[DllImport("kernel32.dll", SetLastError = true)]
static extern UInt32 WaitForSingleObject(IntPtr hHandle, UInt32 dwMilliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
[SuppressUnmanagedCodeSecurity]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool CloseHandle(IntPtr hObject);
[Flags]
public enum AllocationType {
ReadWrite = 0x0004,
Commit = 0x1000,
Reserve = 0x2000,
Decommit = 0x4000,
Release = 0x8000,
Reset = 0x80000,
Physical = 0x400000,
TopDown = 0x100000,
WriteWatch = 0x200000,
LargePages = 0x20000000
}
public const uint PAGE_READWRITE = 4;
public const UInt32 INFINITE = 0xFFFFFFFF;
Получаем доступ к процессу приложения по идентификатору процесса (PID), и внедряем в него DLL-ку:
int access = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION |
PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ;
IntPtr procHandle = OpenProcess(access, false, dwProcessId);
InjectDll(procHandle, BootstrapDllPath);
Если мы сами запустили дочерний процесс, то для этого даже права администратора не понадобятся. Если приаттачились, то придется озаботиться правами:
static Process AttachToTargetProcess(RunnerParameters parameters) {
if (!String.IsNullOrEmpty(parameters.TargetProcessCommandLine))
return StartTargetProcess(parameters.TargetProcessCommandLine,
parameters.TargetProcessArgs);
else if (parameters.Pid != 0) {
Process.EnterDebugMode();
return Process.GetProcessById(parameters.Pid);
}
else
return null;
}
И в манифесте приложения:
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
Далее узнаем адрес функции LoadLibraryW и вызываем её в чужом процессе, указывая имя DLL-ки, которую надо загрузить. Адрес функции мы получаем в своём процессе, а вызов по адресу делаем в чужом. Это прокатывает, так как библиотека kernel32.dll во всех процессах имеет один и тот же базовый адрес. Даже если это когда-то изменится (что вряд ли), далее будет показано, как можно решить вопрос в случае разных базовых адресов.
static bool InjectDll(IntPtr procHandle, string dllName) {
const string libName = "kernel32.dll";
const string procName = "LoadLibraryW";
IntPtr loadLibraryAddr = GetProcAddress(GetModuleHandle(libName), procName);
if (loadLibraryAddr == IntPtr.Zero) {
return false;
}
return MakeRemoteCall(procHandle, loadLibraryAddr, dllName);
}
static bool MakeRemoteCall(IntPtr procHandle, IntPtr methodAddr, string argument) {
uint textSize = (uint)Encoding.Unicode.GetByteCount(argument);
uint allocSize = textSize + 2;
IntPtr allocMemAddress;
AllocationType allocType = AllocationType.Commit | AllocationType.Reserve;
allocMemAddress = VirtualAllocEx(procHandle,
IntPtr.Zero,
allocSize,
allocType,
PAGE_READWRITE);
if (allocMemAddress == IntPtr.Zero)
return false;
UIntPtr bytesWritten;
WriteProcessMemory(procHandle,
allocMemAddress,
Encoding.Unicode.GetBytes(argument),
textSize,
out bytesWritten);
bool isOk = false;
IntPtr threadHandle;
threadHandle = CreateRemoteThread(procHandle,
IntPtr.Zero,
0,
methodAddr,
allocMemAddress,
0,
IntPtr.Zero);
if (threadHandle != IntPtr.Zero) {
WaitForSingleObject(threadHandle, Win32.INFINITE);
isOk = true;
}
VirtualFreeEx(procHandle, allocMemAddress, allocSize, AllocationType.Release);
if (threadHandle != IntPtr.Zero)
Win32.CloseHandle(threadHandle);
return isOk;
}
Что за жесть тут написана? Нам надо передать строковый параметр в вызов LoadLibraryW в чужом процессе. Для этого строчку надо записать в адресное пространство чужого процесса, чем и занимаются VirtualAlloc и WriteProcessMemory. Далее создаём thread в чужом процессе, адресом, выполняющий LoadLibraryW с параметром, который мы только что записали. Дожидаемся завершения thread и чистим за собой память.
Но, к сожалению, технология применима только для обычных DLL, а у нас managed-сборки. Картина Репина «Приплыли»!
Дело в том, что у managed-сборки нет точки входа, аналога DllMain, поэтому, даже если мы внедрим её в процесс как обычную DLL, сборка не сможет автоматически получить управление.
Можно ли передать управление вручную? Теоретически есть 2 пути: использовать module initializer, или экспортировать функцию из managed-сборки и позвать её. Сразу скажу, что штатными средствами C# ни то, ни другое сделать нельзя. Инициализатор модуля можно прикрутить, например, при помощи ModuleInit.Fody, но беда в том, что инициализатор модуля сам по себе не выполнится, надо сперва обратиться к какому-нибудь типу в сборке. Как говаривал кот Матроскин: «Чтобы продать что-нибудь ненужное, нужно сначала купить что-нибудь ненужное, а у нас денег нет!»
Для экспортов, теоретически, есть UnmanagedExports, но у меня оно слёту не завелось, да необходимость и собирать 2 различных по битности варианта managed сборки (AnyCPU не поддерживается), меня оттолкнуло.
Похоже, в этом направлении нам уже ничего не светит. А если изолентой обмотать? А если внедрить в процесс unmanaged DLL, а уже из неё попробовать позвать managed сборку?
HRESULT InjectDotNetAssembly(
/* [in] */ LPCWSTR pwzAssemblyPath,
/* [in] */ LPCWSTR pwzTypeName,
/* [in] */ LPCWSTR pwzMethodName,
/* [in] */ LPCWSTR pwzArgument
) {
HRESULT result;
ICLRMetaHost *metaHost = NULL;
ICLRRuntimeInfo *runtimeInfo = NULL;
ICLRRuntimeHost *runtimeHost = NULL;
// Load .NET
result = CLRCreateInstance(CLSID_CLRMetaHost, IID_PPV_ARGS(&metaHost));
result = metaHost->GetRuntime(L"v4.0.30319", IID_PPV_ARGS(&runtimeInfo));
result = runtimeInfo->GetInterface(CLSID_CLRRuntimeHost,
IID_PPV_ARGS(&runtimeHost));
result = runtimeHost->Start();
// Execute managed assembly
DWORD returnValue;
result = runtimeHost->ExecuteInDefaultAppDomain(
pwzAssemblyPath,
pwzTypeName,
pwzMethodName,
pwzArgument,
&returnValue);
if (metaHost != NULL)
metaHost->Release();
if (runtimeInfo != NULL)
runtimeInfo->Release();
if (runtimeHost != NULL)
runtimeHost->Release();
return result;
}
Выглядит не так уж страшно, и вроде как обещает даже сделать вызов в AppDomain-е по умолчанию. Непонятно, правда, в каком thread, но и на том спасибо.
Теперь нам надо вызвать этот код из нашего загрузчика.
Воспользуемся допущением о том, что смещение адреса функции от адреса, по которому загружена DLL, есть величина постоянная для любого процесса.
Загружаем нужную DLL себе в процесс при помощи LoadLibrary, получаем базовый адрес. Находим адрес вызываемой функции через GetProcAddress.
static long GetMethodOffset(string dllPath, string methodName) {
IntPtr hLib = Win32.LoadLibrary(dllPath);
if (hLib == IntPtr.Zero)
return 0;
IntPtr call = Win32.GetProcAddress(hLib, methodName);
if (call == IntPtr.Zero)
return 0;
long result = call.ToInt64() - hLib.ToInt64();
Win32.FreeLibrary(hLib);
return result;
}
Остался последний кусочек пазла, найти базовый адрес DLL в чужом процессе:
static ulong GetRemoteModuleHandle(Process process, string moduleName) {
int count = process.Modules.Count;
for (int i = 0; i < count; i++) {
ProcessModule module = process.Modules[i];
if (module.ModuleName == moduleName)
return (ulong)module.BaseAddress;
}
return 0;
}
И, наконец, получаем адрес нужной функции в чужом процессе.
long offset = GetMethodOffset(BootstrapDllPath, "InjectManagedAssembly");
InjectDll(procHandle, BootstrapDllPath);
ulong baseAddr = GetRemoteModuleHandle(process, Path.GetFileName(BootstrapDllPath));
IntPtr remoteAddress = new IntPtr((long)(baseAddr + (ulong)offset));
Делаем вызов по полученному адресу, точно так же, как вызывали LoadLibrary в чужом процессе, через MakeRemoteCall (см. выше)
Неудобно то, что мы можем передать только одну строку, а для вызова managed сборки надо понадобится аж 4. Чтобы не изобретать велосипед, сформируем строку как command line, а на unmanaged стороне без шума и пыли воспользуемся системной функцией CommandLineToArgvW:
HRESULT InjectManagedAssemblyCore(_In_ LPCWSTR lpCommand) {
LPWSTR *szArgList;
int argCount;
szArgList = CommandLineToArgvW(lpCommand, &argCount);
if (szArgList == NULL || argCount < 3)
return E_FAIL;
LPCWSTR param;
if (argCount >= 4)
param = szArgList[3];
else
param = L"";
HRESULT result = InjectDotNetAssembly(
szArgList[0],
szArgList[1],
szArgList[2],
param
);
LocalFree(szArgList);
return result;
}
Заметим также, что пересчёт смещения функции неявно предполагает, что битность процессов загрузчика и целевого приложения строго одинакова. Т.е. никуда мы от битности не денемся, и нам придётся делать как 2 варианта загрузчика (32 и 64 бит), так и 2 варианта unmanaged DLL (просто потому, что в процесс можно загрузить только DLL правильной битности).
Поэтому, при работе под 64-разрядной OS, добавим проверку на совпадение битности процессов. Свой процесс:
Environment.Is64BitProcess
Чужой процесс:
[DllImport("kernel32.dll", CallingConvention = CallingConvention.Winapi)]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool IsWow64Process([In] IntPtr process, [Out] out bool wow64Process);
public static bool Is64BitProcess(Process process) {
bool isWow64;
if (!IsWow64Process(process.Handle, out isWow64)) {
return false;
}
return !isWow64;
}
static bool IsCompatibleProcess(Process process) {
if (!Environment.Is64BitOperatingSystem)
return true;
bool is64bitProcess = Is64BitProcess(process);
return Environment.Is64BitProcess == is64bitProcess;
}
Делаем managed сборку, с показом MessageBox-а:
public static int RunWinForms(string arg) {
InitLogifyWinForms();
}
static void InitLogifyWinForms() {
MessageBox.Show("InitLogifyWinForms");
}
Проверяем, всё вызывается, MessageBox показывается. УРА!
Заменяем MessageBox на пробную инициализацию крэш-репортера:
static void InitLogifyWinForms() {
try {
LogifyAlert client = LogifyAlert.Instance;
client.ApiKey = "my-api-key";
client.StartExceptionsHandling();
}
catch (Exception ex) {
}
}
Пишем тестовое WinForms приложение, которое вызывает исключение при нажатии кнопки.
void button2_Click(object sender, EventArgs e) {
object o = null;
o.ToString();
}
Вроде бы всё. Запускаем, проверяем… И тишина. А вдоль дороги, мёртвые с косами стоят.
Вставляем код крэш-репортера прямо в тестовое приложение, добавляем референсы.
static void Main() {
InitLogifyWinForms();
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
Проверяем – работает, значит не в коде инициализации дело. Может с thread-ами что-то не так? Меняем:
static void Main() {
Thread thread = new Thread(InitLogifyWinForms);
thread.Start();
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
Проверяем, опять работает. Что же не так?! У меня нет ответа на этот вопрос. Может кто-нибудь другой сумеет пролить свет на причины такого поведения события AppDomain.UnhandledException. Тем не менее обходное решение я нашёл. Ждём появления хотя бы одного окна в приложении и делаем BeginInvoke через очередь сообщения этого окна:
public static int RunWinForms(string arg) {
bool isOk = false;
try {
const int totalTimeout = 5000;
const int smallTimeout = 1000;
int count = totalTimeout / smallTimeout;
for (int i = 0; i < count; i++) {
if (Application.OpenForms == null || Application.OpenForms.Count <= 0)
Thread.Sleep(smallTimeout);
else {
Delegate call = new InvokeDelegate(InitLogifyWinForms);
Application.OpenForms[0].BeginInvoke(call);
isOk = true;
break;
}
}
if (!isOk) {
InitLogifyWinForms();
}
return 0;
}
catch {
return 1;
}
}
И, о чудо, оно завелось. Отметим серьёзный минус: для консольных приложений неработоспособно.
Осталось навести блеск, и научить крэшрепортер конфигурироваться из собственного config-файла. Оказыватся сделать реально, хоть и на редкость мудрёно:
ExeConfigurationFileMap map = new ExeConfigurationFileMap();
map.ExeConfigFilename = configFileName;
Configuration config = ConfigurationManager.OpenMappedExeConfiguration(map, ConfigurationUserLevel.None);
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<configSections>
<section name="logifyAlert" type="DevExpress.Logify.LogifyConfigSection, Logify.Alert.Win"/>
</configSections>
<logifyAlert>
<collectBreadcrumbs value="1" />
<breadcrumbsMaxCount value="500" />
<apiKey value="my-api-key"/>
<confirmSend value="false"/>
<offlineReportsEnabled value="false"/>
<offlineReportsDirectory value="offlineReports"/>
<offlineReportsCount value="10"/>
</logifyAlert>
</configuration>
Кладём его рядом с exe-шником приложения. Запускаем, проверяем, упс.
Какого? Нужная сборка уже загружена в процесс, а рантайм почему-то решил поискать её по-новой. Пробуем использовать полное имя сборки, с тем же успехом.
Честно говоря, исследовать причины подобного (ИМХО, не вполне логичного) поведения не стал. Есть 2 пути обойти проблему: подписаться на AppDomain.AssemblyResolve и показать системе, где находится искомая сборка; или же просто и незатейливо подкопировать нужные сборки в каталог с exe-шником. Памятуя граблях со странным поведением AppDomain.UnhandledException, не стал рисковать и подкопировал сборки.
Пересобираем, пробуем. Успешно конфигурится и присылает крэш репорт.
Далее рутина, приделываем CLI-интерфейс к загрузчику и в целом причёсываем проект.
LogifyRunner (C) 2017 DevExpress Inc.
Usage:
LogifyRunner.exe [--win] [--wpf] [--pid=value>] [--exec=value1, ...]
--win Target process is WinForms application
--wpf Target process is WPF application
--pid Target process ID. Runner will be attached to process with specified ID.
--exec Target process command line
NOTE: logify.config file should be placed to the same directory where the target process executable or LogifyRunner.exe is located.
Read more about config file format at: https://github.com/DevExpress/Logify.Alert.Clients/tree/develop/dotnet/Logify.Alert.Win#configuration
Теперь имеем в арсенале специалиста техподдержки простое и дубовое средство, позволяющее получить крэш-репорт приложения (а также действия пользователя, предшествующие падению), в которое крэш-репортер изначально не был встроен.
PS:
Исходники на github.
Если кому интересно, сайт проекта и документация. Также вводная статья про Logify здесь, на Хабре.
Автор: Антон Миронов