Технологически в этой статье ничего нового, просто еще одно полезное применение winapi-хуков для решения специфичной проблемы.
При работе с веб-проектами в Visual Studio существует одна неприятная мелочь — при использовании в процессе разработки нескольких бранчей, каждый из которых должен использовать свою копию окружения (например базу данных, или какие то внешние сервисы), возникает проблема с конфигурационными файлами в момент отладки — IISExpress использует только основной web.config в папке проекта, где обычно всякие connection strings содержат значения по умолчанию и где нет никаких специфичных для бранча настроек, и никаких трансформаций при запуске к нему не применяется. Можно конечно принудительно либо автоматически, либо вручную, применять трансформации к web.config, но во-первых измененный файл будет постоянно висеть в pending changes, что создает риск коммита нежелательных изменений, которые потом попадут в другие бранчи, а во-вторых это создает массу неудобств при его редактировании, поскольку перед коммитом каких-либо изменений в конфигурационном файле такие трансформации придется вручную убирать.
Рассмотрим как этого избежать.
Решение довольно простое — необходимо перехватывать чтение конфигурационного файла процессом IISExpress, и вместо исходного файла подсунуть другой, временный файл в котором внесены соответствующие исправления, и который не добавлен в Source Control. Список исправлений которые необходимо применить в зависимости от того из какой папки запускается проект можно указывать, к примеру, в простом xml файле.
Для этого понадобятся:
Фоновая утилита следящая за созданием новых процессов, 32 и 64-битные dll c хуками, 32 и 64-битные exe запускаемые фоновой утилитой и загружающие соответствующую dll с хуком в процесс соответствующей разрядности.
Фоновая утилита следит за процессами через WMI используя класс ManagementEventWatcher и запрос к __InstanceOperationEvent с фильтрацией по типу объекта Win32_Process и требуемым именам процессов. Получение события ____InstanceCreationEvent означает что был создан процесс, информацию о котором можно получить из EventArrivedEventArgs.NewEvent. В данном случае необходим только ProcessId.
Алгоритм загрузки dll в чужой процесс стандартный — в чужом процессе через VirtualAllocEx выделяется память под путь к dll и создается ��оток путем передачи адреса LoadLibrary в CreateRemoteThread. Если код инициализации хуков находится в DllMain, то никаких дополнительных действий не понадобится. Но фоновая утилита самостоятельно внедрять dll и в 32 и в 64-битные процессы одновременно не сможет. Теоретически конечно вызов CreateRemoteThread из 64-битного процесса может создать поток в 32-битном процессе, но в данном случае в качестве функции для потока используется LoadLibrary. А максимально простым способом через GetProcAddress можно получить ее адрес только для той же разрядности что и текущий процесс. У kernel32.dll fixed base address, поэтому для разных процессов одной разрядности адрес функции совпадает. В теории конечно можно бы было вручную разбирать PE-заголовки и не использовать дополнительные процесы для внедрения, но это сложнее.
Перехватывать надо конечно же функцию CreateFileW. Сначала я весь код написал полностью на C#, но на практике с перехватом некоторых слишком фундаментальных функций вроде этой возникают ошибки с loader lock и им подобные, когда managed код вызывается, например, из DllMain каких-либо сторонних библиотек подгружаемых процессом. Поэтому пришлось установку хуков и фильтрацию вызовов требующих обработки вынести в native dll на C, которая в свою очередь загружает managed dll и вызывает managed код оттуда только когда CreateFileW вызывается для .config файлов. Для установки хуков я использовал проверенную временем стороннюю библиотеку MinHook, про нее в интернете много информации и останавливаться на ее описании не буду. Возможно, у кого то возникнет вопрос — 'а не проще ли было все полностью сделать на C и не создавать кучу .net сборок', возможно да, но это скучно.
Логика фильтрации должна проверять что файл существует, является файлом а не директорией, содержит в имени web.config, не расположен в папке windows\microsoft.net\… (системные конфиги нас не интересуют). Если все эти условия выполняются то передаем HANDLE, полученный от вызова исходной системной функции CreateFileW перехватчиком, а также все параметры CreateFileW в managed обработчик, который по этому HANDLE прочтет содержимое. Для простоты лучше использовать оригинальный HANDLE чтобы не делать защиту от рекурсии, к которой приведет чтение того же самого файла каким-нибудь File.ReadAllText, поскольку сработает этот же хук. Далее, в полученном содержимом заменяются все необходимые строки и измененное содержимое записывается во временный файл у которого имя не соответствует вышеописанным критериям фильтрации (опять же чтобы не попасть в рекурсию). Вызываем CreateFileW для этого временного файла с теми же параметрами с которыми был открыт web.config и полученный HANDLE возвращаем из перехватчика CreateFileW. Исходный HANDLE уже не нужен и его следует закрыть.
Native dll с хуком вызывает обработчик из managed dll через LoadLibrary и GetProcAddress. Для этого необходимо экспортировать статический метод как обычную dll функцию. Это делается немного шаманским способом через дизассемблирование ildasm-ом, добавление специальных опций к методу в il-коде и ассемблирование обратно в dll. Об этом тоже в интернете много статей, повторяться не буду, их легко найти поискав например ".vtentry". В исходном коде присутствует простейшая утилита обрабатывающая таким образом сборки.
Помимо IISExpress схожая проблема актуальна и для wcf-сервисов запускаемых через wcfsvchost. Правда в этом случае применение трансформаций работает нормально, но чтобы все было единообразно и чтобы не клонировать лишних файлов с трансформациями и не переключать ничего в Configuration Manager-е, рассмотрим и этот случай. Здесь есть некоторые отличия — wcfsvchost читает конфигурацию сразу при старте процесса, а WMI событие приходит слишком поздно, и хук устанавливается позже чем надо. Но путь к файлу конфигурации передается через командную строку, поэтому внедряться следует в parent-процесс и перехватывать CreateProcessW.
Parent-процесс в данном случае devenv.exe, т.е. Visual Studio. В этом случае, перед тем как вызвать исходную системную функцию CreateProcessW, в managed обработчик передаем строку параметров с которыми создается процесс и адрес массива куда запишется исправленная строка. В обработчике строка разбивается на параметры путем вызова CommandLineToArgvW, далее среди них определяется путь к файлу конфигурации, а затем аналогично создается временный файл с исправленным содержимым и путь в параметрах подменяется на него.
→ Код к статье (перфекционистов просьба не возмущаться — это минимально рабочий вариант сделанный наспех, без обработки ошибок и со многими допущениями)
При работе с веб-проектами в Visual Studio существует одна неприятная мелочь — при использовании в процессе разработки нескольких бранчей, каждый из которых должен использовать свою копию окружения (например базу данных, или какие то внешние сервисы), возникает проблема с конфигурационными файлами в момент отладки — IISExpress использует только основной web.config в папке проекта, где обычно всякие connection strings содержат значения по умолчанию и где нет никаких специфичных для бранча настроек, и никаких трансформаций при запуске к нему не применяется. Можно конечно принудительно либо автоматически, либо вручную, применять трансформации к web.config, но во-первых измененный файл будет постоянно висеть в pending changes, что создает риск коммита нежелательных изменений, которые потом попадут в другие бранчи, а во-вторых это создает массу неудобств при его редактировании, поскольку перед коммитом каких-либо изменений в конфигурационном файле такие трансформации придется вручную убирать.
Рассмотрим как этого избежать.
Решение довольно простое — необходимо перехватывать чтение конфигурационного файла процессом IISExpress, и вместо исходного файла подсунуть другой, временный файл в котором внесены соответствующие исправления, и который не добавлен в Source Control. Список исправлений которые необходимо применить в зависимости от того из какой папки запускается проект можно указывать, к примеру, в простом xml файле.
Для этого понадобятся:
Фоновая утилита следящая за созданием новых процессов, 32 и 64-битные dll c хуками, 32 и 64-битные exe запускаемые фоновой утилитой и загружающие соответствующую dll с хуком в процесс соответствующей разрядности.
Фоновая утилита следит за процессами через WMI используя класс ManagementEventWatcher и запрос к __InstanceOperationEvent с фильтрацией по типу объекта Win32_Process и требуемым именам процессов. Получение события ____InstanceCreationEvent означает что был создан процесс, информацию о котором можно получить из EventArrivedEventArgs.NewEvent. В данном случае необходим только ProcessId.
processWatcher.Query.QueryString = @"SELECT * FROM __InstanceOperationEvent WITHIN 1" + "WHERE TargetInstance ISA 'Win32_Process' AND (" + string.Join(" OR ", processNames.Select(x => "TargetInstance.Name = '" + x + ".exe'")) + ")"; processWatcher.EventArrived += (sender, e) => { if (e.NewEvent.ClassPath.ClassName == "__InstanceCreationEvent") { var processId = (uint)((ManagementBaseObject)e.NewEvent["TargetInstance"]) .Properties["ProcessId"].Value; // ... Do smth useful } };
Алгоритм загрузки dll в чужой процесс стандартный — в чужом процессе через VirtualAllocEx выделяется память под путь к dll и создается ��оток путем передачи адреса LoadLibrary в CreateRemoteThread. Если код инициализации хуков находится в DllMain, то никаких дополнительных действий не понадобится. Но фоновая утилита самостоятельно внедрять dll и в 32 и в 64-битные процессы одновременно не сможет. Теоретически конечно вызов CreateRemoteThread из 64-битного процесса может создать поток в 32-битном процессе, но в данном случае в качестве функции для потока используется LoadLibrary. А максимально простым способом через GetProcAddress можно получить ее адрес только для той же разрядности что и текущий процесс. У kernel32.dll fixed base address, поэтому для разных процессов одной разрядности адрес функции совпадает. В теории конечно можно бы было вручную разбирать PE-заголовки и не использовать дополнительные процесы для внедрения, но это сложнее.
Перехватывать надо конечно же функцию CreateFileW. Сначала я весь код написал полностью на C#, но на практике с перехватом некоторых слишком фундаментальных функций вроде этой возникают ошибки с loader lock и им подобные, когда managed код вызывается, например, из DllMain каких-либо сторонних библиотек подгружаемых процессом. Поэтому пришлось установку хуков и фильтрацию вызовов требующих обработки вынести в native dll на C, которая в свою очередь загружает managed dll и вызывает managed код оттуда только когда CreateFileW вызывается для .config файлов. Для установки хуков я использовал проверенную временем стороннюю библиотеку MinHook, про нее в интернете много информации и останавливаться на ее описании не буду. Возможно, у кого то возникнет вопрос — 'а не проще ли было все полностью сделать на C и не создавать кучу .net сборок', возможно да, но это скучно.
Логика фильтрации должна проверять что файл существует, является файлом а не директорией, содержит в имени web.config, не расположен в папке windows\microsoft.net\… (системные конфиги нас не интересуют). Если все эти условия выполняются то передаем HANDLE, полученный от вызова исходной системной функции CreateFileW перехватчиком, а также все параметры CreateFileW в managed обработчик, который по этому HANDLE прочтет содержимое. Для простоты лучше использовать оригинальный HANDLE чтобы не делать защиту от рекурсии, к которой приведет чтение того же самого файла каким-нибудь File.ReadAllText, поскольку сработает этот же хук. Далее, в полученном содержимом заменяются все необходимые строки и измененное содержимое записывается во временный файл у которого имя не соответствует вышеописанным критериям фильтрации (опять же чтобы не попасть в рекурсию). Вызываем CreateFileW для этого временного файла с теми же параметрами с которыми был открыт web.config и полученный HANDLE возвращаем из перехватчика CreateFileW. Исходный HANDLE уже не нужен и его следует закрыть.
Хук
HANDLE WINAPI _CreateFileW( LPCWSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, LPSECURITY_ATTRIBUTES lpSecurityAttributes, DWORD dwCreationDisposition, DWORD dwFlagsAndAttributes, HANDLE hTemplateFile) { DWORD attributes = GetFileAttributesW(lpFileName); HANDLE result = CreateFileWOriginal(lpFileName, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); HANDLE newFile = NULL; if (attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 && (StrStrI(lpFileName, L"web.config") != NULL || StrStrI(lpFileName, L"app.config") != NULL) && StrStrI(lpFileName, L"Windows") == NULL) { fileHandler(result, &newFile, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); } if (newFile != NULL) { CloseHandle(result); result = newFile; } return result; }
Managed обработчик
public static void GetUpdatedConfigF(IntPtr handle, IntPtr newHandleAddress, uint access, uint share, IntPtr securityAttributes, uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile) { try { if (config == null) return; var path = new StringBuilder(260); if (GetFinalPathNameByHandle(handle, path, (uint)path.Capacity, 0) == 0) return; var matchedSection = config.FirstOrDefault(x => path.ToString().IndexOf(x.Branch, StringComparison.OrdinalIgnoreCase) >= 0); if (matchedSection == null) return; var size = GetFileSize(handle, IntPtr.Zero); if (size == 0) return; var buffer = new byte[size]; uint bytesRead; if (!ReadFile(handle, buffer, (uint)buffer.Length, out bytesRead, IntPtr.Zero)) return; var content = Encoding.UTF8.GetString(buffer); foreach (var replacement in matchedSection.Replacements) content = content.Replace(replacement.Find, replacement.ReplaceWith); var tempFile = Path.GetTempFileName(); MoveFileEx(tempFile, null, 4); File.WriteAllText(tempFile, content); var newHandle = CreateFileW(tempFile, access, share, securityAttributes, creationDisposition, flagsAndAttributes, templateFile); Marshal.WriteIntPtr(newHandleAddress, newHandle); } catch { } }
Native dll с хуком вызывает обработчик из managed dll через LoadLibrary и GetProcAddress. Для этого необходимо экспортировать статический метод как обычную dll функцию. Это делается немного шаманским способом через дизассемблирование ildasm-ом, добавление специальных опций к методу в il-коде и ассемблирование обратно в dll. Об этом тоже в интернете много статей, повторяться не буду, их легко найти поискав например ".vtentry". В исходном коде присутствует простейшая утилита обрабатывающая таким образом сборки.
Помимо IISExpress схожая проблема актуальна и для wcf-сервисов запускаемых через wcfsvchost. Правда в этом случае применение трансформаций работает нормально, но чтобы все было единообразно и чтобы не клонировать лишних файлов с трансформациями и не переключать ничего в Configuration Manager-е, рассмотрим и этот случай. Здесь есть некоторые отличия — wcfsvchost читает конфигурацию сразу при старте процесса, а WMI событие приходит слишком поздно, и хук устанавливается позже чем надо. Но путь к файлу конфигурации передается через командную строку, поэтому внедряться следует в parent-процесс и перехватывать CreateProcessW.
Parent-процесс в данном случае devenv.exe, т.е. Visual Studio. В этом случае, перед тем как вызвать исходную системную функцию CreateProcessW, в managed обработчик передаем строку параметров с которыми создается процесс и адрес массива куда запишется исправленная строка. В обработчике строка разбивается на параметры путем вызова CommandLineToArgvW, далее среди них определяется путь к файлу конфигурации, а затем аналогично создается временный файл с исправленным содержимым и путь в параметрах подменяется на него.
Хук
BOOL WINAPI _CreateProcessW(LPCWSTR lpApplicationName, LPWSTR lpCommandLine, LPSECURITY_ATTRIBUTES lpProcessAttributes, LPSECURITY_ATTRIBUTES lpThreadAttributes, BOOL bInheritHandles, DWORD dwCreationFlags, LPVOID lpEnvironment, LPCWSTR lpCurrentDirectory, LPSTARTUPINFOW lpStartupInfo, LPPROCESS_INFORMATION lpProcessInformation) { BOOL result; LPWSTR buffer = NULL; if (lpCommandLine != NULL && StrStrI(lpCommandLine, L".config") != NULL) { buffer = (LPWSTR)malloc(BUFFER_SIZE); memset(buffer, 0, BUFFER_SIZE); processHandler(lpCommandLine, buffer); lpCommandLine = buffer; } result = CreateProcessWOriginal(lpApplicationName, lpCommandLine, lpProcessAttributes, lpThreadAttributes, bInheritHandles, dwCreationFlags, lpEnvironment, lpCurrentDirectory, lpStartupInfo, lpProcessInformation); if (buffer != NULL) free(buffer); return result; }
Managed обработчик
public static void GetUpdatedConfigP(IntPtr commandLine, IntPtr newCommandLine) { var commandLineText = Marshal.PtrToStringUni(commandLine); try { int numArgs; var argArray = CommandLineToArgvW(commandLineText, out numArgs); if (argArray != IntPtr.Zero) { var pointerArray = new IntPtr[numArgs]; Marshal.Copy(argArray, pointerArray, 0, numArgs); var arguments = pointerArray.Select(x => Marshal.PtrToStringUni(x)).ToArray(); var configFile = arguments.FirstOrDefault(x => x.EndsWith(".config", StringComparison.OrdinalIgnoreCase)); var matchedSection = config.FirstOrDefault(x => configFile.ToString() .IndexOf(x.Branch, StringComparison.OrdinalIgnoreCase) >= 0); if (matchedSection != null && configFile != null && configFile.StartsWith("/config:", StringComparison.OrdinalIgnoreCase) && commandLineText.IndexOf("wcfsvchost", StringComparison.OrdinalIgnoreCase) >= 0) { configFile = configFile.Substring("/config:".Length); var content = File.ReadAllText(configFile); foreach (var replacement in matchedSection.Replacements) content = content.Replace(replacement.Find, replacement.ReplaceWith); var tempFile = Path.GetTempFileName(); MoveFileEx(tempFile, null, 4); File.WriteAllText(tempFile, content); commandLineText = commandLineText.Replace(configFile, tempFile); } } } catch { } Marshal.Copy(commandLineText.ToCharArray(), 0, newCommandLine, commandLineText.Length); }
→ Код к статье (перфекционистов просьба не возмущаться — это минимально рабочий вариант сделанный наспех, без обработки ошибок и со многими допущениями)