Пропуск вшитых интеграций в TikTok на Android: анализ TTVideoEngine, ReVanced-патч и k-анонимность
В коротких видео формат рекламы давно сместился от официальных промо-постов к прямым интеграциям: авторы монтируют вставки (рулетки скинов CS:GO, платформы кейсов, телеграм-каналы) прямо в середину ролика.
Для YouTube эту проблему решает SponsorBlock. Мне стало интересно, насколько реально перенести подобную механику в Android-клиент TikTok. В процессе пришлось столкнуться с проприетарным плеером ByteDance, обфускацией байт-кода, оптимизацией вызовов к плееру и вопросами приватности запросов к базе таймкодов.
В этой статье описываю техническую сторону Proof of Concept: как устроен плеер TikTok, как стабильно перехватывать его методы через ReVanced и как организовать базу меток без слежки за пользователями.
1. Почему стандартные фильтры здесь не работают
Обычная блокировка рекламы в клиентах строится двумя путями:
Сетевые фильтры и DNS: блокируют домены трекеров и рекламных баннеров. Для видеопотока это бесполезно: ролик со вшитой рекламой отдаётся единым файлом с того же CDN ByteDance, что и любой другой контент.
Фильтрация ленты (Feed Filter): перехват ответа API
/aweme/v1/feed/. Официальные рекламные ролики приходят с флагомis_ads: true, и патч просто вырезает их из списка до показа.
В случае со вшитой интеграцией ролик для приложения ничем не отличается от обычного контента:
{
"aweme_id": "7421892381928391200",
"desc": "Полезный гайд #gaming",
"is_ads": false,
"video": {
"play_addr": { "url_list": ["https://v16m-default.akamaized.net/..."] },
"duration": 48200
}
}
Единственный рабочий вариант пропустить такую вставку — перехватить воспроизведение в самом приложении и в нужный момент программно вызвать перемотку (seekTo).
2. Анализ плеера TTVideoEngine и обфускация
TikTok не использует системный MediaPlayer или стандартный ExoPlayer. ByteDance использует собственный закрытый движок — TTVideoEngine, работающий в связке с нативными библиотеками libttvideoengine.so и libmdl.so.
APK-файл версий 36.x разбит более чем на 20 DEX-файлов:
Логика ленты и модели данных (
Aweme) находятся вclasses2.dex–classes5.dex.Сам плеер
TTVideoEngineрасположен глубже (обычноclasses16.dexили соседние).
Код плотно обфусцирован R8:
Имена классов сжаты:
Lo6f/cO;,LX/0vR;.Имена методов и полей случайные:
LJJlJllJl(),LIZ(),LIZIZ().При минорных обновлениях приложения имена меняются.
При этом TTVideoEngine является базовым SDK для разных сервисов ByteDance (например, он же используется в демонстрационном VEVodDemo от Volcengine). Поэтому сам класс com.ss.ttvideoengine.TTVideoEngine не переименовывается, а ключевые методы сохраняют сигнатуры типов:
play() -> voidgetCurrentPlaybackTime() -> int(позиция в миллисекундах)getDuration() -> intseekTo(int, SeekCompletionListener) -> void
Слушатель завершения перемотки (SeekCompletionListener) в коде обфусцирован и выглядит как интерфейс с генерированным именем (например, Lo6f/c0;).
3. Поиск точек входа (Fingerprints) и хукинг
Чтобы патч не ломался при каждом обновлении TikTok, вместо поиска по временным именам методов используется поиск по типам аргументов и возвращаемых значений через ReVanced Patcher:
// Fingerprints.kt
// Старт воспроизведения: метод без параметров с возвратом void
internal val BytecodePatchContext.enginePlayMethod by gettingFirstMethodDeclaratively {
name("play")
definingClass("Lcom/ss/ttvideoengine/TTVideoEngine;")
returnType("V")
parameterTypes()
}
// Перемотка: int (позиция) + обфусцированный интерфейс слушателя
internal val BytecodePatchContext.engineSeekMethod by gettingFirstMethodDeclaratively {
definingClass("Lcom/ss/ttvideoengine/TTVideoEngine;")
returnType("V")
parameterTypes("I", "Lo6f/c0;")
}
Для проверки точек входа до сборки APK используется скрипт на базе androguard (find_hooks.py), который парсит методы DEX-файлов:
CHECKS = [
("плеер: TTVideoEngine.play()V", "Lcom/ss/ttvideoengine/TTVideoEngine;", "play", "()V"),
("плеер: getCurrentPlaybackTime()", "Lcom/ss/ttvideoengine/TTVideoEngine;", "getCurrentPlaybackTime", "()"),
("плеер: getDuration()", "Lcom/ss/ttvideoengine/TTVideoEngine;", "getDuration", "()"),
("плеер: seekTo(I, …)", "Lcom/ss/ttvideoengine/TTVideoEngine;", "seekTo", "(I"),
("лента: getCurrentAweme()", "/BaseListFragmentPanel;", "getCurrentAweme", "()Lcom/ss/android/ugc/aweme/feed/model/Aweme;"),
]
Внедрение инструкций в Smali
В коде патча перехватываются ключевые события и передаются в Java-класс SponsorManager:
// Хук на старт воспроизведения
enginePlayMethod.addInstruction(
0,
"invoke-static/range { p0 .. p0 }, Lapp/revanced/extension/tiktok/mimo/SponsorManager;->onEnginePlay(Ljava/lang/Object;)V"
)
// Хук на перемотку: p1 содержит целевую позицию в миллисекундах
engineSeekMethod.addInstruction(
0,
"invoke-static/range { p0 .. p1 }, Lapp/revanced/extension/tiktok/mimo/SponsorManager;->onEngineSeek(Ljava/lang/Object;I)V"
)
Нюанс: важно использовать инструкцию invoke-static/range. Оптимизатор R8 часто выносит регистр this (p0) за пределы первых 16 регистров (> v15). Обычный invoke-static в таком случае приведёт к ошибке VerifyError при загрузке класса в рантайме.
4. Алгоритм автопропуска: оптимизация проверок таймлайна
Прямолинейный подход — опрашивать getCurrentPlaybackTime() по таймеру каждые 16 мс (60 кадров в секунду). Но постоянный JNI-поллинг в фоновом цикле нагружает процессор и может вызывать подёргивания интерфейса.
В SponsorManager.java интервал следующей проверки рассчитывается динамически — через деление расстояния до целевой точки (бисекция):
/**
* Задержка до следующей проверки таймлайна.
* Интервал равен половине расстояния до ближайшего сегмента.
*/
private static long nextDelay(long pos, Segment next, long dur) {
long gap;
if (next != null) {
gap = next.startMs - pos;
} else if (dur > 0 && !segments.isEmpty()) {
// До конца ролика сегментов нет — целимся в первый сегмент следующего круга
gap = dur - pos + segments.get(0).startMs;
} else {
return FAR_TICK_MS; // 1000 мс
}
return Math.max(MIN_TICK_MS, Math.min(FAR_TICK_MS, gap / 2));
}
Далеко от рекламы: проверка вызывается 1 раз в секунду (
1000 мс).При приближении к рекламному блоку: интервал делится пополам (
gap / 2) и сокращается вплоть до одного кадра (16 мс) непосредственно перед прыжком.
В симуляции на 10 000 прогонов это снизило медианное число вызовов getCurrentPlaybackTime() с 60 до 1.6 вызова в секунду, не теряя точности момента перемотки.
Краевые случаи
Склейка соседних меток: если несколько рекламных отрезков идут подряд (или с зазором менее 300 мс), они объединяются в один вызов перемотки без лишних микро-скачков.
Ручная перемотка: если пользователь сам перемотал ползунок в середину рекламной вставки, хук
onEngineSeekотмечает этот отрезок как просмотренный. Плеер не выбрасывает пользователя обратно.Защита от зацикливания (Loop Guard): если разметка ошибочна (занимает более 80% ролика или вызывает 3 прыжка подряд без реального воспроизведения контента), автопропуск для этого видео отключается.
5. Бэкенд: k-анонимность и Cloudflare Workers
Для работы нужна база таймкодов. Если клиент будет отправлять на сервер прямой идентификатор ролика (GET /api/segments?video_id=...), владелец сервера получит историю просмотров каждого пользователя.
Схема запросов с префиксом хэша
Для защиты приватности используется схема -анонимности (аналогично SponsorBlock):
[Клиент] [Cloudflare Worker]
│ │
├─ ID ролика: "7421892381928391200" │
├─ SHA-256(ID) = "a3f1b80c2e..." │
├─ Префикс: "a3f1" (первые 2 байта / 16 бит) │
│ │
├────────────── GET /api/skip/v1/a3f1 ─────────────────────────>
│ │
│ Поиск в базе D1:
│ WHERE hash LIKE 'a3f1%'
│ (плюс Cache API)
│ │
<────────────── Ответ со всеми роликами корзины a3f1 ──────────┤
│ │
├─ Локальный поиск нужного хэша в полученном списке │
└─ Передача отрезков в плеер │
Клиент считает SHA-256 от ID ролика.
На сервер отправляются только первые 4 символа hex (2 байта).
Все ролики распределены по
корзинам.
Сервер возвращает все известные ему отрезки для роликов с таким префиксом.
Нужный ролик клиент находит уже локально в памяти. Сервер не знает, какое именно видео из корзины открыто на устройстве.
Серверная часть
Бэкенд реализован на Cloudflare Workers + базе D1:
Edge Cache: популярные корзины кешируются через Cache API в памяти дата-центра Cloudflare, не нагружая базу данных D1 при повторных запросах.
Анонимизация пользователей: идентификаторы пользователей при отправке меток не сохраняются в открытом виде — пишется только
HMAC-SHA256(userID, PEPPER).Консенсус: новые отметки валидируются по порогу голосов (система апвоутов/даунвоутов в
consensus.ts). При накоплении отрицательных голосов сегмент скрывается из выдачи.
6. Итоги
В рамках этого Proof of Concept удалось решить технические задачи:
Подтверждена возможность надежного перехвата
TTVideoEngineчерез сигнатуры параметров без привязки к обфусцированным именам методов.Адаптивный шаг таймера снизил частоту опроса плеера до 1.6 вызова/сек.
Протокол с 16-битным префиксом хэша решает задачу приватности при запросах к базе.
Поддерживать собственный модифицированный APK TikTok на постоянной основе я не планирую: сопровождение крупного стороннего клиента со всеми его UI-багами, региональными нюансами и регулярными перестроениями кода требует слишком много времени.
Цель статьи — зафиксировать рабочую логику и механику перехвата плеера. Надеюсь, описанный подход и сигнатуры пригодятся мейнтейнерам ReVanced или другим разработчикам, работающим с анализом Android-приложений.
Материалы PoC
Сигнатуры методов плеера:
app/revanced/patches/tiktok/mimo/Fingerprints.ktЛогика бисекции и пропуска:
app/revanced/extension/tiktok/mimo/SponsorManager.javaСпецификация API префиксов: эндпоинты
/api/skip/v1/{prefix}Логика консенсуса меток:
worker/src/consensus.ts
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.