Зміна вмісту Web.config в runtime при налагодженні Visual Studio і IISExpress

Технологічно в цій статті нічого нового, просто ще одне корисне застосування 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(" АБО ", 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);
}



Код статті (перфекціоністів прохання не обурюватися — це мінімально робочий варіант зроблений поспішно, без обробки помилок і з багатьма допущеннями)
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.