מטלאי קוד ל-shell על השרת: החולשה ב-Gitea שסורק אוטומטי ניצל תוך 11 שניות
ההודעה מחברת האחסון הייתה טכנית ומשעממת: העומס על המעבד ב-VPS מחזיק מעל 70 אחוז לאורך זמן, ולכן משאבי המעבד הוגבלו זמנית. מי שקיבל אותה הוא CTO בסטודיו אינטרנט קטן שמנהל את התשתית לבד, בלי SRE ייעודי, והוא ניגש לבדוק מה בדיוק מחשב שם בהתלהבות כזאת. מה שהוא מצא, ותיעד בפוסט מפורט בהאבר ב-19 באוגוסט, היה חשבון משתמש שהוא לא פתח, ריפוזיטורי פרטי שהוא לא יצר, וקובץ בינארי אלמוני שנמחק מיד אחרי שהורץ.
הדלת שדרכה נכנסו הייתה שרת ה-Git הפרטי של הסטודיו. Gitea, המימוש הקל והפופולרי להרצה עצמית של פלטפורמת Git, קיבלה ב-28 ביולי אדוויזורי אבטחה על CVE-2026-60004, חולשה שמאפשרת למשתמש רגיל עם הרשאת כתיבה לריפוזיטורי אחד להריץ פקודות shell על השרת עצמו. ב-25 באוגוסט הכניסה אותה CISA לרשימת ה-KEV, רשימת החולשות שיש ראיות לניצולן בשטח, עם דדליין תיקון לגופים הפדרליים ב-28 באוגוסט. נעבור על מה שבור שם, למה טלאי קוד שגרתי הופך לסקריפט שרץ, ומה קרה ב-11 השניות שבין פתיחת טופס ההרשמה לבין ההוכחה שהקוד עבד.
מה Git מריץ לכם מאחורי הגב
Git, בכל התקנה, מריץ סקריפטים משלכם ברגעים מוגדרים מראש: לפני commit, אחרי קבלת push, ברגע שהאינדקס נכתב לדיסק. הסקריפטים יושבים בתיקייה בשם hooks בתוך ספריית ה-Git הפנימית של הריפוזיטורי, וכל קובץ בר-הרצה ששמו תואם לאירוע מסוים מורץ אוטומטית כשהאירוע קורה. לרכיבים האלה קוראים git hooks, והם חלק לגיטימי לגמרי מהכלי: ככה מריצים בדיקות תקינות לפני קומיט, וככה שרתי Git מפעילים אוטומציה על push. הבעיה מתחילה במקום אחד בלבד, כשמישהו מבחוץ יכול להחליט מה כתוב בקובץ כזה.
ב-Gitea יש endpoint שנקרא diffpatch, בכתובת POST /api/v1/repos/{owner}/{repo}/diffpatch. הוא עושה מה שהשם מרמז: מקבל טלאי בפורמט diff ומחיל אותו על תוכן הריפוזיטורי דרך הרשת, בלי ששכפלתם אותו למחשב שלכם. פעולה שגרתית לגמרי, שכל משתמש עם הרשאת כתיבה בריפוזיטורי של עצמו רשאי לבצע.
איך טלאי רגיל הופך לסקריפט שרץ
לפי האדוויזורי, הקוד ב-services/repository/files/patch.go החיל את הטלאי בתוך שכפול זמני מסוג bare. שכפול bare הוא עותק בלי תיקיית עבודה, כזה שמכיל רק את המנגנון הפנימי של Git ולא את הקבצים עצמם, וזה בדיוק העניין: בשכפול כזה שורש הריפוזיטורי הוא ספריית ה-Git עצמה. קובץ שנוחת שם בנתיב hooks/post-index-change אינו קובץ תמים בתוך פרויקט. הוא hook פעיל.
נשאר רק להביא אותו לשם, וזה החלק שדורש טריק אחד. שליחה של אותו טלאי פעמיים, מסביר האדוויזורי, יוצרת התנגשות מסוג add/add, שני צדדים שמנסים להוסיף בדיוק את אותו נתיב. Git נופל אז למנגנון המיזוג המשולש שלו, ובנפילה הזאת הוא כותב את הנתיב לדיסק למרות הדגל --cached, שאמור היה להשאיר את השינוי באינדקס בלבד. מכאן זה מתגלגל לבד: Git מפעיל את ה-hook תוך כדי כתיבת האינדקס, כלומר באותה פעולה עצמה שיצרה אותו. "Git invokes the hook while writing the index, allowing repository-controlled content to execute arbitrary commands as the Gitea service account", כתוב באדוויזורי.
התיקון פשוט באותה מידה. בגרסה 1.27.1, שיצאה ב-27 ביולי, שינו בין השאר את השכפול הזמני מ-bare לרגיל. ברגע שיש תיקיית עבודה, שורש הריפוזיטורי כבר אינו ספריית ה-Git, וקובץ בשם hooks/post-index-change חוזר להיות מה שהוא נראה מבחוץ: קובץ בתוך פרויקט, שאיש לא מריץ.
מי בכלל יכול לשלוח את הטלאי
האדוויזורי נותן לחולשה ציון CVSS 9.8, וההיגיון מאחורי הציון הזה שווה רגע. התנאי הפורמלי הוא הרשאת כתיבה לריפוזיטורי, וזה נשמע כמו מחסום אמיתי. בפועל האדוויזורי מנסח את זה מפורשות: כשההרשמה העצמית פתוחה, מבקר אנונימי משיג את ההרשאה הזאת בכוחות עצמו, בהרשמה ויצירת ריפוזיטורי פרטי משלו. גישה לפרויקטים הקיימים על השרת לא נדרשת לו בכלל.
וההרשמה הפתוחה איננה תצורה אקזוטית. במסמך התצורה של Gitea הפרמטר DISABLE_REGISTRATION מגיע עם ערך ברירת מחדל false, כלומר הרשמה פתוחה, ולצידו REGISTER_EMAIL_CONFIRM שגם הוא false, כלומר בלי אימות כתובת מייל. מי שהרים Gitea לשימוש פנימי ולא נגע בשתי השורות האלה חשוף לשרשרת המלאה מהאינטרנט הפתוח. שווה גם לדעת מה עדיין חסר בתמונה: נכון להיום, רשומת CVE-2026-60004 טרם פורסמה במאגר NVD ובמאגר ה-CVE של MITRE, שאליהם CISA עצמה מפנה בהערות. הציון והפירוט הטכני מגיעים כרגע מהאדוויזורי של Gitea בלבד, והחולשה דווחה על ידי חוקר שהאדוויזורי מזכה בשם NightRang3r.
11 שניות
בשרת שתועד בהאבר רצה Gitea בגרסה 1.24.7, וההרשמה, אימות המייל וה-CAPTCHA היו כולם פתוחים. הלוגים של הקונטיינר שמרו את הרצף כמעט לפי שנייה: ב-14:25:16 נפתח טופס ההרשמה ונשלח, ב-14:25:17 נוצר ריפוזיטורי פרטי, ב-14:25:19 וב-14:25:20 נשלחו שתי בקשות diffpatch, ב-14:25:23 נוצר ענף בשם rce-proof, וב-14:25:27 מישהו משך ממנו קובץ בשם proof. מפתיחת הטופס ועד ההוכחה, בערך 11 שניות. המשתמש שנרשם לא התחבר לחשבון שוב אף פעם.
בתוך הקובץ proof הייתה שורה אחת: uid=1000(git) gid=1000(git). הפקודה id רצה באמת, בתוך הקונטיינר של Gitea, תחת המשתמש git.
הדרך שבה ההוכחה חזרה לתוקף מעניינת בפני עצמה. במקום קריאה חוזרת ל-DNS או ל-HTTP, מה שנהוג בסריקות אוטומטיות, ה-hook לקח את הפלט של id, הפך אותו ל-Git object, בנה ממנו commit והזיז ref. אחר כך הסורק פשוט ביקש את הקובץ מהענף החדש דרך ה-endpoint הרגיל של Gitea. ההוכחה נסעה חזרה דרך Git עצמו, וזה נוח מאוד לאוטומציה בקנה מידה גדול: אם הענף הופיע, ההשמשה עבדה, אפשר לעבור לשרת הבא.
המשימה השנייה של ה-hook רצה ברקע, ופענחה מטען base64 שהוריד את השלב הבא. הטוען שהתקבל שם נכתב פרגמטית להפליא, ומנסה חמש דרכים להוריד קובץ בזו אחר זו: curl, ואם אין אז wget, ואם אין אז python3 עם urllib, ואם אין אז perl עם HTTP::Tiny, ובסוף perl עם IO::Socket::INET. הכותב אינו יודע מראש לאן הקוד ינחת, אם ל-VPS מלא, ל-image מינימלי או ל-CI runner, אז הוא מנסה הכול. השלב האחרון התנהג כמו dropper של כורה מטבעות: ניקה את משתני הסביבה LD_PRELOAD ו-LD_LIBRARY_PATH, חיפש תהליכים עם עומס מעבד גבוה וניסה לחסל אותם, בחר בינארי לפי ארכיטקטורה, הוריד אותו לשם אקראי בן שמונה תווים, הריץ ומחק. בקונטיינר נשאר סימן אחד: /tmp/XXNpKFfD (deleted), שנוצר באותה שנייה שבה רץ ה-hook.
כאן שווה לשים לב לזהירות של מי שתיעד. הוא לא הריץ את הבינארים של השלב האחרון ולא ניתח אותם, ולכן, כפי שהוא כותב במפורש, אין לו pool כרייה מאומת, ארנק או שם משפחת נוזקה. הקשר בין מכתב ה-70 אחוז לבין הכורה נשאר אצלו מסקנה סבירה ולא עובדה מוכחת, ורק השרשרת שמתחילה בחשבון הזר ונגמרת ב-dropper מגובה בלוגים ובקבצים.
מה הבידוד הציל, ומה לא
הקוד רץ בתוך הקונטיינר של Gitea, שלא הופעל במצב privileged, והתהליך לא שרד הפעלה מחדש. לא נמצאו סימני שימור אחיזה דרך cron, systemd או מפתחות SSH חדשים, ולא נמצאה כניסת SSH מוצלחת מהכתובת שהופיעה בשרשרת. הווקטור היה HTTPS בלבד, ולפי הממצאים התוקף לא הגיע להרשאות root על ה-VPS. הבידוד עשה כאן את שלו וצמצם את רדיוס הפגיעה.
מה שהוא לא צמצם הוא הכיוון ההפוך. לקונטיינר הייתה גישה חופשית החוצה לאינטרנט, ובלעדיה השרשרת הייתה נעצרת אחרי ההוכחה, כי אי אפשר היה למשוך את השלב הראשון והשני. זו הנקודה שהמתעד מציין כשינוי החשוב אצלו כמעט כמו העדכון עצמו: השאלה הרגילה על שירות ציבורי היא מי יכול להתחבר אליו, והשאלה השנייה, שנשאלת הרבה פחות, היא לאן הוא עצמו יוכל להתחבר אם כבר רץ בתוכו קוד זר.
מה זה אומר בפועל
אם אתם מריצים Gitea בגרסה שבין 1.17 ל-1.27.0, עדכנו ל-1.27.1 ומעלה. הדדליין של CISA ל-28 באוגוסט מחייב רק גופים פדרליים בארה"ב, אבל הוא מסמן באיזו דחיפות מעריכים שם את הניצול בשטח. אחרי העדכון בדקו את שתי השורות שקובעות מי בכלל מגיע ל-endpoint מלכתחילה: אם השרת נועד לצוות פנימי ואין סיבה שאדם מהאינטרנט יירשם אליו לבד, סגרו את ההרשמה. ואם הקונטיינר של השירות פותח חיבורים לכל כתובת בעולם בלי צורך אמיתי, שווה להגביל גם את זה. שרת שנפרץ ולא הצליח להוריד את השלב הבא הוא סיפור קצר בהרבה.
תגובות