ב-29 באוקטובר, ב-17:10 UTC, ארנק אחד ברשת Polygon קיבל את ההעברה הראשונה שלו. חמישים ושתיים דקות אחר כך הצטברו בו 28,636,284.45 טוקני POL, ולידם פוזיציות מ-33 ארנקים שונים. הסוחרים שמהם זה נלקח לא פתחו קובץ מצורף, לא התקינו תוסף ולא הקלידו סיסמה בעמוד מזויף. הם השאירו את polymarket.com פתוח בדפדפן בשעה הלא נכונה.

את השחזור הטכני פרסמו באותו יום Ohey ו-Hudson Rock, שתיהן על בסיס אותו קובץ JavaScript שהספיקו למשוך מהרשת לפני שנעלם. הממצא שהופך את המקרה הזה למשהו אחר מעוד גניבת קריפטו הוא מה שלא היה בקוד: אין בו מפתח פרטי, הוא לא מבקש seed phrase, והוא לא מנסה לדוג סיסמאות. במקום זה הוא חיפש בתוך הדף את רכיב המסחר של Polymarket עצמה, זה שכבר מחזיק את ההרשאה לחתום בשם הארנק של המשתמש, והשתמש בו.

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

מה עושה סקריפט של ניטור ביצועים בדף של בורסת חוזים

כל אתר שרוצה לדעת כמה זמן לוקח לו להיטען אצל גולשים אמיתיים, ולא במעבדה, מטמיע שורת JavaScript קטנה שמודדת את זה בדפדפן של המבקר ושולחת את המספרים לספק. בתעשייה קוראים לזה RUM, ניטור משתמשים אמיתיים. Polymarket, בורסת החוזים שבה סוחרים בתוצאות של אירועים, השתמשה לשם כך בשירות של חברת DebugBear, וטענה את הסקריפט ישירות מהכתובת cdn.debugbear[.]com.

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

השרת שהחליף צדדים באמצע היום

עד הצהריים של 29 באוקטובר הכול נראה רגיל. ב-11:48:50 שרת קצה של Cloudflare עוד שלף מהמקור את הקובץ התקין, 60,493 בתים, וחוקרי Ohey מצאו בכותרות התשובה של העותק התקין חתימה ברורה של Google Cloud Storage. ב-16:54:57 אותה כתובת החזירה שגיאת Cloudflare 521, כלומר המקור הפסיק לענות. וכשהקובץ חזר, הוא היה אחר: 15,187 בתים, בלי אף אחת מכותרות ה-Cloud Storage, ועם ETag בפורמט שמזוהה עם nginx. ב-18:13:44 הדומיין השני של החברה החזיר עמוד שגיאה של nginx/1.24.0 על Ubuntu. המסקנה של Ohey הייתה יבשה: "שרת אחר ענה בשם שני הדומיינים של DebugBear".

מה בדיוק קרה שם, אף מחקר ציבורי לא קבע. DebugBear הצהירה, בעדכון שפרסמה בחשבון ה-X שלה ושעליו דיווח The Hacker News, שרשומות ה-DNS שלה נפרצו דרך רשם הדומיינים, ושעד כה לא ראתה סימן לחדירה למערכות שלה עצמן. Ohey, מצידה, מדגישה שהיא מתעדת את מה שהשתנה בתשובות השרת ולא את הסיבה לכך. ההבדל הזה חשוב: את עצם החלפת הקובץ שני המחקרים מוכיחים, את הדרך שבה זה נעשה מספרת כרגע רק החברה שנפרצה.

התגובה עצמה הייתה מהירה. ב-17:38 Polymarket פרסמה שהיא מזהה "פעילות לא מורשית שמשפיעה על ארנקי משתמשים", ב-17:59:20 יצא build לייצור שמסיר את הסקריפט, ב-18:00:58 DebugBear הודיעה שהיא מושפעת מאירוע אבטחה וביקשה מלקוחותיה להסיר את קוד ההטמעה, וב-18:05 הבורסה עברה למצב ביטול בלבד והשביתה את הרלייר. בסביבות 19:00 המסחר חזר. בדף הסטטוס האירוע מסומן כהשבתה של 37 דקות.

נוזקה בלי מפתחות: איך היא חתמה בשם הסוחרים

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

החיפוש עצמו הוא החלק המתוחכם. Polymarket בנויה ב-React, ולספרייה הזאת יש עץ פנימי של רכיבים שמייצג את הדף. לפי Ohey, הקוד סרק את העץ הזה עד 400 רכיבים, חמש שניות או 60,000 צמתים, בחיפוש אחר אובייקט שיש לו את מתודות שליחת ההוראות. אם לא מצא, ניסה חמש-עשרה פעמים בהפרשים של 250 מילישניות, ואז שינה את נתיב הדף כדי לכפות רענון.

לפני כל זה הקוד בדק אם יש מה לקחת. הוא קרא את מפתחות ההתחברות ש-Polymarket שומרת באחסון המקומי של הדפדפן, ובהם המפתח שמחזיק את פרטי האימות ל-API המסחר (poly_clob_api_key_map), ועצר אם המשתמש לא מחובר או שאין לו הרשאות מסחר. הוא גם שאל את ה-API של האתר אם הגולש חסום גאוגרפית, ואם כן דילג עליו. אחרי המתנה של שנייה וחצי הוא משך את ההגדרות שלו משרת חיצוני, ואם זה נכשל נפל לברירות מחדל קבועות בקוד: ארנק יעד 0x3F99139F1C5854bb628c2e576Aaf638e99dFfc18, רצפה של עשרה דולר לכל פעולה, ו-POL כמטבע התשלום.

ועל כל התחכום, הקוד לא היה מעורפל בכלל: Ohey מתארת JavaScript פשוט ולא מוסתר, דחוס לפונקציה מוקטנת אחת בשם startdebugger.

שני מסלולים, לפי איך שהתחברתם

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

במזומן הקוד טיפל בנפרד. הוא ביקש הצעת מחיר משירות החלפה חיצוני כדי להמיר את היתרה ל-POL ולשלם ישירות לארנק היעד, ואם זה לא הסתדר נפל להעברה ישירה של ה-USDC. כל אצווה כזאת נשלחה כהודעה חתומה בתקן EIP-712 אל הרלייר של Polymarket עצמה, עם תוקף של שעה. במקביל הקוד דיווח לשרת שלו, באותם דומיינים שהוחלפו, את כתובות הארנקים, סוג ההתחברות, מערכת ההפעלה, מזהה מעקב, כתובת IP ומדינה.

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

אז למה ה-CSP לא עצר את זה?

רוב האתרים שמחזיקים כסף מגדירים מדיניות שאומרת לדפדפן לאילו דומיינים מותר לו בכלל לפנות, כדי שקוד זר שהצליח להיכנס לדף לא יוכל לשלוח את הממצאים שלו לאן שהוא רוצה. למדיניות הזאת קוראים Content Security Policy. Polymarket הגדירה אחת, והיא פשוט לא נגעה בתרחיש הזה: לפי Ohey, כל הדומיינים שהקוד הזדוני פנה אליהם, חוץ מאלה של DebugBear, היו ממילא ברשימה המותרת, מפני שהם אותם שירותים שהאתר עובד איתם מדי יום. הקוד לא ניסה לפרוץ את הגדר. הוא עבד דרכה.

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

בעדכון שפרסמה ב-30 באוקטובר Polymarket כותבת שהיא מחזירה סקריפטים של ספקים לאוויר עם בקרות שלמות וניטור מתמשך, ומונה כדוגמאות מדיניות CSP מהודקת, קיבוע גרסאות מפורש ובידוד בארגז חול. במקביל היא איפסה את כל ה-session-ים הפעילים, ניקתה את שכבות המטמון והעבירה את הטיפול לרשויות החוק.

המספרים תלויים במי שסופר. Polymarket, בעדכון הרשמי, אומרת שהיא מחזירה 2.7 מיליון דולר על הגניבה עצמה ועוד 1.2 מיליון כפיצוי למי שנפגע מהשבתת המסחר, 3.9 מיליון דולר בסך הכול, ושנכון לאחר הצהריים של 30 באוקטובר 95 אחוז מהארנקים שנפגעו קיבלו החזר מלא. על השרשרת Ohey סופרת 28,636,284.45 טוקני POL שהגיעו לארנק היעד ו-36 העברות של פוזיציות מ-33 ארנקים. אנליסט עצמאי שמכונה TheFatMbappe עקב אחרי 2.724 מיליון pUSD שיצאו מ-721 ארנקי מקור. Hudson Rock, בזמן אמת, סירבה לנקוב במספר וכתבה שהיקף הנפגעים והנזק המצטבר לא הוכחו. את הכסף עצמו לא השאירו במקום: בין 18:28:32 ל-18:38:16 כל ה-POL יצא מארנק היעד בשלוש העברות לשלוש כתובות אחרות.

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

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

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