امروزه بیشتر سیستمعاملهای مدرن این امکان را برای برنامهها فراهم میکنند که مستقیماً به لایه Datalink دسترسی داشته باشند. این قابلیت عمدتاً امکانات کلیدی زیر را در اختیار ما میگذارد:
- امکان نظارت و مشاهده بستههای دریافتی در لایه Datalink فراهم میشود؛ قابلیتی که اجازه میدهد برنامههایی مثل tcpdump به جای نیاز به سختافزارهای اختصاصی، روی کامپیوترهای معمولی نیز اجرا شوند. در این حالت، اگر کارت شبکه روی حالت بیقید یا promiscuous mode تنظیم شود، برنامه نهتنها بستههای ارسالی برای خودِ سیستم، بلکه تمام ترافیک عبوری از کابل شبکه محلی را هم زیر نظر میگیرد.
- برخی از سرویسها و برنامههای سیستمی را میتوان به جای اجرا در فضای Kernel، در قالب یک برنامه کاربردی معمولی اجرا کرد. به عنوان مثال، در بیشتر نسخههای Unix، فرآیند رسیدگی به درخواستهای RARP (پروتکل Reverse ARP در لایه پیوند) در قالب یک برنامه معمولی در فضای کاربر پیادهسازی شده که مستقیماً از لایه Datalink میخواند و روی آن مینویسد.
در دنیای Unix، سه رویکرد رایج برای دسترسی به لایه Datalink وجود دارد: فیلتر بسته BSD (یا BPF)، رابط ارائهدهنده دیتالینک در SVR4 (یا DLPI: DataLink Provider Interface) و رابط SOCK_PACKET در Linux. در ادامه نگاهی به این سه روش خواهیم انداخت و سپس کتابخانه محبوب ضبط بسته libpcap را بررسی میکنیم.
کتابخانه libpcap میتواند با هر سه روش ذکرشده کار کند و به توسعهدهنده اجازه میدهد برنامههایی بنویسد که مستقل از سازوکار دسترسی سیستمعامل عمل میکنند. برای درک بهتر نحوه کار libpcap، برنامهای خواهیم ساخت که یک پرسوجوی DNS را به Name Server بفرستد (با ساخت دستی دیتاگرام UDP و نوشتن مستقیم آن روی یک raw socket) و سپس پاسخ دریافتی را با libpcap بخواند تا مشخص شود آیا Name Server مقدار UDP checksum را فعال کرده است یا خیر.
۲۹.۲ سیستمعامل BSD و سازوکار BPF
سیستمعامل 4.4BSD و بیشتر مشتقات Berkeley از قابلیت فیلتر بسته BSD یا همان BPF پشتیبانی میکنند. جزئیات پیادهسازی BPF در فصل ۳۱ کتاب TCPv2 به تفصیل آمده است.
همانطور که در تصویر مشخص است، درایور Datalink دقیقاً پیش از ارسال بسته و بلافاصله پس از دریافت آن، BPF را فراخوانی میکند. این زمانبندی دقیق (فراخوانی در سریعترین زمان پس از دریافت و در دیرترین زمان پیش از ارسال) به این دلیل است که دقیقترین برچسب زمانی (Timestamp) ممکن برای بستهها ثبت شود.
ایجاد یک نقطه دسترسی در لایه Datalink برای ضبط بستهها کار چندان پیچیدهای نیست؛ نقطه قوت واقعی BPF در قدرت فیلتر کردن هوشمندانه آن نهفته است. هر برنامهای که دستگاه BPF را باز (Open) میکند، میتواند فیلتر دلخواه خود را روی آن بارگذاری کند تا BPF آن را روی تکتک بستهها اعمال نماید. برخی از این فیلترها ساده هستند (مانند «udp or tcp» که فقط بستههای UDP یا TCP را فیلتر میکند)، اما فیلترهای دیگر قادرند مقادیر بسیار دقیقی را در فیلدهای خاص هدر بسته بررسی کنند.
tcp and port 80 and tcp[13:1] & 0x7 != 0
به عنوان نمونه، فیلتر بالا در فصل ۱۴ (TCPv3) برای ضبط سگمنتهای TCP ورودی یا خروجی پورت ۸۰ استفاده شده که در آنها فلگهای SYN، FIN یا RST فعال هستند. عبارت tcp[13:1] به مقداری ۱ بایتی اشاره دارد که در آفست ۱۳ بایتی از ابتدای هدر TCP قرار گرفته است.
سازوکار BPF از یک ماشین فیلتر مبتنی بر رجیستر بهره میبرد که فیلترهای برنامه را روی هر بسته دریافتی اجرا میکند. اگرچه میتوان برنامههای فیلتر را مستقیماً با زبان ماشین این شبهماشین (Pseudomachine) نوشت، اما سادهترین روش این است که فیلتر را به صورت یک رشته متنی ASCII (مشابه مثال بالا) بنویسیم و سپس با استفاده از تابع pcap_compile (که در بخش ۲۹.۷ به آن میپردازیم) آن را به کد ماشین کامپایل کنیم.
فناوری BPF برای به حداقل رساندن سربار سیستم، از تکنیکهای زیر بهره میبرد:
- عملیات فیلتر کردن مستقیماً در سطح Kernel انجام میشود؛ بنابراین حجم دادهای که باید از BPF به فضای برنامه کپی شود به حداقل میرسد. کپی داده از فضای هسته (Kernel Space) به فضای کاربر (User Space) پردازش سنگین و پرهزینهای است و اگر قرار بود همه بستهها کپی شوند، BPF هرگز نمیتوانست پا به پای سرعت بالای لایه Datalink پیش برود.
- قابلیت BPF به جای کل بسته، تنها بخشی از آن را به برنامه منتقل میکند؛ مفهومی که طول اسنپشات (Snapshot Length) یا snaplen نامیده میشود. از آنجا که بیشتر برنامهها صرفاً به هدر بستهها نیاز دارند و نه به محتوای کامل آنها، این روش حجم دادههای انتقالی به برنامه را به طرز چشمگیری کاهش میدهد. برای مثال، مقدار پیشفرض snaplen در tcpdump برابر با ۹۶ بایت است که شامل ۱۴ بایت هدر Ethernet، ۴۰ بایت هدر IPv6، ۲۰ بایت هدر TCP و ۲۲ بایت دیتا میشود. البته اگر کاربر بخواهد جزئیات پروتکلهای لایه بالاتر مثل DNS یا NFS را بررسی کند، باید این مقدار را افزایش دهد.
- برای کاهش تعداد فراخوانیهای سیستمی (System Calls)، BPF دادههای مربوط به برنامه را بافر میکند و تنها زمانی بافر را به برنامه تحویل میدهد که کاملاً پر شده باشد یا مهلت زمانی خواندن (Read Timeout) به پایان برسد. برنامه میتواند این مقدار مهلت زمانی را تعیین کند؛ به عنوان نمونه، tcpdump مقدار Timeout را ۱۰۰۰ میلیثانیه قرار میدهد، در حالی که Daemon مربوط به RARP برای پاسخگویی آنی به درخواستها، آن را روی ۰ تنظیم میکند.
- گرچه در نمودار ممکن است فقط یک بافر دیده شود، اما BPF برای هر برنامه دو بافر مجزا نگه میدارد؛ به این صورت که در حین کپی شدن دادههای یک بافر به برنامه، بافر دوم با دادههای جدید پر میشود. این یک تکنیک استاندارد و کارآمد به نام بافرینگ دوگانه (Double Buffering) است.
تصویر بالا تنها جریان دریافت بستهها توسط BPF را نشان میدهد؛ بستههایی که لایه Datalink از شبکه پاییندست یا از لایه بالاتر (IP) دریافت کرده است. با این حال، برنامهها میتوانند روی BPF عملیات نوشتن (Write) نیز انجام دهند که باعث ارسال مستقیم بسته از طریق لایه Datalink میشود (بدون عبور از پشته پروتکل IP). با این وجود، بیشتر برنامهها صرفاً از BPF میخوانند؛ زیرا برای ارسال دیتاگرامهای IP نیازی به نوشتن مستقیم روی BPF نیست و میتوان با فعال کردن گزینه IP_HDRINCL در سوکت، هدر و بسته کامل IP را سفارشیسازی کرد. نوشتن مستقیم روی BPF معمولاً فقط برای پروتکلهای غیر از IP کاربرد دارد؛ مثلاً Daemon مربوط به RARP برای ارسال پاسخهای RARP که ماهیت IP ندارند، مستقیماً روی BPF مینویسد.
برای دسترسی به BPF، برنامه باید یک دستگاه BPF آزاد را باز کند؛ مثلاً ابتدا /dev/bpf0 را امتحان میکند و اگر با خطای EBUSY روبهرو شد، به سراغ /dev/bpf1 میرود. پس از باز شدن دستگاه، میتوان با حدود ۱۲ دستور مختلف ioctl ویژگیهای آن را تنظیم کرد؛ مواردی مانند بارگذاری فیلتر، تعیین Read Timeout، اندازه بافر، اتصال دستگاه BPF به اینترفیس دیتالینک و فعالسازی Promiscuous Mode. پس از انجام این پیکربندیها، تبادل داده با فراخوانیهای معمول read و write انجام میگیرد.
۲۹.۳ سیستمعامل SVR4 و رابط DLPI
در محیط SVR4، دسترسی به لایه پیوند از طریق DLPI انجام میشود. رابط DLPI که توسط AT&T طراحی شده، یک لایه انتزاعی و مستقل از پروتکل است که خدمات لایه Datalink را در دسترس قرار میدهد و تعامل با آن از طریق ارسال و دریافت پیامهای STREAMS صورت میگیرد.
رابط DLPI دو شیوه کاری (Style) دارد: در روش اول، یک دستگاه واحد باز شده و اینترفیس مورد نظر با درخواست DL_ATTACH_REQ تعیین میشود؛ در روش دوم، برنامه مستقیماً دستگاه اختصاصی اینترفیس (مانند le0) را باز میکند و برای عملکرد بهینه، دو ماژول STREAMS را به درون مسیر جریان تزریق (Push) مینماید. این دو ماژول عبارتند از pfmod برای فیلتر کردن بسته درون Kernel و bufmod برای بافر کردن دادهها قبل از تحویل به برنامه، که ساختار آنها در تصویر بالا قابل مشاهده است.
از نظر مفهومی، این سازوکار شباهت زیادی به BPF دارد؛ به طوری که pfmod با استفاده از یک شبهماشین فیلترسازی درون هسته را ممکن میسازد و bufmod با پشتیبانی از طول اسنپشات و مهلت زمانی خواندن، هم حجم دادهها و هم تعداد فراخوانیهای سیستمی را کاهش میدهد.
یکی از تفاوتهای جذاب میان DLPI و BPF در نوع معماری شبهماشین مورد استفاده آنهاست؛ فیلتر BPF از یک گراف کنترل جریان غیرمدور یا CFG (Directed Acyclic Graph) بهره میبرد، در حالی که pfmod از درخت عبارتهای بولی (Boolean Expression Tree) استفاده میکند. ساختار BPF به زیبایی به کد ماشینهای مبتنی بر رجیستر تبدیل میشود، اما pfmod متناسب با ماشینهای مبتنی بر پشته است. مقاله مککین و جیکوبسون [McCanne and Jacobson 1993] نشان میدهد که رویکرد CFG در BPF بسته به پیچیدگی فیلتر، بین ۳ تا ۲۰ برابر سریعتر از درخت عبارات بولی عمل میکند.
تفاوت مهم دیگر این است که BPF پیش از آنکه زحمت کپی کردن بسته را به خود بدهد، ابتدا فیلتر را روی آن اعمال میکند تا اگر قرار است بسته دور ریخته شود، اصلاً کپی نشود؛ در مقابل، معماری DLPI ابتدا بسته را کپی میکند تا به pfmod برساند و سپس pfmod در صورت عدم تطابق آن را دور میریزد.
۲۹.۴ لینوکس: رابطهای SOCK_PACKET و PF_PACKET
در لینوکس دو روش برای دریافت بستهها از لایه Datalink وجود دارد. روش قدیمیتر که پشتیبانی عمومیتر اما انعطاف کمتری داشت، ایجاد سوکت از نوع SOCK_PACKET بود. در مقابل، رویکرد مدرنتر که امکانات پیشرفته فیلترینگ و کارایی بالاتر را ارائه میدهد، ایجاد سوکت از خانواده PF_PACKET است.
هر دو روش به دسترسی سطح بالا (مشابه ساخت raw socket) نیاز دارند و آرگومان سوم تابع socket باید یک مقدار غیر صفر برای مشخص کردن نوع فریم Ethernet باشد. در صورت استفاده از سوکتهای PF_PACKET، میتوان برای پارامتر دوم نوع SOCK_DGRAM را جهت دریافت بستههای پردازششده بدون هدر لایه پیوند، یا SOCK_RAW را برای دریافت بسته کامل لایه پیوند انتخاب کرد. اما سوکتهای قدیمی SOCK_PACKET همواره فقط فریم کامل لایه پیوند را برمیگردانند. برای مثال، جهت دریافت تمامی فریمها از لایه Datalink از کد زیر استفاده میشود:
fd = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); /* newer systems */
OR
fd = socket(AF_INET, SOCK_PACKET, htons(ETH_P_ALL)); /* older systems */
با این فراخوانی، تمام فریمهای دریافتی لایه Datalink با هر پروتکلی دریافت میشوند. حال اگر صرفاً فریمهای IPv4 مد نظر باشند، تابع به صورت زیر فراخوانی خواهد شد:
fd = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_IP)); /* newer systems */
OR
fd = socket(AF_INET, SOCK_PACKET, htons(ETH_P_IP)); /* older systems */
برای آرگومان آخر میتوان از ثوابت دیگری نظیر ETH_P_ARP یا ETH_P_IPV6 نیز استفاده کرد. تعیین مقدار پروتکل ETH_P_xxx به لایه پیوند اعلام میکند که چه نوع فریمهایی باید به این سوکت تحویل داده شوند. علاوه بر این، اگر لایه Datalink از حالت Promiscuous پشتیبانی کند (مانند Ethernet)، در صورت نیاز میتوان دستگاه را روی این حالت بیقید تنظیم کرد.
متأسفانه در این روش، شنودکنندههای مختلف در حالت Promiscuous ممکن است با یکدیگر تداخل پیدا کنند و در صورت بروز خطا و بسته شدن ناگهانی یک برنامه معیوب، ممکن است کارت شبکه حتی پس از خروج برنامه همچنان در وضعیت بیقید باقی بماند.
مقایسه این سازوکار لینوکس با BPF و DLPI تفاوتهای قابل توجهی را آشکار میسازد:
- در لینوکس بافرینگ در سطح Kernel وجود ندارد و فیلتر کردن در هسته نیز تنها در نسخههای جدیدتر (با گزینه SO_ATTACH_FILTER در سوکت) پشتیبانی میشود. اگرچه سوکتها بافر دریافت دارند، اما قادر نیستند چندین فریم را با هم ادغام کرده و با یک دستور read به برنامه تحویل دهند؛ از این رو کپی مکرر حجم بالایی از دادهها بین فضای هسته و کاربر، سربار پردازشی زیادی ایجاد میکند.
- رابط SOCK_PACKET قابلیت فیلترسازی بر اساس دستگاه (کارت شبکه) را ارائه نمیدهد، هرچند در سوکتهای PF_PACKET میتوان با فراخوانی bind سوکت را به دستگاه خاصی مقید کرد. با این حال در SOCK_PACKET، اگر در زمان ایجاد سوکت مقدار ETH_P_IP تعیین شود، تمام بستههای تمامی کارتهای شبکه به سوکت سرازیر میشوند. تابع recvfrom ساختار آدرس سوکت عمومی را بازمیگرداند که فیلد sa_data آن حاوی نام دستگاه (مثلاً eth0) است و خود برنامه باید دادههای دستگاههای ناخواسته را پالایش کند؛ این سرازیر شدن حجم عظیمی از دادههای ناخواسته به برنامه، نظارت در شبکههای پرسرعت را با چالش جدی روبهرو میسازد.
۲۹.۵ کتابخانه ضبط بسته libpcap
کتابخانه libpcap رویکردی کاملاً مستقل از سیستمعامل برای دسترسی به لایه ضبط بسته فراهم میکند. در زمان نگارش اولیه، این کتابخانه صرفاً از خواندن بسته پشتیبانی میکرد (البته از نسخه 0.8.0 در سال ۲۰۰۳ به بعد، هر دو قابلیت خواندن و نوشتن فریمهای لایه Datalink فراهم شده است). برای ایجاد و ارسال بستههای سفارشی در لایه پیوند و ساختارهای دلخواه بسته، به بخش بعد توجه کنید.
۲۹.۶ کتابخانه ساخت و تزریق بسته libnet
کتابخانه libnet ابزاری قدرتمند برای ساخت بستههای دلخواه و تزریق مستقیم آنها به شبکه فراهم میآورد و به شکلی مستقل از پلتفرم، هم از سوکتهای raw و هم از حالت دسترسی به لایه Datalink پشتیبانی میکند.
این کتابخانه جزئیات پیچیده مربوط به پیکربندی هدرهای IP، UDP یا TCP را مخفی کرده و روشی ساده و قابل حمل برای تولید بستههای خام و فریمهای Datalink ارائه میدهد. درست مانند libpcap، این کتابخانه نیز شامل توابع متعددی است. در مثال بخش بعد، توابع مورد نیاز برای کار با سوکتهای raw را بررسی میکنیم و در کنار آن، کد مربوط به استفاده مستقیم از raw socket را برای مقایسه خواهیم دید.
تمامی توابع این کتابخانه با پیشوند _libnet شروع میشوند و برای مطالعه جزئیات بیشتر میتوان به صفحات راهنمای (Man Pages) کتابخانه libnet مراجعه کرد.
۲۹.۷ بررسی فیلد UDP Checksum
در این بخش میخواهیم برنامهای کاربردی بنویسیم که یک دیتاگرام UDP حاوی پرسوجوی DNS را به یک Name Server ارسال کرده و پاسخ را با استفاده از کتابخانه ضبط بسته بخواند. هدف اصلی از این تمرین، بررسی فعال بودن محاسبه UDP Checksum توسط سرور است.
در پروتکل IPv4، محاسبه فیلد UDP Checksum اختیاری است. امروزه بیشتر سیستمها به صورت پیشفرض این چکسام را فعال میکنند، اما در سیستمهای قدیمیتر مانند SunOS 4.1.x این قابلیت به طور پیشفرض خاموش بود. با این حال، تمام سیستمهای امروزی — بهویژه سرورهایی که سرویس DNS ارائه میدهند — باید همواره با چکسام فعال کار کنند تا خطر خرابی پایگاه داده ناشی از دیتاگرامهای مخدوش از بین برود.
ما یک دیتاگرام UDP سفارشی (شامل پرسوجوی DNS) خواهیم ساخت و آن را روی یک raw socket مینویسیم و سپس نمونه معادل آن را با libnet نیز بررسی خواهیم کرد. اگرچه میشد برای ارسال کوئری از سوکت معمولی UDP استفاده کرد، اما هدف ما در اینجا آموزش نحوه ساخت بسته کامل IP با استفاده از سوکت آپشن IP_HDRINCL است.
خواندن داده از یک سوکت معمولی UDP امکان دسترسی به فیلد UDP Checksum را نمیدهد و از سوی دیگر، با سوکتهای raw نیز نمیتوان بستههای UDP یا TCP ورودی را خواند (همانطور که در بخش ۲۸.۴ دیدیم). به همین دلیل، برای به دست آوردن دیتاگرام کامل UDP حاوی پاسخ سرور، ناچار به استفاده از ابزار Packet Capture هستیم. در نهایت، فیلد Checksum را در هدر UDP بسته ضبطشده بررسی میکنیم؛ اگر مقدار آن صفر باشد، نشاندهنده غیرفعال بودن چکسام در سرور است.
تصویر زیر سازوکار کلی و جریان کار برنامه ما را خلاصه میکند:
دیتاگرام دستساز UDP را از طریق raw socket ارسال کرده و پاسخ را با libpcap ضبط میکنیم. نکته جالب توجه این است که پشته لایه UDP سیستم نیز همزمان پاسخ Name Server را دریافت میکند، اما چون اطلاعی از پورت مبدأ تصادفیِ انتخابشده توسط برنامه ما ندارد، یک خطای ICMP با عنوان «Port Unreachable» به سمت سرور میفرستد؛ البته Name Server این خطای ICMP را نادیده خواهد گرفت.
لازم به ذکر است که پیادهسازی چنین برنامهای برای پروتکل TCP بسیار دشوارتر خواهد بود؛ زیرا حتی اگر بتوانیم یک سگمنت TCP دلخواه تولید کنیم، در زمان دریافت پاسخ از سرور، پشته پروتکل TCP سیستم میزبان ما که از این ارتباط بیخبر است بلافاصله یک بسته بازنشانی (RST) ارسال کرده و اتصال را قطع میکند.
تصویر زیر توابع تشکیلدهنده این برنامه و ارتباط میان آنها را نشان میدهد:
در ادامه فایل هدر udpcksum.h آمده است. این فایل در کنار هدر اصلی unp.h، شامل هدرهای سیستمی مختلفی است که برای دسترسی به ساختار هدر بستههای IP و UDP ضروری هستند.
بخش نخست تابع main در ادامه آورده شده است:
در ادامه، بخش بعدی تابع main به پردازش آرگومانهای خط فرمان (Command-line arguments) میپردازد:
گزینه 0- باعث میشود پرسوجوی UDP بدون محاسبه Checksum ارسال شود تا ببینیم آیا سرور با این بستهها رفتاری متفاوت از بستههای دارای چکسام خواهد داشت یا خیر.
گزینه i- به ما امکان میدهد کارت شبکه (اینترفیس) مورد نظر برای شنود پاسخ سرور را تعیین کنیم. اگر این گزینه مشخص نشود، کتابخانه ضبط بسته خود اینترفیسی را انتخاب میکند که در سیستمهای مجهز به چند کارت شبکه ممکن است اینترفیس اشتباهی انتخاب شود. این یک تفاوت اساسی میان خواندن از دستگاه Packet Capture و سوکتهای معمولی است؛ در سوکتها میتوان آدرس محلی را با Wildcard تعریف کرد تا بسته از هر اینترفیسی وارد شود دریافت گردد، اما ابزار ضبط بسته تنها روی یک اینترفیس مشخص گوش فرا میدهد.
با استفاده از گزینه l- میتوان آدرس IP و شماره پورت مبدأ را تعیین کرد؛ به این صورت که عبارت بعد از آخرین نقطه به عنوان شماره پورت (یا نام سرویس) و بخش قبل از آن به عنوان آدرس IP مبدأ در نظر گرفته میشود.
بخش پایانی تابع main را در ادامه مشاهده میکنید:
کد تابع open_pcap در ادامه آورده شده است:
تابع main پس از open_pcap، تابع test_udp را فراخوانی میکند که پیادهسازی آن در زیر آمده است؛ وظیفه این تابع ارسال پرسوجوی DNS و دریافت و خواندن پاسخ سرور است:
در ادامه پیادهسازی تابع open_output را میبینید که وظیفه ایجاد و آمادهسازی سوکت raw برای ارسال دیتاگرام سفارشی IP را بر عهده دارد:
در زیر تابع next_pcap آورده شده که بسته بعدی را از دستگاه ضبط بسته دریافت کرده و بازمیگرداند:
این تابع با فراخوانی تابع کتابخانهای pcap_next، بسته بعدی را بازمیگرداند یا در صورت اتمام مهلت زمانی (Timeout)، مقدار NULL برمیگرداند. خروجی pcap_next اشارهگری به خودِ بسته است و آرگومان دوم آن به ساختار pcap_pkthdr اشاره دارد که پس از دریافت، با اطلاعات مربوط به بسته پر میشود.
نگاهی به پیادهسازی درونی pcap_next در کتابخانه نشان میدهد که توابع متنوعی در پسزمینه درگیر هستند. برنامه ما توابع سطح بالای _pcap را صدا میزند؛ برخی از این توابع کاملاً مستقل از سیستمعامل هستند، اما توابع زیرین متناسب با نوع معماری بستهگیری پلتفرم عمل میکنند. به عنوان مثال، پیادهسازی مربوط به BPF تابع سیستمی read را صدا میزند، پیادهسازی DLPI از getmsg استفاده میکند و در سیستمعامل Linux فراخوانی recvfrom به کار گرفته میشود.