如何用 C# 和 HttpClient 识别 AWS WAF 验证码

要在 C# 中识别 AWS WAF 验证码,你需要用 HttpClient 获取受保护的页面,从它的 HTTP 405 响应中取出五个值,并把它们交给 CapSkip AWS WAF 识别工具中的 AmazonWafAsync,再把它返回的一对 voucher 兑换成 aws-waf-token cookie,此后由你的 CookieContainer 负责发送。CapSkip 在你自己的 Windows PC 上识别,所以没有按次识别的费用,识别失败的一次尝试也不会多花一分钱。代码是五个简短的代码块,约 130 行。
你需要什么
- CapSkip 3.0.0 或更高版本,安装在 Windows 上。AWS WAF 支持从该版本开始。
- 需要 .NET 8 SDK 或更高版本,以及 1.4.0 或更高版本的 CapSkip NuGet 包。1.3.0 版没有 AmazonWafAsync,这一点我在包里核对过。下面的所有内容都在 .NET SDK 10.0.100 上运行过,目标框架为 net10.0 和 net8.0,使用 CapSkip 1.5.0。
- 一个你自己的页面,或者你被允许自动化操作的页面,并且它受一条采用 AWS WAF CAPTCHA 动作的规则保护。本指南用 C# 处理返回 HTTP 405 的 AWS WAF 验证码挑战页面。 Python 指南 对这个页面有更详细的说明,并链接到小组件版本。
- 识别工具的地址。Local 模式是同一台 PC 上的 127.0.0.1。Server 模式让另一台机器上的程序通过 API 调用 CapSkip,接近末尾的那一节会讲到。两种模式的切换位置在 连接设置.
测试方式:针对我自己写的三个模拟服务器运行,使用真实的 CapSkip 1.5.0 包。其中一个模拟服务器应用了 CapSkip API 文档中 AWS WAF 部分的检查,一个扮演受保护的站点,一个扮演 token 主机。一个仅用于测试的小处理程序把 awswaf.com 的各个主机重定向到了那个 token 主机,它不在下面的代码里。CapSkip 应用在我的 PC 上运行着,我只向它发送了无效请求,所以没有启动任何拼图,也没有看到真实的识别。没有涉及任何线上的 AWS WAF 站点。本文没有给出识别耗时,我也不能保证某个线上站点一定会接受这个流程产生的 cookie。
第 1 步:创建项目并读取 405 页面
创建一个控制台项目并添加该包。默认模板启用了隐式 using 和可空类型,代码依赖这两者。
dotnet new console -o aws-waf-demo cd aws-waf-demo dotnet add package CapSkip
受保护的页面会返回 HTTP 405,并带有值为 captcha 的 x-amzn-waf-action 请求头。页面正文定义了 window.gokuProps,并从一个 awswaf.com 主机加载 challenge.js 和 captcha.js。代码需要从中取出五个值:gokuProps.key、iv 和 context,外加这两个脚本的地址。iv 和 context 在每个响应中都是新的,所以不要复用旧的一对值。
把 Program.cs 的内容替换为第 1 到第 4 步的 C# 代码块,一个接一个,从这一个开始。第 2 步中的那段简短代码片段不在其中。Page.Read 用两个正则表达式来匹配,因为这个对象位于 script 标签里,而不是 JSON 文件里。当响应不是可解析的 CAPTCHA 页面时,它返回 null。这包括普通的 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 的文档说明,当请求带有值为 text/html 的 Accept 请求头时,响应才会包含拼图页面(参见 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 文档说,你省略的任何值都会在识别时从页面读取。这段代码仍然把五个值都发送出去,因为它刚刚获取过页面,而且无论如何第 3 步都需要 challenge.js 的地址。iv 和 context 这两个值要一起给出,只发送其中一个而不发送另一个,会在发出任何请求之前抛出 ValidationException。脚本地址如果不是 awswaf.com 主机上以 challenge.js、captcha.js 或 jsapi.js 结尾的 https 地址,也会出现同样的情况。我试过另一个主机、一个 http 地址和一个错误的文件名,三种情况都被拒绝了。
结果是一个 SolveResult。CaptchaVoucher 和 ExistingToken 保存那一对值,Code 以 JSON 字符串的形式保存原始答案,CaptchaId 保存任务编号。该调用最多等待默认的 300 秒 recaptchaTimeout,因为 CapSkip 会先打开一个真实的浏览器来生成 token,然后才回答拼图。我没有计时过一次识别。
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);
}要让识别经过代理,请在 options 字典中添加一个 proxy 条目。你自己的请求应该从同一个地址发出,所以请给你的 handler 设置同一个代理。WebProxy 不会读取 user:pass@ 这样的字符串,所以登录信息要放进 Credentials。CapSkip 自己的代码也是这样把它取出来的。下面的第一行在模拟运行中到达 in.php 时是一个 proxy 和一个 proxytype。第二行替换了 Web.Client 里的 handler。它能编译通过,但我没有通过代理发送过请求,所以请当作未经测试。
// 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 兑换 voucher
在这个流程中,做 voucher 兑换的是你的代码,而不是 CapSkip,所以在这一段,AWS 看到的是你的 HttpClient 所在的 IP 地址。voucher 端点位于 challenge.js 旁边:同一个文件夹,用 voucher 取代 challenge.js。Web.Exchange 以 JSON 形式 POST 这一对值,并读取响应中的 token 字段。API 文档的示例发送的是 text/plain;charset=UTF-8 内容类型和一个 Origin 请求头。我读了 CapSkip 应用自己的兑换代码,它还会添加 Referer 和生成该 token 的浏览器的 User-Agent,并且把 null 或无效的 existing_token 所对应的 HTTP 400 算作兑换失败。Web.Exchange 会发送这四个请求头。in.php 的答案不会返回那个 User-Agent,所以这段代码发送的是一个通用的 Chrome User-Agent,AWS 是否在意这个差别,我无法测试。SDK 文档说,这一对值一到手就要立刻兑换。它没有给出有效期,我也没有测量过。
内容类型在 C# 中需要小心处理。用 text/plain 和 Encoding.UTF8 创建的 StringContent,写出来的是 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 中修改了替换用的错误文本,这个过滤器就匹配不上。token 会放进 PageUrl 所在主机的 cookie jar,最后一个请求再次读取该页面。这就是完整的 C# 中 AWS WAF 验证码流程。
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 和真实页面 |
| 模拟服务器拒绝前两个拼图 | 两行 attempt 输出,然后第三个任务之后返回 200 |
| 模拟服务器拒绝前三个拼图 | 三行 attempt 输出、提示验证码未被识别的消息、退出码 1,并且没有 voucher 请求 |
| 从 Web.Get 中去掉 Accept 那一行 | 模拟服务器返回了一个空的 405,Page.Read 返回 null,程序打印出 405 并以 1 退出 |
| 模拟服务器返回 ERROR_BAD_PARAMETERS | ApiException 在第一次尝试时就抛了出来,没有被处理,也没有重试 |
| 兑换之前改动了这一对 voucher | 模拟服务器返回 400,程序以一个未处理的 HttpRequestException 结束 |
这个 cookie 在站点的 CAPTCHA 免疫时间内有效,免疫时间是 AWS WAF 允许已完成验证码的客户端跳过拼图的时长。 AWS WAF 免疫时间文档 给出的默认值是 300 秒,站点所有者可以把它设置为 60 秒到三天。整个运行过程中请保留同一个 HttpClient 及其 cookie jar,并且在 405 再次出现之前,一直复用这个 token。
常见错误
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| CapSkip.ValidationException | SDK 在发出任何请求之前抛出:发送了 iv 却没有 context、脚本地址不是 https 或不在 awswaf.com 上、url 不是 http 或 https,或者 sitekey 为空。消息会指出是哪一项的问题 | 修正消息里指出的那个参数 |
| ApiException: ERROR_CAPTCHA_UNSOLVABLE | CapSkip 没能识别出那个拼图。 API 文档 说明每次新的尝试都会得到不同的拼图 | 循环会用一个新页面重试 |
| 带有其他错误码的 ApiException | ERROR_BAD_PARAMETERS、ERROR_PROXY_FORMAT、ERROR_TASK_NOT_SUPPORTED(API 文档说明,该任务类型不受支持,或者它的设置在 CapSkip 中被关闭了)或 ERROR_NO_SLOT_AVAILABLE(某个 API 密钥超过了 CapSkip 中设置的请求速率限制,该限制只适用于设置了它的密钥) | 它会直接抛出,不会重试。请阅读消息。遇到速率限制时,等几秒再运行 |
| CapSkip.TimeoutException | 在等待时间内没有收到答案。它派生自 CapSkipError,而不是 ApiException,所以重试过滤器不会捕获它 | 检查 CapSkip 是否在运行并在识别,然后再次运行 |
| CapSkip.NetworkException | CapSkip 没有在运行、主机或端口不对,或者你的取消 token 已经触发 | 启动 CapSkip,检查连接设置,并阅读消息 |
| 来自 Web.Exchange 的 HttpRequestException | EnsureSuccessStatusCode 遇到了一个表示失败的响应。在模拟中,不匹配或被改动过的一对值会返回 400。我无法复现真实的情况,而且兑换得太晚的一对值也可能以同样的方式失败 | 立即兑换,并提交一个新任务 |
| 编译器错误 CS0104 | 单独写的 TimeoutException 在 System 和 CapSkip 之间有歧义 | 写成 CapSkip.TimeoutException |
| 带着 cookie 仍然返回 405 | token 已过期、cookie 是为另一个主机添加的,或者站点从请求头里读取 token | 重新识别,然后与页面自己的请求所发送的内容进行比较 |
在服务器上运行 CapSkip
CapSkipClient 接受一个主机和一个端口,如果你打开了 API Key Validation,还要加上 apiKey,所以更换识别工具的位置只需要改一行:传入另一台机器的地址,而不是 127.0.0.1。Server 模式会让 CapSkip 监听你的网络地址或公网 IP,这样另一台 PC、VPS 或托管平台上的 .NET 程序就能调用同样的 API。运行 CapSkip 的机器必须是 Windows,可以是一台 PC,也可以是 Windows 服务器,它仍然归你所有,也仍然不按次计费。CapSkip 建议使用静态公网 IP,以保持远程访问稳定。这个端口随后会接受来自任何地址的连接,所以请为不应进入的地址添加 Windows Firewall 阻止规则。此时识别在那台机器上运行,而第 3 步中的 voucher 兑换则在你的程序所在的地方运行。AWS 是否在意这个差别,我没有测试。如果在意,请把同一个代理同时传给这两段。
常见问题
为什么 catch (TimeoutException) 与 CapSkip 包一起就无法编译?
System 和 CapSkip 都定义了 TimeoutException。在启用了隐式 using、并且文件顶部有 using CapSkip 的情况下,这个短名称有歧义,编译器会以错误 CS0104 停止。请写成 CapSkip.TimeoutException,或者捕获 CapSkipError,它是 SDK 抛出的所有异常的基类。
为什么 voucher 的 POST 不用 StringContent?
它会改写内容类型。用 text/plain 和 Encoding.UTF8 创建的 StringContent 发送的是 text/plain; charset=utf-8,而 API 文档中写的是 text/plain;charset=UTF-8。手动写好请求头的 ByteArrayContent 发送的则是文档中的写法。我无法确认真实的 AWS 是否把这两种写法区别对待,所以我照抄了文档中的那一种。
这个 cookie 能在其他主机或子域名上使用吗?
按示例添加的方式,不行。为 PageUrl 的地址添加到 CookieContainer 的 cookie 是仅限该主机的:在一次探测中,它被发往 www.example.com,而没有发往 api.example.com 或 example.com。如果你的站点要调用另一个主机,也请为那个主机添加这个 cookie。API 文档还说,有些页面会在 x-aws-waf-token 请求头里而不是 cookie 里传递这个 token,所以请检查页面自己的请求发送了什么。
如何取消耗时过长的识别?
把一个 CancellationToken 传给 AmazonWafAsync,示例中就是这么做的,用了一个十分钟的 source。已经被取消的 token 会在提交任何东西之前就停止调用,但它表现为消息是 A task was canceled 的 CapSkip.NetworkException,而不是 OperationCanceledException。同一个类型也用来报告 CapSkip 没有在运行,所以请阅读消息。
在 C# 中识别 AWS WAF 验证码,重试不花一分钱,因为识别工具运行在你自己的 PC 上,这也是为什么本地 验证码绕过 适合那些每次尝试都给你一个新拼图的站点。Python 版的同一流程见 Python 指南,SDK 的其余部分则在 C# 验证码识别页面.
