如何在 PHP 中用 cURL cookie 文件识别 AWS WAF 验证码

要在 PHP 中识别 AWS WAF 验证码,先用 cURL 获取页面,从 HTTP 405 响应中取出五个值,交给 CapSkip AWS WAF 识别工具里的 amazonWaf 处理,然后用拿回的 voucher 组合换取一个 aws-waf-token,并把这个 token 写入 cookie 文件。PHP 进程退出时会忘掉一切,所以 token 就存放在这个文件里。CapSkip 运行在你自己的 Windows PC 上,没有按次识别的费用。
你需要什么
- 装有 CapSkip 3.0.0 或更高版本的 Windows PC,该版本引入了对 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(Windows 版 WampServer 构建)上运行过,搭配 capskip 1.6.0,另外在 8.0.30 和 8.5.0 上也搭配 1.5.0 运行过。1.6.0 版新增了 hcaptcha,而 amazonWaf 和 HTTP 层保持原样。
- 一个 CA 证书包。在全部六个构建上,只要没有设置 curl.cainfo,对公共站点的 https 请求就会失败,报 error 60,SSL certificate problem: unable to get local issuer certificate。开始之前,请先在 php.ini 的 curl.cainfo 里指定一个 CA 文件。
- 一个 PowerShell 提示符,以及一个你拥有或被允许自动化操作的页面,它位于带有 CAPTCHA 动作的 AWS WAF 规则之后。
- CapSkip 的地址。Local 模式在运行它的那台 PC 上服务 127.0.0.1。Server 模式向你的网络或公网 IP 开放 API,这样另一台机器上的 PHP 就能调用它。二选一的位置是 连接设置.
测试方式:我自己写的一个模拟服务器,没有真实的 AWS WAF 站点。它同时扮演 CapSkip 和受保护的页面,并应用 CapSkip API 文档中 AWS WAF 部分列出的检查。第二个监听器通过 TLS 扮演 AWS 的 token 主机,经由 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 会发送 cookie jar 里保存的任何 token,并且特意不设置 CURLOPT_COOKIEJAR(见常见问题)。AWS 的 验证码操作文档 说,当请求带有值为 text/html 的 Accept 头时,响应会包含谜题页面,所以这个辅助函数恰好发送这个值。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,并带有值为 captcha 的 x-amzn-waf-action 头。响应体把 window.gokuProps 设为一个对象,包含 key、iv 和 context 三项,并从一个 awswaf.com 主机加载 challenge.js 和 captcha.js。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 项下。它还不是 token:必须先交换这个 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 换取 token 并保存
取 challenge.js 被加载的那个文件夹,再加上 voucher,这就是端点;这一段是你自己的请求,所以 AWS 在这里看到的是你的 PHP 所在的 IP 地址。把这个 voucher 组合作为 JSON 请求体发送,并读取响应里的 token 字段。CapSkip 文档使用 text/plain 请求体加一个 Origin 头,而 CapSkip 应用还会加上 Referer,所以函数把这三样都发送。对于字符串请求体,除非另行声明,cURL 会声明为表单提交,我删掉那一行请求头时,模拟服务器证实了这一点。SDK 文档称这个组合是短命的,所以要立即交换。
这个调用还会跟随重定向,因为 CapSkip 应用的源码指出,形如 id.region.sdk.awswaf.com 的区域 token 主机会重定向到带哈希的主机。我的模拟服务器对这样的主机发送了 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 jar 是一个 Netscape 格式的文本文件:每行一个 cookie,字段以制表符分隔。保存函数是唯一写入它的代码。它保留其他所有行,替换旧 token,并在独占锁下写入。这一行是仅限主机的,所以 cURL 只把 cookie 发给 PAGE_URL 里的主机,不发给其他主机。在一次探测中,www 和 api 两个名字指向同一台机器,仅限主机的行只送到了 www,没有送到 api,而以点号开头、子域名字段设为 TRUE 的行两者都送到了。过期时间为 0,因为我不知道这个站点的免疫时间。
读取方可能在文件写到一半时读到它。一个进程保存 400 次,另一个进程发送 400 个请求,结果 8.4.15 上有 2 个请求、8.0.30 上有 1 个请求没带 cookie 就发了出去。先写临时文件再重命名,在 Windows 上也没有用:仍有 5 个和 4 个请求缺少 cookie,而且重命名本身在 400 次中分别失败了 1 次和 7 次。把单独出现的一个 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。它的第一次获取会发送 cookie jar 中的 token,所以只要 token 有效,就根本不会联系 CapSkip。只有 405 才会进入识别路径,而这条路径以一把锁开头,这是只有在 PHP 里才说得通的部分。由调度程序启动的两个脚本共用 cookie jar,却不共用内存,所以两者都会看到 405,也都会去识别。有了 flock,第二个会等待,再次获取页面,发现第一个已经存好的 token 就返回。函数结束、句柄被释放时,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 版本上都相同:
| 模拟设置 | 脚本的表现 |
|---|---|
| 空 cookie jar,每个谜题都得到了答案 | 恰好一个任务和一次交换,最后返回 200 |
| 第二次运行,同一个 cookie jar | 没有任务就返回 200,因为第一个请求已经带着 cookie |
| 前两个拼图被拒绝 | 两条重试消息,第三个任务成功 |
| 前三个拼图被拒绝 | 三条重试消息,一个未捕获的 RuntimeException,退出码 255,没有 voucher 请求 |
| 两个脚本同时启动,谜题被保持 2 秒 | 有锁时,只有一个任务,两个脚本都以 200 结束。没有 flock 那一行时,有两个或更多任务 |
在 8.0.30 和 8.4.15 上,每种变体各重复 10 次,有锁时全部 20 次运行都只产生一个任务。没有锁时,20 次运行里有 19 次产生两个任务,8.4.15 上有一次产生三个。token 会一直有效,直到站点的免疫时间耗尽,CapSkip API 文档 将其默认值定为五分钟。在这个时间窗口内运行时,脚本会发送 cookie 并跳过 CapSkip,所以计划任务可以每隔几分钟调用一次这个脚本。
不要在 Web 请求里识别
一次识别的耗时可能超过一个 PHP Web 请求能存活的时间。API 文档说,大多数任务在 15 秒内就绪,代理慢的话还会更久,而 PHP 手册 给 max_execution_time 的默认值是 Web 场景下 30 秒、命令行下不限制。我在 php-cgi.exe(一种 Web SAPI)下,于 8.0.30 和 8.4.15 上运行了整个脚本,对接的模拟服务器把每个谜题保持 40 个模拟秒。它在大约 33 秒后以 Fatal error 终止,信息为 Maximum execution time of 30 seconds exceeded,因为 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 秒后就绪的谜题被错过了:调用在大约 23 秒时以 TimeoutException 结束,始终没有读取答案。在默认的 5 秒轮询上限下,超时设为 20 只能取到大约 17.75 秒之前就绪的答案。
让 Web 一侧保持只读,把识别交给 php waf.php ,并让它按计划运行。这个代码块替换 Web 副本脚本的最后两行。在 8.4.15 的 php-cgi 测试中,用一个 40 秒的模拟谜题,任务正在识别期间发出的两个 Web 请求,在 0.25 秒内就得到了带 Retry-After 头的 503。该任务大约 43 秒后完成,只产生一个任务和一次交换,下一个 Web 请求拿到了真实页面。
// 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、不是 awswaf.com 主机上的 https 的脚本地址、文件名错误的脚本文件、相对的页面 URL,或未知的选项 | 改正消息里指出的内容 |
| 消息里带有 ERROR_CAPTCHA_UNSOLVABLE 的 ApiException | 那个谜题无法作答。每个新任务拿到的谜题都不同,详见 API 文档 | 无需处理:循环会获取新页面并重新提交 |
| NetworkException | 该主机和端口上没有任何程序在监听。一个已关闭的本地端口大约 2 秒后失败 | 启动 CapSkip,并重新检查主机和端口 |
| TimeoutException | 等待结束时仍没有答案。它继承的是 CapSkipError 而不是 ApiException,所以重试循环会让它直接通过 | 确认 CapSkip 正在识别,或者在你想放弃的地方捕获它 |
| 明明捕获了 ApiException,它却仍未被捕获 | 在 CapSkip\CapSkip 的 use 行之后,写成 CapSkip\Exceptions\ApiException 的 catch 指向的是一个不存在的类 | 导入这个异常,或者让名称以反斜杠开头 |
| RuntimeException: SSL certificate problem: unable to get local issuer certificate | curl.cainfo 没有设置,所以 PHP 无法在第一个 https 请求上验证证书,无论是获取页面还是 voucher 调用 | 让 php.ini 里的 curl.cainfo 指向一个 CA 文件 |
| voucher exchange failed: HTTP 400 | CapSkip 应用的源码把 voucher 端点返回的 400 视为 existing_token 为空或无效,这很可能是真正的原因。我的模拟服务器对一组它从未签发过的值返回 400,而我无法复现真实的拒绝 | 用一个新任务重做整个流程,并立即交换 |
| PHP 8.5 上点名 curl_close 的 Deprecated 行 | SDK 里的 Http.php 文件仍然调用 curl_close,而 PHP 8.5.0 已将其弃用。每向 CapSkip 发送一个请求,它就打印一行,识别仍然可以正常进行 | 在 SDK 去掉这个调用之前,把 E_DEPRECATED 从 error_reporting 中排除 |
常见问题
为什么不在每次获取页面时都设置 CURLOPT_COOKIEJAR?
句柄在 PHP 释放它时会写入自己的 cookie jar,而一个在 token 保存之前就读取了文件的句柄,会把旧内容写回去。在一次探测中,这样的句柄一被释放,token 就从文件里消失了,六个版本全都如此。如果你的站点会设置你必须保留的 cookie,请给它们单独准备一个 cookie jar。
小组件页面与此有何不同?
小组件页面是一个普通表单,它会加载 jsapi.js 并自己绘制谜题。SDK 把小组件的 apiKey 当作 sitekey,把 jsapi.js 的地址当作 jsapiScript 选项,没有 iv 或 context。本文只测试了 405 页面,所以第 1 步里的读取函数要为小组件重写。
另一台机器上的 PHP 能在 Server 模式下使用我的 CapSkip 吗?
可以。在 Server 模式下,CapSkip 监听你的网络或公网 IP,所以第二台 PC、VPS 或托管平台上的 PHP 只要在构造函数里换一个主机和端口,就能调用同一套 API,其他都不用变。静态公网 IP 能让远程访问保持稳定。硬件仍然是你的,用量也不计量。Server 模式会向所有地址开放 CapSkip 的端口,所以要让它保持私有,办法是为你不预期的机器添加 Windows Firewall 阻止规则。voucher 交换在 PHP 运行的地方进行,而不是在 CapSkip 所在的机器上,AWS 是否在意这一点,我没有测试。
在 PHP 中识别 AWS WAF 验证码,归结起来就是一个保存 token 的 cookie 文件,加上一把让同一时间只有一个进程做识别的锁。CapSkip 是运行在你自己机器上的 验证码识别工具 ,所以遇到无法作答的谜题,代价只是重试一次,不花一分钱。这套流程的 Python 版本见 Python 指南,更完整的 SDK 内容请见 PHP 验证码识别页面.
