CDP + Fetch в ZennoBrowser: шлем нативные запросы из движка браузера + бонус: обходим CORS

  • Автор темы Автор темы Porosenok
  • Дата начала Дата начала

Porosenok

Client
Регистрация
26.09.2010
Сообщения
1 300
Реакции
127
Баллы
63
zbmasterlogo.png
Здравствуйте, уважаемые участники форума. Вот и настало время очередного конкурса статей, поэтому спешу порадовать вас новой статьей про CDP. В сегодняшней статье мы рассмотрим отправку fetch запросов прямо из браузера. Конечно для этого есть различные библиотеки, которые в том числе подделывают TLS отпечатки под реальные браузеры. Но зачем что-то подделывать когда можно отправить запрос прямо из настоящего браузера с его настоящим отпечатком и куками? Так что приступим.

Для примера возьмем тренировочную форму регистрации на https://lessons.zennolab.com/ru/advanced. Для начала нам нужно поймать запрос в траффике. Нажимаем F12, переходим на вкладку траффика, заполняем и отправляем форму. В итоге видим вот такой POST запрос
1788509128083.png


К запросу браузер прицепляет нужные куки, а так же отправляет payload вида application/x-www-form-urlencoded. Конечно можно пойти казалось бы можно заполнить все поля, решить капчу и кликнуть на кнопку. Но есть путь куда проще и быстрее - отправить fetch запрос, который мало того все нужные куки сам из браузера возьмет, так еще и отпечаток у него будет как у браузера.

Для этого нам понадобится небольшая подготовка и немного теории. Начнем с Runtime.evaluate - метода CDP который позволяет передать произвольный JavaScript код в движок браузера и выполнить его в контексте страницы/фрейма/воркера. Этот метод принимает несколько параметров, сегодня нам будут нужны:
  • expression - это сам JavaScript код для выполнения
  • awaitPromise - дожидаться результата выполнения скрипта или нет
  • returnByValue - вернуть результат или нет
Сам метод будет выглядеть так:
C#:
Развернуть Свернуть Копировать
static async Task<string> RunJS(CDPClient cdpClient, string expression, string sessionId, int timeoutMs = 10000)
{
    try
    {
        object evaluationParams = new { expression, awaitPromise = true, returnByValue = true };
        var response = await cdpClient.SendCommandAsync("Runtime.evaluate", evaluationParams, timeoutMs, sessionId: sessionId);
        var result = response.ToString();
        if (string.IsNullOrEmpty(result))
        {
            throw new Exception("RunJS ответ пустой");
        }
        return result;
    }
    catch (Exception ex)
    {
        throw new Exception($"RunJS Произошла ошибка: {ex.Message}");
    }
}

Теперь подготовим метод-обертку над RunJS, который будет принимать входные параметры запроса, подготавливать JS скрипт, отправлять его и возвращать объект CDPFetchResponse с результатами:
C#:
Развернуть Свернуть Копировать
static async Task<CDPFetchResponse> SendFetchRequestAsync(CDPClient cdpClient, string sessionId, string url, string method = "GET", Dictionary<string, string> customHeaders = null, string body = null, int timeoutMs = 10000, bool sendCredentials = true)
{
try
    {
// Формируем объект настроек для fetch
var fetchOptions = new JObject
                {
{ "method", method },
{ "credentials", sendCredentials ? "include" : "omit" } // Чтобы куки отправлялись даже на кросс-доменные запросы
                };

// Добавляем заголовки, если они переданы
if (customHeaders != null && customHeaders.Count > 0)
        {
var headersObj = new JObject();
foreach (var header in customHeaders)
            {
headersObj[header.Key] = header.Value;
            }
fetchOptions["headers"] = headersObj;
        }

// Добавляем тело, если это POST/PUT и тело не пустое
if (!string.IsNullOrEmpty(body) && (method.Equals("POST", StringComparison.OrdinalIgnoreCase) || method.Equals("PUT", StringComparison.OrdinalIgnoreCase)))
        {
fetchOptions["body"] = body;
        }

// Шаблон JS кода, который мы заставим браузер выполнить
// Обернем в самовызывающуюся async функцию и ВЕРНЕМ Promise
string jsCode = $@"
                    (async () => {{
                        try {{
                            let response = await fetch('{url}', {fetchOptions.ToString(Newtonsoft.Json.Formatting.None)});
                            let resBody = await response.text();
                            return JSON.stringify({{ status: response.status, body: resBody }});
                        }} catch (error) {{
                            return JSON.stringify({{ status: 0, error: error.toString() }});
                        }}
                    }})()
                ";

// Выполняем JS и ждем завершения Promise
string responseBody = await RunJS(cdpClient, jsCode, sessionId, timeoutMs);

// Очистка ответа (RunJS может вернуть экранированный JSON-результат из Runtime.evaluate)
try
        {
var parsedResponse = JObject.Parse(responseBody);
string actualValue = parsedResponse["result"]?["result"]?["value"]?.Value<string>();

if (actualValue != null)
            {
var innerData = JObject.Parse(actualValue);
return new CDPFetchResponse
                {
StatusCode = innerData["status"] != null ? innerData["status"].Value<int>() : 0,
Body = innerData["body"] != null ? innerData["body"].Value<string>() : "",
Error = innerData["error"] != null ? innerData["error"].Value<string>() : null
                };
            }
        }
catch
        {
 
        }

return new CDPFetchResponse { StatusCode = 0, Error = "Failed to parse Runtime.evaluate response: " + responseBody };
    }
catch (Exception ex)
    {
throw new Exception($"SendFetchRequestAsync Ошибка: {ex.Message}");
    }
}

public class CDPFetchResponse
{
    public int StatusCode { get; set; }
    public string Body { get; set; }
    public string Error { get; set; }
}

Помимо самого cdp-клиента, сессии и ссылки в метод передаются тип запроса, необязательные параметры с хедерами и телом запроса, таймаут и параметр credentials который отвечает за то прикреплять ли куки к запросу или нет.
Теперь повторим наш тренировочный POST запрос. Сначала заходим на сайт, а потом делаем запрос:
C#:
Развернуть Свернуть Копировать
    string body = $"fname=Ivan&lname=Ivanov&email=zenno%40afewg.ru&pass1=123123&pass2=123123&captcha=DC9N4W&submit=%D0%A1%D0%BE%D0%B7%D0%B4%D0%B0%D1%82%D1%8C+%D0%B0%D0%BA%D0%BA%D0%B0%D1%83%D0%BD%D1%82";
    var headers = new Dictionary<string, string>
    {
        { "Content-Type", "application/x-www-form-urlencoded" },
    };


    var fetch = await SendFetchRequestAsync(cdpClient, firstPageSessionId, "https://lessons.zennolab.com/ru/adv_reg", "POST", headers, body, 10000, true);

Как видно тут в заголовках мы указали Content-Type, указали тело и тип запроса - POST. Запускаем наш код, пока все грузится успеваем нажать F12 в ZennoBrowser и смотрим траффик:
1788509566896.png


В итоге у нас получился запрос практически 1-в-1, не хватает только некоторых заголовков типа upgrade-insecure-requests, но в случае необходимости их можно самостоятельно указать в словаре headers.

И вроде бы казалось все легко и просто. Но на сайтах иногда бывает так что находясь на основном домене site.com запрос к внутреннему апи уходит либо на субдомен либо на другой домен. И тут то перед нами встает коварный CORS.
Чтобы воспроизвести подобную ситуацию мы зайдем не на https://lessons.zennolab.com/ru/advanced а просто на https://zennolab.com/ru/ и повторим наш запрос. в итоге получим:

1788581559278.png


Как работает этот злосчастный CORS в браузере? Сначала браузер отправляет серверу Preflight-запрос (OPTIONS), если в ответ сервер присылает заголовки Access-Control-Allow-Origin, Access-Control-Allow-Credentials с нужным содержимым (например Access-Control-Allow-Origin: * и отсутствующий Access-Control-Allow-Credentials) то основной запрос проходит, если же содержимое не то или их вовсе нет то основной запрос не проходит и мы видим CORS error как на скрине выше.
Что же делать в такой ситуации? На помощь нам приходит вся мощь CDP. Мы можем просто перехватить OPTIONS запрос, не отправлять его на сервер а вернуть браузеру 204 со всеми разрешающими CORS-заголовками. Тогда браузер ничего не заподозрит и отправит основной запрос. Для этого будем использовать CDP-домен fetch (не путать с самим запросом fetch из javascript). Включаем fetch домен и в настройках перехват и запросов к серверу и ответов от сервера. Запрос перехватываем для того чтобы перехватить OPTIONS о котором шла речь выше и вернуть браузеру нужный ответ. А перехват ответа нужен для того чтобы в ответ сервера прикрепить CORS-заголовки, чтобы браузер не ругался.
C#:
Развернуть Свернуть Копировать
await cdpClient.SendCommandAsync("Fetch.enable", new
                {
                    patterns = new[]
                    {
                        new { requestStage = "Request" },
                        new { requestStage = "Response" }
                    },
                    handleAuthRequests = false
                }, sessionId: sessionId);

После этого когда буду идти запросы и приходить на них ответы будет случаться событие Fetch.requestPaused. Поэтому подписываемся на него
C#:
Развернуть Свернуть Копировать
cdpClient.SubscribeEvent("Fetch.requestPaused", fetchRequestPausedHandler);

Тут в дело вступает обработчик fetchRequestPausedHandler, который как видно из названия будет обрабатывать все события Fetch.requestPaused. Чтобы не растягивать пост приводить его и сам метод для отправки запросов с обходом CORS целиком не буду (их можно будет увидеть в прикрепленном проекте), только обозначу некоторые моменты:

У каждого события есть свой requestId, зная его мы можем выполнять следующие команды (это не полный список):
1) Fetch.continueResponse/Fetch.continueRequest - продолжить запрос, при этом можно оставить все как есть а можно внести изменения в запрос (например поменять тело запроса или хедеры).
2) Fetch.fulfillRequest - вернуть ответ браузеру. На стадии запроса с помощью этой команды можно вообще исключить обращение к серверу, а сразу вернуть готовый ответ браузеру (как раз для нашего OPTIONS запроса), на стадии ответа можно перехватить ответ сервера, модифицировать его и вернуть браузеру.
3) Fetch.getResponseBody - чтобы перехватить тело ответа сервера, например перехватываем тело, в нем json, меняем в нем нужное значение и передаем браузеру через fullfillRequest

Так же помимо requestId у события есть responseStatusCode (если этот параметр не null значит мы перехватили ответ браузеру, в противном случае запрос) и requestUrl (тут думаю из названия все понятно).

Логика перехватчика примерно такая:
Перехватываем запрос/ответ ->
Парсим responseStatusCode, requestUrl и метод ->
Если requestUrl не содержит заданный нами урл, то значит просто пропускаем такие запросы/ответы (через continueResponse/continueRequest) ->
Если метод OPTIONS то значит мы поймали тот самый preflight запрос. Возвращаем его браузеру через fulfullRequest с responseCode 204:
C#:
Развернуть Свернуть Копировать
 if (reqMethod == "OPTIONS" && responseStatusCode == null)
 {
     await cdpClient.SendCommandAsync("Fetch.fulfillRequest", new
     {
         requestId = fetchId,
         responseCode = 204, // No Content
         responseHeaders = corsHeaders
     }, sessionId: sessionId);
     return;
 }
corsHeaders здесь это правильный набор cors хедеров для браузера, чтобы браузер не ругался ->
Если поймали запрос или ответ то так же модифицируем заголовки для CORS и передаем ответ в браузер через fulfullRequest.

В итоге находясь на zennolab.com мы можем успешно выполнить запрос к lessons.zennolab.com обойдя CORS политики
1789830562293.png



Нюанс:
К сожалению CDP не позволяет переназначать некоторые Sec-Fetch-* заголовки которые регулируются на уровне сетевого стека браузера на С++. Например заголовок Sec-Fetch-User, когда переход произошел из-за клика юзера имеет значение ?1. Но если мы сделаем отправку так как описано выше и даже подставим/подменим этот заголовок, то по факту все равно ?1 там не будет. Но это все равно не мешает пользоваться fetch запросами, такие проверки наверное могут быть у каких-нибудь злых защит, а у более простых сайтов fetch'и чтобы дергать внутренние апишки залетают на ура. Чтобы обойти это нужно патчить исходники, я к сожалению пока еще не дорос до такого уровня (может быть еще через несколько конкурсов, кто знает), поэтому пока просьба к уважаемым разработчикам ZennoBrowser по возможности пропатчить этот момент в исходниках.

На этом все, всем спасибо за внимание!
 

Вложения

Последнее редактирование:
  • Оценить
Реакции: Bas и deskuznetsov

Кто просматривает тему: (Всего: 12, Пользователи: 7, Гости: 5)