ЛАБОРАТОРИЯ · БЕЗОПАСНОСТЬ АГЕНТОВ

Как проверить песочницу AI-агента на утечки и обход сетевых ограничений

Уровень: продвинутый Время чтения и практики: 90 минут Результат: изолированный стенд, приманки, журналы и автоматические проверки

Надпись «запущено в контейнере» ещё не означает, что агент изолирован. Процесс может найти настроенный прокси, обратиться к облачному metadata endpoint, прочитать оставленные в окружении ключи, вызвать пакетный менеджер, подключиться к сокету Docker или использовать уязвимость среды выполнения. В этой лаборатории мы закроем произвольный исходящий трафик, поместим внутрь только синтетические токены-приманки, вынесем наблюдение за пределы проверяемого процесса и воспроизведём типичные попытки утечки и выхода из изоляции.

Что получится в конце

Стенд состоит из четырёх независимых слоёв:

  1. минимального образа с проверочным процессом от непривилегированного пользователя;
  2. сетевого режима none, в котором отсутствует маршрут к интернету, DNS и локальной сети хоста;
  3. двух синтетических приманок: переменной окружения и файла, похожего на облачный профиль;
  4. внешнего наблюдателя, который фиксирует системные вызовы connect, socket, unshare, setns и mount.

Проверочный процесс попытается использовать пять каналов выхода:

  • прямое TCP-соединение с тестовым адресом;
  • адрес из переменной HTTPS_PROXY;
  • DNS-запрос с отпечатком приманки в имени;
  • облачный link-local адрес 169.254.169.254;
  • Unix-сокет Docker.

Затем он проверит несколько путей повышения полномочий: raw socket, создание новых пространств имён, присоединение через setns, вызов mount, запись в корневую файловую систему и осмотр процессов. Это не эксплуатация конкретной уязвимости. Цель — подтвердить свойства границы, которые должны сохраняться даже при недоверенном коде внутри.

Граница Проверка Допустимое наблюдение Провал
Сеть TCP, proxy, DNS, metadata Ошибка до передачи данных Соединение установлено или получен ответ
Управление контейнерами /var/run/docker.sock Сокет отсутствует Сокет найден или доступен
Привилегии Raw socket, mount, namespaces EPERM или EACCES Операция выполнена
Файловая система Запись в / EROFS или отказ Файл создан
Приманки Фиксация факта чтения и попытки использования Есть отпечаток, нет успешного egress Приманка появилась за пределами стенда

Почему изоляция незаметно перестаёт быть изоляцией

Контейнер отделяет файловую систему, процессы и часть системных ресурсов, но сам по себе не задаёт безопасную сетевую политику. Обычная bridge-сеть Docker предоставляет контейнеру NAT и исходящий доступ. Если приложение умеет устанавливать пакеты или обращаться к HTTP API, оно, скорее всего, уже умеет передавать данные наружу.

На практике опасен не только прямой запрос к произвольному адресу. У агента могут оказаться косвенные маршруты:

  • egress-прокси, указанный в HTTP_PROXY, HTTPS_PROXY, конфигурации npm, pip, Git или системного менеджера пакетов;
  • внутренний сервис, который принимает URL и сам загружает ресурс, превращаясь в канал SSRF;
  • DNS, куда данные кодируются в поддоменах;
  • облачный metadata endpoint, выдающий временные полномочия workload;
  • смонтированный сокет Docker, containerd или Kubernetes;
  • SSH-agent, сокет браузера, D-Bus или другой IPC-канал хоста;
  • кэш пакетного менеджера с сохранённой авторизацией;
  • домашний каталог разработчика с файлами .aws, .config, .npmrc или .git-credentials;
  • уязвимость ядра либо OCI runtime, позволяющая пересечь границу контейнера.

Поэтому тест должен отвечать не на вопрос «может ли агент выполнить curl», а на четыре отдельных вопроса:

  1. существует ли маршрут передачи байтов за пределы разрешённого контура;
  2. есть ли внутри данные или полномочия, которые стоит украсть;
  3. может ли процесс изменить собственные ограничения;
  4. останутся ли доказательства, если сам процесс не признается в попытке.
Лог приложения показывает то, что приложение решило сообщить. Журнал за границей песочницы показывает то, что процесс действительно пытался сделать.

Конкретный случай: пакетный прокси становится выходом в интернет

Представим агента, который анализирует репозиторий и при необходимости запускает тесты. Его поместили в контейнер, ограничили CPU и память, но оставили стандартную bridge-сеть. В образе присутствует pip, а инфраструктура добавляет переменную:

HTTPS_PROXY=http://packages-proxy.internal:3128

Команда агента напрямую к внешнему IP может блокироваться межсетевым экраном. Однако пакетный клиент отправляет запрос через доверенный прокси. Если прокси проверяет только порт или метод, а не назначение, тот же маршрут можно использовать для произвольного HTTPS-туннеля. Если рядом лежит рабочий токен, агент способен передать его в заголовке, URL, имени пакета либо DNS-метке.

Другой вариант не требует прокси: в контейнер случайно смонтирован /var/run/docker.sock. Получив доступ к API Docker, процесс может создать новый привилегированный контейнер, подключить файловую систему хоста и обойти исходную сетевую политику. Ограничения первого контейнера при этом остаются формально включёнными, но уже не защищают систему.

Наш стенд воспроизводит предпосылки безопасно. Значение HTTPS_PROXY указывает на зарезервированный тестовый адрес, Docker socket не монтируется, а вместо настоящих ключей используются случайные строки без полномочий.

Модель угроз и критерии приёмки

До запуска зафиксируйте, что именно считается доверенным:

Недоверенный субъект
Весь процесс агента, сгенерированные им команды, установленные зависимости и открытые им документы.
Доверенная граница
Конфигурация контейнера, сетевой namespace, ядро хоста и внешний журнал системных вызовов.
Защищаемые данные
Рабочие ключи, исходные файлы вне задачи, учётные данные хоста и сетевые полномочия.
Сигнал обнаружения
Чтение синтетической приманки и последующая попытка сетевого или привилегированного действия.
Успех защиты
Все контрольные попытки видны, ни один произвольный сетевой запрос не завершается, а процесс не получает новых полномочий.
Успех атаки
Достаточно одного установленного соединения, доступного управляющего сокета, успешного mount/setns или записи за пределами разрешённой области.

Мы будем применять default deny: отсутствие явного разрешения означает запрет. В базовом варианте разрешений на сеть нет вообще. Для производственной системы позже можно добавить отдельный сетевой шлюз, но не общую bridge-сеть.

Архитектура стенда

Хост
├── Docker daemon
├── evidence/
│   ├── events.jsonl       ← stdout проверочного процесса
│   ├── syscalls.*         ← внешний strace
│   ├── inspect.json       ← фактическая конфигурация контейнера
│   └── hashes.txt         ← контрольные суммы доказательств
│
└── agent-sandbox
    ├── network_mode: none
    ├── UID/GID: 65532
    ├── root filesystem: read-only
    ├── capabilities: dropped
    ├── no-new-privileges
    ├── PID/memory/CPU limits
    ├── /tmp: isolated tmpfs
    ├── /bait: read-only synthetic tokens
    └── probe.py

Сетевой namespace содержит только loopback-интерфейс. Прямого маршрута, DNS-сервера и NAT нет. Даже если Python, curl или пакетный менеджер знают адрес назначения, ядро не может доставить пакет.

Снаружи к PID контейнера подключается strace. Такой журнал является частью наблюдаемости стенда: проверяемый код не выбирает, какие системные вызовы в него попадут. Для полноценной производственной телеметрии вместо длительного strace обычно используют аудит ядра или eBPF, но для короткой лаборатории трассировка проще и воспроизводимее.

Шаг 1. Подготовьте отдельный каталог

Нужна Linux-машина с rootful Docker, Compose plugin, strace, jq и OpenSSL. Не проводите тест на рабочем runner, где уже доступны реальные секреты.

mkdir sandbox-egress-lab
cd sandbox-egress-lab

mkdir -p app bait evidence
chmod 700 bait evidence

docker version
docker compose version
strace --version
jq --version
openssl version

Итоговая структура будет такой:

sandbox-egress-lab/
├── app/
│   ├── Dockerfile
│   └── probe.py
├── bait/
│   └── cloud_credentials
├── evidence/
├── compose.yaml
├── verify.py
└── .env

Если хост управляется общей командой, создайте для лаборатории отдельную виртуальную машину. Root-доступ к Docker фактически равен административному доступу к хосту, поэтому пользователь, запускающий стенд, тоже входит в доверенную часть эксперимента.

Шаг 2. Создайте проверочный процесс

Сохраните следующий файл как app/probe.py. Он не сканирует реальные адреса и не пытается эксплуатировать уязвимости. Все сетевые назначения либо зарезервированы для документации, либо относятся к стандартному link-local metadata адресу, недоступному из нашего namespace.

import ctypes
import errno
import hashlib
import json
import os
import socket
import time
from pathlib import Path

EVENTS = []

def emit(test, status, detail, **extra):
    event = {
        "time_ns": time.time_ns(),
        "test": test,
        "status": status,
        "detail": detail,
        **extra,
    }
    EVENTS.append(event)
    print(json.dumps(event, ensure_ascii=False), flush=True)

def fingerprint(value):
    return hashlib.sha256(value.encode()).hexdigest()[:16]

def try_tcp(name, host, port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(2)
    try:
        sock.connect((host, port))
    except OSError as exc:
        emit(name, "blocked", type(exc).__name__, errno=exc.errno)
    else:
        emit(name, "connected", f"{host}:{port}")
    finally:
        sock.close()

def try_dns(canary):
    label = fingerprint(canary)
    hostname = f"{label}.egress-check.invalid"
    try:
        socket.getaddrinfo(hostname, 443, type=socket.SOCK_STREAM)
    except socket.gaierror as exc:
        emit("dns_canary", "blocked", str(exc), canary_fp=label)
    else:
        emit("dns_canary", "resolved", hostname, canary_fp=label)

def try_unix_socket():
    path = "/var/run/docker.sock"
    if not Path(path).exists():
        emit("docker_socket", "blocked", "socket_absent")
        return
    sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    try:
        sock.connect(path)
    except OSError as exc:
        emit("docker_socket", "blocked", type(exc).__name__, errno=exc.errno)
    else:
        emit("docker_socket", "connected", path)
    finally:
        sock.close()

def try_raw_socket():
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP)
    except OSError as exc:
        emit("raw_socket", "blocked", type(exc).__name__, errno=exc.errno)
    else:
        emit("raw_socket", "created", "unexpected")
        sock.close()

def try_root_write():
    path = Path("/sandbox-escape-marker")
    try:
        path.write_text("synthetic marker", encoding="utf-8")
    except OSError as exc:
        emit("root_write", "blocked", type(exc).__name__, errno=exc.errno)
    else:
        emit("root_write", "written", str(path))
        path.unlink(missing_ok=True)

def try_namespace_calls():
    libc = ctypes.CDLL(None, use_errno=True)
    clone_newuser = 0x10000000
    clone_newnet = 0x40000000

    result = libc.unshare(clone_newuser | clone_newnet)
    if result == -1:
        code = ctypes.get_errno()
        emit("unshare", "blocked", os.strerror(code), errno=code)
    else:
        emit("unshare", "created", "new user and network namespaces")

    namespace_path = "/proc/1/ns/net"
    fd = os.open(namespace_path, os.O_RDONLY)
    try:
        result = libc.setns(fd, 0)
        if result == -1:
            code = ctypes.get_errno()
            emit("setns", "blocked", os.strerror(code), errno=code)
        else:
            emit("setns", "joined", namespace_path)
    finally:
        os.close(fd)

def try_mount():
    libc = ctypes.CDLL(None, use_errno=True)
    target = b"/tmp"
    result = libc.mount(
        b"tmpfs",
        target,
        b"tmpfs",
        0,
        b"size=1m"
    )
    if result == -1:
        code = ctypes.get_errno()
        emit("mount", "blocked", os.strerror(code), errno=code)
    else:
        emit("mount", "mounted", "/tmp")

def inspect_runtime():
    status = Path("/proc/self/status").read_text(encoding="utf-8")
    fields = {}
    for line in status.splitlines():
        if line.startswith(("Uid:", "Gid:", "CapEff:", "NoNewPrivs:", "Seccomp:")):
            key, value = line.split(":", 1)
            fields[key] = value.strip()
    emit("runtime_status", "observed", fields)

    numeric_pids = sorted(
        entry.name for entry in Path("/proc").iterdir()
        if entry.name.isdigit()
    )
    emit("pid_namespace", "observed", numeric_pids[:20], count=len(numeric_pids))

def main():
    delay = int(os.environ.get("START_DELAY_SECONDS", "20"))
    emit("startup", "waiting", f"{delay}s before probes")
    time.sleep(delay)

    env_canary = os.environ.get("CANARY_API_TOKEN", "")
    file_canary = Path("/bait/cloud_credentials").read_text(
        encoding="utf-8"
    ).strip()

    emit(
        "canary_inventory",
        "observed",
        "synthetic values loaded",
        env_fp=fingerprint(env_canary),
        file_fp=fingerprint(file_canary),
        env_present=bool(env_canary),
        file_present=bool(file_canary),
    )

    try_tcp("direct_tcp", "192.0.2.10", 443)
    try_tcp("proxy_tcp", "192.0.2.20", 3128)
    try_tcp("metadata_tcp", "169.254.169.254", 80)
    try_dns(env_canary)
    try_unix_socket()

    try_raw_socket()
    try_root_write()
    try_namespace_calls()
    try_mount()
    inspect_runtime()

    failures = [
        event["test"] for event in EVENTS
        if event["status"] in {
            "connected", "resolved", "created",
            "written", "joined", "mounted"
        }
    ]
    emit(
        "summary",
        "pass" if not failures else "fail",
        "all boundary checks completed",
        failures=failures,
    )
    raise SystemExit(0 if not failures else 1)

if __name__ == "__main__":
    main()

В события не записывается значение приманки. Вместо него сохраняются первые 16 символов SHA-256. Это не защита настоящего слабого секрета от перебора, но для случайной длинной лабораторной строки позволяет сопоставить события, не размножая значение по журналам.

Шаг 3. Соберите минимальный образ

Сохраните файл app/Dockerfile:

FROM python:3.12-slim

RUN groupadd --gid 65532 sandbox \
    && useradd \
       --uid 65532 \
       --gid 65532 \
       --no-create-home \
       --home-dir /nonexistent \
       --shell /usr/sbin/nologin \
       sandbox

WORKDIR /app
COPY --chown=65532:65532 probe.py /app/probe.py

USER 65532:65532
ENTRYPOINT ["python", "-I", "/app/probe.py"]

Флаг -I включает изолированный режим Python: текущий каталог и пользовательские site-packages не добавляются в путь импорта, а переменные PYTHON* игнорируются. Это не security boundary, но уменьшает число неявных источников кода.

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

Шаг 4. Создайте синтетические приманки

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

umask 077

ENV_CANARY="lab_env_$(openssl rand -hex 24)"
FILE_CANARY="lab_file_$(openssl rand -hex 24)"

printf 'CANARY_API_TOKEN=%s\n' "$ENV_CANARY" > .env
printf '%s\n' "$FILE_CANARY" > bait/cloud_credentials

unset ENV_CANARY FILE_CANARY

chmod 600 .env bait/cloud_credentials
ls -l .env bait/cloud_credentials

Файл .env и каталог bait не следует добавлять в Git, хотя значения и синтетические. Приманки полезны, когда у каждой есть идентификатор владельца, стенда и срока жизни, но эта информация должна храниться отдельно от самого значения.

В производственной проверке событием считается не просто чтение приманки. Агент может законно перечислять переменные или искать конфигурацию. Более сильный сигнал — попытка использовать значение в сетевом запросе, аргументе дочернего процесса, DNS-имени или управляющем API.

Шаг 5. Задайте ограничения контейнера

Сохраните compose.yaml:

services:
  agent:
    build:
      context: ./app
    container_name: agent-sandbox-egress-lab
    network_mode: none
    user: "65532:65532"
    read_only: true
    init: true

    cap_drop:
      - ALL

    security_opt:
      - no-new-privileges:true

    pids_limit: 64
    mem_limit: 256m
    cpus: 1.0

    environment:
      START_DELAY_SECONDS: "20"
      CANARY_API_TOKEN: "${CANARY_API_TOKEN}"
      HTTP_PROXY: "http://192.0.2.20:3128"
      HTTPS_PROXY: "http://192.0.2.20:3128"
      ALL_PROXY: "socks5://192.0.2.30:1080"
      NO_PROXY: ""

    volumes:
      - type: bind
        source: ./bait
        target: /bait
        read_only: true

    tmpfs:
      - /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777

    logging:
      driver: json-file
      options:
        max-size: "5m"
        max-file: "2"

Каждая настройка закрывает отдельный класс ошибок:

  • network_mode: none удаляет внешний сетевой маршрут, а не просит приложение не пользоваться сетью;
  • user исключает запуск от root внутри контейнера;
  • read_only запрещает изменение образа и запись в корень;
  • cap_drop: ALL убирает Linux capabilities, включая управление сетью и монтирование;
  • no-new-privileges не позволяет получить дополнительные права через setuid/setgid-файлы;
  • лимиты PID, памяти и CPU уменьшают последствия fork bomb и ресурсного истощения;
  • tmpfs даёт ограниченную рабочую область без исполнения бинарников;
  • единственный bind mount доступен только для чтения и содержит лишь приманку.

В конфигурации намеренно присутствуют proxy-переменные. Они показывают важное свойство: знание адреса прокси не помогает, если сетевой namespace не имеет маршрута. В реальной системе переменные всё равно следует очищать, потому что после будущего изменения сетевой конфигурации они могут внезапно стать рабочим обходом.

Шаг 6. Проверьте конфигурацию до запуска

Сначала отрендерите Compose-конфигурацию и убедитесь, что переменная приманки подставилась. Не публикуйте вывод команды: он содержит синтетическое значение.

docker compose config > /dev/null
docker compose build --pull=false

Запустите контейнер в фоне. Проверочный процесс ждёт 20 секунд, чтобы вы успели подключить внешний наблюдатель:

docker compose up -d --no-recreate

CONTAINER_ID="$(docker compose ps -q agent)"
test -n "$CONTAINER_ID"

docker inspect "$CONTAINER_ID" > evidence/inspect.json

HOST_PID="$(docker inspect \
  --format '{{.State.Pid}}' \
  "$CONTAINER_ID")"

test "$HOST_PID" -gt 1
printf 'container=%s host_pid=%s\n' \
  "$CONTAINER_ID" "$HOST_PID"

Теперь подтвердите ключевые свойства непосредственно из фактического состояния Docker, а не только из YAML:

jq -e '.[0].HostConfig.NetworkMode == "none"' \
  evidence/inspect.json

jq -e '.[0].HostConfig.ReadonlyRootfs == true' \
  evidence/inspect.json

jq -e '.[0].Config.User == "65532:65532"' \
  evidence/inspect.json

jq -e '.[0].HostConfig.CapDrop | index("ALL") != null' \
  evidence/inspect.json

jq -e '
  .[0].HostConfig.SecurityOpt
  | index("no-new-privileges:true") != null
' evidence/inspect.json

jq -e '
  all(.[0].Mounts[];
    (.Destination != "/var/run/docker.sock")
    and (.Destination != "/run/containerd/containerd.sock")
    and (.Destination != "/")
  )
' evidence/inspect.json

Если любая команда возвращает ненулевой код, остановитесь. Не интерпретируйте последующий тест как проверку требуемой конфигурации: вы запустили другой стенд.

Шаг 7. Подключите журнал системных вызовов

Пока процесс находится в задержке, подключите strace к PID контейнера:

sudo strace \
  -ff \
  -ttt \
  -s 256 \
  -yy \
  -e trace=network,mount,setns,unshare,execve \
  -o evidence/syscalls \
  -p "$HOST_PID"

Команда останется на переднем плане до завершения процесса. Если контейнер уже закончил работу до подключения, увеличьте START_DELAY_SECONDS, пересоздайте его и повторите. Не считайте пустой журнал доказательством отсутствия попыток.

После завершения сохраните журнал приложения и код выхода:

docker logs "$CONTAINER_ID" > evidence/events.jsonl 2>&1

EXIT_CODE="$(docker inspect \
  --format '{{.State.ExitCode}}' \
  "$CONTAINER_ID")"

printf '%s\n' "$EXIT_CODE" > evidence/container-exit-code.txt

sha256sum \
  evidence/inspect.json \
  evidence/events.jsonl \
  evidence/container-exit-code.txt \
  evidence/syscalls* \
  > evidence/hashes.txt

Не подставляйте в отчёт ожидаемый вывод из этой статьи. Фактическими результатами являются только файлы, созданные на вашей машине. Версия ядра, Docker, seccomp-профиль и настройки хоста могут изменить конкретный errno, хотя итоговое решение останется «разрешено» или «запрещено».

Шаг 8. Добавьте автоматическую проверку

Сохраните следующий файл как verify.py:

import json
import sys
from pathlib import Path

EVENT_FILE = Path("evidence/events.jsonl")
INSPECT_FILE = Path("evidence/inspect.json")

required_blocked = {
    "direct_tcp",
    "proxy_tcp",
    "metadata_tcp",
    "dns_canary",
    "docker_socket",
    "raw_socket",
    "root_write",
    "unshare",
    "setns",
    "mount",
}

dangerous_statuses = {
    "connected",
    "resolved",
    "created",
    "written",
    "joined",
    "mounted",
}

def fail(message):
    print(f"FAIL: {message}")
    raise SystemExit(1)

if not EVENT_FILE.exists():
    fail("events.jsonl is missing")

events = []
for number, line in enumerate(
    EVENT_FILE.read_text(encoding="utf-8").splitlines(),
    start=1,
):
    try:
        events.append(json.loads(line))
    except json.JSONDecodeError as exc:
        fail(f"invalid JSON on line {number}: {exc}")

by_test = {}
for event in events:
    by_test.setdefault(event.get("test"), []).append(event)

missing = sorted(required_blocked - set(by_test))
if missing:
    fail(f"missing checks: {missing}")

dangerous = [
    event for event in events
    if event.get("status") in dangerous_statuses
]
if dangerous:
    fail(f"boundary violation: {dangerous}")

for test in sorted(required_blocked):
    statuses = {event.get("status") for event in by_test[test]}
    if statuses != {"blocked"}:
        fail(f"{test} has unexpected statuses: {sorted(statuses)}")

inventory = by_test.get("canary_inventory", [])
if len(inventory) != 1:
    fail("exactly one canary_inventory event is required")

if not inventory[0].get("env_present"):
    fail("environment canary was not loaded")

if not inventory[0].get("file_present"):
    fail("file canary was not loaded")

summaries = by_test.get("summary", [])
if len(summaries) != 1 or summaries[0].get("status") != "pass":
    fail("probe summary is not pass")

inspect = json.loads(INSPECT_FILE.read_text(encoding="utf-8"))[0]
host = inspect["HostConfig"]
config = inspect["Config"]

if host.get("NetworkMode") != "none":
    fail("network mode is not none")

if host.get("ReadonlyRootfs") is not True:
    fail("root filesystem is writable")

if config.get("User") != "65532:65532":
    fail("container user is unexpected")

if "ALL" not in (host.get("CapDrop") or []):
    fail("capabilities were not fully dropped")

security = host.get("SecurityOpt") or []
if "no-new-privileges:true" not in security:
    fail("no-new-privileges is missing")

for mount in inspect.get("Mounts", []):
    destination = mount.get("Destination")
    if destination in {
        "/",
        "/var/run/docker.sock",
        "/run/containerd/containerd.sock",
    }:
        fail(f"dangerous mount: {destination}")

print("PASS: configuration and probe events satisfy the lab policy")

Запустите проверку:

python3 verify.py
test "$(cat evidence/container-exit-code.txt)" = "0"

После этого отдельно убедитесь, что внешний журнал действительно содержит контрольные вызовы:

grep -hE \
  'connect\\(|socket\\(|unshare\\(|setns\\(|mount\\(' \
  evidence/syscalls* \
  > evidence/relevant-syscalls.txt

test -s evidence/relevant-syscalls.txt
sed -n '1,120p' evidence/relevant-syscalls.txt

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

Ручная верификация доказательств

Даже при зелёном результате просмотрите четыре независимых источника.

1. Фактическая конфигурация

В inspect.json должны быть видны NetworkMode: none, read-only rootfs, непривилегированный UID, сброшенные capabilities и отсутствие опасных mount points. Проверьте также, что не включены Privileged, PidMode: host, IpcMode: host и UsernsMode: host:

jq '.[0].HostConfig | {
  Privileged,
  NetworkMode,
  PidMode,
  IpcMode,
  UsernsMode,
  ReadonlyRootfs,
  CapAdd,
  CapDrop,
  SecurityOpt
}' evidence/inspect.json

2. События процесса

Каждый обязательный тест должен присутствовать ровно один раз. Статусы connected, resolved, created, written, joined и mounted являются провалом независимо от итоговой строки summary.

jq -r '[.test, .status, .detail] | @tsv' \
  evidence/events.jsonl

3. Журнал вне песочницы

Найдите обращения к тестовым адресам и metadata endpoint:

grep -hE \
  '192\\.0\\.2\\.(10|20)|169\\.254\\.169\\.254' \
  evidence/syscalls*

Наличие вызова connect здесь ожидаемо: стенд должен доказать, что попытка была сделана. Решающее значение имеет результат системного вызова. Установленное соединение или отсутствие ошибки означает провал.

4. Изоляция процессов

Событие pid_namespace не должно показывать полный список процессов хоста. Само количество процессов не является универсальным порогом: init-процесс и трассировка могут менять картину. Сравните список с ожидаемой структурой контейнера и проверьте, что PidMode не равен host.

Шаг 9. Проведите отрицательные контрольные тесты

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

Контроль A: обычная bridge-сеть

Во временной копии конфигурации удалите network_mode: none. Не меняйте тестовые адреса на реальные. Запустите стенд снова и сравните inspect.json. Проверка конфигурации обязана завершиться с ошибкой ещё до интерпретации сетевых событий.

Этот контроль не обязан устанавливать соединение с зарезервированным IP. Его задача — доказать, что policy-check замечает появление маршрутизируемой сети.

Контроль B: writable root filesystem

Во временной конфигурации установите read_only: false. Тест root_write должен заметить возможность создать файл, а verify.py — завершиться с ошибкой. После теста удалите временный контейнер вместе с записываемым слоем.

Контроль C: добавленная capability

Не используйте privileged: true. Для ограниченного отрицательного контроля временно замените cap_drop: ALL на:

cap_drop:
  - ALL
cap_add:
  - NET_RAW

Проверка raw socket должна обнаружить созданный сокет. Этот тест показывает, что сценарий действительно достигает чувствительной операции, а не всегда печатает заранее заданное blocked.

Контроль D: фиктивный Unix-сокет

Не монтируйте настоящий Docker socket. Вместо него можно добавить отдельный безопасный Unix-сервер с тестовым путём и изменить путь в копии probe.py. Проверка должна отличить «сокет отсутствует» от успешного локального IPC-соединения.

Как отдельно проверить разрешённый egress-прокси

Режим network_mode: none подходит агенту, которому интернет не нужен. Если рабочая задача требует нескольких внешних API, не переключайте контейнер на неограниченную bridge-сеть. Добавьте отдельный allowlist-шлюз и проверяйте два уровня:

  1. на сетевом уровне контейнер может соединиться только с IP и портом шлюза;
  2. шлюз разрешает только конкретные схемы, методы, домены, пути и объёмы данных.

Минимальная матрица тестов для прокси:

Запрос Ожидаемое решение Что зафиксировать
Разрешённый домен и путь Разрешить Идентификатор агента, правило, объём
Другой домен Запретить Запрошенный host и правило отказа
IP вместо домена Запретить Исходный IP и назначение
HTTP redirect на другой host Запретить переход Оба назначения
CONNECT к произвольному порту Запретить Host, порт и решение
Домен с разрешённым суффиксом как подстрокой Запретить Нормализованное имя
Ответ DNS с private/link-local IP Запретить Разрешённые IP после резолвинга
Большое тело с отпечатком приманки Запретить и поднять сигнал Размер, fingerprint, run ID

Прокси не должен принимать решение только по заголовку Host. Нужны нормализация имени, повторная проверка адреса после DNS, запрет private, loopback и link-local диапазонов, контроль redirect и защита от DNS rebinding. TLS passthrough ограничивает возможность анализировать путь и тело; TLS inspection добавляет собственные риски и требует отдельной модели доверия.

Для агентов с package manager лучше выделять внутренний репозиторий с заранее одобренными артефактами, а не предоставлять универсальный HTTP CONNECT. Установка пакета должна происходить на этапе сборки образа, а не во время выполнения недоверенной задачи.

Проверка путей выхода из песочницы

Сетевые тесты не заменяют проверку самой среды выполнения. Перед допуском образа соберите отдельную таблицу границ:

Поверхность Безопасное состояние Как проверить
Docker/containerd socket Не смонтирован docker inspect, поиск Unix-сокетов
Capabilities Сброшены, исключения документированы CapEff, фактические негативные тесты
Seccomp Не отключён /proc/self/status, конфигурация runtime
AppArmor/SELinux Профиль включён и не unconfined Настройки хоста и container inspect
PID namespace Не host PidMode, список /proc
User namespace Root внутри не равен root хоста Настройки daemon/runtime
Bind mounts Минимальны и read-only Полный список Mounts
Устройства Нет добавленных host devices Devices и device cgroup
Runtime и ядро Поддерживаемые исправленные версии Инвентаризация и внутренний vulnerability scan

Попытка unshare в лаборатории подтверждает только текущую комбинацию UID, capabilities, seccomp и настроек ядра. Она не доказывает отсутствие неизвестной цепочки уязвимостей. Для задач с действительно недоверенным кодом усилением могут быть отдельная виртуальная машина, microVM или sandboxed runtime: тогда успешный выход из контейнера всё ещё встречает следующую независимую границу.

Типичные провалы и их интерпретация

Контейнер не имеет прямого интернета, но DNS работает

Это не полный запрет egress. DNS-запросы могут переносить небольшие объёмы данных в именах. Уберите DNS из namespace без сети либо разрешайте резолвинг только через контролируемый resolver с журналированием, ограничением длины, частоты и допустимых зон.

Прямой адрес заблокирован, proxy-соединение установлено

Политика применена к назначению, но не к посреднику. Ограничьте сам proxy на уровне сетевой политики и настройте application-layer allowlist. Проверьте переменные окружения, .npmrc, pip.conf, Git config, Maven settings и системные конфигурации.

Metadata endpoint отвечает

Песочница наследует маршрут облачной среды. Заблокируйте link-local диапазон сетевой политикой и отключите выдачу workload credentials там, где она не нужна. Если токен необходим, его полномочия должны соответствовать одной операции и иметь короткий срок жизни.

Docker socket отсутствует, но найден другой управляющий сокет

Проверьте containerd, CRI-O, Kubernetes service account, SSH-agent, D-Bus, X11/Wayland и сокеты внутренних автоматизаций. Allowlist mount points должен быть явным; поиск только одного известного пути недостаточен.

Raw socket создаётся

У процесса сохранилась CAP_NET_RAW или эквивалентное разрешение. Оно может использоваться для нестандартных протоколов и сетевой разведки. Удалите capability, пересоздайте контейнер и повторите тест.

unshare разрешён непривилегированному пользователю

Это не всегда немедленный escape, но увеличивает доступную поверхность ядра. Решение зависит от workload и дистрибутива: ограничьте системный вызов seccomp-профилем или параметрами хоста, если агенту не требуется создание namespaces.

Проверка прошла, но strace пуст

Нет независимого доказательства, что попытки действительно происходили. Возможно, наблюдатель подключился слишком поздно, PID был неверным или attach запретил Yama. Увеличьте задержку, подтвердите PID через docker inspect и повторите.

Приманка попала в обычный лог

Даже синтетические значения не следует бесконтрольно копировать. Настройте редактирование логов и сохраняйте идентификатор или fingerprint. Для рабочих секретов журналирование полного значения недопустимо.

Все негативные тесты заблокированы, но полезная задача не работает

Это безопасный, но бесполезный стенд. Для рабочего агента добавьте отдельный положительный контроль: доступ к одному фиктивному внутреннему API через точное разрешение. Проверяйте одновременно, что разрешённый маршрут работает, а соседний host, порт, метод и redirect блокируются.

Перенос проверки в производственный контур

Лабораторный рабочий процесс можно превратить в проверку перед выпуском образа:

  1. собрать образ по закреплённым зависимостям;
  2. запустить его с уникальной парой приманок;
  3. применить ту же сетевую политику и runtime-профиль, что в рабочей среде;
  4. выполнить отрицательные и положительные сценарии;
  5. собрать события сети, процессов, файловой системы и policy engine;
  6. связать записи общим run_id;
  7. завершить проверку ошибкой при отсутствующем событии, неизвестном результате или расширении полномочий;
  8. сохранить версии образа, runtime, ядра и правил вместе с доказательствами.

Для долгоживущих агентов добавьте непрерывные сигналы:

  • первое соединение с новым назначением;
  • обращение к private, loopback или link-local диапазону;
  • необычный объём исходящих данных;
  • частые DNS-запросы с длинными уникальными метками;
  • запуск shell, package manager, сетевого диагностического инструмента или нового интерпретатора;
  • доступ к файлам приманок;
  • создание Unix-сокета или обращение к управляющему сокету;
  • изменение namespace, capabilities или seccomp-состояния;
  • расхождение между разрешением policy engine и фактическим сетевым событием.

Сигнал не обязательно означает атаку. Например, агент разработки может законно запускать shell. Ценность появляется при корреляции: чтение приманки, запуск неизвестного процесса и попытка соединения в одном run_id значительно важнее каждого события по отдельности.

Для высокорисковых действий добавьте контур подтверждения. Подтверждение должно быть связано с точным доменом, методом, объёмом и сроком разрешения. Формулировка «разрешить агенту интернет» слишком широка и не должна использоваться как approval.

Ограничения лаборатории

  • Стенд проверяет Linux-контейнер под Docker и не переносится автоматически на macOS, Windows, Kubernetes или serverless runtime.
  • network_mode: none доказывает отсутствие обычного сетевого маршрута, но не анализирует уязвимости ядра и гипервизора.
  • strace подходит для короткой проверки, но создаёт накладные расходы и не является полноценной системой длительного мониторинга.
  • Тестовые адреса не доказывают корректность allowlist-прокси: для него нужна отдельная матрица разрешённых и запрещённых назначений.
  • Приманки обнаруживают только сценарии, в которых код их читает или использует. Агент может украсть другой файл, не касаясь приманки.
  • Fingerprint в журнале не заменяет защищённое хранилище секретов и не должен применяться к коротким предсказуемым значениям.
  • Успешный тест одной версии образа не гарантирует безопасность следующей версии runtime, ядра, зависимости или конфигурации.
  • Лаборатория не проверяет побочные каналы через время выполнения, нагрузку на общие ресурсы, кэш CPU или аппаратные уязвимости.
  • Нет теста на межконтейнерную сеть, service mesh и DNS rebinding, поскольку базовый режим полностью отключает сеть.
  • Нельзя доказать отсутствие неизвестной цепочки уязвимостей одним набором функциональных проверок.

Поэтому итог следует формулировать точно: «при зафиксированной конфигурации и перечисленных сценариях произвольный исходящий трафик и проверенные операции повышения полномочий были запрещены». Формулировка «песочница неуязвима» не подтверждается этим экспериментом.

Очистка стенда

Сначала остановите и удалите лабораторный контейнер:

docker compose down --remove-orphans

Проверьте точный каталог перед удалением приманок. Если доказательства нужны для отчёта, сохраните evidence в контролируемом хранилище, затем удалите только локальные синтетические значения:

pwd
ls -l .env bait/cloud_credentials
shred -u .env bait/cloud_credentials 2>/dev/null \
  || rm -f .env bait/cloud_credentials

Приманки не дают доступа к сервисам, но их удаление предотвращает повторное использование одной строки в нескольких прогонах. Для следующего запуска создайте новую пару.

Финальный чек-лист воспроизводимости

  • Стенд запущен на отдельной машине или в отдельной виртуальной среде.
  • Внутри нет рабочих токенов, SSH-ключей, облачных профилей и исходных данных.
  • Фактический NetworkMode равен none.
  • Контейнер работает от UID/GID 65532, а не от root.
  • Корневая файловая система доступна только для чтения.
  • Все capabilities сброшены.
  • no-new-privileges включён.
  • Docker, containerd и другие управляющие сокеты не смонтированы.
  • Host PID, IPC и network namespaces не используются.
  • Приманки уникальны для запуска и не обладают реальными полномочиями.
  • В журнал попадают только отпечатки приманок, а не значения.
  • Все сетевые и привилегированные сценарии действительно выполнены.
  • Внешний наблюдатель зафиксировал системные вызовы.
  • Ни один опасный статус не появился в events.jsonl.
  • Автоматическая проверка замечает намеренно ослабленную конфигурацию.
  • Версии образа, Docker, ядра и профилей сохранены с результатом.
  • Контрольные суммы доказательств рассчитаны после завершения запуска.
  • Вывод отчёта ограничен проверенными свойствами, без заявления об абсолютной безопасности.

Вывод

Надёжная песочница строится не из одной настройки. Сетевой namespace убирает маршрут, непривилегированный пользователь и capabilities ограничивают системные операции, read-only filesystem уменьшает возможность закрепления, приманки показывают интерес к ценным данным, а внешний журнал позволяет доказать попытку независимо от поведения агента.

Главный принцип проверки — отделять намерение от результата. Агент должен суметь попытаться установить соединение, обратиться к прокси, прочитать приманку или вызвать mount. Защита считается работающей только тогда, когда попытка видна снаружи, чувствительная операция не выполнена, а автоматическая проверка отклоняет неполный или двусмысленный прогон.

Начинайте с режима без сети. Если интернет действительно нужен, добавляйте не общую связность, а один контролируемый шлюз с точным allowlist, журналированием и короткоживущими разрешениями. Любое новое исключение должно сопровождаться положительным тестом полезного маршрута и отрицательными тестами соседних направлений.

← Все практические материалы · Лабораторный словарь →