קובץ ה-JavaScript של החבילה imgdecode הוא בן תשע שורות. הוא מנסה לטעון קובץ בשם imgdecode.node מתוך תיקיית prebuilds, ואם זה נכשל מנסה נתיב שני תחת build/Release. אחר כך הוא מייצא פונקציה אחת בשם load, שמחזירה null. סורק שקורא רק את ה-JavaScript לא מוצא כאן שום דבר זדוני, כי באמת אין שם שום דבר זדוני. כל מה שמעניין יושב בקובץ הבינארי שהשורה הראשונה טוענת.

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

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

נתחיל מהבסיס. חבילת npm יכולה לכלול קוד מהודר ולא רק JavaScript, וספריות נפוצות כמו better-sqlite3 עושות את זה כבר שנים כדי לקבל מהירות שאי אפשר להשיג ב-JavaScript טהור. הקוד הזה נבנה מראש לקובץ עם סיומת node, ו-Node.js טוען אותו כמו כל ספרייה אחרת. ברגע הטעינה הוא קורא לפונקציית האתחול שבתוך הרכיב, עוד לפני שהוא מחזיר למתכנת את מה שהחבילה מייצאת. לממשק הזה קוראים N-API, ולפונקציה קוראים napi_register_module_v1.

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

הרכיב עצמו הוא DLL של 64 ביט שנבנה ב-MSVC מעל node-addon-api. SafeDep מציינת שם פרט קטן: נתיב קובץ ה-PDB נשאר בתוך כל שלושת הבילדים, C:\Users\Administrator\Desktop\npm\imgdecode\build\Release\imgdecode.pdb. מישהו בנה את זה על שולחן העבודה שלו ולא ניקה אחריו.

יש מישהו מול המקלדת?

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

הפירוש ש-SafeDep נותנת לבדיקה הזאת הוא שהיא מעדיפה הרצה מהטרמינל של מתכנת על פני runner של CI או ארגז חול אוטומטי, שבשניהם הפלט בדרך כלל מופנה לקובץ או לצינור. זו הסקה מהקוד ולא הצהרה של התוקף, אבל היא מתיישבת עם מה שהגרסה הראשונה עשתה: ב-1.0.0 אותה בדיקה בדיוק נעשתה ב-JavaScript, דרך process.stdout.isTTY, ורק אחר כך היא עברה אל תוך ה-DLL. גם המיתרים בתוך הרכיב לא יושבים בגלוי אלא מוצפנים ב-XOR עם בייט אחד, 0x37. בין מה שיוצא מהפענוח: הכתובת שממנה מורידים את המטען, מפתח הצפנה, ושם התהליך שאליו הקוד יוזרק.

המטען שמגיע בתוך מה שנראה כמו תמונה

הכתובת שמתקבלת היא קובץ בשם banner.jpg במאגר GitHub בשם cavecrew/proj. ההורדה נעשית דרך WinINet, עם user agent של Mozilla/5.0 ועם דגלים שמורים לקרוא מהרשת ולא לשמור ב-cache, והתשובה נשארת בזיכרון. שום דבר לא נכתב לדיסק.

התוכן מוצפן ב-AES-256 במצב CBC. המפתח הוא ה-SHA-256 של המשפט nif-runtime-2027, 16 הבייטים הראשונים של התשובה הם ה-IV והשאר הוא הטקסט המוצפן, ותשובה של 32 בייט או פחות נזרקת עוד לפני הפענוח. כאן יש מחלוקת קטנה בין המקורות ששווה לציין: Checkmarx ו-CloudSEK דיווחו שהמפתח בקמפיין הוא malfexteam2027, בעוד SafeDep הצליחה לפענח את המטען שהיא שיחזרה רק עם nif-runtime-2027, שהוא גם המפתח שמופיע ברשומות ה-OSV של החבילות האחרות באותה שרשרת. ייתכן שמדובר בשני מפתחות בשני ענפים של אותו קמפיין.

אחרי הפענוח הרכיב בודק את הבייטים הראשונים של התוצאה. אם הוא מוצא חתימה של JPEG (FF D8) או של PNG (89 50 4E 47), הוא משחרר את הזיכרון ועוצר.

זה נראה כמו בדיקת תקינות שגרתית, ולפי הקריאה של SafeDep זה כנראה משהו אחר לגמרי: מתג כיבוי מרחוק. אם המפעיל רוצה להשתיק את הלוֹדר, הוא לא צריך לגעת באף מכונה נגועה ולא להסיר שום דבר מ-npm. מספיק להעלות לאותה כתובת תוכן מוצפן שמתפענח לתמונה, וכל עותק של ה-DLL יפענח, יראה FF D8, ויעצור מעצמו. שימו לב להבדל העדין: תמונה רגילה שתועלה לשם לא תעזור, כי היא לא תעבור בכלל את שלב הפענוח. המתג דורש תמונה שהוצפנה עם המפתח הנכון.

איך הקוד מגיע לרוץ בתוך תהליך לגיטימי

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

ה-thread המקורי של dllhost.exe נשאר מושהה. התהליך קיים ברשימת התהליכים, נראה תקין לגמרי, ומריץ רק את מה שהוזרק אליו. אם ההזרקה נכשלת יש גם נפילה לאחור, והקוד ירוץ בתוך node.exe עצמו. עד כאן מה שהחבילה עושה. מה שמגיע בצד השני פחות מתוחכם, ויותר מטריד.

מה בעצם יורד בסוף

את banner.jpg עצמו SafeDep לא הצליחה להשיג, הוא החזיר 404. מה שהיא כן שיחזרה הוא קובץ אחר באותו מאגר, banner.png, מתוך עותק שמור בארכיון האינטרנט מה-6 באוקטובר: תמונת PNG בגודל פיקסל אחד, ואחריה מספר אורך של ארבעה בייטים ועוד כשמונה מגה-בייט של טקסט base64. אותו מפתח, אותו מבנה IV. מה שיצא משם הוא קובץ הרצה של 6.17 מגה-בייט שנכתב ב-Go, שכל תפקידו להוריד קובץ setup.exe מכתובת IP קבועה בפורט 700 ולהריץ אותו בשם svc_update.exe. SafeDep מדגישה שהיא לא אימתה שגם banner.jpg מגיש את אותו קובץ בדיוק.

השלב השלישי הוא אפליקציית Node.js 18 שנארזה עם pkg, 71.5 מגה-בייט, עם שם פנימי movinlike. הקוד שם הוא bytecode של V8, ולכן מה ש-SafeDep מתארת מבוסס על טבלאות המיתרים ועל סקריפטים מוטמעים, והיא אומרת במפורש שלא אימתה את זרימת ההרצה עצמה. מתוך מה שנמצא שם: חיפוש קבצים לפי שמות כמו seed, mnemonic, wallet ו-senha ולפי סיומות kdbx, pem, ppk ו-env, חילוץ סיסמאות ו-cookies מדפדפני Chromium, גניבת ה-token-ים של Discord יחד עם שינוי קובץ בתוך הלקוח עצמו, ארנקי קריפטו, ובנפרד גם חשבונות של Steam, Epic, Riot ו-Minecraft. יש שם גם ערוץ שליטה פתוח, WebSocket לאותה כתובת IP בפורט 7007, עם פקודות shell, העברת קבצים, צילומי מסך וקריאה מהלוח. השרידות אחרי אתחול נעשית דרך עותק בתיקיית AppData שמתחזה לעדכון של Edge ומשימה מתוזמנת בשם MicrosoftEdgeUpdateService.

פרט אחד שם מספר סיפור שלם. בתוך קובץ ה-env הארוז יושב מפתח רישוי בשם MALFEX-89BEE69BB61967AF1C3B07C9. כלומר סביר שמי שהפעיל את הקמפיין לא בנה את הנוזקה בעצמו, אלא קנה אותה או מוכר אותה לאחרים. הקמפיין הרחב, לפי Checkmarx, רץ מאז אוגוסט 2023: שתים עשרה חבילות של אותו מפרסם, שמונה מהן זדוניות, כ-40,767 הורדות בסך הכול ושלושה ענפים נפרדים. אחד מהם, לוֹדר של נוזקת שליטה מרחוק בשם Overlord, לא מחזיק בכלל כתובת שרת בתוך הקוד אלא קורא כתובות מוצפנות ששמורות בטרנזקציות על בלוקצ'יין Solana.

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

מונה ההורדות של npm סופר 979 הורדות של imgdecode בין 30 בספטמבר ל-12 באוקטובר. SafeDep מזהירה שלא כדאי לקרוא את המספר הזה כמספר מכונות נגועות, כי הוא לא מפריד בין הורדה של אדם לבין mirror או סורק אוטומטי, והיא לא מצאה אף פרויקט ציבורי שמצהיר על החבילה. ובכל זאת היא עדיין שם, ובלי רשומת OSV שתזהיר עליה, בעוד לחמש החבילות האחרות באותה שרשרת יש רשומה כזאת.

אם אתם מריצים מאגר פרוקסי, שלושת השמות שכדאי לחסום הם imgdecode, imgbundle ו-runhelper, גם אם שתיים מהן כבר ירדו מ-npm. הסימן שכדאי לחפש אצלכם הוא חבילה שמגיעה עם DLL מוכן בתיקיית prebuilds בלי שיש בה קוד מקור או binding.gyp שיסבירו מאיפה הוא נבנה. בצד הניטור יש שני דברים שלא אמורים לקרות: רכיב מהודר שמייבא WinINet ו-bcrypt אבל אין בו שום קוד שמפרסר תמונות, ו-node.exe שמופיע כתהליך האב של dllhost.exe. ומי שכבר הריץ אחת מהחבילות האלה על מכונת ווינדוס, הדבר הראשון הוא לנתק אותה מהרשת ולאסוף זיכרון לפני אתחול, כי הקוד הזדוני רץ שם בזיכרון ולא מהדיסק.