Технологически в этой статье ничего нового, просто еще одно полезное применение 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.
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, не расположен в папке windowsmicrosoft.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;
}
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;
}
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);
}
Автор: Einherjar