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

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

要在 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_PARAMETERSApiException 在第一次尝试时就抛了出来,没有被处理,也没有重试
兑换之前改动了这一对 voucher模拟服务器返回 400,程序以一个未处理的 HttpRequestException 结束

这个 cookie 在站点的 CAPTCHA 免疫时间内有效,免疫时间是 AWS WAF 允许已完成验证码的客户端跳过拼图的时长。 AWS WAF 免疫时间文档 给出的默认值是 300 秒,站点所有者可以把它设置为 60 秒到三天。整个运行过程中请保留同一个 HttpClient 及其 cookie jar,并且在 405 再次出现之前,一直复用这个 token。

常见错误

你所看到的原因修复
CapSkip.ValidationExceptionSDK 在发出任何请求之前抛出:发送了 iv 却没有 context、脚本地址不是 https 或不在 awswaf.com 上、url 不是 http 或 https,或者 sitekey 为空。消息会指出是哪一项的问题修正消息里指出的那个参数
ApiException: ERROR_CAPTCHA_UNSOLVABLECapSkip 没能识别出那个拼图。 API 文档 说明每次新的尝试都会得到不同的拼图循环会用一个新页面重试
带有其他错误码的 ApiExceptionERROR_BAD_PARAMETERS、ERROR_PROXY_FORMAT、ERROR_TASK_NOT_SUPPORTED(API 文档说明,该任务类型不受支持,或者它的设置在 CapSkip 中被关闭了)或 ERROR_NO_SLOT_AVAILABLE(某个 API 密钥超过了 CapSkip 中设置的请求速率限制,该限制只适用于设置了它的密钥)它会直接抛出,不会重试。请阅读消息。遇到速率限制时,等几秒再运行
CapSkip.TimeoutException在等待时间内没有收到答案。它派生自 CapSkipError,而不是 ApiException,所以重试过滤器不会捕获它检查 CapSkip 是否在运行并在识别,然后再次运行
CapSkip.NetworkExceptionCapSkip 没有在运行、主机或端口不对,或者你的取消 token 已经触发启动 CapSkip,检查连接设置,并阅读消息
来自 Web.Exchange 的 HttpRequestExceptionEnsureSuccessStatusCode 遇到了一个表示失败的响应。在模拟中,不匹配或被改动过的一对值会返回 400。我无法复现真实的情况,而且兑换得太晚的一对值也可能以同样的方式失败立即兑换,并提交一个新任务
编译器错误 CS0104单独写的 TimeoutException 在 System 和 CapSkip 之间有歧义写成 CapSkip.TimeoutException
带着 cookie 仍然返回 405token 已过期、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# 验证码识别页面.