בקוד המקורי של BSD, בקובץ ההגדרות שנושא זכויות יוצרים משנת 1988, יש שורה אחת שמגדירה מונה פשוט: כמה תהליכים מחוברים כרגע לאותו מקטע זיכרון משותף. הטיפוס שנבחר לו אז היה short חתום, כלומר 16 ביט, והוא יודע לספור עד 32,767. כמעט ארבעה עשורים אחר כך, בקרנל של OpenBSD, הוא עדיין short.

חוקר בשם Andrew Griffiths מחברת המחקר Calif פרסם ב-16 באוקטובר מה קורה כשדוחפים את המונה הזה מעבר לתקרה שלו. הוא מיפה 127 מקטעי זיכרון משותף, שחרר אותם, ויצר 259 תהליכים נוספים ב-fork. 127 כפול 259 הם 32,893, כלומר 126 מעבר למקסימום, ולכן הערך התהפך והגיע ל-32,643 מינוס. Calif מתארכת את ההגדרה הבעייתית לכ-37 שנה אחורה. משם ועד shell עם הרשאות root על OpenBSD 7.9 יש עוד כמה שלבים, וכולם רצו כשההגנות של המערכת דלוקות, בלי אף חולשה שמדליפה כתובות מתוך הקרנל. בהמשך נעבור על איך מונה שלילי הופך ליכולת כתיבה לזיכרון, איך סגירה של socket רגיל משנה את טבלת הדפים, ולמה דווקא ההגנה שבנתה ל-OpenBSD את המוניטין שלה לא עזרה כאן.

למה מונה שמתהפך שווה יותר מבאג ספירה

זיכרון משותף ב-SysV הוא מנגנון ותיק שבו כמה תהליכים נפרדים מחברים את עצמם לאותו אזור זיכרון, כדי לעבוד על אותם נתונים בלי להעביר אותם הלוך ושוב. הקרנל סופר כמה תהליכים מחוברים לכל מקטע, והחשבון הזה הוא כל מה שמפריד בין מקטע חי לבין מקטע שאפשר לזרוק: כל עוד מישהו מחובר המקטע נשאר, וכשהמונה מתאפס הקרנל משחרר את הזיכרון.

עכשיו תחשבו מה קורה כשהמונה שלילי. הקרנל מחזיק רישום שאומר שאף אחד לא מחובר, בזמן שבפועל יש תהליכים שעוד מחזיקים את אותו זיכרון וכותבים אליו. כשהזיכרון משוחרר ומוקצה מחדש למשהו אחר, התהליכים הישנים ממשיכים לכתוב לכתובת שכבר לא שייכת להם, והקרנל מתייחס לתוכן שלה כאילו הוא המבנה החדש שיושב שם. לכשל הזה קוראים use-after-free, והוא אחד הבסיסים הנפוצים להרצת קוד בקרנל.

Griffiths היה צריך לנצח מרוץ כדי להגיע לשם. הוא קרא ל-IPC_RMID, הפקודה שמסמנת מקטע זיכרון משותף להסרה, בתהליך אחד, ובמקביל חיבר בתהליך עזר מקטע חדש בגודל אחר. כדי לדעת מתי בדיוק הקרנל מקצה דפים חדשים הוא עקב אחרי מדדי ההקצאה הפנימיים דרך sysctl, בשדות kb_total ו-kb_totalfree, וסידר את הערימה לפי מה שראה שם. זה לא קרה במכה אחת.

החוליה השנייה בשרשרת היא כתיבה שחורגת מגבולות המבנה שהיא אמורה לעדכן, כתיבה של מינוס 1 שמתרחשת בתנאים מסוימים. המבנה שנבחר לקבל אותה הוא struct ip_moptions, רשומה קטנה באורך 16 בייט שהקרנל מקצה ל-socket ששומר הגדרות של multicast (שליחת תעבורה לקבוצת מאזינים במקום ליעד בודד). כדי שהרשומה תוקצה ותישאר בזיכרון מספיק להגדיר ל-socket ערך שאינו ברירת המחדל ב-IP_MULTICAST_TTL, למשל 2. המילה האחרונה באותה רשומה דחוסה במיוחד: היא מחזיקה שני מונים של 16 ביט זה לצד זה, מספר הקבוצות שה-socket הצטרף אליהן ומספר הקבוצות המקסימלי. הכתיבה החורגת דורכת על שניהם בבת אחת.

איך סגירת socket הפכה מיפוי של משתמש למיפוי של הקרנל

במעבדים מודרניים יש שני מחסומים שאמורים למנוע מהקרנל לקרוא זיכרון של תהליך משתמש או להריץ קוד שיושב שם, גם אם תוקף הצליח לבלבל אותו. קוראים להם SMAP ו-SMEP. כל עוד הם פעילים, השתלטות על מצביע בקרנל היא חצי עבודה בלבד, כי הקוד שהתוקף רוצה להריץ יושב בזיכרון שהקרנל מסרב לגעת בו.

הדרך מסביב עוברת בטבלת הדפים, המבנה שמתרגם כתובות זיכרון ומסמן איזה דף שייך למשתמש ואיזה לקרנל. הקרנל ממפה את טבלת הדפים לתוך עצמה, מיפוי עצמי שמאפשר לו לערוך את הטבלה כמו כל זיכרון אחר. אחרי שהמונים ב-ip_moptions נדרסו, סגירה של אותו socket בפקודת close מריצה בקרנל מעבר על רשימת הקבוצות, ובדרך הוא מוריד מונים בפעולות אטומיות. ההורדות האלה נוחתות על המיפוי העצמי של טבלת הדפים, פעמיים, והופכות מיפוי שהיה נגיש למשתמש למיפוי שנגיש למצב הקרנל. מהרגע הזה הזיכרון של התוקף הוא זיכרון שהקרנל מוכן להריץ, ושני המחסומים נשארו מאחור.

Griffiths מתאר את מה שקיבל כפרימיטיב write what where, כלומר יכולת לכתוב ערך נבחר לכתובת נבחרת: הכתיבה עצמה מגיעה מהסרת איבר מרשימה מקושרת דו-כיוונית (TAILQ_REMOVE), ה"מה" הוא המיפוי שלו שהפך נגיש לקרנל, וה"איפה" הוא כתובת צפויה של מצביע בקרנל. עם זה בידיים הוא דרס את המצביע, קיבל הרצת קוד בקרנל, ואז החזיר את המצביע למקומו וסידר בחזרה את חשבון הזיכרון המשותף כדי שהמערכת תמשיך לעבוד. בסוף התהליך נשאר shell עם הרשאות root.

אז למה KARL לא עצר את זה?

ל-OpenBSD יש מנגנון שהוא סימן מזהה שלה: בכל אתחול היא מקשרת מחדש את קובץ הקרנל, כך שהפונקציות והנתונים שלו נוחתים בכתובות אחרות בכל הפעלה. המנגנון נקרא KARL, והרעיון שמאחוריו הוא בדיוק לשבור תקיפות מהסוג הזה. אם התוקף לא יודע לאיזו כתובת לכתוב, הוא צריך קודם חולשה נפרדת שתדליף לו כתובות.

בפועל זה לא הספיק, והסיבה לא נמצאת בקרנל עצמו אלא במי שמחלק לו זיכרון. "הקצאות הזיכרון של הקרנל נשארות דטרמיניסטיות", כתב Griffiths. גם המקצה שעובד בזמן האתחול וגם המקצה שמחפש את ההתאמה הטובה ביותר אינם מגרילים כתובת בסיס, ולכן הקצאות מוקדמות נוחתות שוב ושוב באותן כתובות על אותה חומרה. כשמבנה שמוקצה כך מכיל מצביעים לפונקציות, הוא הופך למטרה אידיאלית. ההשמשה של Griffiths מכוונת למטפלי ה-ioctl של שני ממשקי רשת פנימיים, lo0 ו-enc0: אחד מהם נדרס כדי לקבל שליטה על זרימת הקוד, והשני משמש אחר כך להחזרת המצביע שנדרס.

מה תוקן, ואיך OpenBSD סיווגה את זה

שתי החולשות מתוקנות כבר מספטמבר, ושתיהן הופצו דרך דף ה-errata הרשמי של OpenBSD 7.9 לכל הארכיטקטורות, בעדכונים שמותקנים עם syspatch. הראשון, errata 018, יצא ב-14 בספטמבר ומתואר שם כמניעת גלישת מספר שלם ב-shmat(4). השני, errata 026, יצא ב-30 בספטמבר ומתואר כבאג בספירת הפניות שעלול להוביל ל-use-after-free בקרנל. מי שמריץ את גרסה 7.8 קיבל את אותו צמד באותם שני תאריכים, שם במספרים 054 ו-062, ולרשומה של 062 נוספה הערה שהפקודה ipcs הפסיקה לעבוד בעקבות שינוי ב-ABI, כלומר הטיפוס של המונה באמת הוחלף.

שווה לעצור על פרט אחד בסיווג. errata 018, החוליה הראשונה בשרשרת שמסתיימת ב-root, מסומן בדף הרשמי כתיקון יציבות (RELIABILITY FIX) ולא כתיקון אבטחה, ורק התיקון השני, שבועיים אחריו, מסומן כ-SECURITY FIX. (וכאן, אגב, מנהל מערכת שמתקין רק תיקוני אבטחה מפספס את החצי הראשון של התקיפה.) לאף אחת משתי הרשומות לא הוצמד מזהה CVE. בנקודה אחת המקורות לא מסתדרים: הדף של Calif משייך את שתי החולשות ל-errata 026 ו-018 בסדר ההפוך מזה שהרשומות הרשמיות מתארות, ולכן התיאור כאן הולך אחרי הדף של OpenBSD. לפי The Hacker News אין עדות לכך שמישהו ניצל את החולשות בפועל, וגם Calif לא מדווחת על ניצול. קוד ההדגמה המלא פורסם רק אחרי שהתיקונים יצאו.

לא באג אחד, אלא מחלקה שלמה

החלק שמעניין מעבר ל-OpenBSD הוא מה ש-Calif מציעה לעשות הלאה, כי ההצעות שלה לא מתייחסות לשתי החולשות האלה אלא לסוגי הכשל שאפשרו אותן. הראשונה היא בדיקת תקינות של רשימות מקושרות לפני שמסירים מהן איבר, כך שאם המצביעים לא מסתדרים המערכת נעצרת במקום לכתוב לזיכרון הלא נכון. FreeBSD ו-NetBSD כבר מיישמות את זה בקוד הרשימות שלהן. השנייה היא להגריל בזמן אתחול את הכתובת של המיפוי העצמי של טבלת הדפים במקום להשאיר אותה קבועה, מה שהיה מוציא מהמשחק בדיוק את ההורדות האטומיות שהתוקף כיוון אליהן. השלישית היא להגריל את כתובת הבסיס של מקצה הזיכרון של הקרנל, צעד שלדברי Calif כמעט לא עולה כלום, ואפשר להוסיף לו גם הגרלה של סדר ההקצאות בזמן ריצה במחיר תקורה מסוימת.

ההצעה הרביעית באה מכיוון אחר, מהקומפיילר: הפעלת בדיקת גבולות למערכים בגודל קבוע גם בגרסאות ההפצה ולא רק בבנייה לפיתוח. Calif הציעה את זה ל-FreeBSD, ולדבריה דיווחה לפרויקט באופן פרטי על 21 כשלים שהבדיקה הזאת הייתה תופסת, חלקם עם השפעה של הסלמת הרשאות מקומית. במאמר הם קושרים את זה למשהו רחב יותר: בעידן שבו חולשות נמצאות בעזרת AI, חסימה של מחלקות שלמות של כשלים שווה יותר מתיקון של באג אחרי באג.

מה זה אומר בפועל

מי שמריץ OpenBSD 7.9 או 7.8 צריך להריץ syspatch ולאמת ששני התיקונים מותקנים, 018 ו-026 בגרסה 7.9 או 054 ו-062 בגרסה 7.8, ולא להסתמך על סינון שמוריד רק תיקוני אבטחה. ההרשאות שהשרשרת הזאת מעניקה הן מקומיות, כך שהמשמעות המעשית הגדולה היא בשרתים שיש בהם משתמשים עם shell שאינם מנהלי המערכת: סטודנטים, לקוחות hosting, או תהליכי שירות שרצים תחת משתמש מוגבל בהנחה שהגבול הזה מחזיק. ואם אתם מתחזקים קוד קרנל בעצמכם, שווה לקרוא את פרק ההמלצות של Calif בעיניים של מי שמחפש בקוד שלו מונים צרים מדי ורשימות מקושרות שמסירים מהן איברים בלי בדיקה.