חמישה ימים מהמיזוג ועד ה-Jira הפנימי: כותרת issue אחת פתחה את הסודות של Snowflake
ב-23 ביוני נפתח issue חדש במאגר הקוד הפתוח snowflakedb/snowflake-connector-net בגיטהאב. הכותרת שלו לא נראתה כמו תלונה על באג, אלא כמו ערבוביה של תווים ומרכאות. תוך שניות ספורות יצאה משרת ההרצה של GitHub Actions פנייה החוצה אל כתובת חיצונית, ובתוכה, מקודדים ב-base64, ה-token של Jira של Snowflake, כתובת המייל שאליה הוא משויך והכתובת של מערכת ה-Jira הפנימית. מי שפתח את ה-issue לא היה חוקר אנושי.
זה היה Red Agent, כלי מחקר אוטונומי מבוסס AI של Wiz, שסרק את ארגון הגיטהאב של Snowflake ואיתר בו קובץ אוטומציה שאפשר להזריק אליו פקודות. הדיווח יצא ל-Snowflake באותו יום דרך HackerOne, והחברה תיקנה את הקוד באותו יום עצמו. Wiz פרסמה את הממצא ב-17 באוגוסט, בכתיבתו של Gal Nagli. בהמשך נראה מה בדיוק השתנה בקובץ אחד, למה תנאי שנראה כמו שער אבטחה לא סגר כלום, ולמה דווקא החלק על הכלים האוטומטיים שלא תפסו את זה הוא המעניין.
מה רץ בכלל כשפותחים issue
GitHub Actions הוא מנגנון האוטומציה המובנה של גיטהאב. מגדירים קובץ workflow בתיקייה .github/workflows, קובעים לו טריגר (דחיפת קוד, פתיחת PR, פתיחת issue), וכשהטריגר קורה גיטהאב מקצה מכונה זמנית, runner, שמריצה את הפקודות שבקובץ. ל-runner הזה יש גישה ל-secrets של המאגר: מפתחות API, פרטי אימות לשירותים חיצוניים, כל מה שהאוטומציה צריכה כדי לעשות את העבודה שלה.
הקובץ הרלוונטי כאן, .github/workflows/jira_issue.yml, עשה משהו שגרתי לגמרי. כשנפתח issue חדש במאגר הוא פתח כרטיס מקביל ב-Jira הפנימי של Snowflake, עם אותה כותרת, כדי שצוות הפיתוח יראה את הפנייה במקום שבו הוא עובד ממילא. הטריגר שהפעיל אותו היה issues: opened. כלומר כל בעל חשבון גיטהאב בעולם יכול היה להריץ אותו, בלי הרשאות במאגר ובלי שום קשר לחברה. זה לא באג. ככה workflow-ים כאלה אמורים לעבוד, וזה בדיוק מה שהופך את מה שרץ בתוכם לרגיש.
שורה אחת שהחליפה דפוס בטוח בדפוס שביר
עד יוני 2026 הקובץ העביר את כותרת ה-issue בדרך הזהירה: הכותרת נכנסה למשתנה סביבה, ומשם הועברה ל-jq כארגומנט מפורש. ההבדל נשמע טכני וזניח, והוא כל הסיפור. כשערך עובר דרך משתנה סביבה, bash מקבל אותו כמחרוזת נתונים בלבד. הוא אף פעם לא נקרא כחלק מהפקודה עצמה, גם אם יש בו מרכאות, נקודה-פסיק או כל תו אחר שבדרך כלל מפריד בין פקודות.
ב-PR מספר 1218, שנכנס לענף הראשי ב-18 ביוני, הדפוס הזה הוסר. במקומו נכנסה שורה שמשבצת את כותרת ה-issue ישירות לתוך פקודת shell, בתוך echo, ומריצה על התוצאה שתי פקודות sed שאמורות לברוח ממרכאות. הכוונה הייתה להגן. הסדר הפוך ממה שנראה.
גיטהאב מרחיב את התבנית ${{ github.event.issue.title }} לפני ש-bash רואה את הסקריפט. הטקסט של הכותרת נכתב פיזית לתוך גוף הפקודה, ורק אחר כך ה-shell מפרסר את מה שיצא. ה-sed שאמור לנקות רץ מאוחר מדי, כי הוא עצמו חלק מאותו סקריפט שכבר מכיל את הכותרת הגולמית בתוכו. מרכאה בודדת בכותרת סוגרת את המחרוזת של echo, וכל מה שבא אחריה מפסיק להיות טקסט ומתחיל להיות פקודה שהמכונה מריצה. לטכניקה הזאת קוראים script injection, ובהקשר הספציפי של קובצי אוטומציה בגיטהאב מדברים על workflow injection.
אז למה השער שהיה שם לא עצר את זה?
כי הוא בדק שדה שלא קיים. ב-workflow היה תנאי if שנראה בדיוק כמו בקרת גישה: הוא התנה את ההרצה בכך שסוג האירוע הוא issues, ושהמשתמש שיזם אותו אינו הבוט whitesource-for-github-com. הכוונה ברורה לגמרי. הביצוע פחות.
השדה שהתנאי קרא ממנו היה github.event.pull_request.user.login. אלא שבאירוע מסוג issues אין בכלל אובייקט pull_request. לפי תיעוד ה-contexts של גיטהאב, התוכן של github.event משתנה לפי סוג האירוע שהפעיל את ה-workflow, וכל שדה ששייך לסוג אחר פשוט אינו שם. אז הביטוי מתאייד ל-null, וההשוואה מצטמצמת לשאלה אם null שונה משמו של הבוט. הוא כמובן שונה, תמיד. השער היה פתוח לרווחה בפני כל אדם באינטרנט, ובקריאה מהירה של הקובץ הוא נראה סגור.
סוכן שנתקל בשגיאה ותיקן את עצמו
החלק שמעניין כאן הוא לא עצם איתור החולשה, אלא מה שקרה כשהניסיון הראשון נכשל. לפי Wiz, יכולת ה-CI/CD של Red Agent סרקה את ארגון הגיטהאב של Snowflake, סימנה את jira_issue.yml כמועמד להזרקה דרך קלט לא מהימן בתוך בלוק run, ובנתה כותרת issue שנועדה לחלץ את פרטי האימות ולשלוח אותם החוצה בערוץ צדדי. בניסיון הראשון הסוכן השתמש בתו הערה כדי לנטרל את שארית השורה, ו-bash החזיר שגיאת תחביר: ההערה בלעה גם את הסוגר שסוגר את הפקודה, והסקריפט נשבר באמצע.
הסוכן לא עצר שם. הוא קרא את שגיאת ההרצה מהלוג, זיהה מה בדיוק נשבר בתחביר, ניסח מחדש את הכותרת כך שתסגור את בלוק ה-shell כמו שצריך, והפעם הפנייה החוצה הגיעה. הכול, לדברי Wiz, בלי מגע יד אדם. הקריאה הגיעה מכתובת Azure של runner של גיטהאב, 20.106.182.197.
המספר שראוי לעצור עליו הוא לא הציון ולא היקף הנזק, כי גם CVE לא הוקצה כאן. הוא לוח הזמנים: חמישה ימים בין הרגע שהקוד הבעייתי נכנס לענף הראשי לרגע שסוכן אוטומטי מצא אותו, השתמש בו ואימת מה נחשף.
מה בדיוק דלף
ה-token שיצא החוצה היה של חשבון Jira בשם qa@snowflake.net, מול המופע snowflakecomputing.atlassian.net. לפי Wiz הוא נתן הרשאות קריאה על פני שלושה תחומים במערכת הפנימית: פרויקטי ההנדסה, פרויקט ה-compliance של האבטחה, ומערכת המעקב של תוכנית ה-bug bounty. השלישי הוא זה שראוי לעצור עליו רגע, כי מערכת המעקב של תוכנית באג באונטי מכילה בדיוק את מה שתוקף היה רוצה לקרוא: דיווחי חולשות שהוגשו ועדיין לא תוקנו.
מה שקרה אחר כך היה מהיר. הדיווח הגיע דרך HackerOne ב-23 ביוני, ובאותו יום Snowflake החזירה את הקובץ לדפוס המקורי, משתנה סביבה ו-jq עם ארגומנט מפורש, ב-PR מספר 1402. ה-token של Jira בוטל והוחלף למחרת, ב-24 ביוני. Snowflake מסרה בתגובה שהדיווח נחקר ותוקן מיד ושהחקירה שלה לא מצאה ראיה לגישה בלתי מורשית, ו-Wiz מדווחת שניתוח לוגים הראה שכל הפניות החריגות בחלון החשיפה בן חמשת הימים הגיעו מכתובות ה-IP של הבדיקות שלה עצמה. שתי החברות מסכימות על כך, ושתיהן צד מעוניין בסיפור. גורם שלישי בלתי תלוי לא אימת את זה.
ומה עם הכלים שהיו אמורים לתפוס את זה?
כאן החלק שקל לפספס. הסריקה של GitHub Advanced Security רצה על גרסת ה-PR הסופית, כולל קובץ ה-workflow שהשתנה, ולא סימנה את ההזרקה. הכלי עבד. הוא פשוט לא ראה את זה.
לגבי החלק של ה-AI כדאי לדייק, כי Wiz עצמה דייקה אותו אחרי הפרסום. הממצא סוקר בהרחבה בעיתונות המקצועית, למשל ב-The Hacker News, אבל הניסוח המחייב נמצא בעדכון ש-Wiz הוסיפה לפוסט שלה ב-17 באוגוסט בשעה 19:57 UTC. שם היא הבהירה ש-Copilot היה co-author שבדק את ה-PR ואת שינוי הקוד וסימן אותם כתקינים בלי לשים לב לחולשה, ושלא ברור אם שינוי הקוד עצמו נכתב בכלל בסיוע AI. התרומה המתועדת של Copilot Autofix באותו PR הייתה תיקון נפרד לקובץ אחר לגמרי, jira_close.yml.
הפער הזה חשוב, כי הוא משנה את מה שהמקרה מלמד. אין כאן ראיה ש-AI כתב את החולשה. יש כאן שכבת סקירה, אנושית ואלגוריתמית כאחת, שהעבירה הלאה שינוי שהחליף דפוס מוקשח בדפוס שביר, ומנגד סוכן אוטומטי אחר שסרק, ניצל ואימת תוך חמישה ימים. הצד ההתקפי של המשוואה מתקצר מהר יותר מהצד המגן.
מה זה אומר בפועל
אם אתם מתחזקים מאגר ציבורי עם GitHub Actions, הדפוס שכדאי לחפש ספציפי מאוד: כל מקום שבו ערך שמגיע ממשתמש חיצוני (כותרת issue, גוף issue, כותרת PR, שם ענף) מוזרק ישירות לתוך בלוק run בתוך ${{ }}. הפתרון אינו לסנן את הערך בעזרת sed או להסתמך על בריחה מתווים, אלא להעביר אותו דרך env ולהתייחס אליו כמשתנה, בדיוק כמו שהקובץ הזה עשה לפני השינוי וכמו שהוא עושה שוב היום. גיטהאב עצמה מתארת את הדפוס הזה כטעות הנפוצה בקטגוריה.
שווה גם לעבור שוב על כל תנאי if שמתפקד כבקרת גישה ולוודא שהשדה שהוא בודק בכלל קיים באירוע שמפעיל את ה-workflow, כי תנאי שבודק שדה ריק הוא תנאי שתמיד מתקיים. ולבסוף, ה-secrets שיושבים ב-runner: ככל שהם קצרי-מועד ומצומצמי-הרשאות יותר, כך חלון של חמישה ימים עולה פחות.
תגובות