Как решить AWS WAF CAPTCHA на C# с HttpClient

aws waf captcha in c# - How to Pass an AWS WAF CAPTCHA in C# With HttpClient

Чтобы решить AWS WAF CAPTCHA на C#, вы загружаете защищённую страницу через HttpClient, достаёте пять значений из её ответа HTTP 405, передаёте их через AmazonWafAsync в решатель CapSkip для AWS WAF, и меняете возвращённую им пару из ваучера и токена на cookie aws-waf-token, которое ваш CookieContainer дальше отправляет сам. CapSkip решает на вашем собственном ПК с Windows, поэтому платы за каждое решение нет, а неудачная попытка не стоит ничего лишнего. Код состоит из пяти коротких блоков и занимает около 130 строк.

Что понадобится

  • CapSkip 3.0.0 или новее, установленный на Windows. Поддержка AWS WAF начинается с этого выпуска.
  • Платформа .NET 8 SDK или новее и пакет CapSkip из NuGet версии 1.4.0 или новее. В версии 1.3.0 нет AmazonWafAsync, что я проверил в пакете. Всё ниже запускалось на .NET SDK 10.0.100, на net10.0 и net8.0, с CapSkip 1.5.0.
  • Страница, которой вы владеете или которую вам разрешено автоматизировать, защищённая правилом с действием AWS WAF CAPTCHA. Это руководство по AWS WAF CAPTCHA на C# разбирает страницу проверки, которая отвечает HTTP 405. Подробнее о ней рассказывает руководство по Python и ведёт к версии для виджета.
  • Адрес решателя. В режиме Local это 127.0.0.1 на том же ПК. Режим Server позволяет программе на другой машине вызывать CapSkip через API, как объясняет раздел ближе к концу. Переключаются они в разделе Настройки подключения.

Как это проверялось: на трёх имитациях серверов, которые я написал, с настоящим пакетом CapSkip 1.5.0. Первая применяет проверки из раздела AWS WAF на странице CapSkip Документация API, вторая изображает защищённый сайт, а третья изображает хост токенов. Небольшой обработчик, существующий только для тестов, перенаправлял хосты awswaf.com на этот хост токенов, и в коде ниже его нет. Приложение CapSkip работало на моём ПК, и я отправлял ему только недопустимые запросы, поэтому я не запускал ни одного задания и не видел настоящего решения. Настоящий сайт с AWS WAF не использовался. В этой статье нет времени решения, и я не могу обещать, что конкретный живой сайт примет cookie, которое даёт этот процесс.

Шаг 1: создаём проект и читаем страницу 405

Создайте консольный проект и добавьте пакет. Шаблон по умолчанию включает implicit usings и nullable-типы, и код опирается на то и другое.

dotnet new console -o aws-waf-demo
cd aws-waf-demo
dotnet add package CapSkip

Защищённая страница отвечает HTTP 405 и заголовком x-amzn-waf-action со значением captcha. Тело определяет window.gokuProps и загружает challenge.js и captcha.js с хоста awswaf.com. Из него коду нужны пять значений: gokuProps.key, iv и context, а также адреса обоих скриптов. И iv, и context новые в каждом ответе, поэтому никогда не используйте старую пару повторно.

Замените содержимое Program.cs блоками C# из шагов с 1 по 4, один за другим, начиная с этого. Короткий фрагмент в шаге 2 к ним не относится. Page.Read ищет совпадения двумя регулярными выражениями, потому что объект находится внутри тега script, а не в файле JSON. Он возвращает null, если ответ не является разбираемой страницей CAPTCHA. Это относится к обычному 200, к 405 с пустым телом и к странице 405, в которой нет одного из пяти значений.

using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using CapSkip;

record Challenge(string SiteKey, string Iv, string Context, string ChallengeScript, string CaptchaScript);

static class Page
{
    // Read the values from an AWS WAF CAPTCHA page (HTTP 405), or return null.
    public static Challenge? Read(HttpStatusCode status, string html)
    {
        if (status != HttpStatusCode.MethodNotAllowed || !html.Contains("gokuProps")) return null;

        var block = Regex.Match(html, @"gokuProps\s*=\s*\{(.*?)\}", RegexOptions.Singleline).Groups[1].Value;
        string Field(string name) =>
            Regex.Match(block, "\"" + name + "\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value;
        string Script(string name) =>
            Regex.Match(html, "<script[^>]+src=\"([^\"]+/" + name + "\\.js)\"").Groups[1].Value;

        var found = new Challenge(Field("key"), Field("iv"), Field("context"),
                                  Script("challenge"), Script("captcha"));
        var values = new[] { found.SiteKey, found.Iv, found.Context, found.ChallengeScript, found.CaptchaScript };
        return values.Any(string.IsNullOrEmpty) ? null : found;
    }
}

Второй блок относится к стороне HttpClient. AWS документирует, что страница с заданием включается, когда запрос содержит заголовок Accept со значением text/html (подробности в документации AWS WAF). HttpClient не отправляет заголовок Accept, пока вы его не добавите, поэтому Web.Get его задаёт. Без него моя имитация отправляет пустой 405. Что отправляет настоящий AWS, я не проверял.

static partial class Web
{
    const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
                             "(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";

    // The jar lives in the handler, so a cookie you add later goes out with every request to its host.
    public static HttpClient Client(CookieContainer jar) =>
        new(new HttpClientHandler { CookieContainer = jar }) { Timeout = TimeSpan.FromSeconds(30) };

    public static async Task<(HttpStatusCode Status, string Body)> Get(HttpClient http, string url)
    {
        using var request = new HttpRequestMessage(HttpMethod.Get, url);
        request.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
        request.Headers.TryAddWithoutValidation("Accept", "text/html");  // HttpClient sends none
        using var response = await http.SendAsync(request);
        return (response.StatusCode, await response.Content.ReadAsStringAsync());
    }
}

Шаг 2: решение через AmazonWafAsync

Сигнатура такая: AmazonWafAsync(sitekey, url, options, cancellationToken). Обязательны только sitekey и url, и вызов без options в запуске на имитации сработал. В документации API сказано, что любое пропущенное значение читается со страницы во время решения. Этот код всё равно отправляет все пять, потому что страницу он получил только что, а адрес challenge.js нужен шагу 3 в любом случае. Значения iv и context идут вместе. Отправка одного без другого вызывает ValidationException до любого запроса. То же происходит для адреса скрипта, который не https на хосте awswaf.com и не оканчивается на challenge.js, captcha.js или jsapi.js. Я пробовал другой хост, адрес http и неверное имя файла, и все три были отклонены.

Результатом служит SolveResult. CaptchaVoucher и ExistingToken содержат пару, Code содержит необработанный ответ в виде строки JSON, а CaptchaId содержит номер задачи. Вызов ждёт до 300 секунд, значения recaptchaTimeout по умолчанию, потому что CapSkip открывает настоящий браузер, чтобы получить токен, прежде чем ответит на задание. Время решения я не замерял.

static class Solve
{
    // Local mode. In Server mode, use your server's address instead.
    static readonly CapSkipClient Solver = new(host: "127.0.0.1", port: 8080);

    public static Task<SolveResult> Run(Challenge c, string pageUrl, CancellationToken ct) =>
        Solver.AmazonWafAsync(c.SiteKey, pageUrl, new Dictionary<string, object?>
        {
            ["iv"] = c.Iv,
            ["context"] = c.Context,
            ["challenge_script"] = c.ChallengeScript,
            ["captcha_script"] = c.CaptchaScript,
        }, ct);
}

Чтобы направить решение через прокси, добавьте запись proxy в словарь options. Ваши собственные запросы должны выходить с того же адреса, поэтому передайте вашему обработчику тот же прокси. WebProxy не читает строку user:pass@, поэтому логин передаётся в Credentials. Собственный код CapSkip извлекает его так же. Первая строка ниже дошла до in.php как proxy и proxytype в запуске на имитации. Вторая заменяет обработчик внутри Web.Client. Она скомпилировалась, но запрос через прокси я не отправлял, так что считайте её непроверенной.

// Snippet, not part of Program.cs. The first line is checked at in.php, the second is not tested.
["proxy"] = new Proxy("HTTP", "user:[email protected]:8080"),  // add to the options in Solve.Run
var handler = new HttpClientHandler { UseProxy = true, CookieContainer = jar, Proxy =
    new WebProxy("http://1.2.3.4:8080") { Credentials = new NetworkCredential("user", "pass") } };

Шаг 3: обмен ваучера через HttpClient

В этом процессе обмен ваучера выполняет ваш код, а не CapSkip, поэтому на этом участке AWS видит адрес вашего HttpClient. Эндпоинт voucher находится рядом с challenge.js: в той же папке, с voucher вместо challenge.js. Web.Exchange отправляет пару POST-запросом в JSON и читает поле token в ответе. Пример в документации API отправляет тип содержимого text/plain;charset=UTF-8 и заголовок Origin. Я прочитал собственный код обмена в приложении CapSkip, который также добавляет Referer и User-Agent браузера, получившего токен, и считает неудачным обменом ответ HTTP 400 при null или недопустимом existing_token. Web.Exchange отправляет все четыре заголовка. Ответ in.php не возвращает этот User-Agent, поэтому этот код отправляет обычный Chrome, и проверить, учитывает ли AWS эту разницу, я не смог. В документации SDK сказано обменять пару сразу после получения. Срок жизни там не указан, и я его не измерял.

С типом содержимого в C# нужна осторожность. StringContent с text/plain и Encoding.UTF8 записывает его как text/plain; charset=utf-8, с пробелом после точки с запятой и строчными utf-8, что я измерил. Поэтому метод строит ByteArrayContent и задаёт заголовок вручную. Имитация записала точное задокументированное написание.

static partial class Web
{
    // Trade the voucher pair for the aws-waf-token. Your code makes this call, not CapSkip.
    public static async Task<string> Exchange(HttpClient http, SolveResult result, Challenge c, string pageUrl)
    {
        // The voucher endpoint sits beside challenge.js, same folder.
        var voucherUrl = c.ChallengeScript[..c.ChallengeScript.LastIndexOf('/')] + "/voucher";
        var origin = new Uri(pageUrl).GetLeftPart(UriPartial.Authority);
        var pair = JsonSerializer.Serialize(new
        {
            captcha_voucher = result.CaptchaVoucher,
            existing_token = result.ExistingToken,
        });

        using var request = new HttpRequestMessage(HttpMethod.Post, voucherUrl)
        {
            Content = new ByteArrayContent(Encoding.UTF8.GetBytes(pair)),
        };
        // StringContent would rewrite this header, so write it exactly.
        request.Content.Headers.TryAddWithoutValidation("Content-Type", "text/plain;charset=UTF-8");
        request.Headers.TryAddWithoutValidation("Origin", origin);
        request.Headers.TryAddWithoutValidation("Referer", origin + "/");
        request.Headers.TryAddWithoutValidation("User-Agent", UserAgent);

        using var response = await http.SendAsync(request);
        response.EnsureSuccessStatusCode();
        using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
        return doc.RootElement.GetProperty("token").GetString()!;  // the aws-waf-token cookie value
    }
}

Шаг 4: полный рабочий пример

Сначала замените PageUrl на вашу защищённую страницу. В том виде, как вставлено, он запрашивает example.com, где нет правила CAPTCHA, поэтому программа не находит ничего, что нужно решать. Затем запустите её командой dotnet run. Каждая попытка заново запрашивает страницу, поэтому у каждой попытки свои iv и context. Сообщение ApiException содержит код ошибки, поэтому фильтр catch повторяет попытку только для ERROR_CAPTCHA_UNSOLVABLE. Любой другой код означает, что повтор завершился бы так же, поэтому ошибка доходит до вашего кода. Если вы изменили заменяющий текст ошибки в CapSkip, фильтр не совпадёт. Токен попадает в хранилище cookie для хоста PageUrl, и последний запрос снова читает страницу. Это весь цикл AWS WAF CAPTCHA на C#.

static class Program
{
    const string PageUrl = "https://www.example.com/";  // change this to your protected page

    static async Task<int> Main()
    {
        var jar = new CookieContainer();
        using var http = Web.Client(jar);
        using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));  // a budget for all solves

        for (var attempt = 1; attempt <= 3; attempt++)
        {
            var (status, html) = await Web.Get(http, PageUrl);  // fresh page, fresh iv and context
            var challenge = Page.Read(status, html);
            if (challenge is null)
            {
                Console.WriteLine($"{(int)status} {html[..Math.Min(60, html.Length)]}");
                return status == HttpStatusCode.OK ? 0 : 1;  // 200 means there was nothing to solve
            }

            SolveResult result;
            try { result = await Solve.Run(challenge, PageUrl, cts.Token); }
            catch (ApiException ex) when (ex.Message.Contains("ERROR_CAPTCHA_UNSOLVABLE"))
            {
                Console.WriteLine($"attempt {attempt}: {ex.Message}");  // the message holds the error code
                continue;  // a fresh page draws a fresh puzzle
            }
            catch (CapSkip.TimeoutException)
            {
                Console.WriteLine("CapSkip gave no answer in time");
                return 1;
            }

            var token = await Web.Exchange(http, result, challenge, PageUrl);  // never store the pair
            jar.Add(new Uri(PageUrl), new Cookie("aws-waf-token", token));

            var (code, page) = await Web.Get(http, PageUrl);
            Console.WriteLine($"{(int)code} {page[..Math.Min(60, page.Length)]}");
            return code == HttpStatusCode.OK ? 0 : 1;
        }

        Console.WriteLine("AWS WAF CAPTCHA not solved in 3 attempts");
        return 1;
    }
}

В запуске на имитациях программа вела себя так. Каждая строка выполнялась и на net10.0, и на net8.0 с одинаковым выводом.

Настройка имитацииЧто сделала программа
Имитация отвечает на каждое заданиеОдна задача и один обмен, затем 200 и настоящая страница
Имитация отклоняет первые два заданияДве строки попыток, затем 200 после третьей задачи
Имитация отклоняет первые три заданияТри строки попыток, сообщение о том, что CAPTCHA не решена, код выхода 1 и ни одного запроса voucher
Строка Accept убрана из Web.GetИмитация отправила пустой 405, Page.Read вернул null, и программа вывела 405 и завершилась с кодом 1
Имитация отвечает ERROR_BAD_PARAMETERSНеобработанное ApiException на первой попытке, без повтора
Пара voucher изменена перед обменомИмитация ответила 400, и программа завершилась необработанным HttpRequestException

Cookie действует в течение времени иммунитета CAPTCHA сайта, то есть периода, когда AWS WAF позволяет клиенту, уже решившему задание, не проходить его снова. Документация AWS WAF по времени иммунитета называет 300 секунд значением по умолчанию и позволяет владельцу сайта задать срок от 60 секунд до трёх суток. Используйте один HttpClient и его хранилище cookie на весь запуск и применяйте токен повторно, пока не вернётся 405.

Частые ошибки

Что вы видитеПричинаИсправить
CapSkip.ValidationExceptionИсключение выбрасывает SDK до любого запроса: iv без context, адрес скрипта вне https и awswaf.com, url не с http или https либо пустой sitekey. Сообщение называет виновникаИсправьте аргумент, который он называет
ApiException: ERROR_CAPTCHA_UNSOLVABLECapSkip не смог решить это задание. Документация API сообщает, что каждая новая попытка получает другое заданиеЦикл повторяет попытку с новой страницей
ApiException с другим кодомERROR_BAD_PARAMETERS, ERROR_PROXY_FORMAT, ERROR_TASK_NOT_SUPPORTED (в документации API сказано, что тип задачи не поддерживается или его настройка отключена в CapSkip) или ERROR_NO_SLOT_AVAILABLE (ключ превысил лимит частоты запросов, заданный в CapSkip, который действует только для ключей, у которых он есть)Ошибка доходит до вашего кода без повтора. Прочитайте сообщение. При лимите частоты подождите несколько секунд и запустите снова
CapSkip.TimeoutExceptionНет ответа за время ожидания. Исключение происходит от CapSkipError, а не от ApiException, поэтому фильтр повтора его не перехватываетПроверьте, что CapSkip запущен и решает, затем запустите скрипт снова
CapSkip.NetworkExceptionCapSkip не запущен, неверно указаны хост или порт либо сработал ваш токен отменыЗапустите CapSkip, проверьте настройки подключения и прочитайте сообщение
HttpRequestException из Web.ExchangeEnsureSuccessStatusCode получил неуспешный ответ. Несовпадающая или изменённая пара дала 400 на имитации. Воспроизвести настоящий случай я не смог, и пара, обмененная слишком поздно, может завершиться так жеОбменивайте сразу и отправляйте новую задачу
Ошибка компилятора CS0104Имя TimeoutException без уточнения неоднозначно между System и CapSkipНапишите CapSkip.TimeoutException
405 даже с cookieТокен истёк, cookie добавлено для другого хоста либо сайт читает токен из заголовкаРешите заново, затем сравните с тем, что отправляют собственные запросы страницы

Запуск CapSkip на сервере

CapSkipClient принимает хост и порт, а также apiKey, если вы включили API Key Validation, поэтому для переноса решателя достаточно изменить одну строку: передайте адрес другой машины вместо 127.0.0.1. Режим Server заставляет CapSkip слушать ваш сетевой адрес или публичный IP, и программа .NET на другом ПК, VPS или размещённой платформе может вызывать тот же API. Машина с CapSkip должна работать под Windows, это ПК или сервер с Windows, и она остаётся вашей и без счётчика. CapSkip рекомендует статический публичный IP, чтобы удалённый доступ оставался стабильным. Порт тогда принимает соединения с любого адреса, поэтому добавьте в Windows Firewall блокирующее правило для тех, кому входить не нужно. Решение тогда выполняется на этой машине, а обмен ваучера на шаге 3 выполняется там, где работает ваша программа. Учитывает ли AWS эту разницу, я не проверял. Если учитывает, передавайте один и тот же прокси на обоих участках.

FAQ

Почему catch (TimeoutException) не компилируется с пакетом CapSkip?

И System, и CapSkip определяют TimeoutException. Если включены неявные using и в начале файла стоит using CapSkip, короткое имя неоднозначно, и компилятор останавливается с ошибкой CS0104. Напишите CapSkip.TimeoutException или перехватывайте CapSkipError, базовый класс всего, что выбрасывает SDK.

Почему не использовать StringContent для POST с ваучером?

Он переписывает тип содержимого. StringContent с text/plain и Encoding.UTF8 отправляет text/plain; charset=utf-8, тогда как в документации API показано text/plain;charset=UTF-8. ByteArrayContent с заголовком, записанным вручную, отправляет задокументированное написание. Я не смог проверить, различает ли настоящий AWS эти два варианта, поэтому скопировал задокументированный.

Работает ли cookie на других хостах или поддоменах?

Не так, как его добавляет пример. Cookie, добавленное в CookieContainer для адреса PageUrl, привязано только к хосту: в пробе оно ушло на www.example.com, а не на api.example.com или example.com. Если ваш сайт обращается к другому хосту, добавьте cookie и для него. В документации API также сказано, что некоторые страницы передают токен в заголовке запроса x-aws-waf-token вместо cookie, поэтому проверьте, что отправляют собственные запросы страницы.

Как отменить решение, которое идёт слишком долго?

Передайте CancellationToken в AmazonWafAsync, как это делает пример с источником на десять минут. Уже отменённый токен останавливает вызов до отправки чего-либо, но проявляется как CapSkip.NetworkException с сообщением A task was canceled, а не как OperationCanceledException. Тот же тип сообщает, что CapSkip не запущен, поэтому читайте сообщение.

Повтор AWS WAF CAPTCHA на C# ничего не стоит, потому что решатель работает на вашем собственном ПК. Поэтому, когда сайт выдаёт новое задание при каждой попытке, подойдёт обход капчи на вашей машине, без платы за каждое решение. Тот же процесс на Python показывает руководство по Python, а об остальных возможностях SDK читайте на странице сервиса распознавания капчи для C#.