מישהו הריץ pip כדי להתקין ספרייה שאין לה קשר ל-LiteLLM, וזה היה מספיק. לפי הניתוח של מיקרוסופט, ההפעלה השגרתית הזאת של Python על מכונת Windows אחת הפעילה קוד שהתחיל לאסוף מפתחות SSH, פרטי אימות של ספקי ענן, היסטוריית פקודות ו-token-ים של מנהלי חבילות. הקוד הגיע לשם בגרסה 1.107.0 של LiteLLM, שפורסמה ל-PyPI ב-21 באוקטובר ב-11:39 UTC והוסרה פחות משעתיים אחר כך.

ב-23 באוקטובר פרסמה LiteLLM ניתוח שורש הבעיה, שהיא מגדירה בעצמה כ"מקור האמת" לאירוע ושמתקן חלק ממה שאמרה יומיים קודם. במקביל יצאו ניתוחים עצמאיים של Socket, של מיקרוסופט ושל JFrog. התמונה שמצטרפת מהם לא מתחילה באוקטובר אלא בקובץ הגדרות אחד שהועלה לריפוזיטורי ציבורי ב-27 במאי. בהמשך נראה איך מפתח שסומן לביטול ולא בוטל הפך ל-token בהרשאות owner, איך token אחד הפך לשלושה, ולמה דווקא מנגנון האישור הכפול לא עצר את השחרור.

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

המפתח שסומן לביטול, ולא בוטל

ב-27 במאי ב-18:53:14 UTC נכנס קובץ opencode.json לריפוזיטורי הציבורי LiteLLM-Labs/lite-harness. בתוכו שכב ה-master key של התקנה של LiteLLM Agent Platform, כלי ניסיוני של הפרויקט לתזמור סוכנים. מישהו שם לב, הקומיט נדחף מחדש כדי לכסות עליו, והמפתחות שנחשפו סומנו לביטול. ואז, בניסוח של הפרויקט עצמו, "ביטול המפתח לא התבצע עבור ה-master key". הפוסט לא מסביר למה.

ארבעה חודשים וחצי אחר כך, ב-10 באוקטובר בין 21:13 ל-21:28 UTC, הגיעו מכתובת 69.48.229.143 קריאות מאומתות ל-API של אותה פלטפורמת סוכנים. אחת מהן ביקשה את רשימת הסוכנים המנוהלים וקיבלה את ההגדרות המלאות של כולם, ובתוכן token של GitHub CLI שהשתייך לאחד משני ה-owner-ים של הארגון ב-GitHub. הוא לא היה של service account ואיש לא הגביל את הרשאותיו, כך שמי שהחזיק בו קיבל הרשאות owner על כל הריפוזיטוריז של הפרויקט. LiteLLM מוסיפים נקודה שכדאי להפנים: token-ים של GitHub CLI מונפקים אחרי אימות דו-שלבי ולא נכנסים לאימות המחודש שגיטהאב דורשת לפעולות רגישות, כך שהחזקה בו מספיקה.

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

איך token אחד הפך לשלושה

עד כאן ההשמשה של מפתח שנשכח. החלק הבא, איך התוקף הרחיב את האחיזה, נעשה כולו בכלים של גיטהאב עצמה. ב-10 באוקטובר ב-22:11:45 UTC נדחף לענף זמני בריפוזיטורי BerriAI/litellm קומיט עם הכותרת התמימה "ci: bump cache config", שהוסיף קובץ workflow בשם .github/workflows/cache.yml. ה-trigger שלו היה push על כל ענף שהוא, ולכן לא היה צריך לגעת ב-main.

Socket פירקו את הקובץ: הוא ממפה כל secret של הארגון למשתנה, כותב את כולם כשורות KEY=value לקובץ ב-tmp, מקודד ב-base64 ושולח ב-curl ל-webhook.site, עם סיומת שמחזירה קוד הצלחה בכל מקרה כדי שהצעד לא יצבע באדום את ההרצה. שש secrets יצאו בסבב הראשון, וביניהן GH_TOKEN, ה-token של מתחזק שני. שתי דקות וחצי אחר כך נמחקה רשומת ההרצה. בערך באותו רגע הוציא ענף מקביל בריפוזיטורי אחר של הפרויקט את RELEASE_PLEASE_TOKEN, ה-token של מתחזק שלישי, לאותו endpoint בדיוק.

ב-12 באוקטובר חזר אותו תרגיל בגדול: workflow של 97 שורות עם שש משימות, שחמש מהן נקשרות ל-GitHub Environments כדי לפתוח secrets שמוגדרים ברמת הסביבה ולא ברמת הארגון. ארבעה-עשר secrets נוספים יצאו, בלי חפיפה לסבב הראשון: מפתחות API של Anthropic, של OpenAI, של AWS Bedrock ושל GitGuardian, נתוני התחברות ל-PostgreSQL ול-Redis, וקובץ session של Linear. על אלה אומרים בפרויקט שהם לא היו על הנתיב שהוביל לחבילה הנגועה.

שתי הסתייגויות מ-Socket שכדאי לקרוא: אי אפשר לדעת אם 10 באוקטובר היה הסבב הראשון, ואי אפשר לדעת אם הצד השני אכן קיבל את המידע, כי ה-workflow-ים מחניקים כל שגיאה.

אז למה אישור כפול לא עצר את השחרור?

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

ההמשך קרא את הנוהל החדש היטב. ב-21 באוקטובר ב-10:08:29 נדחה שינוי ל-main בריפוזיטורי השחרור, שנדחף עם ה-token של המתחזק השני, על ידי הגנת הענף; חמש שניות אחר כך ה-token של ה-owner הסיר את ההגנות והדחיפה עברה. ב-10:46:39 הופעל שלב ה-staging בזהות ה-owner, כשמקור השחרור מופנה לריפוזיטורי דמיון בשם Berri-AI/litellm, שנבדל בתו אחד מהשם האמיתי. ב-11:11:18 אושר שער סביבת הייצור עם ה-token של המתחזק השני, מכתובת IP אחרת, ובין 11:39:51 ל-11:40:13 פורסמו שבעה קבצי wheel של litellm==1.107.0 ל-PyPI. דקות אחר כך הגיעו גם image-ים ל-Docker Hub ול-GHCR, וב-11:53 נמחקו רשומות ההרצה עם ה-token של המתחזק השלישי.

הקריאה של הפרויקט את ההתנהלות מעניינת: מי שהחזיק token של owner היה יכול לבטל את דרישת האישור כולה, ולא עשה את זה. ההסבר שהם מציעים הוא ששינוי הגדרות משאיר עקבות רועשים יותר מהרצת הנוהל הקיים בכלים גנובים. הטיפול באירוע התחיל ב-13:02, PyPI העבירה את 1.107.0 להסגר עד 13:23, ועד 14:20 ירדו תגיות GHCR וטופלו תגיות Docker Hub.

הקובץ שרץ בכל פעם שמפעילים Python

כש-Python עולה, הוא עובר על ספריית הספריות של הסביבה וקורא קבצים עם הסיומת pth. שורה שמתחילה שם ב-import לא נשארת טקסט, אלא מורצת באותו רגע. אחרי זה הוא מנסה לייבא מודול בשם sitecustomize, אם קיים. שני המנגנונים לגיטימיים וקיימים כדי לאפשר התאמות התקנה, ובדיוק בהם נעשה כאן שימוש.

ה-wheel של 1.107.0 הכיל שלושה קבצים שמותקנים לא לתיקיית האפליקציה אלא לספריית הספריות של הסביבה: cook.pth, שמשקלו 12 בייט ובתוכו שורת import אחת, הקובץ cook.py שהיא מייבאת, ו-sitecustomize.py. התוצאה: הקוד רץ בכל הפעלה של Python באותה סביבה, גם אם שום דבר לא ייבא את LiteLLM מעולם. JFrog מוסיפים ששלושת הקבצים מופיעים במניפסט ה-RECORD של ה-wheel עם hash תקין, כלומר נארזו בבנייה ולא הודבקו אחרי ההתקנה.

cook.py מפרסר מטען שמקודד ב-base64 ודחוס ב-zlib, ומריץ אוסף שמזדהה בשם privcollect. לפי הניתוח של OpenSourceMalware מדובר בכ-1,237 שורות עם 16 מודולים, והרשימה גורפת: מסדי נתוני ההתחברות, העוגיות וההיסטוריה של דפדפני Chromium ו-Firefox, מפתחות SSH, GPG ו-TLS, היסטוריית פקודות של shell ושל לקוחות מסדי נתונים, הגדרות של AWS, Azure, GCP, Kubernetes ו-Docker כולל בקשות ל-metadata של הענן, פרטי אימות של git ו-token-ים של מנהלי חבילות, משתני הסביבה של התהליך ובלינוקס גם של תהליכים אחרים דרך procfs, וגם מפתחות ספקים מתוך שתי טבלאות של LiteLLM עצמו. הכל נארז מוצפן ב-AES-256-CBC במפתח חדש לכל הרצה, שעטוף במפתח RSA ציבורי מוטמע, ונשלח ב-HTTP POST לא מוצפן אל www.litellm.dev, דומיין דמיון של litellm.ai הרשמי, לנתיב שנראה כמו בדיקת תקינות.

הנתיב השני, זה שב-sitecustomize.py, מעניין יותר ומסוכן פחות ברגע הזה. הוא מתנתק לרקע, ואחת לשעה קורא קובץ domain.txt מריפוזיטורי בגיטהאב, לוקח ממנו דומיין ומבקש משם קוד Python שהוא מהדר ומריץ בלי בדיקת חתימה. חוקרי Upwind מדווחים שהקובץ ריק מ-20 באוקטובר, כך שהשלב הזה לא נמשך בפועל. מה שכן, אפשר למלא אותו מחדש בלי לשחרר חבילה חדשה, והאוסף המקומי עבד ממילא בלי תלות בו. מיקרוסופט מסמנים את ההשלכה המעשית: שלושת הקבצים יושבים מחוץ לתיקיית האפליקציה, ולכן "הסרת החבילה או תג השחרור אינה התאוששות מספקת".

מה האירוע מלמד על שכבת ה-AI בארגון

הערך של gateway כזה הוא בדיוק הריכוז: מפתח אחד לכל הספקים, מדידה אחת, מגבלה אחת. הצד השני של אותו מטבע הוא שגרסה אחת שלו שווה לתוקף את כל מה שעובר בו. ובתוך שנה נפל אותו פרויקט פעמיים, דרך שני פתחים שונים. במארס, לפי JFrog, הריצה שרשרת ה-CI שלו גרסה זדונית ולא מוצמדת של Trivy, מה שחשף את פרטי האימות של הפרסום והוביל לגרסאות הנגועות 1.82.7 ו-1.82.8; באוקטובר זה היה מפתח שלא הוחלף. JFrog זהירים לגבי הקשר בין השניים ולדעתם מדובר בקמפיינים נפרדים, אבל אף אחד מהם לא היה חולשה שמישהו מצא בקוד. הפתח החדש יחסית הוא שכבת הסוכנים: פלטפורמה ניסיונית החזיקה בהגדרות הסוכנים שלה token חי בהרשאות owner, וקריאת API אחת הספיקה כדי להוציא אותו. ככל שכלים כאלה נכנסים לצוותי פיתוח, שווה לשאול מה שמור בהגדרות שלהם ומי יכול לקרוא אותן.

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

מי שהתקין litellm 1.107.0 בחלון שבין 11:39 ל-13:23 UTC ב-21 באוקטובר, או משך את ה-image-ים המקבילים, צריך להתייחס למכונה כאל מכונה שנפרצה ולא כאל אחת שצריך לעדכן: החלפת מפתחות ממכונה נקייה, ביטול sessions פעילים שהחלפה לבדה לא מפילה, וכל מה שהיה קריא מאותה סביבה, ממפתחות ספקי המודלים ועד החיבור למסד הנתונים של ה-proxy. בדקו גם איפה הגרסה יכולה לשרוד את הניקוי, ובהם מחסני cache של pip ושל uv, קובצי lock ו-image-ים שנבנו באותו יום, ואחרי הבנייה מחדש ודאו ששלושת קובצי ההפעלה אינם שם. בפרויקט אומרים שהלקוחות הארגוניים, שמקבלים שחרור פרטי, לא נפגעו.