בדף הזה מוסבר איך ליצור סביבת ארגז חול משלכם באמצעות Sandbox2. תלמדו איך להגדיר מדיניות Sandbox, וגם כמה שינויים מתקדמים אבל נפוצים. המידע שכאן יכול לשמש כמדריך, לצד הדוגמאות והתיעוד של הקוד בקובצי הכותרת.
1. בחירה של שיטת הפעלה של ארגז חול
ההרצה בארגז חול מתחילה עם רכיב 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 – ציון מתי להפעיל את ארגז החול
השיטה הזו מאפשרת גמישות בכך שהיא לא מוגבלת לארגז חול במהלך האתחול, ואז אפשר לבחור מתי להיכנס לארגז חול על ידי קריאה ל-::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: שרת Fork מותאם אישית – הכנת קובץ בינארי, המתנה לבקשות Fork ושימוש בארגז חול משלכם
במצב הזה אפשר להפעיל קובץ בינארי, להכין אותו לארגז חול, ובשלב מסוים במחזור החיים של הקובץ הבינארי, להפוך אותו לזמין למבצע.
ה-executor ישלח בקשת fork לקובץ הבינארי, שיבצע fork() (באמצעות ::sandbox2::ForkingClient::EnterForkLoop()). התהליך החדש שנוצר יהיה מוכן להרצה בארגז חול באמצעות ::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. יצירת מדיניות של ארגז חול
אחרי שיש לכם רכיב Executor, סביר להניח שתרצו להגדיר Sandbox Policy בשביל Sandboxee. אחרת, ה-Sandboxee מוגן רק על ידי מדיניות ברירת המחדל של Syscall.
מטרת המדיניות של ארגז החול היא להגביל את קריאות המערכת והארגומנטים שניתן לבצע בארגז החול, וגם את הקבצים שאפשר לגשת אליהם. כדי לעשות את זה, אתם צריכים להבין לעומק את קריאות המערכת שנדרשות לקוד שאתם מתכננים להריץ בארגז החול. אחת הדרכים לבחון קריאות מערכת היא להריץ את הקוד באמצעות כלי שורת הפקודה strace של Linux.
אחרי שיש לכם את רשימת קריאות המערכת, אתם יכולים להשתמש ב-PolicyBuilder כדי להגדיר את המדיניות.
PolicyBuilder כולל הרבה פונקציות נוחות ופונקציות עזר שמאפשרות לבצע הרבה פעולות נפוצות (רבות מהן כבר כלולות ב-DefaultPolicyBuilder()). הרשימה הבאה היא רק קטע קטן מתוך הפונקציות הזמינות:
- הוספה לרשימת ההיתרים של כל קריאת מערכת להפעלת תהליך:
AllowStaticStartup();AllowDynamicStartup();
- מוסיפים לרשימת ההיתרים את כל קריאות המערכת הפתוחות/לקריאה/לכתיבה*:
AllowOpen();AllowRead();AllowWrite();
- הוספה לרשימת ההיתרים של כל קריאות המערכת שקשורות ליציאה, לגישה או למצב:
AllowExit();AllowStat();AllowAccess();
- מוסיפים לרשימת ההיתרים את כל קריאות המערכת שקשורות לשינה או לזמן:
AllowTime();AllowSleep();
הפונקציות האלה מאפשרות להוסיף לרשימת ההיתרים כל קריאת מערכת רלוונטית. היתרון של הגישה הזו הוא שאפשר להשתמש באותה מדיניות בארכיטקטורות שונות שבהן קריאות מערכת מסוימות לא זמינות (למשל, ב-ARM64 אין קריאת מערכת OPEN), אבל יש סיכון אבטחה קטן כי מופעלות יותר קריאות מערכת מהנדרש. לדוגמה, הפונקציה AllowOpen() מאפשרת ל-Sandboxee להפעיל כל קריאת מערכת שקשורה לפתיחה. אם רוצים להוסיף לרשימת ההיתרים רק קריאת מערכת ספציפית אחת, אפשר להשתמש ב-AllowSyscall();. כדי להוסיף לרשימת ההיתרים כמה קריאות מערכת בבת אחת, אפשר להשתמש ב-AllowSyscalls().
בשלב הזה, המדיניות בודקת רק את המזהה של syscall. אם אתם רוצים לחזק עוד יותר את המדיניות ולהגדיר מדיניות שבה אתם מאפשרים רק קריאת מערכת עם ארגומנטים מסוימים, אתם צריכים להשתמש ב-AddPolicyOnSyscall() או ב-AddPolicyOnSyscalls(). הפונקציות האלה לא רק מקבלות את מזהה ה-syscall כארגומנט, אלא גם מסנן seccomp-bpf גולמי באמצעות פקודות המאקרו של bpf helper מליבת Linux. למידע נוסף על 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. שינוי המגבלות
מדיניות ארגז החול מונעת מהתהליך בארגז החול להפעיל קריאות מערכת ספציפיות, וכך מצמצמת את שטח המתקפה. עם זאת, יכול להיות שתוקף עדיין יוכל לגרום להשפעות לא רצויות על ידי הפעלת תהליך ללא הגבלת זמן או ניצול של זיכרון 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. הפעלת ארגז החול
בקטעים הקודמים הכנתם את סביבת ארגז החול, את המדיניות, את רכיב ההפעלה ואת Sandboxee. השלב הבא הוא ליצור את האובייקט Sandbox2 ולהריץ אותו.
הפעלה סינכרונית
ארגז החול יכול לפעול באופן סינכרוני, ולכן הוא חוסם את ההודעה עד שמתקבלת תוצאה. בקטע הקוד הבא מוצגת יצירת מופע של האובייקט Sandbox2 וההרצה הסינכרונית שלו. לדוגמה מפורטת יותר, ראו static.
#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.
עם זאת, סביר להניח שתצטרכו לוגיקת תקשורת מורכבת יותר בין תהליך הביצוע לבין Sandboxee. אפשר להשתמש ב-API של התקשורת (ראו קובץ הכותרת comms.h) כדי לשלוח מספרים שלמים, מחרוזות, מאגרי נתונים זמניים של בייטים, מאגרי נתונים זמניים של פרוטוקולים או תיאורי קבצים.
שיתוף של מתארים של קבצים
באמצעות Inter-Process Communication API (ראו ipc.h), אפשר להשתמש ב-MapFd() או ב-ReceiveFd():
משתמשים ב-
MapFd()כדי למפות תיאורי קבצים מהמבצע אל ה-Sandboxee. אפשר להשתמש בזה כדי לשתף קובץ שנפתח מה-executor לשימוש ב-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. אפשר להשתמש בזה כדי לקרוא את הפלט הסטנדרטי (stdout) או את השגיאות הסטנדרטיות (stderr) של ה-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 יוצר מאגר נתונים, לפי גודל ונתונים שצריך להעביר, או ישירות מתיאור קובץ, ומעביר אותו ל-Sandboxee באמצעות comms->SendFD() ב-executor ו-comms->RecvFD() ב-Sandboxee.
בקטע הקוד הבא, אפשר לראות את הצד של ה-executor. ארגז החול פועל באופן אסינכרוני ומשתף נתונים עם ה-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. בדיקה
כמו כל קוד אחר, גם בהטמעה של ארגז החול צריכות להיות בדיקות. בדיקות בארגז חול לא נועדו לבדוק את תקינות התוכנית, אלא לבדוק אם התוכנית בארגז החול יכולה לפעול בלי בעיות כמו הפרות של ארגז החול. כך גם מוודאים שמדיניות ארגז החול נכונה.
תוכנית בארגז חול נבדקת באותו אופן שבו היא מופעלת בסביבת הייצור, עם הארגומנטים וקובצי הקלט שהיא מעבדת בדרך כלל.
הבדיקות האלה יכולות להיות פשוטות כמו בדיקת מעטפת או בדיקות C++ באמצעות תהליכי משנה. כדאי לעיין בדוגמאות כדי לקבל השראה.
סיכום
תודה שקראתם עד כאן. אנחנו מקווים שאהבתם את המדריך שלנו, ושעכשיו אתם מרגישים שאתם יכולים ליצור ארגזי חול משלכם כדי לשמור על בטיחות המשתמשים.
יצירת ארגזי חול וכללי מדיניות היא משימה קשה ומועדת לשגיאות קלות. כדי להיות בטוחים, מומלץ לבקש ממומחה אבטחה לבדוק את המדיניות והקוד שלכם.