Ізоляція коду студентів, або Складний шлях до безпечного запуску чужого коду

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Всім привіт! Останні чотири роки я працюю в Edtech-стартапі Mate academy. Ми допомагаємо вивчати програмування та знаходити першу роботу в ІТ. Щоб наші студенти навчились писати код, треба, щоб вони писали код. Що логічно 🙂

Тому ми розробили для них просунуту LMS-платформу, де вони і вчаться його писати. Цей блог буде саме про цю LMS-платформу. А точніше про мій досвід її написання з безпечним запуском коду, якому, як виявилось в процесі, не можна довіряти.

Також розповім про рішення як на стороні застосунку, так й інфраструктурні, які можуть стати корисними в подібних ситуаціях, як наша. Запуск чужого коду — не щоденна задача, але вона виникає, а інформації про цю тему не так багато. Тому сподіваюсь, що цей матеріал стане в пригоді тим, хто стикнеться зі схожими потребами. Також на власних помилках укотре нагадаю, що безпека завжди повинна бути на першому місці.

Дисклеймер: в англомовній літературі є термін untrusted code. Прямий переклад «код, якому не можна довіряти». Це як «Той, що біжить по лезу», бо не буває у нас того дієприслівника. Але так писати — дуже довго, тому в тексті використовую «чужий код», хоч воно мені теж не дуже подобається.

З чого все починалось

Зараз на нашій платформі реалізовано десятки корисних функцій, які допомагають студентам не тільки вивчати програмування, а й залишатись вмотивованими та комунікувати з іншими. Та починалось все з простого маленького MVP. У далекому 2018-му задача звучала приблизно так: «Зробити, щоб студенти писали код на нашому сайті, без сторонніх ресурсів».

Звісно треба зберігати результати в базі даних і валідувати код на сервері, щоб не було можливості «обманути» систему. На щастя, я одразу подумав, що запускати код, який написали студенти безпосередньо в нашому моноліті, — погана ідея. Так почався пошук іншого, безпечного, ізольованого запуску коду наших студентів.

Варіанти безпечного запуску небезпечного коду

Перша група варіантів — повністю інфраструктурні рішення

Ми всі довіряємо докеру, віртуальним машинам, Kubernetes і подібним інструментам. Варіантів багато: від запуску dedicated-машин в Kubernetes cluster до Firecracker, Kata containers.

Наприклад, ми могли б зупинитись на Kubernetes варіанті:

Одна з вимог — це UX, близький до реального часу. А інфраструктура потребує багато часу, щоб запуститись.

І хоч є опції, які працюватимуть швидше, ніж просто запускати новий под за кліком на кнопку Run, можна організувати overprovisioning, щоб завжди була вільна машина, яка готова вже взяти задачу в роботу. Але однаково це буде недостатньо швидко, адже для користувача все має відбуватись майже миттєво. Та й зусиль на імплементацію і підтримку потрібно немало.

Другий варіант — це sandboxing

Тут все просто: ми запускаємо код в пісочниці та сподіваємося, що ніхто з нього не зможе вийти. Але зробити ідеальний sandbox майже неможливо, і навіть якщо таки вдасться, дуже швидко можна впертись у його обмеження, щоб зрештою запускатись десь окремо.

Як мінімум через те, що якщо ми хочемо таймаут в пʼять секунд на запуск тестів, тобто дозволяємо коду студентів пʼять секунд працювати синхронно — а це не ок, щоб API-сервер чекав стільки. Бо студент може написати while (true) {} і цілий API-под зупиниться і протягом цього часу не буде нічого опрацьовувати.

Третій варіант — гібридний варіант

Оскільки обидва попередні варіанти нам не підійшли, я зупинився на гібридному рішенні: Partial infrastructure isolation with AWS Lambda + Running code in a sandbox + Custom solutions to mitigate sandbox restrictions.

Тобто перш за все — це запуск коду на AWS Lambda.

Lambda functions стали чудовими інструментами для нашої задачі зі створення платформи для студентів, бо за замовчуванням вона має тільки права писати логи, read-only файлову систему та повністю автоматично і необмежено масштабується.

До того ж, це вже якась ізоляція, як мінімум від інших частин платформи. Тобто навіть якщо падає запуск коду — все інше працюватиме. Точніше навіть, якщо, умовно, JS впав, то з Python усе буде ок. Малесенький мікросервіс — чим лямбди, по суті, і є.

Це можна імплементувати й самостійно (як і все зрештою), але для чого, якщо за нас це вже зробили.

Вийшло приблизно ось так: frontend надсилає запит на API endpoint, а якщо бути точним — викликає GraphQL mutation.

Тоді API дістає з бази метадані та викликає Proxy Lambda. Це дуже проста річ, бо єдина її задача — викликати Worker Lambda для відповідної мови, провалідувати результат і обробити помилки.

Але AWS Lambda — не якись магічний інструмент. Це теж сервер, просто його адмініструє AWS, він теж опрацьовує багато запитів від різних користувачів. І вони можуть впливати одне на одного. Тому sandboxing також присутній.

Дуже круто, що Node.js уже має інструмент для запуску коду в ізоляції.

vm — це вбудований в Node.js модуль, який дозволяє запускати код в окремій V8 Virtual machine. Це дає окремий контекст, тобто global object. Але event loop і heap — ті самі.

Ось приклад як це працює:

Маємо код в рядку, передаємо його в метод runInNewContext, разом з ним передаємо контекст, який cпільно з return value буде способом спілкування з кодом, який запускається в sanbox.

Але в коду є можливість використовувати глобальні змінні з основного контексту, якщо ми не передали свої. Це дозволяє нам, наприклад, передати туди пустий об’єкт замість process. Аналогічно можна підмінити require на той, який дозволить імпортувати тільки певні модулі.

Але, на жаль, vm не є безпечним способом запускати чужий код. Sandbox легко можна покинути, навіть якщо закрити всі глобальні змінні, за допомогою прототипів, викидання помилок й інших способів.

На щастя, існує бібліотека vm2, яка додає більше безпеки до vm модуля за допомогою Proxy та інших механізмів. А також дає зручний інтерфейс для налаштування vm. Її я і використав в MVP. А з оновленням Node.js є ще кращий спосіб запускати небезпечний код — isolated-vm. Цей пакет написаний на C++ і може вважатись справді безпечним. Та на час розробки його ще не було.

Як змінювався мій код

Однак мати технологію — ще не означає використовувати її. Перейдімо до коду і того, як він змінювався.

Перш за все, маємо ініціалізацію пісочниці:

Ми створюємо пісочницю (на другому рядку) з потрібним нам контекстом. У цій пісочниці ми можемо запускати код багато разів, і ці запуски можуть змінювати глобальні змінні пісочниці.

Спочатку ми запускаємо код студента. Код — це завжди оголошення функції. Після запуску функція стає доступною глобально. Далі ми продовжуємо працювати з тою самою пісочницею.

Ми не повертаємо vm з функції, а функцію, яка буде використовуватись замість стандартного expect. Далі глянемо, як вона створюється.

Тут ми приймаємо код, який треба запустити, й очікуваний результат vm.run повертає значення, в яке перетворився запущений вираз. Далі — стандартний expect. Тут код, який ми запускаємо — це вже не код студента, а код автора задачі. У ньому зазвичай запускається імплементована студентом функція. Це відбувається в пісочниці, а результат повертаємо в основний контекст, де робимо звичайну для Unit-тестів перевірку. І, власне, сам тест:

У before блоці створюємо функцію, яка запускатиме код в sandbox та порівнюватиме його з результатом. А в кожному тесті використовуємо наш кастомний expect.

Це хороше рішення, яке чудово працювало для близько 50 задач, але... ніхто не хоче таким користуватись. Чому? Бо Unit-тести на код мають виглядати як Unit-тести. Без незрозумілих initVM та safeExpect.

Писати JS-код у рядку дуже незручно, якщо це щось трохи складніше, ніж функція суми. Також ми вирішили показувати код тестів студентам, а такий код точно не те, що допоможе їм зрозуміти, де помилка.

Тому сильним вольовим рішенням я видалив використання vm і почав просто запускати звичайні Unit-тести в лямбді. Усі тести до задач з кучерявим кодом вище були переписані на звичні й зрозумілі всім Unit-тести. І незадовго почалось веселе життя.

Якщо запускати студентський код не в sandbox, такий тривіальний код, як обнулення прототипу, робить життя цікавим і веселим.

Array.prototype = null — це тільки один варіант, і насправді способів все зламати багато. Тоді інстанс лямбди має невалідний JS runtime, і це триває, поки він не стане «холодним» і AWS його не покладе. А це 15 хвилин.

Спочатку це було майже непомітно. Хтось зі студентів зламав, ми трохи не запускались, і все виправлялось (проскочило хіба кілька помилок). Але з масштабуванням і ростом кількості користувачів та задач лямбда перестала ставати холодною взагалі.

Тому в цій ситуації головне завжди мати ноутбук з собою, щоб «передеплоїти лямбду». Достатньо змінити якісь параметри, і AWS підніме новий інстанс.

Геніальний костиль, або Як я вирішив піти кудись без ноуту

Однієї прекрасної суботи, коли я гуляв в Lavina Mall, лямбда зламалась і після перезапуску ламалась майже одразу знову. А я ще й був без ноута (можете собі уявити, який я оптиміст).

Тоді мій колега Вадим Ільченко придумав геніальний костиль. Я думаю, це абсолютний чемпіон зі всіх костилів. Пам’ятаєте проксі лямбду, яка була на початку?

Якщо воркер падає з помилкою — проксі лямбда її ловить, змінює опис лямбди або таймаут чи майже будь-який параметр ресурсу в AWS. Піднімається свіжий інстанс, і все знову працює.

Це, звісно, класно, але з таким довго жити не хотілось, і стало зрозуміло, що потрібно назад повертати пісочницю.

Нарешті рішення

Проблема попереднього рішення з vm була в тому, що sandbox був всередині тесту. А для UX потрібно, щоб тести були повністю у vm . Та проблема в тому, що як mocha , так і jest всередині vm не запускаються. Вони використовують process , os модуль і інші речі, які заборонені в пісочниці. Інакше — ні про яку безпеку мова не йтиме.

Жити я спокійно з такою ситуацією не міг, тому за вихідні написав свій тест-ранер. Не знаю чому, але я навіть не заглядав, як працюють наявні рішення, просто глянув на describe , it і подумав: «Як воно може працювати?».

І, насправді, все доволі просто. Усі глобальні функції з mocha просто беруть і складають колбеки в масив. Як ви могли помітити, в тестах ми одразу запускаємо describe , before та it, але безпосередньо не запускаємо власні колбеки. Це перша фаза, коли читаємо файл.

Це круте рішення, хто б його коли не придумав, бо ми можемо прочитати багато файлів і тоді запускати тести, можемо по одному. І це дуже класно лягає на vm sandbox — запустили там код тесту, і в глобальній змінній уже є масив колбеків. А другим етапом запускаємо їх в try/catch і складаємо кудись результати.

Ось вже більш-менш знайомий код створення пісочниці, тільки з додатковими рівнями абстракції. Тут ми створюємо sandbox, передаючи туди глобальні функції тест-ранера (before, it, describe, etc.) в змінній hooks. А також функцію run, яка запустить тести. І вона, і hooks мають у замиканні структуру даних з колбеками, які потрібно виконати, щоб запустити тести.

Після створення пісочниці виконуємо в ній кожен файл з тестами. Тест імпортує код студента і зберігає функцію в замиканні.

Глобальні хуки додають колбеки в деревовидну структуру даних, яка відповідає структурі тестів.

Частина, яка пропущена, щоб не робити приклади більш громіздкими — це EventEmitter, який використовується, щоб сповіщати про результати виконання тестів та інші події життєвого циклу. Ось такий тестовий файл

перетвориться в аналогічну структуру даних:

Зрештою весь код тестів виконується в пісочниці, а зовні ми отримуємо результати.

І цим кодом я дуже гордий.

На самописному тест-ранері правильно запустились тести приблизно 150 наявних тоді задач, без змін в тести чи еталонні рішення. Пощастило, що я вже зробив до того систему валідації задач для студентів.

Початковий код мав фейлити тести, а еталонне рішення — проходити. Відповідно всі рішення задач були, достатньо було все це прогнати, підмінивши ранер. Минуло пʼять років, і сьогодні це рішення продовжує чудово справлятись зі своєю задачею, а наша LMS-платформа є однією з причини, чому студенти приходять вчитись програмуванню та писати код саме до нас.

Висновки

  • Node.js має чудові бібліотеки для ізоляції коду. І якщо виникне схожа задача — є чим скористатись.
  • Завжди варто думати про користувачів, навіть якщо це адміністратори (які пишуть задачі). Робочого рішення недостатньо. Воно має бути зручним в користуванні.
  • Буває, що з’являється бажання зрізати кути і замість правильного рішення зробити швидке, яке нехтує безпекою чи має інші вади. Не варто 🙂 Це тільки додасть проблем в найближчому майбутньому.
👍ПодобаєтьсяСподобалось7
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Цікаво було дізнатись, що задача запуску untrusted code так досі і не вирішена. Років 7 назад вирішували її на роботі, і по суті зійшлись на першому описаному варіанті — запуск в контейнерах, chroot і здається щось для таймауту, по суті все на основі лінукс-інструментів. Перфоманс впирається в те скільки клієнтів вс скільки підготовлених контейнерів. Дуже наворочено і незручно, але по суті безпечно.

Звалити це на авс лямбди — це непогане бізнес рішення, але коли ваш бізнес — це продавати запуск untrusted коду (в нас так було), то очевидно неоптимальне.

А рішення на основі vm чи чого ще в nodejs виглядають гарно, але не скейляться на інші мови. В джаві до речі це ще простіше по ідеї, через те що з коробки є ще один рівень ізоляції — віртуальна машина.

Можливо, та скоріше за все не зрозумів питання топіку, деталей контексту...

А так, про безпечний санбокс в жс, мені є цікавим рішеня: (цє як офтоп з нятяком на ськюріті ) docs.deno.com/...​manual/basics/permissions

> Deno is secure by default. Therefore, unless you specifically enable it, a program run with Deno has no file, network, or environment access. Access to security sensitive functionality requires that permissions have been granted to an executing script through command line flags, or a runtime permission prompt. This is a major difference from Node, where dependencies are automatically granting full access to everything, introducing hidden vulnerabilities in your project.

> Since Deno provides no I/O access by default, it’s useful for running untrusted code and auditing third-party code. Мабуть якщо про жс це воно? Думаю ви скоріше за все знайомі.

питання про жс рантайм, не головна думка...
справжній ресеарч і мвп я би почав з github.com/topics/linux-namespaces

node.require(vm) ? what?

ізоляція unsafe calls of languages libs/api — думаю дуже важке рішення

docker/k8n/pods/sidecar — namespaces based infrastructure solution, it is not about runtime for single python/js/java isolated process

веду до того что АВС не зламають, а ось дедос-кид не напишуть?

загалом, так и не зрозумів як ви запускаєте untrusted customer source code
ps якось не згадували про seLinux/appArmor, policies & profiles

Ще можна запускати віртуальну js машину з Linux у браузері студента. Для якихось простих речей. І так, це не захищає від любителів щось хакнути

А чому не стандартний підхід, який можна описати трьома лексемами: chroot, sudo -u?

А якщо студент далі зробить Ctrl+D?

chroot це тільки обмежити доступ до файлової системи (і то з нюансами), а окрім цього ще треба обмеження на цпу і пам’ять, на час виконання і т.д. і т.п.

Треба нагнати студентів, щоб написали позитивних відгуків і перекрили конструктивну критику у коментах.

Тоді доведеться зробити їм знижку на повернення коштів за навчання. В такі скрутні часи.

Навіщо знижка? Раз вдалось переконати платити $4000+ протягом 3+ років то й в цьому переконають

Десь хтось писав, що від студентів не вимагають залишати відгук, а пропонують це зробити за знижку. Щось типу коли на товарі за 4000 міняють на цінних зі знижкою 3999

Замість написання цієї статті могли б навчити своїх студентів GitHub Actions й деплою проєктів на Netlify та Vercel, а самі б переглядали результати

Поки ваше рішення виглядає як велосипед

Авжеж. GitHub Actions + GitHub Codespaces, все вже зроблено — треба лише налаштувати DevSecOps CI/CD пайплайни із максимальним фокусом у Sec, щоб будь які тести виконувались в зовсім безправних контейнерах.

Може навіть зробити все це як шаблони, а студенти нехай створять собі кожен свою організацію в GitHub і там використовують оті шаблони. Тоді усі питання безпеки — то все буде питаннями які вирішують GitHub розробники, а студенти ще й отримують досвід налаштування інфраструктури девопс. Ну може студентам буде потрібно якісь гроші віддати гіт хабу, але ж там немає ніяких астрономічних сум і академія мабуть теж не безкоштовна.

А оці всі сандбокси — то все від лукавого — до першого студента-хакера, якому буде трошки нудно виконувати завдання, та дуже цікаво щось поламати. 20 років назад я бачив такого студента на однієї із онлайн олімпіад, де теж була автоматизація тестів у сандбоксі. На останньому етапі, хтось прислав експлойт на С із захованими в шифрований байт масив системними визовами, та стилізований у вигляді новорічної ASCII ялинки. Сайт олімпіади після цього тиждень лежав а оцю ASCII ялинку демонстрували та обговорювали на форумі.

А ще.. заходимо на vm2 сандбокс із статті у гітхаб і бачимо оце:
> TL;DR The library contains critical security issues and should not be used for production! The maintenance of the project has been discontinued. Consider migrating your code to isolated-vm.

Потім ідемо до цього isolated-vm і вже там бачимо декілька security-related багів та комент у readme:
> There are some major architectural changes which need to be added to improve the stability and security of the project.

Ой. :)

То виходить технічний рівень викладачів Mate Academy залишає бажати кращого й добре, що ви завчасно знайшли інформацію про наявність критичних вразливостей

Насправді то може і не та ліба, але це не важливо, бо воно все більш менш однаково працює. Сандбокси це взагалі не про секуріті, це про «хочу щось зробити а потім повернути все взад», тести якісь наприклад (тести trusted коду).

nodejs.org/...​l#vm-executing-javascript
> The node:vm module is not a security mechanism. Do not use it to run untrusted code.

Більш того — докер теж не про секуріті, а про ізоляцію trusted коду: security.stackexchange.com/...​andbox-for-untrusted-code

Лише hardware virtualization більш менш норм. Теж є баги (гуглити spectre, meltdown), але там вже треба дуже дуже великий інтерес (і гроші) мати, щоб ламати щось. Усе що дозволяє працювати із untrusted кодом працює через повноцінну віртуалізацію, включаючи всіх хмарних провайдерів, оті всі GitHub codespaces і все таке інше. А щоб не чекати на старт VM... ну треба мати hot-swap пул машин які вже працюють і лише чекають на дані. Якщо ви GutHub, то можете собі таке легко дозволити.

Доречі є доволі цікава технічна стаття від CloudFlare як вони розробляли свої Workers та вирішували частину цих проблем developers.cloudflare.com/...​reference/security-model

Є офіційна штука Github Classroom де оце

Може навіть зробити все це як шаблони, а студенти нехай створять собі кожен свою організацію в GitHub і там використовують оті шаблони

вже вирішено) Створюється репозиторій по шаблону в організації для студентів з необхідними правами доступу. До того ж від користувача вимагається мінімум дій.

Ми допомагаємо вивчати програмування та знаходити першу роботу в ІТ.

CS50 допомагає вивчати програмування початківцям бо CS50 це безкоштовний онлайн-курс від Гарварду!

Підписатись на коментарі