WAF 开发实战
背景 #
爬虫越来越多,峰值流量越来越高,非标应用难以维护等,对于 WAF 产生了挑战。通过 WAF 重构,减轻历史负担,承担更多安全场景
需求 #
- 性能上提升
- 能够更好地处理大流量场景
- 更加合理标准化的架构方案
- 函数级别的灰度控制能力
设计 #
本身谈不上大的改动或者设计,因为 WAF 升级过程中需要同时兼容两个版本,没有做很大变更,大致如下。原先 WAF 大致如下
实际遇到的问题 #
Redis
Redis 负责规则/开关的存储,由 WAF-admin 控制同步
Nginx-Lua 部分需要和 Redis/WAF-admin 通讯(storm 为 filebeat 同步计算)。而问题则是 Redis 因为历史原因通过 IP 连接单库,虽然有从库,仍然有风险造成类似单点故障的问题。
Storm
Storm 为计频,满足实时计算的封禁需求
为组内早先自己搭建,完全由自己维护。目前公司内部不再使用 storm 集群,即自维护的集群如果出现了主节点 down 等问题,一时间也无法完全恢复,同样容易造成单点故障问题
WAF-admin
WAF-admin 是主控端,负责 Nginx-Lua 控制,数据对接处理等
完全由 Python 编写,代码中有许多 Hardcode 的地方,无法 CI/CD 走标准发布。在特殊情况没法及时扩容等
Others
一些分布在机器上的定时任务,脚本,用于 WAF 的分析等,没有良好的备份/标准文档等
改动 #
图中的 Redis 被移除,WAF-admin 重构为 Java(标准化,可以 CI/CD),Mysql 不再自己维护,Storm 全部重写为 Flink 并且满足新的需求
具体细节 #
WAF 开发的具体笔记,仅记录重要部分
Nginx-Lua #
公司的 WAF 直接以 Lua 的形式部署在 LB 之后的 Nginx 集群。通过伪代码的形式展示存在的问题和修复
错误的函数使用 #
入职接手之后,碰到过一个线上的问题。有一个接口,单独使用 ip + url 的形式无法封禁。经过排查,是一个很小的点。在 Nginx 中,某个 Server 配置如下
server {
...
server_name xxx.test.com xxx1.test.com;
...
}
而当时封禁函数使用的地址获取方式为
ngx.var.server_name .. ngx.var.uri
其中 ngx.var.server_name
默认获取 server_name 的第一个,早在 2015 年就有人提过这个问题。ngx.var.server_name
本身使用目的不是如此,而应该使用内置的 ngx.var.host
来获取
重复的字符操作 #
在老版本的 Lua 中,经常能看到一些字符串的重复操作。常见的是:从 nginx 共享内存中读取规则,并且解析。伪代码如下
function demo_check()
local config = rules:get("demo")
for _, d in pairs(split(config), "!!!") do
...
这样的代码几乎出现在每个函数中,穿插在对应的 access 流程里。假设我们有 10 个 check 函数,那么每次请求我们都需要做 10 次无效的字符串 split 操作。在后续的对比里,这段 split 所占的大约为 15 ~ 20%
如何解决这个问题? 很尴尬的是,这个 WAF 代码原先并无引入 worker,没有缓存的功能。大致为,在 nginx 的 init_worker 阶段引入 worker 文件,做秒级的规则 pull 和解析。代码也很简单,写好函数,定时调用即可
Segfault
当我开心的写完,引入这个文件之后,我们发现在一些测试机器上会出现 segment fault 的问题,我们 dump 下这个文件,稍微看了一下。
gdb <nginx> core
> (gdb) bt
`text`
#0 ... in ngx_http_core_create_srv_conf()
#1 ... in ngx_http_lua_init_worker()
#2 ... in ngx_worker_process_init()
...
大致可以看出跟我们引入的 init_worker 文件相关,函数定位在 ngx_http_core_create_srv_conf
。通过查询,我们看到有一个类似 issue。大致原因为:当 nginx 版本 >= 1.15.0 且 lua-nginx-module 组件 <= 0.10.14 时会出现这个问题。
这种往往是在后期测试时发现,能搜到的只有官方的特殊 issue。同时对于 WAF 这种影响面很大的,上线发布等要有合理的测试、灰度等发现这类比较隐蔽的问题
多次字符拼接
在原先 Log 日志打印部分,能看到类似以下代码
local log = clientip .. " [" .. time .. " ]" .. method .. " " .. ngx_method .. " " ......
有类似超 9 个字符串连续拼接的场景。在 Lua 中每次 .. 拼接意味着开辟新空间,产生拷贝。这种多次拼接的长字符串拼接,对性能会有较大的影响,会有频繁 GC 的风险。在新 WAF 中改为通过 table concat 的方式做拼接。
concat 函数本身不会有频繁申请拷贝的操作,而是当写满一个定长的 BUFFER 之后,才会生成一个 TString 做一次内存合并。在其他的一些简单场景,如单次使用 .. 进行拼接,则无需做修改。
多维度限流
之前的限流完全依赖两个,一个是实时处理(Storm 根据日志计频),但是往往会有小的延时(例如 10 秒);另一个就是单机器上的 URL 限流(兜底)。我们需要一个更为灵活的多维度限流
调研之后,我们需要一种支持任意 Header 字段,Cookie 字段的限流,来满足一些活动场景下,大流量代拍、恶意刷取的场景。限流部分可以使用 lua-resty-limit-traffic,这种使用类似于匀速器,即如果设定 1 秒内限定 rate 100,则任意的 0.1 秒都是均匀的。由于引入组件还需要做稳定性测试,延长了整个项目的周期,所以决定直接用共享内存实现
伪代码如下:
function advanced_cc_check(context)
...
if advanced_cc_check == "on" then
cache:flush_expired()
local rule = get_rule("advanced_cc" .. context.uri)
for field, value in pairs(rule) do
if field == "ip" then
local key = "advanced_cc" .. context.uriip
local count = cache:get(key)
local counter = 0;
if count then
counter = cache:incr(key, 1)
else
cache:set(key, 1, 1)
counter = 1
end
if counter > value then
ngx.exit(xxx)
log...
...
elseif field == "cookie" then
...
elseif field == "header" then
...
elseif field == "arg" then
...
其余限流部分可以自己实现,和 IP 部分代码类似
其余
其余则为灰度,版本控制,规则更新等
Flink #
待更新…