לוח ניהול על שרת נתקל כמעט תמיד באותו קיר: תהליך האינטרנט צריך לעשות משהו שאין לו הרשאות אליו. לחסום כתובת, לפתוח פורט, לקרוא את היומן. התשובה המתבקשת היא להעניק שורה צרה אחת ב-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, מבצע את מה שהוחדר. עשינו זאת ב-4 ביולי בשרת שלנו: קובץ ב-/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, הרשאות 755, ו— וזה אינו אופציונלי — הוא חייב להיות לא ניתן לכתיבה מהאינטרנט, אחרת כל המבנה מאבד משמעות. ב-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 מקומה מול העיניים, לצד שאר מצב השרת, ולא בזיכרון שלאחר תקרית. איך זה נראה כשהכול מקובץ יחד מציגים דפי ההדגמה שלהלן.