תיקייה אחת ב-cgroup, וה-agent של SPIRE מנפיק לתוקף את הזהות של workload שכן
השורה שחזרה מה-agent הייתה קצרה ומנומסת: no identity issued. חוקר של Unit 42 ישב עם הרשאות root על node בקלאסטר Kubernetes, פנה דרך ה-socket המקומי וביקש תעודת זהות ששייכת ל-workload אחר שרץ על אותה מכונה, וקיבל סירוב מסודר. עד כאן הכול עבד בדיוק כמו שתוכנן. ואז הוא יצר תיקייה אחת שמחקה את המבנה שבו הקרנל מסווג את התהליכים של אותו workload, העביר אליה את התהליך של עצמו, והריץ שוב את אותה בקשה בדיוק. הפעם חזר token חתום ותקף לגמרי, ובשדה הזהות שלו רשום spiffe://example.org/ns/a/sa/a.
זה לא היה שלו.
המחקר, The Machine With Many Faces, פורסם ב-10 בספטמבר ונכתב על ידי Eviatar Garzi מ-Unit 42 של פאלו אלטו. שימו לב דווקא למה שאין בו: אין CVE, אין גרסה לעדכן ואין באג בקוד. Unit 42 גם כותבת במפורש שלא ראתה את הטכניקה בשימוש בשטח. מה שכן יש בו הוא הדגמה נקייה של הנחה שכל מערכת זהויות למכונות נשענת עליה, ומה נשאר ממנה ברגע שהיא נשברת. נעבור כאן על מה SPIFFE בא לפתור, איך ה-agent מחליט למי מגיעה איזו זהות, ולמה מספיק לשקר לו בנקודה אחת.
הבעיה שקוראים לה "הסוד הראשון"
קחו שני שירותים, frontend ו-backend, שצריכים לדבר ביניהם בערוץ מוצפן ומאומת. אפשר לייצר לכל אחד זוג מפתחות, להחליף ביניהם את החלק הציבורי ולהרים mTLS, אבל מיד נפתחות שאלות שאף אחד לא אוהב לענות עליהן: מי מחליף את המפתחות כשהם מתיישנים, מי מבטל אותם אם אחד השירותים נפרץ, ואיך בכלל יודעים שמי שמציג את המפתח הוא באמת מי שהוא טוען. מתחת לכל אלה יושבת שאלה בסיסית יותר. כדי להכניס לשירות סוד ראשוני צריך ערוץ מאובטח, וכדי לאבטח את הערוץ צריך סוד. בתעשייה קוראים ללולאה הזאת בעיית Secret Zero.
SPIFFE הוא תקן פתוח שמנסה לפרק אותה, על ידי החלפת הסודות ארוכי-החיים בזהויות קצרות-טווח שמונפקות מחדש כל הזמן. לכל workload יש שם בפורמט של כתובת, למשל spiffe://example.org/ns/a/sa/a, ויש לו מסמך חתום קריפטוגרפית שמוכיח שהשם הזה באמת שלו. למסמך הזה קוראים SVID, והוא מגיע כתעודת X.509 לשימוש ב-mTLS או כ-token בפורמט JWT לאימות ברמת האפליקציה. SPIRE הוא המימוש הנפוץ של התקן, והוא זה שפרוס בפועל בסביבות Kubernetes. הוא בנוי משרת שמתפקד כ-CA, כלומר הגורם שחותם על התעודות ומחזיק את רשומות הרישום, ומ-agent שרץ על כל node ומחלק זהויות ל-workload-ים שרצים עליו דרך socket מקומי.
איך ה-agent מחליט מי אתם
כאן מתחיל החלק המעניין. כש-workload רוצה זהות הוא פונה ל-agent דרך ה-Workload API, בדרך כלל ב-socket שיושב ב-/run/spire/sockets/agent.sock. הבקשה עצמה לא מכילה שום הצהרה על מי הפונה, וזה בכוונה: אם ה-workload היה מצהיר על עצמו, כל אחד היה יכול להצהיר מה שבא לו. במקום זה ה-agent שולף מהחיבור את מספר התהליך של הצד השני, ומשם ואילך הוא חוקר את התהליך הזה בעצמו.
את החקירה מבצעים תוספים שנקראים workload attestors, ובסביבת Kubernetes עובדים בעיקר שניים. התוסף של Kubernetes לוקח את מספר התהליך וקורא ממנו את היררכיית ה-cgroup, מנגנון הקרנל שמקבץ תהליכים לקבוצות ומאפשר להגביל להן משאבים. הנתיב שם הוא שרשרת שכוללת את מזהה ה-pod ואת מזהה ה-container, והתוסף מפרסר אותם החוצה. עם שני המזהים האלה ביד הוא פונה ל-kubelet המקומי, מציג את ה-token של חשבון השירות של ה-agent עצמו (שיושב ב-/var/run/secrets/kubernetes.io/serviceaccount/token), מושך את רשימת ה-pod-ים שרצים על ה-node ומאתר את זה שהמזהים שלו תואמים. מה שחוזר הוא ערימה של תכונות: k8s:ns עם ה-namespace, k8s:sa עם חשבון השירות, ואחריהן שם ה-pod, שם ה-container, ה-image ותוויות. התוסף השני, של Unix, מוסיף מאפיינים ברמת מערכת ההפעלה כמו unix:uid ו-unix:gid.
לתכונות האלה קוראים selectors. ה-agent מחבר את כל מה שאסף ומשווה מול רשומות הרישום שהוא שומר בזיכרון מהשרת. אם ה-selectors של רשומה כלשהי מוכלים במה שנאסף על התהליך, ה-agent מחזיר לו את ה-SVID של אותה רשומה. אף שלב בשרשרת הזאת לא מבקש מה-workload סוד כלשהו.
עד כאן איך המערכת מזהה. הצד השני, מה קורה כשהיא מזהה לא נכון, הוא כל הסיפור.
אז למה תיקייה אחת מספיקה?
Unit 42 הקימה בסביבת המעבדה שלה pod בשם workload-a ורשמה לו רשומה בשרת: הזהות spiffe://example.org/ns/a/sa/a, עם שני selectors בלבד, ה-namespace וחשבון השירות. מהמכונה עצמה, בלי שום זיוף, בקשה ל-token בשם הזהות הזאת נדחתה. כמו שצריך.
השלב הבא היה למצוא את מספר התהליך של workload-a דרך כלי ניהול ה-container-ים, ולקרוא ממנו את נתיב ה-cgroup שלו. משם העניין קצר: החוקרים יצרו נתיב cgroup חדש שמשכפל את המבנה של הקורבן, והכניסו אליו את התהליך שלהם. באותו רגע, כשה-agent ילך ויקרא את היררכיית ה-cgroup של התהליך התוקף, הוא ימצא שם את מזהה ה-pod ואת מזהה ה-container של workload-a, ימשיך משם ל-kubelet, ויקבל בחזרה בדיוק את ה-selectors של הקורבן. אותה בקשה שנדחתה קודם חזרה הפעם עם token חתום שבתוכו הזהות של workload-a. החוקרים מציינים שהם בכלל לא היו חייבים ליצור נתיב נפרד, כתיבה לתוך ה-cgroup המקורי הייתה עובדת גם היא, והם בחרו במסלול העוקף רק כדי לא לגעת במצב הריצה של ה-pod האמיתי.
הסיבה שזה עובד פשוטה: כל שרשרת הזיהוי תלויה בנתון אחד שמי שיש לו root על המכונה שולט בו במלואו. הפונקציה בקוד של SPIRE שמוציאה את מזהה ה-pod וה-container קוראת אותם ישירות מהמידע שהקרנל חושף על התהליך, כמו שאפשר לראות ב-קוד המקור של הפרויקט. ה-agent לא נפרץ ולא רומה על ידי באג. הוא קרא נאמנה את מה שכתוב, ומה שכתוב היה שקר.
Spooffe, ומה קורה כשמריצים את זה על כל המכונה
מהדגמה ידנית אחת אפשר להגיע רחוק יותר. Unit 42 שחררה כלי פתוח בשם Spooffe, ברישיון Apache-2.0, שמבצע את התהליך הזה בלולאה: הוא סורק את ה-node אחרי workload-ים פעילים, מאתר את נתיבי ה-cgroup שלהם, משכפל כל אחד בתורו ומבקש מה-agent המקומי את הזהות שמתאימה לו. התוצאה היא חילוץ של כל הזהויות שקיימות על אותה מכונה, אחת אחרי השנייה, לתיקייה מקומית. לפי ה-README הכלי דורש Linux והרשאות root, ומוגדר כמיועד למחקר ולבדיקות אבטחה מורשות.
Spooffe כולל גם יכולת נוספת שהמחקר מזכיר אבל לא מרחיב עליה, והיא בדיקה האם תוקף יכול להתחזות ל-agent עצמו ולדבר ישירות מול השרת. זה כיוון אחר לגמרי: לא לקבל זהות של workload שכן, אלא לתפוס את התפקיד של מי שמחלק אותן.
כמה רחוק זה באמת מגיע?
שווה למקם את זה נכון, כי הכותרת מפתה יותר מהמציאות. הטכניקה כולה מתחילה מהרשאות root על ה-node. תוקף מרוחק בלי דריסת רגל לא יכול להשתמש בה, והיא לא מקצרת לאף אחד את הדרך פנימה. מה שהיא כן עושה הוא לשנות את מה שמשיגים אחרי שכבר נכנסו. הרדיוס שלה הוא הזהויות שמשויכות לאותו node, כלומר ה-workload-ים שרצים לידכם על אותה מכונה, ולא כל מה שקיים בקלאסטר.
הנקודה שמטרידה יותר היא איך זה נראה בצד המגן. "ברגע שגבול האמון הזה נשבר, בידוד הזהויות בין ה-workload-ים קורס, ומאפשר לתוקפים לנוע לרוחב באמצעות פרטי אימות לגיטימיים במקום סודות גנובים", כתבו החוקרים. ה-SVID שהתוקף מקבל הוא לא מזויף ולא משוכפל, הוא הונפק זה עתה על ידי השרת האמיתי, חתום כמו שצריך ותקף לזמן הרגיל. בקרה שמחפשת סודות שדלפו או תעודות פגות לא תראה כאן דבר. ההמלצה שהמחקר מסכם בה היא בהתאם, ומנוסחת כהנחת מודל איומים ולא כתיקון: להניח שהרשאות root על node שוות לפריצה של כל הזהויות שמשויכות אליו.
מה זה אומר בפועל
אם אתם מריצים SPIRE בקלאסטר, הדבר הראשון שכדאי לעדכן הוא לא קובץ הגדרות אלא מסמך: ה-node צריך להיות מסומן במודל האיומים שלכם כגבול אמון אמיתי, ופריצה שלו צריכה להיחשב לחשיפה של כל ה-workload-ים שיושבים עליו. משם נגזרות המסקנות הרגילות והמשעממות, שדווקא כאן מחזירות הרבה: לצמצם container-ים פריבילגיים וגישה ישירה למארח, ולא לבנות רשומות רישום רגישות על selectors רחבים כמו namespace וחשבון שירות בלבד, כשאפשר לדרוש מאפיינים ספציפיים יותר. ומי שרוצה מספר במקום הערכה יכול פשוט להריץ את Spooffe על node בסביבת בדיקות ולראות בעיניים כמה זהויות יוצאות משם.
תגובות