תחילת העבודה עם Sandbox2

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

1. בחירת שיטת ביצוע של Sandbox

Sandboxing מתחיל עם מנהל (ראה Sandbox Executor), שאחראי להפעלת Sandboxee. קובץ הכותרת executor.h מכיל את ה-API שנדרש למטרה זו. ה-API גמיש מאוד ומאפשר לכם לבחור מה הכי מתאים לתרחיש לדוגמה שלכם. בקטעים הבאים מתוארות 3 השיטות השונות שמהן אפשר לבחור.

שיטה 1: עצמאי – ביצוע קובץ בינארי כשהרצה בארגז חול כבר מופעלת

זוהי הדרך הפשוטה ביותר להשתמש בהרצה בארגז חול (sandboxing) והיא השיטה המומלצת כשרוצים לבצע הרצה בארגז חול (sandbox) של קובץ בינארי מלא שאין לכם קוד מקור עבורו. זו גם הדרך הבטוחה ביותר להשתמש בארגז החול, כי אין אתחול שלא הופעל בארגז חול שעלול להשפיע לרעה.

בקטע הקוד הבא, אנו מגדירים את הנתיב של הקובץ הבינארי להרצה בארגז החול ואת הארגומנטים שעלינו להעביר ל-syscall של הפעלה. כפי שניתן לראות בקובץ הכותרת executor.h, אנחנו לא מציינים ערך עבור envp ולכן מעתיקים את הסביבה מתהליך ההורה. חשוב לזכור שהארגומנט הראשון הוא תמיד שם התוכנית שרוצים לבצע, וקטע הקוד שלנו לא מגדיר ארגומנט אחר.

דוגמאות לשיטת ההרצה הזו: static ו-tool.

#include "sandboxed_api/sandbox2/executor.h"

std::string path = "path/to/binary";
std::vector<std::string> args = {path};  // args[0] will become the sandboxed
                                         // process' argv[0], typically the
                                         // path to the binary.
auto executor = absl::make_unique<sandbox2::Executor>(path, args);

שיטה 2: Sandbox2 Forkserver – אומרים למנהל המערכת מתי לעבור לארגז החול

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

בקטע הקוד הבא, אנו משתמשים באותו הקוד שמתואר בשיטה 1 למעלה. עם זאת, כדי לאפשר לתוכנה לפעול באופן שלא פועל בארגז החול במהלך האתחול, אנחנו קוראים ל-set_enable_sandbox_before_exec(false).

#include "sandboxed_api/sandbox2/executor.h"

std::string path = "path/to/binary";
std::vector<std::string> args = {path};
auto executor = absl::make_unique<sandbox2::Executor>(path, args);
executor->set_enable_sandbox_before_exec(false);

מכיוון שלחשבון ההפעלה יש עכשיו Sandbox מושבת עד שהוא יקבל הודעה מה-Sandboxee, עלינו ליצור מופע ::sandbox2::Client, להגדיר תקשורת בין המפעיל לבין Sandboxee, ואז להודיע למנהל המערכת שהאתחול שלנו הסתיים ושאנחנו רוצים להתחיל את הרצה בארגז חול עכשיו על ידי קריאה ל-sandbox2_client.SandboxMeHere().

// main() of sandboxee
int main(int argc, char** argv) {
  gflags::ParseCommandLineFlags(&argc, &argv, false);

  // Set-up the sandbox2::Client object, using a file descriptor (1023).
  sandbox2::Comms comms(sandbox2::Comms::kSandbox2ClientCommsFD);
  sandbox2::Client sandbox2_client(&comms);
  // Enable sandboxing from here.
  sandbox2_client.SandboxMeHere();
  …

דוגמה לשיטת ההרצה הזו היא crc4, כאשר crc4bin.cc הוא ה-Sandboxee ומודיע למנהל (crc4sandbox.cc) מתי עליו להיכנס ל-Sandbox.

שיטה 3: שרת Forkserver בהתאמה אישית – הכנת קובץ בינארי, המתנה לבקשות מזלג וארגז חול בעצמכם

מצב זה מאפשר לך להפעיל קובץ בינארי, להכין אותו להרצה בארגז חול (sandboxing) ולהפוך אותו לזמין למפעיל ברגע מסוים במחזור החיים של הקובץ הבינארי.

מנהל ההפעלה ישלח בקשת מזלג לקובץ הבינארי שלך, fork() (באמצעות ::sandbox2::ForkingClient::WaitAndFork()). התהליך החדש שייווצר יהיה מוכן להרצה בארגז חול (sandbox) עם ::sandbox2::Client::SandboxMeHere().

#include "sandboxed_api/sandbox2/executor.h"

// Start the custom ForkServer
std::string path = "path/to/binary";
std::vector<std::string> args = {path};
auto fork_executor = absl::make_unique<sandbox2::Executor>(path, args);
fork_executor->StartForkServer();

// Initialize Executor with Comms channel to the ForkServer
auto executor = absl::make_unique<sandbox2::Executor>(
    fork_executor->ipc()->GetComms());

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

דוגמה לשיטת ההרצה הזו זמינה בקטע custom_fork.

2. יצירת מדיניות Sandbox

לאחר שיש לך מנהל מערכת, סביר להניח שתרצה להגדיר מדיניות ארגז חול עבור Sandboxee. בכל מקרה אחר, Sandboxee מוגן רק על ידי מדיניות ברירת המחדל של Syscall.

בעזרת מדיניות Sandbox, המטרה היא להגביל את ה-syscall והארגומנטים ש-Sandboxee יכול ליצור, וכן את הקבצים שאליהם יש לו גישה. יהיה עליך להבין לעומק את ה-syscalls שנדרשים על ידי הקוד שאתם מתכננים להפעיל ב-Sandbox. אחת הדרכים לצפייה ב-syscalls היא הרצת הקוד באמצעות הרצועה של כלי שורת הפקודה של Linux.

אחרי שיוצרים את הרשימה של ה-syscalls, אפשר להשתמש ב-PolicyBuilder כדי להגדיר את המדיניות. PolicyBuilder כוללות הרבה פונקציות עזר ותכונות שימושיות שמאפשרות לבצע הרבה פעולות נפוצות. הרשימה הבאה היא רק חלק קטן מהפונקציות הזמינות:

  • רשימת היתרים של כל קריאה למערכת הפעלה של התהליך:
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • יש להוסיף לרשימת ההיתרים כל מערכת הפעלה/קריאה/כתיבה*:
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • רשימת היתרים של שיחות מערכת שקשורות ליציאה/גישה/למצב:
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • רשימת ההיתרים של שיחות מערכת שקשורות לשינה או לזמן:
    • AllowTime();
    • AllowSleep();

פונקציות הנוחות האלה מוסיפים לרשימת ההיתרים כל מערכת הפעלה רלוונטית. היתרון בכך הוא שניתן להשתמש באותה מדיניות בארכיטקטורות שונות שבהן קריאות למערכות (syscalls) מסוימות אינן זמינות (למשל, ל-ARM64 אין פונקציית syscall פתוחה), אך עם סיכון אבטחה קל להפעלת יותר Syscalls מהנדרש. לדוגמה, AllowOpen() מאפשר ל-Sandboxee לקרוא לכל מערכת הפעלה קשורה פתוחה. אם רוצים להוסיף לרשימת ההיתרים רק שיחה אחת ספציפית, אפשר להשתמש ב-AllowSyscall(); כדי לאשר כמה שיחות מערכת הפעלה בו-זמנית. ניתן להשתמש ב-AllowSyscalls().

עד עכשיו המדיניות בודקת רק את מזהה ה-Syscall. אם צריך לחזק עוד יותר את המדיניות, ואתם רוצים להגדיר מדיניות שבה אפשר להפעיל מערכת הפעלה רק עם ארגומנטים מסוימים, צריך להשתמש בפונקציה AddPolicyOnSyscall() או ב-AddPolicyOnSyscalls(). לא רק פונקציות אלו מקבלות את מזהה syscall כארגומנט, אלא גם מסנן גולמי seccomp-bpf באמצעות פקודות המאקרו bpf helper מליבת Linux. מידע נוסף על BPF זמין בתיעוד הליבה. אם נתקלתם בכתיבת קוד BPF שחוזר על עצמו, שלדעתכם צריך לכלול wrapper של נוחות שימוש, אתם יכולים להגיש בקשה להוספת תכונה.

מלבד פונקציות שקשורות ל-Syscall, PolicyBuilder מספק גם מספר פונקציות שקשורות למערכת קבצים כמו AddFile() או AddDirectory(), כדי לאגד קובץ או ספרייה בארגז החול. ניתן להשתמש בכלי העזר AddTmpfs() כדי להוסיף אחסון זמני של קבצים בתוך ארגז החול.

הפונקציה השימושית במיוחד היא AddLibrariesForBinary(), שמוסיפה את הספריות ואת המנגנון שנדרשים על ידי קובץ בינארי.

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

#include "sandboxed_api/sandbox2/policy.h"
#include "sandboxed_api/sandbox2/policybuilder.h"
#include "sandboxed_api/sandbox2/util/bpf_helper.h"

std::unique_ptr<sandbox2::Policy> CreatePolicy() {
  return sandbox2::PolicyBuilder()
    .AllowSyscall(__NR_read)  // See also AllowRead()
    .AllowTime()              // Allow time, gettimeofday and clock_gettime
    .AddPolicyOnSyscall(__NR_write, {
        ARG(0),        // fd is the first argument of write (argument #0)
        JEQ(1, ALLOW), // allow write only on fd 1
        KILL,          // kill if not fd 1
    })
    .AddPolicyOnSyscall(__NR_mprotect, {
        ARG_32(2), // prot is a 32-bit wide argument, so it's OK to use *_32
                   // macro here
        JNE32(PROT_READ | PROT_WRITE, KILL), // prot must be the RW, otherwise
                                             // kill the process
        ARG(1), // len is a 64-bit argument
        JNE(0x1000, KILL),  // Allow single page syscalls only, otherwise kill
                            // the process
        ALLOW,              // Allow for the syscall to proceed, if prot and
                            // size match
    })
    // Allow the openat() syscall but always return "not found".
    .BlockSyscallWithErrno(__NR_openat, ENOENT)
    .BuildOrDie();
}

3. התאמת המגבלות

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

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

קטע הקוד הבא מציג כמה דוגמאות להתאמות של מגבלות. כל האפשרויות הזמינות מתועדות בקובץ הכותרת limits.h.

// Restrict the address space size of the sandboxee to 4 GiB.
executor->limits()->set_rlimit_as(4ULL << 30);
// Kill sandboxee with SIGXFSZ if it writes more than 1 GiB to the filesystem.
executor->limits()->set_rlimit_fsize(1ULL << 30);
// Number of file descriptors which can be used by the sandboxee.
executor->limits()->set_rlimit_nofile(1ULL << 10);
// The sandboxee is not allowed to create core files.
executor->limits()->set_rlimit_core(0);
// Maximum 300s of real CPU time.
executor->limits()->set_rlimit_cpu(300);
// Maximum 120s of wall time.
executor->limits()->set_walltime_limit(absl::Seconds(120));

דוגמה לשימוש במחלקה sandbox2::Limits זמינה בכלי לדוגמה.

4. הפעלת ארגז החול

בקטעים הקודמים, הכנתם את הסביבה, המדיניות, המפעיל וה-Sandboxee בארגז החול. השלב הבא הוא יצירת האובייקט Sandbox2 והרצתו.

הרצה באופן סינכרוני

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

#include "sandboxed_api/sandbox2/sandbox2.h"

sandbox2::Sandbox2 s2(std::move(executor), std::move(policy));
sandbox2::Result result = s2.Run();  // Synchronous
LOG(INFO) << "Result of sandbox execution: " << result.ToString();

הפעלה באופן אסינכרוני

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

#include "sandboxed_api/sandbox2/sandbox2.h"

sandbox2::Sandbox2 s2(std::move(executor), std::move(policy));
if (s2.RunAsync()) {
  // Communicate with sandboxee, use s2.Kill() to kill it if needed
  // ...
}
Sandbox2::Result result = s2.AwaitResult();
LOG(INFO) << "Final execution status: " << result.ToString();

5. תקשורת עם Sandboxee

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

עם זאת, סביר להניח שאתם צריכים לוגיקת תקשורת מורכבת יותר בין המפעיל לבין Sandboxee. ניתן להשתמש ב-comms API (ראו בקובץ הכותרת comms.h) כדי לשלוח מספרים שלמים, מחרוזות, מאגרי בייטים, protobufs או מתארי קבצים.

שיתוף מתארי קבצים

באמצעות Inter-Process Communication API (ראו ipc.h), ניתן להשתמש ב-MapFd() או ב-ReceiveFd():

  • אפשר להשתמש ב-MapFd() כדי למפות מתארי קבצים מהקובץ המופעל ל-Sandboxee. ניתן להשתמש באפשרות הזו כדי לשתף קובץ שנפתח מיישום ההפעלה לשימוש ב-Sandboxee. אפשר לראות שימוש לדוגמה בסטטי.

    // The executor opened /proc/version and passes it to the sandboxee as stdin
    executor->ipc()->MapFd(proc_version_fd, STDIN_FILENO);
    
  • שימוש ב-ReceiveFd() ליצירת נקודת קצה (endpoint) מסוג שקע. אפשר להשתמש בו כדי לקרוא את הפלט הסטנדרטי של Sandboxee או שגיאות רגילות. שימוש לדוגמה ניתן לראות בכלי.

    // The executor receives a file descriptor of the sandboxee stdout
    int recv_fd1 = executor->ipc())->ReceiveFd(STDOUT_FILENO);
    

שימוש ב-Comms API

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

כדי להתחיל להשתמש ב-comms API, תחילה עליכם לקבל את האובייקט comms מהאובייקט Sandbox2:

sandbox2::Comms* comms = s2.comms();

כאשר האובייקט Comms יהיה זמין, ניתן יהיה לשלוח נתונים ל-Sandboxee באמצעות אחת ממשפחת הפונקציות Send* . דוגמה לשימוש ב-comms API מופיעה בדוגמה crc4. קטע הקוד הבא מציג קטע מתוך דוגמה זו. ההפעלה שולחת unsigned char buf[size] באמצעות SendBytes(buf, size):

if (!(comms->SendBytes(static_cast<const uint8_t*>(buf), sz))) {
  /* handle error */
}

כדי לקבל נתונים מ-Sandboxee, יש להשתמש באחת מהפונקציות Recv*. קטע הקוד הבא הוא קטע מהדוגמה של crc4. האופרטור מקבל את בדיקת הסיכום כמספר שלם ללא סימן [unsigned integer] של 32 סיביות: uint32_t crc4;

if (!(comms->RecvUint32(&crc4))) {
  /* handle error */
}

שיתוף נתונים עם מאגרי נתונים זמניים

תכונה נוספת של שיתוף נתונים היא שימוש ב-buffer API כדי לשתף כמויות גדולות של נתונים, ולהימנע מעותקים יקרים שנשלחים הלוך ושוב בין ה-exe וגם Sandboxee.

מבצע ההפעלה יוצר מאגר נתונים זמני לפי הגודל והנתונים שיועברו או ישירות מתיאור הקובץ, ומעביר אותו ל-Sandboxee באמצעות comms->SendFD() בהפעלה ו-comms->RecvFD() ב-Sandboxee.

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

// start the sandbox asynchronously
s2.RunAsync();

// instantiate the comms object
sandbox2::Comms* comms = s2.comms();

// random buffer data we want to send
constexpr unsigned char buffer_data[] = /* random data */;
constexpr unsigned int buffer_dataLen = 34;

// create sandbox2 buffer
absl::StatusOr<std::unique_ptr<sandbox2::Buffer>> buffer =
     sandbox2::Buffer::CreateWithSize(1ULL << 20 /* 1Mib */);
std::unique_ptr<sandbox2::Buffer> buffer_ptr = std::move(buffer).value();

// point to the sandbox2 buffer and fill with data
uint8_t* buf = buffer_ptr‑>data();
memcpy(buf, buffer_data, buffer_data_len);

// send the data to the sandboxee
comms‑>SendFd(buffer_ptr‑>fd());

בצד של ה-Sandboxee, צריך גם ליצור אובייקט מאגר נתונים זמני ולקרוא את הנתונים מתיאור הקובץ שנשלח על ידי המפעיל:

// establish the communication with the executor
int fd;
comms.RecvFD(&fd);

// create the buffer
absl::StatusOr<std::unique_ptr<sandbox2::Buffer>> buffer =
     sandbox2::Buffer::createFromFd(fd);

// get the data
auto buffer_ptr = std::move(buffer).value();
uint8_t* buf = buffer_ptr‑>data();

/* work with the buf object */

6. יציאה מ-Sandbox

בהתאם לאופן שבו אתם מפעילים את ארגז החול (ראו שלב זה), עליכם להתאים את אופן סיום ה-Sandbox, וכן את האופן שבו אתם מסיימים את ארגז החול.

יציאה מארגז חול שפועל באופן סינכרוני

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

Sandbox2::Result result = s2.Run();
LOG(INFO) << "Final execution status: " << result.ToString();

יציאה מארגז חול שפועל באופן אסינכרוני

אם ארגז החול הופעל באופן אסינכרוני, יש שתי אפשרויות לסיום השימוש. תחילה, אפשר פשוט להמתין להשלמת ה-Sandboxee ולקבל את סטטוס הביצוע הסופי:

sandbox2::Result result = s2.AwaitResult();
LOG(INFO) << "Final execution status: " << result.ToString();

לחלופין, אפשר להרוג את Sandboxee בכל שלב, אבל עדיין מומלץ להתקשר אל AwaitResult() כי ה-Sandboxee עשוי להסתיים בגלל סיבה אחרת בינתיים:

s2.Kill();
sandbox2::Result result = s2.AwaitResult();
LOG(INFO) << "Final execution status: " << result.ToString();

7. בדיקה

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

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

הבדיקות האלה יכולות להיות פשוטות כמו בדיקת מעטפת או בדיקות C++ באמצעות תהליכי משנה. כדאי לעיין בדוגמאות כדי לקבל השראה.

סיכום

תודה על שקראת את המידע הזה. אנו מקווים שאהבת את המדריך שלנו ועכשיו יש לך את הכוח ליצור ארגזי חול משלך כדי לשמור על בטיחות המשתמשים.

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