قبلاً فکر میکردم نوشتن لایه احراز هویت خودم یک نشان افتخار است. اگر JWTها را درک کنید و بتوانید یک رمز عبور را هش کنید، چقدر میتواند سخت باشد؟ یک بکاِند Node راه انداختم، توکن صادر کردم و کار را تمام شده فرض کردم. کد کار میکرد. تستها با موفقیت پاس شدند. سپس شروع کردم به خواندن درباره اینکه حملات واقعی چگونه اتفاق میافتند و تمام باورهایم فرو ریخت. هر فصل درباره تزریق (injection)، شمارش (enumeration) و نشتهای کانال جانبی (side-channel leaks)، با حسی از ناامیدی مرا به سمت ویرایشگرم میکشاند. سیستم احراز هویت من فقط دارای شکاف نبود؛ بلکه چهار درِ کاملاً باز داشت که خودم آنها را نصب کرده بودم. در اینجا آنچه پیدا کردم و دقیقاً آنچه تغییر دادم را آوردهام.
تزریق SQL از طریق الحاق رشتهها (String Concatenation)
اولین باگ، قدیمیترین ترفند موجود بود. من ورودی کاربر را میگرفتم و مستقیماً در رشتههای SQL قرار میدادم. در مسیر ورود (login route)، ایمیل را از بدنه درخواست میگرفتم و آن را در کوئریای مانند SELECT * FROM users WHERE email = '${email}' الحاق میکردم. چون فرانتاِند را در کنترل داشتم، این کار بیخطر به نظر میرسید. این یک فرض خطرناک است. یک مهاجم نیازی به فرانتاِند شما ندارد. یک بدنه POST دستکاری شده میتواند آن بررسی ورود را به یک نشت داده یا پاک شدن کل پایگاه داده تبدیل کند. اگر یک پیلود (payload) مانند ' OR '1'='1 یا بدتر از آن، یک stacked query که جداول را حذف میکند ارسال شود، و اگر رشته به صورت خام اجرا شود، دادههای شما از بین میرود. من هیچ راهی برای متمایز کردن کد خودم از دادههای مهاجم برای پایگاه داده قرار نداده بودم.
راه حل، اعتبارسنجی بیشتر ورودی یا فرار دادن (escaping) دستی رشتهها نبود. راه حل واقعی، کوئریهای پارامتری شده (parameterized queries) بود. من به node-postgres تغییر وضعیت دادم و شروع به استفاده از جایگذارهایی (placeholders) مانند $1 کردم. کوئری به یک قالب تبدیل میشود: SELECT * FROM users WHERE email = $1. درایور، SQL و مقادیر را از طریق کانالهای جداگانه ارسال میکند. پایگاه داده با ورودی صرفاً به عنوان داده برخورد میکند، فارغ از اینکه شامل چه کاراکترهایی باشد. این یک تغییر، کل دسته از حملات تزریق را میبندد. خواندن آن سادهتر، نگهداری آن آسانتر است و این بارِ تبدیل شدن به یک جادوگر regex را هر بار که یک عبارت WHERE مینویسید، از دوش شما برمیدارد.
شمارش ایمیل از طریق پیامهای خطا
اشتباه دوم من شبیه به یک تجربه کاربری (UX) خوب به نظر میرسید. وقتی کاربر ایمیل اشتباهی وارد میکرد، User not found را برمیگرداندم. وقتی ایمیل درست بود اما رمز عبور اشتباه، Incorrect password را برمیگرداندم. این کار مفید به نظر میرسید، اما در عین حال ابزاری برای شناسایی (reconnaissance) مهاجمان بود. اسکریپتهای شمارش (enumeration) میتوانند با هزاران آدرس ایمیل، به نقطه پایانی (endpoint) ورود شما حمله کنند. اگر بدنه پاسخ یا کد وضعیت بسته به وجود داشتن یا نداشتن حساب کاربری تغییر کند، اسکریپت میتواند فهرستی تأیید شده از کاربران شما بسازد. آن لیست به پایهای برای حملات credential stuffing، فیشینگ هدفمند و تلاشهای بیشتر برای حملات brute-force تبدیل میشود.
باید میپذیرفتم که گاهی اوقات کاربرپسند بودن باید در برابر امنیت شکست بخورد. من تمام مسیرهای ورود ناموفق را تغییر دادم تا دقیقاً همان رشته را برگردانند: Invalid credentials. بدون هیچ راهنمایی. بدون هیچ منطق شرطی در پاسخ خطا. چه ایمیل وجود نداشته باشد، چه رمز عبور اشتباه باشد یا حساب کاربری قفل شده باشد، متن یکسان باقی میماند. این موضوع برای جریانهای ثبتنام و بازیابی رمز عبور نیز صدق میکند؛ فاش نکنید که آیا یک آدرس از قبل در سیستم شما وجود دارد یا خیر. یک پیام عمومی، نشت اطلاعاتی را که مهاجمان به آن متکی هستند، از بین میبرد.
حملات زمانبندی (Timing Attacks) در مقایسه رمز عبور
باگ سوم نامرئی بود. من داشتم
