So lösen Sie ein AWS-WAF-Captcha in PHP mit einer cURL-Cookie-Datei

aws waf captcha in php - How to Solve AWS WAF CAPTCHA in PHP With a cURL Cookie File

Um ein AWS-WAF-Captcha in PHP zu lösen, rufen Sie die Seite mit cURL ab, entnehmen fünf Werte der HTTP-405-Antwort und übergeben sie an amazonWaf im CapSkip-Solver für AWS WAF, tauschen Sie das zurückerhaltene Voucher-Paar gegen einen aws-waf-token ein und schreiben Sie diesen Token in eine Cookie-Datei. Ein PHP-Prozess vergisst alles, wenn er endet, daher ist die Datei der Ort, an dem der Token lebt. CapSkip läuft auf Ihrem eigenen Windows-PC und verlangt keine Gebühr pro Lösung.

Was Sie brauchen

  • Ein Windows-PC mit CapSkip 3.0.0 oder neuer, der Version, die die Unterstützung für AWS WAF eingeführt hat.
  • PHP 8.0 oder neuer mit der Erweiterung curl, Composer und capskip/capskip 1.4.0 oder neuer, in dem amazonWaf zuerst auftaucht. Alles unten lief auf PHP 8.0.30, 8.1.33, 8.2.29, 8.3.28, 8.4.15 und 8.5.0 (WampServer-Builds für Windows) mit capskip 1.6.0 sowie auf 8.0.30 und 8.5.0 auch mit 1.5.0. Version 1.6.0 fügte hcaptcha hinzu und ließ amazonWaf und die HTTP-Schicht unverändert.
  • Ein CA-Bundle. Auf allen sechs Builds scheiterte eine https-Anfrage an eine öffentliche Website bei nicht gesetztem curl.cainfo mit Fehler 60, SSL certificate problem: unable to get local issuer certificate. Geben Sie in der php.ini unter curl.cainfo eine CA-Datei an, bevor Sie beginnen.
  • Eine PowerShell-Eingabeaufforderung und eine Seite, die Ihnen gehört oder die Sie automatisieren dürfen, hinter einer AWS-WAF-Regel mit der Aktion CAPTCHA.
  • Eine Adresse für CapSkip. Der Local-Modus bedient 127.0.0.1 auf dem PC, auf dem CapSkip läuft. Der Server-Modus öffnet die API für Ihr Netzwerk oder Ihre öffentliche IP, sodass PHP auf einem anderen Rechner sie aufrufen kann. Den Modus wählen Sie unter Verbindungseinstellungen.

So wurde getestet: mit einem Mock, den ich geschrieben habe, und ohne echte AWS-WAF-Website. Er antwortet als CapSkip und als geschützte Seite und prüft nach den Regeln aus dem AWS-WAF-Abschnitt der API-Dokumentation. Ein zweiter Listener antwortet über TLS als Token-Host von AWS, erreicht über die Variable https_proxy, mit einer Wegwerf-Zertifizierungsstelle in curl.cainfo, damit die Zertifikatsprüfung eingeschaltet blieb. Nichts davon ist eine Lösezeit, und jede Regel, die der Mock durchsetzt, stammt von mir und ist kein dokumentiertes Verhalten von AWS.

Schritt 1: das SDK installieren und die 405-Seite lesen

Legen Sie einen Ordner an und installieren Sie das SDK:

mkdir waf-demo
cd waf-demo
composer require capskip/capskip

Erstellen Sie als Nächstes waf.php. Die Schritte 1 bis 4 landen alle in dieser einen Datei, der Reihe nach. Setzen Sie zuerst PAGE_URL auf Ihre eigene Seite, denn example.com hat keine CAPTCHA-Regel.

Jede Anfrage bekommt ein frisches cURL-Handle, das freigegeben wird, wenn die Funktion zurückkehrt. Seitenabrufe setzen CURLOPT_COOKIEFILE, sodass cURL den Token sendet, den das Cookie-Jar enthält, und sie setzen absichtlich kein CURLOPT_COOKIEJAR (siehe FAQ). Laut der AWS CAPTCHA-Aktionsdokumentation wird die Rätselseite eingebunden, wenn die Anfrage einen Accept-Header mit dem Wert text/html trägt, daher sendet der Helfer genau diesen. cURL sendet standardmäßig */*, und ob AWS */* als text/html zählt, konnte ich nicht testen. Mein Mock sandte ein leeres 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
    ]);
}

Eine passende CAPTCHA-Regel lässt AWS WAF mit 405 antworten, wobei der Header x-amzn-waf-action auf captcha gesetzt ist. Der Body setzt window.gokuProps auf ein Objekt mit den Einträgen key, iv und context und lädt challenge.js und captcha.js von einem awswaf.com-Host. Das SDK-Beispiel sagt, gokuProps.iv und gokuProps.context veralten innerhalb von Minuten, daher ruft die Schleife die Seite vor jeder Lösung erneut ab. Die Funktion gibt für jede andere Antwort null zurück.

// 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;
}

Schritt 2: amazonWaf aufrufen

Die Signatur lautet amazonWaf(string $sitekey, string $url, array $options = []). Der sitekey ist gokuProps.key auf einer Challenge-Seite. Die Werte aus gokuProps.iv und gokuProps.context werden zusammen übergeben oder gar nicht, und fehlen beide, liest CapSkip sie beim Start der Lösung von der Seite. Das SDK prüft jedes Argument, bevor eine Anfrage PHP verlässt, daher scheitert ein ungültiger Wert sofort, und der Mock sieht ihn nie. Die Fehlertabelle listet die Fälle auf, die ich ausprobiert habe.

Ein erfolgreicher Aufruf gibt ein Array mit captchaId, captcha_voucher und existing_token zurück, dazu die rohe Antwort im Eintrag code des Ergebnisses. Das ist kein Token: Das Voucher-Paar muss erst eingetauscht werden. Die Standardwartezeit beträgt 300 Sekunden, da CapSkip in einem echten Browser löst. Alle SDK-Exceptions erweitern CapSkipError, und ApiException trägt den Fehlertext von CapSkip. Ein Proxy wird als Option proxy übergeben, ein Array mit type HTTP und uri user:[email protected]:8080, das beim Mock als proxy und als proxytype ankam. Leiten Sie Ihre eigenen Anfragen über denselben Proxy, was ich nicht testen konnte.

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'],
    ]);
}

Schritt 3: den Voucher gegen den Token tauschen und speichern

Nehmen Sie den Ordner, aus dem challenge.js geladen wurde, und fügen Sie voucher an. Das ist der Endpunkt, und dieser Abschnitt ist Ihre eigene Anfrage, sodass AWS dabei Ihre PHP-Adresse sieht. Senden Sie das Paar als JSON-Body und lesen Sie das Feld token der Antwort. Die CapSkip-Dokumentation verwendet einen text/plain-Body plus einen Origin-Header, und die CapSkip-App fügt einen Referer hinzu, daher sendet die Funktion alle drei. Bei einem String als Body erklärt cURL die Anfrage zu einem Formular-POST, wenn Sie nichts anderes angeben, was der Mock bestätigte, als ich die Header-Zeile löschte. Die SDK-Dokumentation nennt das Paar kurzlebig, tauschen Sie es also sofort ein.

Der Aufruf folgt außerdem Weiterleitungen, denn der Quelltext der CapSkip-App vermerkt, dass ein regionaler Token-Host der Form id.region.sdk.awswaf.com auf den Hash-Host weiterleitet. Mein Mock sandte für einen solchen Host ein 307, und der Aufruf folgte ihm. Ohne die Option scheiterte er mit HTTP 307. Andere Codes habe ich nicht ausprobiert.

// 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'];
}

Das Cookie-Jar ist eine Textdatei im Netscape-Format: ein Cookie pro Zeile, Felder durch Tabulatoren getrennt. Die Speicherfunktion ist der einzige Code, der sie schreibt. Sie behält jede andere Zeile, ersetzt den alten Token und schreibt unter einer exklusiven Sperre. Die Zeile gilt nur für den Host, daher sendet cURL das Cookie an den Host in PAGE_URL und an keinen anderen. In einem Test, bei dem die Namen www und api auf einen Rechner zeigten, erreichte eine Zeile nur für den Host www, aber nicht api, während eine Zeile mit führendem Punkt und dem Subdomain-Feld auf TRUE beide erreichte. Der Ablauf ist 0, weil ich die Immunitätszeit der Website nicht kenne.

Ein Leser kann die Datei mitten im Schreiben erwischen. Bei einem Prozess, der 400-mal speicherte, und einem anderen, der 400 Anfragen sendete, gingen 2 Anfragen unter 8.4.15 und 1 unter 8.0.30 ohne das Cookie hinaus. Eine temporäre Datei zu schreiben und umzubenennen half unter Windows nicht: Weiterhin fehlte bei 5 und 4 Anfragen das Cookie, und das Umbenennen selbst scheiterte 1- und 7-mal von 400. Behandeln Sie ein einzelnes 405 als Grund für einen erneuten Versuch.

// 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);
}

Schritt 4: alles zusammenfügen, hinter einer Sperre

Der letzte Block ist der ganze Ablauf. Führen Sie ihn aus mit php waf.php. Sein erster Abruf sendet den Token aus dem Cookie-Jar, ein gültiger Token bedeutet also, dass CapSkip nie kontaktiert wird. Nur ein 405 führt auf den Lösungspfad, und dieser Pfad beginnt mit einer Sperre, dem Teil, der nur in PHP Sinn ergibt. Zwei von einem Scheduler gestartete Skripte teilen sich das Cookie-Jar und keinen Speicher, daher sähen beide das 405 und beide würden lösen. Mit flock wartet das zweite, ruft erneut ab, findet den Token des ersten und kehrt zurück. PHP gibt die Sperre frei, wenn das Handle am Ende der Funktion freigegeben wird, und ein Lauf macht höchstens drei Versuche.

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";

Ergebnisse des Mock-Laufs, identisch auf allen sechs PHP-Versionen:

Mock-EinstellungWas das Skript tat
Leeres Cookie-Jar, jedes Rätsel beantwortetGenau eine Aufgabe und ein Tausch, am Ende ein 200
Zweiter Lauf, dasselbe Cookie-Jar200 ohne Aufgabe, weil schon die erste Anfrage das Cookie trug
Die ersten zwei Puzzles abgelehntZwei Wiederholungsmeldungen, Erfolg bei der dritten Aufgabe
Die ersten drei Puzzles abgelehntDrei Wiederholungsmeldungen, eine nicht abgefangene RuntimeException, Exit-Code 255, keine Voucher-Anfrage
Zwei gleichzeitig gestartete Skripte, Rätsel 2 Sekunden lang festgehaltenMit der Sperre eine Aufgabe, und beide endeten mit 200. Ohne die Zeile flock zwei Aufgaben oder mehr

Bei 10 Wiederholungen pro Variante unter 8.0.30 und 8.4.15 ergab die Sperre in allen 20 Läufen eine Aufgabe. Ohne sie ergaben 19 von 20 Läufen zwei Aufgaben, und ein Lauf unter 8.4.15 ergab drei. Der Token bleibt gültig, bis die Immunitätszeit der Website abgelaufen ist. Sie beträgt laut der CapSkip API-Dokumentation standardmäßig fünf Minuten. Ein Lauf innerhalb dieses Fensters sendet das Cookie und überspringt CapSkip, sodass ein geplanter Job dieses Skript alle paar Minuten aufrufen kann.

Lösen Sie nicht innerhalb einer Webanfrage

Eine Lösung kann länger dauern als eine PHP-Webanfrage. Die API-Dokumentation sagt, die meisten Aufgaben seien innerhalb von 15 Sekunden fertig und ein langsamer Proxy verlängere das. Laut dem PHP-Handbuch beträgt der Standardwert von max_execution_time für die Webnutzung 30 Sekunden, für die Kommandozeile gibt es keinen. Ich habe das ganze Skript unter php-cgi.exe, einer Web-SAPI, mit 8.0.30 und 8.4.15 ausgeführt, gegen einen Mock, der jedes Rätsel 40 simulierte Sekunden lang festhielt. Es starb nach etwa 33 Sekunden mit dem Fatal Error Maximum execution time of 30 seconds exceeded, weil PHP den Fehler erst auslöste, als ein Poll-Sleep im SDK endete. Unter php.exe brauchte dasselbe Skript etwa 43 Sekunden.

Ein kürzeres SDK-Timeout rettet das nicht. Mit einem Timeout von 20 pollt das SDK bei etwa 0,25, 0,75, 1,75, 3,75, 7,75, 12,75 und 17,75 Sekunden, schläft dann bis 22,75 und gibt auf. Unter 8.0.30 und 8.4.15 wurde ein Rätsel, das nach 16 oder 17,5 Sekunden bereit war, bis etwa 18 Sekunden abgeholt, eines, das nach 19 Sekunden bereit war, wurde aber verpasst: Der Aufruf endete nach etwa 23 Sekunden in einer TimeoutException und las die Antwort nie. Bei der Standardobergrenze von 5 Sekunden für das Polling erfasst ein Timeout von 20 nur Antworten, die bis etwa 17,75 Sekunden bereit sind.

Halten Sie die Webseite schreibgeschützt und überlassen Sie das Lösen dem Befehl php waf.php in einem geplanten Task. Der Block ersetzt die letzten zwei Zeilen einer Webkopie des Skripts. In einem php-cgi-Test unter 8.4.15 mit einem Mock-Rätsel von 40 Sekunden bekamen zwei Webanfragen, die gesendet wurden, während der Job löste, innerhalb von 0,25 Sekunden ein 503 mit einem Header Retry-After. Der Job war nach etwa 43 Sekunden mit einer Aufgabe und einem Tausch fertig, und die nächste Webanfrage erhielt die echte Seite.

// 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);

Häufige Fehler

Was Sie sehenUrsacheBeheben
ValidationExceptionWird im SDK vor jeder Anfrage ausgelöst: gokuProps.iv oder gokuProps.context allein gesendet, eine Skriptadresse, die auf einem awswaf.com-Host nicht https ist, eine Skriptdatei mit falschem Namen, eine relative Seiten-URL oder eine unbekannte OptionKorrigieren Sie, was die Meldung nennt
ApiException mit ERROR_CAPTCHA_UNSOLVABLE in der MeldungDieses Rätsel war nicht lösbar. Jede neue Aufgabe bekommt ein anderes, laut der API-DokumentationNichts: Die Schleife ruft eine neue Seite ab und sendet erneut
NetworkExceptionAm Host und Port lauscht nichts. Ein geschlossener lokaler Port scheiterte nach etwa 2 SekundenStarten Sie CapSkip und prüfen Sie Host und Port erneut
TimeoutExceptionDas Warten endete ohne Antwort. Die Exception erweitert CapSkipError statt ApiException, daher lässt die Wiederholungsschleife sie durchPrüfen Sie, ob CapSkip löst, oder fangen Sie sie dort ab, wo Sie aufgeben wollen
ApiException nicht abgefangen, obwohl Sie sie abfangenNach einer use-Zeile für CapSkip\CapSkip nennt ein catch mit CapSkip\Exceptions\ApiException eine Klasse, die nicht existiertImportieren Sie die Exception, oder beginnen Sie den Namen mit einem Backslash
RuntimeException: SSL certificate problem: unable to get local issuer certificatecurl.cainfo ist nicht gesetzt, daher kann PHP das Zertifikat bei der ersten https-Anfrage nicht prüfen, ob beim Seitenabruf oder beim Voucher-AufrufLassen Sie curl.cainfo in der php.ini auf eine CA-Datei zeigen
voucher exchange failed: HTTP 400Der Quelltext der CapSkip-App behandelt ein 400 vom Voucher-Endpunkt als null oder ungültiges existing_token, die wahrscheinliche echte Ursache. Mein Mock gibt 400 für ein Paar zurück, das er nie ausgestellt hat, und eine echte Ablehnung konnte ich nicht reproduzierenWiederholen Sie die Runde mit einer neuen Aufgabe und tauschen Sie sofort ein
Deprecated-Zeilen mit curl_close unter PHP 8.5Die Datei Http.php im SDK ruft weiterhin curl_close auf, was PHP 8.5.0 als veraltet markiert. Sie gibt pro Anfrage an CapSkip eine Zeile aus, und die Lösung funktioniert trotzdemLassen Sie E_DEPRECATED aus error_reporting heraus, bis das SDK den Aufruf entfernt

FAQ

Warum nicht bei jedem Seitenabruf CURLOPT_COOKIEJAR setzen?

Ein Handle schreibt sein Cookie-Jar, wenn PHP es freigibt, und ein Handle, das die Datei vor dem Speichern des Tokens gelesen hat, schreibt seinen alten Inhalt zurück. In einem Test verschwand der Token aus der Datei in dem Moment, in dem ein solches Handle freigegeben wurde, bei allen sechs Versionen. Setzt Ihre Website Cookies, die Sie behalten müssen, geben Sie ihnen ein eigenes Cookie-Jar.

Wie unterscheidet sich eine Widget-Seite davon?

Eine Widget-Seite ist ein gewöhnliches Formular, das jsapi.js lädt und das Rätsel selbst zeichnet. Das SDK übernimmt den apiKey des Widgets als sitekey und die Adresse von jsapi.js als Option jsapiScript, ohne iv oder context. Dieser Beitrag testet nur die 405-Seite, daher müsste der Leser in Schritt 1 für eine Widget-Seite umgeschrieben werden.

Kann PHP auf einem anderen Rechner mein CapSkip im Server-Modus nutzen?

Ja. Im Server-Modus lauscht CapSkip auf Ihrer Netzwerkadresse oder öffentlichen IP, sodass PHP auf einem zweiten PC, einem VPS oder einer gehosteten Plattform dieselbe API mit einem anderen Host und Port im Konstruktor aufruft, und sonst ändert sich nichts. Eine statische öffentliche IP hält den Fernzugriff stabil. Die Hardware bleibt Ihre und ohne Abrechnung. Der Server-Modus öffnet den CapSkip-Port für jede Adresse, daher hält eine Blockierregel der Windows Firewall für die Rechner, die Sie nicht erwarten, ihn privat. Der Voucher-Tausch läuft dort, wo PHP läuft, nicht auf dem CapSkip-Rechner, und ich habe nicht getestet, ob AWS sich daran stört.

Ein AWS-WAF-Captcha in PHP läuft auf eine Cookie-Datei hinaus, die den Token enthält, und eine Dateisperre, die jeweils nur einem Prozess das Lösen erlaubt. Weil CapSkip ein Captcha-Löser ist, der auf Ihrem eigenen Rechner läuft, kostet ein unlösbares Rätsel eine Wiederholung und kein Geld. Die Python-Version dieses Ablaufs finden Sie im Python-Leitfaden, und mehr zum gesamten SDK finden Sie auf der PHP-Captcha-Solver-Seite.