Nginx 配置抵御 DDOS 或 CC 攻击
防攻击的思路我们都明白,比如限制 IP 啊,过滤攻击字符串啊,识别攻击指纹啦。可是要如何去实现它呢?用守护脚本吗?用 PHP 在外面包一层过滤?还是直接加防火墙吗?这些都是防御手段。不过本文将要介绍的是直接通过 nginx 的普通模块和配置文件的组合来达到一定的防御效果。
验证浏览器行为
简易版
下面就是 nginx 的配置文件写法。
if ($cookie_say != "hbnl"){
add_header Set-Cookie "say=hbnl";
rewrite .* "$scheme://$host$uri" redirect;
}
让我们看下这几行的意思,当 cookie 中 say 为空时,给一个设置 cookie say 为 hbnl 的 302 重定向包,如果访问者能够在第二个包中携带上 cookie 值,那么就能正常访问网站了,如果不能的话,那他永远活在了 302 中。你也可以测试一下,用 CC 攻击器或者 webbench 或者直接 curl 发包做测试,他们都活在了 302 世界中。
当然,这么简单就能防住了?当然没有那么简单。
增强版
仔细的你一定会发现配置文件这样写还是有缺陷。如果攻击者设置 cookie 为 say=hbnl(CC 攻击器上就可以这么设置),那么这个防御就形同虚设了。
然后,我们来看下这种方式的配置文件写法
if ($cookie_say != "hbnl$remote_addr"){
add_header Set-Cookie "say=hbnl$remote_addr";
rewrite .* "$scheme://$host$uri" redirect;
}
这样的写法和前面的区别是,不同 IP 的请求 cookie 值是不一样的,比如 IP 是 1.2.3.4,那么需要设置的 cookie 是 say=hbnl1.2.3.4。于是攻击者便无法通过设置一样的 cookie(比如 CC 攻击器)来绕过这种限制。你可以继续用 CC 攻击器来测试下,你会发现 CC 攻击器打出的流量已经全部进入 302 世界中。
不过大家也能感觉到,这似乎也不是一个万全之计,因为攻击者如果研究了网站的机制之后,总有办法测出并预先伪造 cookie 值的设置方法。因为我们做差异化的数据源正是他们本身的一些信息(IP、user agent 等)。攻击者花点时间也是可以做出专门针对网站的攻击脚本的。
完美版
那么要如何根据他们自身的信息得出他们又得出他们算不出的数值?
我想,聪明的你一定已经猜到了,用 salt 加散列。比如 md5("opencdn$remote_addr"),虽然攻击者知道可以自己 IP,但是他无法得知如何用他的 IP 来计算出这个散列,因为他是逆不出这个散列的。当然,如果你不放心的话,怕 cmd5.com 万一能查出来的话,可以加一些特殊字符,然后多散几次。
很可惜,nginx 默认是无法进行字符串散列的,于是我们借助 nginx_lua 模块来进行实现。
rewrite_by_lua '
local say = ngx.md5("opencdn" .. ngx.var.remote_addr)
if (ngx.var.cookie_say ~= say) then
ngx.header["Set-Cookie"] = "say=" .. say
return ngx.redirect(ngx.var.scheme .. "://" .. ngx.var.host .. ngx.var.uri)
end
';
通过这样的配置,攻击者便无法事先计算这个 cookie 中的 say 值,于是攻击流量(代理型 CC 和低级发包型 CC)便在 302 地狱无法自拔了。
大家可以看到,除了借用了 md5 这个函数外,其他的逻辑和上面的写法是一模一样的。因此如果可以的话,你完全可以安装一个 nginx 的计算散列的第三方模块来完成,可能效率会更高一些。
这段配置是可以被放在任意的 location 里面,如果你的网站有对外提供 API 功能的话,建议 API 一定不能加入这段,因为 API 的调用也是没有浏览器行为的,会被当做攻击流量处理。并且,有些弱一点爬虫也会陷在 302 之中,这个需要注意。
同时,如果你觉得 set-cookie 这个动作似乎攻击者也有可能通过解析字符串模拟出来的话,你可以把上述的通过 header 来设置 cookie 的操作,变成通过高端大气的 js 完成,发回一个含有 doument.cookie=...的文本即可。
那么,攻击是不是完全被挡住了呢?只能说那些低级的攻击已经被挡住而来,如果攻击者必须花很大代价给每个攻击器加上 webkit 模块来解析 js 和执行 set-cookie 才行,那么他也是可以逃脱 302 地狱的,在 nginx 看来,确实攻击流量和普通浏览流量是一样的。那么如何防御呢?下节会告诉你答案。
请求频率限制
不得不说,很多防 CC 的措施是直接在请求频率上做限制来实现的,但是,很多都存在着一定的问题。
那么是哪些问题呢?
首先,如果通过 IP 来限制请求频率,容易导致一些误杀,比如我一个地方出口 IP 就那么几个,而访问者一多的话,请求频率很容易到上限,那么那个地方的用户就都访问不了你的网站了。
于是你会说,我用 SESSION 来限制就有这个问题了。嗯,你的 SESSION 为攻击者敞开了一道大门。为什么呢?看了上文的你可能已经大致知道了,因为就像那个“红包拿来”的扬声器一样,很多语言或者框架中的 SESSION 是能够伪造的。以 PHP 为例,你可以在浏览器中的 cookie 看到 PHPSESSIONID,这个 ID 不同的话,session 也就不同了,然后如果你杜撰一个 PHPSESSIONID 过去的话,你会发现,服务器也认可了这个 ID,为这个 ID 初始化了一个会话。那么,攻击者只需要每次发完包就构造一个新的 SESSIONID 就可以很轻松地躲过这种在 session 上的请求次数限制。
那么我们要如何来做这个请求频率的限制呢?
首先,我们先要一个攻击者无法杜撰的 sessionID,一种方式是用个池子记录下每次给出的 ID,然后在请求来的时候进行查询,如果没有的话,就拒绝请求。这种方式我们不推荐,首先一个网站已经有了 session 池,这样再做个无疑有些浪费,而且还需要进行池中的遍历比较查询,太消耗性能。我们希望的是一种可以无状态性的 sessionID,可以吗?可以的。
rewrite_by_lua '
local random = ngx.var.cookie_random
if(random == nil) then
random = math.random(999999)
end
local token = ngx.md5("opencdn" .. ngx.var.remote_addr .. random)
if (ngx.var.cookie_token ~= token) then
ngx.header["Set-Cookie"] = {"token=" .. token, "random=" .. random}
return ngx.redirect(ngx.var.scheme .. "://" .. ngx.var.host .. ngx.var.uri)
end
';
大家是不是觉得好像有些眼熟?是的,这个就是上节的完美版的配置再加个随机数,为的是让同一个 IP 的用户也能有不同的 token。同样的,只要有 nginx 的第三方模块提供散列和随机数功能,这个配置也可以不用 lua 直接用纯配置文件完成。
有了这个 token 之后,相当于每个访客有一个无法伪造的并且独一无二的 token,这种情况下,进行请求限制才有意义。
由于有了 token 做铺垫,我们可以不做什么白名单、黑名单,直接通过 limit 模块来完成。
http{
...
limit_req_zone $cookie_token zone=session_limit:3m rate=1r/s;
}
然后我们只需要在上面的 token 配置后面中加入
limit_req zone=session_limit burst=5;
于是,又是两行配置便让 nginx 在 session 层解决了请求频率的限制。不过似乎还是有缺陷,因为攻击者可以通过一直获取 token 来突破请求频率限制,如果能限制一个 IP 获取 token 的频率就更完美了。可以做到吗?可以。同时,我们也要感谢一下 Tengine,Tengine 的 limit 模块可以支持多个变量。本文的所有的实验均在 Tengine 下完成。
http{
...
limit_req_zone $cookie_token zone=session_limit:3m rate=1r/s;
limit_req_zone $binary_remote_addr $uri zone=auth_limit:3m rate=1r/m;
}
location /{
limit_req zone=session_limit burst=5;
rewrite_by_lua '
local random = ngx.var.cookie_random
if (random == nil) then
return ngx.redirect("/auth?url=" .. ngx.var.request_uri)
end
local token = ngx.md5("opencdn" .. ngx.var.remote_addr .. random)
if (ngx.var.cookie_token ~= token) then
return ngx.redirect("/auth?url=".. ngx.var.request_uri)
end
';
}
location /auth {
limit_req zone=auth_limit burst=1;
if ($arg_url = "") {
return 403;
}
access_by_lua '
local random = math.random(9999)
local token = ngx.md5("opencdn" .. ngx.var.remote_addr .. random)
if (ngx.var.cookie_token ~= token) then
ngx.header["Set-Cookie"] = {"token=" .. token, "random=" .. random}
return ngx.redirect(ngx.var.arg_url)
end
';
}
我想大家也应该已经猜到,这段配置文件的原理就是:把本来的发 token 的功能分离到一个 auth 页面,然后用 limit 对这个 auth 页面进行频率限制即可。这边的频率是 1 个 IP 每分钟授权 1 个 token。当然,这个数量可以根据业务需要进行调整。
需要注意的是,这个 auth 部分我 lua 采用的是 access_by_lua,原因在于 limit 模块是在 rewrite 阶段后执行的,如果在 rewrite 阶段 302 的话,limit 将会失效。因此,这段 lua 配置我不能保证可以用原生的配置文件实现,因为不知道如何用配置文件在 rewrite 阶段后进行 302 跳转,也求大牛能够指点一下啊。
当然,你如果还不满足于这种限制的话,想要做到某个 IP 如果一天到达上限超过几次之后就直接封 IP 的话,也是可以的,你可以用类似的思路再做个错误页面,然后到达上限之后不返回 503 而是跳转到那个错误页面,然后错误页面也做个请求次数限制,比如每天只能访问 100 次,那么当超过报错超过 100 次(请求错误页面 100 次)之后,那天这个 IP 就不能再访问这个网站了。
于是,通过这些配置我们便实现了一个网站访问频率限制。不过,这样的配置也不是说可以完全防止了攻击,只能说让攻击者的成本变高,让网站的扛攻击能力变强,当然,前提是 nginx 能够扛得住这些流量,然后带宽不被堵死。如果你家门被堵了,你还想开门营业,那真心没有办法了。
然后,做完流量上的防护,让我们来看看对于扫描器之类的攻击的防御。
防扫描
ngx_lua_waf 模块: https://github.com/loveshell/ngx_lua_waf
这个是一个不错的 waf 模块,这块我们也无需重复造轮子。可以直接用这个模块来做防护,当然也完全可以再配合 limit 模块,用上文的思路来做到一个封 IP 或者封 session 的效果。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于