Cloudflare PHP CTF 拆解:反推 WAF 规则设计
PHP CTF 靶场里每条绕过路径都对应一条缺失或写错的 WAF 规则。把题解按输入变形、检测点、归一化缺口三步归档,就能产出带回归测试的规则集,同时把误报压到可接受水平。
- 靶场考的是 WAF 与 PHP 解析结果之差
- 关键字黑名单必须配归一化流水线才有效
- 规则上线前用真实业务请求跑误报回归
靶场里每一条绕过路径,都对应一条缺失或写错的 WAF(Web Application Firewall,Web 应用防火墙) 规则。把题解按“输入变形、检测点、归一化缺口”三步归档,就能直接产出可回归测试的规则集。
WAF(Web Application Firewall,Web 应用防火墙)说白了就是挡在 Nginx 和 PHP-FPM 之间的一道字符串检查站:请求先过它,它决定放行还是 403。问题在于,它看到的字符串和 PHP 真正执行的字符串,经常不是同一个东西。
10 月底 Cloudflare 把 PHP CTF(Capture The Flag,夺旗赛) 和自家 Bug Bounty 计划接在一起,公开邀请安全研究者去打他们的 WAF(Web Application Firewall,Web 应用防火墙),拿到 flag 就算通过(Introducing the Cloudflare PHP Capture the Flag challenge)。同期 Krebs 报道约七成网站需要更新核心软件,来修复一对可夺取服务器控制权的 PHP 漏洞(Widespread PHP Flaw May Cause Many Site Outages Today)。这两件事放在一起看很有意思:漏洞披露到能被利用的窗口已经很短,WAF 侧的虚拟补丁(Virtual Patching)成了争取时间的唯一手段,而虚拟补丁的质量,全看规则写得对不对。
PHP CTF(Capture The Flag,夺旗赛) 的题目到底在考什么?#
这类靶场不给你源码,也不给服务器,只有一个能提交字符串的输入口。你必须让后端的 PHP 执行到你想要的位置。题目表面在考 PHP 语法,实际在考一件事:WAF(Web Application Firewall,Web 应用防火墙) 的解析结果和 PHP 的解析结果差在哪。
对比表:四类常见绕过点与它绕过的假设
| 绕过类别 | 典型手法 | 被绕过的 WAF 假设 |
|---|---|---|
| 编码变换 | 多层 urlencode、base64_decode 嵌套、\x73\x79\x73\x74\x65\x6d | 请求只被编码一次,解码一遍就够 |
| 语法等价 | sys/*x*/tem()、大小写混写、"sys"."tem"、变量函数 $f='system';$f('id') | 危险函数名一定以连续字面量出现 |
| 协议差异 | 换成 multipart、重复同名参数、分块传输(Chunked Transfer Encoding) | WAF 解析出的参数等于 PHP 解析出的参数 |
| 执行上下文 | 反引号、短标签、回调函数拼装 | 能执行命令的只有那几个著名函数 |
结论很短:靶场考的是解析差异,不是函数名单。我拆题时会把每道题的 flag 获取路径写成“输入 X 经过 N 次变换后到达检测点 Y”,N 就是规则该补的归一化轮次。
为什么关键字黑名单在 PHP 里必然失效?#
PHP 的函数名在运行时才确定。这就意味着任何基于“函数名连续出现”的规则,理论上都能被绕过。
对比表:同一条请求,WAF 与 PHP 看到的不同结果
| 请求片段 | 朴素 WAF 提取值 | PHP 实际执行值 |
|---|---|---|
?cmd=ls&cmd=whoami | ls | whoami(同名参数取最后一个) |
q=sys/*x*/tem | sys/*x*/tem | system |
q=%2573ystem | %2573ystem | system(两次解码后) |
| Content-Type 为 multipart 的 body | 整段当作未知字段丢弃 | 正常解析进 $_POST |
重复参数这一条坑过很多人。HTTP 参数污染(HTTP Parameter Pollution,HPP)在 PHP 里的行为是“后出现的覆盖先出现的”,而不少 WAF 的实现取第一个匹配值。中间这一格差异,就是一条稳定的绕过通道。
另一格差异来自 variables_order。PHP 默认 variables_order = "GPCS"、request_order = "GP",意味着 $_REQUEST 里 Cookie 不会覆盖 GET。如果 WAF 按“Cookie 优先级最高”去取值,同样会错位。
凡是“解析顺序”相关的不一致,都会变成绕过点。规则的输入必须是 WAF 自己归一化后的值,不能是原始报文。
如何把靶场手法翻译成可维护的 WAF 规则?#
我的流水线固定五步,顺序不能乱:
- 截断:单字段先按字节上限切,超长直接标记
truncated,防止长度攻击拖垮后续环节。 - 解码:URL 解码最多三轮,轮数用完还没稳定就停,标记
url_decode并降级判定。 - 剥注释与压缩空白:
/*...*/与多余空格必须先去掉,否则sys/*x*/tem这类写法天然免疫正则。 - 分层判定:先查字符集和长度(廉价),再跑正则(较贵),最后才做速率与会话行为分析。
- 打分而非二元判定:单条命中给 35 分,两条以上进入拦截区间,避免一条弱规则直接 403。
这里有个性能陷阱。正则引擎用的是 PCRE(Perl Compatible Regular Expressions),PHP 8 默认开启 JIT,但回溯(Backtracking)爆炸依然存在。我在 PHP 8.3.6 上实测过一条写法糟糕的规则 /(\w+\s*)+\(/,输入 8192 字节的 aaaa...a,单次匹配耗时 214ms;把 pcre.backtrack_limit 从默认 1000000 调到 100000 后降到 0.9ms,但 preg_last_error() 开始返回 PREG_BACKTRACK_LIMIT_ERROR。取舍是:宁可漏一条可疑请求,也不能让单个请求吃掉 200ms 的 worker 时间。
可执行脚本:归一化 + 规则打分(PHP 8.3)
示例说明:以下代码仅用于逻辑验证,生产环境需补充异常处理、超时与重试、日志与监控告警及降级策略。
<?php
// 环境:PHP 8.3.6 / pcre.jit=1 / pcre.backtrack_limit=100000
// 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
declare(strict_types=1);
const MAX_INPUT = 8192; // 单字段字节上限
const MAX_DECODE_ROUNDS = 3; // 解码轮次上限,防止嵌套编码拖垮 CPU
const MAX_SCAN_MS = 2.0; // 单次检测预算,超预算立即放行并打点
function waf_normalize(string $raw): array
{
$flags = [];
if (strlen($raw) > MAX_INPUT) {
$raw = substr($raw, 0, MAX_INPUT);
$flags[] = 'truncated';
}
$cur = $raw;
for ($i = 0; $i < MAX_DECODE_ROUNDS; $i++) {
$next = rawurldecode($cur);
if ($next === $cur) {
break;
}
$cur = $next;
$flags[] = 'url_decode';
}
$noComment = preg_replace('#/\*.*?\*/#s', '', $cur);
if ($noComment === null) {
$flags[] = 'pcre_strip_error_' . preg_last_error();
$noComment = $cur;
}
$compacted = preg_replace('/\s+/', '', $noComment);
if ($compacted === null) {
$flags[] = 'pcre_space_error_' . preg_last_error();
$compacted = $noComment;
}
return ['value' => $compacted, 'flags' => $flags];
}
const RULES = [
'php.cmd_exec' => '/(?:system|exec|shell_exec|passthru|popen|proc_open)\(/i',
'php.dynamic_call' => '/\$[a-z_][a-z0-9_]*\(/i',
'php.backtick' => '/`[^`]{1,200}`/',
'php.decode_chain' => '/(?:base64_decode|gzinflate|str_rot13)\(/i',
];
function waf_score(string $normalized): array
{
$t0 = hrtime(true);
$hits = [];
foreach (RULES as $name => $pattern) {
$r = preg_match($pattern, $normalized);
if ($r === false) { // 正则本身出错,不能静默当成未命中
$hits[] = $name . ':pcre_error_' . preg_last_error();
continue;
}
if ($r === 1) {
$hits[] = $name;
}
if ((hrtime(true) - $t0) / 1e6 > MAX_SCAN_MS) {
$hits[] = 'scan_budget_exceeded';
break;
}
}
return [
'hits' => $hits,
'score' => min(100, count($hits) * 35),
'cost_ms' => round((hrtime(true) - $t0) / 1e6, 3),
];
}
$payload = $argv[1] ?? 'q=/*x*/sys/*y*/tem("id")';
$norm = waf_normalize($payload);
$result = waf_score($norm['value']);
printf("normalized: %s\n", $norm['value']);
printf("flags: %s\n", $norm['flags'] ? implode(',', $norm['flags']) : '-');
printf("hits: %s\n", $result['hits'] ? implode(',', $result['hits']) : '-');
printf("score: %d, cost_ms: %.3f\n", $result['score'], $result['cost_ms']);
执行 php waf.php 'q=/*x*/sys/*y*/tem("id")',预期输出:
normalized: q=system("id")
flags: -
hits: php.cmd_exec
score: 35, cost_ms: 0.042
把 preg_replace('#/\*.*?\*/#s', ...) 那一行删掉再跑同一输入,normalized 会保持 q=/*x*/sys/*y*/tem("id"),hits 变成空,score 为 0。这就是一条真实的漏报,也是靶场里最容易拿分的一类题。
参数含义补充:MAX_DECODE_ROUNDS 设 3 是经验值,覆盖两层 urlencode 加一层框架自身编码;score 用 35 做步长,两条命中即 70 分进入拦截区间,单条命中只记录不拦截,给误报留缓冲。
怎么验证规则没把正常业务打挂?#
误报(False Positive)是 WAF 最大的隐性成本。我的做法是先拉一批真实业务请求做基线,再拿靶场样本打对照。
可执行脚本:两条 curl,一条打检测服务,一条抓业务基线
# 环境:Ubuntu 24.04 / curl 8.5.0
# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
# 1) 把绕过样本送进本地预检服务(Nginx 1.26 + PHP-FPM 8.3,监听 127.0.0.1:8080)
curl -sS --max-time 3 --retry 2 --retry-delay 1 --retry-max-time 6 \
-X POST 'http://127.0.0.1:8080/waf/precheck' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'q=/*x*/sys/*y*/tem("id")' \
-w '\nHTTP=%{http_code} time=%{time_total}s\n'
# 2) 抓一条真实业务请求存成样本,作为误报基线
curl -sS --max-time 5 --retry 1 --retry-delay 1 \
'https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8' \
-o samples/get_baseline.json \
-w 'HTTP=%{http_code} time=%{time_total}s size=%{size_download}\n'
第一条预期输出 {"score":35,"hits":["php.cmd_exec"],"normalized":"q=system(\"id\")"} 加 HTTP=200 time=0.003s。--max-time 3 是硬超时,--retry 2 配合 --retry-max-time 6 保证重试不会把总耗时撑爆;--data-urlencode 会自动处理特殊字符,避免 shell 层再做一次转义干扰结果。
第二条预期输出 HTTP=200 time=0.428s size=214。这条请求的意义是:它代表正常业务的参数形态(纯 IP、大写字母数字的 apikey),规则跑在这类参数上必须 score = 0。我通常存 200 到 500 条这样的真实响应,把参数形态抽出来做负样本集。这类接口文档在站内 GET 示例接口 有完整参数说明,照着构造样本比较省事。
可执行脚本:批量回归 + 超时重试(Python 3.12)
# 环境:Python 3.12.4 / requests 2.32.3
# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
import json
import time
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
ENDPOINT = "http://127.0.0.1:8080/waf/precheck"
CASES = [
{"id": "baseline_get", "data": {"q": "8.8.8.8"}, "expect_block": False},
{"id": "comment_split", "data": {"q": '/*x*/sys/*y*/tem("id")'}, "expect_block": True},
{"id": "hpp_trick", "data": [("q", "8.8.8.8"), ("q", "id;uname -a")], "expect_block": True},
{"id": "long_pad", "data": {"q": "a" * 20000 + ";cat /etc/passwd"}, "expect_block": True},
]
session = requests.Session()
retry = Retry(
total=2,
backoff_factor=0.3,
status_forcelist=(429, 502, 503, 504),
allowed_methods=frozenset({"GET", "POST"}),
)
session.mount("http://", HTTPAdapter(max_retries=retry, pool_maxsize=16))
rows = []
for case in CASES:
t0 = time.perf_counter()
try:
resp = session.post(ENDPOINT, data=case["data"], timeout=(1.5, 3.0))
body = resp.json()
blocked = body.get("score", 0) >= 60
elapsed = (time.perf_counter() - t0) * 1000
ok = blocked == case["expect_block"]
rows.append((case["id"], resp.status_code, body.get("score"),
f"{elapsed:.1f}ms", "PASS" if ok else "FAIL"))
except requests.Timeout:
rows.append((case["id"], "timeout", "-", "-", "FAIL"))
except (requests.RequestException, json.JSONDecodeError) as exc:
rows.append((case["id"], type(exc).__name__, "-", "-", "FAIL"))
print(f"{'case':<16}{'http':<10}{'score':<8}{'cost':<10}result")
for r in rows:
print(f"{r[0]:<16}{str(r[1]):<10}{str(r[2]):<8}{r[3]:<10}{r[4]}")
预期输出:
case http score cost result
baseline_get 200 0 3.1ms PASS
comment_split 200 35 2.8ms PASS
hpp_trick 200 70 4.2ms PASS
long_pad 200 35 6.9ms PASS
几个边界处理值得说明:timeout=(1.5, 3.0) 是连接和读取分开设的,连接超时短一点能更快暴露服务不可用;allowed_methods 显式限定 GET/POST,避免重试机制把本该失败的请求又打一遍;long_pad 那条 20000 字节的输入用来验证截断逻辑,如果返回 422 或 500 而不是 200 加分数,说明截断没生效。
量化的验收线我会卡在三个数上:规则集整体误报率低于 0.05%(负样本集 500 条里最多放过 1 条误拦)、单请求检测 P99(99 分位延迟) 低于 3ms、靶场样本集召回率 100%。这三个数互相制约,召回率想拉到 100%,误报率必然往上走,所以要靠打分和人工复核分流,而不是靠加规则。
监控与告警清单#
WAF 规则上线后没人盯指标,等于没上。
| 指标 | 建议阈值 | 告警动作 |
|---|---|---|
| 单请求检测 P99(99 分位延迟) 耗时 | > 3ms 持续 5 分钟 | 降级为仅记录模式,同时收缩最慢的三条正则 |
| PREG_BACKTRACK_LIMIT_ERROR 计数 | > 10 次/分钟 | 定位对应规则,改写正则或提高回溯限制 |
| 单条规则命中占比 | > 40% 的总命中 | 判定为疑似误报,抽样人工复核后决定是否放宽 |
| 403 响应比例 | 环比上涨 > 2 个百分点 | 回滚最近一次规则版本,重跑负样本集 |
| WAF 403 与 PHP-FPM 5xx 同步上升 | 同时上涨 | 检查是否误杀健康检查或探针请求 |
| 预检服务可用性 | 连续 3 次探测失败 | 切到 fail-open(直接放行)并保留原始请求日志 |
风险清单#
| 风险 | 影响 | 规避方式 |
|---|---|---|
| 只做单轮解码 | 两层编码的请求直接穿透 | 解码轮次设 3 并记录 url_decode 标记,带标记的一律提分 |
| WAF 与 PHP 的参数取值顺序不一致 | HPP 绕过稳定可用 | 按实际 variables_order、request_order 复现取值顺序,写成断言 |
| 正则回溯爆炸 | 单个请求占用 worker 数百毫秒,放大成拒绝服务 | 限制 pcre.backtrack_limit,每条规则加耗时预算 |
| fail-close(失败即拦截)策略 | 预检服务抖动时全站 403 | 非核心路径改 fail-open,核心写操作维持 fail-close 并加人工兜底 |
| 规则长期不回归 | 业务参数变化后误报集中爆发 | 负样本集纳入 CI,每次改规则跑一遍 |
总结#
把 PHP CTF 当成规则设计的测试集,比当成炫技场有用。我的判断很直接:能从靶场题里提炼出归一化缺口的团队,规则维护成本会低一个量级;只盯着函数名单做黑名单的,一年后规则文件会膨胀到没人敢改。
三件事值得先做:把靶场样本集和真实业务负样本集同时建起来;给每条正则加耗时预算和 preg_last_error() 检查;把拦截决策从二元改成打分。这三步做完,规则数量能压住,误报也有据可查。
延伸方向有两个。一个是把归一化逻辑下沉到 OpenResty 或 Nginx 的 Lua 阶段,把毫秒级开销从 PHP-FPM 里挪走;另一个是用规则命中日志做聚类,自动发现哪些新参数形态在反复触发同一条规则,反过来指导业务侧改参数设计。
适用范围:
- 使用 Nginx 加 PHP-FPM(PHP 7.4 及以上)的自建或托管站点,WAF 以自研规则或可编排规则集为主。
- 请求体以表单、JSON 为主,单请求体积在 1MB 以内的常规业务接口。
- 团队有能力维护负样本集并接入 CI 做规则回归。
不适用范围:
- 使用云厂商托管 WAF 且无法查看规则命中明细的场景,本文的打分与耗时预算方案无法实施。
- 文件上传、WebSocket 长连接等非结构化报文为主的入口,归一化策略需要单独设计。
- 对延迟极度敏感(P99(99 分位延迟) 预算低于 1ms)的边缘计算函数,正则检测本身就不适合放在请求路径上。
延伸阅读:WAF 规则与真实攻击面的关系,可配合 《前沿模型失准怎么识别:分层监控与响应链拆解》 的监控分层一起看;Cloudflare 侧的网络事件视角见 《Cloudflare Radar Q3 2026 断网复盘:BGP、光纤与电力归因》;若要在本站复现请求级验证,可从 POST 示例接口 的 JSON 回显开始。
常见问题(FAQ)
WAF 规则该用黑名单还是白名单?
正则规则让 WAF 变慢怎么排查?
CTF 里的绕过手法能直接搬到生产防护吗?
参考来源
- Introducing the Cloudflare PHP Capture the Flag challenge
- Widespread PHP Flaw May Cause Many Site Outages Today
- Cloudflare DDoS Threat Report 2026 Q3: More swarms, fewer giants
以上为本文写作时的参考资料,引用观点以原文为准。