בדף הזה נסביר איך ליצור סביבת ארגז חול משלכם באמצעות Sandbox2. תלמדו איך להגדיר מדיניות Sandbox, וגם כמה שינויים מתקדמים אבל נפוצים. אפשר להיעזר במידע שמופיע כאן, וגם בדוגמאות ובתיעוד הקוד בקובצי הכותרת.
1. בחירה של שיטת הפעלה של ארגז חול
הסנדבוק מתחיל עם מפעיל (ראו 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();
…
דוגמה לשיטת הביצוע הזו היא crc4, שבה crc4bin.cc הוא Sandboxee ומודיע ל-executor (crc4sandbox.cc) מתי הוא צריך להיכנס לארגז החול.
שיטה 3: שרת פורק מותאם אישית – הכנת קובץ בינארי, המתנה לבקשות פורק וארגז חול משלכם
במצב הזה אפשר להפעיל קובץ בינארי, להכין אותו להפעלת ארגז חול ובשלב מסוים במחזור החיים של הקובץ הבינארי, להפוך אותו לזמין למבצע.
התהליך יבצע 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 אמיתי. דוגמה נוספת לשימוש אופייני היא כש-Sandboxee עובר אתחול ארוך שדורש הרבה משאבי CPU, שאפשר להריץ אותו לפני עיבוד הנתונים הלא מהימנים.
דוגמה לשיטת הביצוע הזו מופיעה ב-custom_fork.
2. יצירת מדיניות של ארגז חול
אחרי שיש לכם מפעיל, כדאי להגדיר Sandbox Policy עבור Sandboxee. אחרת, ארגז החול מוגן רק על ידי מדיניות ברירת המחדל של קריאות המערכת.
המטרה של מדיניות ארגז החול היא להגביל את קריאות המערכת (syscalls) והארגומנטים שאפשר לבצע בארגז החול, וגם את הקבצים שאפשר לגשת אליהם. צריך להבין לעומק את קריאות המערכת (syscalls) שנדרשות לקוד שמתכננים להריץ בסביבת ארגז חול. אחת הדרכים לצפות בקריאות המערכת היא להריץ את הקוד באמצעות הכלי strace של Linux לשורת הפקודה.
אחרי שיש לכם את רשימת קריאות המערכת, אתם יכולים להשתמש ב-PolicyBuilder כדי להגדיר את המדיניות. PolicyBuilder כולל הרבה פונקציות נוחות ופונקציות עזר שמאפשרות לבצע הרבה פעולות נפוצות. הרשימה הבאה היא רק קטע קטן של הפונקציות הזמינות:
- הוספת כל קריאת מערכת לרשימת ההיתרים להפעלת תהליך:
AllowStaticStartup();AllowDynamicStartup();
- הוספה לרשימת ההיתרים של כל קריאות המערכת (syscalls) מסוג open/read/write*:
AllowOpen();AllowRead();AllowWrite();
- מוסיפים לרשימת ההיתרים את כל קריאות המערכת שקשורות ליציאה, לגישה או למצב:
AllowExit();AllowStat();AllowAccess();
- מוסיפים לרשימת ההיתרים את כל קריאות המערכת שקשורות לשינה או לזמן:
AllowTime();AllowSleep();
הפונקציות הנוחות האלה מאפשרות להוסיף לרשימת ההיתרים כל קריאת מערכת רלוונטית. היתרון בשיטה הזו הוא שאפשר להשתמש באותה מדיניות בארכיטקטורות שונות שבהן קריאות מערכת מסוימות לא זמינות (למשל, ב-ARM64 אין קריאת מערכת OPEN). עם זאת, יש סיכון אבטחה קל שנובע מהפעלת יותר קריאות מערכת ממה שנדרש. לדוגמה, AllowOpen() מאפשרת ל-Sandboxee לקרוא לכל קריאת מערכת שקשורה לפתיחה. אם רוצים להוסיף לרשימת ההיתרים רק קריאת מערכת ספציפית אחת, אפשר להשתמש ב-AllowSyscall();. כדי להוסיף לרשימת ההיתרים כמה קריאות מערכת בבת אחת, אפשר להשתמש ב-AllowSyscalls().
בשלב הזה, המדיניות בודקת רק את מזהה קריאת המערכת. אם אתם רוצים לחזק עוד יותר את המדיניות ולהגדיר מדיניות שבה אתם מאשרים רק קריאת מערכת עם ארגומנטים מסוימים, אתם צריכים להשתמש ב-AddPolicyOnSyscall() או ב-AddPolicyOnSyscalls(). הפונקציות האלה מקבלות כארגומנט לא רק את מזהה קריאת המערכת, אלא גם מסנן seccomp-bpf גולמי באמצעות פקודות מאקרו של bpf helper מליבת לינוקס. מידע נוסף על BPF זמין במסמכי התיעוד של ליבת המערכת. אם אתם כותבים קוד BPF חוזר על עצמו ואתם חושבים שצריך להיות לו wrapper לשיפור השימושיות, אתם מוזמנים לשלוח בקשה להוספת תכונה.
בנוסף לפונקציות שקשורות ל-syscall, הכלי PolicyBuilder מספק גם מספר פונקציות שקשורות למערכת הקבצים, כמו AddFile() או AddDirectory(), כדי לבצע bind-mount של קובץ או ספריה בארגז החול. אפשר להשתמש בכלי העזר AddTmpfs() כדי להוסיף אחסון זמני לקבצים בארגז החול.
פונקציה שימושית במיוחד היא AddLibrariesForBinary() שמוסיפה את הספריות ואת ה-linker שנדרשים על ידי קובץ בינארי.
לצערנו, עדיין צריך לעבוד קצת באופן ידני כדי להחליט אילו קריאות למערכת להוסיף לרשימת ההיתרים. יוצרים מדיניות עם קריאות המערכת שנדרשות לקובץ הבינארי ומריצים אותה עם עומס עבודה נפוץ. אם מופעלת הפרה, צריך להוסיף את קריאת המערכת לרשימת ההיתרים ולחזור על התהליך. אם נתקלתם בהפרה שלדעתכם עלולה להיות מסוכנת להוספה לרשימת ההיתרים, והתוכנית מטפלת בשגיאות בצורה חלקה, אתם יכולים לנסות לגרום לה להחזיר שגיאה במקום זאת באמצעות 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. שינוי המגבלות
מדיניות ארגז החול מונעת מה-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 או לקרוא את הפלט הרגיל של Sandboxee.
עם זאת, סביר להניח שתצטרכו לוגיקת תקשורת מורכבת יותר בין תהליך הביצוע לבין Sandboxee. אפשר להשתמש ב-API של התקשורת (ראו את קובץ הכותרת comms.h) כדי לשלוח מספרים שלמים, מחרוזות, מאגרי בייטים, פרוטוקולי protobuf או מתארים של קבצים.
שיתוף של תיאורי קבצים
באמצעות Inter-Process Communication API (ראו ipc.h), אפשר להשתמש ב-MapFd() או ב-ReceiveFd():
משתמשים ב-
MapFd()כדי למפות מתארים של קבצים מה-executor אל 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);
שימוש ב-Comms API
Sandbox2 מספק comms API נוח. זו דרך פשוטה וקלה לשתף מספרים שלמים, מחרוזות או מאגרי בייטים בין רכיב ההפעלה לבין 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. הקובץ להרצה מקבל את סיכום הביקורת כמספר שלם לא מסומן בן 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, צריך גם ליצור אובייקט מאגר ולקרוא את הנתונים ממתאר הקובץ שנשלח על ידי קובץ ההפעלה:
// 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 צריכה לכלול בדיקות. בדיקות Sandbox לא נועדו לבדוק את התקינות של התוכנית, אלא לבדוק אם התוכנית ב-Sandbox יכולה לפעול ללא בעיות כמו הפרות של Sandbox. כך גם מוודאים שהמדיניות של ארגז החול נכונה.
תוכנית בסביבת Sandbox נבדקת באותו אופן שבו היא מופעלת בסביבת ייצור, עם הארגומנטים וקבצי הקלט שהיא בדרך כלל מעבדת.
הבדיקות האלה יכולות להיות פשוטות כמו בדיקת מעטפת או בדיקות C++ באמצעות תהליכי משנה. כדאי לעיין בדוגמאות כדי לקבל השראה.
סיכום
תודה שקראת עד כאן. אנחנו מקווים שהמדריך שלנו עזר לך, ועכשיו יש לך את הכלים ליצור ארגזי חול משלך כדי לשמור על בטיחות המשתמשים שלך.
יצירת ארגזי חול ומדיניות היא משימה מורכבת שעלולה להוביל לשגיאות קלות. כדי להיות בטוחים, מומלץ לבקש ממומחה אבטחה לבדוק את המדיניות והקוד.