Начало работы с Sandbox2

На этой странице вы узнаете, как создать собственную изолированную среду с помощью Sandbox2. Вы научитесь определять политику песочницы и использовать некоторые продвинутые, но распространённые настройки. Используйте эту информацию в качестве руководства, наряду с примерами и документацией по коду в заголовочных файлах.

1. Выберите метод Sandbox Executor

Песочница начинается с исполнителя (см. Исполняющий объект песочницы ), который отвечает за запуск Sandboxee . Заголовочный файл executor.h содержит API, необходимый для этой цели. API очень гибкий и позволяет вам выбрать наиболее подходящий для вашего случая использования. В следующих разделах описаны три различных метода, которые вы можете выбрать.

Метод 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);

Поскольку исполнитель теперь имеет отключенную песочницу, пока он не получит уведомление от 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 ), когда он должен войти в песочницу.

Метод 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, требующая значительных ресурсов процессора, которую можно выполнить до обработки ненадёжных данных.

Пример этого метода исполнителя см. в custom_fork .

2. Создайте политику «песочницы»

После создания исполнителя вам, вероятно, потребуется определить политику песочницы для песочницы Sandboxee. В противном случае песочница Sandboxee будет защищена только политикой системных вызовов по умолчанию .

Цель политики «песочницы» — ограничить системные вызовы и аргументы, которые может выполнять «песочница», а также файлы, к которым она может получить доступ. Вам необходимо детально понимать системные вызовы, необходимые для кода, который вы планируете поместить в «песочницу». Один из способов наблюдения за системными вызовами — запустить код с помощью утилиты командной строки Linux strace.

Получив список системных вызовов, вы можете использовать PolicyBuilder для определения политики. PolicyBuilder содержит множество удобных и вспомогательных функций, позволяющих выполнять множество распространённых операций. Следующий список — лишь небольшая часть доступных функций:

  • Разрешить любые системные вызовы для запуска процесса:
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • Добавить в список разрешенных открытых системных вызовов /read /write*:
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • Добавьте в список разрешенных системных вызовов, связанных с выходом/доступом/состоянием:
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • Добавьте в список разрешенных системных вызовов, связанных со сном/временем:
    • AllowTime();
    • AllowSleep();

Эти удобные функции разрешают все соответствующие системные вызовы. Преимущество этого заключается в том, что одну и ту же политику можно использовать на разных архитектурах, где некоторые системные вызовы недоступны (например, в ARM64 нет системного вызова OPEN), но с небольшим риском для безопасности, связанным с включением большего количества системных вызовов, чем необходимо. Например, AllowOpen() позволяет Sandboxee вызывать любые связанные открытые системные вызовы. Если нужно разрешить только один конкретный системный вызов, можно использовать AllowSyscall(); чтобы разрешить несколько системных вызовов одновременно, можно использовать AllowSyscalls() .

Пока что политика проверяет только идентификатор системного вызова. Если вам необходимо дополнительно усилить политику и определить политику, разрешающую системные вызовы только с определёнными аргументами, используйте AddPolicyOnSyscall() или AddPolicyOnSyscalls() . Эти функции принимают в качестве аргумента не только идентификатор системного вызова, но и необработанный фильтр seccomp-bpf, используя вспомогательные макросы bpf из ядра Linux. Подробнее о BPF см. в документации ядра . Если вы пишете повторяющийся код BPF, который, по вашему мнению, должен быть обёрнут для удобства использования, смело отправляйте запрос на добавление функции.

Помимо функций, связанных с системными вызовами, PolicyBuilder также предоставляет ряд функций, связанных с файловой системой, таких как AddFile() и AddDirectory() для привязки файла/каталога к песочнице. Вспомогательная функция AddTmpfs() может использоваться для добавления временного хранилища файлов в песочницу.

Особенно полезная функция — AddLibrariesForBinary() , которая добавляет библиотеки и компоновщик, необходимые двоичному файлу.

К сожалению, добавление системных вызовов в разрешённый список всё ещё требует ручной работы. Создайте политику с системными вызовами, которые, как вы знаете, нужны вашему исполняемому файлу, и запустите её с обычной рабочей нагрузкой. Если обнаружено нарушение, добавьте системный вызов в разрешённый список и повторите процесс. Если вы столкнулись с нарушением, которое, по вашему мнению, может быть рискованным для включения в разрешённый список, и программа корректно обрабатывает ошибки, вы можете попробовать заставить её возвращать ошибку с помощью 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 по умолчанию работает с жёсткими ограничениями на выполнение. Если эти ограничения по умолчанию создают проблемы для корректного выполнения вашей программы, вы можете изменить их с помощью класса 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 и его синхронное выполнение. Более подробный пример см. в 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 или прочитать стандартный вывод Sandboxee.

Однако вам, скорее всего, потребуется более сложная логика взаимодействия между исполнителем и Sandboxee. API comms (см. заголовочный файл comms.h ) можно использовать для отправки целых чисел, строк, байтовых буферов, Protobuf-ов и файловых дескрипторов.

Совместное использование файловых дескрипторов

Используя 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() для создания конечной точки пары сокетов. Её можно использовать для чтения стандартного вывода или стандартных ошибок Sandboxee. Пример использования можно найти в инструменте .

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

Использование API связи

Sandbox2 предоставляет удобный API для обмена данными . Это простой и удобный способ обмена целыми числами, строками или байтовыми буферами между исполнителем и Sandboxee. Ниже приведены некоторые фрагменты кода из примера crc4 .

Чтобы начать работу с API-интерфейсом Comms, сначала необходимо получить объект Comms из объекта Sandbox2:

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

После того, как объект comms станет доступен, данные можно отправить в Sandboxee с помощью одной из функций семейства Send*. Пример использования API comms можно найти в примере 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 */
}

Обмен данными с буферами

Еще одна функция обмена данными — использование API буфера для обмена большими объемами данных и избежания дорогостоящего копирования, которое передается туда и обратно между исполнителем и 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. Выход из песочницы

В зависимости от того, как вы запускаете песочницу (см. этот шаг ), вам придется скорректировать способ завершения песочницы, а следовательно и Sandboxee.

Выход из песочницы, работающей синхронно

Если песочница работала синхронно, то Run вернётся только после завершения работы Sandboxee. Поэтому никаких дополнительных действий для завершения не требуется. Фрагмент кода ниже иллюстрирует этот сценарий:

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

Выход из песочницы, работающей асинхронно

Если песочница работала асинхронно, возможны два варианта завершения. Во-первых, можно просто дождаться завершения работы песочницы и получить окончательный статус выполнения:

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++ с использованием подпроцессов. Ознакомьтесь с примерами для вдохновения.

Заключение

Спасибо, что дочитали до этого места. Надеемся, вам понравилось наше руководство, и теперь вы уверены в своих силах создавать собственные «песочницы», которые помогут обеспечить безопасность ваших пользователей.

Создание песочниц и политик — сложная задача, подверженная трудноуловимым ошибкам. В целях безопасности мы рекомендуем вам обратиться к эксперту по безопасности для проверки вашей политики и кода.