پنل مدیریت روی سرور تقریباً همیشه به یک دیوار می‌خورد: فرایند وب باید کاری انجام دهد که اجازه‌اش را ندارد. مسدود کردن یک نشانی، باز کردن یک درگاه، خواندن ژورنال. پاسخی که خود را تحمیل می‌کند، دادن یک خط باریک در sudoers است و نه چیزی بیشتر:

www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client set * banip *

این خط چنین خوانده می‌شود: «فقط مسدود کردن». در واقعیت معنایش این است: «هر چیزی، با اختیار root». در ادامه: چرا چنین می‌شود، چگونه آن را روی سرور خودتان در پنج دقیقه بررسی کنید و چنین قاعده‌هایی با چه چیزی جایگزین می‌شوند.

ستاره * در واقع چه می‌کند

نکتهٔ تعیین‌کننده: sudo آرگومان‌ها را یکی‌یکی مقایسه نمی‌کند، بلکه تمام خط فرمان را می‌سنجد، و * در الگو به‌آسانی از فاصله‌ها — یعنی از مرز آرگومان‌ها — می‌گذرد. از همین رو عبارت عینی banip که در میانهٔ قاعده نشسته است چیزی را محدود نمی‌کند: کافی است واژهٔ banip جایی در فرمان بیاید، و پیش و پس از آن می‌توان هر چیزی فرستاد.

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

fail2ban-client get portscan actions
sudo fail2ban-client set portscan action nftables-multiport actionban "<فرمان>" banip 192.0.2.10
sudo fail2ban-client set portscan banip 192.0.2.11

فراخوان نخست متن کنش را در یک کنش واقعی جابه‌جا می‌کند، فراخوان دوم زندان را به کار می‌اندازد — و fail2ban که با اختیار root اجرا می‌شود، آنچه جاسازی شده را اجرا می‌کند. ما این کار را در ۴ ژوئیه روی سرور خودمان انجام دادیم: پرونده‌ای در /root با اختیار root ساخته شد، یعنی فرایند وب از قاعده‌ای که باریک به نظر می‌رسید، اختیار کامل روی ماشین گرفت.

در این میان سه جزئیات روشن شد که در نوشته‌های دیگران معمولاً نیست:

  • گزینهٔ -c پیش از set را sudo قطع می‌کند: در الگو set بی‌واسطه به نام فایل اجرایی چسبیده است و پیش از آن چیزی نمی‌توان گنجاند؛
  • addaction و action بدون واژهٔ banip نیز عبور نمی‌کنند — اما هر چه لازم است در یک فرمان set … banip … جا می‌گیرد و بدین‌سان الگو برآورده می‌شود؛
  • نام کنش باید واقعی باشد، وگرنه چیزی برای جابه‌جا کردن نیست: آن را fail2ban-client get <jail> actions نشان می‌دهد.

چه کسان دیگری در همین فهرست‌اند

fail2ban اینجا گناهی ندارد و یکتا هم نیست. خطرناک خودِ همنشینی NOPASSWD با ستاره است. آنچه در کنارش روی سرورهای خودمان یافتیم:

  • journalctl * — ژورنال از طریق صفحه‌بند باز می‌شود و از صفحه‌بند پوسته اجرا می‌گردد. قاعده در ظاهر به خواندن گزارش‌ها مربوط است؛ در عمل یک پوستهٔ root است. درمان، الگوی باریک‌تر نیست بلکه گروه systemd-journal است: آن‌گاه journalctl بی‌هیچ sudo کار می‌کند؛
  • grep * /var/log/fail2ban.log — آرگومان نخست grep الگو است، اما ستاره اجازه می‌دهد مسیر دومی هم داده شود و grep با اختیار root اجرا می‌گردد. خواندن /etc/shadow با این قاعده یک فرمان است. جایگزین همان است: گروه adm برای خواندن گزارش‌ها؛
  • ufw --force * و ufw allow/deny/delete * برهنه — فرایند وب می‌تواند دیوار آتش را به‌طور کامل خاموش کند. ناخوشایندتر آنکه پنل در هر دو مورد این قاعده‌ها را هرگز به کار نمی‌برد: اختیارهای مرده‌ای که از نسخه‌های نخستین نصب‌کننده مانده بودند؛
  • lynis-scan.sh * — پوششی که "$@" را به فرایند root می‌سپارد، با قاعده‌ای بی‌هیچ محدودیت برابر است.

چگونه ببینید چه دارید

آنچه باید دید پرونده نیست، بلکه اختیارهای واقعی یک کاربر مشخص است — همان کاربری که استخر PHP-FPM واقعاً با آن اجرا می‌شود (همیشه www-data نیست؛ روی یکی از ماشین‌های ما پنل با نام admin اجرا می‌شد):

ps -o user= -C php-fpm | sort -u
sudo -l -U www-data
sudo -l -U admin

سپس خودِ فهرست پرونده‌ها، و اینجا دو دام هست که از ما وقت گرفت:

sudo grep -rn 'NOPASSWD' /etc/sudoers /etc/sudoers.d/
ls -la /etc/sudoers.d/

نخست: قاعده‌ها همزمان در دو جا زندگی می‌کنند. در مورد ما خط خطرناک هم در /etc/sudoers.d/monitor بود و هم در /etc/sudoers اصلی — در اصلی در حدود هشت نسخه که نسخه‌های مختلف نصب‌کننده انباشته بودند. پاک کردن یک جا و آسوده شدن، راه معمول باز گذاشتن رخنه است.

دوم: sudo پرونده‌هایی را که در نامشان نقطه دارند نادیده می‌گیرد. پروندهٔ www-data.bak3 در /etc/sudoers.d/ مانند قاعده‌ای معتبر به نظر می‌رسد و مانند قاعده‌ای معتبر خوانده می‌شود، اما اثری ندارد. این در هر دو جهت کار می‌کند: «قاعده هست، اختیار نیست» و آرامش دروغین از اینکه نسخه‌ای از پیکربندی «همان‌جا کنارش افتاده است».

با چه چیزی جایگزین کنیم

باریک‌تر کردن الگو بی‌فایده است — ستاره در هر جای خط، مسئله را به آغاز بازمی‌گرداند. تنها یک روش پایدار است: پوششی با اختیار root که آرگومان‌های موضعی ثابت می‌گیرد، خودش آن‌ها را بررسی می‌کند و برنامه را بی‌هیچ امکانی برای افزودن چیزی فرا می‌خواند.

#!/bin/sh
# /usr/local/sbin/arciveo-f2b — ban|unban <jail> <ip>، و نه چیزی بیشتر
set -eu
action="$1"; jail="$2"; ip="$3"
case "$action" in ban|unban) ;; *) echo "invalid action"; exit 1 ;; esac
case "$jail" in *[!a-zA-Z0-9_-]*|'') echo "invalid jail"; exit 1 ;; esac
printf '%s' "$ip" | grep -Eq '^[0-9a-fA-F:.]+$' || { echo "invalid ip"; exit 1; }

case "$action" in
  ban)   exec /usr/bin/fail2ban-client set "$jail" banip   "$ip" ;;
  unban) exec /usr/bin/fail2ban-client set "$jail" unbanip "$ip" ;;
esac

پوشش از آنِ root است، با مجوز ۷۵۵، و — این اختیاری نیست — نباید از وب نوشتنی باشد، وگرنه تمام این ساختار بی‌معنا می‌شود. در sudoers تنها همین می‌ماند:

www-data ALL=(root) NOPASSWD: /usr/local/sbin/arciveo-f2b

آرگومان‌ها در قاعده فهرست نمی‌شوند: خود اسکریپت آن‌ها را بررسی می‌کند و جایگاه‌ها در آن ثابت‌اند، پس جایی برای لغزاندن متن کنش نیست. قاعده‌های فقط‌خواندنی — وضعیت‌ها، ss، ipset list — در خط‌های جداگانه می‌آیند، آن‌ها هم بی‌ستاره در جایی که ممکن است.

جداگانه دربارهٔ جایگزینی sudo با گروه‌ها. روش درست است: systemd-journal برای ژورنال و adm برای گزارش‌ها ارزان‌تر و ایمن‌تر از هر قاعدهٔ sudo درمی‌آیند. اما بیرون کشیدن کاربر وب از گروه‌ها به‌صورت کورکورانه ممکن نیست. ما www-data را روی میزبانی که پنل داشت از یک گروه بیرون آوردیم و یک‌باره روی همهٔ سایت‌ها خطای 403 گرفتیم: Apache آنجا با نام www-data اجرا می‌شود، پرونده‌های سایت‌ها از آنِ کاربر دیگری هستند و دسترسی خواندن را دقیقاً همان عضویت در گروه می‌داد. بازگرداندن نیازمند راه‌اندازی کامل — نه بازخوانی — Apache و PHP-FPM بود، زیرا کارگرهای قدیمی مجموعهٔ پیشین گروه‌ها را نگه می‌دارند و حالت «کار می‌کند، بعد 403» می‌سازند.

بررسی پس از تغییر

پیش از جایگزینی پروندهٔ sudoers باید نحو آن بررسی شود — وگرنه می‌توان کاملاً بی‌sudo ماند:

sudo visudo -cf /etc/sudoers.d/monitor

پس از آن باید تأیید شد که مسیر قدیم مرده است، و آن هم به نام همان کاربر:

sudo -k
sudo -u www-data sudo -n fail2ban-client set portscan banip 192.0.2.10

پاسخ باید a password is required باشد، در حالی که فراخواندن پوشش با آشغال به‌جای نشانی باید invalid ip بدهد. اینجا دامی از خودش در کمین است: sudo -v تأیید را پانزده دقیقه در حافظه نگه می‌دارد و پس از آن هر بررسی با sudo -n «با موفقیت» می‌گذرد. دقیقاً همین‌گونه یک بار قاعده‌ای را تأیید کردیم که روی سرور هرگز نبود. از همین رو sudo -k پیش از بررسی الزامی است.

فایدهٔ پایش

قاعده‌های sudo کم تغییر می‌کنند، اما بی‌صدا تغییر می‌کنند: نصب‌کننده به آن‌ها می‌افزاید، پنل میزبانی از خود چیزی اضافه می‌کند، بستهٔ حذف‌شده خط‌هایش را جا می‌گذارد. بازبینی یک‌باره آنچه امروز هست را می‌بندد و دربارهٔ آنچه به‌روزرسانی بعدی می‌آورد چیزی نمی‌گوید. نتیجهٔ عملی ساده است: فهرست کسانی که می‌توانند root شوند، جایش پیش چشم است، کنار دیگر وضعیت سرور، نه در یادآوری پس از یک رخداد. اینکه همهٔ این‌ها یکجا چگونه به نظر می‌رسد را صفحه‌های نمایشی زیر نشان می‌دهند.