בדף הזה נסביר איך ליצור סביבת ארגז חול משלכם באמצעות Sandbox2. תלמדו איך להגדיר מדיניות Sandbox, וגם כמה שינויים מתקדמים אבל נפוצים. אפשר להיעזר במידע שמופיע כאן, וגם בדוגמאות ובתיעוד הקוד בקובצי הכותרת.
1. בחירת שיטה להרצת ארגז חול
ההרצה בארגז חול מתחילה עם רכיב Executor (ראו Sandbox Executor), שאחראי להרצת Sandboxee. קובץ הכותרת executor.h מכיל את ה-API שדרוש למטרה הזו. ה-API גמיש מאוד ומאפשר לכם לבחור את מה שהכי מתאים לתרחיש לדוגמה שלכם. בקטעים הבאים מתוארות 3 מתודולוגיות שונות שתוכלו לבחור מביניהן.
שיטה 1: עצמאי – הפעלת קובץ בינארי עם ארגז חול שכבר מופעל
זו הדרך הכי פשוטה להשתמש ב-Sandbox, והיא מומלצת כשרוצים להפעיל Sandbox על קובץ בינארי שלם שאין לו קוד מקור. זו גם הדרך הכי בטוחה להשתמש בארגז חול, כי אין אתחול מחוץ לארגז החול שיכול לגרום להשפעות שליליות.
בקטע הקוד הבא, אנחנו מגדירים את הנתיב של הקובץ הבינארי שצריך להפעיל בארגז חול ואת הארגומנטים שצריך להעביר לקריאת המערכת 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();
…
דוגמה לשיטת executor היא crc4, שבה crc4bin.cc
הוא Sandboxee והוא מודיע ל-executor (crc4sandbox.cc
) מתי הוא צריך להיכנס לארגז החול.
שיטה 3: שרת פורקים בהתאמה אישית – מכינים קובץ בינארי, מחכים לבקשות פורק ומפעילים ארגז חול באופן עצמאי
במצב הזה אפשר להפעיל קובץ בינארי, להכין אותו להפעלת ארגז חול ובשלב מסוים במחזור החיים של הקובץ הבינארי, להפוך אותו לזמין למבצע.
התהליך יבצע בקשת פיצול לקובץ הבינארי שלכם, שתעבור דרך fork()
(באמצעות ::sandbox2::ForkingClient::WaitAndFork()
). התהליך החדש שייווצר יהיה מוכן להפעלת ארגז חול באמצעות ::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.
מטרת המדיניות של ארגז החול היא להגביל את קריאות המערכת (syscalls) והארגומנטים שאפשר לבצע בארגז החול, וגם את הקבצים שאפשר לגשת אליהם. צריך להבין לעומק את קריאות המערכת (syscalls) שנדרשות לקוד שמתכננים להריץ בסביבת Sandbox. אחת הדרכים לצפות בקריאות המערכת היא להריץ את הקוד באמצעות הכלי 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. אפשר להשתמש ב-Comms 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);
שימוש ב-Comms API
Sandbox2 מספק 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 צריכה לכלול בדיקות. בדיקות Sandbox לא נועדו לבדוק את התקינות של התוכנית, אלא לבדוק אם התוכנית ב-Sandbox יכולה לפעול ללא בעיות כמו הפרות של Sandbox. כך גם מוודאים שהמדיניות של ארגז החול נכונה.
תוכנית בסביבת ארגז חול נבדקת באותו אופן שבו היא מופעלת בסביבת ייצור, עם הארגומנטים וקבצי הקלט שהיא בדרך כלל מעבדת.
הבדיקות האלה יכולות להיות פשוטות כמו בדיקת מעטפת או בדיקות C++ באמצעות תהליכי משנה. כדאי לעיין בדוגמאות כדי לקבל השראה.
סיכום
תודה שקראת עד כאן. אנחנו מקווים שהמדריך שלנו עזר לך, ועכשיו יש לך את הכלים ליצור ארגזי חול משלך כדי לשמור על בטיחות המשתמשים.
יצירת ארגזי חול ומדיניות היא משימה מורכבת שעלולה להוביל לשגיאות קלות. כדי להיות בטוחים, מומלץ לבקש ממומחה אבטחה לבדוק את המדיניות והקוד.