קישור שיתוף של ChatGPT נראה כמו הדבר הכי משעמם באינטרנט. מישהו שאל שאלה, המודל ענה, ומי שמקבל את הקישור רואה עמוד קריאה. בשיחה אחת שחוקרי Parameter בנו באוקטובר היו עוד שני דברים: גרף אינטראקטיבי רגיל, ובלוק HTML שה-ChatGPT מציג דרך תכונת Preview. מי שפתח את הקישור הזה קיבל, בלי להתקין שום דבר ובלי להדביק שורת קוד, JavaScript של התוקף שרץ בתוך ה-session המחובר שלו ב-chatgpt.com.

מוקול גויאל, שפרסם את המחקר ב-28 באוקטובר, מתאר מה הקוד הזה הצליח לעשות: לשלוף את רשימת השיחות של הקורבן, לפתוח שיחה פרטית שמעולם לא שותפה עם התוקף, ולשלוח חלק מהתוכן שלה לשרת חיצוני. OpenAI שחזרה את הבעיה, העלתה אותה מדירוג P2 לדירוג P1 החמור ביותר שלה, וסגרה אותה ב-26 באוקטובר, שמונה ימים אחרי שהדיווח הוגש דרך תוכנית ה-bug bounty של OpenAI ב-Bugcrowd. הפרס היה 5,000 דולר. בהמשך נראה אילו שתי תכונות תמימות יצרו יחד את המסלול, איך הודעה אחת בין חלונות הכניסה HTML לעמוד האמיתי, ולמה שכבת ההגנה האחרונה של ChatGPT לא עצרה את זה.

שתי תכונות שנראו סבירות כל אחת בנפרד

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

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

הדלת שנשארה פתוחה: מי בדיוק שלח את ההודעה

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

את הבדיקה הזאת MathLive לא עשתה. המאזין שלה זיהה הודעה מהסוג הציבורי virtual-keyboard-message וטיפל בה, בלי לדרוש שתגיע מחלון שאושר מראש. ההגדרה שקובעת את המדיניות הזאת נקראת originValidator, ולפי הגדרות הטיפוסים הרשמיות של הספרייה ברירת המחדל שלה היא none, כלומר בלי בדיקת מקור בכלל. ההודעה שעניינה את החוקרים היא זו שמחליפה את פריסת המקלדת, והפריסה יכולה להכיל HTML. הגרף התקין את המקבל, ה-Preview סיפק את השולח הלא מאומת, ואף אחד מהשניים לא נכתב כדי לדבר עם השני.

אז איך HTML שמוכנס לעמוד הופך לקוד שרץ?

MathLive לא משאירה את הפריסה שקיבלה בצד. כשהמקלדת נפתחת היא מחברת אותה למחרוזת HTML ומשייכת אותה ל-innerHTML בעמוד ה-ChatGPT האמיתי. בדרך היא מעבירה את המחרוזת דרך פונקציה בשם createHTML, ששמה מרמז על סינון. לפי המחקר היא לא מסננת כלום: היא עוטפת את המחרוזת, מסמנת אותה כמהימנה, ומעבירה את ה-HTML כמו שהוא.

זה עדיין לא הספיק. תגית script שמוכנסת דרך innerHTML נשארת רדומה ולא מופעלת, וזה המחסום שהחוקרים נתקלו בו בשלב הזה. מה שכן עובר הוא iframe מסוג srcdoc, frame שנושא את ה-HTML שלו ישירות בתוך תכונה במקום לטעון אותו מכתובת. את ה-frame הזה MathLive הכניסה לעמוד הראשי בלי תכונת sandbox, ומכיוון שהעמוד הראשי יצר אותו, הדפדפן נתן לו את הזהות של chatgpt.com עצמו. ברגע הזה הקוד של התוקף יצא מהקופסה, והוא כבר לא אורח על כתובת זרה אלא חלק מהאתר.

נשאר מחסום אחרון. לעמוד של ChatGPT יש כלל שקובע מאיפה מותר לו לטעון סקריפטים ומה אסור לו להריץ ישירות מתוך הקוד של הדף, ולכלל הזה קוראים Content Security Policy, בקצרה CSP. הוא עשה את שלו וחסם קוד שהוכנס בתוך הדף עצמו. אבל רשימת ההיתרים כללה גם דומיין חיצוני אחד שימושי, www.youtube.com, כדי לאפשר הטמעות וידאו. ל-YouTube יש endpoint בשם oEmbed שמחזיר מידע על סרטון, והוא תומך בסגנון קריאה ותיק שבו שם של פונקציה עובר כפרמטר והתשובה חוזרת כקוד JavaScript במקום כ-JSON (לטכניקה קוראים JSONP). ה-endpoint סימן את שם הפונקציה שהחוקרים שלחו כלא חוקי, אבל שתל אותו בגוף התשובה לפני שהחזיר את שגיאת האימות. מכיוון ש-YouTube היה כבר ברשימה המותרת, הדפדפן טען את התשובה כסקריפט לגיטימי והריץ את מה שהיה בתוכה.

גויאל מדגיש שזאת הייתה עקיפה ולא שורש הבעיה: "CSP הוא הגנה שכבתית בעלת ערך, אבל הוא לא יכול להפוך מסלול רינדור לא בטוח למסלול בטוח", כותבים החוקרים בסיכום הלקחים. חסימה של אותו endpoint אחד הייתה מורידה מסלול אחד להרצת קוד, ומשאירה Preview לא מהימן שעדיין מצליח להכניס HTML לעמוד הראשי.

מה הקוד עשה, ואיך הכול נדחס לקישור אחד

חלון התראה שהדפדפן מסמן כמגיע מ-chatgpt.com הוא הדבר הפחות מעניין שהשרשרת הזאת יכלה להציג, וגויאל כותב את זה במפורש. הקוד רץ בזהות של האתר, כלומר באותו הקשר מאומת שבו רץ ה-ChatGPT עצמו. ה-frame קרא מתוך המסמך של העמוד העוטף את נתוני ה-session, חילץ משם את ה-token ואת מזהה החשבון של הקורבן, ודרש שהשניים יסכימו ביניהם כשאפשר היה לבדוק את זה. עם הכותרות האלה בזיכרון הוא פנה ישירות ל-API של ChatGPT: קודם רשימת השיחות האחרונות, ואחר כך תוכן ההודעות של שיחה אחת שנבחרה.

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

עד כאן מה שהקוד יכול. הצד השני, איך כל זה נדחס לקישור אחד שנשלח לאדם זר, הוא מה שהפך את זה למתקפה.

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

הפתרון היה לשים את שני החלקים בשיחה ציבורית אחת ורגילה: גרף אמיתי שמאתחל את MathLive בעמוד הראשי, ואחריו בלוק HTML שה-Preview שלו שולח את ההודעות. קישור ה-share שנוצר שימר את השרשרת כולה גם עבור משתמש אחר, והתוקף יכול להפיץ אותו בדיוק כמו כל שיתוף שיחה אחר, במייל, בצ'אט, בקריאת תמיכה או בפוסט ברשת. בשחזור היציב ביותר הקורבן לחץ פעם אחת על כפתור Preview הרגיל. אחר כך החוקרים סימנו את הבלוק כך שה-Preview נפתח מעצמו, ואז פתיחת הקישור הייתה הפעולה היחידה שנדרשה. את המסלול האוטומטי הם שחזרו שלוש פעמים בחשבון נפרד, כולל אחרי רענון מלא, כשההוכחה מופיעה בערך 4.5 שניות אחרי הפתיחה.

שלוש החלטות תכנון, לא באג אחד

מה שמעניין במקרה הזה הוא שאף אחת מהתכונות לא הייתה שבורה בנפרד. ה-Preview היה מבודד כמו שצריך, גרפים באמת צריכים לטעון ספריית מתמטיקה, frames באמת צריכים להחליף הודעות, ו-CSP שמתיר ספק מדיה גדול הוא החלטה נפוצה. גויאל מונה שלוש החלטות שחברו יחד לשרשרת: MathLive קיבלה הודעה שמשנה מצב בלי להוכיח מי שלח אותה, פורמט ההודעה אפשר להעביר HTML גולמי כנתוני פריסת מקלדת, וה-HTML הזה יכול היה ליצור מסמך לא מבודד שיורש את הזהות של chatgpt.com.

גם התיקון התגלגל בשלבים. OpenAI לא פרסמה מצידה תיאור של האירוע, וכל מה שידוע על אופן הסגירה מגיע מציר הזמן של Parameter: ב-22 באוקטובר OpenAI דיווחה שאחת משתי גרסאות ה-frontend תוקנה והשנייה עוד בעבודה, ב-23 באוקטובר שינויים ב-CSP חסמו חלק מהשרשרת בזמן שעדכון אחד נותר, וב-26 באוקטובר שתי הגרסאות כבר דחו את ההודעות שבדיווח. הלקח שהחוקרים בחרו לנסח ראשון קצר: "בידוד חזק רק כמו כל גשר שחוצה אותו." וזה לא עניין ייחודי ל-OpenAI. כל מוצר שמריץ תוכן שמשתמשים מייצרים בתוך frame מבודד, ומחזיק באותו עמוד גם ספריות צד שלישי שמאזינות להודעות, נמצא באותה תמונה בדיוק.

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

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

למי שמפתח, השאלה המעשית ישירה יותר. אם יש בעמוד שלכם ספרייה שמאזינה ל-postMessage, בדקו מה ברירת המחדל שלה לאימות מקור ההודעה, ואם היא מרנדרת HTML שהגיע בהודעה, ודאו שה-frame שהיא יוצרת נשאר מבודד. בשתי ההגדרות האלה נגמרה השרשרת הזאת.