[CENTER][IMG=align=center;alt=%5BIMG%5D]https://files.catbox.moe/anju5t.png[/IMG] [SIZE=2][COLOR=rgb(155, 154, 154)]проверено на реальном устройстве и на эмуляторе, осень 2026[/COLOR][/SIZE][/CENTER] [RIGHT][SIZE=2][COLOR=rgb(155, 154, 154)]время чтения ~25 минут[/COLOR][/SIZE][/RIGHT] [CENTER][SIZE=5]Всех приветствую.[/SIZE][/CENTER] [SIZE=4]Пишу впервые, поэтому извиняюсь за сумбур заранее. Разберем, как я расковырял [tooltip=141]антифрод[/tooltip] одного маркетплейса: слои защиты, ключ шифрования из нативной библиотеки и один способ, который выглядит глупо, но работает.[/SIZE] [SIZE=4]Хотел запостить это на другом форуме, но друг посоветовал начать здесь. Надеюсь, не прогадал.[/SIZE] [SIZE=4]Честно скажу сразу, материала получилось много. Если вы из тех, кто читает только дайте мне кода и все - листайте вниз к спойлерам с кодом, но я все-таки советую читать по порядку, иначе будет каша в голове :kek: [/SIZE] [spoiler=Инструментарий;align=left][SIZE=4]Ничего сверхъестественного, все как у людей:[/SIZE] [LIST] [*][COLOR=rgb(255, 255, 255)]apktool[/COLOR] - распаковать и собрать APK, полазить по smali; [*][COLOR=rgb(255, 255, 255)]jadx[/COLOR] - читать Java-логику глазами; [*][COLOR=rgb(255, 255, 255)]capstone[/COLOR] - дизассемблировать arm64; [*][COLOR=rgb(255, 255, 255)]pycryptodome[/COLOR] - AES для расшифровки пейлоадов; [*][COLOR=rgb(255, 255, 255)]adb[/COLOR] - реальный телефон (arm64) + эмулятор для быстрых прогонов. [/LIST] [SIZE=4]Терпение. Много терпения.[/SIZE] [spoiler=Скриншот;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/5p8748.png[/IMG][/spoiler][/spoiler] [spoiler=План, чтобы вы не потерялись;align=left][SIZE=4]Первым делом я полез в декомпил и почти сразу понял неприятное: каналов идентификации не один, а [COLOR=rgb(255, 255, 255)]три[/COLOR]. Нашел один - обрадовался. Нашел второй - напрягся. Нашел третий - понял, что теперь их надо еще и согласовывать между собой. :stonks_down: [/SIZE] [SIZE=4]План такой:[/SIZE] [LIST=1] [*]разбираем все три канала; [*]достаем статический ключ из .so; [*]понимаем алгоритм подписи запросов; [*]пишем JNI-оракул, который вызывает нативную функцию без самого приложения; [*]собираем пейлоад антибота и заставляем сервер его проглотить; [*]автоматически проходим JS-челлендж; [*]собираем клиент и находим нормальные [tooltip=151]прокси[/tooltip]. [/LIST] Погнали :fox_jump:[/spoiler] [spoiler=Проверяемость и артефакты (как убедиться, что это не выдумка);align=left][SIZE=4]Чтобы не быть голословным, вот конкретные артефакты, по которым все воспроизводится 1:1. Версия приложения на момент разбора: [COLOR=rgb(65, 168, 95)]19.33.1[/COLOR] (versionCode [COLOR=rgb(65, 168, 95)]16030341[/COLOR]), версия Ozon ID SDK: [COLOR=rgb(65, 168, 95)]14.15.0[/COLOR].[/SIZE] [SIZE=4]Оригинальный APK из стора:[/SIZE] [CODE] package : ru.ozon.fintech.finance label : Ozon Банк versionName : 19.33.1 versionCode : 16030341 size : 139214377 bytes sha256 : 2e0ac90df07146d17d3d9432f608b5ce39663a04d242409214c5a791ec0b303c [/CODE] [spoiler=Скриншот;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/i56yeq.png[/IMG][/spoiler] [SIZE=4]Нативная библиотека [unfurl="https://libozon-id-sdk.so"]libozon-id-sdk.so[/unfurl] (sha256 по ABI):[/SIZE] [CODE] arm64-v8a : c0fa6b288669572c5dff5fe86766b6005c32a0bd6c9fc1122a7e404700929646 (899192 bytes) armeabi-v7a : 32b4b78db57208f29d2e15548aed10e6f7a0db78eaae06105ef15a5a2c79af61 (566748 bytes) x86_64 : 4332436c4b610e971a16da27b82021b3fb25568d9d3c090ca4dd0df314501b7b (869464 bytes) [/CODE] [spoiler=Скриншот;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/hgrwef.png[/IMG][/spoiler] [SIZE=4]Экспортируемые символы библиотеки (адреса виртуальные):[/SIZE] [CODE] 0xe2da8 D bytes ; статический блоб, 84 байта 0x62d08 T Java_ru_ozon_id_ad_AdManager_getValue1 0x63880 T Java_ru_ozon_id_ad_AdManager_getValue2 0x63894 T Java_ru_ozon_id_amf_Amf_getValueNative [/CODE] [spoiler=Скриншот;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/il3sld.png[/IMG][/spoiler] [SIZE=4]Блоб 84 байта (key = blob[0:32], algo = blob[64:67], cipher = blob[67:84]):[/SIZE] [CODE] 5d3a9d9237450777b3028be69a06bd1376ccc0d2f73748bedfc28909a551d789 290752c732d0da1d0a78cb327d53c8b66e61a67e6f6ae39c9bdf0d3c78832622 4145534145532f4346422f4e6f50616464696e67 = "AESAES/CFB/NoPadding" [/CODE] [spoiler=Скриншот;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/brwpbp.png[/IMG][/spoiler] [SIZE=4]Строки, найденные прямо в бинарях (файловые оффсеты):[/SIZE] [CODE] libozon-id-sdk.so @ file offset 0x28876 x-ad-id @ file offset 0x42d0e AESAES/CFB/NoPadding @ file offset 0xdade6 [/CODE] [spoiler=Скриншот;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/ujdchl.png[/IMG][/spoiler] [SIZE=4]А в [tooltip=916]dex[/tooltip], кроме этого, лежат строки: d9f8368943e8fe03, 14.15.0, OzonID SDK 14.15.0, ozonbankfinance_android.[/SIZE] [spoiler=Как проверить самому;align=left][SIZE=4]Команды, которыми я это делал:[/SIZE] [CODE] # 1. достать библиотеку из APK unzip ozon_bank.apk lib/arm64-v8a/libozon-id-sdk.so -d out # 2. сверить хеш sha256sum out/lib/arm64-v8a/libozon-id-sdk.so # c0fa6b288669572c5dff5fe86766b6005c32a0bd6c9fc1122a7e404700929646 # 3. посмотреть экспорты nm -D --defined-only out/lib/arm64-v8a/libozon-id-sdk.so | grep Java_ # 0x62d08 ... getValue1 # 0x63880 ... getValue2 # 0x63894 ... Amf_getValueNative # 4. вытащить блоб 84 байта (скрипт из раздела с кодом) python3 extract_blob.py # 5d3a9d92... / AESAES/CFB/NoPadding [/CODE] [SIZE=4]Если хеши и адреса совпали - вы на той же версии, и весь дальнейший алгоритм повторится 1:1.[/SIZE][/spoiler] [spoiler=Границы применимости;align=left][SIZE=4]Честно про ограничения, чтобы не было иллюзий:[/SIZE] [LIST] [*]вся статья привязана к версии [COLOR=rgb(65, 168, 95)]19.33.1[/COLOR] и SDK [COLOR=rgb(65, 168, 95)]14.15.0[/COLOR]. На других версиях смещения, а возможно и сам ключ, отличаются - блоб достается тем же скриптом заново; [*]JS-челлендж у меня был [COLOR=rgb(65, 168, 95)]v48[/COLOR] (видно по подключенному css style_v48_3.css). Он обновляется независимо от приложения; [*]ответы сервера (incidentId, challengeURL, abt_data) одноразовые и завязаны на [tooltip=964]IP[/tooltip] и cookies. Повторить чужой [tooltip=529]запрос[/tooltip] байт-в-байт не выйдет; [*]в entry-точке iso я гонял и как IN, и как RU - сервер в обоих случаях отдавал соответствующую страницу входа (в тестах на скринах виден индийский вариант, для РФ-аккаунта ставится RU). [/LIST][/spoiler][/spoiler] [spoiler=Шаг 1. Ищем, что вообще уходит на сервер;align=left][SIZE=4]Залезаю в декомпил, ищу все что связано с идентификацией устройства. Картина сложилась быстро. Итак, отправляется следующее:[/SIZE] [SIZE=4][COLOR=rgb(255, 255, 255)]Канал №1[/COLOR] - большой JSON [COLOR=rgb(65, 168, 95)]FingerprintDTO[/COLOR], полей около 54. Его шифруют и шлют отдельным запросом.[/SIZE] [SIZE=4][COLOR=rgb(255, 255, 255)]Канал №2[/COLOR] - еще один отпечаток, [COLOR=rgb(65, 168, 95)]DeviceStatus[/COLOR], уезжает base64-строкой в HTTP-заголовке.[/SIZE] [SIZE=4][COLOR=rgb(255, 255, 255)]Канал №3[/COLOR] - подпись запросов: набор заголовков [COLOR=rgb(65, 168, 95)]x-o3-*[/COLOR], где самое сочное - нативная подпись.[/SIZE] [SIZE=4]Все три канала про одно и то же устройство. Поэтому главная ловушка для новичка - подменить каждый по отдельности и удивиться, почему прилетает [tooltip=528]бан[/tooltip]. Нужна [COLOR=rgb(255, 255, 255)]согласованность[/COLOR]. Сейчас дойдем и до этого.[/SIZE][/spoiler] [spoiler=Шаг 2. Разбираем FingerprintDTO;align=left][SIZE=4]Класс [COLOR=rgb(65, 168, 95)][unfurl="https://ru.ozon.id"]ru.ozon.id[/unfurl].antibot.FingerprintDTO[/COLOR], 54 поля. Часть константы (типа версии их SDK), остальное тянется из системы. Собирается все в [COLOR=rgb(65, 168, 95)]ru.cbdc.C4261bz0.a(type, str)[/COLOR].[/SIZE] [SIZE=4]Источники по самым интересным полям:[/SIZE] [LIST] [*][COLOR=rgb(255, 255, 255)]model / manufacturer / brand / hardware / board / device / product[/COLOR] - обычные [COLOR=rgb(65, 168, 95)]Build.*[/COLOR]; [*][COLOR=rgb(255, 255, 255)]sdk / release / securityPatch / buildId / fingerprint / tags / time / user[/COLOR] - тоже [COLOR=rgb(65, 168, 95)]Build.*[/COLOR]; [*][COLOR=rgb(255, 255, 255)]экран, плотность, xdpi/ydpi[/COLOR] - [COLOR=rgb(65, 168, 95)]DisplayMetrics[/COLOR]; [*][COLOR=rgb(255, 255, 255)]пакет, версия, время установки[/COLOR] - [COLOR=rgb(65, 168, 95)]PackageInfo[/COLOR]; [*][COLOR=rgb(255, 255, 255)]локаль, таймзона[/COLOR] - [COLOR=rgb(65, 168, 95)]Configuration[/COLOR] и [COLOR=rgb(65, 168, 95)]TimeZone[/COLOR]; [*][COLOR=rgb(255, 255, 255)]isRoot[/COLOR] - [COLOR=rgb(65, 168, 95)]ru.cbdc.Yd0.a[/COLOR]; [*][COLOR=rgb(255, 255, 255)]androidDeviceId[/COLOR] - [COLOR=rgb(65, 168, 95)][unfurl="https://Settings.Secure"]Settings.Secure[/unfurl] "android_id"[/COLOR]; [*][COLOR=rgb(255, 255, 255)]isEmulator[/COLOR] - свойство [COLOR=rgb(65, 168, 95)]ro.boot.qemu[/COLOR]; [*][COLOR=rgb(255, 255, 255)]ozonIdSdkVersion[/COLOR] - константа [COLOR=rgb(65, 168, 95)]14.15.0[/COLOR]. [/LIST] [spoiler=Деталь, из-за которой я поседел;align=left][SIZE=4]Поле [COLOR=rgb(65, 168, 95)]androidFingerprintType[/COLOR]. Название намекает на Build.TAGS, правда? Ага, конечно. На деле туда кладут строку типа задачи антибота: [COLOR=rgb(65, 168, 95)]antibot[/COLOR] / [COLOR=rgb(65, 168, 95)]asyncOnAuthAppLaunch[/COLOR] / [COLOR=rgb(65, 168, 95)]asyncOnLogin[/COLOR].[/SIZE] [SIZE=4]Я разумеется сунул туда Build.TAGS и полвечера не понимал, почему сервер морщится. Вот из-за таких мелочей и уходит время :smile_facepalm: [/SIZE][/spoiler] [SIZE=4]Дальше объект уходит в JSON и шифруется. Вот тут начинается самое вкусное.[/SIZE] [spoiler=Скриншот (фрагмент пейлоада);align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/yanku8.png[/IMG][/spoiler][/spoiler] [spoiler=Шаг 3. Нативная библиотека и ключ, который не должен был достаться;align=left][SIZE=4]Шифрование живет в [COLOR=rgb(65, 168, 95)][unfurl="https://libozon-id-sdk.so"]libozon-id-sdk.so[/unfurl][/COLOR], класс [COLOR=rgb(65, 168, 95)][unfurl="https://ru.ozon.id"]ru.ozon.id[/unfurl].amf.Amf[/COLOR]. Метод [COLOR=rgb(65, 168, 95)]Amf.getValueNative()[/COLOR] возвращает 84 байта. Открываю в дизассемблере - и что вы думаете? Он просто копирует статический блоб из секции данных. Никаких хитрых вычислений, просто держите массив:[/SIZE] [CODE] malloc(0x54) ; 84 байта SetByteArrayRegion(ptr@0xdddb0, 0, 0x54) ; копируем блоб return byte[84] [/CODE] [SIZE=4]Блоб лежит по адресу [COLOR=rgb(65, 168, 95)]0xe2da8[/COLOR] и это не просто ключ, а упакованная структура: внутри и ключ, и название алгоритма, и режим. Достаю через program headers:[/SIZE] [spoiler=Код извлечения;align=left][src=python] import struct P = 'libozon-id-sdk.so' f = open(P, 'rb').read() e_phoff = struct.unpack_from(' str: raw = base64.b64decode(payload) iv, ct = raw[:16], raw[16:] cipher = AES.new(KEY, AES.MODE_CFB, iv=iv, segment_size=128) return cipher.decrypt(ct).decode() print(decode('')) [/src] [SIZE=4]В плейнтексте - ровно тот JSON, что мы и шифровали. И вот, собственно, подтверждение от сервера: наш отпечаток принят.[/SIZE] [spoiler=Скриншот;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/hxddif.png[/IMG][/spoiler][/spoiler] [SIZE=4]В тот момент я выдохнул. Ключ есть, схема воспроизведена, сервер кушает наш пейлоад. Но рано радовался - помимо отпечатка есть еще подпись. :colobok_ok: [/SIZE] [/spoiler] [spoiler=Шаг 5. Подпись x-ad-id;align=left][SIZE=4]Подписывается каждый [tooltip=529]запрос[/tooltip]. За это отвечает OkHttp-интерцептор [COLOR=rgb(65, 168, 95)][unfurl="https://ru.ozon.id.ad"]ru.ozon.id.ad[/unfurl].AdManager[/COLOR].[/SIZE] [SIZE=4]Логика: берется URL, имя приложения, версия, значение заголовка [COLOR=rgb(65, 168, 95)]x-o3-fp[/COLOR], метод и некий [COLOR=rgb(65, 168, 95)]adId[/COLOR]. Все летит в нативный [COLOR=rgb(65, 168, 95)]getValue1(...)[/COLOR]. Результат в base64 кладется в заголовок, имя которого дает второй метод - [COLOR=rgb(65, 168, 95)]getValue2()[/COLOR].[/SIZE] [SIZE=4]Забавный момент: [COLOR=rgb(65, 168, 95)]getValue2()[/COLOR] всегда возвращает одно и то же - [COLOR=rgb(65, 168, 95)]x-ad-id[/COLOR]. А [COLOR=rgb(65, 168, 95)]x-o3-fp[/COLOR] это вообще константа из конфига: [COLOR=rgb(65, 168, 95)]1.d9f8368943e8fe03[/COLOR]. Все страшное лежит на поверхности :duck_money: [/SIZE] [SIZE=4]Лезем в дизасм [COLOR=rgb(65, 168, 95)]getValue1[/COLOR]:[/SIZE] [CODE] Java_ru_ozon_id_ad_AdManager_getValue1: ... bl 0xd1550 ; вытащить строку из jstring (x0=env, x1=jstring) ... bl 0xd15f0 ; получить счетчик / время bl 0xd1600 ; дописать аргумент в буфер bl 0xd1610 ; дописать разделитель ("1".."6") ... ; tolower (A-Z -> a-z) ; reverse (разворот строки) ; xor-слой ('.' -> '!') ... blr x8 ; env->NewByteArray(len) blr x8 ; env->SetByteArrayRegion(...) ret [/CODE] [spoiler=Как устроена строка подписи;align=left][SIZE=4]Если докопать функцию до конца, видно что она клеит строку по строгой схеме: шесть аргументов, между ними - разделители-цифры по порядку.[/SIZE] [QUOTE] url + "1" + appName + "2" + appVersion + "3" + x-o3-fp + "4" + method + "5" + <секунды> + "6" + adId [/QUOTE] [SIZE=4]Потом вся строка проходит через:[/SIZE] [LIST] [*]приведение к нижнему регистру; [*]реверс; [*]XOR-слой (' [srci].[/srci] ' заменяется на ' [srci]![/srci] '); [*]и отдается как byte[], который кодируется в base64. [/LIST] [SIZE=4]Отсюда и недетерминированность - внутрь замешано время и случайный префикс.[/SIZE] [src=python] import base64 sig = base64.b64decode(x_ad_id) print(len(sig), sig[:9], sig[9:11]) # [9 случ.] [1.] [hex millis]... [/src][/spoiler] [spoiler=Скриншот дизассемблера;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/lela3k.png[/IMG][/spoiler] [SIZE=4]Тут был выбор. Либо сидеть и воспроизводить алгоритм руками (tolower, reverse, XOR), либо пойти хитрее. Думаю вы уже поняли, что я выбрал :genius: [/SIZE] [/spoiler] [spoiler=Шаг 6. JNI-оракул - просим нативку посчитать за нас;align=left][SIZE=4]Раз функции экспортируются по имени, их можно дернуть из своего кода, вообще не восстанавливая алгоритм. Это и есть оракул: мы не знаем что внутри, но нам и не надо.[/SIZE] [SIZE=4]Главное - воссоздать класс с тем же FQN и теми же сигнатурами:[/SIZE] [src=java] package ru.ozon.id.ad; public final class AdManager { static { System.loadLibrary("ozon-id-sdk"); } public AdManager() {} // ВАЖНО: методы instance (не static), // иначе 6 строк не попадут в x2..x7 на arm64 public final native byte[] getValue1(String s1, String s2, String s3, String s4, String s5, String s6); public final native String getValue2(); } [/src] [SIZE=4]Собираем простенький APK (солвер на Java, а сам разбор я вел на [tooltip=894]Python[/tooltip]), кидаем рядом [COLOR=rgb(65, 168, 95)][unfurl="https://libozon-id-sdk.so"]libozon-id-sdk.so[/unfurl][/COLOR] под все ABI - и пробуем:[/SIZE] [src=java] AdManager m = new AdManager(); Log.i("RATSIGN", "getValue2 = " + m.getValue2()); byte[] sig = m.getValue1( "https://api.ozon.ru/composer-api.bx/_action/initAuth", "ozonbankfinance_android", "19.33.1(16030341)", "1.d9f8368943e8fe03", "POST", "3fd5c2606717c5b03fe22ad7bb3f11253fe7"); Log.i("RATSIGN", "sign = " + Base64.encodeToString(sig, Base64.NO_WRAP)); [/src] [spoiler=Результат;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/7huln1.png[/IMG][/spoiler] [SIZE=4]Структура подписи такая:[/SIZE] [QUOTE] [9 случайных]1.[hex millis].[adId].[11-байт тег].1 [/QUOTE] [SIZE=4]Недетерминирована, ровно как в оригинале. Работает и на x86_64, и на arm64. Когда я увидел осмысленный вывод из своего APK, где нет ни строчки кода приложения, я минут пять просто сидел и улыбался :yupiyei: [/SIZE] [SIZE=4]Итог: подписываем произвольные [tooltip=529]запросы[/tooltip] родной нативной функцией, не зная ее алгоритма.[/SIZE] [/spoiler] [spoiler=Шаг 7. DeviceStatus - второй отпечаток, о котором забывают;align=left][SIZE=4]Второй канал - [COLOR=rgb(65, 168, 95)]ru.ozon.fintech.antifraud.internal.models.DeviceStatus[/COLOR]. JSON, base64, заголовок [COLOR=rgb(65, 168, 95)]x-device-status[/COLOR].[/SIZE] [SIZE=4]Полей тьма: vendor, device, таймзона, carrier, emulator, ids, foundApps, isRoot, battery, screenOn, permissions, sessionId, cpu/ram, акселерометр, гироскоп, магнитометр, давление, debugInfo... В общем, досье.[/SIZE] [SIZE=4]Для нас критично вот что: часть полей пересекается с первым отпечатком (модель, [tooltip=539]root[/tooltip], emulator), и они [COLOR=rgb(255, 255, 255)]обязаны совпадать[/COLOR]. Иначе сервер видит рассинхрон: в одном канале Pixel 7, в другом - реальная модель телефона. Именно на этом палятся почти все.[/SIZE] [SIZE=4]Кстати, [COLOR=rgb(65, 168, 95)]cert_hash[/COLOR] у меня оказался пустым - то есть подменять нечего. Но проверять его стоит, у другой версии он может быть.[/SIZE] [spoiler=Как выглядит фрагмент;align=left][src=json] {"vendor":"Google","device":"Pixel 7", "raw_ids":{"a_build_fingerprint":"google/panther/panther:14/UP1A.231005.007/10754064:user/release-keys"}, "found_apps":[],"is_root":false,"emulator":false, "debug":{"usb_debug_enabled":false,"wifi_debug_enabled":false,"is_debugging":false}, "cert_hash":null,"carrier":"YOTA"} [/src][/spoiler] [SIZE=4]Вывод: один набор данных об устройстве - и везде только он. Иначе рассинхрон и [tooltip=528]бан[/tooltip].[/SIZE] [/spoiler] [spoiler=Шаг 8. Карта заголовков;align=left][SIZE=4]Кроме подписи шлется куча заголовков, часть которых должна совпадать с отпечатком:[/SIZE] [CODE] x-o3-app-name : ozonbankfinance_android x-o3-app-version : 19.33.1(16030341) x-o3-device-type : mobile x-o3-device-language : ru x-o3-fp : 1.d9f8368943e8fe03 x-app-version : app/android/19.33.1//19.33.1 x-app-uuid : x-ob-sessionid : x-ob-model-device : Google Pixel 7 x-ob-osver : 14 x-app-store : google x-ob-theme : light x-ob-new-arch : true x-ob-interface-scale : 1.0:2.75 x-ob-insets : 0,0,0,0 x-ob-cold-start : false x-requested-with : ru.ozon.app x-ad-id : <подпись> [/CODE] [SIZE=4]Особое внимание на [COLOR=rgb(65, 168, 95)]x-ob-model-device[/COLOR] - обязан совпадать с моделью в отпечатке. Такие мелочи и рушат всю связку.[/SIZE][/spoiler] [spoiler=Шаг 9. MF-флоу: шлем отпечаток;align=left][SIZE=4]Собираем все вместе:[/SIZE] [LIST=1] [*]дергаем эндпоинт авторизации; [*]сервер отвечает [COLOR=rgb(226, 80, 65)]403[/COLOR] и заголовком [COLOR=rgb(65, 168, 95)]ozon-antibot: 1[/COLOR], ставит [tooltip=154]cookie[/tooltip] [COLOR=rgb(65, 168, 95)]abt_data[/COLOR]; [*]собираем [COLOR=rgb(65, 168, 95)]FingerprintDTO[/COLOR] и шифруем родным ключом; [*]шлем на [COLOR=rgb(65, 168, 95)]/abt/result-mobile[/COLOR]; [*]сервер отвечает [COLOR=rgb(65, 168, 95)]{"isValid": true}[/COLOR]. [/LIST] [spoiler=Скриншот ответа сервера;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/mqoz7g.png[/IMG][/spoiler] [SIZE=4]Сам запрос:[/SIZE] [src=python] dto = build_fingerprint_dto(type='asyncOnLogin', device_id=android_id) mf = build_mf(dto) requests.post( 'https://api.ozon.ru/abt/result-mobile?isAsyncOnLogin=true', json={'mf': mf, 'mfToken': None, 'n': None}, cookies={'abt_data': abt_data}, headers=sign_headers(url, 'POST') ) [/src] [spoiler=Скриншот принятого отпечатка;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/hxddif.png[/IMG][/spoiler] [SIZE=4]Итог: сервер валидирует отпечаток и отдает [COLOR=rgb(65, 168, 95)]isValid: true[/COLOR]. Я потирал руки, но, как оказалось, это было еще не все. Дальше меня ждал сюрприз.[/SIZE] [/spoiler] [spoiler=Шаг 10. JS-челлендж и самое изящное решение;align=left][SIZE=4]Помимо заголовка [COLOR=rgb(65, 168, 95)]ozon-antibot: 1[/COLOR], сервер в теле ответа присылает JSON с полем [COLOR=rgb(65, 168, 95)]challengeURL[/COLOR]. Прямым текстом: реши челлендж.[/SIZE] [src=json] {"incidentId": "fab_chlg_...", "challengeURL": "https://api.ozon.ru/challenge.html?challenge=...", "supportURL": "...", "timeoutSec": 180, "blockURL": "...&bm=block_2&..."} [/src] [SIZE=4]Перехожу по ссылке - HTML-страница, которая подгружает JS-бандл [COLOR=rgb(65, 168, 95)]abt-challenge[/COLOR] (у меня v48) и что-то считает.[/SIZE] [spoiler=Два пути;align=left][SIZE=4][COLOR=rgb(255, 255, 255)]Путь первый[/COLOR] - реверсить JS целиком. Трудоемко, и бесперспективно вдолгую: версия сменится, логика поменяется, работа в тыкву.[/SIZE] [SIZE=4][COLOR=rgb(255, 255, 255)]Путь второй[/COLOR] - не решать челлендж, а исполнить его как браузер. Мы на Android, а тут браузерный движок из коробки.[/SIZE] [SIZE=4]Взял второй. Он устойчивее: даже если сервер сменит версию, солвер продолжит работать, потому что исполняет их же код.[/SIZE][/spoiler] [SIZE=4]Логика солвера - проще некуда:[/SIZE] [LIST] [*]закинуть cookies в [COLOR=rgb(65, 168, 95)]CookieManager[/COLOR]; [*]открыть [COLOR=rgb(65, 168, 95)]challengeURL[/COLOR] в скрытом WebView; [*]дождаться, пока JS отработает и cookies изменятся; [*]забрать cookies и повторить [tooltip=529]запрос[/tooltip]. [/LIST] [src=java] public static String solve(Activity act, String url, Map seed, long timeoutMs) { final CountDownLatch latch = new CountDownLatch(1); final CookieManager cm = CookieManager.getInstance(); cm.setAcceptCookie(true); act.runOnUiThread(new Start(act, url, seed, cm, latch)); // создаем WebView на UI-потоке latch.await(timeoutMs, TimeUnit.MILLISECONDS); return cm.getCookie("https://api.ozon.ru"); // возвращаем cookies } [/src] [SIZE=4]Внутри - WebView с JS, seed-cookies и поллинг до изменения cookie:[/SIZE] [src=java] WebView wv = new WebView(act); WebSettings s = wv.getSettings(); s.setJavaScriptEnabled(true); s.setDomStorageEnabled(true); s.setUserAgentString(UA); for (Map.Entry e : seed.entrySet()) cm.setCookie("https://api.ozon.ru", e.getKey() + "=" + e.getValue()); cm.flush(); wv.loadUrl(challengeUrl); new Handler(Looper.getMainLooper()).postDelayed(new Poll(cm, latch), 1500); [/src] [SIZE=4]После прохождения страница уходит на [COLOR=rgb(65, 168, 95)]/abt/challenge/ok[/COLOR], [tooltip=154]cookie[/tooltip] [COLOR=rgb(65, 168, 95)]abt_data[/COLOR] обновляется.[/SIZE] [spoiler=Скриншот решения;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/6s0pr5.png[/IMG][/spoiler] [SIZE=4]Секунд пять, без участия человека. И подчеркну: это не разгадка капчи, а автоматическое исполнение их же страницы. Этот момент радует меня больше всего [/SIZE] [/spoiler] [spoiler=Шаг 11. Прокси, личности и урок про IP;align=left][SIZE=4]А вот тут я споткнулся по крупному. Долго гонял все с обычного [tooltip=964]IP[/tooltip] - и после серии запросов сервер прогрелся и начал отдавать жесткий [tooltip=528]бан[/tooltip] с признаком [COLOR=rgb(226, 80, 65)]bm=block_2[/COLOR]. Вроде все правильно, подпись сходится, отпечаток валиден - а летит [tooltip=528]бан[/tooltip]. [/SIZE] [spoiler=Скриншот бана;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/hlh46a.png[/IMG][/spoiler] [SIZE=4]И тут меня осенило: дело в [tooltip=964]IP[/tooltip]. Переехал на чистые резидентные [tooltip=151]прокси[/tooltip] - и [tooltip=528]бан[/tooltip] снялся. Вместо [COLOR=rgb(226, 80, 65)]block_2[/COLOR] сервер начал отдавать [COLOR=rgb(243, 121, 52)]fab_chlg_[/COLOR], то есть челлендж, который мы уже умеем решать.[/SIZE] [spoiler=Скриншот личностей;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/qhti6t.png[/IMG][/spoiler] [SIZE=4]Отдельная боль - SOCKS5. Стоковый Android-стек не умеет SOCKS5 с аутентификацией, поэтому пришлось писать свой HTTP/1.1 поверх [COLOR=rgb(65, 168, 95)]SSLSocket[/COLOR]:[/SIZE] [src=python] # SOCKS5: приветствие -> аутентификация -> CONNECT s.sendall(b"\x05\x01\x02") # требуем user/pass assert s.recv(2) == b"\x05\x02" s.sendall(auth(user, password)) assert s.recv(2) == b"\x01\x00" s.sendall(connect_domain("api.ozon.ru", 443)) # далее - обычный TLS поверх полученного сокета [/src] [spoiler=Скриншот проверки прокси;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/4xntk6.png[/IMG][/spoiler] [SIZE=4]И важный момент про TLS, который часто упускают: [tooltip=151]прокси[/tooltip] туннелирует [tooltip=958]TCP[/tooltip], а TLS строится локально, end-to-end. Поэтому [COLOR=rgb(255, 255, 255)]JA3 через [tooltip=151]прокси[/tooltip] не меняется[/COLOR], он идентичен прямому соединению. Для антифродов, которые сверяют TLS-отпечаток, это критично.[/SIZE] [/spoiler] [spoiler=Шаг 12. Собираем все вместе;align=left][SIZE=4]Финальный флоу:[/SIZE] [LIST=1] [*]загружаем личность для прокси; [*][COLOR=rgb(65, 168, 95)]initAuth[/COLOR] (clientId = [COLOR=rgb(65, 168, 95)]ozonbankfinance_android[/COLOR]); [*]сервер отдает [COLOR=rgb(226, 80, 65)]403[/COLOR] + [COLOR=rgb(65, 168, 95)]abt_data[/COLOR]; [*]шифруем [COLOR=rgb(65, 168, 95)]FingerprintDTO[/COLOR] и шлем на [COLOR=rgb(65, 168, 95)]/abt/result-mobile[/COLOR] -> [COLOR=rgb(65, 168, 95)]isValid: true[/COLOR]; [*]открываем [COLOR=rgb(65, 168, 95)]challengeURL[/COLOR] в WebView -> [COLOR=rgb(65, 168, 95)]/abt/challenge/ok[/COLOR]; [*]повторяем [COLOR=rgb(65, 168, 95)]entry[/COLOR] с обновленными cookies -> [COLOR=rgb(65, 168, 95)]200 OK[/COLOR] и валидный DTO. [/LIST] [spoiler=Скриншот полного лога;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/ixrkmw.png[/IMG][/spoiler] [SIZE=4]Результат - сервер отдает страницу входа:[/SIZE] [src=json] {"data":{"title":"Введите номер телефона", "subtitle":"Мы отправим код или позвоним...", "inputs":[{"type":"phone","name":"phone","countrySelect":{...}}]}} [/src] [spoiler=Скриншот ответа;align=left][IMG=align=left;alt=%5BIMG%5D]https://files.catbox.moe/myjsmh.png[/IMG][/spoiler] [SIZE=4]Воспроизводится стабильно на разных резидентных [tooltip=964]IP[/tooltip] с разными личностями. Все четыре звена - подпись, отпечаток, челлендж и сам [tooltip=529]запрос[/tooltip] - работают связкой. Когда я первый раз увидел [COLOR=rgb(65, 168, 95)]200 OK[/COLOR] с нормальным DTO, честно, не поверил и перезапустил еще раз пять[/SIZE] [/spoiler] [spoiler=Таймлайн разбора;align=left][SIZE=4]Для любителей хронологии:[/SIZE] [LIST=1] [*]другой банк - осечка, нативная защита убивает процесс [*]переезд на маркетплейс, знакомство с Ozon ID; [*]найдены три канала отпечатка; [*]из .so вытащен статический ключ (84 байта); [*]AES-CFB воспроизведен, round-trip сошелся; [*]разобран алгоритм подписи, написан JNI-оракул [*]MF-пейлоад собран, сервер отвечает isValid: true; [*]стопор: прогретый [tooltip=964]IP[/tooltip] дает block_2 - переезд на резидентные прокси; [*]сервер меняет [tooltip=528]бан[/tooltip] на JS-челлендж - написан WebView-солвер; [*]челлендж пройден, entry вернул 200 и EntryDTO [/LIST][/spoiler] [spoiler=Грабли, чтобы вы не наступали;align=left][SIZE=4]Что отняло больше всего времени:[/SIZE] [LIST] [*][COLOR=rgb(255, 255, 255)]Привязка нативных методов.[/COLOR] [COLOR=rgb(65, 168, 95)]static[/COLOR] для [COLOR=rgb(65, 168, 95)]getValue1[/COLOR] нельзя - на arm64 аргументы не попадут в нужные регистры; [*][COLOR=rgb(255, 255, 255)]Опечатка в ключе.[/COLOR] Один пропущенный hex-символ = 83 байта вместо 84 и все ломается. Ключ - только из .so программно; [*][COLOR=rgb(255, 255, 255)]Рассинхрон каналов.[/COLOR] Разные устройства в [COLOR=rgb(65, 168, 95)]FingerprintDTO[/COLOR] и [COLOR=rgb(65, 168, 95)]x-device-status[/COLOR] = палево; [*][COLOR=rgb(255, 255, 255)]Порядок запросов.[/COLOR] Отпечаток логичнее слать проактивно, а не только после [COLOR=rgb(226, 80, 65)]403[/COLOR]; [*][COLOR=rgb(255, 255, 255)][tooltip=964]IP[/tooltip].[/COLOR] Прогретый [tooltip=964]IP[/tooltip] = [COLOR=rgb(226, 80, 65)]block_2[/COLOR], чистый резидентный = челлендж; [*][COLOR=rgb(255, 255, 255)]WebView и [tooltip=151]прокси[/tooltip].[/COLOR] Челлендж идет с [tooltip=964]IP[/tooltip] устройства, API - через [tooltip=151]прокси[/tooltip]. Пока связано через [tooltip=154]cookie[/tooltip], но на будущее стоит гнать все через [tooltip=151]прокси[/tooltip]. [/LIST][/spoiler] [spoiler=Итоги;align=left][SIZE=4]Что мы сделали по итогу:[/SIZE] [LIST] [*]разобрали три канала идентификации; [*]вытащили статический ключ из нативной библиотеки; [*]воспроизвели AES-CFB и подтвердили сервером ([COLOR=rgb(65, 168, 95)]isValid: true[/COLOR]); [*]разобрали алгоритм подписи и написали JNI-оракул; [*]собрали пейлоад антибота и прошли JS-челлендж через WebView; [*]построили клиент с резидентными [tooltip=151]прокси[/tooltip] и правилом один [tooltip=964]IP[/tooltip] - одна личность. [/LIST] [SIZE=4]Главный вывод: [tooltip=141]антифрод[/tooltip] - это система из слоев, и обходить ее по частям не работает. Работает только связка.[/SIZE] [I][SIZE=4][COLOR=rgb(247, 218, 100)]P.S. Только после написания статьи сервер иногда стал отдавать челлендж и на входе[/COLOR][/SIZE][/I][/spoiler] [spoiler=Полный код: обход, авто-солвер и клиент;align=left][SIZE=4]Ниже я собрал весь рабочий код, который использовал сам. Ничего не выкидывал, все как есть, включая грабли и костыли. Код разбит по файлам, чтобы было проще ориентироваться. Если будете повторять - начинайте с кейса ключа, потом оракул подписи, потом солвер.[/SIZE] [spoiler=1. Извлечение ключа из .so (Python);align=left][SIZE=4]Первый шаг - достать статический блоб 84 байта из нативной библиотеки.[/SIZE] [src=python] import struct P = 'libozon-id-sdk.so' f = open(P, 'rb').read() # разбираем program headers, чтобы получить файловые смещения e_phoff = struct.unpack_from(' str: raw = base64.b64decode(payload) iv, ct = raw[:16], raw[16:] c = AES.new(KEY, AES.MODE_CFB, iv=iv, segment_size=128) return c.decrypt(ct).decode() def build_mf(dto: dict, mf_token: str = None) -> str: key = KEY if mf_token is not None: key = KEY[0:24] + mf_token[-8:].encode() plain = f"{int(time.time() * 1000)}," + json.dumps(dto, separators=(',', ':'), ensure_ascii=False) iv = os.urandom(16) c = AES.new(key, AES.MODE_CFB, iv=iv, segment_size=128) ct = c.encrypt(plain.encode()) return base64.b64encode(iv + ct).decode() def fingerprint_dto(dtype: str, device_id: str) -> dict: return { "androidFingerprintType": dtype, "androidModel": "Pixel 7", "androidManufacturer": "Google", "androidBrand": "google", "androidHardware": "panther", "androidBoard": "panther", "androidDevice": "panther", "androidProduct": "panther", "androidSdk": 34, "androidRelease": "14", "androidSecurityPatch": "2024-01-05", "androidBuildId": "UP1A.231005.007", "androidRadioVersion": "g5300g-000000-240101-B-11012446", "androidFingerprint": "google/panther/panther:14/UP1A.231005.007/10754064:user/release-keys", "androidTags": "release-keys", "androidType": "user", "androidTime": 1698796800000, "androidUser": "android-build", "androidBootloader": "slider-1.0-10062794", "androidSku": "unknown", "androidOdmSku": "unknown", "androidDisplay": "UP1A.231005.007", "androidHost": "abfarm-release-rbe-64-0004", "androidSupportedAbis": ["arm64-v8a", "armeabi-v7a", "armeabi"], "androidSocManufacturer": "Google", "androidSocModel": "Tensor G2", "androidScreenWidth": 1080, "androidScreenHeight": 2400, "androidDensity": 2.75, "androidDensityWidth": 420.0, "androidDensityHeight": 420.0, "androidPackageName": "ru.ozon.fintech.finance", "androidVersionName": "19.33.1", "androidFirstInstallTime": 1780000000000, "androidLastUpdateTime": 1780000000000, "androidVersionCode": 16030341, "androidLocales": ["ru-RU"], "androidTimezoneId": "Asia/Krasnoyarsk", "androidTimezoneRawOffset": 25200000, "androidTimezoneDisplayName": "Krasnoyarsk Standard Time", "androidIsDeviceSecure": True, "androidIsDebug": False, "androidConnectionType": "WIFI", "androidIsVpnConnected": False, "androidIsRoot": False, "androidIdentifierForVendor": None, "androidDeviceId": device_id, "androidDevelopmentSettingsEnabled": 0, "androidAdbEnabled": 0, "androidStayOnWhilePluggedIn": 0, "androidDebugApp": None, "androidWaitForDebugger": 0, "androidIsEmulator": None, "androidOzonIdSdkVersion": "14.15.0", } if __name__ == '__main__': dto = fingerprint_dto('asyncOnLogin', '3f96f7afad658ec0') mf = build_mf(dto) print('mf =', mf) print('decoded =', decode_fingerprint(mf)) [/src][/spoiler] [spoiler=3. JNI-оракул подписи (Java) + сборка;align=left][SIZE=4]Класс оракула. Сигнатуры instance и повторяют оригинал 1:1.[/SIZE] [src=java] package ru.ozon.id.ad; public final class AdManager { static { System.loadLibrary("ozon-id-sdk"); } public AdManager() {} // instance-методы: 6 строк должны попасть в x2..x7 на arm64 public final native byte[] getValue1(String s1, String s2, String s3, String s4, String s5, String s6); public final native String getValue2(); } [/src] [SIZE=4]Обертка, которой я подписывал запросы:[/SIZE] [src=java] public final class Sign { private static final AdManager AD = new AdManager(); public static String xadid(String url, String method, String app, String ver, String xo3fp, String adId) { byte[] sig = AD.getValue1(url, app, ver, xo3fp, method, adId); return android.util.Base64.encodeToString(sig, android.util.Base64.NO_WRAP); } } [/src] [SIZE=4]Сборка APK с оракулом (aapt2 -> javac -> d8 -> apksigner):[/SIZE] [src=bash] SDK=/opt/android-sdk BT=$SDK/build-tools/34.0.0 AJ=$SDK/platforms/android-34/android.jar rm -rf build && mkdir -p build/classes build/dexout $BT/aapt2 link -o build/base.apk --manifest AndroidManifest.xml -I $AJ --auto-add-overlay javac -source 8 -target 8 -bootclasspath $AJ -d build/classes $(find src -name '*.java') $BT/d8 --lib $AJ --min-api 24 --output build/dexout $(find build/classes -name '*.class') cp build/base.apk build/unsigned.apk ( cd build/dexout && zip -q ../unsigned.apk classes.dex ) zip -qr build/unsigned.apk lib # lib/arm64-v8a/libozon-id-sdk.so + x86_64 $BT/zipalign -f -p 4 build/unsigned.apk build/aligned.apk keytool -genkeypair -keystore build/ks.jks -storepass android -keypass android \ -alias k -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=d,O=d,C=US" >/dev/null 2>&1 $BT/apksigner sign --ks build/ks.jks --ks-pass pass:android --key-pass pass:android \ --out build/oracle.apk build/aligned.apk adb install -r build/oracle.apk [/src][/spoiler] [spoiler=4. SOCKS5-туннель (Java);align=left][SIZE=4]Стандартный Android-стек не умеет SOCKS5 с авторизацией, поэтому пишем свой [tooltip=537]туннель[/tooltip].[/SIZE] [src=java] public final class Socks5 { public static Socket connect(String ph, int pp, String user, String pass, String host, int port) throws IOException { Socket s = new Socket(); s.connect(new InetSocketAddress(ph, pp), 12000); s.setSoTimeout(20000); s.setTcpNoDelay(true); InputStream in = s.getInputStream(); OutputStream out = s.getOutputStream(); out.write(new byte[]{5, 1, 2}); // VER, NMETHODS, USER/PASS out.flush(); byte[] r = readN(in, 2); if (r[1] == 2) { byte[] u = user.getBytes("UTF-8"); byte[] p = pass.getBytes("UTF-8"); ByteArrayOutputStream b = new ByteArrayOutputStream(); b.write(1); b.write(u.length); b.write(u); b.write(p.length); b.write(p); out.write(b.toByteArray()); out.flush(); r = readN(in, 2); if (r[1] != 0) throw new IOException("socks5 auth code=" + r[1]); } else if (r[1] != 0) { throw new IOException("socks5 method code=" + r[1]); } byte[] hb = host.getBytes("UTF-8"); ByteArrayOutputStream b = new ByteArrayOutputStream(); b.write(5); b.write(1); b.write(0); b.write(3); // VER CMD RSV ATYP=DOMAIN b.write(hb.length); b.write(hb); b.write((port >> 8) & 0xff); b.write(port & 0xff); out.write(b.toByteArray()); out.flush(); byte[] rep = readN(in, 4); if (rep[1] != 0) throw new IOException("socks5 connect code=" + rep[1]); int atyp = rep[3]; if (atyp == 1) readN(in, 4); else if (atyp == 3) readN(in, readN(in, 1)[0]); else if (atyp == 4) readN(in, 16); readN(in, 2); return s; } private static byte[] readN(InputStream in, int n) throws IOException { byte[] b = new byte[n]; int off = 0; while (off < n) { int k = in.read(b, off, n - off); if (k < 0) throw new IOException("socks5 eof"); off += k; } return b; } } [/src][/spoiler] [spoiler=5. HTTP/1.1 поверх SOCKS5+SSLSocket (Java);align=left][SIZE=4]Минимальный клиент с cookie-jar, разбором chunked/Content-Length и редиректами.[/SIZE] [src=java] public final class Http { public final String ph; public final int pp; public final String user; public final String pass; public final Jar jar = new Jar(); public Http(String ph, int pp, String user, String pass) { this.ph = ph; this.pp = pp; this.user = user; this.pass = pass; } public Resp req(String method, String url, String body, Map headers) throws IOException { String rest = url.substring(url.indexOf("://") + 3); String hostPort = rest, path = "/"; int slash = rest.indexOf('/'); if (slash >= 0) { hostPort = rest.substring(0, slash); path = rest.substring(slash); } int port = 443; String host = hostPort; int colon = hostPort.indexOf(':'); if (colon >= 0) { host = hostPort.substring(0, colon); port = Integer.parseInt(hostPort.substring(colon + 1)); } Socket tunnel = Socks5.connect(ph, pp, user, pass, host, port); SSLSocket ssl = (SSLSocket) SSLSocketFactory.getDefault() .createSocket(tunnel, host, port, true); SSLParameters p = ssl.getSSLParameters(); p.setServerNames(Collections.singletonList(new SNIHostName(host))); ssl.setSSLParameters(p); ssl.startHandshake(); StringBuilder h = new StringBuilder(); h.append(method).append(' ').append(path).append(" HTTP/1.1\r\n"); h.append("Host: ").append(host).append("\r\n"); for (Map.Entry e : headers.entrySet()) h.append(e.getKey()).append(": ").append(e.getValue()).append("\r\n"); if (!jar.header().isEmpty()) h.append("Cookie: ").append(jar.header()).append("\r\n"); byte[] bb = body == null ? new byte[0] : body.getBytes("UTF-8"); h.append("Content-Length: ").append(bb.length).append("\r\n"); h.append("Connection: close\r\n\r\n"); OutputStream os = ssl.getOutputStream(); os.write(h.toString().getBytes("ISO-8859-1")); if (bb.length > 0) os.write(bb); os.flush(); Resp r = readResponse(ssl.getInputStream()); ssl.close(); return r; } // readResponse + Jar опущены для краткости, логика обычная: // status line -> headers -> (chunked | content-length | read-all) // каждый Set-Cookie кладем в jar. } [/src][/spoiler] [spoiler=6. Личность на прокси (Java);align=left][SIZE=4]Один набор идентификаторов на прокси-IP, хранение в SharedPreferences.[/SIZE] [src=java] public final class Identity { public final String androidId, installId, deviceId, sessionId; public static Identity load(Context ctx, String key) { SharedPreferences sp = ctx.getSharedPreferences("rat_id_" + key, Context.MODE_PRIVATE); String a = sp.getString("android_id", null); String i = sp.getString("install_id", null); String d = sp.getString("device_id", null); String s = sp.getString("session_id", null); boolean dirty = false; if (a == null) { a = randHex(16); dirty = true; } if (i == null) { i = UUID.randomUUID().toString(); dirty = true; } if (d == null) { d = UUID.randomUUID().toString(); dirty = true; } if (s == null) { s = UUID.randomUUID().toString(); dirty = true; } if (dirty) sp.edit().putString("android_id", a).putString("install_id", i) .putString("device_id", d).putString("session_id", s).apply(); return new Identity(a, i, d, s); } private static String randHex(int n) { SecureRandom r = new SecureRandom(); StringBuilder sb = new StringBuilder(); for (int k = 0; k < n; k++) sb.append(Character.forDigit(r.nextInt(16), 16)); return sb.toString(); } } [/src][/spoiler] [spoiler=7. MfEncoder на Java (Java);align=left][SIZE=4]Тот самый шифратор пейлоада родным ключом.[/SIZE] [src=java] public final class MfEncoder { private static final byte[] BLOB = hex( "5d3a9d9237450777b3028be69a06bd1376ccc0d2f73748bedfc28909a551d789" + "290752c732d0da1d0a78cb327d53c8b66e61a67e6f6ae39c9bdf0d3c78832622" + "4145534145532f4346422f4e6f50616464696e67"); public static String encode(String json) throws Exception { byte[] key = slice(BLOB, 0, 32); String cipher = new String(slice(BLOB, 67, 84), "UTF-8"); // AES/CFB/NoPadding String algo = new String(slice(BLOB, 64, 67), "UTF-8"); // AES String plain = System.currentTimeMillis() + "," + json; byte[] iv = new byte[16]; new SecureRandom().nextBytes(iv); Cipher c = Cipher.getInstance(cipher); c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, algo), new IvParameterSpec(iv)); byte[] ct = c.doFinal(plain.getBytes("UTF-8")); byte[] out = new byte[iv.length + ct.length]; System.arraycopy(iv, 0, out, 0, iv.length); System.arraycopy(ct, 0, out, iv.length, ct.length); return Base64.encodeToString(out, Base64.NO_WRAP); } private static byte[] slice(byte[] a, int i, int j) { byte[] r = new byte[j - i]; System.arraycopy(a, i, r, 0, j - i); return r; } private static byte[] hex(String s) { byte[] r = new byte[s.length() / 2]; for (int i = 0; i < r.length; i++) r[i] = (byte) Integer.parseInt(s.substring(i*2, i*2+2), 16); return r; } } [/src][/spoiler] [spoiler=8. Авто-солвер JS-челленджа (Java);align=left][SIZE=4]WebView исполняет abt-challenge и мы забираем cookies. Никакого ручного участия.[/SIZE] [src=java] public final class ChallengeSolver { private static final String API = "https://api.ozon.ru"; public static String solve(Activity act, String url, Map seed, long timeoutMs) { final CountDownLatch latch = new CountDownLatch(1); final CookieManager cm = CookieManager.getInstance(); cm.setAcceptCookie(true); act.runOnUiThread(new Start(act, url, seed, cm, latch)); try { latch.await(timeoutMs, TimeUnit.MILLISECONDS); } catch (InterruptedException ignored) {} return cm.getCookie(API); } static final class Start implements Runnable { final Activity act; final String url; final Map seed; final CookieManager cm; final CountDownLatch latch; Start(Activity a, String u, Map s, CookieManager c, CountDownLatch l) { act = a; url = u; seed = s; cm = c; latch = l; } public void run() { WebView wv = new WebView(act); WebSettings s = wv.getSettings(); s.setJavaScriptEnabled(true); s.setDomStorageEnabled(true); s.setDatabaseEnabled(true); s.setUserAgentString("Mozilla/5.0 (Linux; Android 14; Pixel 7 Build/UP1A.231005.007; wv) " + "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/120.0.0.0 Mobile Safari/537.36"); cm.setAcceptThirdPartyCookies(wv, true); if (seed != null) for (Map.Entry e : seed.entrySet()) cm.setCookie(API, e.getKey() + "=" + e.getValue()); cm.flush(); act.setContentView(wv); wv.setWebViewClient(new WebViewClient()); wv.loadUrl(url); new Handler(Looper.getMainLooper()).postDelayed(new Poll(cm, latch), 1500); } } static final class Poll implements Runnable { final CookieManager cm; final CountDownLatch latch; String base; int tries; Poll(CookieManager c, CountDownLatch l) { cm = c; latch = l; } public void run() { String c = cm.getCookie(API); if (base == null) base = c; if (tries++ >= 18 || (tries > 2 && c != null && !c.equals(base))) { latch.countDown(); return; } new Handler(Looper.getMainLooper()).postDelayed(this, 1000); } } } [/src][/spoiler] [spoiler=9. Полный флоу NetworkProbe (Java);align=left][SIZE=4]Связка всего: initAuth -> entry -> mf -> челлендж -> entry. Подпись через оракул.[/SIZE] [src=java] public static void run(Context ctx) throws Exception { Identity id = Identity.load(ctx, "p10811"); Http http = new Http("192.168.3.66", 10811, "user", "pass"); // 1) initAuth String initUrl = API + "/composer-api.bx/_action/initAuth?type=OnSessionInit"; http.req("POST", initUrl, "{\"clientId\":\"ozonbankfinance_android\",\"traceId\":null}", h(initUrl, "POST", id)); sleep(2500); // 2) entry (ожидаем 403 + antibot) String entry = API + "/composer-api.bx/_action/ozonIdPageEntry?iso=RU"; Http.Resp r = http.req("POST", entry, entryBody(id), h(entry, "POST", id)); r = follow(http, r, entryBody(id), id); // 3) антибот-пейлоад String abt = API + "/abt/result-mobile?isAsyncOnLogin=true"; String mf = MfEncoder.encode(Fingerprint.json("asyncOnLogin", id.androidId)); http.req("POST", abt, "{\"mf\":\"" + mf + "\",\"mfToken\":null,\"n\":null}", h(abt, "POST", id)); sleep(2500); // 4) авто-солвер челленджа String challenge = field(r.body, "challengeURL"); if (challenge != null && ctx instanceof Activity) { String cookies = ChallengeSolver.solve((Activity) ctx, challenge, http.jar.entries(), 20000); http.jar.merge(cookies); sleep(1000); } // 5) повтор entry -> 200 EntryDTO Http.Resp r2 = http.req("POST", entry, entryBody(id), h(entry, "POST", id)); r2 = follow(http, r2, entryBody(id), id); Log.i("RATNET", "final: " + r2.code + " " + r2.body); } private static Map h(String url, String method, Identity id) { String ver = "19.33.1(16030341)"; Map m = new LinkedHashMap<>(); m.put("Content-Type", "application/json"); m.put("User-Agent", UA); m.put("x-requested-with", "ru.ozon.app"); m.put("x-o3-app-name", "ozonbankfinance_android"); m.put("x-o3-app-version", ver); m.put("x-o3-device-type", "mobile"); m.put("x-o3-device-language", "ru"); m.put("x-o3-fp", "1.d9f8368943e8fe03"); m.put("x-app-uuid", id.installId); m.put("x-ob-sessionid", id.sessionId); m.put("x-ob-model-device", "Google Pixel 7"); m.put("x-ad-id", Sign.xadid(url, method, "ozonbankfinance_android", ver, "1.d9f8368943e8fe03", id.installId)); return m; } [/src][/spoiler] [spoiler=10. Полный флоу на Python (через оракул по сети);align=left][SIZE=4]Если не хочется поднимать Java-клиент, подпись можно запросить у оракула отдельно (например по локальному сокету), а весь флоу гонять на [tooltip=894]Python[/tooltip].[/SIZE] [src=python] import requests, base64 API = 'https://api.ozon.ru' XO3FP = '1.d9f8368943e8fe03' def build_headers(url, method, app, ver, ad_id, xad_id): return { 'content-type': 'application/json', 'user-agent': 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36', 'x-requested-with': 'ru.ozon.app', 'x-o3-app-name': app, 'x-o3-app-version': ver, 'x-o3-device-type': 'mobile', 'x-o3-device-language': 'ru', 'x-o3-fp': XO3FP, 'x-app-uuid': ad_id, 'x-ob-model-device': 'Google Pixel 7', 'x-ad-id': xad_id, } s = requests.Session() r = s.post(f'{API}/composer-api.bx/_action/initAuth?type=OnSessionInit', json={'clientId': 'ozonbankfinance_android', 'traceId': None}) r = s.post(f'{API}/composer-api.bx/_action/ozonIdPageEntry?iso=RU', json={'deviceId': DEVICE_ID, 'supportCountrySelect': True}) mf = build_mf(fingerprint_dto('asyncOnLogin', ANDROID_ID)) s.post(f'{API}/abt/result-mobile?isAsyncOnLogin=true', json={'mf': mf, 'mfToken': None, 'n': None}) r = s.post(f'{API}/composer-api.bx/_action/ozonIdPageEntry?iso=RU', json={'deviceId': DEVICE_ID, 'supportCountrySelect': True}) print(r.status_code, r.text[:400]) [/src][/spoiler][/spoiler] [CENTER] [SIZE=6][COLOR=rgb(255, 255, 255)]Всем добра![/COLOR][/SIZE][/CENTER] [SIZE=4]Ну вот и все, вроде ничего не забыл. Если было полезно - оставьте реакцию, мне будет приятно :fox_jump: [/SIZE]