ابوالفضل کاظمی
ابوالفضل کاظمی
خواندن ۲۷ دقیقه·۵ سال پیش

مقدمه‌ای بر حملات Stack Overflow - قسمت اول - معرفی در لینوکس ۳۲بیتی

۰) مقدمه

اگر به صورت جدی با زبان‌های ++C/C برنامه نوشته باشید، قطعا با خطای Segmentation Fault‌ در زمان دسترسی خارج از محدوده‌ی آرایه‌ها و یا کار با اشاره‌گر نامعتبر و تلاش برای دسترسی به فضای حافظه‌ی غیر مجاز، مواجه شده‌اید. همچنین در زمان بکارگیری توابعی مثل gets نیز warningهای کامپایلر مبنی بر آسیب‌پذیر بودن استفاده از این توابع را مشاهده کرده‌اید، ولی آیا به دلیل آسیب‌پذیر بودن این توابع فکر کرده‌اید؟ آیا ارتباط بین Buffer Overflow و این توابع را می‌دانید؟

نکته: دقت کنید که محدوده‌ی آرایه‌ها در ++C/C چک نشده و استثنایی مثل چیزی که در جاوا/پایتون و خیلی از زبان‌های دیگر وجود دارد پرتاب نمی‌شود ولی اگر خارج از محدوده‌ی آرایه تغییری ایجاد کنید ممکن است باعث خطای Segmentation Fault‌ شود که در پایان این مقاله علت آنرا فرا خواهید گرفت.

در حالت کلی Overflow‌ به معنی نوشتن بیش از حد مجاز در محلی از حافظه است (البته در موارد مرتبط با موضوع این مقاله وگرنه این موضوع کلیت ندارد) و در صورت استفاده‌ی هوشمندانه می‌تواند باعث تغییر مسیر اجرای برنامه و در نهایت اجرای کد دیگری مثل bin/bash/ و دسترسی به Shell شود. در این مقاله ابتدا با ساختار پروسه‌ها و نگاشت حافظه در آن‌ها آشنا شده و پس از بررسی ساختار پشته در زمان اجرای توابع، به بررسی Stack Overflow خواهیم پرداخت.

نکته: در این مقاله از نسخه‌ی ۳۲ بیتی Ubuntu 14.04 استفاده شده است، ولی مطالب آن بر روی نسخه‌ی ۳۲ بیتی توزیع‌های دیگر لینوکس و یا در صورت کامپایل برنامه‌ها در توزیع‌های ۶۴بیتی با gcc -m32 قابل پیاده‌سازی است. اگر براتون سوال شده، خودم هم نمی‌دونم چرا این توزیع را انتخاب کردم!!! :-D

برنامه‌های نوشته شده، پس از کامپایل، کد زبان ماشین تولید می‌کنند که قابل فهم برای CPU بوده و قادر به اجرای آن‌ها می‌باشد. برای اجرای یک برنامه، باید کد و داده‌ی آن در حافظه بارگذاری شده و سپس CPU با خواندن کد برنامه از حافظه، شروع به اجرای آن کند. پس برای تمامی عملیاتی که در یک برنامه داریم، مثل انتساب‌ها، دستورات شرطی، حلقه‌ها و فراخوانی توابع باید چیزی در حافظه وجود داشته باشد که عملیات و داده‌ی مورد نیاز آنرا مشخص می‌کند. به عنوان مثال برای a=5 باید مشخص شود که a به چه بخشی از حافظه اشاره کرده و عملیات مورد نظر ما این است که مقدار ثابتی را در آن قرار دهیم و قصد جمع کردن و یا انتساب مقدار متغیر دیگر در آن را نداریم. به مشخص کننده‌ی عملیات CPU و کاری که باید انجام شود، Opcode می‌گویند که توسط سازنده‌ی CPU و برای تمامی عملیات پشتیبانی شده توسط آن تعریف شده و برای نوشتن برنامه از آن استفاده می‌شود. در زبان‌های برنامه‌نویسی، ما با عبارت‌ها و دستورات سطح بالا که درک آن برای انسان ساده‌تر می‌باشد کار می‌کنیم. حتی در زبان اسمبلی که سطح پایین‌ترین حالت برنامه نویسی است، ما برای راحتی با عبارت‌هایی مثل mov, add, push, pop کار می‌کنیم ولی پردازنده درکی از این عبارات نداشته و در نهایت باید وظیه‌اش به صورت مجموعه‌ای از Opcodeها مشخص شود.

نکته:‌ مثال‌های ما بر روی پردازنده‌های Intel‌ می‌باشد. برای اطلاع از Opcode دستورات مختلف، دستورات پشتیبانی شده توسط پردازنده، ساختار پردازنده و موارد مختلف مربوط به آن به Intel Developer’s Manual مراجعه کنید.

۱) معرفی Segmentهای برنامه

زبان‌های برنامه نویسی مختلف، طرز کار متفاوتی برای تولید کد زبان ماشین (کد قابل فهم توسط CPU که شامل همان Opcode‌ها می‌باشد) دارند ولی در نهایت نتیجه‌ی کار و خروجی مورد نظر برای CPU یکسان خواهد بود. زبان‌های سطح پایینی مثل ++Assembly, C, C پس از کامپایل، مستقیم کد زبان ماشین تولید می‌کنند ولی زبان‌هایی مثل Java, .Net به این صورت عمل نکرده و کدی تولید می‌کنند که توسط ماشین مجازی آن زبان پردازش شده و در نهایت تبدیل به کد زبان ماشین شده و اجرا می‌گردد. داده و کد یک برنامه در بخش‌های مختلفی در حافظه به نام Segment قرار می‌گیرند که امکان تعیین مجوز خواندن، نوشتن، اجرا کردن برای دسترسی به داده و کد به صورت مجزا را فراهم می‌کنند.

نکته: مجوز Segmentهای مختلف (RWX که قابلیت خواندن، نوشتن و اجرا شدن را مشخص می‌کند) قابل تغییر است که در ادامه به توضیح بخشی از آن خواهیم پرداخت.

کد برنامه در text/code segment قرار داده می‌شود که مجوز خوانده و اجرا شدن دارد. متغیرهای global برنامه در صورتیکه مقدار اولیه داشته باشند، در data segment‌ و در صورتیکه مقداردهی برای آن‌ها انجام نشده باشد در bss segment قرار داده می‌شوند. پارامترهای ورودی توابع و متغیرهای تعریف شده در تابع، بر روی stack قرار داده می‌شوند که برای جلوگیری از تزریق کد در برنامه و اجرا کردن آن، بخش stack برنامه‌ها مجوز اجرا نداشته و تنها می‌توان از آن اطلاعاتی خوانده و بر روی آن نوشت. در صورتیکه حافظه به صورت پویا و در زمان اجرای برنامه اختصاص داده شود، از بخشی از حافظه به نام heap استفاده خواهد شد. این ساختارها در شکل ۱ مشاهده می‌شوند. به نحوه‌ی رشد stack و heap در این شکل دقت کنید. همانطور که مشخص است stack‌ از آدرس‌های بالاتر به سمت آدرس پایین‌تر رشد کرده و heap برعکس آن، از آدرس کمتر به سمت آدرس بیشتر رشد می‌کند.

شکل ۱) ساختار حافظه برنامه‌ها
شکل ۱) ساختار حافظه برنامه‌ها

برای بررسی بخش‌بندی حافظه‌ی برنامه‌ها و آشنایی دقیق با کاربرد stack‌ در ارسال پارامترها و تعریف متغیرهای محلی، برنامه‌ی شکل ۲ را در نظر بگیرید. همانطور که مشاهده می‌شود دو متغیر سراسری که یکی دارای مقدار اولیه بوده و دیگری مقداری ندارد به همراه دو متغیر محلی عددی تعریف شده و سپس تابع printf برای نمایش مقدار متغیرهای محلی فراخوانی شده است.

شکل ۲) برنامه تستی یک
شکل ۲) برنامه تستی یک


برای مشاهده‌ی بخش‌های مختلف برنامه‌ها و تحلیل آن‌ها در لینوکس می‌توان از ابزارهایی مثل objdump, readelf استفاده نمود. در شکل ۳ نحوه‌ی کامپایل برنامه‌ی شکل ۲ و بررسی segment‌ مربوط به متغیرهای سراسری نمایش داده شده است. در ستون چهارم (آبی رنگ) عنوان segment دو متغیر سراسری مشاهده می‌شود که متغیر سراسری some_value که مقداری نداشت در bss و channel_id که مقداردهی شده بود در data قرار داده شده است.

شکل ۳) استفاده از دستور objdump برای بدست آوردن segment و offset متغیرهای سراسری
شکل ۳) استفاده از دستور objdump برای بدست آوردن segment و offset متغیرهای سراسری

برای اطلاع از ساختار کلی، هدرها و segmentهای یک برنامه می‌توانیم مشابه شکل ۴ از دستور readelf استفاده نماییم. همانطور که در ستون چهارم از راست (آبی رنگ) مشاهده می‌شود، بخش text که حاوی کد برنامه است دارای مجوز X برای اجرا شدن می‌باشد ولی دارای مجوز W نبوده و امکان تغییر کد در زمان اجرا وجود ندارد. همچنین بخش data, rodata که داده‌های برنامه را در بر دارند مجوز اجرای کد ندارند.

شکل ۴) استفاده از readelf برای مشاهده‌ی اطلاعات segmentها
شکل ۴) استفاده از readelf برای مشاهده‌ی اطلاعات segmentها

با مشاهده‌ی شکل ۵ و نحوه‌ی استفاده از دستور execstack نیز مشخص است که stack دارای مجوز X نبوده و امکان اجرای کد از روی آن وجود ندارد. هر چند می‌توانیم در زمان کامپایل برنامه و یا همانطور که در شکل ۵ مشخص است، با دستور execstack آنرا تغییر دهیم.

شکل ۵) تغییر اجرایی بودن stack با دستور execstack
شکل ۵) تغییر اجرایی بودن stack با دستور execstack


۲) معرفی ساختار و کاربرد Stack

از stack در فراخوانی توابع، ارسال پارامترهای مورد نیاز آن‌ها، تعیین حافظه برای متغیرهای محلی، ذخیره‌ی آدرس بازگشت به تابع فراخوان و همچنین ذخیره‌ی مقدار رجیسترهایی که در تابع تغییر کرده و مقدار آن‌ها در آینده مورد نیاز است، استفاده می‌شود. در برنامه‌نویسی اسمبلی دو رجیستر BP, SP برای کار با پشته در نظرگرفته شده‌اند. SP‌ همیشه به بالای پشته و BP به ابتدای فضای پشته‌ی مربوط به تابع فعلی اشاره می‌کند. در زمان فراخوانی یک تابع، برای از بین نرفتن فضای پشته تابع قبلی (تابع فراخوان یا caller) مقدار BP توسط تابع فعلی (تابع فرخوانی شده یا callee) بر روی پشته ذخیره شده و در انتهای کار تابع callee و قبل از بازگشت به تابع فراخوان، از روی پشته برداشته شده و در BP قرار می‌گیرد (تغییر مقدار رجیسترهای پشته در انتهای کار توابع توسط دستور LEAVE انجام می‌شود). توجه کنید که به دلیل رشد پشته از آدرس بیشتر به آدرس کمتر، بالای پشته در پایین حافظه قرار دارد.

نکته: رجیسترهای BP, SP در حالت ۱۶ بیتی که زمان شروع به کار کامپیوتر بوده و Real Mode نامیده می‌شود استفاده می‌شوند. در این حالت حافظه‌ی قابل دسترس 1MB است. پس از شروع به کار هسته‌ی سیستم‌عامل، تغییر وضعیتی به Protected Mode انجام می‌شود که در آن امکان استفاده از حافظه‌ی 4GB (بدون در نظر گرفتن PAE) فراهم بوده و رجیسترها نیز ۳۲ بیتی می‌باشند و به عنوان مثال رجیسترهای پشته EBP, ESP هستند. در حالت ۶۴ بیتی نیز رجیسترهای پشته RBP, RSP بوده و البته مدل فراخوانی توابع نیز با مدل ۳۲ بیتی متفاوت است که در مقاله‌ی دیگری به آن خواهیم پرداخت.

برای فراخوانی توابع، پارامترهای تابع از راست به چپ بر روی پشته قرار داده می‌شوند. اینکار باعث می‌شود که با قرار دادن EBP به عنوان مبنا، با اضافه کردن به EBP به پارامترهای تابع و به ترتیب از اولین پارامتر تا آخرین آن‌ها و با کم کردن از EBP به متغیرهای محلی دسترسی داشته باشیم. در نهایت نکته‌ی آخر در مورد بازگشت از تابع است. پس از اتمام کار یک تابع، دستوری که بلافاصله پس از محل فراخوانی قرار دارد، اجرا شده و اجرای برنامه ادامه می‌یابد. رجیستر EIP همواره حاوی آدرس دستور بعدی است که باید توسط CPU اجرا شود. در زمان شروع فراخوانی یک تابع، ابتدا آدرس دستور بعد از فراخوانی تابع بر روی پشته قرار داده شده و سپس پرش به ابتدای تابع انجام شده و EIP برابر آدرس ابتدای تابع شده و اجرای تابع شروع می‌شود (اینکار توسط دستور call انجام می‌شود) در انتهای کار تابع نیز، مقدار آدرس برگشت ذخیره شده، از روی پشته برداشته شده و در EIP قرار داده شود و به این صورت ادامه‌ی کار از جایی که فراخوانی تابع انجام شده بود پیگیری خواهد شد (اینکار توسط دستور ret انجام می‌شود). توضیحات ارائه شده در مورد فراخوانی توابع در شکل ۶ به تصویر کشیده شده‌اند. در این شکل دقت کنید که EBP همواره به محل ذخیره‌ی مقدار قبلی خود اشاره کرده، آدرس بازگشت از تابع در محل EBP+4 قرار داشته و پارامترهای ورودی تابع از EBP+8 شروع می‌شوند که اولین پارامتر تابع در این آدرس قرار دارد.

شکل ۶) ساختار پشته در زمان فراخوانی تابع
شکل ۶) ساختار پشته در زمان فراخوانی تابع

با دقت در شکل ۶ مشاهده می‌شود که اندازه‌ی هر خانه از پشته برابر ۴ بایت یا ۳۲ بیت است که مدل پیش‌فرض چینش پشته در برنامه‌های ۳۲ بیتی است. تاثیر این موضوع به این شکل است که اگر به عنوان مثال یک متغیر محلی به صورت [char s[10 نیز داشته باشید، مقدار فضای تخصیص داده شده برای آن بر روی پشته ۱۲ بایت می‌باشد و نه ۱۰ بایتی که شما تعریف کرده‌اید. با جمع بندی مطالب بیان شده در مورد پشته در زمانیکه در یک تابع قرار داشته باشید، ساختار پشته از دید تابع به صورت شکل ۷ خلاصه می‌شود.

 شکل ۷) خلاصه ساختار stack در زمان فراخوانی تابع و از دید تابع
شکل ۷) خلاصه ساختار stack در زمان فراخوانی تابع و از دید تابع


۳) بررسی ساختار Stack به کمک gdb

برای بررسی ساختار پشته به صورت عملی برنامه‌ی شکل ۲ را در (gdb (GNU Debugger باز کرده و کد اسمبلی و ساختار اجرایی آنرا بررسی می‌کنیم. برای باز کردن یک برنامه در محیط gdb کافی است مسیر برنامه را به عنوان پارامتر برای آن ارسال نمود: gdb prog-path

این محیط به صورت پیش فرض از مدل اسمبلی AT&T استفاده می‌کند که نسبت به مدل اینتل کمی عجیب است ولی می‌توان با دستور set disassembly-flavor intel آنرا تغییر داد! در شکل ۸ ساختار تابع main برنامه‌ی شکل ۲ به صورت اسمبلی نمایش داده شده است. همانطور که در خط 0+ مشاهده می‌شود، اولین کاری که در تابع انجام شده است، ذخیره کردن مقدار EBP است و پس از آن در خط 1+ مقدار ESP (بالای پشته در ابتدای تابع) در EBP قرار گرفته و به این شکل EBP به محل ذخیره‌ی مقدار قبلی خود اشاره کرده و مرز بین پارامترهای تابع و آدرس برگشت بــا متغیرهای محلی خواهد شد.

شکل ۸) ساختار اسمبلی تابع main در gdb
شکل ۸) ساختار اسمبلی تابع main در gdb

خط 3+ باعث می‌شود که Alignment پشته به صورت مضربی از ۱۶بایت قرار داده شود (کاری که gcc انجام داده است و علت آن این است که CPU در هر مرحله خواندن از حافظه ۱۶ بایت بارگذاری می‌کند، هر چند این عدد قابل تغییر است) و در خط بعدی یعنی 6+ فضایی برابر ۳۲ بایت برای پشته‌ی داخل تابع main در نظر گرفته می‌شود. اگر به میزان فضای مورد نیاز دقت کنیم، می‌بینیم که در این تابع دو متغیر محلی a, b تعریف شده‌اند که ۸ بایت برای آن‌ها بر روی پشته مورد نیاز است. از طرف دیگر پس از آن، یک فراخوانی تابع printf وجود دارد که سه پارامتر برای آن ارسال شده است. برای پارامتر اول که یک رشته است، آدرس رشته ارسال می‌شود (۴ بایت) و دو پارامتر دیگر که عددی می‌باشند مقدارشان بر روی پشته کپی می‌شود (در نهایت ۱۲ بایت برای پارامترهای printf) که در مجموع 20=8+12 بایت در این تابع بر روی پشته نیاز است که در خط 6+ به اندازه‌ی نزدیکترین عدد مضرب ۱۶ یعنی ۳۲ بایت فضا رزرو شده است.

خطوط 9+ ,17+ مقداردهی اولیه به متغیرهای a, b را نشان داده و از خط 25+ تا خط 41+ قرار دادن پارامترهای تابع printf بر روی پشته قابل مشاهده است. از مقادیر داده شده و با دقت به این نکته که مقدار a برابر 5 و b برابر 7 است، می‌توانیم محل a, b را بر روی پشته تشخیص دهیم. در آدرس EBP-4 روی پشته که معادل آدرس ESP+0x1c است فضای حافظه برای متغیر b بوده (به مقدار ۷ قرار داده شده در این مکان در خط 17+ دقت کنید) و پایین آن متغیر a در آدرس EBP-8 که معادل ESP+0x18 است بر روی پشته قرار داده می‌شود. با توجه به خطوط 25+ تا 41+ و شیوه‌ی قرارگیری پارامترهای تابع printf بر روی پشته نیز نکات جالبی مشاهده می‌شود. در این خطوط به دلیل عدم امکان تبادل اطلاعات بین دو قسمت از حافظه به صورت مستقیم، ابتدا مقدار متغیرها از حافظه خوانده شده، در رجیستر EAX قرار داده شده و سپس در محل دیگر حافظه که مربوط به پارامترهای printf می‌باشد قرار داده می‌شوند. از راست به چپ، ابتدا از آدرس ESP+0x1c مقدار متغیر b، سپس از ESP+0x18 مقدار متغیر a و درنهایت آدرس رشته‌ی format بر روی پشته قرار داده می‌شوند. در شکل ۹ ساختار پشته قبل از فراخوانی تابع printf نمایش داده شده است.

شکل ۹) ساختار پشته قبل از فراخوانی تابع printf
شکل ۹) ساختار پشته قبل از فراخوانی تابع printf

برای بررسی آدرس بازگشت main می‌توانیم در بخشی از کد یک break-point گذاشته و با اجرا کردن برنامه محتویات حافظه در آدرس EBP+4 را مشاهده کرده و آنرا disassemble کنیم. با انجام اینکار مشاهده می‌شود که تابع main پس از اجرا شدن به کتابخانه‌ی libc باز می‌گردد و شروع اجرای آن از این کتابخانه بوده است. در شکل ۱۰ یک break-point پس از اجرای تابع printf و در خط 53+ و در آدرس 0x08048482 از text segment قرار داده شده و با اجرای برنامه با دستور run محتویات آدرس ارسال شده به عنوان پارامتر اول printf به صورت رشته‌ای نمایش داده شده است (همان format-string). پس از آن نیز آدرس بازگشت main از EBP+4 نمایش داده شده و با disassemble کردن آن تابع libc که main از آنجا فراخوانی شده مشاهده می‌شود. دستور x در محیط gdb محتویات بخشی از حافظه را نمایش می‌دهد. دستور x/s برای نمایش به صورت رشته و x/wx برای نمایش یک کلمه (۳۲ بیت) به صورت عدد مبنای ۱۶ بکار می‌رود.

شکل ۱۰) بررسی تابع main برنامه شکل ۲ در زمان اجرا
شکل ۱۰) بررسی تابع main برنامه شکل ۲ در زمان اجرا
امیدوارم تا اینجای بحث خسته نشده باشید و جذابیت بحث براتون حفظ شده باشه، چونکه تازه پس از این مقدمه‌ی طولانی می‌خواهیم وارد مبحث Stack Overflow Attack بشیم!‌ :-D


۴) بررسی یک برنامه‌ی آسیب‌پذیر

بحث Stack Overflow Attack در این خلاصه می‌شود که با نوشتن بیش از حد در یک بخش از حافظه، آدرس بازگشت تابع را تغییر داده و باعث اجرای کد دیگری شد. به عنوان مثال برنامه‌ی ساده‌ی شکل ۱۱ را در نظر بگیرید.

شکل ۱۱) ساده‌ترین برنامه‌ی آسیب‌پذیر!
شکل ۱۱) ساده‌ترین برنامه‌ی آسیب‌پذیر!

در این برنامه به صورت خیلی ساده یک آرایه‌ی کاراکتری تعریف شده و با استفاده از تابع gets اطلاعاتی از ورودی دریافت شده و در این آرایه قرار داده می‌شود. در صورت کامپایل این برنامه با خطایی مبنی بر منسوخ شدن و خطرناک بودن تابع gets مواجه خواهید شد. علت این است که همانطور که در شکل ۱۱ مشخص است، این تابع اندازه‌ی ورودی را چک نمی‌کند. ساختار پشته برای این تابع main و فضایی که برای آرایه در نظر گرفته شده است، در شکل ۱۲ مشاهده می‌شود. دقت کنید که اسم آرایه ابتدای آدرس ذخیره کردن داده را مشخص کرده و با اضافه کردن عددی به آن، به خانه‌های بعدی آرایه دسترسی پیدا کرده و به سمت آدرس‌های بالاتر پیش خواهیم رفت و این به معنی این است که در صورت نوشتن بیش از حد در این آرایه (بیش‌تر از ۱۲۸ کاراکتر) امکان نوشتن در محل ذخیره‌ی EBP و آدرس بازگشت وجود دارد.

شکل ۱۲) ساختار پشته برای برنامه‌ی آسیب‌پذیر
شکل ۱۲) ساختار پشته برای برنامه‌ی آسیب‌پذیر

برای کامپایل کردن این برنامه به صورت زیر عمل می‌کنیم:

gcc -fno-stack-protector -z execstack -mpreferred-stack-boundary=2 ovf2.c -o ovf2

در این دستور کامپایل چند نکته وجود دارد:

  • سوئیچ fno-stack-protector- برای غیرفعال کردن مکانیزمی به نام canary است که برای تشخیص Overflow وجود دارد و ما برای فراهم کردن امکان تست ساده‌ای که انجام می‌دهیم آنرا غیرفعال می‌کنیم.
  • سوئیچ zexecstack- برای دادن مجوز اجرا به پشته است که کدی که جایگزین خواهیم کرد امکان اجرا داشته و با استثنای سیستم‌عامل مواجه نشود.
  • سوئیچ mpreferred-stack-boundary=2- باعث می‌شود که alignment پشته بجای ۱۶ بایتی که در بخش قبلی داشتیم برابر ۴ بایت باشد که حدالامکان برای رسیدن از ابتدای بافر (متغیر رشته‌ای str) تا آدرس بازگشت همان ۱۲۸بایت به اضافه‌ی ۴ بایت برای EBP یعنی در مجموع ۱۳۲ بایت نیاز باشد (البته باز هم ممکن است به این مقدار چند بایت، بسته به کامپایلر و توزیعی که استفاده می‌کنید اضافه شود. پس از ابتدای رشته تا آدرس بازگشت، حداقل ۱۳۲ بایت نیاز خواهید داشت و برای بدست آوردن مقدار دقیق آن باید مشابه چیزی که در ادامه می‌آید در محیط gdb تست کنید).

با این توضیحات بیایید تست کنیم و ببینیم اگر بجای آدرس بازگشت BBBB قرار دهیم چه اتفاقی رخ خواهد داد. برای اینکار باید ۱۳۶ بایت (به صورت کاراکتری ۱۲۸ کاراکتر برای آرایه، ۴ کاراکتر برای مقدار EBP و در نهایت ۴ بایت برای آدرس بازگشت) در بافر بنویسیم که چهار بایت آخر آن BBBB بوده و ۱۳۲ بایت ابتدایی آن اهمیتی ندارد. برای تولید این رشته از پایتون به صورت زیر استفاده می‌کنیم:

python -c &quotprint( 'A'*132 + 'BBBB' )&quot > /tmp/inp

این دستور ۱۳۲ کاراکتر A و چهار کاراکتر B را پشت سرهم قرار داده و در فایل tmp/inp/ ذخیره می‌کند. در محیط gdb از این فایل به عنوان ورودی استفاده کرده و نتیجه را در شکل ۱۳ مشاهده می‌کنیم.

شکل ۱۳) اجرای overflow و تغییر آدرس بازگشت از main
شکل ۱۳) اجرای overflow و تغییر آدرس بازگشت از main

با اجرای برنامه در محیط gdb امکان مشاهده‌ی آدرس بازگشت وجود دارد و همانطور که در تصویر مشخص است، این آدرس برابر 0x42424242 است که ASCII همان BBBB می‌باشد. به دلیل اینکه این آدرس به جای درستی اشاره نکرده و پس از اتمام کار main امکان بازگشت به محل خاصی وجود ندارد، این اجرا Segmentation Fault می‌دهد.

۵) اجرای کد به کمک آسیب‌پذیری

الان که موفق به اجرای موفقیت آمیز Stack Overflow شدیم، بیایید یک برنامه را اجرا کنیم. برای اجرای یک برنامه‌ی خارجی معمولا یک گزینه‌ی مناسب اجرای bin/bash/ و بدست آوردن یک shell است، که امکان دسترسی به کلیه‌ی دستورات را فراهم می‌کند. برای انجام اینکار باید برنامه‌ی اجرای shell در جایی از حافظه بارگذاری شده و سپس آدرس آن بجای آدرس بازگشت از main قرار گیرد. با کمی دقت مشخص است که بهترین محل ذخیره‌ی برنامه‌ی جدید، در ادامه‌ی stack و بعد از آدرس بازگشت می‌باشد. برنامه باید به صورت باینری که همان Opcode است ذخیره شود. مثلا در صورت نیاز به NOP (که البته نیاز هم خواهد شد!) باید 0x90 و به عبارت دقیق‌تر “x90\” بر روی پشته قرار داده شود.

نکته: به برنامه‌ای که به صورت Opcode در حافظه قرار گرفته و از این طریق اجرا شود Shellcode می‌گویند.

اما سوال مهم این است که آدرسی که باید به آن پرش صورت بگیرد چه آدرسی است؟ برای پیدا کردن این آدرس، در ابتدای main و پس از قرار دادن مقدار ESP در EBP می‌توانیم این مقدار را برداشته و از آن برای آدرس بازگشت جدید استفاده نمود. ولی هنوز یک مشکل باقی است! برای اطلاع از این مشکل من در شکل ۱۴ آدرس stack را در سه بار اجرای یک برنامه نمایش داده‌ام. عدد بعد از proc/ شناسه یا PID پروسه است و maps نگاشت حافظه را در بر دارد.

شکل ۱۴) آدرس stack در چندبار اجرای برنامه آسیب‌پذیر
شکل ۱۴) آدرس stack در چندبار اجرای برنامه آسیب‌پذیر

همانطوری که مشاهده می‌شود، آدرس بخشی از حافظه‌ی پروسه که stack در آن قرار دارد، در هر اجرا متفاوت است. این تغییر باعث می‌شود که امکان بدست آوردن آدرس stack و پرش به آن برای اجرای کد، پس از انجام Overflow امکان‌پذیر نباشد.

در سیستم‌عامل‌های فعلی برای جلوگیری از نفوذ به برنامه‌ها و تشخیص آدرس دقیق ساختارهای پروسه‌ها از امکانی به نام (ASLR (Address Space Layout Randomization استفاده می‌شود. این مورد باعث می‌شود که Segmentهای مختلف برنامه در هر اجرا، آدرس‌های متفاوتی در حافظه داشته باشند. در لینوکس این قابلیت از طریق proc/sys/kernel/randomize_va_space/ کنترل می‌شود که می‌تواند یکی از سه عدد زیر را داشته باشد:

  • 0: غیر فعال
  • 1: فعال بودن برای کتابخانه‌ها و پشته
  • 2: فعال بودن برای کتابخانه‌ها، پشته و heap

نکته: دقت کنید که بررسی ASLR و مشاهده‌ی mapping نمایش داده شده در شکل ۱۴ را از داخل gdb انجام ندهید. gdb برای راحتی در debug برنامه‌ها این مورد را غیر فعال می‌کند.

برای سادگی در تست برنامه‌ی آسیب‌پذیر، ما ASLR را به صورت زیر غیر فعال می‌کنیم.

غیر فعال کردن ASLR در لینوکس
غیر فعال کردن ASLR در لینوکس

مورد دیگری که باید به آن توجه شود این است که در هربار اجرای برنامه‌ها به دلیل پارامترهای مختلفی مثل مسیر اجرای برنامه، ممکن است پشته ساختاری دقیقا مشابه دفعه‌ی قبل نداشته و چند بایت کمتر یا بیشتر از دفعه‌ی قبل بر روی آن قرار داشته باشد، پس نمی‌توانیم یک آدرس دقیق برای ابتدای برنامه‌ی مورد نظر خود (همان اجرای bin/bash/) در نظر بگیریم. برای رفع این مشکل نیز قبل از داده‌ی مربوط به اجرای bin/bash/ یکسری NOP قرار داده و به وسط آن‌ها اشاره می‌کنیم. به این صورت اجرای برنامه مختل نشده و با اجرا کردن تعداد کمتر یا بیشتری از دستورات NOP به ابتدای Shellcode خواهیم رسید. به دلیل اینکه نوشتن یک Shellcode برای اجرای bin/bash/ توضیحات جداگانه‌ای لازم دارد فعلا در مورد این قسمت توضیحاتی ارائه نشده و می‌توانید یک Shellcode آماده از یکی از سایت‌های shell-storm.org و یا www.exploit-db.com دانلود کنید.

در نهایت برای بدست آوردن آدرس بازگشت مشابه شکل ۱۵، برنامه را در gdb باز کرده، پس از تغییر EBP یک break-point گذاشته و آنرا اجرا می‌کنیم. آدرس EBP+4 که محل ذخیره‌ شدن آدرس بازگشت از main است را به عنوان base آدرس Shellcode استخراج کرده و با اضافه کردن مقداری به آن آدرسی که باید بجای آدرس بازگشت از main برای اجرای Shellcode قرار گیرد را بدست می‌آوریم.

نکته: ممکن است آدرس‌های نمایش داده شده در این مقاله با چیزی که شما تست می‌کنید متفاوت باشد.

شکل ۱۵) بدست آوردن محل ذخیره آدرس بازگشت main و base آدرس shellcode
شکل ۱۵) بدست آوردن محل ذخیره آدرس بازگشت main و base آدرس shellcode

با جمع‌بندی و کنار هم قرار دادن موارد ذکر شده در یک اسکریپت پایتون امکان اجرای Shellcode وجود خواهد داشت. در شکل ۱۶ اسکریپت نوشته شده مشاهده می‌شود. در این اسکریپت از struct.pack برای تولید آدرس نهایی جایگزین main-return استفاده شده است که little-endian بودن را لحاظ کرده و نیازی به برعکس نوشتن آدرس به صورت byte-string را نداشته باشیم!

شکل ۱۶) اسکریپت پایتون برای اجرای shellcode
شکل ۱۶) اسکریپت پایتون برای اجرای shellcode

در این اسکریپت ۳۰۰ بار NOP قبل از shellcode قرار داده شده و آدرس بازگشت از main برابر با ۲۰۰بایت بعد از محل آدرس بازگشت تنظیم شده است. در شکل ۱۷ فضای پشته پس از ارسال خروجی این اسکریپت به عنوان ورودی برنامه‌ی شکل ۱۱ مشاهده می‌شود.

شکل ۱۷) ساختار پشته پس از ارسال خروجی اسکریپت پایتون به برنامه‌ی آسیب‌پذیر
شکل ۱۷) ساختار پشته پس از ارسال خروجی اسکریپت پایتون به برنامه‌ی آسیب‌پذیر

با ذخیره کردن خروجی پایتون در فایلی مثل tmp/inp_shell/ و اجرای برنامه‌ی دوم در محیط gdb با این ورودی، می‌توانیم اجرا شدن bin/bash/ را مشابه شکل ۱۸ مشاهده نماییم.

شکل ۱۸) اجرا شدن موفقیت آمیز shellcode در gdb
شکل ۱۸) اجرا شدن موفقیت آمیز shellcode در gdb


۶) جمع‌بندی

سوالی که ممکن است برایتان پیش آید این است که خب این چه مشکل امنیتی می‌تواند داشته باشد؟ و با اجرا شدن این bash چه اتفاقی رخ خواهد داد؟ برای پاسخ به این سوال و اتمام این مقاله، فرض کنید که نرم‌افزاری دارید که setuid بر روی آن تنظیم شده و owner فایل آن نیز root باشد (یا حتی از آن ساده‌تر، کلا با کاربر root اجرا شده باشد و شما بتوانید ورودی به آن بدهید که منجر به Stack Overflow شود!!!). معنی این مورد این است که Effective User در زمان اجرا شدن این برنامه کاربر root بوده و برنامه تحت مجوزهای آن اجرا می‌شود. اگر چنین برنامه‌ای آسیب پذیر بوده و امکان نفوذ به آن فراهم شده باشد، شما می‌توانید به یک root shell دسترسی پیدا کرده و کنترل کامل سیستم را پیدا کنید. برای تست این موضوع من setuid را بر روی برنامه‌ی دوم تست خودمان فعال کرده و Overflow اجرای bash را خارج از gdb اجرا می‌کنم. این مورد در شکل ۱۹ مشاهده می‌شود.

شکل ۱۹) تنظیم کردن setuid و اجرای Overflow خارج از gdb
شکل ۱۹) تنظیم کردن setuid و اجرای Overflow خارج از gdb

در شکل مشخص است که انجام Overflow با خطای Segmentation Fault مواجه نشده و برنامه اجرا شده است، ولی چرا Shell نمایش داده نشد؟!

موضوع این است که به دلیل بسته شدن پروسه‌ی پدر که همان برنامه‌ی ovf2 می‌باشد، bash فرزند نیز بسته شده است! برای جلوگیری از این مورد می‌توانیم از دستور cat استفاده کنیم (این دستور ورودی دریافت شده را به خروجی ارسال کرده و تا زمان عدم دریافت signal باز خواهد ماند) و با باز نگه داشتن یک stream امکان تایپ دستور و دریافت نتیجه را داشته باشیم. در شکل ۲۰ نحوه‌ی استفاده نمایش داده شده است. دقت کنید که امکان استفاده از تمامی دستورات لینوکس وجود داشته و کاربر نیز root گزارش داده می‌شود!

نکته: در این خروجی پس از اجرای Overflow دستورات وارد شده به رنگ سبز و خروجی دستور آبی رنگ است.

شکل ۲۰) بدست آوردن root-shell به کمک برنامه‌ی آسیب‌پذیر
شکل ۲۰) بدست آوردن root-shell به کمک برنامه‌ی آسیب‌پذیر


در این مقاله سعی کردم مقدمات حملات Stack Overflow را به صورت کامل و با پیش‌نیازهایی که برای درک نحوه‌ی کار آن لازم است شرح داده و مثالی عملی از نحوه‌ی اجرای آن نمایش دهم که برای افرادی که هیچ آشنایی با این مباحث ندارند نقطه‌ی شروع مناسبی فراهم شود.

امیدوارم مفید بوده باشه، موفق باشید.

لینوکسبرنامه نویسیexploit developmentهکامنیت سایبری
شاید از این پست‌ها خوشتان بیاید