DLL Injection

یکشنبه, ۲ شهریور ۱۴۰۰

DLL Injection
Win32
C++

مقدمه

تمام برنامه‌ها در ویندوز در قالب پردازه‌هایی اجرا می‌شوند که دارای مرز و محدوده‌اند. مرزی که با نحوه‌ی مدیریتِ حافظه‌ در ویندوز با فضای آدرسِ مجازی ایجاد می‌شود. به طور دقیق‌تر می‌‎توان گفت؛ در ویندوز یک پردازه به حافظه‌ی پردازه‌های دیگر دسترسی ندارد بنابراین هیچ‌گاه نمی‌توان از یک پردازه تابعی را که در پردازه‌ای دیگر قرار دارد فراخوانی کرد. با این تفاسیر فرض کنید به هر بهانه‌ای، مثل اتوماسیون، تستِ امنیتی، هک و غیره، نیاز به انجام چنین کاری دارید. بنابراین باید راهی یافت تا کدهای خودمان را در پردازه‌ی دیگر قرار دهیم تا در نهایت اجرا شود. در برنامه‌نویسیِ ویندوز DLL Injection تکنیک شناخته شده‌ای است که این کار را محقق می‌کند. با استفاده از تکنیکِ DLL Injection می‌توان یک DLL را که حاوی کدهای مطبوع‌مان است در فضای آدرسِ پردازه‌ای دیگر بارگزاری کرد تا اجرا شود. این مقاله به یکی از روش‌های پیاده‌سازیِ این تکنیک می‌پردازد. مقاله ابتدا با چند پاراگراف درباره‌ی Kernel Objectها آغاز می‌شود و سپس با بررسیHookها، ‏DLL Injection و مثالی در این زمینه به پایان می‌رسد.

Kernel Objects

به نظر من هر برنامه‌نویسی که قصد دارد کدِ نزدیک به سیستم‌عامل بنویسد باید درکِ درستی از Kernel Objectها داشته باشد چراکه بسیاری از توابعِ ارائه شده در API ِ ویندوز با آن‌ها سروکار دارند. به طور کل ویندوز را می‌توان سیستم‌عاملی objectمحور شناخت و منظور از objectمحور، object oriented نیست. در این مبحث object را متغیری از جنسِ یک structure تصور کنید (مثل ++C) که توسط سیستم نگهداری می‌شود یعنی؛ به صورت مستقیم و با استفاده از اشاره‌گرها توسط پردازه‌های دیگر قابل دسترسی نیست و اصلا به همین دلیل است که به آن Kernel Object می‌گویند. یعنی objectهای متعلق به kernel یا هسته‌ی سیستم‌عامل. برای نمونه می‌توان به این objectها اشاره کرد: File، ‏Job، ‏Thread، ‏Process، ‏Semaphore.

Kernel Object ِ مربوط به هر منبع (Resource) اطلاعاتی آماری درباره‌ی آن منبع نگهداری می‌کند. مثلا با ایجاد هر پردازه (Process) ویندوز یک object برای آن می‌سازد که اطلاعاتی مثل ID، ‏UsageCount و ‏SecurityDescriptor را در بر دارد. برخی از این اطلاعات در تمام objectها مشترک‌اند - مثل UsageCount - و برخی تنها به object ِ مربوط به پردازه تعلق دارند - مثل ID. همان‌طور که گفته شد دسترسی مستقیم به objectهای ویندوز ممکن نیست و خواندن یا نوشتن در این objectها تنها از طریق API ِ ویندوز امکان‌پذیر است. به این طریق ویندوز از حفظ یکپارچگی در داده‌های این objectها اطمینان حاصل می‌کند چراکه این اطلاعات تنها از طریق توابعی از قبل مشخص شده و با وظایفی تعریف شده قابل تغیراند. استفاده از توابعی که با kernel objectها سر و کار دارند به روش خاصی انجام می‌شود و برای کار با آنها باید از موجودیتی به نامِ Handle استفاده کرد. می توانید Handle را شناسه‌ی یک object تصور کنید. شناسه‌ای که با استفاده از آن یک object را ایجاد (create) یا باز (open) می‌کنیم، از آن استفاده می‌کنیم و در نهایت آن را می‌بندیم (close). مثال زیر از فایل که یکی از معروف‌ترین objectهای ویندوز است، استفاده می‌کند. در این مثال ابتدا یک فایل در دیسک می‌سازیم و سپس مقداری در آن می‌نویسیم. تابعِ CreateFile در ویندوز وظیفه‌ی ایجاد یک object ِ فایل را بر عهده دارد. با فراخوانی این تابع ویندوز یک object برای نگهداریِ اطلاعاتی مثل محل ذخیره یا حالتِ دسترسی به یک فایل ایجاد می‌کند، آدرسِ object را در جدول پردازه درج می‌کند و در نهایت handle ِ مربوط را بازگشت می‌دهد. از این به بعد می‌توان از آن handle برای انجام کارهای مختلفی مثل نوشتن در فایل استفاده کرد. پس از اتمام نوشتن در فایل، handle را با استفاده از تابعِ CloseHandle از بین می‌بریم. دقت کنید بستنِ handle به معنای از بین رفتنِ object نیست. همان‌طور که در ابتدا بیان شد مالکِ تمام objectها خودِ سیستم‌عامل است، پس بستنِ یک handle یا بسته شدنِ پردازه‌ی مالکِ آن handle لزوما به از بین رفتن objectهای سیستم‌عامل نمی‌انجامد بلکه امکان دارد object همچنان به حیات خود ادامه دهد و توسط پردازه‌های دیگر مورد استفاده قرار گیرد. در قسمت بعدی بیشتر به این بحث می‌پردازم.

1 2 3 4 5 6 7 8 9 10#include <Windows.h> int main() { HANDLE hFile = CreateFile(L"C:\\myfile.txt", GENERIC_WRITE, 0, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL); char data[] = "Hello Kernel Objects!"; DWORD numberOfBytesToWrite = strlen(data); DWORD numberOfBytesWritten = 0; WriteFile(hFile, data, numberOfBytesToWrite, &numberOfBytesWritten, NULL); CloseHandle(hFile); }

Handle

همان‌طور که بیان شد hanlde شناسه‌ای است برای ارجاع به یک object ِ خاص. فهم این موضوع بسیار مهم است که حوزه‌ی اعتبارِ handle پردازه است، یعنی؛ مالکیتِ تمام handleهای موجود در سیستم بر عهده‌ی پردازه‌هاست. به این صورت که سیستم‌عامل آدرسِ حافظه‌ی objectهای مورد نیازِ یک پردازه را، همراه با اطلاعاتی دیگر که نوع آن object را تعین می‌کنند، در جدولی به نام Process Handle Table قرار می‌دهد و برای هر ردیفِ این جدول شناسه‌ای تعین می‌کند که همان handle است. جدول handleهای پردازه در قسمتی از حافظه‌ی متعلق به آن پردازه قرار دارد بنابراین بدیهی است که مقادیرِ handleها خارج از فضای پردازه اعتباری ندارند؛ استفاده از hanldeی که متعلق به پردازه‌ی A است در پردازه‌ی B امکان‌پذیر نیست. با این تفاسیر اگر چندین پردازه‌ی مختلف قصد استفاده از یک object ِ واحد را داشته باشند، هر یک ردیفی برای آن در جدولشان نگهداری می‌کنند و برای هر ردیف handleی تعریف می‌کنند. برای به اشتراک‌گزاری handleها بین پردازه‌های مختلف راه‌هایی وجود دارد که دغدغه‌ی این مقاله نیست.

فراخوانیِ تابع CreateFile یک فایل در مسیر مشخص شده و handle ِ آن را می‌سازد. سپس مقدارِ handle به تابعِ WriteFile پاس داده می‌شود. عمکلرد تابعِ WriteFile با handle ِ ‏hFile به این شکل است: ابتدا با استفاده از handle به سراغ ردیف مربوطه در جدولِ handleها می‌رود و از آنجا آدرسِ object ِ ‏hFile را پیدا می‌کند و سپس با استفاده از آن به object ِ اصلی در سیستم‌عامل دسترسی می‌یابد. سیستم‌عامل اطلاعات تکمیلی بسیاری را درباره‌ی فایل در داخل object ِ آن نگهداری می‌کند. تابع WriteFile با استفاده از این اطلاعات مختصات فیزیکی فایل را بدست می‌آورد و در نهایت مبادرت به نوشتن اطلاعات در دیسک می‌کند. با این تفاسیر پردازه بدون اینکه درگیر پیچیدگی‌های مربوط به objectهای ویندوز شود تنها با استفاده از مقداری از نوع HANDLE درخواستش را برای خواندن/تغیر یک object به توابع ویندوز ارائه می‌دهد. تصویر زیر نحوه‌ی تعامل چندین پردازه‌ی مختلف با یک object ِ خاص را نشان می‌دهد:

به یاد داشته باشید که Handleها باید بسته شوند. ویندوز برای مدیریت طول‌عمر objectها از روش شمارش استفاده می‌کند به این معنی که هر بار پردازه‌ای اقدام به بازکردن یک object کند مقدارِ UsageCount ِ آن یکی زیاد خواهد شد و با بستنِ هر Handle عکس آن. با این روش هنگامی که مقدارِ UsageCount به صفر برسد ویندوز اقدام به از بین بردن object از حافظه می‌کند. اگر پردازه‌ای handleهای باز داشته باشد، ویندوز قبل از بستن پردازه تمام آنها را می‌بندد. بستنِ handleها معمولا با استفاده از تابع CloseHandle انجام می‌شود اما ویندوز برای برخی objectهای خاص توابع دیگری نیز دارد.

Hooking

hook به قطعه‌کدی گفته می‌شود که توسط یک عامل خارجی و برای دریافت رویدادهای سیستم نصب می‌شود. بدیهی است که عامل خارجی و سیستم در اینجا مفاهیمی نسبی هستند که گاهی می‌توانند به بزرگیِ یک سیستم‌عامل و گاهی به کوچکیِ یک object باشند. مثلا اگر از react استفاده کرده باشید این مفهوم با استفاده از توابعی مثل useState یا useEffect پیاده‌سازی شده است. یا مثالی دیگر مکانیزمِ event handling در زبانِ #C است که با استفاده از عملگرِ =+ کار می‌کند. در مثالِ react عامل خارجی، استفاده کننده‌ی framework است و در #C استفاده کننده‌ی یک object. در تمام سیستم‌هایی که امکان hooking را فراهم می‌کنند مکانیزمی برای ثبت اطلاعاتِ قطعه‌کدها، که معمولا تابع هستند، وجود دارد. مکانیزم مشابه در بین این‌گونه سیستم‌ها این است که اطلاعاتِ توابع را در جایی نگهداری می‌کنند و هنگام رخ‌دادنِ رویداد مورد نظر، توابع ثبت شده را یکی پس از دیگری فراخوانی می‌کنند. ویندوز هم از این قاعده مستثنی نیست. ویندوز برای اینکه بتواند توابعِ متقاضی را نسبت به رویدادهای مربوط به windowها مطلع کند از مکانیزمِ hooking استفاده می‌کند. در این مکانیزم برای هر یک از انواعِ hook صفی به نام hook chain تعریف می‌شود که وظیفه‌ی نگهداریِ لیستی از توابع را دارد. توابعِ موجود در hook chain هنگام رخ‌دادن رویداد مورد نظر یکی پس از دیگری فراخوانی می‌شوند. در ادبیات ویندوز به این توابع اصطلاحا hook procedure می‌گویند که ساختارشان همواره ثابت و به این شکل است:

LRESULT CALLBACK HookProc(int code, WPARAM wparam, LPARAM lparam){
  // rokhdad ra dar inja barresi konid
  return CallNextHookEx(NULL, code, wparam, lparam);
}

قطعه کد بالا ساختارِ یک تابعِ ثبت شده در hook chain را نشان می‌دهد. پارامترِ code حاوی نوعِ hook است؛ مثل WH_KEYBOARD. اگر اطلاعاتی اضافی در رابطه با رویدادِ مورد نظر وجود داشته باشد سیستم آنها را در پارامترهای دوم و سوم قرار می‌دهد؛ مثلا کلیدی از کیبورد که فشار داده شده. به فراخوانی تابع CallNextHookEx در انتها دقت کنید. هر تابعِ حاضر در hook chain وظیفه‌ی فراخوانی تابعِ بعدی را دارد. در صورتی که این کار انجام نشود در حقیقت پردازش hook chain به پایان می‌رسد. البته این شکلِ اجرا در hook chainهای حساس صدق نمی‌کند ولی بهتر است همیشه از CallNextHookEx در انتهای تابع استفاده کنیم. همان‌طور که بیان شد در ویندوز برای هر نوع hook یک hook chain ِ مجزا وجود دارد. برای مثال می‌توان به WH_CALLWNDPROC و WH_KEYBOARD اشاره کرد که به ترتیب مسئول پردازش پیغام های ارسالی به windowها و پیغام‌های کیبورد هستند:

برای ثبت کردنِ یک تابع در یک hook chain از تابع SetWindowsHookEx استفاده می‌کنیم. ساختارِ این تابع به شکل زیر است و برای درک کامل آن هنوز چند پارامتر دیگر باید توضیح داده شود.

HHOOK SetWindowsHookExA(
  int        idHook,
  HOOKPROC   lpfn,
  HINSTANCE  hmod,
  DWORD      dwThreadId 
);

SetWindowHookEx همیشه تابعِ متقاضی را در ابتدای hook chain می‌گذارد و چگونگی اجرای آن را نسبت به مقدار پارامترها تنظیم می‌کند. با استفاده از پارامتر اول، idHook، تعین می‌کنیم که قصد استفاده از کدام نوع hook را داریم؛ برای مثال WH_KEYBOARD. با پارامتر دوم، lpfn، اشاره‌گری به تابعِ متقاضی برای اجرا را تعین می‌کنیم که همان تابعِ متقاضی برای فراخوانی هنگام رویداد سیستم است. سیستم این اشاره‌گر را در ابتدای hook chain قرار می‌دهد تا هنگام رخ‌دادنِ رویدادی که پارامتر اول مشخص کرده است، آن را اجرا کند. تابعی که در اینجا قرار می‌دهیم باید ساختاری (signature) مشابهِ آنچه قبلا ذکر شد داشته باشد. پارامتر سوم، hmode، ‏handle ِ مربوط به ماژولی است که تابعِ متقاضی در آن قرار دارد. توجه کنید که تفاوت یک فایل DLL با ماژول در این است که ماژول، موجودیتی لود شده در حافظه است که با ساختاری مشخص آماده‌ی اجراست. پارامتر چهارم، dwThreadId، شناسه‌ی نخی است که تابع را اجرا خواهد کرد. نحوه‌ی استفاده از SetWindowsHookEx بر اساس شرایط می‌تواند تغیر کند، به خصوص دو پارامتر آخر، ولی من در اینجا فقط گونه‌ای را توضیح می‌دهم که برای این مقاله کاربرد دارد. برای اطلاعات بیشتر به توضیحات مایکروسافت مراجعه کنید. در قسمت بعدی با ذکر مثالی چگونگی استفاده از این تابع برایتان روشن خواهد شد.

DLL Injection

سیستم‌عامل ویندوز به هر پردازه فضای آدرس خودش را اختصاص می‌دهد و پردازه‌ها اجازه‌ی تجاوز از این فضا و استفاده از فضاهای دیگر را ندارند. با این حال اگر نیاز باشد به فضای آدرس پردازه‌های دیگر را دسترسی داشته باشیم می‌توانیم با اجرای تکنیکی معروف به DLL Injection به هدفمان برسیم. پیاده‌سازی این تکنیک به روش‌های مختلفی انجام می‌شود اما در اینجا از روشی hooking به windowهای پردازه‌ی هدف استفاده می‌کنیم. بنابراین بدیهی است که این روش فقط زمانی کاربرد دارد که پردازه‌ی هدف دارای window است. بنابراین اگر پردازه‌ی A یک تابعِ موجود در یک DLL را به رویدادی در پردازه‌ی B، ‏hook کند، عملا ماژولی که تابع در آن قرار دارد را وارد فضای آدرس پردازه‌ی دیگر کرده است. البته استفاده از SetWindowsHookEx تنها راه ممکن برای پیاده سازیِ DLL Injection نیست ولی در این مقاله تنها همین روش بررسی خواهد شد. توجه کنید که تمام ماژول‌ها و پردازه‌های درگیر در این فرآیند، یعنی پردازه‌ی inject کننده، ماژولی که قرار است inject شود و در نهایت پردازه‌ای که بدان hook می‌کنیم باید از یک platform برخوردار باشند؛ x86 یا 64.

مثال: خواندن آیتم‌های موجود در دسکتاپ

در برنامه‌ی پیش رو قصد دارم یک DLL را به پردازه‌ی explorer که متعلق به خودِ ویندوز است inject کنم. هدفم از انجام این کار خواندنِ تمام آیتم‌های روی دسکتاپ است. ویندوز برای مدیریت آیکن‌های روی دسکتاپ از کنترلِ ListView استفاده می‌کند و خواندنِ آیتم‌های موجود در ListView توسط API ِ ویندوز در خارج از پردازه‌ی مالکِ آن امکان‌پذیر نیست. بنابراین اگر واقعا قصد خواندنِ آیتم‌های روی دسکتاپ را داشته باشیم راهی جز وارد کردن کدمان به پردازه‌ی explorer نداریم. مثال زیر در دو پروژه‌ی جدا تعریف شده است. قطعه کدِ اول در یک پروژه‌ی Console و دومی در یک پروژه‌ی Dynamic Link Library. پروژه‌ی Console وظیفه‌ی inject کردن و پروژه‌ی دوم همان DLLی است که قرار است در فضای آدرس پردازه‌ی explorer قرار گیرد. این پروژه پس از لود یکایکِ آیتم‌های موجود روی دسکتاپ را می‌خواند و نتیجه را در فایلی در C:\result.txt ذخیره می‌کند.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18// Injector #include <Windows.h> HWND getDesktopListview() { HWND progman = FindWindow(NULL, L"Program Manager"); HWND shell = GetWindow(progman, GW_CHILD); return GetWindow(shell, GW_CHILD); } int main() { HWND hListView = getDesktopListview(); DWORD dThreadId = GetWindowThreadProcessId(hListView, NULL); HMODULE hMod = LoadLibrary(L"Hook.dll"); HOOKPROC fn = (HOOKPROC)GetProcAddress(hMod, "ReadDesktop"); HHOOK hHook = SetWindowsHookEx(WH_CALLWNDPROC, fn, hMod, dThreadId); SetForegroundWindow(hListView); }

در این پروژه ابتدا handle ِ مربوط به کنترلِ ListView روی دسکتاپ پیدا می‌شود. جایگاه این کنترل اصولا دو مرحله پایین‌تر از Program Manager است ولی اگر به هر دلیلی این‌طور نباشد طبیعی است که باید کدِ این قسمت را تغیر دهید تا برنامه اجرا شود. بعد از بدست آوردنِ HWND (بخوانید اِچ ویند) با استفاده از تابعِ GetWindowThreadProcessId شناسه‌ی نخِ سازنده‌ی این کنترل بدست می‌آید. تابع LoadLibrary فایل DLL ِ مربوط به پروژه‌ی دیگر را لود می‌کند. توجه کنید که فرض بر این است که این فایل در کنار فایل اجراییِ Console قرار دارد. وظیفه‌ی تابعِ GetProcAddress پیدا کردنِ آدرسِ تابع در این ماژول است. تابعِ ReadDesktop در پروژه‌ی Hook تعریف شده است. در نهایت با استفاده از اشاره‌گری به تابع، ‏handle ِ ماژولی که تابع در آن قرار دارد و شناسه‌ی نخ، تابعِ ReadDesktop به WH_CALLWNDPROC، ‏hook می‌شود. تابعِ ReadDesktop با اولین پیغامی که به یکی از windowهای explorer ارسال می‌شود فراخوانی خواهد شد. دلیلِ حضورِ SetForegroundWindow ارسالِ یک پیغام است تا این اتفاق را تسریع کند. تابعِ ReadDesktop طوری نوشته شده است که تنها یک بار آیتم‌های موجود در دسکتاپ را بخواند و در فایلی ذخیره کند.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36// Hook #include<Windows.h> #include <fstream> #include <CommCtrl.h> bool read{ false }; HWND getDesktopListview() { HWND progman = FindWindow(NULL, L"Program Manager"); HWND shell = GetWindow(progman, GW_CHILD); return GetWindow(shell, GW_CHILD); } void printItems() { HWND hListView = getDesktopListview(); std::wofstream file; file.open("C:\\result.txt"); int count = ListView_GetItemCount(hListView); for (int i = 0; i < count; i++) { wchar_t text[100]; ListView_GetItemText(hListView, i, 0, text, _countof(text)); file << text << '\n'; } file.close(); } extern "C" { __declspec(dllexport) LRESULT CALLBACK ReadDesktop(int code, WPARAM wParam, LPARAM lParam) { if (!read) { read = true; printItems(); } return CallNextHookEx(NULL, code, wParam, lParam); } }

پایان

یکی از نکاتِ قابل توجه در اجرای پروژه‌ی بالا این است که بستنِ پردازه‌ی Injector، ‏باعث از بین رفتنِ handle ِ ماژولِ DLL از پردازه‌ی explorer می‌شود. دلیل این اتفاق این است که مقدار UsageCount ِ این ماژول تنها توسط Injector و برای اولین بار به مقدار یک افزایش پیدا کرده است. بنابراین با بستنِ پردازه‌ی injector این مقدار به صفر می‌رسد و سیستم‌عامل آن را می‌بندد. به عبارت دیگر اگر قصد دارید که ماژولِ تزریق شده، پس از بسته شدنِ پردازه‌ی تزریق کننده، همچنان به حیات خود ادامه دهد باید اطمینان حاصل کنید پردازه‌ی هدف مقدار UsageCount ِ آن را افزایش می‌دهد. یک راه دیگر برای جلوگیری از این مشکل لود کردن DLLهای دیگر با استفاده از LoadLibrary است.

یکی از روش‌های اجرای DLL Injection برای برنامه‌هایی که مبتنی بر windowها هستند استفاده از SetWindowsHookEx است که در این مقاله به آن پرداختم. hook کردن به پروسه‌هایی که فاقد window هستند با استفاده از CreateRemoteThread انجام می‌شود که روشی به مراتب پیچیده‌تر است. در نهایت باید گفت انجام hook با استفاده از کدهای net. هم ممکن است و در پیاده سازی آن فقط باید اطمینان حاصل کرد که تابعِ hook procedure در export section ِ فایل DLL قرار می‌گیرد تا اشاره به آن از طریق GetProcAddress ممکن باشد.