
Unnatural diagnosis

Dealing with program crashes by end users is important, but rather difficult. Access to the client’s machine is usually not; 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 the opportunity to collect a special version of the application and install it to the client? Then welcome to kat!
So, in TRIZ terms we have a technical contradiction: we need to change the program so that it writes logs / sends crash reports, but there is no way to change the program. To clarify, there is no way to change it in a natural way, add the necessary functionality, rebuild and install the client. Therefore, we, following the precepts of the gurus of thermorectal cryptanalysis , will change it in an unnatural way!
We’ll embed our crash reporter in the program , including writing for such difficult cases. Of course, no one bothers to use the following approaches to introduce other code into the program that was not originally intended by the developers.
So, we need the managed application itself, in some “magical way”, to download the necessary assemblies and execute the initialization code:
LogifyAlert client = LogifyAlert.Instance;
client.ApiKey = "my-api-key";
client.StartExceptionsHandling();
Well, they drove.
The “magic” technology that we need exists and is called DLL-injection , and it will be a bootloader that launches the application (or attaches itself to an already launched one) and introduces the required DLL into the application process.
It looks like this
Pack of Interops
[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 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 launched a child process, then for this even administrator rights are not needed. If you attach, then you have to take care of 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:
Next, we find out the address of the LoadLibraryW function and call it in a foreign process, indicating the name of the DLL 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 works because the kernel32.dll library has the same base address in all processes. Even if this once changes (which is unlikely), it will be further shown how to resolve the issue in the case of different base addresses.
InjectDll and MakeRemoteCall Code
What kind of tin is written here? We need to pass the string parameter to the LoadLibraryW call in a foreign process. To do this, write the line into the address space of another process, which is what VirtualAlloc and WriteProcessMemory do . Next, we create a thread in another process, an address that executes LoadLibraryW with the parameter we just wrote. We are waiting for the completion of thread and we clean the memory.
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 the string parameter to the LoadLibraryW call in a foreign process. To do this, write the line into the address space of another process, which is what VirtualAlloc and WriteProcessMemory do . Next, we create a thread in another process, an address that executes LoadLibraryW with the parameter we just wrote. We are waiting for the completion of thread and we clean the memory.
But, unfortunately, the technology is applicable only to ordinary DLLs, and we have managed assemblies. Repin's painting “Sailed”!
The fact is that the managed assembly does not have an entry point, an analogue of DllMain , therefore, 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 module initializer , or export a function from a managed assembly and call it. I must say right away that using either standard C # means, neither one nor the other can be done. The module initializer can be fastened, for example, using ModuleInit.Fody, but the trouble is that the initializer of the module alone will not succeed, you must first turn to some type in the assembly. As the cat Matroskin used to say: “To sell something unnecessary, you first need to buy something unnecessary, but we don’t have money!”
For exports, theoretically, there are UnmanagedExports , but I didn’t get it, and I need to collect 2 different on the bitness of the managed assembly option (AnyCPU is not supported), it pushed me away.
It seems that nothing shines for us in this direction.
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 doesn’t look so scary, and it seems to promise to even make a call to AppDomain by default. It is not clear, however, in which thread, but thanks for that.
Now we need to call this code from our bootloader.
We use the assumption that the offset of the function address from the address at which the DLL is loaded is a constant for any process.
We load the necessary DLL into the process using LoadLibrary , we get the base address. Find the address of the called function through 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;
}
The last piece of the puzzle remains, 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));
We make a call to the received address, just as we called LoadLibrary in a foreign process, through MakeRemoteCall (see above). It is
inconvenient that we can only pass one line, and to call managed assembly we need as much as 4. To not 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 CommandLineToArgvW system function :
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;
}
We also note that recalculation of the offset of the function implicitly assumes that the bits of the bootloader and the target application are strictly identical. Those. We won’t get anywhere from bitness, and we will have to do both 2 variants of the bootloader (32 and 64 bits), and 2 variants of unmanaged DLL (simply because only DLLs with the correct bitness 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 a managed assembly, with a MessageBox showing:
public static int RunWinForms(string arg) {
InitLogifyWinForms();
}
static void InitLogifyWinForms() {
MessageBox.Show("InitLogifyWinForms");
}
We check, everything is called, MessageBox is shown. HURRAH!

Replace MessageBox with a trial initialization of the crash reporter:
static void InitLogifyWinForms() {
try {
LogifyAlert client = LogifyAlert.Instance;
client.ApiKey = "my-api-key";
client.StartExceptionsHandling();
}
catch (Exception ex) {
}
}
We are writing a test WinForms application that throws an exception when a button is clicked.
void button2_Click(object sender, EventArgs e) {
object o = null;
o.ToString();
}
It seems to be all. Run, check ... And silence.
We paste the crash reporter code directly into the test application, add the references.
static void Main() {
InitLogifyWinForms();
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
We check - it works, so it is not the initialization code that matters. Maybe something is wrong with the threads? Change:
static void Main() {
Thread thread = new Thread(InitLogifyWinForms);
thread.Start();
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
Check, it works again. What is wrong ?! I have no answer to this question. Maybe someone else can shed light on the reasons for this behavior of the AppDomain.UnhandledException event . Nevertheless, I found a workaround. We are waiting for at least one window to appear 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, lo and behold, it wound up. Note a serious minus: for console applications is inoperative.
It remains to bring shine, and teach the crash reporter to configure from its own config-file. It turns out to be real, albeit extremely tricky:
ExeConfigurationFileMap map = new ExeConfigurationFileMap();
map.ExeConfigFilename = configFileName;
Configuration config = ConfigurationManager.OpenMappedExeConfiguration(map, ConfigurationUserLevel.None);
We write a config
We put it next to the application exe-shnik. Run, check, oops.

Which one? The necessary assembly is already loaded into the process, and for some reason the runtime decided to look for it in a new way. We try to use the full name of the assembly, with the same success.
Honestly, I did not begin to 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 assembly is located; or simply and simply copy the necessary assemblies into a directory with an exe-shnik. Mindful of a rake with the strange behavior of AppDomain.UnhandledException , I did not take risks and copied the assembly.
Rebuild, try. Successfully configures and sends a crash report.

Next, the routine, we attach the CLI-interface to the bootloader and generally 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 the tech support specialist a simple and oak-based tool that allows you to get a crash report of the application (as well as user actions preceding the crash), in which the crash reporter was not originally built in.
PS:
Sources on github .
If anyone is interested, the project website and documentation . Also introductory article about Logify here on Habré.