مقالهی زیر از وبلاگ شخصیام کپی شده، برای استفاده از مثال و خوانایی بهتر لطفا آن را در این لینک بخوانید
تمام برنامهها در ویندوز در قالب پردازههایی اجرا میشوند که دارای مرز و محدودهاند. مرزی که با نحوهی مدیریتِ حافظه در ویندوز با فضای آدرسِ مجازی ایجاد میشود. به طور دقیقتر میتوان گفت؛ در ویندوز یک پردازه به حافظهی پردازههای دیگر دسترسی ندارد بنابراین هیچگاه نمیتوان از یک پردازه تابعی را که در پردازهای دیگر قرار دارد فراخوانی کرد. با این تفاسیر فرض کنید به هر بهانهای، مثل اتوماسیون، تستِ امنیتی، هک و غیره، نیاز به انجام چنین کاری دارید. بنابراین باید راهی یافت تا کدهای خودمان را در پردازهی دیگر قرار دهیم تا در نهایت اجرا شود. در برنامهنویسیِ ویندوز DLL Injection تکنیک شناخته شدهای است که این کار را محقق میکند. با استفاده از تکنیکِ DLL Injection میتوان یک DLL را که حاوی کدهای مطبوعمان است در فضای آدرسِ پردازهای دیگر بارگزاری کرد تا اجرا شود. این مقاله به یکی از روشهای پیادهسازیِ این تکنیک میپردازد. مقاله ابتدا با چند پاراگراف دربارهی Kernel Objectها آغاز میشود و سپس با بررسیHookها، DLL Injection و مثالی در این زمینه به پایان میرسد.
به نظر من هر برنامهنویسی که قصد دارد کدِ نزدیک به سیستمعامل بنویسد باید درکِ درستی از 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 همچنان به حیات خود ادامه دهد و توسط پردازههای دیگر مورد استفاده قرار گیرد. در قسمت بعدی بیشتر به این بحث میپردازم.
#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); }
همانطور که بیان شد 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های خاص توابع دیگری نیز دارد.
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 به هدفمان برسیم. پیادهسازی این تکنیک به روشهای مختلفی انجام میشود اما در اینجا از روشی hooking به windowهای پردازهی هدف استفاده میکنیم. بنابراین بدیهی است که این روش فقط زمانی کاربرد دارد که پردازهی هدف دارای window است. بنابراین اگر پردازهی A یک تابعِ موجود در یک DLL را به رویدادی در پردازهی B، hook کند، عملا ماژولی که تابع در آن قرار دارد را وارد فضای آدرس پردازهی دیگر کرده است. البته استفاده از SetWindowsHookEx تنها راه ممکن برای پیاده سازیِ DLL Injection نیست ولی در این مقاله تنها همین روش بررسی خواهد شد. توجه کنید که تمام ماژولها و پردازههای درگیر در این فرآیند، یعنی پردازهی inject کننده، ماژولی که قرار است inject شود و در نهایت پردازهای که بدان hook میکنیم باید از یک platform برخوردار باشند؛ x86 یا 64.