导语:几乎每个做过Web开发的人都从某一端接触过这个问题。也许你曾试图从某个服务里多榨出一个免费试用期,也许你是服务的运营者,某天醒来发现同一分钟内注册了一万个账号。从外部看,这个系统像一堵墙:验证码、”检测到异常活动”的横幅、一个升起或落下的旗帜。实际上,它不是墙,也不是旗帜——它是一颗洋葱。
一、Bot与欺诈:两个不同的概念
在深入之前,必须先厘清两个常被混用的词:Bot 和 欺诈。
Bot是一种手段,是自动化、脚本、不是人手动操作鼠标。欺诈是一种意图,是滥用服务或功能来获取服务本不打算给予的利益。
四个会话,按驱动方式和用途分类:
| 自动化驱动 | 人工驱动 | |
|---|---|---|
| 正常 | Googlebot每六小时抓取同一产品页面 | 客户在手机上付款,一指一卡 |
| 滥用 | 一夜之间批量注册4212个账号来刷关注 | 一个人用五十张虚拟卡注册五十个账号来套取注册奖励 |
两个是Bot,两个是欺诈。它们不是完全重叠的两组。
Bot和欺诈并不总是同时出现。Googlebot是Bot,但不是欺诈,它是被欢迎的。一个真实的人坐在真实的笔记本前,手动开设五十个账号来套取注册奖励——这是欺诈,但没有哪怕一个Bot参与其中。大多数有趣的案例都落在中间地带,但将两个维度分开,是理解防御体系为何如此构建的关键。检测Bot是技术问题:这个东西是自动化的吗?检测欺诈是上下文问题:这个行为,这个身份,是滥用的吗?洋葱的不同层回答不同的问题,最强的系统是两者结合的。
二、欺诈的两种形态:手动与自动
手动欺诈
Web上的手动欺诈最常见是对某个服务或功能的滥用,以获取某种利益。表现为信用卡欺诈、优惠滥用(一个人创建多个账号以反复使用本仅限新用户的优惠,比如免费试用)、社交媒体刷票刷赞、垃圾信息等。
当目标是滥用某个优惠时(例如听音乐免广告),几乎不需要自动化。优惠通常持续一个月,所以整个”攻击”就是:注册账号、领取一个月优惠、三十天后再来。服务方可能会部署设备指纹和IP限流等对策,但两者都可以轻易绕过——指纹可以被能伪造指纹的反检测浏览器绕过,IP限制可以被住宅代理绕过。
实际上,在欺诈检测领域,尤其是在低容量欺诈场景中,必须走得远得多。欺诈检测不是一层,也不是一个方案解决所有问题。它是一颗洋葱,一层又一层不同的检查和算法,每个都针对不同的信号。
所以在我们的优惠滥用例子中,一旦指纹和IP限制被绕过,我们就进入下一层。检查注册所用的邮箱:是全新的吗?在哪些社交平台注册过?有没有任何历史?然后检查用来领取优惠的支付方式:是虚拟卡吗?是由Wise、Bunq或Revolut这类互联网银行发行的吗?每个问题都是一个新的数据点,它们共同构成了一个全新的、叠加在技术层之上的层。
注意发生了什么:我们不再看设备和会话,而是开始看身份。这是欺诈检测更”欺诈”的那一面——检查提供的真实身份信息,而不是提供信息的机器。设备层抓不到这个人,但身份层可能可以。
自动欺诈
自动欺诈最常用于大容量任务:批量点赞、爬虫、垃圾信息等。自动欺诈继承自手动欺诈,然后分支出去。如果有人想出售已领取优惠的现成账号,就必须把手动版本自动化,才能规模化运行。
对抗自动欺诈可以使用对抗手动欺诈的相同防御手段,但目标通常不同,所以往往没有相同的信息可以依赖。可能没有真实支付方式,也没有有历史的邮箱。在这种情况下,必须更依赖技术信号和跨多个会话的统计分析。
以批量注册账号来刷帖点赞或关注的大容量攻击为例。可以查看每个会话从账号创建到最终动作(点赞、关注)的精确行为模式。单独一个会话可能看起来完全合法。但把所有最近的会话汇总起来,基于行为而非设备构建指纹,模式就开始显现。驱动所有这些看似合法会话的独立脚本暴露了自己,因为它们的移动方式完全相同。一个会话是噪声。一万个移动方式完全相同的会话就是一个签名。
三、防御的目标:让攻击者入不敷出
现在我们知道了要对付的是什么,可以定义目标了。目标不是阻止和停止每一次欺诈尝试,从来都不是。
目标是让欺诈的成本高于欺诈本身的价值。换句话说,把攻击者的成本推高,直到他们的投资回报变成负数,整个事情对他们来说不再可行。
这个目标不是靠一个方案或一个聪明的向量就能达到的。它是通过层层叠加、层层叠加、再层层叠加来达到的,建造检测洋葱,直到穿过所有层的成本高于另一边的回报。
在最原始的系统中,这可能只是简单地在目标动作前加一个额外步骤:必需的短信确认、必需的邮件确认、最低充值。这些都会提高攻击者成本。但这个原始方案有两个问题:它是一层,所以可以被绕过(只是需要更多资源);更重要的是,它也在向每个合法用户收税。
每个检查都同时从两个预算中支出——攻击者付出的成本、诚实用户付出的成本:
| 检查手段 | 攻击者付出的 | 诚实用户付出的 |
|---|---|---|
| 邮件确认 | 他们已有的一个域名邮箱 | 离开标签页、找到邮件、回来 |
| 短信确认 | 每个周期购买一个新号码,批量采购 | 交出手机号,或者离开 |
| 固定难度的工作量证明 | 移植到原生代码,在GPU上运行 | 笔记本上几毫秒,便宜手机上卡顿 |
| 按评分调整的工作量证明 | 税落在每个看起来自动化的周期上 | 零,因为干净会话从不触发它 |
花他们的,别花你的。 值得保留的那一层,是诚实会话永远不会注意到的,因为它根本不会触发它。
第二点是目标的另一半,也是人们忘记的那一半。你不只是在花攻击者的钱,你也在花用户的耐心。每个增加的检查都有摩擦成本,这个摩擦预算有限。短信门控确实拦住了很多优惠滥用,但也会失去那些不想交出手机号的真实注册用户。这就是为什么没有人把每个检查都调到最高灵敏度:太激进的检测会失败它真正的工作——不是”抓欺诈”,而是”抓欺诈但不赶走人类”。艺术在于尽可能多地消耗攻击者的回报,同时尽可能少地消耗用户的摩擦预算。一层好防御是攻击者付出很多、合法用户什么也不用付出,因为合法用户根本不会触发它。
四、检测洋葱:六层结构
在开始剥洋葱之前,知道大致按什么顺序剥是有帮助的,因为层不是随机的。大多数技术层都沿着攻击者自己的技术栈,从最像浏览器的到最不像浏览器的:一个被自动化的真实浏览器,然后是一个指纹被伪造的真实浏览器,最后完全没有浏览器,只是一个直接与API对话的脚本。与这些并列的、而不是在其下面的,是完全不关心浏览器的层:请求来自的网络,以及背后的身份。
六层,以及单独击败每一层的东西:
攻击者技术栈,从最像浏览器到最不像:
| 层 | 检测什么 | 单一突破手段 |
|---|---|---|
| 01 浏览器标志 | 自动化在页面上留下的属性 | 一个把所有标志都设置回去的隐身驱动 |
| 02 反检测引擎 | 指纹描述的是否是一台真实出厂的机器 | 从真实供体机器重放渲染 |
| 03 引擎完整性 | 是否有浏览器真正运行了代码 | 一个正确回答每个探针的沙箱 |
并列层(不是其下):
| 层 | 检测什么 | 单一突破手段 |
|---|---|---|
| 04 网络层 | IP地址、TLS握手、TCP栈 | 住宅代理上的真实家庭连接 |
| 05 行为生物识别 | 鼠标曲线和按键节奏 | 一个耐心地手动操作的人类 |
| 06 业务逻辑 | 动作的顺序,跨所有账号 | 一个账号,缓慢移动,独立的 |
每行单独都会输。没有哪一层需要单独赢。
五、第一层:浏览器标志
第一个也是最简单的技术层是浏览器标志:浏览器引擎本身免费提供的那些小线索。它们表现为环境中只有当浏览器被代码驱动而不是人类驱动时才存在的对象和属性。经典的是navigator.webdriver,当浏览器以自动化方式启动时为true。还有各特定工具在页面上留下的痕迹,比如ChromeDriver注入的cdc_前缀全局变量(cdc_adoQpoasnfa76pfcZLmcfl_Array、$cdc_asdjflasutopfhvcZLmcfl_)、Selenium留下的_Selenium_IDE_Recorder和__selenium_evaluate钩子、__webdriver_evaluate、__driver_evaluate、nightmare、phantomas,以及大量回溯到PhantomJS和Watir的框架特定指纹。
真实的传感器不是检查其中一个,而是同时检查所有这些,并把结果打包成一个数字。概念上就是一个大位掩码:
const automationBits =
Boolean(window.__nightmare) |
(Boolean(window.cdc_adoQpoasnfa76pfcZLmcfl_Array) << 1) |
(Boolean(window._Selenium_IDE_Recorder) << 2) |
(Boolean(window.__webdriver_evaluate) << 3) |
(Boolean(window.__driver_evaluate) << 4) |
(Boolean(window.domAutomationController) << 5) |
(Boolean(document.$cdc_asdjflasutopfhvcZLmcfl_) << 6);
位掩码是用一个数字回答二十个是/否问题的方式。每个答案获得自己的位置,所以到达服务器的单个整数同时携带了每个检查,比列表传输更小,且给攻击者阅读载荷时没有明显的grep目标。
它还会用多种方式读取同一个线索,并期望答案一致。document元素上有webdriver属性、navigator上有webdriver属性、window上有webdriver属性、XPathResult的存在、driver属性、selenium属性,它把所有这些收集成一行:
function collectSeleniumData() {
const cdc =
window.$cdc_asdjflasutopfhvcZLmcfl_ || document.$cdc_asdjflasutopfhvcZLmcfl_ ? "1" : "0";
const wdAttr = document.documentElement.getAttribute("webdriver") != null ? "1" : "0";
const navWd = navigator.webdriver ? "1" : "0";
const winWd = typeof window.webdriver != "undefined" ? "1" : "0";
const xpath = typeof window.XPathResult !== "undefined" ? "1" : "0";
const driverAttr = document.documentElement.getAttribute("driver") != null ? "1" : "0";
const seleniumAttr = document.documentElement.getAttribute("selenium") != null ? "1" : "0";
return [cdc, wdAttr, navWd, winWd, xpath, driverAttr, seleniumAttr].join(",");
}
这些标志在攻击者切换到隐身驱动之前是完美的。隐身驱动的存在就是为了隐藏所有这些线索:它们不添加cdc_全局变量,把navigator.webdriver设回false,修补掉那些属性,并接入库来生成类似人类的鼠标移动,而不是让光标瞬间移动。面对配置正确的隐身设置,上述整个位掩码读起来是干净的。
但隐身驱动也不完美,这里我们找到了层中嵌套层的第一处。要伪造一个值,工具必须在某处进行干预,而这个干预本身,如果知道去哪里看,是可以检测到的。
最便宜的例子是计时。在Chromium或任何其他引擎的原版构建中,读取两个不同的navigator属性运行大致相同的代码量,耗时大致相同。比较navigator.cookieEnabled和navigator.webdriver:两者都是简单的属性读取,成本应该相同。很多隐身工具隐藏webdriver的方式是在JavaScript中重新定义getter:
Object.defineProperty(Navigator.prototype, "webdriver", {
get() {
return false;
},
});
那个替换getter是位于值前面的额外JavaScript。读取cookieEnabled直接走原生路径;现在读取webdriver要先绕道注入的函数。单次读取太吵无法测量,但平均二十万次,绕道就会显现出来:
function averageReadNs(read, iterations) {
const start = performance.now();
for (let i = 0; i < iterations; i++) read();
return ((performance.now() - start) / iterations) * 1e6;
}
const clean = averageReadNs(() => navigator.cookieEnabled, 200000);
const patched = averageReadNs(() => navigator.webdriver, 200000);
单次读取任一属性只需要几纳秒,网页可用的时钟粒度太粗,无法看到那么小的差异。做二十万次,测量误差被平均掉了,但额外的工作没有,所以单次读取中不可见的差异变成了可以依赖的稳定差距。
如果patched稳定地比clean高出固定幅度,你几乎肯定是在看JavaScript级别的钩子,而不是真实浏览器。
重要的警告——这也是下一层存在的原因——这个计时技巧只能捕获在JavaScript中完成的伪造。在源码层面修补的工具,或者一开始就根本不设置自动化标志的工具,返回false时没有绕道也没有钩子,所以计时相同,这个特定检查什么都看不到。这正是反检测引擎赖以生存的缝隙。
六、第二层:反检测引擎
反检测引擎是反检测浏览器背后的引擎(Mimic引擎是Multilogin内的典型例子)。它们的目的是看起来像完全合法、原版构建的Chrome,同时携带额外的机器来伪造用于指纹环境的所有值和函数。
它们做很多事情:伪造user agent、报告的CPU和GPU、内存、向canvas和WebGL渲染添加噪声、交换字体列表等等。目标是让一台物理计算机启动多个浏览器实例,每个实例在网站看来都像一台完全独立且完全合法的机器。
这些引擎在隐藏自己不是原版Chrome这件事上确实很擅长。但每伪造一个值,就暴露了更多的表面,更多的表面意味着更多的泄漏机会。再次出现同样的规律:这一层里面有多层。从来没有二元的”检测到/没检测到”的终态,只有你的创造力愿意走多远。
从表面开始。对于引擎来说,要伪造指纹,首先必须决定伪造的指纹是什么。它需要生成所有指纹值的组合,而这个生成本身就是一种指纹。大多数这些引擎携带有限列表——一组CPU、一组GPU、一组内存大小、一组屏幕分辨率——然后掷骰子选择组合并应用。但列表是有限的,更有用的是,它们能产生的很多组合在现实世界中不存在。
例如,一台引擎可能愉快地给你一台报告8核移动ARM平台、高端独立桌面GPU(WebGL渲染器字符串中)、2GB设备内存和4K分辨率的机器。没有任何设备曾经被这样构建过。在现实世界中,硬件是一致捆绑的——给定的GPU字符串与窄范围的CPU数量、内存大小和平台共存,因为它只存在于少数实际产品中。所以不是试图证明任何一个值是假的,而是检查这个组合是否是现实曾经产生过的。一台声称一个从未出货的配对的机器,不是一台稀有机器,而是一台生成的机器。
更深层也更顽固的问题是渲染指纹。Canvas指纹的原理是:当你在HTML 上绘制文本或形状,然后读回像素,你得到的精确字节取决于底层整个渲染栈:GPU、图形驱动、操作系统文本光栅器、抗锯齿、次像素微调、浏览器构建。两台相同栈的机器产生字节完全相同的输出;改变任一层,字节就会轻微偏移。把这些字节哈希就得到一个canvas哈希。WebGL是同一思想的更深一层:渲染一个3D场景并读回,也可以直接问驱动它是谁:
function webglRenderer() {
const gl = document.createElement("canvas").getContext("webgl");
const info = gl.getExtension("WEBGL_debug_renderer_info");
return {
vendor: gl.getParameter(info.UNMASKED_VENDOR_WEBGL),
renderer: gl.getParameter(info.UNMASKED_RENDERER_WEBGL),
};
}
这里有用的属性是:没有人选择这个值。User agent字符串是浏览器决定说的关于它自己的一句话,任何能说的都可以不同地说。Canvas哈希是特定芯片、特定驱动和特定字体光栅器做它们的工作的算术结果,所以改变它意味着要么对这个答案撒谎,要么改变硬件。
当引擎伪造GPU时,它在这个渲染上把自己逼入了死角。它真的只有两个诚实的选择,两者都是可检测的。它可以向输出添加噪声,这样哈希就不匹配底层真实硬件;或者它可以让真实硬件渲染并返回真实结果。
如果它添加噪声,你从统计上捕获它。如果你持有数十万个真实指纹的数据库,并且知道每个配置产生的canvas渲染,在任何给定时刻,实际不同的canvas渲染只有几千个量级,因为它们是由有限的操作系统、GPU、驱动和浏览器组合决定的,而不是由个体决定的。所以一个没有出现在任何历史记录中的canvas哈希,更可能是渲染上的噪声,而不是一台真实的新机器。
如果引擎返回真实渲染,它仍然可以被标记,因为现在渲染与它声称的指纹不匹配。它说是Windows上的集成Intel GPU,但产生了macOS上Apple GPU的canvas输出。渲染与故事不符。
更好的引擎现在达到的第三个选项值得了解,因为这才是真正的前沿:重放一个真实渲染——一个从匹配的物理机器上真实捕获的输出,现在逐字节地服务回去。这同时击败了”未见哈希”检查和”错误硬件”检查。但渲染从不单独存在。Canvas哈希必须与WebGL渲染器字符串一致,WebGL渲染器字符串必须与字体列表一致,字体列表必须与音频指纹一致,音频指纹必须与屏幕几何和设备内存一致。从一台供体机器重放一个信号并将其缝合到伪造的其他所有东西上,交叉检查就会在接缝处捕获。跨信号的一致性比任何单一信号都难伪造得多。
在这一切之下还有一个可爱的线索。真实的GPU被要求两次绘制相同的东西,会产生完全相同的像素两次——它是确定性的。很多噪声实现是每次绘制时扰动而不是每个会话扰动,所以让环境渲染完全相同的canvas两次并比较两个结果,可以告诉你噪声是来自硅还是来自脚本。真实硬件是无聊且可重复的。噪声不是。
七、第三层:引擎完整性(无头客户端与沙箱)
当攻击者运行大规模自动攻击时,资源成本就是一切,在做同样工作的两种方式之间存在巨大差异。一方面,你启动一个真实浏览器来解析页面、渲染它、运行网站的JavaScript,然后发送实际完成目标的API请求。另一方面,你写一个轻量级无头客户端,根本不打开浏览器,直接发送请求,模仿网站前端本来会发送的内容。
两者之间的差距不小,是数量级的差距。一个真实浏览器实例携带数百兆内存、完整渲染管道、JavaScript引擎,以及布局和绘制一个它甚至不需要看的页面的CPU成本。原始HTTP客户端只携带一个socket和几KB状态。按每个实例的内存、每个周期的CPU时间、每台机器的吞吐量计算,你说的是相同硬件上数百到数千倍的容量差。当你的目标是容量,而一个”周期”是一次完整攻击(比如注册然后关注),能够运行数千个周期而浏览器只能运行一个,这就是整个操作的经济学。
所以防御者在这层的工作是让不运行浏览器变得昂贵,并让检测到的浏览器证明它是真实的。这层防御是多层的,各部分组合使用效果最好。
工作量证明(Proof of Work)
工作量证明被当作”检测”或”阻止”Bot的方式到处宣传。它根本不做这个,值得大声说出来。工作量证明不会告诉你客户端是人类还是脚本。它做的是另外两件事。
首先,它提高了编写无头客户端的成本。要在没有浏览器的情况下发送有效请求,攻击者必须在客户端代码中找到工作量证明例程、理解它,并在自己的客户端中重新实现。这是开发方面的摩擦,在任何请求发送之前就有了。
其次——这是真正的目的——它强制在每个周期上承担计算成本,不管那个周期是在浏览器还是无头客户端中运行。客户端收到一个挑战,必须燃烧CPU找到一个输入,其哈希满足某个条件,经典的”找一个nonce使哈希以N个零开头”的形状:
function solveChallenge(challenge, difficulty) {
const target = "0".repeat(difficulty);
let nonce = 0;
while (true) {
if (sha256Hex(challenge + nonce).startsWith(target)) return nonce;
nonce++;
}
}
没有巧妙的方法可以解决这个。哈希不能反向运行,所以找到答案的唯一路线是尝试数字直到找到有效的,每个额外的零要求的零将尝试次数乘以16。同时,检查提交的答案只是一个哈希。这种不对称——查找昂贵、验证即时——就是整个机制。
对于一个合法用户解决一次,这个成本是无形的,他们从未注意到的几毫秒。对于运行数万个并发周期的攻击者,同样这几毫秒乘以出来,是一个非常真实且蓄意的性能税。它不会阻止攻击。它让攻击成本更高——记住,这就是整个目标。
沙箱与完整客户端重实现
如果攻击者想避免运行像Blink这样的完整浏览器引擎,他们有两条路,两者各有用途和弱点。
第一条路是逆向工程并重实现整个客户端,手动重建网站前端所做的所有事情:它探测哪些值、如何组装其载荷、如何解决工作量证明。如果目标没有真正的防御,这效果很好,因为没有Bot保护的网站永远不会轮换其客户端代码,载荷格式和工作量证明例程是静止的,所以逆向一次就完成了。在这种情况下,完整重实现是攻击者最好的工具。
但面对强大的反Bot服务,客户端脚本被严重混淆,通常是虚拟化的,并且轮换非常频繁,有时每小时多次。现在重实现之路变得残酷:攻击者必须逆向一个移动目标、重写客户端、并跟上比他们实际能跟上的更快的轮换节奏。它变得几乎无法维护。(大型语言模型确实已经移动了这条线,它们非常擅长处理混淆代码,所以一个曾经需要人类一周的重写现在可以快得多。但轮换把一次性的逆向变成了一台跑步机,一次又一次地在时钟上逆向,这是一个不同于任何单一反混淆的更难的问题,这正是防御所依赖的压力点。)
这就是第二条路存在的原因:在V8等引擎之上的轻量级沙箱中运行客户端自己的JavaScript,未经修改。现在你根本不需要理解客户端,也不关心它何时轮换,你只需执行它给你的任何东西并读出答案。它用一步就绕过了整个逆向工程跑步机。问题是你的沙箱必须是坚不可摧的,与真实浏览器无法区分,因为客户端脚本一旦运行,它的全部工作就是四处看看,判断自己是在真实浏览器中还是在沙箱中。
引擎检查
这里开始有趣了,因为有大量方法可以区分沙箱和真实浏览器引擎,而且大多数看起来根本不像检查。
最原始的方法是容量:进行大量DOM和渲染调用,看看沙箱是否能正确跟上。对于沙箱来说,要生存下来,它必须将每个这些API重新实现为行为完全像浏览器,这是昂贵的,但这是你支付一次然后你的沙箱永久更强的成本。所以粗糙版本是沙箱最终能赢得的消耗战。
优雅版本走低级路线,问那些答案依赖于真实引擎怪癖的问题, naive模拟会微妙地弄错。一个最爱是WebIDL整数转换。很多DOM方法声明它们的参数为unsigned long,规格说明浏览器必须在使用之前将你传入的任何东西强制转换为真实的32位无符号整数。所以2 ** 32(超过最大值一)会回绕到0:
const nodes = document.querySelectorAll("div");
nodes.item(4294967296);
在真实浏览器中,4294967296被强制转换为0,你得到第一个节点。在一个只是把参数当作普通JavaScript数字处理的沙箱中,没有回绕发生,索引保持4294967296,你得到null。同样的调用,不同的答案,差异就是一个指纹。整个人[Clamp]和[EnforceRange]类型转换家族给你同样的杠杆。
然后是原生代码完整性。在真实引擎中,内置函数的字符串化形式是一个特定的形状:
Function.prototype.toString.call(document.createElement);
真实的Chrome回答”function createElement() { [native code] }”。一个用JavaScript垫片了createElement的沙箱返回垫片的源码,而不是,除非它去费力遮蔽每个函数的toString、它的访问器显示名称(function get userAgent() { [native code] })、它的原型链,以及从原生调用内部抛出的错误堆栈的精确形状。漏掉一个就泄漏。
缺失表面是最直白的线索。真实浏览器暴露了巨大的API,而模拟只实现它认为需要的部分。所以你探测那些不显眼的角落:File、FileList、BroadcastChannel、PaymentRequest、DOMException、FocusEvent、InputEvent、ClipboardEvent、PublicKeyCredential认证器接口、AudioWorklet、document.styleSheets、previousElementSibling。忘记DOMException的沙箱不会礼貌地回答”undefined”,本应抛出DOMException的代码路径反而抛出ReferenceError,这是一个更响亮的信号。形状必须正确,而不仅仅是存在:在真实浏览器中navigator.mimeTypes字符串化为[object MimeTypeArray],但用普通对象伪造它的沙箱回答[object Object]。
整个音频栈也是一个小世界。问一个OfflineAudioContext渲染一个振荡器通过压缩器并对样本求和,真实浏览器产生一个特定数字;沙箱必须精确复制。给decodeAudioData喂一个损坏的缓冲区,真实浏览器用EncodingError DOMException拒绝,所以沙箱必须用相同的错误名拒绝,否则它会在异常处理器中被捕获。
八、第四层:网络层
到目前为止一切都生活在浏览器内部。但每个请求也通过网络到达,来自一个IP,由TLS握手和HTTP/2连接携带,而房子的这一面是它自己的完整洋葱,根本不在乎攻击者的浏览器欺骗有多好。
最外层是IP声誉。这个地址从哪里来,它的ASN是什么?数据中心范围(AWS、GCP、大型主机)便宜、丰富,从那里运行欺诈是非常弱的,因为它们被轻易知道。所以攻击者转向住宅和移动代理,借用真实家庭或电话连接的地址,这是他们能买到的最有效的一件事,这就是为什么IP白名单或简单限流如此快速失效。真实传感器将IP声誉视为由服务器驱动的实时信号,与其他一切折叠在一起,而不是静态黑名单,正是因为好地址不断轮换。
所以地址看起来干净、住宅、不起眼。现在内部的层打开了:它真的是一个住宅用户,还是一个背后有欺诈者的住宅代理?有几件事可以给出代理跃点。TCP/IP栈有自己的指纹:初始TTL、MTU、TCP窗口和选项,这些由实际终止连接的机器的操作系统设置,而这通常不是浏览器声称的操作系统。还有一个计时线索:当流量通过额外跃点中继时,原始TCP握手完成与TLS ClientHello第一个字节到达之间的间隙不正常地拉伸。浏览器的时区可以与IP的地理位置比较——”德国住宅”IP驱动设置为Asia/Shanghai的浏览器,是一个值得仔细看的错配。
然后是TLS指纹层,与即将到来的无头客户端讨论直接配对,因为它是同一个升级阶梯。每个TLS客户端都有一个指纹,新的JA4源自其ClientHello的精确形状、密码列表、扩展、它们的顺序。一个普通的Python requests脚本的TLS指纹与Chrome的完全不同,所以在你读取载荷的第一个字节之前,你就知道它不是它声称的浏览器。攻击者的对策是一个像curl_cffi或tls-client这样的库,逐字节模仿Chrome的ClientHello。所以检测升级到一个级别:现在检查一致性。TLS指纹说Chrome 151。HTTP/2指纹(帧设置、头顺序)也说Chrome 151吗?User-Agent呢?sec-ch-ua client-hints头呢?真实的Chrome在所有这些上都与自己一致,因为它们都来自同一个二进制文件。模仿者必须复制所有这些,保持一致,否则接缝就会显示。
这里值得命名的反复出现的形状,因为它在每个剩余层都会回来。你几乎永远不会因为一个值是错的而捕获一个好的攻击者。你捕获的是两个不能同时为真的值,一个会话必须同时满足的独立源越多,让它们全部保持一致就越昂贵。
九、第五层:行为生物识别
假设攻击者放弃与引擎斗争,直接运行一个真实浏览器,欺骗干净,由隐身驱动驱动,在住宅IP上。技术层安静了。现在我们观察这个东西的行为,因为移动真实浏览器的脚本仍然不像手的移动。
传感器坐在所有输入事件的整个传播上并将它们记录为紧凑流:mousemove、mousedown、mouseup、click、pointerdown、pointerup、touchstart、touchmove、touchend、keydown、keyup、keypress,加上deviceorientation、devicemotion,以及告诉它页面何时获得你注意力的blur/focus/visibilitychange转换。对于每个事件,它保留一个小元组:序列号、种类、从跟踪开始时的时间偏移、坐标和一个运行校验和,它限制每种类型的数量,所以它永远不会变成内存泄漏。从中你得到交互的形状:鼠标路径的曲率和速度、击键之间的停留和飞行时间、滚动物理、”移动”会话上的触摸事件是否真的看起来像手指。
所有这些中最有价值的字段不是任何坐标。它是isTrusted。每个真实用户事件都带有isTrusted === true,由浏览器本身设置,页面JavaScript无法伪造。你自己分派的事件或通过自动化协议以某些方式驱动页面产生的事件,通过时带有isTrusted === false。所以传感器在其流中标记任何不受信任的事件,并保持一对计数器:
function updateTrustedCounters(event) {
if (event.isTrusted === true) trustedCount++;
else untrustedCount++;
}
浏览器在事件创建的那一刻就盖上这个印章,在任何页面代码能到达它之前。一个脚本可以构建一个在所有其他方面看起来完全相同的事件并在任何元素上分派它,印章仍然会说这个事件不是来自物理设备。这是页面几乎无法撒谎的几件事之一。
单独一个不受信任的事件不是定罪的,扩展和合法脚本也会触发合成事件。但一旦不受信任的事件堆积超过某个阈值,特别是当整个交互类(每个”click”、每个”keydown”)都是合成的,而同一个元素上从未出现过可信事件,传感器停止等待并发出一个标记为完全该条件的专用遥测发布。它有效地当场抓住了自动化——不是通过一个缺失的属性,而是通过事件本身的结构。
十、第六层:业务逻辑
到目前为止我们只看技术环境和会话本身。但还有更多,其中最重要和最被忽视的部分是实际的业务逻辑——用户正在做的事情的意义。
业务逻辑在这里是应用程序做的任何事情:注册、关注、购买、提现。这是Bot和欺诈检测讨论中被遗漏的最大部分之一,而且是在噩梦场景中仍然有效的层——攻击者击败了上面的一切,一台真实的干净设备、一个真实的住宅连接、真实的人手。当机器和网络告诉你什么都没有时,动作的顺序仍然可以。
业务逻辑分析单独使用不太有用,像其他每一层一样,它想要被组合。一个原始例子:一个全新账号注册,直奔JohnDoe123的主页,然后关注。单独看,这几乎不透露什么,人们每天都这样做。但有两件事让它变得强大。首先,与技术和网络层交叉参考,如果一千个全新账号都在执行完全相同的模式,在借来的住宅IP上,指纹略微太干净,业务逻辑是将它们聚类在一起的东西。其次,时间——所有这些全新账号都在同一个小时注册。
十一、总结
Bot与欺诈检测不是一堵墙,不是一个旗帜,它是一颗洋葱。层层叠加,每层都有自己的内部层。没有任何单一检查能决定”Bot”或”不是Bot”,也永远不会有点说”你已经添加了足够的检查”。唯一的真正限制是你的创造力。
最强的系统是结合技术信号和上下文信号的系统——检测自动化并检测滥用——并在每一层问同样的两个问题:这让攻击者付出多少?这让诚实用户付出多少?
花攻击者的,不要花你自己的。
出处:本文译自 TrustSig 博客原文 How does Bot & Fraud prevention even work? 原文地址:https://trustsig.eu/blog/how-bot-and-fraud-prevention-works/














暂无评论内容