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

בדיוק ברגע הזה, לפי המחקר של Cycode, לתוקף כבר יש token גישה תקף לחשבון שלכם. החוקר Yuval Elbar פרסם ב-28 בספטמבר ניתוח של חולשה ב-MCP Python SDK הרשמי של Anthropic, ה-SDK שדרכו נכתבים לקוחות MCP רבים בפייתון. שרת MCP זדוני יכול לגרום ל-SDK לשלוח אליו את ה-client secret של האפליקציה, את קוד ההרשאה, ואת מפתח ההוכחה שנועד למנוע בדיוק את הגניבה הזאת. ההודעה הרשמית פורסמה באותו יום ומדרגת את החולשה 7.5. מספר CVE עדיין לא הוקצה לה.

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

איך SDK מחליט לאן לשלוח את פרטי האימות

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

התהליך שבו ה-SDK מברר את זה נקרא discovery, והגרסה התקינה שלו עובדת בשלושה צעדים. קודם הלקוח שואל את שרת ה-MCP מי מטפל בהתחברויות שלו, והשרת מחזיר כתובת של ספק ההתחברות, נניח Google או Okta או Azure AD. אחר כך ה-SDK מוריד מהכתובת הזאת את קובץ ההגדרות של ספק ההתחברות. ולבסוף הוא משווה: האם שדה ה-issuer בקובץ ההגדרות, כלומר הזהות שספק ההתחברות מצהיר עליה בעצמו, תואם לכתובת שהתקבלה בצעד הראשון?

הצעד השלישי הוא כל האבטחה כאן. הוא זה שתופס שרת MCP שמשקר לגבי מי ספק ההתחברות שלו.

הבדיקה שרצה רק כשיש לה מול מה לרוץ

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

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

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

איך עוברים את הבדיקה השנייה בשקר

ל-SDK יש שכבת הגנה נוספת, credential binding. אפשר לחשוב עליה כתווית שמודבקת על פרטי האימות השמורים ואומרת "אלה שייכים לספק ההתחברות X". לפני שה-SDK משתמש בהם הוא בודק שספק ההתחברות הנוכחי תואם לתווית. הבעיה היא מול מה הוא משווה אותה: הוא משווה אותה לשדה ה-issuer שהגיע מקובץ ההגדרות, בדיוק אותו שדה שהתוקף שולט בו עכשיו כי הבדיקה הראשונה לא רצה. אז התוקף פשוט כותב שם את השם של ספק ההתחברות האמיתי שלכם. הבדיקה שואלת אם פרטי האימות שייכים לאותו ספק, והתשובה חיובית, כי התוקף אמר שכן.

זה החלק המעניין בחולשה. התוקף לא שובר את ההגנה השנייה, הוא עובר אותה. אותו נתון לא מאומת שחמק מהבדיקה הראשונה זורם הלאה כקלט אמין ומספק את הבדיקה הבאה בתור. אותו דבר קורה לשכבה השלישית, audience binding, שאמורה להטביע בקוד ההרשאה חותמת שאומרת שהקוד תקף רק מול שרת מסוים. ה-SDK מטביע את החותמת רק כשמסלול ה-discovery המודרני עבד. במסלול הנסיגה, זה שהתוקף כפה, אין חותמת. קוד ההרשאה עובד בכל מקום.

כדי לבודד מה באמת חוסם את המתקפה, ב-Cycode בדקו שלוש תצורות. בתצורת המתקפה השרת מחזיר 404, בדיקת ה-issuer מדולגת, והגניבה מצליחה. בתצורת הביקורת הכל זהה חוץ מדבר אחד, השרת כן עונה לשאילתת ה-discovery, ואז הבדיקה רצה, ההצהרה של התוקף לא תואמת לכתובת שה-SDK גילה בעצמו, והתהליך נעצר לפני שנשלח משהו. בתצורה השלישית התוקף מנסה לגרום לבדיקה לעבור במקום לדלג עליה ומצהיר על עצמו כספק ההתחברות, ואז דווקא ה-credential binding נכנס לפעולה, זורק את פרטי האימות של הקורבן ומאלץ רישום מחדש מול השרת של התוקף. המסקנה חדה: התצורה היחידה שבה פרטי האימות האמיתיים עוזבים את המכונה היא זו שבה הבדיקה לא רצה.

אז למה המשתמש בכלל מאשר את זה?

כאן נכנס לתמונה הפרט שהופך את זה ממחקר תיאורטי למתקפה שעובדת. ההגדרות שהתוקף מספק מפנות את עמוד ההתחברות אל ספק ההתחברות האמיתי שלכם. אתם מגיעים לעמוד של Google או של Okta, בכתובת הנכונה, עם התעודה הנכונה. אין שם שום דבר לזהות, ואתם מאשרים כי אין שום סיבה לא לאשר. (וזה, אגב, מה שמבדיל את המקרה הזה מפישינג OAuth רגיל: אין עמוד מזויף לחפש, כי אין עמוד מזויף.)

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

לספק האינטראקטיבי, OAuthClientProvider, ניתן ציון נמוך יותר, 6.5, כי מישהו צריך ליזום את ההתחברות. על הנייר זה חסם. בפועל ה"אינטראקציה" הזאת היא אישור של עמוד התחברות אמיתי מספק הזהות שלכם, הפעולה שכל משתמש מאומן לעשות בלי להסס. מעבר לזה, הציון מניח שהמשתמש בחר ביודעין להתחבר לשרת זדוני, וב-Cycode מונים כמה תרחישים שמבטלים גם את ההנחה הזאת: רשומה מזויפת או חטופה בקטלוג שרתי MCP, שהמשתמש בוחר מתוך רשימה שנראית אמינה ומסודרת; הזרקת הנחיות (prompt injection) בסביבת סוכן AI, שבה המודל הוא שבוחר לאיזה שרת להתחבר והוא מכוון לשרת של התוקף בלי שהמשתמש בחר בכלל; או שליטה ב-DNS או במערכת פנימית שנפרצה, שבה הלקוח פונה לשם מוכר ומקבל את השרת של התוקף. שני הספקים שרצים בין מכונות בלי אדם בתמונה, ClientCredentialsOAuthProvider ו-PrivateKeyJWTOAuthProvider, מקבלים 7.5 בדיוק כי אין שם אינטראקציה בכלל.

מה נשאר לתוקף אחרי שהדלת נפתחה

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

ומהצד של הקורבן אין שום סימן. אין ניסיון התחברות כושל, אין התראה על כתובת IP חריגה, אין אתגר MFA. התוקף הזדהה עם ה-client secret הנכון, עם קוד הרשאה תקף ועם מפתח ההוכחה הנכון. מבחינת שרת ההתחברות זו התחברות לגיטימית לכל דבר, ומערכות הניטור הרגילות לא יסמנו אותה.

החוקרים מסכמים את זה כתבנית ולא כבאג נקודתי: בדיקה שרצה רק כשנתון מסוים קיים, כשהתוקף הוא שקובע אם הנתון קיים. "כל הבקרות היו שם", הם כותבים. פונקציית אימות ה-issuer, ה-credential binding, הגבלת ה-audience. שלושתן קיבלו את אותו קלט לא מאומת, ושלושתן אמרו שהכל נראה תקין. בסיווג הרשמי זה מופיע כ-CWE-345, אימות לא מספק של מהימנות נתונים.

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

החולשה נוגעת לגרסאות 1.9.1 עד 1.29.1 ולגרסאות 2.0.0 עד 2.1.1, ותוקנה ב-1.30.0 וב-2.2.0. לא נפגעים שרתי MCP שנבנו עם ה-SDK, לקוחות מקומיים שרצים ב-stdio, ולקוחות שמצרפים את ה-token-ים שלהם בעצמם.

אם אתם מריצים לקוח MCP בפייתון מעל HTTP עם אחד משלושת ספקי האימות האלה, העדכון הוא הצעד הראשון והוא לא מסיים את העבודה. בשני הספקים שרצים בין מכונות צריך להעביר במפורש את הפרמטר issuer= עם הכתובת של ספק ההתחברות הנכון, אחרת הם עדיין ילכו אחרי מי שהשרת מצביע עליו; ההשמטה מייצרת כרגע אזהרת deprecation ותהפוך לחובה בגרסה 3.0. צריך גם לנקות את רישומי ה-OAuth השמורים, כי רישומים שנוצרו בגרסאות הישנות נשמרו בלי תווית ונשארים לא קשורים לאף ספק. ומי שיש סיכוי שהלקוח שלו התחבר לשרת MCP לא מהימן לפני העדכון: החליפו את ה-client secret ובטלו את ה-token-ים אצל ספק ההתחברות. בגרסאות הישנות אין מעקף, רק להתחבר לשרתים שאתם באמת סומכים עליהם.