← חזרה לבלוג

🚧 מחקר בתהליך

המאמר חשוף כעת לקריאה, אך המחקר עדיין מתבצע ונכתב בימים אלו.

בס"ד

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.

🚧 תחת עריכה 🚧 המשך המחקר (ניסוי 2 ואילך) טרם פורסם במלואו ועדיין בכתיבה.

ניסוי 2: נתיב ה-Native Binder

המעקף (Direct SurfaceFlinger IPC): החלטנו לא לדבר עם ה-WMS בכלל. ה-WMS הוא "פקיד" שבודק דרכונים (Window Tokens). במקום זאת, ה-PoC שלנו משתמש ב-Native Binder כדי לייצר טרנזקציה שמכוונת ישירות ל-Handle של SurfaceFlinger (דרך הממשק android.gui.ISurfaceComposer). המטרה בשלב זה היא לבקש מ-SurfaceFlinger שיקצה לנו BufferQueue לשפוך אליו פיקסלים, תוך דילוג מוחלט על ה-WMS.

חייבים IPC מתווך, כי האפליקציה יושבת ב-UID משלה, וכנ"ל ה-SYSTEM SERVER מן הסתם. דרך ה-NATIVE יש יותר מורכבות ואפשר לשלוט במידע ששולחים. האינטראקציה מול הדרייבר מבוססת על שלוש פעולות ליבה: פתיחת הדרייבר (open), הקצאת זיכרון משותף (mmap) לצורך ארכיטקטורת ה-Single-Copy, ושימוש בפקודת ioctl כדי להעביר את מבנה הנתונים binder_write_read אל הקרנל.

שאלה: הבנו שכאשר 2 תהליכים חולקים את אותו מרחב זכרון (כמו למשל Dynamic Linker והאפליקציה), אז הם לא צריכים לתקשר דרך Binder. אם כך, כשמקצים זכרון משותף בעזרת mmap, מדוע צריך גם את ה-Binder? (Why does Android mmap need Binder?)

אין בעיה, נניח ויש רק זיכרון משותף בין התהליכים:
צריך שהקרנל יפקח על המידע שמועבר, כדי שהאפליקציה לא תשלח קוד זדוני ל-WMS למשל. צריך למנוע התנגשות תהליכונים – צריך Semaphore. צריך שמישהו "יצנתק" לתהליך שממתין לתשובה, כדי שהתהליך יהיה א-סינכרוני.
זה בדיוק Binder. את כל הדברים האלה, עושה הקרנל.

אז השאלה הבאה שנשאל היא: אם יש mmap ו-binder, למה צריך ioctl?
והתשובה תהיה ש-mmap הוא לקריאה בלבד וצריך מבנה שיאפשר קריאה וכתיבה. זה בדיוק ה-semaphore. ה-ioctl אחראי לנעול את הטרנזקציה (תור ה-binder). הקרנל מוודא שאף תהליך אחר לא נוגע בבאפר של תהליך היעד באותו שבריר שנייה. אז יש לנו כאן תבנית עיצוב Observer מושלמת (בהשאלה מעולם ה-UI).

בנוסף, כדי לדבר ישירות עם האחראי לרנדור המסך - SurfaceFlinger, צריך לשלוח בקשה תקינה בתוך ה-Parcel buffer. במקום ש-JAVA יבקשו מ-WMS שיבקש מ-SurfaceFlinger, נבקש ישירות ממנו דרך ה-BINDER שיצרנו. כדי לדבר איתו ישירות, צריך לתת לו Buffer Queue שה-UI Thread שלי מאזין אליו, כדי לקבל את רנדור המסך הכבד, ולא ב-BINDER שיעמיס על ניהולו בקרנל.
להגיד ל-SurfaceFlinger מהו מיקום החלון שאני רוצה שיפתח - זו חבילת טרנזקציה SurfaceControl / ASurfaceTransaction.

כעת נעבור לבניית קוד ה-Native:

השיטה - לכתוב סקריפט NATIVE ב-FRIDA שיסניף איך נראית פקודת טרנזקציה על ה-BINDER, בשלב הלגיטימי יותר, ה-ACTIVITY BOOTSTRAP, ששם כבר יש WINDOW TOKEN ו-WMS בכיף ישלח עבורנו את הטרנזקציה ל-SURFACEFLINGER.

איך ממשיכים מכאן (Action Plan)?

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

  1. שלב הדיג (Discovery):
    • ודא שבקובץ ה-ipc_sniffer.js מוגדר TARGET_HANDLE = -1.
    • הרץ את סקריפט ה-Python (ההוסט).
    • המתן עד שתקפוץ לך ההדפסה האדומה במסוף: [!!!] DISCOVERY: SurfaceFlinger Interface found on Handle X [!!!].
  2. שלב הסינון והתפיסה (Golden Template):
    • סגור את הסקריפט (Ctrl+C).
    • עדכן את קובץ ה-JS שלך עם ה-Handle שמצאת (לדוגמה: const TARGET_HANDLE = 2;).
    • הפעל מחדש את סקריפט ה-Python.
    • כעת, פתח את האפליקציה שלך (או כל אפליקציה אחרת) כדי לאלץ את שלב ה-Activity Bootstrap וקריאת ה-WindowManager.addView.
    • תראה את פקודות ה-CREATE_CONNECTION נשלחות, ולאחריהן תקבל את זרם ה-Hex של הקצאת החלון בפועל.
  3. שלב הפריצה (Bypass WMS):
    ברגע שיהיה לך את ה-Hexdump של טרנזקציית יצירת החלון, תוכל להפסיק להאזין. השלב הבא במחקר שלך יהיה לקחת את ה"תבנית הבינארית" הזו, ולכתוב קוד צד-לקוח (למשל סקריפט C/C++ קטן שרץ מתוך ה-Shell) שיזריק בדיוק את הנתונים האלו ישירות ל-/dev/binder.
    פעולה זו תדלג לחלוטין על בדיקות ההרשאות הלוגיות של ה-Window Manager Service (כגון חובת נוכחות Window Token), ותאלץ את SurfaceFlinger להקצות לך שכבה גרפית חוקית ולצייר אותה למסך, ובכך תוכיח את קיומו של "פרדוקס ההרשאות" בפלטפורמה!

פרק ד' – ניתוח סטטי של ה-Payload (Static Analysis)

XXXXXXXXXxx

פרק ה' – אכיפת SELinux (SELinux Enforcement)

התנגשות מול חומת המציאות (The Enforcement): כאן מגיע ה-Climax של המחקר הנוכחי. מה קורה כש-SurfaceFlinger מקבל את הפנייה הישירה שלנו? בשלב הזה אתה חושף את תוצאות ה-PoC:

האם SurfaceFlinger זרק אותנו כי חסרה הרשאת SELinux ספציפית (android.permission.ACCESS_SURFACE_FLINGER)?
או שאולי SurfaceFlinger עצמו ביצע חזרה למעלה (Call-back) כדי לוודא מול ה-WMS אם יש לנו Token תקין ל-Layer המבוקש?

זהו "פרדוקס ההרשאות" בהתגלמותו: מצאת את הצינור הישיר למנוע הגרפי, אבל מודל האבטחה של אנדרואיד עטף גם אותו בחומות לוגיות (SELinux / Token validation).