Mohsen Tahmasebi
Mohsen Tahmasebi
خواندن ۱۱ دقیقه·۳ سال پیش

رقص با Crash ها: مقدمه ای بر Fuzzing و ورودی های نامعتبر در امنیت سایبری

تصویری از فازر AFL
تصویری از فازر AFL


اگر به یک برنامه کامپیوتری، یک ورودی نامعتبر بدیم چه اتفاقی میفته؟ در نگاه اول احتمالا با خودتون میگید خب، احتمالا ارور میده. ارور! چه کلمه زیبایی، اما قبل از ادامه بهتره کمی تعاریفمون رو یکی کنیم. ارور یعنی خطا، و خطا ممکنه در یک بخشی از عملکرد برنامه به وجود بیاد (به دلایل مختلف)، پس برنامه ارور نمیده بلکه به ارور برمیخوره و حتی ممکنه متوجه این ارور هم نشه! اینجا وقتی میگیم برنامه ای ارور (Error) میده، منظورمون این هست برنامه یک اشکال در روند اجرا (مثلا یک ورودی نامعتبر) رو شناسایی کرده، به عنوان مثال قرار بوده شما شماره موبایلتون رو وارد کنید (که طبیعتا عدد هست) اما حروف انگلیسی وارد کردید که خب طبعا نامعتبر خواهد بود. اینجا برنامه "اگر" متوجه این خطا بشه قاعدتا به یک ارور خورده و مرحله بعدی هندل کردن این ارور (Error handling) هست، واکنشی که این برنامه باید در مواجهه با این ارور داشته باشه. این واکنش میتونه نمایش یک پیام خطا (ارور دادن) یا خروج از برنامه یا خیلی چیز های دیگه باشه.

اما اگر برنامه متوجه این خطا نشه چی؟ خب طبعا برنامه وارد مرحله ارور هندلینگ نمیشه و شما پیام خطایی از برنامه دریافت نمیکنید. اما قسمت خطرناک و هیجان انگیز ماجرا اینجا شروع میشه. اگر برنامه متوجه خطا نشه و تلاش نکنه این خطا رو درست کنه یا جلوی ادامه برنامه رو بگیره، این ورودی های نامعتبر در روند اجرای برنامه قرار گرفتن و حالا ممکنه با اشکال در عملکرد برنامه مواجه بشیم (بخش های مختلف برنامه برای مواجهه با چنین ورودی طراحی نشدن و آمادگی ندارن، به عبارتی Unexpected Value)، چراکه این ورودی ها در حال دست به دست شدن در بخش های مختلف برنامه و پردازش شدن هستن، و چون در مثال ما "قرار بوده" این ورودی عدد باشه برنامه هم به عنوان عدد با اون ها برخورد میکنه (اگر تعجب کردید که این موضوع چطور ممکنه، در زبان های low level امکانش وجود داره اما در زبان های high level مثل پایتون این موارد توسط خود اینترپرتر بررسی و هندل میشن). حالا این "اشکال در عملکرد برنامه" (program malfunction) شکل های مختلفی داره. شاید یک جایی در روند اجرا، بخشی از برنامه بخواد از این عدد (که حالا عدد نیست) استفاده کنه و روی اون عملیاتی رو انجام بده و خب، یا به یک خروجی کاملا غلط میرسه (چون با حروف مثل عدد برخورد کرده) یا برنامه کلا به مشکل بر میخوره و به اصطلاح "کرش" (crash) میکنه.

حالا تازه رسیدیم به کرش کردن یک برنامه. وقتی برنامه ای دچار یک خطای بحرانی میشه به طوری که دیگه قابلیت ادامه اجرا رو نداره، کرش میکنه. کرش کردن توسط خود برنامه انجام نمیشه بلکه روند اجرا به نحوی دچار مشکل شده که یا کل برنامه به اصطلاح گیر میکنه و دیگه کار نمیکنه، یا سیستم عامل کلا جلوی اجرای اون رو میگیره. این اتفاقیه که اگر یک برنامه نتونه ارور های مهمش رو هندل کنه میفته. ممکنه یک ارور ساده توسط برنامه شناسایی و هندل نشه و نهایتا به طور موقت در یکی از بخش های غیرضروری برنامه مشکل ایجاد کنه، مثلا یک خروجی نامعتبر غیر مهم، اما ممکنه یک ارور ساده و هندل نشده بعد از طی شدن مراحل مختلف پردازش در برنامه، یک جایی یک چیزی مثل مدیریت بخش های مختلف حافظه (Memory management) رو خراب کنه و اطلاعاتی که برنامه باهاشون کار میکنه رو بهم بریزه یا باعث اتفاقات بحرانی دیگه ای بشه که نهایتا منجر به از کار افتادن کامل برنامه و همون کرش کردن میشه.

حداقل اتفاقی که اینجا میفته، از کار افتادن برنامه هست. این موضوع وقتی مهم میشه که خب، مثلا سیستم عامل شما هم یک برنامه کامپیوتری هست و ورودی های مختلفی رو پردازش میکنه (مثل پکت ها و اطلاعاتی که از شبکه دریافت میشن) و مهاجم با وجود یک باگ در بخش شبکه سیستم عامل شما، میتونه با ارسال یک سری اطلاعات خاص که منجر به کرش کردن سیستم عامل شما بشه، کل سرور یا کامپیوتر شما رو از کار بندازه و مجبورتون کنه سیستم رو ریست کنید که اصلا چیز خوبی برای سرور ها و سازمان ها نیست. این موضوع که به عنوان حمله DoS یا Denial of Service (حملات منع خدمات) شناخته میشه به طیف وسیعی از برنامه ها (سرور های ایمیل و وب و سیستم عامل ها و...) و شرکت ها صدمه زده و میزنه. اما این بدترین اتفاقی نیست که ممکنه بیفته و در شرایط و آسیب پذیری های خاص مهاجم میتونه با دادن یک ورودی خاص به برنامه شما، بدون ایجاد کرش و از کار افتادن برنامه (چون برنامه این ورودی نامعتبر رو شناسایی نکرده و جلوی اجرای برنامه رو نگرفته و اون ورودی خاص جوری طراحی شده که باعث خطای بحرانی و نهایتا کرش در برنامه نشه)، کد و اعمال مخرب خودش رو روی کامپیوتر قربانی اجرا کنه (RCE: Remote Code Execution یا ACE: Arbitrary code execution) که بحثی است برای بعدا...

به عنوان آخرین بخش مقدمه (بله تمام این ها مقدمه بود) باید گفت که معنی "ورودی نامعتبر" پیچیده تر از مثال ما درباره حروف و اعداد هست، ورودی نامعتبر یا به عبارتی ورودی خاص ما (چون از اینجا به بعد اصطلاح ورودی نامعتبر آنچنان درست نیست و اصولا ورودی معتبره اما برنامه رفتار سالمی با ورودی نداره) ممکنه در ظاهر و طبق استاندارد های برنامه کاملا معتبر باشه اما جایی در برنامه، به نحوی با این ورودی برخورد بشه که نباید. مثلا قراره در بخشی از برنامه هرجا به عبارت "گلابی" رسیدیم انتقال اطلاعات در حافظه رو متوقف کنیم. "گلابی" عبارتی هست که توسط خود برنامه و در درون برنامه برای تعیین مرز و انتهای اطلاعات استفاده میشه پس من باید در ابتدای گرفتن ورودی در برنامه مطمئن باشم که کاربر در ورودیش از "گلابی" استفاده نکرده چون باعث تداخل خواهد شد و عملا برنامه باید با این ورودی به عنوان یک ورودی "نامعتبر" برخورد کنه یا استفاده از "گلابی" رو کنار بذاره و یک روش درست و استاندارد پیاده سازی کنه.

فازینگ و فازر ها: چرا؟

هنوز اینجایید؟ بابت اتمام مطالعه مقدمه تبریک میگم. با این مقدمه، تعریف فازینگ (Fuzzing) بسیار راحت خواهد بود: "دادن حجم زیادی از ورودی های "نیمه تصادفی" به برنامه برای پیدا کردن اشکالات و آسیب پذیری های احتمالی". خب چرا به جاش نمیریم سورس کد رو بررسی کنیم؟ چون در یک برنامه بزرگ با چند میلیون خط کد و هزاران ماژول و بخش که بر اساس ورودی، هربار بخش های اجرا شده ممکنه متفاوت باشن، احتمالا اکثر چیز ها از زیر دست شما در خواهد رفت. اما خب چرا از استاتیک آنالیزر ها ابزار های بررسی سورس کد استفاده نمیکنیم؟ در واقع اینکار رو میکنیم و خوب هم جواب داده! طی چند سال اخیر با پیشرفت ابزار های بررسی سورس و روند برنامه (Program Flow) خیلی از باگ ها و آسیب پذیری های اینچنینی شناسایی و برطرف شدن، اما نه همشون. پیچیدگی بعضی برنامه ها و سیستم های کامپیوتری و تنوع دلایل و Root Cause های این باگ ها به قدری زیاده که خیلی اوقات این نوع ابزار ها نمیتونن چنین باگ هایی رو کشف کنن. نتیجتا تازه به بحث زیبای فازینگ میرسیم.

قبل از هر چیز ما در امنیت وب هم چیزی تحت عنوان Web Fuzzing یا API Fuzzing رو داریم که باز هم حکایت همین ورودی های نیمه تصادفی هست اما تفاوت های کلیدی داره، به همین جهت ما اینجا صرفا به فازینگ برنامه های کامپیوتری میپردازیم. در فازینگ ما به صورت تصادفی یا نیمه تصادقی ورودی های مختلفی رو به برنامه (در اشکال مختلف) میدیم و منتظر میشینیم تا امیدوارانه برنامه دچار رفتار غیرعادی و بحرانی (مثل کرش کردن) بشه. وقتی برنامه دچار این رفتار شد، ما میفهمیم که یک جای کار مشکل داره و به بررسی های بیشتر روی ورودی داده شده و خروجی برنامه و خود برنامه میپردازیم تا دلیل ریشه ای (Root Cause) این اتفاق و ترجیحا یک روش سوءاستفاده ازش رو پیدا کنیم. اما فرایند فازینگ به همین راحتی ها نیست. چالش های مهم و اساسی در فاز کردن برنامه ها وجود داره. مثلا خیلی از برنامه ها چند ورودی میگیرن یا به شیوه های خاص ورودی میگیرن و از اونجایی که ما داریم این فرایند رو اتوماتیک میکنیم، نیاز داریم راهکاری داشته باشیم تا کل فرایند دادن ورودی، اجرا و بررسی عملکرد برنامه اتوماتیک انجام بشه. یا یک مثال دیگه، ما نیاز داریم برنامه سریعا ورودی ما رو بگیره، پردازش کنه و کارش رو تموم کنه و خارج بشه. در نتیجه این دست موارد که حاصل ساختار مختلف برنامه های مختلف هستن، شما نمیتونید هر برنامه ای رو به یک شکل فاز کنید و عموما نیاز دارید بخش هایی از برنامه یا فازر (Fuzzer، ابزاری که باهاش فازینگ انجام میشه) رو تغییر بدید تا مناسب هم بشن.

انواع

بر اساس آگاهی فازر

اما پیچیدگی های فازینگ به همینجا ختم نمیشه (و در واقع در ابعاد فازر ها، تازه شروع میشه). انواع مختلفی از فازر وجود داره. این نوع بندی بر اساس پارامتر های مختلفی انجام میشه. از یک نگاه میشه فازر ها رو بر اساس "آگاهی" اون ها از ساختار و ساختمان برنامه دسته بندی کرد:

  1. نوع white-box از فازر ها با تلاش برای کسب آگاهی عمیق از ساختار درونی برنامه، به صورت سازمان یافته (با ساختن ورودی های حساب شده تر) تلاش میکنن بخش های مختلف برنامه رو تست کنن و به "Code Coverage" بیشتری برسن. "Code Coverage" در شکل ساده یعنی شما چند درصد از کل برنامه رو با تست هاتون پوشش دادید (جلوتر بیشتر بهش خواهیم پرداخت). همونطور که قبلا گفته شد، یک برنامه بخش های مختلفی داره که ممکنه بر اساس ورودی که بهش داده میشه، یک تعدادی از این بخش های اجرا بشن و یک تعدادی نه. حالا اگر ورودی تغییر کنه ممکنه بخش های اجرا شده هم متفاوت باشن و اینطوری شما با تست های مختلف میتونید بخش های مختلف رو با ورودی های مختلف تست کنید. این نوع فازر ها سرعت به شدت کمتری دارن و بیشتر متکی بر ورودی های حساب شده تر هستن تا ایجاد حجم زیادی از ورودی های نیمه تصادفی.
  2. از طرفی black-box هارو داریم که بدون اطلاع از ساختمان درونی برنامه و صرفا با تعامل (ورودی دادن و خروجی گرفتن) با برنامه هدف، حجم انبوهی از ورودی های تقریبا یا کاملا تصادفی رو به برنامه میدن. این فازر ها سرعت بالایی دارن.
  3. و نهایتا gray-box ها که با اطلاعات و آگاهی محدود تر نسبت به white-box ها، تلاش در تست های هدفمند تر و رسیدن به "Code Coverage" بهتری دارن. این نوع از فازر ها عملا ترکیب دو نوع قبلی محسوب میشن و تلاش در داشتن سرعت black-box فازر ها در کنار دقت white-box فازر ها دارن.

در این نوع دسته بندی فازر ها دو نکته مهم وجود داره. اول اینکه برای gray-box و white-box فازر ها، روش های مختلفی برای "آگاهی از برنامه" ارائه شده، مثلا پچ کردن و اضافه کردن بخش هایی به برنامه که به فازر اطلاع میده فلان بخش از برنامه الان فعال شده و اینطوری فازر میفهمه باعث اجرای بخش جدیدی از کد شده و به این صورت "Code Coverage" خودش رو بالا میبره.

نکته دوم، پیچیدگی عملیات تصمیم گیری فازر های مختلف (به خصوص برای بحث Code Coverage) هست. بعضی از فازر ها از الگوریتم های پیچیده و یادگیری ماشین برای بالا بردن "Code Coverage" استفاده میکنن. اون ها به مرور "یاد میگیرن" و "حدس میزنن" چطور بخش های مختلف برنامه رو تست کنن و بهشون دسترسی پیدا کنن. به عنوان مثال برنامه شما با استفاده از فلگ های مختلف، کار های مختلفی انجام میده. فلگ s دو ورودی از کاربر میگیره و دومی رو از اولی کم میکنه (بخش تفریق برنامه) و فلگ a دو ورودی میگیره و اون هارو با هم جمع میکنه (بخش جمع برنامه).

test -s 1234 222

test -a 223 1732

نتیجتا با توجه به فلگ داده شده به برنامه، یک بخش متفاوت از اون اجرا میشه و اگر فازر در ابتدا هیچ اطلاعی از این جریان نداشته باشه (در بخش بعدی خواهیم گفت چطور میتونن بدونن)، این احتمال وجود داره که بعد از یک تعداد زیادی تست و با توجه به الگوریتم ها، فازر این موضوع رو کشف کنه و شروع به تست ورودی های مختلف روی هر یک از این دو بخش برنامه کنه. (این اتفاق واقعا در خیلی از فازر های gray-box میفته. فازر تصادفا فلگ a رو به برنامه میده و کد اضافه شده فازر به برنامه، به فازر اطلاع میده یک بخش جدید اجرا شده، نتیجتا فازر بعد از یک مدت و به روش های مختلف حدس میزنه این فلگ a یک ربطی با این بخش کشف شده جدید داره و شروع به بازی با این بخش جدید میکنه...)

حتی در نوع black-box فازر ها (که اطلاعی از ساختار درونی و بخش های مختلف ندارن) هم راهکار های آکادمیکی ارائه شده که فازر بدون اینکه از درون برنامه خبر دار بشه به بخش جدیدی دست پیدا کرده، با استفاده از الگوریتم های یادگیری ماشین این موضوع رو حدس میزنه.

بر اساس نحوه ساخت ورودی ها

اما از یک دید دیگه هم دسته بندی فازر ها صورت میگیره. اینکه چقدر فازر از نوع و ساختار ورودی که میسازه آگاهی داره و اون رو آگاهانه میسازه:

  1. فازر های smart که به روش های مختلف (model-based, grammar-based, protocol-based) ابتدا توسط کاربر به مقداری از آگاهی درباره ساختار ورودی ها میرسن. مثلا در مثال قبلیمون درباره فلگ های s و a دیگه نیازی نبود خود فازر اون هارو کشف کنه و ما از ابتدا فلگ هارو بهش معرفی میکردیم و بعد میگفتیم کجای ورودیمون (جای دو عدد ورودی) اطلاعات ساخته شده رو وارد کنه و به برنامه بده.
  2. فازر های dumb که نیازی به این اطلاعات درباره ورودی ها ندارن و خودشون با استفاده از seed داده شده شروع به ساختن ورودی میکنن.

سخن پایانی

سال هاست که عده ای فکر میکنن با به وجود اومدن راهکار های آنالیز کد و باقی راهکار ها، فازینگ و فازر ها به زودی به تاریخ خواهند پیوست، اما با پیشرفت هر روزه فازر ها به خصوص در بحث به کارگیری الگورتیم های یادگیری ماشین، این موضوع ثابت شده که این روش نابود کننده اعصاب و دوست داشتنی با به کارگیری شخصی سازی های متفاوت و نوآوری هنوز هم میتونه به شدت مفید باشه.

امیدوارم از این پست استفاده کرده باشید، اگر نظر یا اصلاحی به این پست دارید همینجا کامنت کنید یا به توییتر من (@moh53n) پیام بدید.

موفق باشید!

هکامنیت سایبریfuzzingaflبرنامه نویسی
بلاگ انگلیسی: https://moh53n.medium.com
شاید از این پست‌ها خوشتان بیاید