Противоестественная диагностика

в 12:01, , рубрики: .net, C#, devexpress, logify, WinAPI, Блог компании DevExpress, Программирование

Противоестественная диагностика - 1

Разбираться с падениями программы у конечных пользователей — дело важное, но довольно тяжкое. Доступа к машине клиента обычно нет; если есть доступ, то нет отладчика; когда есть отладчик, оказывается, что проблема не воспроизводится и т.п. Что делать, когда нет даже возможности собрать специальную версию приложения и установить её клиенту? Тогда добро пожаловать под кат!

Итак, в терминах ТРИЗ имеем техническое противоречие: нам необходимо изменить программу, чтобы она писала логи/отправляла крэшрепорты, но возможности изменить программу нет. Уточним, нет возможности изменить её естественным путём, добавить нужный функционал, пересобрать и установить клиенту. Поэтому, мы, следуя заветам гуру терморектального криптоанализа, изменим её противоестественным путём!

Встроим в программу свой крэш-репортер, в том числе для таких сложных случаев и писали. Разумеется, никто не мешает использовать приведённые далее подходы для внедрения в программу другого кода, изначально непредусмотренного разработчиками.

Итак, нам надо, чтобы managed приложение само, каким-то «волшебным образом», загрузило необходимые сборки и выполнило код инициализации:

LogifyAlert client = LogifyAlert.Instance;
client.ApiKey = "my-api-key";
client.StartExceptionsHandling();

Что-ж, погнали.

Необходимая нам, «волшебная» технология существует и называется DLL-injection, и будет представлять из себя загрузчик, который запустит приложение (или приаттачится к уже запущенному), и внедрит в процесс приложения нужную нам DLL.

Выгдядит это следующим образом

Пачка Interop-ов

[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 во всех процессах имеет один и тот же базовый адрес. Даже если это когда-то изменится (что вряд ли), далее будет показано, как можно решить вопрос в случае разных базовых адресов.

Код InjectDll и MakeRemoteCall

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 показывается. УРА!

Противоестественная диагностика - 2

Заменяем 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 через очередь сообщения этого окна:

Workaround, 18+

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-шником приложения. Запускаем, проверяем, упс.

Противоестественная диагностика - 3

Какого? Нужная сборка уже загружена в процесс, а рантайм почему-то решил поискать её по-новой. Пробуем использовать полное имя сборки, с тем же успехом.

Честно говоря, исследовать причины подобного (ИМХО, не вполне логичного) поведения не стал. Есть 2 пути обойти проблему: подписаться на AppDomain.AssemblyResolve и показать системе, где находится искомая сборка; или же просто и незатейливо подкопировать нужные сборки в каталог с exe-шником. Памятуя граблях со странным поведением AppDomain.UnhandledException, не стал рисковать и подкопировал сборки.

Пересобираем, пробуем. Успешно конфигурится и присылает крэш репорт.

Противоестественная диагностика - 4

Далее рутина, приделываем CLI-интерфейс к загрузчику и в целом причёсываем проект.

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 здесь, на Хабре.

Автор: Антон Миронов

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js