Безопасность
Клиент FiveM — это программа на чужом компьютере. Логгеры событий и Lua-исполнители — обычные инструменты, свободно доступные, а сервер, который безопасен только пока с ним разговаривает собственная страница телефона, небезопасен. Здесь описано, что это означает на практике и где живут правила.
Всё ниже было просмотрено обработчик за обработчиком, и в итоге нашлось одиннадцать проблем. Они перечислены в конце — не потому, что список интересен, а потому, что интересна его форма: четыре из них были одной и той же ошибкой в четырёх разных местах.
Затем тот же вопрос был задан с обратной стороны — не что клиент может отправить, а чему телефон верит из того, что ему отправлено, — и это правило шестое.
Шесть правил
Заголовок раздела «Шесть правил»1. Кто вы — приходит от сервера, а не из сообщения.
Phone.Handler(name, fn) вызывает fn(src, data, player), где src — это соединение, а player ищется по нему. Ни один обработчик не берёт у клиента идентификатор, номер телефона или персонажа, чтобы решить, чьих данных касаться. Получатель определяется по номеру телефона через базу данных; отправитель — всегда вызывающий.
Единственный раз, когда это было нарушено, mic_phone:boot был зарегистрирован как сам Phone.Boot — а вторым аргументом Phone.Boot является запись, из которой строится телефон, тогда как callback передаёт обработчику в качестве его второго аргумента всё, что прислал клиент. Любой мог запросить чей угодно телефон по имени. В server/tests.lua есть тест, который вызывает зарегистрированный callback с подготовленным персонажем и падает, если получает телефон.
2. События самого фреймворка — это не двери.
RegisterNetEvent открывает имя события для сети для этого ресурса. ESX вызывает esx:playerLoaded и esx:playerLogout локально через TriggerEvent и никогда их не открывает, — поэтому регистрация их здесь как сетевых событий заставила mic_phone принимать их от любого клиента, с любым id игрока внутри. Это попадает прямо в Phone.OnDropped: завершить звонок этого игрока, убрать его из рации, забыть его телефон.
Для всего, что фреймворк вызывает сам, используйте AddEventHandler. Линтер скажет вам обратное; он неправ, и если ему последовать, дыра вернётся.
3. Ничто из присланного клиентом не передаётся дальше в том виде, в каком пришло.
Всё, что будет сохранено или показано кому-то другому, пересобирается из известных нам полей, а не пересылается целиком: Nearby.Shared — для того, что один телефон передаёт другому, attachment() — для того, что несёт сообщение, запрос в services:request, элемент в citylist:save. Поле, которое никто не назвал, этот путь не переживает.
4. Адрес проверяется по хостам, которые использует этот сервер.
Photos.AllowedUrl — https, короче 600 символов и хост из ServerConfig.Media.Hosts. Через неё проходит каждая картинка, попадающая в строку базы данных или на экран другого игрока: камера, передача через Nearby, фото объявлений, вложения сообщений, посты и профили в соцсетях.
Без неё подготовленный клиент может поставить адрес по своему выбору перед каждым игроком, пролистывающим мимо, — а это способ собирать адреса всех на сервере, — или, поскольку эти колонки имеют тип LONGTEXT, поместить целую картинку внутрь самой ссылки.
5. У всего есть размер и частота.
DB.Fit(value, width) | строка, обрезанная по размеру своей колонки |
DB.Json(value, limit) | таблица в закодированном виде — или ничего, если она не поместится |
DB.SetJson | 128 КБ на блоб; самый большой реальный, который встречался, — 929 байт |
Phone.TooFast | 100 вызовов за 10 секунд на игрока |
Photos.Chunk | 3 загрузки одновременно, 24 МБ, очистка после минуты тишины |
Contacts.Most | 200 |
Calls.MostRecents | 100 |
MOST_ATTACHMENTS | 8 |
Размеры важнее, чем кажется. База данных работает в строгом режиме, поэтому значение шире колонки хотя бы на один символ не обрезается — вставка завершается ошибкой посреди всего остального, что делал этот обработчик. В переводе средств эта строка идёт после того, как деньги уже перемещены.
6. То, что написал другой игрок, — это текст, и страница построена так, чтобы он не мог перестать им быть.
Пять вышеуказанных — о том, что доходит до сервера. Это — о том, что доходит до телефона, и направление это более опасное: страница, отрисовывающая чужой профиль, — это та же страница, которая может вызывать каждый обработчик от имени своего владельца. Тег, который выполнится там, может прочитать сообщения этого игрока и переместить его деньги. Исполнитель ему не нужен — нужно имя.
Телефон отрисовывает себя шаблонными литералами, поэтому каждое значение — это разметка, если только что-то его не экранирует. Этим «чем-то» является escapeHtml в core/app.js, а rpEsc в городских приложениях — та же функция под локальным именем. Две половины:
Экранируйте в точке записи. Каждая интерполяция, несущая чьи-то слова, — имя, handle, подпись, заметка, район, адрес — проходит через одну из этих двух. Это проверено разбором каждого шаблонного литерала в html/, а не их чтением, потому что их 2918.
И не полагайтесь на это. html/index.html содержит Content-Security-Policy без 'unsafe-inline' в script-src. В этом телефоне нет ни одного встроенного скрипта и ни одного встроенного обработчика — каждый скрипт — это файл, — поэтому политика ничего не стоит и означает, что onerror, который страницу обманом заставили записать, просто не выполняется. Это та половина, которая не зависит от того, что следующий человек, пишущий функцию отрисовки, что-то вспомнит. server/web.js добавляет две вещи, которые <meta> сказать не может: frame-ancestors 'none', потому что ссылка на связанный телефон — это весь секрет, а этот телефон может отправлять деньги, и nosniff.
tests/verify-injection.js подаёт каждому приложению строку, которая была бы однозначно заметна, если бы перестала быть текстом, а затем ищет её в отрисованной странице.
И то, что сервер отправляет без запроса. shared/config/*.lua — это shared_scripts: каждая их строка доходит до клиента каждого игрока и может быть вычитана из игры с помощью исполнителя. В Config.Web хранилось имя, на котором отвечает этот сервер, адрес, на который указывает его QR-код, и — дважды — адрес электронной почты его владельца, ничего из чего клиент никогда не читал. Теперь они лежат в shared/server_config.lua, который fxmanifest.lua перечисляет в server_scripts и рядом с которым уже лежал пустой пример для поставки. Файл конфигурации — это не закрытое место, а настройки, описывающие этот сервер, — именно те, которые в нём держать не стоит.
Где легко ошибиться
Заголовок раздела «Где легко ошибиться»Аккуратная ветка и та, что рядом с ней. Четыре из одиннадцати были одной и той же ошибкой: функция, обрабатывавшая тот случай, который имели в виду, и пропускавшая другой. data: загружался, а url — нет, в четырёх отдельных местах, каждое из которых было написано в разное время человеком, только что аккуратно поработавшим тремя строками выше. Если вы добавляете ветку в любое из этих мест, спросите, что делает другая ветка.
Щедрый лимит — всё равно лимит. Список контактов ждёт запроса на каждый контакт, а вставка занимает около трети секунды на сервере, где это писалось: пятьсот контактов — это почти три минуты базы данных, удерживаемой одним запросом. Число было неверным не потому, что оно большое, — а потому, что его никто ни на что не умножил.
Правила живут в одной функции, а не в каждом обработчике. Всё из таблицы выше находится в одном месте. Это единственный способ, при котором правило переживает следующего человека, добавляющего обработчик.
Проверка, которая останавливается раньше времени, — не проверка. Photos.AllowedUrl читала хост и останавливалась на первом слеше, — а всё после этого слеша как раз и пишет клиент. https:// + разрешённый нами хост + /x.jpg" onerror="… проходил её, и телефон помещал эту строку в src элемента img у каждого игрока, открывшего объявление. Правило было верным; но её спрашивали о трети адреса.
Одиннадцать
Заголовок раздела «Одиннадцать»| Что это позволяло сделать | Где |
|---|---|
| Завершать звонок любого игрока и сбрасывать его рацию | esx:playerLogout, зарегистрированный как сетевое событие |
| Заполнять память сервера, пока он не упадёт | загрузки без лимита и без очистки |
| Перемещать деньги без записи и получать сообщение об ошибке | заметка шире своей колонки, в строгом режиме |
| Подставить любой адрес в фотобиблиотеку другого игрока | передачи через Nearby |
| Поставить любой адрес перед каждым игроком в городе | фото объявлений |
| Поставить любой адрес перед всеми в чате | вложения сообщений |
| Поставить любой адрес перед всеми в приложении | посты и профили в соцсетях |
| Вызывать любой обработчик так быстро, как получится отправлять | отсутствие ограничения частоты на внутриигровом пути |
| Хранить сколько угодно и повторно | настройки, места, альбомы, предпочтения |
| Добавлять что угодно в исходящий запрос | time, вставленный в URL без экранирования |
| Читать телефон любого персонажа | mic_phone:boot, исправлено раньше |
На все одиннадцать теперь есть тест в server/tests.lua, а было пять.
Два из них стоит выделить отдельно. Фото объявлений и посты в соцсетях доступны только через Photos.AllowedUrl — поэтому тест этой функции говорит, что правило верно, и ничего не говорит о том, спрашивают ли эти обработчики её по-прежнему. Тест the handlers that take a photo still ask whether it is one заходит через дверь, как это делает связанный телефон, и читает отказ: если проверку убрать, подготовленный адрес принимается, объявление записывается и рассылается на каждый телефон в городе, пост записывается и показывается всем в приложении, а профиль отвечает
without the picture that was not an address — got https://<a host we allow>/x.jpg" onerror="alert(1)— и это строка, которая попала бы в img. Успешный прогон не записывает вообще ничего; очистка рядом предназначена для проваленного прогона — того, в котором это имеет значение.
Тест про событие фреймворка — белая ворона: нет способа спросить у среды выполнения, какие имена событий ресурс открыл для сети, поэтому он читает bridge/framework/server.lua и ищет нужные слова. Он слабее остальных, и всё же он есть — ошибка, от которой он защищает, такова, что линтер активно её рекомендует.
И пять, идущих в обратную сторону
Заголовок раздела «И пять, идущих в обратную сторону»| Что это позволяло сделать | Где |
|---|---|
| Выполнить код на телефоне каждого, кто посмотрел их профиль | имя, handle, район, работа или интерес, вставленные в разметку |
| Выполнить код на телефоне каждого, кто открыл объявление | адрес фото, вставленный в src, и проверка адреса, допускавшая кавычку |
| Выполнить код на телефоне каждого, кто читает счёт, гараж или бизнес | эмитенты, названия, номера, места и сведения о бизнесе, вставленные как есть |
| Заставить любое из перечисленного действительно выполниться | вообще никакой политики контента на странице |
| Поместить телефон внутрь страницы другого сайта | отсутствие frame-ancestors у веб-входа |
Первые три — по одной ошибке каждая; последние две — причина, по которой первые три имели значение. Все пять покрыты tests/verify-injection.js, который проверяет страницу, которую построил бы браузер, а не строки, которые в неё пошли.
Оба теста сначала посмотрели на падение, с убранным исправлением: тест адреса называет три отсутствующих отказа, а тест инъекции считает четыре элемента, вышедших из своего атрибута, — и ничего не говорит о том, что что-то выполнилось, потому что к тому моменту политика тоже уже была на месте.