前提:Cloudflare Pro 及 以上套餐。主要针对Layer7(CC)层面攻击。
Cloudflare Snippets 是 Cloudflare 去年推出的一个功能,它允许用户在其网站上快速插入自定义代码片段,这些代码片段可以用于多种目的,包括但不限于增强网站的功能、进行 A/B 测试、添加分析工具或实现其他自定义功能。简单来说,可以把Snippets理解为 迷你/阉割 版的Workers,相比于Workers,其Runtime和File Size上限更低,但灵活性更高(支持无限量的请求和灵活的路由匹配),因此Cloudflare Snippets 比较适合在边缘节点部署一些轻量的组件或功能。
Cloudflare WAF 以及 其他CDN WAF产品现状
目前来说,大部分传统的主流CDN WAF相关产品,防御CC攻击大多都基于速率限制和验证码以及规则匹配。
速率限制
对IP/UA/指纹等传入参数进行速率限制,超出设定的速率阈值则临时封禁或者对请求发起人机验证。
验证码/人机验证
验证码主要分两种,一种是 无感验证码 (Invisible Captcha or Silent Challenge),另一种是 交互式 (Interactive) 验证码。
就用户体验而言,前者(无感验证)无疑是优于后者的。
无感验证
无感验证可以是多维度的。
举几个简单的例子
- 利用tls指纹过滤掉一批恶意流量。(黑名单机制)
- 对公共代理、有攻击历史的IP进行封锁或风控。(黑名单机制)
- 要求客户端执行一段JS代码,对客户端环境进行收集,过滤掉一部分异常请求。(AWS WAF、Datadome、Kasada等都有类似的功能)
- 引入AI模型,对流量进行学习,解放双手。(目前已知的产品有Datadome、Secbit等)
交互式验证
交互式的验证需要用户进行鼠标点击等操作,示例如下。
hCaptcha

reCAPTCHA

Cloudflare Turnstile

AWS WAF Captcha

规则匹配
就目前来说,绝大部分传统CDN WAF是以规则(Rule-based)为导向的。规则为基础的WAF的缺点其实不少,比如灵活性欠缺、规则过多导致的维护成本过高、具有滞后性(规则的变化可能无法跟上攻击者的攻击方式的变换)、无法面面俱到(总有无法匹配或者遗漏的请求)。
另一方面,以规则为基础的WAF对用户(网站主)有一定的门槛,首先网站主需要了解WAF产品的使用方法以及如何正确地分析和匹配预期请求。
理想状态下,设计良好的WAF规则能让网站在遭受攻击时,也不对正常用户(至少95%以上正常用户)展示交互式验证码,这意味着通过规则匹配对大部分的恶意流量进行拦截。但在实际操作中,理想和现实差距是很大的。以Cloudflare为例,大部分站点在遭受攻击时,也只能寻求五秒盾的庇护。
常见的CC攻击方式
粗略来看,CC攻击可以大致分为两种。
一种是洪水攻击(特征是IP分布广,单IP请求量大,请求总量也大,几分钟内可以制造千万甚至上亿的请求。),另一种是具备JS执行能力和验证码解决能力的更为高阶的攻击,亦或是两者的结合版。就目前来说,大部分CC攻击以前者为主,本文后续的WAF设计主要是针对洪水攻击。
理想状态下WAF所需要具备的部分功能以及部分实现思路
以下仅作分享,后续文章可能会分享部分功能的具体实现。
1. 基于IP/ASN情报的风控。
① 将有攻击历史的IP和代理IP纳入IP库,对这类IP进行额外的风控。对高风险ASN进行风控。
② 机房IP识别,有些场景需要对机房IP进行封锁,所以可以预先对机房ASN进行整理(互联网公开的免费数据不完整,需要手动整理,总数在1W+)。
实现思路:① 制造蜜罐,引诱攻击,对攻击进行分析,提取攻击IP。 ② 全网爬取公开IP代理库,将IP纳入风控列表。③引入第三方IP情报库。
2. 基于TLS指纹和浏览器环境检测的风控。
①手动收集一些常见的自动化tls指纹,并进行封锁。苦差事。② 利用JS对浏览器环境检测,对部分异常的请求进行风控或者封锁。
3. 不同难度/强度的人机验证方式,可以根据业务场景进行升级或者降级。
将人机验证分为多个等级,比如 1-2-3-4-5级,可以根据业务场景进行升级或降级,不同场景应用不同挡位/难度的验证方式。同时对流量进行实时监控,当流量远超于日常流量基准或当并发数达到设置阈值时,自动启用更高强度的验证方式。
利用Cloudflare Snippets设计一个轻量的无感验证
前文提到了,互联网上大部分攻击请求目前还是以洪水攻击为主,这意味着大部分场合下完全不需要交互式验证的介入,所以我们完全可以用Cloudflare Snippets设计一个轻量的无感验证进行流量过滤。
设计思路
本次设计的无感验证也可以叫做是Javascript验证,要求客户端(浏览器)执行一段JS,JS包含Cookie生成逻辑,Snippet返回JS并让客户端执行总耗时不会超过1秒。
举个例子,在没有JS验证前,所有用户都可以直接访问网页。
第一版
现在网站主做了一个限制,要求携带 __token=opensesame 这个cookie的用户才能访问页面,对应的JS则是document.cookie = "__token=opensesame; path=/"; location.reload();
上述示例是后续代码的原始模型,现在我们需要对这个JS进行优化和升级。
上述设计潜在问题:不同用户,不同用户可以直接利用相同的Cookie进行访问,攻击者如果使用多个IP进行请求,只需要获取一次cookie即可,很显然这不是我们想要的结果。
第二版
基于第一版,我们可以把 IP 参数纳入到 Cookie 中,即预期cookie __token 的 值是 sha256(IP+KEY),key为自定义密钥,存储在snippet。当用户访客网站时,snippet根据用户的ip计算出对应的值,然后返回JS。假设用户IP为1.1.1.1,预期设置的key为changeme,则snippet会返回 JS document.cookie = "__token=1efc629a0b6391b63b53111df28ef4d0fa2d90b7e3c9539b8e25b90d4200a114; path=/"; location.reload(); 当用户成功设置cookie后,snippet会对用户的cookie进行校验,校验通过则放行请求。
如此设计,还面临两个问题。一方面是cookie是明文展示的,很容易获取,攻击者可以直接查看网站返回内容,甚至不需要调用浏览器就能获取到所需cookie;另一方面是只要用户IP不变,获取一次cookie后,cookie永远不会失效,如果后端能自定义设置cookie的有效期就更好了。
第三版
根据第二版的两个问题,我们进行逐一修复。
先来解决cookie有效期的问题,我们可以将未来时间戳纳入key来实现。假设当前时间戳是1757402014592,设置cookie有效期是30分钟,则未来时间戳对应的是 1757403814592。我们将 1757403814592 纳入 刚刚的 token 里,访客ip为1.1.1.1时,sha256(timestamp+1.1.1.1+token) ,也就是 sha256 (17574038145921.1.1.1changeme) 也就是 f13e0834ae9cad210e8f70affe026a6b43f85fccd737b6a1326ade7229a3ef5e,返回JS 则是 document.cookie = "__token=1757403814592:f13e0834ae9cad210e8f70affe026a6b43f85fccd737b6a1326ade7229a3ef5e; path=/"; location.reload();
此时snippet后端需要判断两个点:①timestamp是否大于当前时间戳 ②1757403814592+访客ip+chageme的sha256的值是否是f13e0834ae9cad210e8f70affe026a6b43f85fccd737b6a1326ade7229a3ef5e。如果两者符合条件,则通过,否则返回新的JS代码。
Cookie有效期问题解决后,我们需要继续处理Cookie可以被轻易获取的问题。对此,我们可以对设计一个自定义hash算法,要求前端对后端返回的cookie进行加工,相对应的Snippet可以这样返回
<meta name="check" content="1757403814592:f13e0834ae9cad210e8f70affe026a6b43f85fccd737b6a1326ade7229a3ef5e"><script src="/check.js"></script>
check.js 示例
function abchash(input) {
const s = [
0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19
];
const rounds = 12;
function G(v, a, b, c, d, x, y) {
v[a] = (v[a] + v[b] + x) | 0;
v[d] = v[d] ^ v[a];
v[d] = (v[d] >>> 16) | (v[d] << 16);
v[c] = (v[c] + v[d]) | 0;
v[b] = v[b] ^ v[c];
v[b] = (v[b] >>> 12) | (v[b] << 20);
v[a] = (v[a] + v[b] + y) | 0;
v[d] = v[d] ^ v[a];
v[d] = (v[d] >>> 8) | (v[d] << 24);
v[c] = (v[c] + v[d]) | 0;
v[b] = v[b] ^ v[c];
v[b] = (v[b] >>> 7) | (v[b] << 25);
}
let m = [];
for (let i = 0; i < input.length; i += 4) {
m.push(
(input.charCodeAt(i) | 0) +
((input.charCodeAt(i + 1) | 0) << 8) +
((input.charCodeAt(i + 2) | 0) << 16) +
((input.charCodeAt(i + 3) | 0) << 24)
);
}
while (m.length < 16) {
m.push(0);
}
let v = s.slice();
v = v.concat([
0x6A09E667 ^ m[0], 0xBB67AE85 ^ m[1], 0x3C6EF372 ^ m[2], 0xA54FF53A ^ m[3],
0x510E527F ^ m[4], 0x9B05688C ^ m[5], 0x1F83D9AB ^ m[6], 0x5BE0CD19 ^ m[7]
]);
for (let r = 0; r < rounds; r++) {
G(v, 0, 4, 8, 12, m[0], m[1]);
G(v, 1, 5, 9, 13, m[2], m[3]);
G(v, 2, 6, 10, 14, m[4], m[5]);
G(v, 3, 7, 11, 15, m[6], m[7]);
G(v, 0, 5, 10, 15, m[8], m[9]);
G(v, 1, 6, 11, 12, m[10], m[11]);
G(v, 2, 7, 8, 13, m[12], m[13]);
G(v, 3, 4, 9, 14, m[14], m[15]);
let temp = m[0];
m.shift();
m.push(temp);
}
let h = [];
for (let i = 0; i < 8; i++) {
h[i] = s[i] ^ v[i] ^ v[i + 8];
}
let output = "";
for (let i = 0; i < 8; i++) {
output += h[i].toString(16).padStart(8, '0');
}
return output;
}
function setCookie(name, value, maxAgeSeconds) {
document.cookie = `${name}=${value}; max-age=${maxAgeSeconds}; path=/`;
}
const metaTag = document.querySelector('meta[name="check"]');
if (metaTag) {
const content = metaTag.getAttribute('content');
const [prefix, hashInput] = content.split(':');
const hashedValue = abchash(hashInput);
const token = `${prefix}:${hashedValue}`;
setCookie('__token', token, 3600);
window.location.reload();
}
上述JS并不是最终版本,我们还需要用合适的工具对原check.js进行混淆,混淆后的check.js示例 (https://go.0xfc.de/jxj89f),混淆完成后将新的check.js进行替换原JS文件。
此时,snippet后端需要校验的是 ①timestamp是否大于当前时间戳 ②1757403814592+访客ip+chageme的sha256的值的abchash的值是否符合预期。如果两者符合条件,则通过,否则返回新的JS代码。
完整Snippet代码示例
假设对应的需要开启的对应域名是 example.com,则对应的路由匹配表达式是
(http.host eq "example.com" and http.request.uri.path ne "/check.js")
另外需要注意的是,实际部署时需替换key的值以及现有的hash算法。
const TOKEN_COOKIE_NAME = '__token';
const TOKEN_VALIDITY_MS = 30 * 60 * 1000; // 30 minutes
const SECRET_KEY = 'changeme'; // Replace with your own secure key
export default {
async fetch(request) {
try {
const ip = request.headers.get('CF-Connecting-IP') || '';
const cookies = parseCookies(request.headers.get('Cookie') || '');
const now = Date.now();
const expiryTimestamp = now + TOKEN_VALIDITY_MS;
const newToken = await generateToken(ip, expiryTimestamp);
const token = cookies[TOKEN_COOKIE_NAME];
if (!token || !(await isValidToken(token, ip, now))) {
return createChallengeResponse(newToken);
}
return await fetch(request);
} catch (error) {
console.error('Worker error:', error);
return createErrorResponse();
}
},
};
function parseCookies(cookieString) {
return cookieString
.split('; ')
.reduce((cookies, cookie) => {
const [name, value] = cookie.split('=');
cookies[name] = value;
return cookies;
}, {});
}
async function generateToken(ip, timestamp) {
const hash = await sha256(`${timestamp}${ip}${SECRET_KEY}`);
return `${timestamp}:${hash}`;
}
async function isValidToken(token, ip, now) {
const [timestampStr, hash] = token.split(':');
const timestamp = Number(timestampStr) || 0;
if (timestamp <= now) return false;
const expectedHash = await abchash(await sha256(`${timestamp}${ip}${SECRET_KEY}`));
return hash === expectedHash;
}
function createChallengeResponse(token) {
const headers = new Headers({
'Content-Type': 'text/html; charset=UTF-8',
'Cache-Control': 'no-store, no-cache, must-revalidate'
});
return new Response(
`<meta name="check" content="${token}"><script src="/check.js"></script>`,
{ status: 403, headers }
);
}
function createErrorResponse() {
return new Response('Error', { status: 500 });
}
async function sha256(message) {
const msgBuffer = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
return Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
async function abchash(input) {
const initialState = [
0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19
];
const rounds = 12;
function G(v, a, b, c, d, x, y) {
v[a] = (v[a] + v[b] + x) | 0;
v[d] = v[d] ^ v[a];
v[d] = (v[d] >>> 16) | (v[d] << 16);
v[c] = (v[c] + v[d]) | 0;
v[b] = v[b] ^ v[c];
v[b] = (v[b] >>> 12) | (v[b] << 20);
v[a] = (v[a] + v[b] + y) | 0;
v[d] = v[d] ^ v[a];
v[d] = (v[d] >>> 8) | (v[d] << 24);
v[c] = (v[c] + v[d]) | 0;
v[b] = v[b] ^ v[c];
v[b] = (v[b] >>> 7) | (v[b] << 25);
}
let message = [];
for (let i = 0; i < input.length; i += 4) {
message.push(
(input.charCodeAt(i) | 0) +
((input.charCodeAt(i + 1) | 0) << 8) +
((input.charCodeAt(i + 2) | 0) << 16) +
((input.charCodeAt(i + 3) | 0) << 24)
);
}
while (message.length < 16) message.push(0);
let state = initialState.slice();
state = state.concat([
initialState[0] ^ message[0], initialState[1] ^ message[1],
initialState[2] ^ message[2], initialState[3] ^ message[3],
initialState[4] ^ message[4], initialState[5] ^ message[5],
initialState[6] ^ message[6], initialState[7] ^ message[7]
]);
for (let r = 0; r < rounds; r++) {
G(state, 0, 4, 8, 12, message[0], message[1]);
G(state, 1, 5, 9, 13, message[2], message[3]);
G(state, 2, 6, 10, 14, message[4], message[5]);
G(state, 3, 7, 11, 15, message[6], message[7]);
G(state, 0, 5, 10, 15, message[8], message[9]);
G(state, 1, 6, 11, 12, message[10], message[11]);
G(state, 2, 7, 8, 13, message[12], message[13]);
G(state, 3, 4, 9, 14, message[14], message[15]);
message.push(message.shift());
}
let hash = [];
for (let i = 0; i < 8; i++) {
hash[i] = initialState[i] ^ state[i] ^ state[i + 8];
}
return hash.map(h => h.toString(16).padStart(8, '0')).join('');
}
待完成的(也许下期更新?)
-
对搜索引擎进行过白、可以自定义白名单。
-
更复杂的JS逻辑,并且在Javascript内增加浏览器环境检测。
-
对部分JS代码进行动态生成。
-
IP情报的收集以及建立自己的IP库。
-
更高阶的验证方式(点击、旋转、滑块验证等),当检测到异常请求时,自动由无感验证升级到交互验证防御。
演示站点 (升级版)
bd
bd
商家和大站用到多些吧,一般MJJ用不上
bd
好东西
bd
看完了,所有Pro在哪领
顶
考虑使用jwt吧 能控制过期还能防止伪造
我目前在使用Cloudflare Snippets 提前验证jwt避免一些内部接口被刷请求
感谢分享
个人很反感这些验证码,尤其是谷歌的