logo NodeSeekbeta

利用 Cloudflare Snippets 设计一个专属的WAF (Part 1)

前提: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) 验证码。

就用户体验而言,前者(无感验证)无疑是优于后者的。

无感验证

无感验证可以是多维度的。

举几个简单的例子

  1. 利用tls指纹过滤掉一批恶意流量。(黑名单机制)
  2. 对公共代理、有攻击历史的IP进行封锁或风控。(黑名单机制)
  3. 要求客户端执行一段JS代码,对客户端环境进行收集,过滤掉一部分异常请求。(AWS WAF、Datadome、Kasada等都有类似的功能)
  4. 引入AI模型,对流量进行学习,解放双手。(目前已知的产品有Datadome、Secbit等)

交互式验证

交互式的验证需要用户进行鼠标点击等操作,示例如下。

hCaptcha

tBwqNTEFN1UHd6DYVNrHg9GzT0Jo1mZQ.webp

reCAPTCHA

0vabDy5UwuPmOZXkctATBAl5ZKpIHpmF.webp

Cloudflare Turnstile

ftnn8DYPHIHbzvH0pMFQnbnKxQBEpRCU.webp

AWS WAF Captcha

sTMh1DuNg0edQkpZjxjmjUfP7Av8X2vi.webp

规则匹配


就目前来说,绝大部分传统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('');
}

待完成的(也许下期更新?)


  1. 对搜索引擎进行过白、可以自定义白名单。

  2. 更复杂的JS逻辑,并且在Javascript内增加浏览器环境检测。

  3. 对部分JS代码进行动态生成。

  4. IP情报的收集以及建立自己的IP库。

  5. 更高阶的验证方式(点击、旋转、滑块验证等),当检测到异常请求时,自动由无感验证升级到交互验证防御。

演示站点 (升级版)


https://waf.aapq.net/

123
  • bd

  • bd

  • 商家和大站用到多些吧,一般MJJ用不上 ac01

  • bd

  • 好东西

  • bd

  • 看完了,所有Pro在哪领 ac01

  • 考虑使用jwt吧 能控制过期还能防止伪造
    我目前在使用Cloudflare Snippets 提前验证jwt避免一些内部接口被刷请求

  • 感谢分享 xhj003 个人很反感这些验证码,尤其是谷歌的

123

你好啊,陌生人!

我的朋友,看起来你是新来的,如果想参与到讨论中,点击下面的按钮!

📈用户数目📈

目前论坛共有43680位seeker

🎉欢迎新用户🎉