Взлом 700 миллионов аккаунтов Electronic Arts (этично): как это было

от автора

История началась, когда тестировал одну из сред разработки EA, «integration», которую удалось обнаружить в ходе изучения EA Desktop.

Изображение баннера

Изображение баннера

API аутентификации для боевой среды находится по адресу accounts.ea.com, а сервер аутентификации интеграции — accounts.int.ea.com. Волей случая я нашёл способ получить токен привилегированного доступа к этой среде (это не относится к данной истории, но в исполняемом файле одной из игр были жёстко закодированы учётные данные), но я не был уверен, что смогу с ним что‑то сделать.

Экран входа в EA Desktop с баннером «интеграция»

Экран входа в EA Desktop с баннером «интеграция»

Документация на функцию аутентификации 

Я начал искать любую открытую документацию по API. Сервер аутентификации явно использовал обратный прокси-сервер, поскольку путь /connect возвращал ошибки 404 в формате json, а любой другой адрес возвращал страницу с кодом 404 и заголовок “server” ответа был установлен на istio-envoy.

Индекс account.ea.com возвращает страницу 404.

Индекс account.ea.com возвращает страницу 404

Я начал искать путь по которому могла находится документация на API:

❌ /docs ❌ /swagger ❌ /swagger-ui ❌ /swagger-ui/index.html ❌ /api-docs ❌ /connect/docs ❌ /connect/swagger ❌ /connect/swagger-ui ❌ /connect/swagger-ui/index.html ❔ /connect/api-docs

Здесь произошло кое-что интересное. До сих пор все пути возвращали страницу с кодом 404, и все пути начинающиеся с /connect возвращали 404 json, но /connect/api-docs вернул ошибку 404 без данных. 

$ curl -D - https://accounts.int.ea.com/connect HTTP/1.1 404 Not Found x-nexus-sequence: <redacted> x-nexus-hostname: intaccounts-<redacted> content-type: application/json;charset=UTF-8 server: istio-envoy x-envoy-upstream-service-time: 1 x-envoy-hostname: ip-10-141-1-59.ec2.internal  {"error":"404 Not Found","error_description":"No handler found for GET /connect","error_code":null,"code":107018} $ curl -D - https://accounts.int.ea.com/connect/api-docs HTTP/1.1 404 Not Found x-nexus-sequence: <redacted> x-nexus-hostname: intaccounts-<redacted> content-length: 0 server: istio-envoy x-envoy-upstream-service-time: 1 x-envoy-hostname: ip-10-141-2-91.ec2.internal

Я знал, что путь /connect/api-docs должен перенаправлять запрос на другой сервис, отличный от других маршрутов /connect, поэтому начал искать маршруты внутри него.

❌ /connect/api-docs/swagger ❌ /connect/api-docs/swagger-ui ❌ /connect/api-docs/index.html ✅ /connect/api-docs/index.json

Успех! Возвращён файл Swagger JSON. 

$ curl -D - https://accounts.int.ea.com/connect/api-docs/index.json HTTP/1.1 200 OK x-nexus-sequence: <redacted> x-nexus-hostname: intaccounts-<redacted> content-type: application/json;charset=ISO-8859-1 content-length: 216 ...  {     "swaggerVersion": "1.1",     "apiVersion":  "1.0",     "basePath": "https://accounts.int.ea.com:443/connect",     "apis": [{         "path": "/api-docs/connect",         "description": "Nexus Connect"     }] }

Это была ссылка на другой путь, /api-docs/connect, который вернул полную реализацию Swagger для Nexus Connect API.

Файлы swagger были довольно старыми (версия 1.1), поэтому пришлось запустить swagger-codegen-cli для обновления их до спецификаций OpenAPI 3.0. Затем я запустил локальный сервер пользовательского интерфейса Swagger, и вуаля!

Документация по API Nexus Connect

Документация по API Nexus Connect

Я уже знал о /auth и /tokeninfo, но никогда не слышал о других функциях. Кроме того, в /auth было гораздо больше возможных параметров, чем я ожидал.

Другие эндпоинты были интересны, но не казались полезными. Теперь, когда я знал, что у Nexus API есть документация, я решил испытать удачу на других сервисах. 

Ищем больше 

EA Desktop использует API GraphQL под названием «Service Aggregation Layer» (SAL), который, вероятно, объединяет несколько серверных сервисов в единый API. К сожалению, путь api-docs был верным только для API сервера аутентификации интеграции, а SAL был защищён брандмауэром.

Затем я попробовал использовать более старую версию SAL: «gateway». Раньше я использовал некоторые API функции на сервере gateway.ea.com, например, поиск пользователей.

Формат для всех путей на gateway.ea.com выглядит следующим образом: proxy/{service}/{route}. Как и для API аутентификации, я сделал запрос gateway.int.ea.com/proxy/api-docs/index.json и получил следующий результат: 

{     "swaggerVersion": "1.1",     "apiVersion":  "2.0",     "basePath": "https://gateway.int.ea.com:443/proxy",     "apis": [         {             "path": "/api-docs/addresses",             "description": "Identity 2.0"         },         {             "path": "/api-docs/agerequirements",             "description": "Identity 2.0"         },         {             "path": "/api-docs/billing",             "description": "Billing 2.0"         },         {             "path": "/api-docs/clientmigrationrecord",             "description": "Identity 2.0"         },         {             "path": "/api-docs/commerce",             "description": "Commerce 2.0"         },         ... 79 more     ] }

Это казалось золотой жилой. Через gateway я получил гораздо больше, чем рассчитывал. Хоть я и раньше и использовал API gateway, но знал лишь о нескольких функциях. Я понятия не имел, что на нём доступно более 80 сервисов.

Взяв все полученные пути, преобразовал все файлы в современные спецификации OpenAPI и стал их изучать. Было несколько интересных, но бесполезных вещей.

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

Для начала посмотрел функцию /identity/pids/me, которая возвращает много общей информации об учётной записи:

$ curl \   -H 'Authorization: Bearer <redacted>' \   -H 'X-Extended-Pids: true' \   -D - https://gateway.ea.com/proxy/identity/pids/me HTTP/1.1 200 OK Content-Type: application/json;charset=utf-8 Content-Length: 1013 x-nexus-sequence: <redacted> x-nexus-hostname: prdgateway-<redacted> x-sequence: <redacted> server: istio-envoy x-hostname: prdgateway-<redacted> x-bundle: com.ea.nucleus.eaid x-version: 2.0.0 x-envoy-upstream-service-time: 16  {   "pid" : {     "externalRefType" : "NUCLEUS",     "externalRefValue" : "<redacted, same as pidId>",     "pidId" : <redacted>,     "email" : "******@gmail.com",     "emailStatus" : "VERIFIED",     "strength" : "STRONG",     "dob" : "<redacted>-**-**",     "age" : <redacted>,     "country" : "US",     "language" : "en",     "locale" : "en_US",     "status" : "ACTIVE",     "stopProcessStatus" : "OFF",     "reasonCode" : "",     "tosVersion" : "1.0",     "parentalEmail" : "",     "thirdPartyOptin" : "false",     "globalOptin" : "false",     "dateCreated" : "<redacted>T0:0Z",     "dateModified" : "<redacted>T0:0Z",     "lastAuthDate" : "<redacted>T0:0Z",     "registrationSource" : "eadm-origin",     "authenticationSource" : "196775",     "showEmail" : "NO_ONE",     "discoverableEmail" : "NO_ONE",     "anonymousPid" : "false",     "underagePid" : "false",     "teenToAdultFlag" : false,     "defaultBillingAddressUri" : "",     "defaultShippingAddressUri" : "",     "passwordSignature" : <redacted>,     "tfaEnabled" : true   } }

Затем идёт функция /identity/pids/me/personas, которая возвращает список «персонажей»: 

{   "personas": {     "persona": [       {         "personaId": <redacted>,         "pidId": <redacted>,         "displayName": "BattleDash",         "name": "battledash",         "namespaceName": "cem_ea_id",         "isVisible": true,         "status": "ACTIVE",         "statusReasonCode": "NONE",         "showPersona": "EVERYONE",         "dateCreated": "<redacted>",         "lastAuthenticated": "<redacted>"       },       {         ...,         "displayName": "BattleDash#<redacted>",         "name": "battledash#<redacted>",         "namespaceName": "steam",         ...       },       {         ...,         "displayName": "BattleDash",         "name": "battledash",         "namespaceName": "xbox",         ...       }     ]   } }

Каждая запись «персонажа» относящаяся к разным платформам, связана с учётной записью EA и имеет персональный идентификатор. Основная учётная запись EA («Origin») находилась в namespace cem_ea_id. Данные из игр обычно хранят, ассоциируя с этими записями, поэтому у вас может быть разная статистика/инвентарь/и т. д. для разных платформ.

Исследуем дальше

Немного покопавшись в документации, я наткнулся на функцию /identity/pids/{pidId}/personas/{personaId}. Она принимала методы типа GET, PUT или DELETE. Меня заинтересовало, что запрос PUT принимал параметры dp.client.default и dp.server.default. Обычный клиент аутентификации EA Desktop использует dp.client.default, поэтому нам доступен этот запрос на обновление персонажа.

Вот прототип тела запроса:

{   "displayName": "string",   "namespaceName": "string",   "status": "string",   "statusReasonCode": "string",   "lastAuthenticated": "string",   "nickName": "string",   "pidId": "long", }

Я выполнил тестовый запрос со своим PID для основной учётной записи EA, изменив displayName в «BattleDash2»: 

$ curl -x PUT \   -H 'Authorization: Bearer <redacted>' \   -H 'Content-Type: application/json' \   -d '{"displayName":"BattleDash2"}' \   -D - https://gateway.ea.com/proxy/identity/pids/<redacted>/personas/<redacted> HTTP/1.1 200 OK Content-Type: application/json;charset=utf-8 Content-Length: 65 ...  {   "personaUri": "/pids/<redacted>/personas/<redacted>" }

Обновив страничку аккаунта в EA, я увидел, что моё имя пользователя действительно изменилось на BattleDash2. Я попробовал изменить его обратно, и это сработало. Запрос обходил кулдаун при смене ника и проверку электронной почты для изменения ника.

Моей первой мыслью после этого было попытаться что-то сломать, изменив namespaceName, но что бы я в этом поле не писал, это не не давало никакого эффекта. 

Затем я увидел, что поле status может иметь значения: ACTIVE, DISABLED, PENDING, DELETED, BANNED. Это означало, что я смогу забанить/разбанить себя. 

Я отправил еще один запрос, на этот раз изменив свой личный статус на BANNED. Ничего не изменилось, EA Desktop был полностью пригоден для использования, но когда я попытался войти в игру, меня встретило следующее: 

Игра EA, отображающая всплывающее окно «запрещено» при входе в систему

Игра EA, отображающая всплывающее окно «запрещено» при входе в систему

Результат изменения моего статуса на «BANNED» приятно порадовал. Я думал, что с этим можно сделать, и попробовал изменить поле «pidId». Мне казалось, что этот запрос будет заблокирован и он предназначен только для тестовой среды или чего‑то такого. Но нет, к моему ужасу (счастью), при указании идентификатора аккаунта EA моего друга и идентификатора своего персонажа в Steam, меня встретило такое сообщение:

$ curl -x PUT \   -H 'Authorization: Bearer <redacted>' \   -H 'Content-Type: application/json' \   -d '{"pidId":<friendId>}' \   https://gateway.ea.com/proxy/identity/pids/<myPid>/personas/<mySteamPersonaId> {   "personaUri": "/pids/<friendId>/personas/<mySteamPersonaId>" }

Я перезагрузил страницу своей учётной записи EA, и увидел, что мой аккаунт Steam больше не был привязан к моей учётной записи EA. Она стала привязанной к аккаунту EA моего друга.

Я отправил ещё один запрос, изменив pidId, и успешно вернул свою учётную запись Steam. Но интересно, если я могу переместить мои собственные привязанные учётные записи в любой аккаунт EA, не смогу ли я войти в свою привязанную учётную запись, и таким образом, получить доступ к аккаунту EA другого игрока?

Я переместил свой аккаунт Steam обратно в учётную запись друга, а затем вошёл в систему через Steam на сайте EA. К сожалению, меня встретили вот так:

Ошибка привязки учетной записи EA

Ошибка привязки учетной записи EA

Система выдала запрос на подтверждение электронной почты, вызываемый при входе в систему из нового места. Отображаемый адрес электронной почты не был моим. Это подтверждало, что я пробую войти в учётную запись моего друга, но меня остановила 2FA. Я потратил немало времени, пытаясь обойти этот шаг входа в систему, но безуспешно. 

Тогда я вернулся к запросу на обновление профиля, переместил свой аккаунт Steam обратно в свою учётную запись EA и попытался изменить имя пользователя на BattleDash <script>alert(1)</script>. Запрос удался, и когда я зашёл на страницу учётной записи, я увидел окно оповещения, вызванное изменением имени пользователя: 

Ошибка привязки учетной записи EA

Ошибка привязки учетной записи EA

Что ж, это здорово, по крайней мере, у меня была XSS. С помощью неё можно попытаться попробовать перехватить чужой сеанс. Но я был так близок к тому, чтобы войти в другие аккаунты. Хотелось найтиспособ обойти 2FA проверку входа в систему. 

Изгой-один: Звёздные войны: Истории — «Я на пороге величия»

Изгой-один: Звёздные войны: Истории — «Я на пороге величия»

Вернувшись к API функциям, я тестировал разные вещи и решил попробовать заменить идентификатор личности в /identity/pids/{pidId}/personas/{personaId} на другой идентификатор личности. 

Я создал новую учётную запись EA для тестирования, получил её личный идентификатор и сразу же попытался изменить имя пользователя этого аккаунта, не проходя аутентификацию: 

$ curl -x PUT \   -H 'Authorization: Bearer <redacted>' \   -H 'Content-Type: application/json' \   -d '{"displayName":"TestAcc123"}' \   https://gateway.ea.com/proxy/identity/pids/<myPid>/personas/<otherPersonaId> {   "personaUri": "/pids/<otherPid>/personas/<otherPersonaId>" }

М-м-м, сработало!Я только что изменил имя пользователя учётной записи, совершенно не связанной с моей. Я попробовал изменить статус на BANNED, и это сработало — он не мог зайти в игры.

Итак, если в «персонажах» хранится много данных учётной записи и я могу переместить «персонажа» других учётных записей EA в свою учётную запись EA, то не могу ли я взять под контроль любой аккаунт EA?

Что ж, давайте попробуем переместить чужую учётную запись EA на мой аккаунт. Как мне получить их идентификатор личности? Пользователи могут скрыть свою учётную запись, что делает её недоступной для обнаружения системой поиска друзей в EA Desktop.

В документации API указана функция по адресу /identity/personas, которая принимает displayName. Она показывает личный идентификатор, идентификатор учетной записи и даже время, когда создали учётную запись и в последний раз вошли в систему. К сожалению, она не отображает людей, чья учетная запись скрыта.

$ curl \   -H 'Authorization: Bearer <redacted>' \   -H 'X-Expand-Results: true' \   https://gateway.ea.com/proxy/identity/personas?displayName=BattleDash {   "personas" : {     "persona" : [ {       "personaId" : <redacted>,       "pidId" : <redacted>,       "displayName" : "BattleDash",       "name" : "battledash",       "namespaceName" : "cem_ea_id",       "isVisible" : true,       "status" : "ACTIVE",       "statusReasonCode" : "NONE",       "showPersona" : "EVERYONE",       "dateCreated" : "<redacted>",       "lastAuthenticated" : "<redacted>"     } ]   } }

Покопавшись, я нашёл функцию /identity/namespaces/{namespace}/personas, которая работает примерно так же, но ищет в определённых пространствах имён. Она возвращает только имя пользователя и идентификатор, но это все, что нам нужно. Эта функция также возвращает скрытые учётные записи. 

$ curl \   -H 'Authorization: Bearer <redacted>' \   https://gateway.ea.com/proxy/identity/namespaces/cem_ea_id/personas?displayName=BattleDash {   "personaUri" : [ {     "value" : "/personas/<redacted>",      "key" : "BattleDash"   } ] }

Итак, учитывая это, давайте перенесём в мой аккаунт чужую привязанную учётную запись EA: 

$ curl -x PUT \   -H 'Authorization: Bearer <redacted>' \   -H 'Content-Type: application/json' \   -d '{"pidId":<myId>}' \   https://gateway.ea.com/proxy/identity/pids/<myPid>/personas/<otherAccountsPersonaId> {   "error": {     "failure": [ {       "cause": "TOO_MANY_PERSONAS_FOR_NAMESPACE",       "field": "namespaceName"     } ],     "code": "VALIDATION_FAILED"   } }

Хм. Я не могу переместить чужую учётную запись EA в свою, поскольку у меня уже есть своя учётная запись в EA. Как мне это обойти? 

После некоторого размышления я пришёл к выводу, что люди, которые регистрировали учётные записи через консоль, скорее всего, имеют привязанную учётную запись(и соответственно персонажа) только для этой платформы, а не для персонажа в cem_ea_id.

Я создал консольную учётную запись, и да, у неё не было записи персонажа в cem_ea_id. Я переместил своего персонажа из cem_ea_id в учётную запись консоли, а затем переместил персонажа из cem_ea_id учётной записи «жертвы» в свою учётную запись. 

Теперь при входе в систему у меня был ник «жертвы», игровая статистика для некроссплатформенных игр и некоторые другие данные учётной записи. При входе в учётную запись EA «жертвы» мне предложили «Завершить настройку учётной записи», выбрав имя пользователя, как если бы я только что создал аккаунт. 

К сожалению, права на игры, друзья и данные сохранения игр для новых кроссплатформенных игр, таких как Battlefield 2042, хранятся в самой учётной записи EA, а не в персонаже.

Что мы уже имеем: я могу переместить свои привязанные учётные записи (персонажей) в любую учётную запись EA, которую захочу, и я могу переместить любого персонажа в свою учётную запись EA. Я также могу изменить статус любого персонажа на BANNED и изменить имя пользователя любого персонажа. Но попытка войти в учётную запись другого пользователя после перемещения к нему одного из моих персонажей вызывает запрос на подтверждение электронной почты.

Хорошо, но как можно использовать консольные учётные записи для исправления ошибки «TOO_MANY_PERSONAS_FOR_NAMESPACE». Я вспомнил, что никогда не видел приглашения 2FA при входе в игру EA на консоли. Обходит ли 2FA вход в учётную запись EA с помощью токена Xbox/PSN? 

В документации API для Nexus Connect API описано, как передаются токены Xbox/PSN, поэтому я получил идентификатор клиента PSN EA из процесса входа в PSN на сайте EA. Получил токен аутентификации PSN и попытался войти с его помощью. 

Персонаж в PS3 был создан в моём аккаунте, и я смог войти в него с токенами PSN без каких-либо запросов 2FA. Теперь идея заключалась в том, чтобы переместить этот персонаж из PS3 в уч`тную запись жертвы, а затем войти в систему с помощью токена PSN: 

$ curl -x PUT \   -H 'Authorization: Bearer <redacted> \   -H 'Content-Type: application/json' \   -d '{"pidId":<victimId>}' \   https://gateway.ea.com/proxy/identity/pids/<myPid>/personas/<ps3PersonaId> {   "error" : {     "failure" : [ {       "cause" : "INVALID_CLIENT_NAMESPACE",       "field" : "namespaceName"     } ],     "code" : "VALIDATION_FAILED"   } }

М-м-м, что? Что не так? 

После тестирования выяснилось, что функция /pids/me/personas не содержала учетную запись PSN, если только я не использовал токен аутентификации PSN. Таким образом, токен аутентификации каким‑то образом определяет, какие персонажи возвращаются.

Я внимательно изучил документацию по API аутентификации и, добавив X-Include-Namespace заголовок к функции /connect/tokeninfo, я обнаружил, что у каждого клиента oauth есть набор индивидуальных пространств имён, с которыми он может работать: 

$ curl -H 'X-Include-Namespace: true' \   https://accounts.ea.com/connect/tokeninfo?access_token=<redacted> {   "client_id": "JUNO_PC_CLIENT",   "scope": "... dp.client.default ...",   ...,   "persona_namespaces": [     "cem_ea_id",     "steam",     "epic",     "xbox"   ],   ... }

Это плохо (для меня). Клиент, у которого есть dp.client.default, который нам нужен для обновления персонажей, не имеет ps3 пространство имён. Однако по какой-то причине у него есть пространство имён Xbox. К сожалению, я не могу генерировать действительные для игры токены Microsoft XSTS, поэтому я не могу войти в систему с помощью токена Xbox вручную. 

Последняя надежда 

Хорошо. Возможно, я все равно смогу переместить свой аккаунт Xbox в учётную запись EA жертвы, затем войти в игру на реальном Xbox и оказаться в аккаунте EA жертвы. Для игр с кроссплатформенным прогрессом, таких как BF2042, это даст мне игровую статистику и инвентарь жертвы, несмотря на то, что я играю под своим аккаунтом Xbox. 

Я создал новую учётную запись EA для тестирования, и попросил друга создать ещё одну новую учётную запись с именем SecTestVictim. Я создал новый аккаунт Microsoft с именем TestVictim, связал его с созданной мной учётной записью EA, переместил моего персонажа Xbox в учётную запись EA жертвы и убедился, что попытка войти через учётную запись Xbox на веб-сайте EA вызывает проверку электронной почты. 

Итак, я оставил на ночь Xbox, устанавливать Battlefield 2042 и стал ждать момента истины… 

Я оказался внутри!  Вошёл в BATTLEFIELD 2042 на Xbox под учётной записью жертвы.

Вы вошли в BATTLEFIELD 2042 под учетной записью жертвы.

Вы вошли в BATTLEFIELD 2042 под учетной записью жертвы.

Успешно обошёл проверку электронной почты для нового местоположения, войдя в систему с консоли. 

Если вход в систему на Xbox заставлял EA «довериться» моему местоположению, я пробовал войти в систему через свою учётную запись Xbox на веб-сайте EA. Вместо экрана подтверждения входа в систему на этот раз я вошёл в учётную запись EA. 

Зашел на сайт EA под учетной записью жертвы.

Зашел на сайт EA под учетной записью жертвы

Возможности

Здесь есть несколько основных путей для злоумышленников: 

  • Вы можете удалить личные данные любого игрока из его учётной записи, украсть имя его персонажа и игровые данные. 

  • Вы можете войти в чью-либо учётную запись EA, передав ему свой аккаунт Xbox, войдя в игру EA с помощью Xbox, а затем войти в его учётную запись EA через учётную запись Xbox, поскольку теперь ваша сеть является доверенной. 

А также: 

  • Вы можете забанить персонажей, что лишит их возможности играть в большинство онлайн-игр. 

  • Вы можете изменить чьё-то имя пользователя, а затем забрать его себе. 

  • Вы можете обойти игровые баны, переместив своего персонажа в новую учётную запись, поскольку они обрабатываются путём отключения прав на игру для всей учётной записи. 

Все это не требует взаимодействия с пользователем и осуществляется используя API. Это отличная демонстрация того, почему при проектировании такой системы нельзя упускать из виду ни одну вещь. 

Потенциально могут возникнуть некоторые дебаты по поводу параметров CVSS, но я бы сказал, что это 10.0: AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

Некоторые мысли 

Учитывая серьёзность проблемы, немного странно, сколько времени понадобилось EA на выпуск исправлений. Меня уверяли, что исправление будет сделано не раньше конца года. Я понимаю, что снаружи это выглядит просто, но было бы разумно установить быстрый патч, исправляющий суть проблемы. 

Также разочаровывает то, что EA до сих пор не запустила программу вознаграждения за ошибки. Не имея какого-либо реального стимула сообщать об уязвимостях, я знаю людей, которые вместо этого предпочли оставить бы их при себе. Мне бы очень хотелось, чтобы EA последовала примеру остальных игроков отрасли и начали выплачивать вознаграждения. 

Тем не менее, было здорово поработать с ними. 

Спасибо за чтение!

Хронология событий

  • 16.06.2024 — Сообщил в EA об уязвимостях 

  • 18.06.2024 — EA запрашивает дополнительную информацию о моей среде тестирования 

  • 25.06.2024 — ЕА отправляет подтверждение и присваивает критический уровень серьёзности 

  • 28.06.2024 — Черновик этой статьи опубликован в EA. 

  • 08.07.2024 — Развёрнуто обновление 1 (проверка владения персоной) 

  • 18.07.2024 — Установлено обновление 2 (неизвестно) 

  • 06.09.2024 — Установлено обновление 3 (неизвестно) 

  • 10.09.2024 — Установлено обновление 4 (документация удалена) 

  • 08.10.2024 — Установлено обновление 5 (неизвестно) 



ссылка на оригинал статьи https://habr.com/ru/articles/856846/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *