Как решить AWS WAF CAPTCHA на PHP с файлом cookie в cURL

Чтобы решить AWS WAF CAPTCHA на PHP, загрузите страницу через cURL, вытащите пять значений из ответа HTTP 405, передайте их в amazonWaf, за которым стоит решатель CapSkip для AWS WAF, обменяйте полученную пару voucher на aws-waf-token и запишите этот токен в файл cookie. Процесс PHP забывает всё при завершении, поэтому токен хранится в файле. CapSkip работает на вашем собственном ПК с Windows и не берёт плату за каждое решение.
Что понадобится
- ПК с Windows и CapSkip 3.0.0 или новее: в этой версии появилась поддержка AWS WAF.
- PHP 8.0 или новее с расширением curl, Composer и capskip/capskip 1.4.0 или новее, где впервые появился amazonWaf. Всё ниже запускалось на PHP 8.0.30, 8.1.33, 8.2.29, 8.3.28, 8.4.15 и 8.5.0 (сборки WampServer для Windows) с capskip 1.6.0, а также на 8.0.30 и 8.5.0 с 1.5.0. В версии 1.6.0 добавлен hcaptcha, а amazonWaf и слой HTTP остались прежними.
- Набор сертификатов CA. На всех шести сборках при незаданном curl.cainfo запрос по https к публичному сайту падал с ошибкой 60, SSL certificate problem: unable to get local issuer certificate. Перед началом укажите файл CA в curl.cainfo в php.ini.
- Окно PowerShell и страница, которой вы владеете или которую вам разрешено автоматизировать, за правилом AWS WAF с действием CAPTCHA.
- Адрес для CapSkip. Режим Local обслуживает 127.0.0.1 на ПК, где он запущен. Режим Server открывает API для вашей сети или публичного IP, чтобы PHP на другой машине мог его вызывать. Выберите режим в разделе Настройки подключения.
Как проверялось: на имитации, которую я написал, без настоящего сайта AWS WAF. Она отвечает и как CapSkip, и как защищённая страница, применяя проверки из раздела AWS WAF на странице CapSkip Документация API. Второй слушатель отвечает как хост токенов AWS по TLS, к нему идут через переменную https_proxy, а в curl.cainfo стоит временный удостоверяющий центр, чтобы проверка сертификатов оставалась включённой. Здесь нет ни одного времени решения, а каждое правило, которое применяет имитация, придумано мной и не является документированным поведением AWS.
Шаг 1: установка SDK и чтение страницы 405
Создайте папку и установите SDK:
mkdir waf-demo cd waf-demo composer require capskip/capskip
Затем создайте waf.php. Шаги с 1 по 4 попадают в этот один файл по порядку. Сначала задайте PAGE_URL на свою страницу, потому что у example.com нет правила CAPTCHA.
Каждый запрос получает новый дескриптор cURL, который освобождается при возврате из функции. Загрузки страницы задают CURLOPT_COOKIEFILE, поэтому cURL отправляет любой токен, который лежит в хранилище, и намеренно не задают CURLOPT_COOKIEJAR (см. FAQ). В документации AWS по действию CAPTCHA сказано, что страница с головоломкой включается в ответ, если у запроса есть заголовок Accept со значением text/html, поэтому вспомогательная функция отправляет именно его. По умолчанию cURL отправляет */*, и считает ли AWS */* за text/html, я проверить не смог. Моя имитация отправляла пустой 405.
<?php
// waf.php: Steps 1 to 4 go into this one file, in order.
require __DIR__ . '/vendor/autoload.php';
const PAGE_URL = 'https://www.example.com/'; // the page that answers HTTP 405
const JAR = __DIR__ . '/waf-cookies.txt'; // the token store, shared by every PHP process
// One cURL handle per request, freed when the function returns.
function request(string $url, array $options = []): array
{
$ch = curl_init($url);
curl_setopt_array($ch, $options + [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0 Safari/537.36',
]);
$body = curl_exec($ch);
if ($body === false) {
throw new RuntimeException(curl_error($ch));
}
return [curl_getinfo($ch, CURLINFO_RESPONSE_CODE), $body];
}
// A page fetch only reads the jar, so it sends whatever token is in the file.
function fetch(string $url): array
{
return request($url, [
CURLOPT_COOKIEFILE => JAR,
CURLOPT_HTTPHEADER => ['Accept: text/html'], // cURL sends */* unless told otherwise
]);
}Подходящее правило CAPTCHA заставляет AWS WAF отвечать 405 с заголовком x-amzn-waf-action, равным captcha. Тело задаёт window.gokuProps как объект с записями key, iv и context и загружает challenge.js и captcha.js с хоста awswaf.com. В примере SDK сказано, что gokuProps.iv и gokuProps.context устаревают в течение минут, поэтому цикл загружает страницу заново перед каждым решением. Для любого другого ответа функция возвращает null.
// The five values on an AWS WAF CAPTCHA page, or null for any other response.
function read_challenge(int $status, string $html): ?array
{
if ($status !== 405 || !str_contains($html, 'gokuProps')) {
return null;
}
preg_match('/gokuProps\s*=\s*\{(.*?)\}/s', $html, $block);
$field = fn (string $name): ?string =>
preg_match('/"' . $name . '"\s*:\s*"([^"]+)"/', $block[1] ?? '', $m) ? $m[1] : null;
$script = fn (string $name): ?string =>
preg_match('/<script[^>]+src="([^"]+\/' . $name . '\.js)"/', $html, $m) ? $m[1] : null;
$found = [
'sitekey' => $field('key'),
'iv' => $field('iv'),
'context' => $field('context'),
'challenge_script' => $script('challenge'),
'captcha_script' => $script('captcha'),
];
return in_array(null, $found, true) ? null : $found;
}Шаг 2: вызов amazonWaf
Сигнатура такая: amazonWaf(string $sitekey, string $url, array $options = []). В роли sitekey выступает gokuProps.key на странице проверки. Значения gokuProps.iv и gokuProps.context передаются вместе или не передаются вовсе, а когда пропущены оба, CapSkip читает их со страницы в начале решения. SDK проверяет каждый аргумент до того, как запрос покинет PHP, поэтому плохое значение падает сразу, а имитация его не видит. Таблица ошибок перечисляет случаи, которые я пробовал.
Успешный вызов возвращает массив с captchaId, captcha_voucher и existing_token, а также необработанный ответ в записи code результата. Это не токен: сначала пару voucher нужно обменять. Ожидание по умолчанию составляет 300 секунд, потому что CapSkip решает в настоящем браузере. Все исключения SDK наследуют CapSkipError, а ApiException несёт текст ошибки от CapSkip. Прокси передаётся опцией proxy, массивом с type HTTP и uri user:[email protected]:8080, и в имитацию он пришёл как proxy и proxytype. Свои запросы направляйте через тот же прокси; этого я проверить не смог.
use CapSkip\CapSkip;
// Local mode. In Server mode, use your server's address instead.
$solver = new CapSkip(['host' => '127.0.0.1', 'port' => 8080]);
function solve(CapSkip $solver, array $challenge): array
{
// The answer is a voucher pair, not a token.
return $solver->amazonWaf($challenge['sitekey'], PAGE_URL, [
'iv' => $challenge['iv'],
'context' => $challenge['context'],
'challenge_script' => $challenge['challenge_script'],
'captcha_script' => $challenge['captcha_script'],
]);
}Шаг 3: обмен voucher на токен и сохранение
Возьмите папку, из которой загружен challenge.js, и добавьте voucher. Это и есть адрес обмена, а этот этап вы выполняете своим запросом, поэтому AWS видит на нём адрес вашего PHP. Отправьте пару телом JSON и прочитайте поле token в ответе. В документации CapSkip используется тело text/plain плюс заголовок Origin, а приложение CapSkip добавляет Referer, поэтому функция отправляет все три. Для строкового тела cURL объявляет отправку формы, если не указать иное, и имитация это подтвердила, когда я удалил строку с заголовком. В документации SDK пара названа недолговечной, поэтому обменивайте её сразу.
Вызов также следует за перенаправлениями, потому что в исходном коде приложения CapSkip отмечено, что региональный хост токенов вида id.region.sdk.awswaf.com перенаправляет на хост с хешем. Моя имитация отправила 307 для такого хоста, и вызов проследовал за ним. Без этой опции он завершился с HTTP 307. Другие коды я не пробовал.
// Trade the voucher pair for the aws-waf-token value.
function exchange(array $result, array $challenge): string
{
// The voucher endpoint sits beside challenge.js, same folder.
$script = $challenge['challenge_script'];
$voucherUrl = substr($script, 0, strrpos($script, '/')) . '/voucher';
$site = parse_url(PAGE_URL);
$origin = $site['scheme'] . '://' . $site['host'] . (isset($site['port']) ? ':' . $site['port'] : '');
[$status, $body] = request($voucherUrl, [
CURLOPT_POST => true,
CURLOPT_FOLLOWLOCATION => true, // a regional host may redirect to the real one
CURLOPT_POSTFIELDS => json_encode([
'captcha_voucher' => $result['captcha_voucher'],
'existing_token' => $result['existing_token'],
]),
CURLOPT_HTTPHEADER => [
'Content-Type: text/plain;charset=UTF-8', // PHP would send a form type otherwise
'Origin: ' . $origin,
'Referer: ' . $origin . '/',
],
]);
if ($status !== 200) {
throw new RuntimeException("voucher exchange failed: HTTP $status");
}
return json_decode($body, true, 512, JSON_THROW_ON_ERROR)['token'];
}Хранилище cookie здесь представляет собой текстовый файл в формате Netscape: одна cookie на строку, поля разделены табуляциями. Только функция сохранения пишет в него. Она сохраняет все остальные строки, заменяет старый токен и пишет под исключающей блокировкой. Строка привязана только к хосту, поэтому cURL отправляет cookie на хост из PAGE_URL и больше никуда. В пробе с именами www и api, указывающими на одну машину, строка только для хоста дошла до www, но не до api, а строка с точкой в начале и полем субдомена TRUE дошла до обоих. Срок действия равен 0, потому что я не знаю времени иммунитета сайта.
Читатель может застать файл в момент записи. Когда один процесс сохранял 400 раз, а другой отправлял 400 запросов, 2 запроса на 8.4.15 и 1 на 8.0.30 ушли без cookie. Запись во временный файл с последующим переименованием в Windows не помогла: 5 и 4 запроса всё равно остались без cookie, а само переименование не удалось 1 и 7 раз из 400. Считайте одиночный 405 причиной для повтора.
// The only writer of the jar. It keeps every other line and replaces the old token.
function save_token(string $token): void
{
$lines = is_file(JAR) ? file(JAR, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) : [];
$lines = array_filter($lines, fn (string $line) => !str_contains($line, "\taws-waf-token\t"));
// Netscape format: host, subdomains, path, secure, expiry (0 = session), name, value
$lines[] = parse_url(PAGE_URL, PHP_URL_HOST) . "\tFALSE\t/\tFALSE\t0\taws-waf-token\t" . $token;
file_put_contents(JAR, implode("\n", $lines) . "\n", LOCK_EX);
}Шаг 4: собираем всё за блокировкой
Последний блок содержит весь процесс. Запустите его командой php waf.php. Первая загрузка отправляет токен из хранилища, поэтому действующий токен означает, что к CapSkip обращения не будет. Только ответ 405 входит в путь решения, и этот путь начинается с блокировки: эта часть имеет смысл только в PHP. Два скрипта, запущенные планировщиком, делят хранилище, но не делят память, поэтому оба увидят 405 и оба решат. С flock второй ждёт, загружает страницу снова, находит токен первого и возвращается. PHP снимает блокировку, когда дескриптор освобождается по окончании функции, а один запуск делает не более трёх попыток.
use CapSkip\Exceptions\ApiException;
function fetch_protected(CapSkip $solver): array
{
[$status, $html] = fetch(PAGE_URL); // sends the token in the jar, if there is one
if (read_challenge($status, $html) === null) {
return [$status, $html];
}
$lock = fopen(JAR . '.lock', 'c');
flock($lock, LOCK_EX); // one solve at a time across processes, released when $lock is freed
for ($attempt = 1; ; $attempt++) {
[$status, $html] = fetch(PAGE_URL); // the process we waited for may have saved a token
$challenge = read_challenge($status, $html);
if ($challenge === null) {
return [$status, $html];
}
if ($attempt > 3) {
throw new RuntimeException('AWS WAF CAPTCHA not solved in 3 attempts');
}
try {
save_token(exchange(solve($solver, $challenge), $challenge)); // exchange at once
} catch (ApiException $e) {
echo "attempt $attempt: {$e->getMessage()}\n"; // each retry draws a new puzzle
}
}
}
[$status, $body] = fetch_protected($solver);
echo $status, ' ', substr($body, 0, 60), "\n";Результаты прогона на имитации, одинаковые на всех шести версиях PHP:
| Настройка имитации | Что сделал скрипт |
|---|---|
| Пустое хранилище, на каждую головоломку получен ответ | Ровно одно задание и один обмен, итог 200 |
| Второй запуск, то же хранилище | 200 без задания, потому что первый запрос уже нёс cookie |
| Первые два пазла отклонены | Два сообщения о повторе, успех на третьем задании |
| Первые три пазла отклонены | Три сообщения о повторе, необработанное RuntimeException, код выхода 255, запроса voucher нет |
| Два скрипта, запущенные одновременно, головоломка удерживается 2 секунды | С блокировкой: одно задание, и оба завершились с 200. Без строки flock: два задания или больше |
При 10 повторениях на вариант на 8.0.30 и 8.4.15 блокировка дала одно задание во всех 20 запусках. Без неё 19 из 20 запусков дали два задания, а один запуск на 8.4.15 дал три. Токен остаётся годным, пока не истечёт время иммунитета сайта, которое на странице CapSkip Документация API указано как пять минут по умолчанию. Запуск внутри этого окна отправляет cookie и обходится без CapSkip, поэтому запланированное задание может вызывать этот скрипт каждые несколько минут.
Не решайте внутри веб-запроса
Решение может длиться дольше, чем веб-запрос PHP. В документации API сказано, что большинство заданий готовы в течение 15 секунд, а медленный прокси добавляет время, тогда как в руководстве по PHP для max_execution_time по умолчанию задано 30 секунд для веба и ничего для командной строки. Я запускал весь скрипт под php-cgi.exe, веб-SAPI, на 8.0.30 и 8.4.15 против имитации, которая удерживала каждую головоломку 40 условных секунд. Он умер с фатальной ошибкой Maximum execution time of 30 seconds exceeded примерно через 33 секунды, потому что PHP выбросил ошибку только тогда, когда закончился сон опроса внутри SDK. Под php.exe тот же скрипт работал около 43 секунд.
Более короткий таймаут SDK не спасает. При таймауте 20 SDK опрашивает примерно на 0.25, 0.75, 1.75, 3.75, 7.75, 12.75 и 17.75 секундах, затем спит до 22.75 и сдаётся. На 8.0.30 и 8.4.15 головоломка, готовая через 16 или 17.5 секунды, забиралась примерно к 18 секундам, но готовая через 19 секунд пропускалась: вызов завершался TimeoutException примерно через 23 секунды и так и не читал ответ. При потолке опроса в 5 секунд по умолчанию таймаут 20 ловит только ответы, готовые примерно к 17.75 секундам.
Оставьте веб-часть только для чтения, а решение поручите php waf.php по расписанию. Блок заменяет последние две строки веб-копии скрипта. В тесте php-cgi на 8.4.15 с головоломкой имитации в 40 секунд два веб-запроса, отправленные во время решения, получили 503 с заголовком Retry-After в течение 0.25 секунды. Скрипт завершился примерно через 43 секунды с одним заданием и одним обменом, и следующий веб-запрос получил настоящую страницу.
// Web copy of waf.php: replace its last two lines with this read-only check.
// Solving stays with "php waf.php", run from a scheduled task.
[$status, $html] = fetch(PAGE_URL);
if (read_challenge($status, $html) !== null) {
http_response_code(503);
header('Retry-After: 60');
exit('No valid token yet, try again soon.');
}
http_response_code($status);
echo substr($html, 0, 60);Частые ошибки
| Что вы видите | Причина | Исправить |
|---|---|---|
| ValidationException | Выбрасывается внутри SDK до любого запроса: gokuProps.iv или gokuProps.context отправлены по отдельности, адрес скрипта не https или не на хосте awswaf.com, файл скрипта с неверным именем, относительный URL страницы или неизвестная опция | Исправьте то, что названо в сообщении |
| ApiException с ERROR_CAPTCHA_UNSOLVABLE в сообщении | Эту головоломку нельзя было решить. Каждое новое задание получает другую, как сказано на странице Документация API | Ничего: цикл загружает новую страницу и отправляет снова |
| NetworkException | По указанным хосту и порту никто не слушает. Закрытый локальный порт дал сбой примерно через 2 секунды | Запустите CapSkip и перепроверьте хост и порт |
| TimeoutException | Ожидание закончилось без ответа. Исключение наследует CapSkipError, а не ApiException, поэтому цикл повтора его пропускает | Убедитесь, что CapSkip решает, или перехватите исключение там, где хотите сдаться |
| ApiException не перехвачено, хотя вы его перехватываете | После строки use для CapSkip\CapSkip блок catch, записанный как CapSkip\Exceptions\ApiException, называет класс, которого нет | Импортируйте исключение или начните имя с обратной косой черты |
| RuntimeException: SSL certificate problem: unable to get local issuer certificate | curl.cainfo не задан, поэтому PHP не может проверить сертификат при первом запросе по https, будь то загрузка страницы или вызов voucher | Укажите в curl.cainfo в php.ini файл CA |
| voucher exchange failed: HTTP 400 | Исходный код приложения CapSkip считает 400 от эндпоинта voucher признаком null или недействительного existing_token, это вероятная настоящая причина. Моя имитация возвращает 400 для пары, которую она не выдавала, а воспроизвести настоящий отказ я не смог | Повторите раунд с новым заданием и обменивайте сразу |
| Строки Deprecated с curl_close на PHP 8.5 | Файл Http.php внутри SDK по-прежнему вызывает curl_close, что PHP 8.5.0 объявляет устаревшим. Он печатает по строке на каждый запрос к CapSkip, а решение всё равно работает | Уберите E_DEPRECATED из error_reporting, пока SDK не откажется от этого вызова |
FAQ
Почему не задавать CURLOPT_COOKIEJAR при каждой загрузке страницы?
Дескриптор записывает своё хранилище, когда PHP его освобождает, а дескриптор, который прочитал файл до сохранения токена, записывает обратно его старое содержимое. В пробе токен пропадал из файла в тот момент, когда такой дескриптор освобождался, на всех шести версиях. Если сайт устанавливает cookie, которые нужно сохранить, дайте им отдельное хранилище.
Чем от этого отличается страница с виджетом?
Страница с виджетом представляет собой обычную форму, которая загружает jsapi.js и сама рисует головоломку. SDK принимает apiKey виджета как sitekey, а адрес jsapi.js как опцию jsapiScript, без iv и context. В этой статье проверяется только страница 405, поэтому читалку из Шага 1 для виджета пришлось бы переписать.
Может ли PHP на другой машине использовать мой CapSkip в режиме Server?
Да. В режиме Server CapSkip слушает вашу сеть или публичный IP, поэтому PHP на втором ПК, на VPS или на размещённой платформе вызывает тот же API с другими хостом и портом в конструкторе, и больше ничего не меняется. Статический публичный IP делает удалённый доступ стабильным. Железо остаётся вашим, и решения на нём не оплачиваются отдельно. Режим Server открывает порт CapSkip для всех адресов, поэтому приватным его сохраняет блокирующее правило Windows Firewall для машин, которых вы не ждёте. Обмен voucher выполняется там, где работает PHP, а не на машине с CapSkip, и важно ли это для AWS, я не проверял.
AWS WAF CAPTCHA на PHP сводится к файлу cookie с токеном и блокировке, которая позволяет решать только одному процессу за раз. Когда распознавание капчи работает на вашей собственной машине, неразрешимая головоломка стоит повтора, а не денег. Версию этого процесса для Python описывает руководство по Python, а остальные вызовы SDK описаны на странице сервиса распознавания капчи для PHP.
