امنیت

تزریق SQL در ORM؛ جاهایی که پارامتری نمی‌شود

ORM به‌خودی‌خود جلوی تزریق SQL را نمی‌گیرد. سه نقطه‌ای که کوئری‌بیلدر رشته می‌سازد و راه بستنشان را ببینید.

امنیت

کوئری لاگ‌شده را نگاه می‌کنید و می‌بینید چیزی که فرستاده شده با چیزی که در کد نوشته‌اید فرق دارد. یک OR 1=1 ته شرط نشسته، یا یک ORDER BY که ستونش را شما تعیین نکرده‌اید. کد از Eloquent یا Hibernate یا Prisma استفاده می‌کند، پس باید امن باشد. نیست. تزریق SQL در ORM یک تناقض نیست؛ نتیجهٔ مستقیم این واقعیت است که ORM فقط جایی پارامتری می‌کند که خودش رشتهٔ کوئری را می‌سازد. هر جا شما رشته بسازید و به آن تحویل بدهید، محافظت خاموش می‌شود.

سه نقطهٔ مشخص وجود دارد که این اتفاق در آن‌ها می‌افتد. هر سه در کدی هستند که روزمره نوشته می‌شود و همه‌شان هم در بازبینی کد سالم به نظر می‌رسند.

whereRaw و خواهر و برادرهایش

هر ORM یک دریچهٔ فرار دارد. در Laravel اسمش whereRaw است، در TypeORM می‌شود query()، در SQLAlchemy می‌شود text(). این‌ها برای وقتی‌اند که کوئری‌بیلدر از عهدهٔ کاری برنمی‌آید و شما باید SQL خام بنویسید. مشکل این است که این دریچه هیچ محافظتی ندارد.

// این تزریق‌پذیر است
$users = DB::table('users')
    ->whereRaw("email = '$email'")
    ->get();

// این نیست
$users = DB::table('users')
    ->whereRaw('email = ?', [$email])
    ->get();

تفاوت در یک علامت سؤال است. نسخهٔ اول رشته را قبل از رسیدن به درایور می‌سازد، پس کوتیشن داخل $email ساختار کوئری را می‌شکند. نسخهٔ دوم مقدار را به‌عنوان پارامتر bind می‌فرستد و درایور آن را داده می‌بیند، نه کد.

این‌جا اشتباه می‌کنند: خیلی‌ها فکر می‌کنند اگر ورودی را با htmlspecialchars یا یک تابع escape دستی تمیز کنند، کافی است. علامت این اشتباه را در لاگ می‌بینید، نه در تست. کوئری در محیط توسعه درست کار می‌کند چون ورودی تست شما کوتیشن ندارد. در production اولین درخواستی که ' داشته باشد، یا خطای syntax می‌دهد یا بدتر، بی‌صدا نتیجهٔ اشتباه برمی‌گرداند. escape دستی هم تقریباً همیشه ناقص است؛ هر دیتابیس قواعد نقل‌قول خودش را دارد و درایور همان قواعد را دقیق‌تر از شما می‌داند.

نام ستون و جهت مرتب‌سازی از ورودی کاربر

این جدی‌ترین مورد است، چون پارامتر bind اصلاً جواب نمی‌دهد. placeholder فقط برای مقدار است. اسم ستون، اسم جدول و کلمهٔ ASC/DESC بخشی از ساختار کوئری‌اند و دیتابیس اجازه نمی‌دهد پارامتر شوند.

// ?sort=created_at&dir=desc
$sort = $request->query('sort');
$dir  = $request->query('dir');

User::orderBy($sort, $dir)->paginate();

اگر $sort مستقیم به orderBy برود، مهاجم می‌تواند ستون دلخواه را مرتب کند و از ترتیب نتایج، مقدار ستون‌هایی مثل password_hash یا api_token را استخراج کند. این تکنیک کورکورانه نیست؛ با چند صد درخواست مرتب‌شده، بیت‌به‌بیت مقدار لو می‌رود. اگر ORM شما orderBy را با اعتبارسنجی داخلی اجرا نکند، حتی می‌توان یک subquery کامل جای نام ستون جا داد.

راه درست، allow-list است. نه فیلتر کردن کاراکترهای خطرناک، نه regex روی الگوی ستون.

$allowed = ['created_at', 'name', 'price'];
$sort = in_array($request->query('sort'), $allowed, true)
    ? $request->query('sort')
    : 'created_at';

$dir = $request->query('dir') === 'asc' ? 'asc' : 'desc';

هر ورودی‌ای که به ساختار کوئری می‌رود باید از یک مجموعهٔ بسته انتخاب شود، نه اینکه «تمیز» شود. تفاوت این دو در این است که allow-list ذاتاً امن است و فیلترکردن ذاتاً شکننده.

IN و LIKE و مرتب‌سازی‌های پویا

دو جای دیگر که مرتب دست‌کم گرفته می‌شوند. اول whereIn با آرایه‌ای که از ورودی ساخته شده: اگر آرایه را با implode به رشته تبدیل کنید و داخل whereRaw بگذارید، به همان مشکل اول برگشته‌اید. whereIn('id', $ids) خودش پارامتری می‌کند؛ whereRaw("id IN ($ids)") نمی‌کند.

دوم LIKE. حتی وقتی مقدار را bind می‌کنید، کاراکترهای % و _ در ورودی کاربر معنی wildcard دارند. این تزریق SQL نیست، ولی یک حملهٔ DoS سبک است: یک % در ابتدای الگو، ایندکس را بی‌استفاده می‌کند و روی جدولی با چند میلیون ردیف، کوئری را از چند میلی‌ثانیه به چند ثانیه می‌برد. اگر جست‌وجوی سایت کند شده و CPU دیتابیس بالا رفته، قبل از هر چیز الگوهای LIKE را چک کنید.

ORM را دور نزنید، ولی به آن هم اعتماد نکنید

موضع من روشن است: تا وقتی کوئری‌بیلدر جواب می‌دهد، از آن استفاده کنید و به SQL خام نروید. اگر مجبور شدید، فقط با placeholder. اگر placeholder جواب نمی‌دهد (مثل نام ستون)، allow-list. این ترتیب را عوض نکنید.

ولی این تصمیم هزینه دارد و باید بدانید. allow-list یعنی هر بار اضافه‌کردن یک ستون قابل مرتب‌سازی، یک تغییر کد لازم دارد. تیم‌هایی که این را «غیرقابل نگهداری» می‌بینند، سراغ فیلترکردن می‌روند و شش ماه بعد با یک گزارش نشت داده روبه‌رو می‌شوند. هزینهٔ نگهداری allow-list را بپردازید؛ ارزان‌تر از بازنویسی جدول کاربران است.

یک نکتهٔ عملیاتی هم هست: ORM خطاها را با کوئری کامل لاگ می‌کند. اگر APP_DEBUG در production روشن باشد، همان کوئری که تزریق‌پذیر است، همراه با مقادیر bind در پاسخ HTTP برمی‌گردد و نقشهٔ راه را به مهاجم می‌دهد. این را در امنیت وردپرس؛ از wp-config تا افزونه‌هایی که خودشان حفره‌اند هم دیده‌ایم: بیشتر نشت‌ها از یک تنظیم debug فراموش‌شده شروع می‌شوند، نه از یک آسیب‌پذیری پیچیده.

چطور بفهمید کدتان واقعاً آسیب‌پذیر است

سه کار، به همین ترتیب.

  1. در کل کدبیس دنبال whereRaw، orderByRaw، selectRaw، DB::raw، query() و text() بگردید. هر خروجی را دستی بخوانید. این grep ده دقیقه وقت می‌برد و معمولاً دو تا پنج مورد پیدا می‌کند.
  2. هر پارامتری که به orderBy، groupBy، select یا نام جدول می‌رود را ردیابی کنید تا ببینید از request می‌آید یا نه.
  3. روی staging با یک ابزار اسکن مثل sqlmap روی همان endpointها تست کنید. اگر پارامتر مرتب‌سازی را با --level=5 --risk=3 بدهید، معمولاً در چند دقیقه نتیجه می‌گیرید.

اگر لاگ ممیزی دارید، درخواست‌هایی با sort= غیرعادی یا کوئری‌های طولانی را می‌توانید عقب‌گرد کنید. اگر ندارید، لاگ ممیزی چیست و چگونه آن را دست‌نخورده نگه داریم؟ نقطهٔ شروع درستی است، چون بدون لاگ نمی‌فهمید حمله شروع شده یا نه.

و یک لایهٔ دفاعی که خیلی‌ها فراموش می‌کنند: کاربر دیتابیس اپلیکیشن نباید DROP یا FILE داشته باشد. اگر تزریق موفق شود ولی کاربر فقط SELECT و INSERT روی چند جدول داشته باشد، دامنهٔ خسارت از «کل سرور» به «یک جدول» کوچک می‌شود. این تنظیم پنج دقیقه‌ای، بدترین سناریو را قابل تحمل می‌کند.

اگر ترجیح می‌دهید این بازبینی را به تیم دیگری بسپارید، خدمات امنیت سرورنت شامل بررسی همین الگوها در کد و پیکربندی سرور است. برای دیدن وضعیت لحظه‌ای سرویس‌ها هم وضعیت سرویس‌ها را چک کنید.

پرسش‌های پرتکرار

آیا استفاده از ORM به‌تنهایی جلوی تزریق SQL را می‌گیرد؟

نه. ORM فقط وقتی محافظت می‌کند که خودش رشتهٔ کوئری را بسازد و مقدار را به‌عنوان پارامتر bind بفرستد. به‌محض اینکه شما با whereRaw یا DB::raw رشته بسازید و ورودی کاربر را داخلش بگذارید، همان آسیب‌پذیری SQL خام برمی‌گردد.

قاعدهٔ ساده: هر جا در کد شما رشته‌ای با . یا + به هم چسبانده می‌شود تا کوئری شود، آن نقطه مشکوک است.

چرا نمی‌توانم نام ستون را با placeholder پارامتری کنم؟

چون placeholder جای مقدار است، نه جای شناسه. دیتابیس قبل از اجرا ساختار کوئری را تجزیه می‌کند و نام ستون باید در همان مرحله مشخص باشد؛ پارامتر bind بعد از آن مرحله پر می‌شود.

پس برای نام ستون، جدول و جهت مرتب‌سازی چاره‌ای جز allow-list ندارید. یک آرایهٔ ثابت از مقادیر مجاز بسازید و هر چیز دیگری را به مقدار پیش‌فرض برگردانید.

نشانهٔ حملهٔ تزریق SQL در لاگ‌ها چیست؟

کوئری‌هایی با کوتیشن تک اضافه، OR 1=1، UNION SELECT، کامنت‌های -- یا /* در انتهای شرط، و پارامترهایی با کدگذاری URL تودرتو. در سطح سرور هم درخواست‌های پشت‌سرهم با پارامترهای مرتب‌سازی متفاوت روی یک endpoint، الگوی کلاسیک استخراج کورکورانه است.

اگر لاگ ممیزی جدا و دست‌نخورده دارید، می‌توانید بفهمید کدام کوئری‌ها واقعاً اجرا شده‌اند و دامنهٔ نشت را محدود کنید.

آیا WAF جلوی این نوع تزریق را می‌گیرد؟

بخشی از آن را، ولی نه همه را. WAF روی الگوهای شناخته‌شده در پارامترهای ورودی کار می‌کند و تزریق از طریق نام ستون مرتب‌سازی را که به شکل یک کلمهٔ ساده می‌آید، معمولاً نمی‌بیند. محافظت در برابر DDoS و WAF لایهٔ کاهش بار هستند، نه جایگزین allow-list در کد.

ترتیب درست این است: اول کد را درست کنید، بعد لایهٔ شبکه را اضافه کنید. برعکسش فقط زمان می‌خرد.

پشتیبانی سرورنت

تیم فنی و تحریریه‌ی سرورنت — تخصص در زیرساخت، شبکه و میزبانی وب.

خدمات امنیت
اشتراک‌گذاری:

دیدگاه‌ها ۰

هنوز دیدگاهی ثبت نشده؛ اولین نفر باشید!

دیدگاه خود را بنویسید

سرویس مرتبط

خدمات امنیت

تست نفوذ توسط متخصصان دارای مدرک OSCP، امن‌سازی زیرساخت و مانیتورینگ امنیتی ۲۴ ساعته — گزارش‌هایی که مدیر می‌فهمد و مهندس اجرا می‌کند.