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