בס"ד
The Permission Paradox
מאת שי מרדכי
תקציר מנהלים
במאמר הקודם (Android Bootstrap Hijacking), ניתחנו כיצד נוזקות כמו Anatsa מצליחות להריץ קוד בשלב מוקדם מאוד במרחב התהליך (attachBaseContext ו-ContentProvider.onCreate), עוד לפני שה-UI Thread הקצה חלון גרפי (Graphical window) כלשהו. ראינו כיצד היא כבר "חיה" בזיכרון ומחזיקה בתהליך משלה (UI Thread), וגילינו שחוקרי אבטחה ונוזקות לא חייבים להמתין ל-Entry Point הקלאסי של Activity.onCreate.
למעשה, ניתן להזריק ולהריץ קוד בשלבים מוקדמים בהרבה:
Application.attachBaseContext()ContentProvider.onCreate()Application.onCreate()
בכל התחנות הללו, לאפליקציה כבר יש תהליך ויש זיכרון, אך עבור המשתמש – המסך נותר שחור לחלוטין. זה עורר בי שאלה: האם ניתן להריץ חלון UI בשלב הזה, כשעדיין ה-UI לא עלה?
כדי לגשת לשאלה, אנחנו צריכים להבין טוב יותר מה גורם לחלון UI לעלות, או יותר נכון, מה קורה מאחורי הקלעים כאשר מתכנת מבקש להריץ UI דרך JAVA API. במאמר זה נחקור את תהליך יצירת חלון UI, דרך משקפיים של JAVA API ודרך משקפיים של Native, וננסה "לפרוץ את הגבולות".
רקע ומושגים
תהליך יצירת חלון UI באפליקציית אנדרואיד:
במאמר הקודם, סקרנו את שלבים א' - ה' של הרצת האפליקציה באנדרואיד, החל מ-Zygote Fork וכלה ביצירת Activity, אך לעת עתה הוא עדיין ריק מ-UI.
נמשיך בשלב ו' - האתחול הלוגי והטעינה ל-RAM:
שלב ו' - חלק א' (המערכת):
זהו השלב הבירוקרטי. ה-OS מבצעת רישום:
- Activity Token - איזו אפליקציה מנסה להציג חלון.
- Window Token - האם היא מורשית לכך.
- PhoneWindow - איזה סוג חלון זה.
רגע לפני שלב ו', AMS לוקח "מזוודה" מ-PMS. המזוודה הזו הכילה את כל מאפייני ה-UI ש-PMS אסף בשלב ה-BOOT מה-Manifest של האפליקציה, כמו שראינו במאמר הקודם. כרגע הם עדיין ב-RAM.
setContentView - מהו התוכן הלוגי שהיא רוצה להציג:
הפקודה ליצירת חלון: setContentView. המתכנת חייב להשתמש ב-Content כדי לתקשר עם System Server שיאפשר לו יצירת חלון מבעד ל-Sandbox. ה-XML הופך לעץ אובייקטים בזיכרון (DecorView) ונשמר בתוך ה-PhoneWindow.
כמו כל ישראלי טוב, שאלתי "איך אפשר לעקוף את הבירוקרטיה הזו?".
ActivityThread, ה"סוכן" של System Server בתוך האפליקציה, מתחיל להזרים נתונים לוגיים שיאפשרו יצירת חלון. הוא מתחיל ביצירת אובייקט ה-Activity (כמו שכבר ראינו), וכעת מקצה לו PhoneWindow (חלון לוגי ריק) ו-Activity Token (הדרכון).
הערה: מכאן אפשר להבין את מטרת יצירת אובייקט ה-Activity. כדי שיהיה רישום של האפליקציה ב-OS וכל מה שהיא עושה יהיה מתועד. שכל דבר שהאפליקציה רוצה לעשות, יתבצע דרך
ActivityThreadש"ידליף" ל-SYSTEM SERVER.
שלב ו' - חלק ב' (המתכנת) Activity.onCreate:
כמו שראינו במאמר הקודם, שהנוזקה מימשה את Application.onCreate כדי להריץ קוד מוקדם, כך מתכנת האפליקציה מריץ קוד בפעם הראשונה ע"י מימוש של Activity.onCreate.
הוא משתמש באובייקט Context (שנועד לאפשר גישה מבוקרת דרך ה-SANDBOX של האפליקציה לרכיבי החומרה ותקשורת בין תהליכים אחרים), כדי לגשת לקובץ: the developer uses setContentView(R.layout.main) to read the Layout XML (the UI file containing buttons, text, etc.). The API turns this UI Layout XML into a memory object called DecorView.
שלב ו' - חלק ג' (המערכת) Activity.onStart:
המערכת מריצה את onStart. ה-AMS מסמן שהחלון מוגדר לוגית כראוי, אבל המסך עדיין שחור. זה השלב שבו ActivityThread מדליף באמצעות Callback ל-AMS דרך BINDER.
שלב ז' (ההרמה לחזית - Activity.onResume):
ה-AMS שולח הודעה ל-ActivityThread להריץ את handleResumeActivity, שמפעילה את Activity.onResume. האפליקציה מוכנה לעלות לחזית (Foreground).
מיד לאחר מכן, מתרחשת הפנייה הראשונה לניהול התצוגה. האפליקציה לוקחת את ה"מזוודה" משלב ו' - DecorView, מצרפת לו Window Token ושולחת אותו מה-RAM דרך גבולות ה-Sandbox אל עבר ה-WMS כדי לדרוש ייצוג גרפי פיזי על המסך.
שלב ח' — האפליקציה דורשת ייצוג גרפי:
בתוך handleResumeActivity, מתרחשת הפנייה הראשונה לניהול התצוגה. האפליקציה לוקחת את ה-DecorView (עץ ה-UI שנבנה בזיכרון בשלב ו') וקוראת:
WindowManager.addView(decorView, params)
זהו הרגע שבו האפליקציה הלוגית דורשת ייצוג גרפי פיזי — ובדיוק כאן שאלת המחקר שלנו מתחדדת: מה יקרה אם נקרא ל-addView מוקדם יותר, עוד בשלב ה-Bootstrap?
מיד בתוך handleResumeActivity (שלב ח'), המערכת קוראת אוטומטית ל-WindowManager.addView(). כאן נוצר ה-ViewRootImpl (שלב ט') שחוצה את הגבול (שלב י').
שלב ט' — ViewRootImpl: הגשר הסמוי:
הקריאה ל-addView מייצרת אובייקט שרוב המפתחים לא מכירים: ViewRootImpl. הוא הגשר הבלעדי בין ה-UI Thread של האפליקציה לבין שירותי המערכת.
שלב י' — חציית הגבול אל ה-System Server:
ה-ViewRootImpl פותח ערוץ Binder דרך IWindowSession אל ה-WMS. הוא שולח:
"הקצה לי חלון חוקי (Window Token) ומשטח ציור."
וכאן — ה-WMS עושה בדיוק את מה שנלמד עליו בפרק הבא.
הערה: מתי בדיוק החלון נוצר (הטיימינג של ה-Hook)?
ציינת את שלב ה-Activity.onCreate(). חשוב להבין שמבחינה ארכיטקטונית, ב-onCreate האפליקציה אמנם קוראת ל-setContentView ובונה את עץ התצוגה הלוגי בזיכרון (ה-DecorView ו-PhoneWindow), אך היא עדיין לא מבקשת מ-SurfaceFlinger לצייר אותו.
הפנייה האמיתית (שמייצרת את טרנזקציית ה-Binder שאנחנו רוצים לתפוס) קורית קצת לאחר מכן: לאחר שהאפליקציה עוברת את onStart ו-onResume, המערכת קוראת לפונקציהViewRootImpl#performTraversals. פונקציה זו מפעילה אתViewRootImpl#relayoutWindow, ורק בשלב הזה נשלחת בקשה אל ה-WindowManagerService (WMS), אשר פונה ל-SurfaceFlinger כדי להקצות את אובייקט ה-SurfaceControl (השכבה הגרפית הפיזית). לכן, אינך צריך לכתוב קוד מיוחד ב-onCreate; עצם ההפעלה הרגילה של האפליקציה תייצר את הטרנזקציות בדיוק ברגע הנכון.
מושגים
1. Android Sandbox: הצורך בתקשורת (IPC)
באנדרואיד, כל אפליקציה פועלת בתוך Sandbox משלה. מנגנון זה, המיושם על ידי ה-Zygote מיד לאחר ה-fork(), מבודד את האפליקציה מתהליכים אחרים באמצעות מזהים ייחודיים (UID/GID) וחוקי הרשאות קשוחים (SELinux).
מנגד, ניהול המסך והחלונות במכשיר מתבצע באופן בלעדי על ידי ה-WindowManagerService (WMS), שירות מערכת בעל הרשאות גבוהות היושב בתוך תהליך ה-system_server. מכיוון שהאפליקציה חסומה בתוך ה-Sandbox שלה, היא אינה יכולה לייצר חלונות בעצמה. עליה לתקשר עם ה-WMS ולבקש ממנו לעשות זאת עבורה. תקשורת זו בין תהליכים מבודדים מחייבת מנגנון Inter-Process Communication (IPC).
2. Binder IPC: הצינור אל המערכת (Java vs. Native)
ה-Binder הוא מנגנון ה-IPC הראשי והייחודי של אנדרואיד, המנוהל על ידי דרייבר ליבתי (/dev/binder). כדי להבין מדוע אנדרואיד משתמשת בו ולא בחלופות הסטנדרטיות של לינוקס, יש להסתכל על מנגנון העברת הנתונים:
| מנגנון תקשורת | העתקות זיכרון | שימוש באנדרואיד |
|---|---|---|
| Local Socket | 2 (שולח ← קרנל ← מקבל) | Zygote ↔ System Server בלבד |
| Loopback Socket | 2 + Network Stack Overhead | תאימות לאחור |
| Binder IPC | 1 (Single-Copy via mmap) | כל תקשורת אפליקציה ↔ מערכת |
מדוע ה-Zygote חריג ומשתמש ב-Socket?
הסיבה נעוצה באילוץ קרנל: פעולת fork() משכפלת רק את ה-Thread שקרא לה — שאר ה-Threads של ה-Binder Thread Pool ממרחב הזיכרון של האב "נעלמים". מבני הנתונים של ה-Binder נשארים רשומים תחת ה-PID של ה-Zygote האב, מה שיגרום ל-State Inconsistency ו-Deadlock בתהליך הבן. Local Socket, לעומת זאת, הוא צינור פסיבי שאפשר לסגור בקריאת close() אחת נקייה מיד לאחר הפיצול.
מנגנון ה-Single-Copy: הטוויסט של אנדרואיד על mmap
באופן מסורתי, הפקודה mmap נלמדת כפתרון קסם למערכות עם זיכרון RAM מוגבל. במקום לטעון קובץ ענק לתוך הזיכרון בבת אחת באמצעות read() (פעולה שעלולה לחנוק את המערכת), mmap מסתפקת ברישום הכתובות בזיכרון הווירטואלי. מנהל הזיכרון (MMU) דואג לטעון פיזית (Demand Paging) רק את חלקי הקובץ שבאמת ניגשים אליהם באותו רגע. זה הופך את mmap לקלאסיקה אקדמית של ניהול זיכרון יעיל.
אך כשצוללים לקרביים של אנדרואיד, מגלים שגוגל לקחה את אותו כלי בדיוק והשתמשה בו למטרה ארכיטקטונית שונה לחלוטין. אנדרואיד משתמשת ב-mmap לא כדי לקרוא קבצי ענק מהדיסק, אלא כדי לפתור את בעיית איטיות התקשורת בין תהליכים (IPC). במקום למפות קובץ דיסק סטטי, הדרייבר של Binder מחייב כל תהליך אנדרואיד (לרבות ה-System Server ו-SurfaceFlinger) לקרוא ל-mmap מיד עם עלייתו. פעולה זו ממפה מראש חוצץ זיכרון (Buffer) של כ-1MB מתוך ה-Userspace של התהליך, ישירות אל תוך מרחב הזיכרון של הקרנל.
הארכיטקטורה הזו היא הבסיס למנגנון ה-Single-Copy המפורסם. בניגוד לתקשורת מבוססת Socket שבה הקרנל פסיבי והמידע מועתק פעמיים (מהשולח לקרנל, ומהקרנל למקבל), ב-Binder הקרנל הוא שחקן פעיל. הוא מבצע פעולת copy_from_user אחת בלבד — ישירות ממרחב השולח לתוך זיכרון ה-mmap הפיזי של תהליך היעד, ללא תיווך כפול. מכיוון שהזיכרון הזה ממופה לקריאה בלבד (PROT_READ) עבור התהליך המקבל, כל מידע שנוחת שם נחשב ל"אמת מוחלטת" שרק הקרנל אישר לכתוב.
המידע המועבר נארז לתוך מיכל נתונים בינארי הנקרא Parcel. כדי לשלוח את המידע הזה, קיימות שתי גישות מרכזיות לתקשורת מול ה-Binder:
- דרך ה-Java API: שכבת קוד עליונה שעוטפת את ה-Parcel ומסתירה את המורכבות. היתרון הוא נוחות ומהירות פיתוח, אך המחיר הוא אובדן גמישות ומניעת שליטה מלאה על מבנה הנתונים המדויק שנשלח.
- דרך ה-Native (C/C++): ירידה לשכבת הנייטיב (
libbinder.so) מאפשרת לעקוף את החסמים הלוגיים של ה-Java. ברמה זו, ניתן להרכיב את ה-Parcel באופן ידני ומוחלט (Byte-by-Byte) ולשלוח אותו ישירות לקרנל באמצעות קריאתioctl(). גישה זו מעניקה כוח אבסולוטי בתקשורת מול שירותי המערכת ופותחת את הדלת למניפולציות מתקדמות ועקיפת שומרי סף.
3. Window Token: חומת ההרשאות
כאשר בקשה מגיעה מה-Binder אל ה-WMS, השירות חייב לוודא שהבקשה חוקית ומוסמכת. לשם כך, אנדרואיד משתמשת במנגנון הנקרא Window Token. זהו אובייקט ומזהה פנימי המייצג חלון UI חוקי, המשויך בדרך כלל למחזור החיים של Activity.
כאשר אפליקציה מבקשת לצייר חלון חדש, היא חייבת לצרף לתוך ה-Parcel את ה-Window Token שלה. ה-WMS מחלץ את ה-Token ובודק את תקינותו. בשלבי הריצה המוקדמים של האפליקציה (כדוגמת attachBaseContext), ה-Token הזה טרם נוצר והוקצה לתהליך. כתוצאה מכך, כל ניסיון לגיטימי להציג חלון בשלב זה יידחה מיד על ידי ה-WMS.
ניסוי 1: מחסום ה-Window Token בשכבת ה-Java
בצד ה-Java API, כשתהליך רוצה לתקשר עם תהליכי החומרה - הדרייברים, הוא עושה זאת בעזרת אובייקט Context.
בשילוב ה-Context הבסיסי שהתהליך מחזיק כרגע, הוא עדיין טרם קיבל את אובייקטי ה-Lifecycle וה-Window Tokens הנדרשים לצורך הקצאת חלונות גרפיים דרך שכבת ה-Framework.
הרשאת SYSTEM_ALERT_WINDOW (informally "Draw on Top"): מאפשרת לאפליקציה לצייר שכבות (Overlays) מותאמות אישית מעל כל מסך אחר במכשיר. השכבות יכולות להיות שקופות, clickable (קולטות לחיצות) או pass-through (הלחיצות עוברות דרכן אל האפליקציה שמתחת). עבור אפליקציות שיורדות מה-Google Play Store, הרשאה זו מוענקת אוטומטית (Automatically Granted) בלי שהמשתמש מתבקש לאשר אותה בזמן אמת.
כאשר מפתח מבקש להציג חלון צף (Overlay), הוא משתמש בממשק: WindowManager.addView(). מאחורי הקלעים, קוד ה-AIDL של ה-Framework אורז את הבקשה לתוך ה-Parcel, ומעביר אותה דרך ה-Binder IPC אל ה-WMS.
לפני אישור הבקשה, ה-WMS מבצע אימות ומחפש בתוך ה-Parcel מזהה ייחודי שנקרא Window Token, המוכיח את מחזור החיים הלגיטימי של האפליקציה. בשלב ה-Bootstrap המוקדם (ה-Process Entry Point), ה-Token הזה אינו קיים.
תוצאה: כל ניסיון לקרוא ל-addView מתוך ה-Java בשלב זה, נחסם ע"י ה-WMS, והמערכת מניבה שגיאה:
E AndroidRuntime: Process: com.shay.permissionparadox, PID: 7041
E AndroidRuntime: java.lang.RuntimeException: Unable to instantiate application com.shay.permissionparadox.MyStubApplication package com.shay.permissionparadox: android.view.WindowManager$BadTokenException: Unable to add window -- token null is not valid; is your activity running?
E AndroidRuntime: at com.shay.permissionparadox.MyStubApplication.attachBaseContext(MyStubApplication.java:29)
מסקנה: מכיוון שהתהליך הנייטיבי חי ויורש File Descriptor פתוח לדרייבר ה-Binder, אנו מסיקים כי החסם הוא לוגי בשכבת ה-JVM.