Ultimatum — еще один форк хромиума, с претензией…
Добрый день! Меня зовут Тимур и я программист.
Сегодня я хочу сделать настоящий анонс своей сборки chromium — Ultimatum. Он умеет уже достаточно много что бы гордо носить свое собственное имя.
В прошлой своей статье я рассказал о том как пробросил в js прямой доступ до http кеша и объяснил для чего я это делаю. Статья завершилась со словами что я еще вернусь со своим антидетект браузером. Я вернулся и это немного больше чем антидетект браузер.
Если коротко — Ultimatum уже помножил на ноль такие техники трекинга как hsts-pinning, favicons cache и вообще использование многих других кешей в трекинге. А также! Теперь можно поставить расширение с любого сайта, не только со сторов гугля, оперы или микрософта (с них кстати тоже можно — со всех!). А еще! Можно перехватывать сетевые запросы и подменять их полностью! Ну и так далее и тому подобное.
А теперь более подробно и более спокойно.
Вот тут лежат все коммиты
Скачать бинарники можно у меня в бложике, пока только osx и винда, https://gonzazoid.com
Все api — строго для webextensions, требуют нестандартных permissions и соотв. расширения написанные с использованием этих api не могут быть выложены в сторы (chrome, opera, edge) так как с точки зрения сторов у них невалидный манифест.
Для начала повторюсь про diskCache.
diskCache
Для доступа к api в манифесте расширения нужно указать permission diskCache. Расширение с таким пермишеном после установки получает доступ к api:
await chrome.diskCache.keys(cache_name); // возвращает массив ключей в кеше
await chrome.diskCache.getEntry(cache_name, key); // возвращает указанное вхождение в кеш
await chrome.diskCache.putEntry(cache_name, entry); // пишет в указанный кеш, key указывается в entry
await chrome.diskCache.deleteEntry(cache_name, key); // удаляет указанное вхождение
При этом вхождение в кеш имеет следующий формат:
{
key: "string",
stream0: ArrayBuffer,
stream1: ArrayBuffer,
stream2: ArrayBuffer,
ranges: Array
}
// где ranges состоит из объектов:
{
offset: number,
length: number,
}
Свойство ranges необязательное и указывается только для sparse entities. stream0, 1, 2 обязательны для всех, но для sparse entities используется только stream0 и stream1, при этом stream1 содержит все чанки идущие друг за другом (без пустот), а ranges указывают где они (чанки) должны были располагаться. То есть длина stream1 должна совпадать с суммой всех length указанных в ranges. (Это все отображение деталей реализации disk_cache в хромиум, это не моя самодеятельность)
Про устройство disk_cache я подробнее говорил в прошлой статье, если что-то непонятно — загляните туда.
cache_name может принимать значения http
, js_code
, wasm_code
и webui_js_code
. Я работал пока только с http, если будете экспериментировать с другими кешами — не стеснятесь, делитесь результатами.
Итак, http кеш. Имея доступ к нему мы можем вытащить весь кеш, сохранить его в каком либо месте, можем полностью стереть его, а можем записать то что нам надо, например кеш от предыдущей сессии. Все это я реализовал в своем расширении Помогатор, в одной из следующих статей я расскажу как этим расширением пользоваться и какие возможности оно дает.
Какие техники трекинга мы удаляем с этого плана бытия с этим api? Из списка техник evercookie это:
- Storing cookies in HTTP ETags (Backend server required)
- Storing cookies in Web cache (Backend server required)
А в общем — любая техника которая основывается на проверке скачан ли уже ресурс или еще нет (за исключением favicon — там свой кеш) — на этих возможностях подскользнется и забуксует.
У данного api есть пока небольшая протечка — код не вывозит параллельные запросы, поэтому лучше дождаться получения элемента кеша и только потом делать следующий запрос. То же самое с записью. Это все надеюсь временно, я работаю над тем что бы стабилизировать api и такая проблема есть только у этого api, все остальные работают стабильно и спокойно переносят параллельные запросы.
sqliteCache
Позволяет получить доступ к favicon cache и history cache (они реализованы на базе sqlite). History уже достаточно давно в трекинге не участвует, но я решил что пусть будет. Для того что бы получить доступ к api у расширения в манифесте должен быть указан permission sqlCache
.
Интерфейс у api следующий:
await chrome.sqlCache.exec(storage, sql_request, bindings);
где:
storage
— строка, указывает к какой базе идет запрос. Может принимать значенияfaviconCache
илиhistoryCache
. Если знаете какие либо sqlite базы в недрах chromium-а в которые хотелось бы заглянуть — говорите, обсудим.sql_request
— строка, собственно сам sqlite запрос.bindings
Тут интересно. В самом запросе конретные значения не указываются, вместо этого указывается символ подмены?
. А в bindings мы указываем что там на самом деле должно быть подставлено. То есть bindings это массив элементов каждый из которых может быть (js→c++):string
(литерал, не объект) — трансформируется вsql::ColumnType::kText
number
— транформируется вsql::ColumnType::kFloat
(в js числа являются float, а не integer, мы же помним?)- объект с полями {
type: «int»,
value: «string, decimal»
} транформируется вsql::ColumnType::kInteger
. такие сложности с integer связаны с тем что sqlite поддерживает int до 64 разрядов и во первых float (в js) не поддерживает такую точность, а во вторых если мы начнем js-овский float (который number) использовать для kInteger то нам еще придется отличать его от использования для kFloat. Можно было бы приспособить для этого js-овский BigInt, но на самом деле это ничего не облегчает поэтому оставил так. ArrayBuffer
— транформируется вsql::ColumnType::kBlob
null
— транформируется вsql::ColumnType::kNull
Это покрывает все типы sqlite, подробности можно посмотреть на их сайте, документация у них вполне себе приличная.
В результате запроса мы получаем массив, в котором каждый элемент отображает одну строку результата и является массивом элементов. Каждый элемент имеет один из типов указанных выше для биндингов. То есть что-то вроде:
[
[ /* первая строка результата */ "строка", 3.14, { type: "int", value: "73" } ],
[ /* вторая строка результата */ "еще одна строка", 2.718, { type: "int", value: "43" } ],
...
]
Зачем понадобилось делать отдельное api для favicons если есть http cache? Дело в том что chrome/chromium работают с favicon «странно». Для них есть отдельный кеш, не http (в сети очень много статей в которых упоминается о том что этот кеш не сбрасывается, но это уже не так, при удалении browsing data он удаляется, не могу точно сказать с какой версии chromium, в 129-ой точно). Этот кеш достаточно активно используется что бы трекать пользователей, например в довольно известных supercookie. На хабре был перевод, сам код supercookie можно тут посмотреть, а описание того как работает техника — нескромно, но в моей предыдущей статье есть наиболее общее обьяснение, все что мне попадалось на просторах интернета — гораздо более частные случаи, если это не так — пишите, добавлю в статью ссылок.
О том как устроены favicon cache и history cache я расскажу отдельной статьей, здесь пока просто обзор api.
hstsCache
На данный момент hsts pinning
— самая непробиваемая техника трекинга (из известных мне), так что необходимость помножить ее на ноль была очевидна. Хромиум предоставляет довольно убогий интерфейс для работы с hsts, доступный по адресу chrome://net-internals/#hsts
и причины этой убогости стали ясны когда я распотрошил код, об этом ниже расписано.
Сама техника трекинга описана много где, есть paper по теме HSTS Supports Targeted Surveillance (если погуглить можно найти pdf в свободном доступе), на русском языке на хабре есть статья где упоминается эта техника, в общем при желании разобраться не долго.
Так вот, проблема в том что хромиум не предоставляет никаких инструментов что бы посмотреть какие домены что там записали в hsts cache. То есть посмотреть можно только зная домен, а вот список доменов вы не получите никак. Дело в том что хромиум не хранит сами домены, ключом к записи правил является хэш от домена. Я еще пока только думаю над тем стоит ли это исправлять, а пока что просто пробросил стандартный интерфейс для доступа. Api у нас выглядит так (доступно для расширений с permission hstsCache):
await chrome.hstsCache.keys(); // возвращает все доступные ключи в hsts cache. Каждый ключ является ArrayBuffer-ом
await chrome.hstsCache.getEntry(key); // возвращает вхождение в hstsCache с указанным ключом
await chrome.hstsCache.putEntry(entry); // записывает вхождение в кеш
await chrome.hstsCache.deleteEntry(key); // удаляет вхождение в кеш с указанным ключом
При этом вхождение имеет вид:
{
key, // ArrayBuffer(32),
upgradeMode, // number,
includeSubdomains, // boolean,
expiry, // number-timestamp like Date.now()
lastObserved, // number-timestamp like Date.now()
}
Подробно расписывать не буду, кто знаком с техникой hsts-pinning-а — тот поймет как этим пользоваться, кто не знаком — придется познакомиться для того что бы этим пользоваться.
localStorages
Расширение с таким пермишеном получает доступ ко всем записям в localStorage независимо от origin и прочего. То есть мы можем читать/писать/удалять любую запись любого localStorage. Api выглядит так:
await chrome.localStorages.keys(); // возвращает массив ключей, каждый ключ - arrayBuffer
await chrome.localStorages.getEntry(key); // возвращает запись соответствующую ключу, результат - arrayBuffer
await chrome.localStorages.putEntry(key, entry); // если запись существует - меняем ее, если нет - создаем
await chrome.localStorages.deleteEntry(key); // удаляем запись
await chrome.localStorages.flush(); // объяснение ниже
await chrome.localStorages.purgeMemory(); // объяснение ниже
Ключ является буфером, если мы переведем его в строку то получим значения такого плана:
[
"META:chrome://settings",
"META:devtools://devtools",
"META:https://habr.com",
"METAACCESS:chrome://settings",
"METAACCESS:devtools://devtools",
"METAACCESS:https://habr.com",
"VERSION",
"_chrome://settings\u0000\u0001privacy-guide-promo-count",
"_devtools://devtools\u0000\u0001console-history",
"_devtools://devtools\u0000\u0001experiments",
"_devtools://devtools\u0000\u0001localInspectorVersion",
"_devtools://devtools\u0000\u0001previously-viewed-files",
"_https://habr.com\u0000\u0001rom-session-start",
"_https://www.google.com/^0https://stackoverflow.com\u0000\u0001rc::h",
"_https://www.youtube.com/^0https://habr.com\u0000\u0001ytidb::LAST_RESULT_ENTRY_KEY"
]
Нас интересуют ключи с префиксом _http
— именно они имеют отношение к web-у, но как видим у нас есть тут доступ и к другим интересным вещам. Я это еще особо не исследовал, если кто поковыряет и найдет что-то интересное — дайте знать.
Первые 4 функции достаточно понятны, тут особо ничего нового нет, давайте посмотрим на flush и purgeMemory. Для начала — вот кусочек из соотв. mojom файла:
components/services/storage/public/mojom/local_storage_control.mojom
// Tells the service to immediately commit any pending operations to disk.
Flush();
// Purges any in-memory caches to free up as much memory as possible. The
// next access to the StorageArea will reload data from the backing database.
PurgeMemory();
Так вот, как это работает? Есть некая общая база которая где то там лежит, неважно где и неважно как. В процессе серфинга при отображении табов и фреймов с этой базы идет выборка и вытягиваются все ключи для соотв. origins (там чуть сложнее на самом деле, берется origin текущего фрейма и то ли main frame то ли parent frame, по памяти точно не скажу, надо смотреть код) Далее все фреймы которым нужны эти записи работают с их копиями в памяти. И это нормально с точки зрения производительности. Но! Когда мы хотим прочитать записи из базы — мы не знаем насколько они актуальные. Поэтому мы делаем flush () и заставляем все изменения закоммитить в базу. После этого мы можем ЧИТАТЬ и быть уверенными что работаем с актуальными данными. При этом все кешированные данные так же остаются в своих кешах и никакой просадки по производительности табы и фреймы не получают.
Далее. Мы прочитали данные, приняли какие то решения и решили что-то изменить. Пишем эти изменения в базу. Но при этом мы помним что уже открытые табы/фреймы сидят со своими кешами и этих изменений они не увидят. Вот для этого мы делаем purgeMemory (). Кеши сбрасываются и при следующем запросе к localStorage домена произойдет выборка записей с базы — да, вместе с нашими изменениями если эти изменения касались этого домена. То есть purgeMemory () мы делаем после ЗАПИСИ в базу и тут какая то просадка по производительности неизбежна.
urlRequest
Тут прям интересно. Помните как в 2019 гугль заявил что api webRequest нехорошее и поддержка его (в части блокирующих или точнее блокируемых запросов) будет прекращаться? А потом не стал переводить это api на третий манифест. И народ бурлил, и отвалились ad блокеры. И гугль выкатил свои decalrative network requests и народ бурлил еще больше. А потом опера (и вроде как микрософт, но это не точно) заявили что будут поддерживать webrequests до последнего, но они так и остались во втором манифесте. Помните, да? Я вот тоже помню. И честно говоря до сих пор не очень хорошо понимаю всех этих бурлений. В том смысле что ну хочет себе гугль стрелять в ногу — так это его нога, имеет право. Нас то он в свои ноги стрелять не заставляет. В итоге я просто скопировал код webRequest api, переименовал его в urlRequest, поднял до третьего манифеста и убрал весь код (на пока) связанный с событиями, оставив только onBeforeRequest. Но вот его я немного подрихтовал что бы он выглядел посимпатичнее, а именно:
- перехватываются все запросы, никаких защищенных доменов нет (в хромиуме есть домены гугля, запросы к которым не перехватывались)
- помимо cancel и redirect можно вернуть объект Response либо промис который разрезолвится в Response и в этом случае запроса в сеть не будет вообще — инициатору запроса отдадутся данные Response.
- все запросы которые попадают под requestFilter всегда блокируются, если выясняется что запрос нам не интересен можно вернуть пустой ответ либо cancel: false, в этом случае запрос пойдет в сеть без какого либо вмешательства.
Как это работает в коде? У расширения должен быть пермишен urlRequest
, вот так выглядит навешивание листенера:
chrome.urlRequest.onBeforeRequest.addListener((evt) => {
console.log(evt);
if (evt.url !== "https://pikabu.ru/") return { cancel: false };
return {
response: new Promise((resolve) => {
resolve(
new Response(
"haha",
{ statusText: "OK", headers: { "Content-Type": "text/html; charset=utf-8"}}
)
);
})
};
}, {urls: ["https://pikabu.ru/*"]})
То есть теперь можно писать расширения которые по сути могут в том числе выполнять функции web-сервера. Там есть пока еще ограничения по хедерам (они вшиты в код Response) — это я менять не буду, но возможность создавать любой ответ без ограничений — добавлю.
Далее. Я намерен вернуть все остальные события из api webRequest, убирая по пути ограничение там где это разумно, пробросить tcp и udp модули в js — и это в связке с перехватом запросов дает возможность регистрировать и реализовывать поддержку любых протоколов, на уровне js расширений. С учетом того что доступ к http кешу у нас уже есть — значительная часть обслуживающая сетевые запросы может переехать в js — это снизит объем c++ кода в проекте и даст бОльшие возможности для реализации новых протоколов, мне кажется это многим командам может быть интересно.
Итого
Собственно это пока все по api, еще одно напоминание — Ultimatum помимо описанных выше возможностей умеет схемы hash://, signed:// и related://, что (на мой взгляд) является базисом для web3.0, интересующиеся темой могут посмотреть мои предыдущие статьи, там я это описывал немного подробнее, повторяться не буду, а по web3.0 будет отдельная статья, там будет интересно.
Напоследок напомню — расширения, реализующие новые возможности могут быть включены в сборку (там есть такая тема как internal extension, как например реализован pdf reader) либо установлены по дефолту — это может быть интересно electron based проектам.
Так же напомню — эта сборка умеет ставить расширения с любого источника, достаточно отдать crx с правильным заголовком («Content-Type»: «application/x-chrome-extension»). Никакого урона безопасности не наносится, он все так же предупреждает пользователя что происходит и пользователь имеет возможность подтвердить действие или отказаться. Но при этом разработчики расширений получают возможность раздавать их со своих сайтов не мучаясь с долгими ревью в сторах и не считаясь с ограничениями китов индустрии. Я пока еще не наладил механизм апдейта, но это все будет, и тут я очень рассчитываю на поддержку — сборка дает больше возможностей разработчикам, владельцы расширений предлагают такую возможность пользователям, Ultimatum получает рекламу. В общем если есть идеи/предложения — я на связи и готов обсуждать.
Код открытый, лицензия MIT, берите и пользуйтесь кому надо и как надо.
На этом на сегодня все, как использовать эти api и какие планы развития проекта — в следующих статьях.
А новостей на сегодня больше нет, с Вами был Тимур, хорошего настроения!