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

את המתקפה תיארו חמישה חוקרים במאמר Remote-Timer-as-a-Service שהועלה ל-arXiv ב-17 באוגוסט. שניים מהם, Martin Schwarzl ו-Albert Pedersen, עובדים ב-Cloudflare עצמה, ושלושה, Haocheng Xiao, Sam Ainsworth ו-Nigel Topham, מגיעים מאוניברסיטת אדינבורו. יומיים אחר כך פרסמה Cloudflare פוסט משלה בחתימת אותם חוקרים, שמאשר את הממצאים ומפרט מה עשתה בעקבותיהם. לפי הפוסט העבודה נעשתה ב-2024 ובתחילת 2025, וההגנות שנבנו בגללה כבר פרוסות.

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

למה שני לקוחות של Cloudflare חולקים תהליך אחד

Cloudflare Workers מריצה קוד של לקוחות בקצה הרשת, במיליוני בקשות HTTP בשנייה. הדרך המקובלת להפריד בין לקוחות היא תהליך נפרד לכל אחד, אבל תהליך עולה זמן עלייה וזמן החלפת הקשר, ומערכת שאמורה להגיב במילישניות לא יכולה להרשות לעצמה את זה. אז Workers ויתרה על הגבול הזה ונשענת על מנוע V8, שמפריד בין הלקוחות ברמת השפה: כל לקוח מקבל isolate משלו, ואלפי isolate-ים של אלפי לקוחות חיים יחד בתוך תהליך אחד.

וכאן נכנסת לתמונה בעיה בחומרה. המעבד המודרני לא מחכה לתשובות. כשהוא מגיע לתנאי, במקום לעצור עד שיידע לאן להמשיך, הוא מנחש את הכיוון הסביר ומתחיל כבר להריץ את ההמשך. אם הניחוש היה שגוי הוא זורק את התוצאות, אבל דבר אחד נשאר מאחור: הנתונים שהספיק לקרוא בדרך יושבים עכשיו במטמון. מי שמודד אחר כך כמה מהר הוא קורא כתובות שונות יכול להסיק מזה מה המעבד קרא בזמן הניחוש השגוי. לזה קוראים Spectre, וזה לא באג בקוד של אף אחד מהלקוחות אלא תכונה של הסיליקון שמתחת לכולם. כשכל הלקוחות יושבים באותו תהליך, קריאה כזאת חוצה את הגבול בין לקוח ללקוח. Cloudflare ידעה את זה: ב-2021 היא כבר פרסמה, יחד עם Schwarzl וחוקרים מ-TU Graz, מתקפה כזאת שדלפה 120 ביט בשעה, ואת ההגנות שנבנו נגדה.

איך מודדים זמן כשכל השעונים קפואים

ההגנה הראשונה תקפה את היכולת למדוד. גם Date.now וגם performance.now מחזירות ב-Workers את הזמן של אירוע הקלט/פלט האחרון ונשארות קפואות לאורך ההרצה, אין ריבוי תהליכונים שאפשר להפוך לשעון מאולתר, ואין זיכרון משותף. המתקפה מ-2021 עקפה את זה בעזרת בקשות יוצאות ששימשו תקתוק גס, אבל מספרן מוגבל, והניסוי רץ ברשת מקומית ולא בייצור.

הפעם החוקרים מצאו ערוץ אחר: WebSocket. מספר ההודעות בחיבור כזה אינו מוגבל, והתקורה שלו נזקפת לזמן הקיר ולא לזמן המעבד של ה-Worker, כך שהיא לא אוכלת את המכסה. ה-Worker התוקף מבקש משרת מרוחק לסמן חותמת זמן, מריץ את מה שהוא מודד, מבקש חותמת שנייה ושואל מה ההפרש. כשה-Worker השני מדווח Date.now הרזולוציה החציונית עמדה על מילישנייה אחת, והגרסה הטובה ביותר הרימה שרת שעון על אותה מכונה דרך ה-Sandbox SDK של Cloudflare. גם ממרחק של יותר מ-1,200 קילומטרים, כותבים החוקרים, נשמרה רזולוציה יציבה של מאות מיקרו-שניות. המסקנה שלהם חדה: אין דרך למנוע שעון מרוחק כל עוד מותר לתוקף להתחבר לשרתים מרוחקים.

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

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

איך נשארו על המכונה, ולמה ההגנה לא הבחינה

Worker רגיל מוגבל ל-30 שניות של זמן מעבד להפעלה, וזה היה אמור להיות החסם. אבל Durable Objects, Worker-ים מיוחדים שנועדו להחזיק מצב לאורך זמן, מקבלים איפוס של המונה בכל בקשת HTTP שמגיעה אליהם, והודעת WebSocket נספרת פנימית כבקשה כזאת. מי ששולח אות חיים כל פחות מ-30 שניות מחזיק את אותו isolate חי, לפי המדידות שלהם, בין חמש שעות ליותר מ-20 שעות ברצף. את זיהוי המכונה עשו דרך ה-endpoint ‏/cdn-cgi/trace שכל דומיין ב-Cloudflare מקבל, שמחזיר בין השאר ערך כמו fl=114f227, שבו 114 הוא מזהה מרכז הנתונים ו-227 מזהה המכונה. אחר כך פשוט מייצרים Durable Objects עד ששניים מהם נוחתים באותו תהליך.

ההגנה שאמורה לתפוס בדיוק את זה נקראת DyPrIs. היא עוקבת אחרי מוני ביצועים לכל סקריפט, מנרמלת את כמות ניחושי הענף השגויים לפי כמות הפניות ל-iTLB, ומעבירה סקריפט חשוד לתהליך נפרד. שתי הנחות היסוד שלה קרסו כאן. הראשונה: הבידוד נאכף רק אחרי שהרצת הסקריפט הסתיימה, ו-Durable Object שמקבל אותות חיים לא מסיים לעולם, כך שהדליפה רצה ברצף ואף פעם לא מגיעה לנקודה שבה מבודדים אותה. השנייה: הטיפול בהודעות ה-WebSocket מריץ בעצמו הרבה קוד ומעלה את פעילות ה-iTLB, וזה מוריד את היחס המנורמל אל מתחת לסף הזיהוי. "שני הכשלים הם מגבלה יסודית של גישת הזיהוי, ולא פספוס בהטמעה", כתבו החוקרים, מפני שתוקף ששולט בדפוס הקלט/פלט שלו תמיד יוכל לנפח את המכנה.

מה באמת דלף משם

השלב הראשון היה לאתר את הזיכרון. V8 מחזיק את כל עצמי הערימה של isolate באזור רציף בגודל 4 ג'יגה-בייט ושומר מצביעים כהיסט של 32 ביט מבסיס אחד, ולכן החוקרים בנו רכיב שמבלבל בין Uint8Array ל-Uint32Array כדי לחלץ את כתובת הבסיס הזאת. הם הריצו את הניסוי 1,000 פעמים וקיבלו 12 ביט בשנייה בדיוק ממוצע של 99.60 אחוז. בניסוי השני הם חילצו 128 בתים מאזור vDSO של המערכת, החל מהיסט 0xe20 שבו יושבת המחרוזת gettimeofday, בדיוק של 99.16 אחוז. רק אז הגיעה השרשרת המלאה: לחלץ את כתובת הבסיס של עצמם, ללכת ממנה לאורך שרשראות מצביעים אל מרחב הכתובות המשותף של התהליך, ומשם לערימה של ה-Worker הקורבן ולקריאת ה-token בית אחרי בית.

המספרים האלה נמדדו בתנאים נוחים. השרתים היו מבוססי AMD EPYC מדור Zen2 ו-Zen3, והניסויים רצו בלילה בעומס מעבד של 10 עד 25 אחוזים. הם מדדו את שיעור ההצלחה לאורך כשלושה ימים והראו מתאם ישיר בינו לבין העומס על המכונה: בשעות עומס המתקפה מאטה, אך נשארת אפשרית. מול 2 ביט בדקה של המתקפה הקודמת, מדובר בשיפור של פי 360. מטעמי אתיקה כל הניסויים כוונו לסקריפטים שהחוקרים עצמם שלטו בהם, ולא נגעו בנתוני לקוחות.

מה Cloudflare פרסה, ומה עוד פתוח

התגובה הגיעה בשלוש שכבות, ורובה תואר כבר בפוסט של Cloudflare מספטמבר 2025. הראשונה היא ארגז החול של V8, שמחסל את המצביעים הגולמיים בני 64 הביט והופך מצביעי ערימה להיסט של 32 ביט בתוך כלוב של 4 ג'יגה-בייט. החוקרים אישרו שהרכיב שלהם מפסיק לעבוד כשהארגז פעיל, אבל הוסיפו שעצמים רבים עדיין לא הועברו אליו, ושהוא מקשה על בניית רכיבים כאלה בלי לבטל אותה.

השכבה השנייה היא חומרה. מפתחות הגנת הזיכרון של אינטל מאפשרים עד 15 מפתחות לתהליך, V8 שומר כמה מהם למהדרים שלו, ונשארים בערך 12. Cloudflare נותנת מפתח לכל ערימת isolate, וכל גישה שחוצה את הגבול נתקלת במלכודת חומרה אלא אם החוט המבצע מחזיק את המפתח המתאים. בחלוקה אקראית זה חוסם כ-92 אחוזים מהגישות, 11 מתוך 12, ולכן היא משולבת עם סבב מפתחות קבוע כך שאף ארגז חול בטווח של 32 ג'יגה-בייט לא חולק מפתח עם שכנו. השכבה השלישית היא שיפור של DyPrIs, שתלמד לזהות הרצות ארוכות ועומסי קלט/פלט כבדים.

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

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

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