בדף הזה נסביר איך ליצור סביבת ארגז חול משלכם באמצעות Sandbox2. תלמדו איך להגדיר מדיניות ארגז חול, וגם כמה שינויים מתקדמים אבל נפוצים. המידע שכאן יכול לשמש כמדריך, לצד הדוגמאות והתיעוד של הקוד בקובצי הכותרת.
1. בחירת שיטת הרצה בארגז חול (Sandbox)
ההרצה בארגז חול מתחילה עם רכיב Executor (ראו Sandbox Executor), שאחראי להרצת Sandboxee. קובץ הכותרת executor.h מכיל את ה-API שדרוש למטרה הזו. ה-API גמיש מאוד ומאפשר לכם לבחור את מה שהכי מתאים לתרחיש לדוגמה שלכם. בקטעים הבאים מתוארות 3 מתודולוגיות שונות שתוכלו לבחור מביניהן.
שיטה 1: עצמאי – הפעלת קובץ בינארי עם ארגז חול שכבר מופעל
זו הדרך הפשוטה ביותר להשתמש בארגז חול, והשיטה המומלצת כשרוצים להפעיל בארגז חול קובץ בינארי שלם שאין לכם את קוד המקור שלו. זו גם הדרך הכי בטוחה להשתמש בארגז חול, כי אין אתחול מחוץ לארגז החול שיכולות להיות לו השפעות שליליות.
בקטע הקוד הבא, אנחנו מגדירים את הנתיב של הקובץ הבינארי שצריך להפעיל בארגז חול, ואת הארגומנטים שצריך להעביר לקריאת המערכת execve. כפי שאפשר לראות בקובץ הכותרת 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 – הגדרת הזמן שבו ה-executor יופעל בארגז חול
השיטה הזו מאפשרת גמישות, כי אפשר להפעיל את התוסף מחוץ לארגז החול במהלך האתחול, ואז לבחור מתי להיכנס לארגז החול באמצעות קריאה ל-::sandbox2::Client::SandboxMeHere(). כדי להשתמש בה, צריך להגדיר בקוד מתי רוצים להתחיל את הארגז, והיא צריכה להיות חד-הברגה (אפשר לקרוא למה בשאלות הנפוצות).
בקטע הקוד הבא, אנחנו משתמשים באותו קוד שמופיע בשיטה 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);
מכיוון שה-executor כולל עכשיו ארגז חול מושבת עד שהוא מקבל הודעה מה-Sandboxee, אנחנו צריכים ליצור מופע של ::sandbox2::Client, להגדיר תקשורת בין ה-executor לבין ה-Sandboxee, ואז להודיע ל-executor שהאתחול שלנו הסתיים ושאנחנו רוצים להתחיל להשתמש בארגז החול עכשיו על ידי קריאה ל-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();
…
דוגמה ל-method של ה-executor הזה היא crc4, שבה crc4bin.cc הוא ה-Sandboxee והוא מודיע ל-executor (crc4sandbox.cc) מתי הוא צריך להיכנס לארגז החול.
שיטה 3: Custom Forkserver – הכנת קובץ בינארי, המתנה לבקשות fork וארגז חול משלכם
במצב הזה אפשר להפעיל קובץ בינארי, להכין אותו לארגז חול, ובשלב מסוים במחזור החיים של הקובץ הבינארי, להפוך אותו לזמין למבצע.
fork()
::sandbox2::ForkingClient::EnterForkLook()התהליך החדש שנוצר יהיה
מוכן להפעלת ארגז חול עם ::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 אמיתי. דוגמה נוספת לשימוש אופייני היא כאשר לארגז החול יש אתחול ארוך שדורש משאבי מעבד (CPU) רבים, שאפשר להריץ לפני עיבוד הנתונים הלא מהימנים.
דוגמה לשיטת הביצוע הזו מופיעה ב-custom_fork.
2. יצירת מדיניות של ארגז חול (sandbox)
אחרי שיש לכם רכיב Executor, סביר להניח שתרצו להגדיר Sandbox Policy בשביל Sandboxee. אחרת, ארגז החול מוגן רק על ידי מדיניות ברירת המחדל של קריאות המערכת.
המטרה של מדיניות ארגז החול היא להגביל את קריאות המערכת והארגומנטים שארגז החול יכול לבצע, וגם את הקבצים שהוא יכול לגשת אליהם. צריך להבין לעומק את קריאות המערכת (syscalls) שנדרשות לקוד שמתכננים להריץ בסביבת ארגז חול. אחת הדרכים לצפייה בקריאות המערכת היא להריץ את הקוד באמצעות כלי שורת הפקודה strace של Linux.
אחרי שיש לכם את רשימת קריאות המערכת, אתם יכולים להשתמש ב-PolicyBuilder כדי להגדיר את המדיניות.
PolicyBuilder כולל הרבה פונקציות נוחות ופונקציות עזר שמאפשרות לבצע הרבה פעולות נפוצות (רבות מהן כבר כלולות ב-DefaultPolicyBuilder()). הרשימה הבאה היא רק קטע קטן מתוך הפונקציות הזמינות:
- הוספה לרשימת ההיתרים של כל קריאת מערכת להפעלת תהליך:
AllowStaticStartup();AllowDynamicStartup();
- מוסיפים לרשימת ההיתרים את כל קריאות המערכת הפתוחות/לקריאה/לכתיבה*:
AllowOpen();AllowRead();AllowWrite();
- הוספה לרשימת ההיתרים של כל קריאות המערכת שקשורות ליציאה, לגישה ולמצב:
AllowExit();AllowStat();AllowAccess();
- מוסיפים לרשימת ההיתרים את כל קריאות המערכת שקשורות לשינה או לזמן:
AllowTime();AllowSleep();
הפונקציות הנוחות האלה מוסיפות לרשימת ההיתרים כל קריאת מערכת רלוונטית. היתרון של הגישה הזו הוא שאפשר להשתמש באותה מדיניות בארכיטקטורות שונות שבהן קריאות מערכת מסוימות לא זמינות (למשל, ב-ARM64 אין קריאת מערכת OPEN), אבל יש סיכון אבטחה קטן כי מופעלות יותר קריאות מערכת מהנדרש. לדוגמה, AllowOpen() מאפשרת ל-Sandboxee לקרוא לכל קריאת מערכת שקשורה לפתיחה. אם רוצים להוסיף לרשימת ההיתרים רק קריאת מערכת ספציפית אחת, אפשר להשתמש ב-AllowSyscall();. כדי להוסיף לרשימת ההיתרים כמה קריאות מערכת בבת אחת, אפשר להשתמש ב-AllowSyscalls().
בשלב הזה, המדיניות בודקת רק את המזהה של syscall. אם אתם צריכים לחזק עוד יותר את המדיניות ורוצים להגדיר מדיניות שבה אתם מאפשרים רק קריאת מערכת עם ארגומנטים מסוימים, אתם צריכים להשתמש ב-AddPolicyOnSyscall() או ב-AddPolicyOnSyscalls(). הפונקציות האלה מקבלות כארגומנט לא רק את מזהה קריאת המערכת, אלא גם מסנן seccomp-bpf גולמי באמצעות פקודות מאקרו של bpf helper מליבת לינוקס. למידע נוסף על BPF, אפשר לעיין במסמכי התיעוד של הליבה. אם אתם כותבים קוד BPF שחוזר על עצמו, ולדעתכם צריך להיות לו wrapper של שימושיות, אתם יכולים להגיש בקשה להוספת תכונה.
בנוסף לפונקציות שקשורות ל-syscall, הכלי PolicyBuilder מספק גם מספר פונקציות שקשורות למערכת הקבצים, כמו AddFile() או AddDirectory(), כדי לבצע bind-mount לקובץ או לספרייה בארגז החול. אפשר להשתמש בכלי העזר AddTmpfs() כדי להוסיף אחסון זמני של קבצים בארגז החול.
פונקציה שימושית במיוחד היא AddLibrariesForBinary() שמוסיפה את הספריות ואת ה-linker שנדרשים על ידי קובץ בינארי.
לצערי, עדיין צריך לעבוד קצת באופן ידני כדי להבין אילו קריאות למערכת צריך להוסיף לרשימת ההיתרים. יוצרים מדיניות עם קריאות המערכת שנדרשות לקובץ הבינארי ומריצים אותה עם עומס עבודה נפוץ. אם מופעלת הפרה, צריך להוסיף את קריאת המערכת לרשימת ההיתרים ולחזור על התהליך. אם נתקלתם בהפרה שלדעתכם עלולה להיות מסוכנת להוספה לרשימת ההיתרים, והתוכנית מטפלת בשגיאות בצורה חלקה, אתם יכולים לנסות לגרום לה להחזיר שגיאה במקום זאת באמצעות BlockSyscallWithErrno().
בדוגמה הבאה, האילוץ sandbox2::PolicyBuilder נוצר ישירות כדי להדגים הגבלה של הארגומנטים write ו-mprotect וחסימה של openat (שאחרת DefaultPolicyBuilder() היה מאפשר):
#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. שינוי המגבלות
מדיניות ארגז החול מונעת מה-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 מופיעה בדוגמה tool.
4. הפעלת ארגז החול
בקטעים הקודמים הכנתם את הסביבה, המדיניות, ה-executor וה-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();
הרצה אסינכרונית
אפשר גם להריץ את ארגז החול באופן אסינכרוני, כך שהוא לא ייחסם עד שתתקבל תוצאה. האפשרות הזו שימושית, למשל, כשמתקשרים עם Sandboxee. בקטע הקוד הבא מוצג תרחיש השימוש הזה. דוגמאות מפורטות יותר אפשר לראות במאמרים בנושא crc4 וtool.
#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 או לקרוא את הפלט הסטנדרטי (stdout) של ה-Sandboxee.
עם זאת, סביר להניח שתצטרכו לוגיקת תקשורת מורכבת יותר בין ה-executor ל-Sandboxee. אפשר להשתמש ב-API של התקשורת (ראו את קובץ הכותרת comms.h) כדי לשלוח מספרים שלמים, מחרוזות, מאגרי בייטים, פרוטוקולי protobuf או מתארים של קבצים.
שיתוף של מתארים של קבצים
באמצעות Inter-Process Communication API (ראו ipc.h), אפשר להשתמש ב-MapFd() או ב-ReceiveFd():
משתמשים ב-
MapFd()כדי למפות מתארים של קבצים מהמפעיל אל Sandboxee. אפשר להשתמש בזה כדי לשתף קובץ שנפתח מהמבצע לשימוש ב-Sandboxee. אפשר לראות דוגמה לשימוש ב-static.// The executor opened /proc/version and passes it to the sandboxee as stdin executor->ipc()->MapFd(proc_version_fd, STDIN_FILENO);משתמשים ב-
ReceiveFd()כדי ליצור נקודת קצה של socketpair. אפשר להשתמש באפשרות הזו כדי לקרוא את הפלט הרגיל או את השגיאות הרגילות של Sandboxee. אפשר לראות דוגמה לשימוש בכלי.// The executor receives a file descriptor of the sandboxee stdout int recv_fd1 = executor->ipc())->ReceiveFd(STDOUT_FILENO);
שימוש ב-API לתקשורת
Sandbox2 מספק comms API נוח. זו דרך פשוטה וקלה לשתף מספרים שלמים, מחרוזות או מאגרי בייטים בין ה-executor ל-Sandboxee. הנה כמה קטעי קוד מהדוגמה של crc4.
כדי להתחיל להשתמש ב-API של התקשורת, קודם צריך לקבל את אובייקט התקשורת מאובייקט Sandbox2:
sandbox2::Comms* comms = s2.comms();
אחרי שאובייקט התקשורת זמין, אפשר לשלוח נתונים אל Sandboxee באמצעות אחת מהפונקציות של משפחת Send*. דוגמה לשימוש ב-API של התקשורת מופיעה בדוגמה crc4. בקטע הקוד הבא מוצג קטע מהדוגמה הזו. מנהל העיזבון שולח unsigned char
buf[size] עם SendBytes(buf, size):
if (!(comms->SendBytes(static_cast<const uint8_t*>(buf), sz))) {
/* handle error */
}
כדי לקבל נתונים מה-Sandboxee, משתמשים באחת מהפונקציות של Recv*. קטע הקוד הבא הוא קטע מתוך הדוגמה של crc4. ה-executor מקבל את סיכום הביקורת (checksum) במספר שלם לא חתום של 32 ביט: uint32_t crc4;
if (!(comms->RecvUint32(&crc4))) {
/* handle error */
}
שיתוף נתונים עם מאגרי נתונים זמניים
פונקציונליות נוספת לשיתוף נתונים היא שימוש ב-buffer API כדי לשתף כמויות גדולות של נתונים ולמנוע העתקות יקרות שנשלחות הלוך ושוב בין ה-executor ל-Sandboxee.
ה-executor יוצר Buffer, לפי גודל ונתונים שצריך להעביר, או ישירות ממתאר קובץ, ומעביר אותו ל-Sandboxee באמצעות comms->SendFD() ב-executor ו-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, צריך גם ליצור אובייקט מאגר נתונים ולקרוא את הנתונים מתיאור הקובץ שנשלח על ידי ה-executor:
// 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. יציאה מארגז החול
בהתאם לאופן שבו מפעילים את ארגז החול (ראו את השלב הזה), צריך לשנות את האופן שבו מסיימים את ארגז החול, וכך גם את Sandboxee.
יציאה מארגז חול שפועל באופן סינכרוני
אם ארגז החול פועל באופן סינכרוני, הפונקציה Run תחזיר ערך רק כשה-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 צריכה לכלול בדיקות. בדיקות בארגז חול לא נועדו לבדוק את תקינות התוכנית, אלא לבדוק אם התוכנית בארגז החול יכולה לפעול בלי בעיות כמו הפרות של ארגז החול. כך גם מוודאים שהמדיניות של ארגז החול נכונה.
תוכנית בארגז חול נבדקת באותו אופן שבו היא מופעלת בסביבת הייצור, עם הארגומנטים וקובצי הקלט שהיא מעבדת בדרך כלל.
הבדיקות האלה יכולות להיות פשוטות כמו בדיקת מעטפת או בדיקות C++ באמצעות תהליכי משנה. כדאי לעיין בדוגמאות כדי לקבל השראה.
סיכום
תודה שקראתם עד כאן. אנחנו מקווים שאהבתם את המדריך שלנו, ושעכשיו אתם מרגישים שאתם יכולים ליצור ארגזי חול משלכם כדי לשמור על בטיחות המשתמשים.
יצירת ארגזי חול וכללי מדיניות היא משימה קשה ומועדת לשגיאות קלות. כדי להיות בטוחים, מומלץ לבקש ממומחה אבטחה לבדוק את המדיניות והקוד שלכם.