新媒体站点通常跑在Nginx、OpenResty或者Apache这类Web服务器上,CC攻击的本质不是打带宽,而是用海量看似正常的请求耗尽后端PHP、MySQL和Redis连接池。攻击者往往瞄准搜索接口、评论提交、用户登录、API回调这类高消耗动态资源,一台普通VPS在每秒几百个恶意请求下就会开始出现502、504或者数据库连接超时。所以防护的第一步不是上高防,而是把“正常流量”和“脚本流量”在到达业务逻辑之前就区分开。

先看一个最典型的场景:站点突然涌入大量请求某个带参数的列表页,比如 /api/search?keyword=xxx,每个请求都触发一次MySQL全文检索。攻击者不断变换keyword参数,缓存无法命中,数据库CPU瞬间打满。此时单纯限制单IP频率往往不够,因为攻击源可能是几千个代理IP。真正有效的做法是结合请求特征、行为频率和业务逻辑做分层拦截。

第一层:Nginx动态令牌与请求速率基线

Nginx自带的limit_req模块只能按IP限速,对分布式CC效果有限。更实用的方案是在Nginx层引入lua-resty-limit-traffic库,针对每个URI和UA组合建立动态计数器。具体逻辑是:先统计过去10秒内同一URI下不同IP的请求分布,如果某个URI的请求量突然超过基线5倍,就对该URI启用全局令牌桶,所有访问该URI的请求必须携带服务端下发的动态token,否则直接返回444。

下面这段OpenResty配置展示了如何实现URI级别的异常检测和令牌校验:

http {
    lua_shared_dict cc_uri_stats 20m;
    lua_shared_dict cc_token_bucket 20m;

    server {
        listen 80;
        
        location /api/ {
            access_by_lua_block {
                local uri = ngx.var.uri
                local key = "uri_req:" .. uri .. ":" .. ngx.now() // 10
                local count = ngx.shared.cc_uri_stats:incr(key, 1, 10)
                
                if count and count > 500 then
                    -- URI进入异常状态,要求token
                    local token = ngx.var.arg_token
                    if not token or token == "" then
                        ngx.exit(444)
                    end
                    
                    local valid = ngx.shared.cc_token_bucket:get("valid_token:" .. token)
                    if not valid then
                        ngx.exit(444)
                    end
                end
            }
            
            proxy_pass http://backend;
        }
    }
}

这个方案的关键在于“异常状态”是动态触发的。平时正常用户访问完全无感,一旦某个接口被集中攻击,Nginx自动切换到令牌校验模式,攻击流量在边缘节点就被丢弃,根本到不了PHP进程。令牌可以通过正常页面加载时由后端下发,有效期控制在30秒到2分钟,攻击者即使抓到页面也无法批量伪造。

第二层:浏览器行为验证与无感挑战

很多CC工具是纯HTTP客户端,不会执行JavaScript,也不会维护Cookie会话。利用这一点,可以在Nginx层对疑似攻击流量返回一个带有JS挑战的中间页。这个挑战不是传统的验证码,而是让客户端执行一段简单的数学运算或者时间戳签名,然后自动重定向回原URL并带上计算结果。

具体实现可以用Nginx的error_page配合Lua生成挑战页。当某个IP或URI触发风控阈值后,Nginx返回503,同时设置一个特殊的Cookie标记。挑战页里的JS会读取服务端下发的nonce值,计算SHA256后作为参数重新请求。Nginx在access阶段验证这个签名,通过则放行并设置白名单Cookie,后续请求不再挑战。

这里给出一个精简的挑战页生成逻辑:

local nonce = ngx.md5(ngx.var.remote_addr .. ngx.now())
ngx.header["Set-Cookie"] = "cc_challenge=" .. nonce .. "; Path=/; Max-Age=300"
ngx.say([[

安全验证




]])

验证逻辑放在Nginx access阶段,检查cc_verify参数是否等于nonce的字符编码累加结果。这种方案对真实浏览器用户几乎无感,延迟不超过1秒,但能拦截绝大多数基于curl、Python requests或者简单脚本的CC工具。需要注意的是,搜索引擎爬虫可能不会执行JS,所以要对已知的爬虫UA做白名单处理,避免误伤收录。

第三层:Redis动态黑名单与全局联防

单台Nginx的防护能力有限,当攻击流量分散在多个子域名或者多台后端时,需要把风控状态同步到Redis。具体做法是:每台Nginx节点在检测到某个IP或UA在短时间内触发多次风控事件后,将特征写入Redis的Sorted Set,并设置过期时间。其他节点在收到请求时先查Redis,命中黑名单的直接拒绝。

这里的关键不是简单按IP封禁,而是按“IP+UA+URI”组合特征封禁。因为很多攻击者使用代理池,单个IP可能只发几十个请求,但UA和URI高度一致。通过组合特征,可以在攻击发起后的10秒内全网联动封禁,而不需要等单个IP累积到阈值。

一个实用的Redis黑名单写入逻辑如下:

local redis = require "resty.redis"
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
    return
end

local feature = ngx.var.remote_addr .. "|" .. ngx.var.http_user_agent .. "|" .. ngx.var.uri
local key = "cc_blacklist:" .. ngx.md5(feature)
red:zadd("cc_blacklist_scores", ngx.now(), key)
red:expire(key, 300)

查询阶段用zscore判断特征是否在黑名单内,如果分数存在且未过期,直接返回403。这种方案的优点是封禁粒度细,误伤率低,而且Redis的读写性能足够支撑每秒数万次查询。对于大型新媒体站点,还可以把Redis换成集群模式,进一步分散压力。

第四层:后端业务限流与降级开关

即使Nginx层做了大量拦截,仍然会有部分攻击流量穿透到PHP应用。此时必须在应用层做最后一道防线。常见的做法是在框架中间件里实现基于内存或Redis的接口级限流,同时对高消耗操作做降级处理。

例如搜索接口在检测到请求量超过正常阈值时,自动切换为只返回缓存结果,不再实时查询数据库。评论提交接口在风控触发后要求用户先完成手机验证码或者拖动滑块。登录接口在连续失败后强制要求图形验证码,并记录设备指纹。

一个典型的PHP限流中间件示例:

class RateLimitMiddleware {
    public function handle($request, $next) {
        $key = 'rate:' . $request->ip() . ':' . $request->path();
        $count = Redis::incr($key);
        Redis::expire($key, 60);
        
        if ($count > 30) {
            // 触发降级
            if ($request->is('api/search')) {
                return response()->json([
                    'data' => Cache::get('search_fallback', []),
                    'degraded' => true
                ]);
            }
            return response('请求过于频繁', 429);
        }
        
        return $next($request);
    }
}

降级策略要提前设计好,不能等攻击来了才临时改代码。每个高消耗接口都应该有一个对应的降级路径,比如搜索降级到热门词缓存,列表页降级到静态快照,用户中心降级到只读模式。这样即使攻击穿透了前面几层,业务也不会完全瘫痪。

第五层:日志分析与攻击溯源

防护做完之后,还需要持续分析Nginx访问日志,找出攻击特征并更新规则。建议把Nginx日志格式改为JSON,方便用jq或者ELK做聚合分析。重点关注的字段包括:请求URI、状态码、响应时间、UA、Referer、请求体大小。

一个实用的日志分析命令可以快速找出被集中攻击的URI:

cat access.log | jq -r '.uri' | sort | uniq -c | sort -rn | head -20

再结合状态码分布,如果某个URI的499或502比例突然升高,说明该接口正在被CC攻击或者后端已经过载。此时可以针对性地在Nginx层增加临时规则,比如对该URI启用更严格的限速,或者直接返回静态占位内容。

另外要注意的是,新媒体站点经常使用CDN,攻击流量可能被CDN节点分散,源站看到的请求IP都是CDN回源IP。这种情况下必须正确配置CDN的真实IP传递头,比如X-Forwarded-For或者X-Real-IP,并在Nginx里用real_ip模块还原客户端真实IP,否则所有基于IP的限速和封禁都会失效。

实操中的几个关键细节

第一,动态token的生成和校验要避免使用可预测的算法。不要简单用时间戳+固定密钥,攻击者抓包后可以逆向出规律。建议使用HMAC-SHA256,密钥定期轮换,token里包含过期时间和客户端IP的哈希。

第二,JS挑战页要控制跳出率。如果挑战逻辑过于复杂或者加载慢,真实用户会直接关掉页面。挑战页应该极简,只包含必要的JS,不加载任何外部资源,执行时间控制在200毫秒以内。

第三,Redis黑名单要设置合理的过期时间。太短会导致攻击者反复触发风控,太长可能误伤使用动态IP的移动用户。一般建议5到15分钟,根据实际攻击模式调整。

第四,所有风控规则都要有监控和告警。可以在Nginx里统计被拦截的请求数量,定期上报到监控系统。如果拦截量突然暴增,说明正在遭受大规模攻击,需要人工介入分析。

第五,不要把CC防护做成“一刀切”的硬限速。新媒体站点的流量波动很大,一篇爆款文章可能带来几十倍于平时的正常访问。风控阈值要基于历史基线动态调整,而不是写死一个固定值。可以用滑动窗口算法计算过去30分钟的平均请求量,超过平均值3倍才触发风控。

把这些层次组合起来,形成一套从边缘到应用、从实时拦截到事后分析的完整防护体系。单独任何一层都不足以对抗复杂的CC攻击,但多层叠加后,攻击成本会大幅上升,绝大多数脚本小子和低端攻击工具会在前两层就被过滤掉。对于新媒体站点来说,这套方案不需要购买昂贵的高防IP,用现有的Nginx和Redis就能实现,成本低且效果显著。