📜 ⬆️ ⬇️

Unnatural diagnostics


Dealing with the end-user program crashes is important, but rather difficult. There is usually no access to the client’s car; if there is access, then there is no debugger; when there is a debugger, it turns out that the problem is not reproduced, etc. What to do when there is not even an opportunity to build a special version of the application and install it to the client? Then welcome under the cat!

So, in terms of TRIZ, we have a technical contradiction: we need to change the program so that it writes logs / sends crash reports, but there is no possibility to change the program. Specify, there is no possibility to change it naturally, add the necessary functionality, rebuild and install the client. Therefore, we, following the precepts of the gore guru of cryptanalysis , will change it in an unnatural way!

We integrate our crash reporter into the program, including for such complex cases and wrote. Of course, no one bothers to use the following approaches to introduce another code into the program, which was not originally intended by the developers.

So, we need the managed application itself, in some “magic way”, to load the necessary assemblies and execute the initialization code:
')
LogifyAlert client = LogifyAlert.Instance; client.ApiKey = "my-api-key"; client.StartExceptionsHandling(); 

Well, we drove.

The “magic” technology we need, exists and is called DLL injection , and will be the loader that launches the application (or attaches to an already running one) and injects the DLL we need into the application process.

It looks like this

Interop Pack
 [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; 


We get access to the application process by the process identifier (PID), and we implement the DLL in it:

 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); 

If we ourselves have launched a child process, then for this, even administrator rights will not be needed. If attacked, you will have to attend to the rights:

 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; } 

And in the application manifest:

 <requestedExecutionLevel level="requireAdministrator" uiAccess="false" /> 

Next, find out the address of the LoadLibraryW function and call it in another process, indicating the name of the DLL that needs to be loaded. We get the address of the function in our process, and we make the call to the address in someone else’s. This rolls, since the library kernel32.dll has the same base address in all processes. Even if this once changes (which is unlikely), then we will show how to solve the problem in the case of different base addresses.

InjectDll and MakeRemoteCall Code
 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; } 

What kind of tin is written here? We need to pass a string parameter to the call to LoadLibraryW in another process. For this, the line must be written to the address space of another process, which VirtualAlloc and WriteProcessMemory are doing . Next, create a thread in another process, an address, that executes LoadLibraryW with the parameter we just wrote. We wait for the thread to complete and clean the memory.


But, unfortunately, the technology is applicable only for ordinary DLLs, and we have managed assemblies. Repin's painting "Sailed"!

The fact is that a managed assembly does not have an entry point, an analogue of DllMain , so even if we inject it into the process as a regular DLL, the assembly will not be able to automatically get control.

Is it possible to transfer control manually? Theoretically, there are 2 ways: use the module initializer , or export the function from the managed build and call it. I’ll say right away that neither can be done using standard C # tools. The initializer of the module can be screwed, for example, with the help of ModuleInit.Fody , but the trouble is that the initializer of the module itself will not be executed, you must first refer to some type in the assembly. As the cat Matroskin used to say: “To sell something unnecessary, you must first buy something unnecessary, but we have no money!”

For exports, theoretically, there is UnmanagedExports , but I didn’t get it to the meeting, and I’ve need to collect 2 differently-chopped versions of the managed assembly (AnyCPU is not supported), it pushed me away.

It seems that nothing shines in this direction. And if the tape wrap? And if you implement the unmanaged DLL process, and then try to call the managed assembly from it?

It turns out you can
 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; } 


It looks not so scary, and it seems like it promises to even make a call in the AppDomain by default. It is not clear, however, in which thread, but thanks to that.

Now we need to call this code from our bootloader.

Let us use the assumption that the offset of the function address from the address to which the DLL is loaded is a constant value for any process.

We load the necessary DLL into the process itself with the help of LoadLibrary , we get the base address. Find the address of the function being called via 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; } 

There is the last piece of the puzzle, find the base address of the DLL in another process:

 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; } 

And, finally, we get the address of the desired function in another process.

 long offset = GetMethodOffset(BootstrapDllPath, "InjectManagedAssembly"); InjectDll(procHandle, BootstrapDllPath); ulong baseAddr = GetRemoteModuleHandle(process, Path.GetFileName(BootstrapDllPath)); IntPtr remoteAddress = new IntPtr((long)(baseAddr + (ulong)offset)); 

Make a call to the received address, just as we called LoadLibrary in another process, via MakeRemoteCall (see above)

It is inconvenient that we can only transfer one line, and to call the managed assembly, you need to need as many as 4. In order not to reinvent the wheel, we will form the line as a command line, and on the unmanaged side without noise and dust we will use the system function 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; } 

Note also that the recalculation of the offset function implicitly assumes that the bitness of the loader processes and the target application is strictly the same. Those. we are not going anywhere, and we will have to do both 2 boot loader options (32 and 64 bits) and 2 unmanaged DLL options (simply because only the correct bitness DLL can be loaded into the process).

Therefore, when working under 64-bit OS, we add a check for the coincidence of the bitness of the processes. Own process:

 Environment.Is64BitProcess 

Alien process:

 [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; } 

We make managed assembly, with MessageBox display:

 public static int RunWinForms(string arg) { InitLogifyWinForms(); } static void InitLogifyWinForms() { MessageBox.Show("InitLogifyWinForms"); } 

We check, everything is called, MessageBox is shown. HOORAY!



Replace MessageBox with a test initialization of a crash reporter:

 static void InitLogifyWinForms() { try { LogifyAlert client = LogifyAlert.Instance; client.ApiKey = "my-api-key"; client.StartExceptionsHandling(); } catch (Exception ex) { } } 

We write a test WinForms application that causes an exception when a button is pressed.

 void button2_Click(object sender, EventArgs e) { object o = null; o.ToString(); } 

It seems to be all. We start, we check ... And silence. And along the road, dead with braids stand.

We insert the crash-reporter code directly into the test application, add references.

 static void Main() { InitLogifyWinForms(); Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new Form1()); } 

We check - it works, it means that the case is not in the initialization code. Maybe something is wrong with thread? Change:

 static void Main() { Thread thread = new Thread(InitLogifyWinForms); thread.Start(); Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new Form1()); } 

Check, works again. What is wrong ?! I have no answer to this question. Can someone else shed light on the reasons for this behavior of the AppDomain.UnhandledException event. Nevertheless, I found a workaround. We are waiting for the appearance of at least one window in the application and do BeginInvoke through the message queue of this window:

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; } } 



And, about a miracle, it was got. We note a serious disadvantage: for console applications it is inoperable.

It remains to bring up the brilliance, and teach the crash reporter to be configured from its own config-file. It turns out to do realistically, albeit unusually surprising:

 ExeConfigurationFileMap map = new ExeConfigurationFileMap(); map.ExeConfigFilename = configFileName; Configuration config = ConfigurationManager.OpenMappedExeConfiguration(map, ConfigurationUserLevel.None); 


We write a config
 <?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> 



Put it next to the exe-shnik application. Run, check, oops.



Which one The necessary assembly is already loaded into the process, but for some reason rantaym decided to look for it in a new way. We try to use the full name of the assembly, with the same success.

Frankly speaking, I didn’t investigate the reasons for this (IMHO, not quite logical) behavior. There are 2 ways to get around the problem: subscribe to AppDomain.AssemblyResolve and show the system where the desired assembly is located; or simply and simply copy the necessary assemblies into the directory with the exe-schnick. Mindful of the AppDomain.UnhandledException rake with strange behavior, I did not take risks and copied the assemblies.

Rebuild, try. Successfully configured and sends a crash report.



Further routine, we attach the CLI-interface to the loader and in general we comb the project.

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


Now we have in the arsenal of a tech support specialist a simple and oak tool that allows you to get a crash report of the application (as well as user actions that precede the fall), in which the crash reporter was not built in initially.

PS:

Sources on github .
If anyone is interested, the project site and documentation . Also introductory article about Logify here, on Habré.

Source: https://habr.com/ru/post/343536/


All Articles