













上一篇结尾我说,这套防护链已经复杂到需要一张自己的地图,而复杂本身就是新的风险。这一篇就是那张地图。
它不是一次事件的复盘,是站在一个时间点上往回看,把两个月里因为不同原因加上去的每一层防线画在同一张图里,看它们各自管什么、各自怎么坏,以及层与层之间的缝隙里漏过什么。
系列:paddy-newsprint 主题开发记 · 续篇
前置阅读:做了一个报纸风格的 WordPress Markdown 主题 · 换装之后:75 到 93,三条军规的生产环境成绩单 · 上线不是终点:主题没写错,麻烦都在它脚下
主题分类:架构复盘
阅读时间:约 12 分钟
请求从访客发出到源站的 PHP 开始执行,中间经过的每一道关卡都是后来加的。最初只有源站和防火墙,后来加了 CDN,再后来加了 WAF,再后来在 WAF 上面又加了边缘规则。每一层都不是一开始就规划好的,是出了事才补的。
flowchart LR
A[请求进来] --> B[边缘缓存层]
B -->|未命中| C[应用防火墙层]
C -->|放行| D[网络防火墙层]
D -->|白名单| E[源站]
B -->|命中| F[直接返回]
C -->|拦截| G[4xx 段]
C -->|限速| H[429 → 封禁链路]
D -->|非白名单| I[丢弃]
E -->|过载| J[503 透传]
边缘缓存层管“绝大多数请求根本不该到源站”,应用防火墙层管“到了源站之前的请求是不是恶意的”,网络防火墙层管“谁有资格回源”,源站自己管“拿到请求之后怎么处理”。看上去各司其职,问题出在每一层都有自己安静的失败方式,而失败的时候它不一定告诉你。
这一段上一篇讲过,这里只收口成一条可复用的方法。
出异常时第一件事不是看代码,是认“这是谁拦的”。每一层的拦截都有自己的签名:应用防火墙的攻击拦截走 4xx 段,响应体里带它自己静态资源的路径特征;人机验证和自定义规则是同一段里的另外两个编号;边缘平台的自定义规则拦截走 5xx 段的一个自有编号,响应头里带平台标识和一个日志 UUID;503 是源站过载的透传,不是任何一层“拦”的。
先看状态码落在哪一段,再看响应头和响应体的特征,就能定位到是哪一层在动作。然后只去看那一层的规则和日志,比在几套系统里乱翻快得多。这个方法在上一篇文章里那条通配符事故中救了我一整天的排查时间。
这是这张地图上最重要的标记。不是“可能坏”,是“已经坏过了,而且坏的时候没出声”。
边缘缓存层的失败方式是“该缓存的东西没缓存”。 文章页一开始没进缓存,每次都真实回源,这条攻击路径一直开着。而验证缓存又有一个坑:HEAD 请求永不被缓存,用 curl -I 验证会看到永远 MISS,把一个漂亮的命中误判成“缓存失效”。必须 GET 二连看 Age 有没有递增。这个坑本身不危险,危险的是你以为缓存是好的、实际不是。
应用防火墙的失败方式是“它被调到了观察模式,然后有人忘了关”。
这件事上一篇没展开。换主题之前我做了一次 WAF 规则调整,为了看新规则的行为对不对,把拦截模式切成了观察模式。观察模式的语义是“匹配到了只记录不拦”,等价于这一层临时不存在。然后我忘了关,它开了两天半。
那两天半里来了三起洪水,全部是慢速 GET 页面洪水,正好打在文章页没缓存的窗口上。WAF 在观察模式下全部放行,源站的 PHP 被打穿,503 透传给访客。等到我发现的时候,日志里躺着三天的事件记录,每一行都是“匹配到了,未拦截”。
教训是两条。第一,WAF 规则变更一律先开观察模式跑一段,从日志确认匹配行为再切拦截,这是对的;但切完之后必须在当天回来看,观察模式不是安全状态,是裸奔状态。第二,换主题的窗口前后严禁再开观察模式,因为那几天你在改模板、改路径、改资源 URL,每一项变更都需要 WAF 在拦截状态兜底。
网络防火墙的失败方式是“开机时静默丢掉了启动任务”。 上一篇详细讲过的 systemd 排序环:一个定制服务的排序声明自相矛盾,每次开机必然成环,systemd 解环时丢一个 job,丢谁不固定。这次轮到防火墙被丢,它从开机起就没启动过,但 enabled 状态不变、配置文件里的开关写着 yes、失败列表是空的。四种检查项里三项绿、一项红,而那一项红必须靠查防火墙运行状态才看得到。
这个环是存量缺陷,不是某次升级引入的。平时大概率一直在吞另一个服务(那个服务在已初始化的服务器上近乎无害),偶尔轮到防火墙才被抓到。修复是删掉那条排序声明,但那个服务包一旦更新就得重新核一遍。
封禁链路的失败方式是“那个本该盯着它的哨兵没在跑”。 上一篇文章里白名单漏了第 196 条 CIDR,那个缺口的最佳观测点是自检哨兵,它专门检测“有没有请求来自白名单之外的 IP”。但它的定时任务在快照回滚中丢了,没人发现。原因是哨兵只写异常行不写汇总行,于是“没运行”和“运行了且一切正常”在日志里长得一模一样。
四层,四种静默失败。每一种的根因都不同,但有一个共同点:如果不是因为别的症状顺便暴露了它,它会继续安静地待下去。
flowchart LR
EC[边缘缓存层] -->|坏的样子 该缓存的没缓存| CACHECHK[只能靠 GET 二连看 Age]
WAF[应用防火墙层] -->|坏的样子 停在观察模式| WAFCHK[只能靠查当前模式是不是拦截]
FW[网络防火墙层] -->|坏的样子 开机时启动任务被丢掉| FWCHK[只能靠查运行状态和开机日志]
BA[封禁链路] -->|坏的样子 哨兵没在跑| BACHK[只能靠查日志里有没有汇总行]
有些问题不属于任何一层,它落在层与层之间的接缝上。
第一个接缝是“诊断代码和被诊断代码共享同一个假设”。上一篇文章里,白名单漏项的诊断循环用了 while read,和被诊断的加载循环犯了完全相同的跳过末行的错误,于是第一轮结论是“缺失 0 条”。检测方法必须独立于被检测代码的假设,否则同错相消,你什么都看不见。这条原则不只适用于 shell,适用于任何“你写的工具去检查另一个工具的输出”的场景。
第二个接缝是“快照回滚会抹掉跨层的取证证据”。排序环的跨启动证据、白名单哨兵的定时任务、当天做完的几项正确变更,全部在一次回滚中消失。回滚是时间机器不是撤销键,它不区分“要撤销的”和“不要撤销的”。这意味着:如果你的取证证据只存在磁盘上,一次回滚就能把它们全清掉。上一篇文章里我为此丢掉了排序环的全部跨启动考古材料,此后只能靠每次重启后 grep 一次来维持监测。
第三个接缝更隐蔽:每一层各自看都是好的,但叠在一起产生了新的盲区。边缘缓存层在正常工作,所以大部分请求到不了 WAF;WAF 在正常工作,所以到达的请求里恶意的大多被拦了;防火墙在正常工作,所以白名单外的回源被丢弃了。这条链看起来很完整,但它意味着:如果有一个请求类型恰好穿过所有层的“正常”路径,比如一个慢速的、来自合法 IP 的、请求静态路径的、不需要回源的 GET,没有任何一层会觉得它有问题。而慢洪水就是这种请求。
flowchart LR
R[慢速 GET 一个合法路径] --> A[边缘缓存层 未命中 按规则回源]
A --> B[应用防火墙层 请求合法 放行]
B --> C[网络防火墙层 来源在白名单里 放行]
C --> D[源站 PHP 照常执行 直到过载]
D --> E[四层判断都没错 合起来漏掉了这类请求]
WordPress 本身不是全然被动地等着被保护。我在应用层挂了一个安全插件,定位是“应用感知的告警器”,不是 volumetric 防御。
它管的三件事是:登录爆破限制(3 次失败就锁,管理员开二次验证)、404 扫描限制(爬虫和人类分开计数,超限封禁)、请求总量限速(动作用 throttle 而非 block,拖住即可不产生 503)。
它在这一轮里完成了全部四次封禁,但都是在 404 扫描这个场景。慢洪水一个都没抓到,因为慢洪水的请求是合法路径的 GET,应用层看不出它和正常流量的区别。
这个定位要拿准。如果指望它扛 volumetric 攻击,你会把阈值调紧,然后第一个被挡住的是自己。打开媒体库网格时并发拉几十张缩略图,登录 cookie 让缓存把请求全部放回源站,一瞬间超阈值,人机验证粘滞一个小时,媒体库卡死。上一篇文章里我踩过这个坑。应用层防护是最后一道意识,不是第一道城墙。
地图上从边缘缓存到源站 PHP 之间画了四层,源站往里是一片空白。
这片空白不是我忘了画,是我评估之后决定不填。我调研了云厂商提供的一个主机侧排查工具,功能矩阵很清晰:病毒查杀含 webshell 检测、异常登录记录、弱口令扫描、进程和网络连接研判。它恰好补上现有体系对落盘文件的零覆盖——上传目录和插件目录是 webshell 的常见落点,而前面四层全在边界,没有一个看主机里面。
不装的原因有三条。第一,它需要一个常驻客户端,而我的原则是尽量不增加常驻组件;每个常驻的东西都是一个需要维护、可能被更新搞坏、可能在某次重启后不启动的新依赖。第二,它的“隔离”动作对 WordPress 站点太危险,插件和主题里的混淆代码容易误报,点一下隔离就是站点故障。第三,它的定位是触发式应急工具,真出事再装来得及,平时挂着只增加运维面。
这片空白是刻意的。但它是地图上最大的未标记区域,也是我最没底的一块。如果有一天边界层被绕过——不管是因为配置错误还是因为零日漏洞——这片空白就是攻击者唯一需要面对的对手。目前它唯一的覆盖是:每周的例行巡检脚本会查配置层的几项基线,但那不是文件和进程层的检测,只是“配置有没有被改过”。
这是这张地图最后一条标注,也是我在画完它之后最明显的感觉。
每一层单独看都有存在的理由。边缘缓存把绝大多数请求挡在源站之外,WAF 拦住恶意模式,防火墙只放 CDN 回源,封禁链路对高频 IP 自动拉黑,应用层补一个登录爆破和 404 扫描的收口。每一层都是在一次具体的事故之后加的,不是拍脑袋。
但层的数量本身就是风险。每一层都有自己的配置面,每一次变更都可能碰歪某一层;每一层都有自己的静默失败模式,而失败模式之间不互通——缓存层失败看 Age,防火墙失败看 journalctl,封禁链路失败看哨兵有没有汇总行,WAF 失败看它是不是被切到了观察模式。你得记住每一层的体检方法,还得记住每一层“看起来好实际坏”的签名。
然后还有层间的接缝。诊断代码不能共享被诊断代码的假设。快照回滚不区分要留的和要扔的。一层放行的东西下一层不一定拦,而一个精心构造的请求可以穿过所有层的“正常”路径。
两个月前这张地图上只有防火墙一层。现在有四层加一片空白。下次出事的时候,大概率还会再加一层。每一层都让“常规攻击”更难过,但也让“诊断出了什么事”更难了一步。
主机侧的空白是刻意的,但也是未验证的。我评估了那个工具,没装也没测过它在真实场景下的检出率和误报率。“真出事再装来得及”是一个判断,不是一个结论。
封禁链路目前只有“自动封 + 手动解封”。没有自动解封机制,封错了就只能人去放。在一个人维护的站上这没问题,但规模一大就会变成瓶颈。
边缘层的 CC 模块我还没有启用。如果启用,单 IP 的高频请求在边缘就吸收了,零源站压力。但多一层意味着多一套配置要维护,多一种静默失败的可能性。我没有证明“不启用比启用好”,只是现在的洪水规模还到不了需要它的程度。
最后,这张地图本身只覆盖了我这台机器。它不是通用架构,里面的每一层、每一个阈值、每一个失败模式都是在具体的环境里撞出来的。抄不了,只能当参照。
主题现在是 0.10.13,防护链现在是四层加一片空白。主题的代码我一行一行写的,每个函数为什么在那儿我都知道。防护链不是,它是事故堆出来的,每一层都带着一次半夜爬起来的记忆。
地图画完了。下一件事不是再加一层,是回头检查每一层的体检方法是不是都在例行巡检里,以及那张巡检清单本身有没有哨兵。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。