Frida in Race - UnCrackable Level 2

מאת שי מרדכי | 28 באפריל 2026 | Mobile Security Researcher | Reverse Engineer

במאמר הראשון בסדרה, דיברנו על פרדוקס האבטחה שבמנוע V8, שמסוגל להפוך מידע (Data) לקוד בר-ביצוע (Execution) בזמן ריצה.

אי שם בשנת 2010, מפתח בשם Ole André Vadla Ravnås נתקל בחומה. הוא חיפש דרך לנתח תוכנות בזמן ריצה, אך כפי שהעיד בעצמו: "לא היו כלים טובים במרחב הזה של Cross-platform Dynamic Binary Instrumentation". הכלים הישנים היו סטטיים, מסורבלים, ודרשו קימפול מחדש לכל שינוי קטן. מתוך הצורך הזה נולדה Frida - מערכת שמאפשרת להזריק קוד שמשנה את ה-Execution Flow של תוכנית בזמן אמת.

תוך כדי עבודה על אתגר UnCrackable Level 1 של OWASP, הרגיש לי ש-Frida לא מספיקה. חשבתי עליה כקופסה שחורה שלא מכירה את המשתנים המקומיים של הפונקציה, וקינאתי ב-QBDI שיודעת לעקוב אחרי כל פקודה שמגיעה ל-CPU. לאחר עבודה על ה-Level 2 והעמקה במכשולים שהוצבו שם, נדהמתי מול ה-Execution Flow של Java עצמה (כמעט נשרף לי המוח שם - בקטע טוב), ואיך Frida משתלבת בו בצורה כל כך אלגנטית.

איך האפליקציה "נולדת"?

בהרצת האפליקציה, Zygote (תהליך קדום של מערכת ההפעלה) משכפל את עצמו לתהליך חדש (Zygote Fork) שיש בו רק Thread אחד ראשי - ה-Main/UI Thread. סביבת ה-ART (Android Runtime) מאותחלת ב-Zygote, לכן ה-Zygote Fork יורש אותה כחלק ממרחב הזיכרון שלו.

בפועל, כל מה שאנחנו קוראים לו 'האפליקציה' יושב על לולאה אחת (Looper) שחיה בתוך ה-UI Thread ומנוהלת על ידי ActivityThread – וזה אומר שכל פעולה שלנו בסופו של דבר חוזרת לאותה נקודת תזמון אחת.

בינתיים, פרוסס אחר של מערכת ההפעלה (המכונה System Server), מתחיל Flow חדש כדי להריץ את ה-EntryPoint (בהתאם ל-AndroidManifest.xml שכתב המתכנת), ולאחר מכן שולח פקודות הרצה ל-Looper (כמו onCreate, onStart וכו') שיריץ את הפונקציות של האפליקציה. ה-UI Thread מריץ את הפקודות אחת אחת בעזרת המתווך שלו, ה-Looper.

הערה ממאמר 4: בדומה למודלים של IPC בין דפדפן ליישומים בלינוקס, גם כאן התקשורת לא מתבצעת ישירות, אלא דרך מנגנון IPC-Messaging בין ה-Zygote Fork ל-System Server. ב-Uncrackable Level 2 הבחנתי ב-Thread נוסף שהמתכנתים שתלו בקוד כדי לא להעמיס על ה-UI Thread, והוא נקרא Worker Thread.

המבט שלי מ-Level 1: Frida vs QBDI

רכיב ה-System Server מזכיר לי את Frida – שניהם פועלים כקופסה שחורה (Black Box) ויודעים רק על הפונקציה שיש ל-UI Thread בקוד האפליקציה, אבל לא יודעים מה קורה בפנים. אמנם QBDI (שקינאתי בו בעבר), יותר מזכיר לי את ה-UI Thread, כי שניהם עובדים כקופסה לבנה (White Box) ויודעים בדיוק מה קורה בכל צעד ובכל Instruction בקוד.

מה גיליתי על Frida ב-Level 2?

גיליתי שהמרחק בין Frida ל-QBDI אינו רחוק כל כך, וזה מה שהופך את Frida למהפכנית. Frida עדינה יותר: היא רק מזריקה פקודת קפיצה (Hook) לקוד משלה (קובץ .so שיש בו Native Functions) שמריץ את ה-Hook Script שלי ב-JavaScript, אך לאחר מכן מחזירה את המושכות לאפליקציה ונעלמת. ואילו QBDI ממש מכריח את האפליקציה להעביר דרכו כל פקודה, והוא מחליט מה לעשות איתה – האם להריץ פקודה אחרת או את הפקודה המקורית.

איך Frida עובדת? (מנגנוני הזרקה)

נפתח בשאלה שהעסיקה אותי: אם הסוכן של Frida (ה-Frida Agent) הוא סך הכל קובץ Native שהלינקר (Dynamic Linker) אמור לטעון, אז איפה מתרחשת הטעינה? הרי אין בקוד האפליקציה פקודת System.loadLibrary עבור libfrida-agent.so!

התשובה בקצרה – Frida "מרמה" את תהליך האפליקציה בעזרת Shellcode ממוקד, וגורמת לו להריץ פקודה ישירה ללינקר שיטען את הסוכן שלה. כך נוצר Frida Thread "יש מאין" שרץ במקביל ל-UI Thread, ואז כבר אין לה שום בעיה לדבר עם הלינקר שישלים את הטעינה. התהליך מתבצע בשלושה סגנונות תזמון:

1. הדרך הרגילה: Attach

במצב זה ההזרקה מתבצעת לאחר שרכיב ה-System Server כבר הורה ל-Looper של ה-UI Thread להריץ את onCreate וכנראה עוד פקודות מערכת. ל-Frida אין ברירה אלא להשתמש בהרשאות Root, לפנות לקרנל (Kernel), ולהורות ל-CPU: "עצור את ה-Threads של האפליקציה הספציפית הזו בלבד!" (תהליך ה-frida-server רץ כפרוסס נפרד ולכן אינו מושפע מכך).

המכשול ב-Level 2: המתכנתים חסמו את אפשרות ה-Attach על ידי העברת הגנות האבטחה לשכבת ה-Native. כבר בהתחלת הריצה, ה-Native Code נטען לקוד האפליקציה על ידי ה-Linker תוך שימוש בפונקציה המיוחדת JNI_OnLoad. ה-ART רואה שיש שם JNI_OnLoad ומריץ מיד את מה שנמצא בו – פקודת ptrace מנעתית (Anti-Debugging) שמונעת מה-Frida-Server להזריק Shellcode. מכיוון שה-ART הוא חלק מה-UI Thread, ה-ptrace הזה מתרחש בשלב מוקדם מאוד (עוד לפני שרכיב ה-System Server הספיק להגיד לLooper להריץ את הפונקציה הבאה). מעתה הקרנל לא מאפשר לאף תהליך לבצע ptrace, ו-Frida נחסמת.

שאלה ארכיטקטונית: האם JNI_OnLoad מתרחש אפילו לפני ActivityThread.main() שמריץ את ה-EntryPoint? תשובה: זה לא אפשרי. חייב שה-EntryPoint ירוץ תחילה, אחרת שום קוד Java לא יופעל, ובפרט לא פקודת System.loadLibrary שטוענת את רכיב ה-Native שבו נמצא ה-JNI_OnLoad.

2. הדרך השנייה: Spawn

כדי לנצח את ה-JNI_OnLoad שרץ במיקרו-שניות, עלינו להקדים את שלב ההזרקה. ב-Spawn מריצים את Frida יחד עם האפליקציה – מיד לאחר שנוצר ה-Zygote Fork, אך לפני שה-Instruction Pointer (ה-IP) הספיק להריץ פקודה אחת מהקוד המקורי. Frida מספיקה לתזמן זאת בכך שהיא מבקשת ממערכת ההפעלה לבצע את ה-Fork מה-Zygote ומיד להעביר את התהליך החדש למצב השהייה (Suspend). מאחר ושום קוד עדיין לא רץ, הדבר מייצר חלון הזדמנויות נקי להזרקת ה-Shellcode ללא הפרעה של מנגנוני ההגנה.

3. הדרך המתקדמת: Zygote Hook

טכניקה המשנה לחלוטין את חוקי המשחק. במקום להמתין ל-Fork, ה-Frida-Server מזריק את ה-Frida Agent ישירות לתוך תהליך ה-Zygote האב עצמו, עוד לפני שנולד ממנו תהליך אפליקציה כלשהו. בדרך זו, כל אפליקציה חדשה שעולה במכשיר "נולדת" כאשר ה-Agent של Frida כבר מוטמע ב-DNA שלה מראש. מכיוון שהזרקה זו משאירה עקבות במערכת (כמו שינוי של TracerPid בסטטוס הפרוסס), בתרחישים מסוימים נעדיף להשתמש בכלים מודרניים כמו Zygisk לצורך ההזרקה התשתיתית.

סיכום

Frida משתמשת בטכניקות מתקדמות של הזרקת זיכרון (Memory Injection) כדי לגרום לתהליך האפליקציה – בכל אחד משלבי חייה השונים – לקרוא באופן עצמאי ל-Dynamic Linker ולטעון את ה-Agent שלה, מה שמאפשר שליטה מלאה בריצה מבלי לפגוע ביציבות התהליך.