Этот небольшой, местами смешной, местами не очень отчёт поведает тебе о том, как за один вечер я умудрился найти три бага в сервисе IPScore Check и пополнить свой баланс на 5,000,000$.

Всё начинается с бага Ссылка на заголовок

Я часто пользуюсь ботом IPScore Check, когда встаёт вопрос о проверке IP-адресов. Он агрегирует бо́льшую часть общедоступной информации об адресах, да и разработчика я хорошо знаю.

Ну и как-то от нехрен делать я тыкнул на кнопку “Проверить мой IP” (я и раньше на неё тыкал, но было всё ок, читай дальше) и обнаружил странную вещь:

IP-адрес CloudFlare вместо моего

Бот по какой-то причине получал от WebView не мой адрес, а айпишник CloudFlare. Казалось бы, бэкенд берёт реальный адрес подключения вместо айпишника клиента в заголовках - но раньше-то всё работало, да и разраб меня убеждает, что на его машине всё ок… Несколько секунд гугления, и я вспоминаю, что совсем недавно настроил IPv6 на роутере:

If Pseudo IPv4 is set to Overwrite Headers - Cloudflare overwrites the existing Cf-Connecting-IP and X-Forwarded-For headers with a pseudo IPv4 address while preserving the real IPv6 address in CF-Connecting-IPv6 header.1

Однако, Pseudo IPv4 был отключен, да и видел я не псевдоадрес, а IP сервера CloudFlare, который менялся между запросами.

Манипулирование IP-адресом Ссылка на заголовок

И тут мой товарищ скидывает кусок кода с фразой “Может, баг где-то в коде? Ты видишь его?”

async def get_ip(self, request: Request):
        if not request:
            raise HTTPException(status_code=400, detail="Request object is required")
        
        headers = request.headers
        ipv4 = None
        ipv6 = None

        cf_ip = headers.get("CF-Connecting-IP")
        if cf_ip:
            if ':' in cf_ip:
                ipv6 = cf_ip
            else:
                ipv4 = cf_ip

        if not ipv4 or not ipv6:
            x_forwarded_for = headers.get("X-Forwarded-For")
            if x_forwarded_for:
                ip_list = x_forwarded_for.split(',')
                for ip in ip_list:
                    ip = ip.strip()
                    if ':' in ip:
                        ipv6 = ipv6 or ip
                    else:
                        ipv4 = ipv4 or ip

        if not ipv4:
            x_real_ip = headers.get("X-Real-IP")
            if x_real_ip and ':' not in x_real_ip:
                ipv4 = x_real_ip

        client_ip = request.client.host
        if not ipv4 and not ipv6:
            if ':' in client_ip:
                ipv6 = client_ip
            else:
                ipv4 = client_ip

        ip = ipv4 if ipv4 else ipv6

        return {
            "status": "success",
            "data": {
                "ip": ip,
                "ipv4": ipv4,
                "ipv6": ipv6
            }
        }

Ну да, вижу. И даже не один…

  • Первое - и самое главное - если запрос пришел с серверов CloudFlare, то вся функция должна свестись к одному единственному действию - cf_ip = headers.get("CF-Connecting-IP"), остальные хедеры проверять не нужно;
  • Второе - в случае, если запрос пришёл со сторонних адресов - использовать хедер X-Real-IP;
  • Третье - забыть навсегда (почти) про request.client.host и X-Forwarded-For.
Информация
Небольшое пояснение: бэкенд сервиса построен на микросервисной архитектуре, API находится в Docker-контейнере, закрытым за реверс-проксей Nginx. Т.к. сеть контейнера находится за NAT-ом, request.client.host всегда будет содержать NAT-овский IP-адрес из частной подсети 172.16.0.0/12. Реверс-прокси устанавливает хедер X-Real-IP, содержащий реальный IP-адрес, с которого подключается клиент.

Собственно, причина того, что вместо своего IPv6 адреса я видел IPv4 адрес CloudFlare - багованная (и небезопасная) логика обработки хедеров, содержащих IP-адреса, в частности бэкенд очень сильно хотел, чтобы у него был IPv4-адрес клиента, даже если его у него нет.

Также я сразу заметил, что здесь совершенно нет проверок на принадлежность реального IP-адреса подключения к диапазонам сетей CloudFlare, а микросервис API прослушивает порт на внешнем адресе, позволяя обращаться к нему напрямую (однако CloudFlare его фильтрует). И тут меня наводнила мысль:

  • Если обращаться к API через реверс-прокси в обход CloudFlare, можно манипулировать IP-адресом посредством заголовка CF-Connecting-IP;
  • Если обращаться к API в обход реверс-прокси, можно использовать и заголовок X-Real-IP;
  • В коде отсутствует валидация IP-адреса - можно указывать произвольный текст.

PoC: Ссылка на заголовок

Манипулирование IP-адресом

Impact: Ссылка на заголовок

  1. Скрытие адреса в логах и обход блокировок. Ситуация усугубляется тем, что порт API-сервиса доступен из вне, а сам сервис находится за NAT-ом, т.е. поймать реальный IP-адрес клиента внутри сервиса невозможно;
  2. Разработчик намекнул, что в некоторых местах возможно было триггернуть SSRF;
  3. При определенных условиях появляется возможность отправлять произвольные сообщения некоторым пользователям Telegram через официального бота.

Слабое шифрование и IDOR Ссылка на заголовок

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

Заметка
Тут я бы хотел отметить грамотное проектирование архитектуры приложения - TG-бот является лишь пользовательским фронтендом, а весь основной функционал реализован в отдельном сервисе и имеет свой REST API, каждый отдельный компонент реализован в виде отдельного сервиса с контейнеризацией и/или физическим разделением. Использование такой (микросервисной) архитектуры позволяет не только упростить и ускорить разработку, поддержку, интеграцию и масштабирование приложения, но и в какой-то степени повысить отказоустойчивость и безопасность всей инфраструктуры в целом (при грамотном проектировании и настройке, естественно).

Тыкаюсь я на https://ipscore.me/, оттуда на https://docs.ipscore.me/ и нахожу клиентскую документацию на REST API сервиса. Залогинившись на основном домене через Telegram и потыкав апишку (https://api.ipscore.me), я обнаруживаю, что я нахожусь ровно под тем же юзером, под которым работает TG-бот на моём аккаунте. С одной стороны это очень удобно, но с другой стороны это прямо развязало мне руки:

  • При работе с REST API я свободен от ограничений и защитных механизмов, реализованных в самом Telegram и в коде бота;
  • Я значительно расширяю количество автоматизированных и ручных инструментов и утилит для исследования приложения;
  • Взаимодействовать с API гораздо проще и удобнее, чем с ботом посредством Telegram;
  • Я получаю возможность взаимодействовать с методами и параметрами, взаимодействие с которыми через бота проблематично либо ограничено.

Так замечательно, что аж плакать хочется. Бегаю я по разным методам, слушаю Never gonna give you up (попался?). Меня больше всего интересовали методы, принимающие данные от пользователя - а их там оказалось совсем немного, однако create_invoice показался мне весьма интересным:

GET /create_invoice/Bitcoin/BTC/5000000/ HTTP/1.1
HTTP/1.1 200 OK

{
  "status": "success",
  "data": {
    "invoice_id": "403F386D45"
  }
}
GET /create_invoice/Bitcoin/BTC/1/ HTTP/1.1
HTTP/1.1 200 OK

{
  "status": "success",
  "data": {
    "invoice_id": "403F386D44"
  }
}

Вы тоже заметили? Последовательность invoice_id уж больно легко вычисляется. Нет, она не линейная , но очень простая.

Заметка
Как я узнал позднее, здесь использовалось простое шифрование, основанное на умножении и кодировании некоторых символов (полиалфавитная замена). А ещё функционал инвойсов как раз-таки и был тем самым легаси-костылем, до которого у разработчика всё никак не доходили руки - работает же)

Первое, про что я подумал - IDOR, и не ошибся. Как оказалось, API в методах управления инвойсами не проверял авторизацию пользователя, но требовал её лишь для предоставления доступа к методам. По итогу появилась возможность просмотреть инвойсы всех пользователей сервиса, включая архивные записи:

{
  "status": "success",
  "data": {
    "invoice_id": ***,
    "merchant_name": "Ip Score Checker",
    "merch_id": 20,
    "logo_img": "https://ipscore.me/favicon.ico",
    "transaction_id": ***,
    "currency": 840,
    "currency_code": "USD",
    "coin": "USDT",
    "network": "Tron",
    "wallet_address": "T*********************************",
    "wallet_id": ***,
    "exchange_rate": 0.999845,
    "amount": 10.0,
    "client_amount": 9.99845,
    "order_id": "****",
    "description": "********* Top-up 10.0 USD",
    "lang": "en",
    "status": "success",
    "confirmation_status": null,
    "created_at": "2024-**-**T00:07:35",
    "payment_due_date": "2024-**-**T02:07:39"
  }
}

Кроме того выяснилось, что метод update_invoice мог обновлять и чужие, и архивные инвойсы. С помощью этого метода можно изменить сеть и монету для оплаты и, как оказалось, обновить инвойсу payment_due_date. Кстати, api_key в параметрах запроса совершенно ничего не делает, как я понял. Возможно, это какой-то костыль, притянутый из старого легаси, когда авторизация была не Bearer по токенам, а по API-ключам (предполагаю).

Заметка
Кстати, платёжный шлюз, используемый в приложении - собственная разработка владельца IPScore.

Impact: Ссылка на заголовок

  1. Можно собрать информацию об инвойсах пользователей, посчитать доходы разработчика. Я посчитал, честно - хз на что он сервера содержит :(
  2. Инвойсы не содержат информации об адресах кошельков клиентов, но её без труда можно подтянуть из блокчейна;
  3. Можно оживить все архивные инвойсы пользователей;
  4. Исходит из 3: вероятность DoS в случае, если архивных инвойсов очень много. Как минимум время проверки транзакций увеличится (у шлюза подняты собственные ноды, кстати), повысится задержка ответа, т.к. шлюз начнёт в больших количествах генерировать адреса - а это не самое быстрое задание;
  5. Можно изменить адрес и/или сеть/монету в текущем инвойсе пользователя. Если обновление инвойса отключает старый адрес для оплаты, а пользователь успеет перевести монеты на него - оплата не пройдет. Коридор проверки оплаты у шлюза около 1 минуты.

5000000$ ценой в несколько центов Ссылка на заголовок

Во время исследования поведения API-методов работы с инвойсами я обнаружил довольно занятную вещь:

POST /update_invoice/ HTTP/1.1

{
  "invoice_id": "403F386D45",
  "network": "Tronium",
  "coin": "TRahiX",
  "api_key": "string"
}
HTTP/1.1 200 OK

{
  "status": "success",
  "data": {
    "invoice_id": "403F386D45"
  }
}

Оказывается, API не проверяет валидность значений сети и монеты, полученных от клиента. При создании инвойса - та же история. Давайте посмотрим, что стало с инвойсом:

GET /get_invoice/403F386D45/ HTTP/1.1
HTTP/1.1 200 OK

{
  "status": "success",
  "data": {
    "invoice_id": ***,
    "merchant_name": "Ip Score Checker",
    "merch_id": 20,
    "logo_img": "https://ipscore.me/favicon.ico",
    "transaction_id": null,
    "currency": 840,
    "currency_code": "USD",
    "coin": null,
    "network": null,
    "wallet_address": "bc1q**************************************",
    "wallet_id": ***,
    "exchange_rate": 0.0,
    "amount": 5000000.0,
    "client_amount": 0.0,
    "order_id": "****",
    "description": "********* Top-up 5000000.0 USD",
    "lang": "en",
    "status": "success",
    "confirmation_status": null,
    "created_at": "2024-08-29T01:08:12",
    "payment_due_date": "2024-08-29T03:08:15"
  }
}

Поля сеть, монета, курс и количество монет на отправку превратились в null и нули, но адрес для отправки средств и количество у.е. на зачисление остались прежними. И тут меня щёлкнуло - раз client_ammount стал нулём - инвойс может оплатиться буквально любой транзакцией с любым количеством монет. Ну а дальше я перевожу несколько центов на адрес для оплаты и ловлю сообщение в боте об успешном пополнении счёта: Your balance has been successfully replenished by 5000000 $ through Ugate!

PoC: Ссылка на заголовок

Я мультимиллионер!

Impact: Ссылка на заголовок

  • Я теперь $ мультимиллионер :)

В заключение Ссылка на заголовок

А не будет заключения, оно приняло Ислам. Вместо него будет немного мемов. Если уж очень сильно хочется воды в стиле “всегда тестируйте свои сервисы и API” - попросите ChatGPT это извергнуть. Да, кстати, я им не пользовался при написании этой статьи, а разработчик залатал все дырки на момент выхода статьи.

Список использованных инструментов aka Набор мазохиста Ссылка на заголовок

  • Телефон под управлением ОС Android
  • Kiwi Browser (apk)
  • Restler (apk)
  • Termux (apk)
  • cURL (termux)

БагБаунти, ёпта, 100₽, лучше бы блечил Ссылка на заголовок

Запросил вывод средств И получил…

На самом деле скриншоты выше - просто смешная ситуация, никто ни на кого не в обиде, денег за этот Bounty: Райское наслаждение я не просил, не ждал и не брал, да и сам ресёрч организован был на энтузиазме в свободный вечер… Среды?

Среда, чуваки!

А вообще, IPScore - действительно полезный сервис, а разраб - реально чёткий поцык. В ближайшее время (которое должно было быть уже вчера, но вместо этого я заставил разраба латать дыры) планируется масштабное обновление, нацеленное на повышение производительности и оптимизацию инфраструктуры, а чуть позже будет расширен функционал инструмента “IP-Трекер” и внедрены другие полезные инструменты для работы с сетями. Так что давай, тыкай ссылочку, пользуйся, плоти нолог. P.S. на самом деле не плоти - на этом зеркале скоро появится скидка вместо моего реферального процента. С ненавистью любовью, FazaN ❤️

Всё для тебя ©Михайлов