שרת שמקבל בקשת HTTP אחת, במשקל של פחות מחצי מגה בייט, אמור לענות לה ולשכוח ממנה. במעבדה של FoxIO זה לא מה שקרה. אחרי בקשה אחת כזאת שרת ה-HTTP/3 של Bun נתקע על 100 אחוז מעבד, הפסיק לענות לבקשות חדשות, ולא חזר לעצמו בכלל.

החוקר Sébastien Féry מ-FoxIO פרסם ב-20 באוקטובר את Header Accordions, מחקר שבדק עשר ספריות HTTP/3 ומצא את אותה משפחת חולשות בשבע מהן. ברשימה נמצאות ספריות שמריצות חלק גדול מהתעבורה המודרנית: quic-go, שעליה בנויים Caddy, Traefik ו-cloudflared, ה-quiche של Cloudflare, lsquic, Bun, aioquic ו-mitmproxy. כולן חולקות טעות אחת בחשבון, והיא שהן סופרות את מה שהגיע ברשת ולא את מה שנשאר בזיכרון אחרי הפריסה. פרי נתקל בזה בדרך אגב, בזמן שבדק מימושי QUIC שונים בעבור JA4Scan, הסורק של FoxIO, ואחרי שקרא את סדרת MadeYouReset של גל בר נחום על מתקפות מניעת שירות ב-HTTP/2.

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

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

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

כמה חוסכים ככה? שורה כמו strict-transport-security עם כל הערכים שלה שוקלת 71 בייט. ב-QPACK היא הופכת לבייט בודד, הפניה לאינדקס 58 בטבלה הסטטית. יחס דחיסה של 71 ל-1 על כותרת אחת, וזה בלי לדחוס בכלל את התוכן עצמו.

היעילות הזאת היא גם הנשק. מי שכותב שרת צריך להגדיר גבול לכמה כותרות הוא מוכן לקבל, והתקן אומר במדויק איך למדוד אותו: RFC 9114 בסעיף 4.2.2 קובע שהגודל נספר על הכותרות אחרי הפריסה, כאורך השם בתוספת אורך הערך, ועוד 32 בייט לכל שדה בעבור המטפחת שהשדה גורר איתו בזיכרון. פרי מצא שהספריות שנפלו טעו לפחות באחת מחמש דרכים: הן בדקו את גודל ה-frame הדחוס במקום את הפרוס, פרסו את הבלוק כולו לפני שבדקו משהו, השמיטו את 32 הבייט מהחישוב, הגדירו ברירת מחדל של "בלי הגבלה" כשאף אחד לא הגדיר ערך, או פשוט לא אכפו את ההגדרה גם כשהיא כן הוגדרה. "השאלה פשוטה: איזה רכיב בפועל דוחה את הכותרות שגדולות מדי?", כתב.

שתי דרכים לנפח את אותה בקשה

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

השנייה הפוכה, והיא גם ההרסנית יותר. התוקף מכניס פעם אחת רשומה אחת גדולה, בסביבות 4 קילובייט, דרך ערוץ הקודד של QPACK, ומכאן והלאה הוא מפנה אליה שוב ושוב בהפניות של בייט אחד. כל הפניה כזאת מאלצת את השרת להקצות ולהעתיק מחדש את הרשומה המלאה. בייט אחד נכנס, 4 קילובייט נתפסים בזיכרון, יחס של 4064 ל-1. זאת השיטה שעבדה על lsquic ועל Bun.

15 מגה בייט ב-quic-go, 5 גיגה ב-lsquic

ב-quic-go הגבול קיים ונראה שפוי. השרת מגביל את ה-frame של הכותרות למגה בייט אחד, אלא שההגבלה הזאת חלה על המטען הדחוס, ופונקציית הפריסה מפרסרת את הבלוק כולו במעבר אחד בלי לבדוק גודל באמצע. בלוק של מיליון בייט התפרס אצל פרי ל-160,075 שדות והשאיר כ-15 מגה בייט בזיכרון גם אחרי שאיסוף הזבל של Go עבר, כלומר פי 15 ממה שנשלח. ההודעה הרשמית של quic-go מעריכה שההתנפחות יכולה להגיע לפי 50, יותר ממה שפרי מדד בתנאים שלו, וזה הפער בין הערכה של מפתחי הספרייה לבין מדידה אחת בסביבה אחת. החולשה קיבלה את המזהה CVE-2025-64702 בציון 5.3, תוקנה בגרסה 0.57.0, והתיקון מוסיף פריסה הדרגתית שבודקת את הגודל אחרי כל רשומה ומחזירה שגיאת HTTP 431 כשהגבול נחרג.

ב-quiche של Cloudflare היו שני באגים שנדחסו למזהה אחד. הראשון, החישוב סכם רק את אורך השם והערך והשמיט את 32 הבייט שהתקן דורש. במגבלה של מגה בייט אחד התקן מתיר כ-32 אלף כותרות, והחשבון של quiche נתן מעבר לכמיליון כותרות של בייט אחד. השני בכלל לא נוגע לכותרות: מפרסר ה-frame-ים הקצה זיכרון לפי האורך המוצהר בכותרת ה-frame, עוד לפני שהמטען הגיע, כך שהצהרה על frame של 16 מגה בייט גוזרת הקצאה מיידית גם אם התוקף לא שולח אחריה שום דבר. ההודעה של Cloudflare מסמנת את CVE-2026-12523 בציון 7.5, עם תיקון בגרסה 0.29.3.

את המקרה העמוק ביותר פרי מצא ב-lsquic, הספרייה של LiteSpeed. שם שלוש בעיות מצטברות: ההקצאה פר הפניה לטבלה הדינמית בלי שום תקרה מצטברת לגודל הפרוס, הגדרת גבול הכותרות שברירת המחדל שלה היא אפס, כלומר בלי הגבלה, ושגם לא מועברת בפועל למפרסר של QPACK, ותקרת 64 הקילובייט שמתאפסת בכל בלוק כותרות חדש ולא נספרת לאורך כל ה-stream, כשהספרייה מוכנה לקבל בלוקים כאלה בלי הגבלה לפני שהגיע הגוף של הבקשה. התוצאה שפרי מדד: פחות מ-10 מגה בייט של תעבורה ברשת, מתוך 50 חיבורים, נעצו את הזיכרון התפוס בתקרה של 5 גיגה בייט בתוך שנייה אחת, כשמוני ה-cgroup של הקונטיינר רשמו יותר מ-900 חריגות מהגבול. גרסה 4.9.0 מיולי 2026 מתקנת את זה בכך שהיא מגבילה כמה סטים פרוסים של כותרות השרת מחזיק פר stream, עם ברירת מחדל של אחד.

המונה של Bun מתהפך, והשרת לא חוזר

Bun משתמש ב-lsquic מתחת למנוע, אבל מחליף את פונקציות הטיפול בכותרות של lsquic בקוד משלו. הקוד הזה בודק את הגודל של כל כותרת בנפרד ולא את הסכום המצטבר, והגבול שהוא מודיע עליו ללקוח, 16 קילובייט, לא נאכף בפועל. שישים אלף הפניות ל-stream, 5.8 מגה בייט ברשת בסך הכול, הספיקו כדי שמנגנון ניהול הזיכרון של המערכת יהרוג קונטיינר בגודל 2 גיגה בייט.

והנה הבאג השני באותה פונקציה, זה שהופך מניעת שירות זמנית לקבועה. הקוד מחזיק את גודל החוצץ במשתנה מסוג unsigned int ומכפיל אותו פי שתיים בכל פעם שצריך עוד מקום. כשהגודל הנדרש עובר 2 גיגה בייט, ההכפלה הבאה חורגת מהטווח של המשתנה ומתגלגלת לאפס. מאפס, ההכפלה נשארת אפס לנצח, והלולאה שמחפשת גודל מתאים לא מסתיימת אף פעם. מכיוון שהלולאה הזאת רצה בתוך לב עיבוד החיבורים של lsquic, כל השרת יושב בה. בפועל: stream אחד, כ-516 קילובייט על הקו, והשרת נשאר על 100 אחוז מעבד בלי דרך לחזור. התקרה המקורית של lsquic הייתה מונעת את המסלול הזה, בדיוק זאת שהוחלפה.

Bun עדיין לא תוקן, וגם לא aioquic, שבה המגבלה נקראת מהגדרות החיבור ואז נשכחת, ולא ספריית h3 של hyperium, שבה סט כותרות גדול מדי מפיל את ה-thread בפאניקה. פרי דיווח ל-h3 בדצמבר 2025, והמתחזק סגר את הפניה למחרת בנימוק שהספרייה בשלב אלפא וברירת המחדל תשתנה. עד שתשתנה, ההמלצות הן לא לחשוף שרת HTTP/3 של Bun לאינטרנט, לכבות HTTP/3 ב-mitmproxy, ולהגדיר ידנית גבול הגיוני במי שמשתמש ב-h3.

מה עשו שלוש הספריות שלא נפלו

הצד השני של הטבלה מעניין לא פחות, כי הוא מראה שאין כאן קושי אמיתי. ה-quiche של גוגל, שהיא פרויקט נפרד לגמרי מזה של Cloudflare למרות השם הזהה, אוכפת תקרה של 60 אלף שדות תוך כדי הפריסה ומאפסת את ה-stream כשהתקרה נחרגת. ה-http.sys של מיקרוסופט, שמאחורי IIS, סופר הדרגתית ומחזיר שגיאת עומס מוגדרת. ngtcp2 ו-nghttp3 מגיעים עם טבלה דינמית כבויה כברירת מחדל, ואחרי הדיווח של פרי הם רק עדכנו את התיעוד כדי להבהיר שהגבול המוצהר הוא הצהרה ללקוח ולא אכיפה. שלוש הספריות האלה ספרו את מה שבאמת יושב בזיכרון, ברגע שהוא נכנס לזיכרון.

כדאי גם לשים את ההיקף בפרופורציה. מדובר במניעת שירות, לא בהרצת קוד ולא בחילוץ מידע, ולא פורסם דיווח על ניצול בפועל. מה שכן, לוח הזמנים של הגילוי מספר משהו: הדיווח הראשון יצא לדרך בנובמבר 2025, התיקון של quic-go הגיע תוך פחות משבועיים, והתיקון של Cloudflare הגיע שמונה חודשים אחר כך, ביולי 2026. בתקופה הזאת החולשה הייתה בידי המפתחים ולא בידי המגננה של מי שמריץ את התוצר שלהם.

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

מי שמריץ Caddy, Traefik או cloudflared צריך לוודא שהגרסה שלו נבנתה מול quic-go 0.57.0 ומעלה, ומי שמריץ LiteSpeed או OpenLiteSpeed רוצה את lsquic 4.9.0. מי שמשתמש ב-quiche של Cloudflare בתוך מוצר משלו צריך 0.29.3, ומי שמנתח תעבורת HTTP/3 ב-Wireshark רוצה את 4.6.2 או 4.4.12, שם הדיווח של Wireshark מתאר קריסה של המפרסר ולא חולשה בשרת. אם יש לכם שרת HTTP/3 של Bun חשוף לאינטרנט, הוא עדיין פתוח ואין לו תיקון, ועד שיהיה כדאי להחזיר את החיבורים האלה ל-HTTP/2 או להעמיד מולם proxy שאוכף גבול כותרות בעצמו.