宝塔 Nginx 免费防火墙 8.4 赶紧卸载吧:偷偷上传文件,凭据明文裸奔
声明:本文基于本地源码审计与测试机全新安装后的实测记录整理。感谢大肥鱼(DeepSeek)、克劳德(Claude)、奥特曼(ChatGPT)、北美豆包(Gemini)各路 AI 评审员协助完成交叉代码复核。
起因:一条关于宝塔 Nginx 免费防火墙的留言
这事儿得从上周四下午的一条读者留言说起:
【安全咨询】宝塔 Nginx 免费防火墙是否存在自动上传源码和凭据明文传输的问题?
……
静态检查发现以下行为:
send_baota2()在 WebShell 扫描匹配规则后,可能把完整文件内容上传至http://www.bt.cn/api/panel/btwaf_submit。……
目前仅有静态代码检查结果,没有证据表明存在恶意后门或已经发生实际数据泄露。
其实之前给宝塔 Nginx 免费防火墙 8.3 打补丁的时候,我确实扫到过这段代码,但当时根本没往深处细想,只当是某种云查杀样本库逻辑,加上我日常也不用面板自带的木马查杀,脑子里一下就划过去了。
今天下午看到这条评论,我翻出源码看了一眼这部分的代码逻辑
好家伙,居然是真的!而且确实走的是明文的 HTTP!!
我整个人都惊呆了:不是,这玩意私底下走 HTTP 的地方还有什么?!(我第一个反应:这不就是某输入法明文上传用户输入信息事件的翻版嘛)
简单肉眼过了一下代码,我当场差点从工位上蹦起来:
- 它居然会去读取宝塔的
userInfo.json,把面板绑定的access_key和uid直接走明文 HTTP 发给云端?! - 它在每次打开插件首页时,居然敢尝试通过明文 HTTP 路径拉取源码脚本,然后在后台用 root 权限直接
make && make install现场编译运行?!
有点离谱了吧?!这哪是防火墙,这分明是个”特洛伊木马全家桶”啊!
整个插件本体加规则,足足有接近2万行,指望我一个人看是看不过来的(而且我这个纯业余的水平)于是索性直接把 宝塔 Nginx 免费防火墙 8.4(署名作者”民国三年一场雨”,官方发布页在这儿)的代码结构拉出来,把相关文件和脚本都扒出来,摇上大肥鱼(DeepSeek)、克劳德(Claude)、奥特曼(ChatGPT)、北美豆包(Gemini)四路 AI 一起做了一轮交叉全量静态审计。
看完最终整理出的4方审查报告,我脑瓜子嗡嗡的,忍不住先在博客里发了一条碎碎念说说给大伙儿预警一下。
周末忙完工作,我抽空在自己的测试机上全新安装了 宝塔 Nginx 免费防火墙 8.4,做了真机实测(走本地回环链路闭环测,不碰公网,我能构造出测试环境的都实测了)。
真机跑下来,推翻了部分 AI 过度推断的吓人言论,将 P0 级问题数量从 20 多个压缩到了 13 个,比如:
- 并不是加个
multipart头就能绕过 POST 防护——2024 年之后云端下发的规则给后面加了独立的post_data()来兜底,实测-F "id=1 union select..."仍然是 403; - 宝塔 Nginx 免费防火墙的安装脚本虽然给目录的权限写了个 777,试图让同服务器任何正常权限的程序都可读可写,但宝塔系统的底层策略替它兜了底,给收紧到了 700。
但真实现状,也没好到哪里去。
几条逆天漏洞,在真机上全·部·复·现·了。
P0 级漏洞:宝塔 Nginx 免费防火墙六个纯纯的”内鬼”行为
属于那种但凡稍微不那么业余的 Linux 运维或者 Web 开发,看完都会当场吸氧的神奇代码。
顺便说一句,这插件上一回就出过事,上次那个 undefined 的锅还没凉透呢。
P0-1 静默源码外传:数据库密码跟着代码一起打包出门
触发条件:在面板点过一次「扫描木马」,且站点里有任意 .php 文件命中规则。
代码位置:free_waf_main.py:142-160(scan 函数)与 304-309(send_baota2 函数)
你在宝塔面板点”webshell查杀”,以为这只是在本地拿规则扫一遍文件?
天真了我的朋友,现实是:只要你服务器上的任何一个 .php 文件命中了规则,它就会在后台神不知鬼不觉地调用 self.send_baota2(file)。它既不需要你点击确认,更不会弹窗问你”是否上传样本分析”。
# free_waf_main.py 第 154-158 行
result = {}
result[file] = r
self.__webshell_data.append(result)
self.send_baota2(file) # <--- 毫无征兆地直接把文件送走
那这个 send_baota2 到底准备干什么呢?咱们继续看代码:
# free_waf_main.py 第 304-309 行
def send_baota2(self, filename):
cloudUrl = 'http://www.bt.cn/api/panel/btwaf_submit' # <--- http明文上传
pdata = {
'codetxt': public.ReadFile(filename), # <--- 文件全文
'md5': self.read_file_md5(filename),
'type': '0',
'host_ip': public.GetLocalIp(),
'size': os.path.getsize(filename),
}
ret = public.httpPost(cloudUrl, pdata)
return True # <--- 永不检查结果,绝对是true
啥玩意,直接上传??还是 http 链路?
为了抓包验证,我在测试机上写了一个只有 108 字节的测试文件,在里面故意留了一行假数据库密码:$db_pass = "SECRET_MARKER_DB_PASSWORD_42";。
实测截获的数据包如下:
url : http://www.bt.cn/api/panel/btwaf_submit
codetxt len : 108
codetxt : '<?php\n// WAF-SEC-AUDIT-MARKER-20261022-UNIQUE\n$db_pass = "SECRET_MARKER_DB_PASSW...'
host_ip : <面板请求的 Host 头>
md5 : f6e4c135a97d1616562346fd2edfdebc

108 字节的代码全文,连同里面的假数据库密码,一字不差地打包走明文 http:// 送出去了!
这个 host_ip 字段public.GetLocalIp() 在面板里取的是 request.headers.get('host'),也就是你访问面板时用的那个 Host 头(1.2.3.4:8888 之类)对于大部分人来说,就是你服务器的公网 IP。
更绝的是它的查杀规则写得极其宽泛:"str_rot13"、base64_decode\($_、"eval(" 这些都能命中(rule.json 一共 52 条规则,含 eval 的 8 条、含 base64_decode 的 4 条、含 str_rot13 的 2 条)。就问你,随便找个现代 PHP 程序、WordPress 插件、甚至任何涉及加解密的程序,有几个能逃过这些规则?
这他喵的一命中,你的项目代码、里面的数据库链接信息,就在不经意间顺着明文 HTTP 裸奔到了外部。
其实下边的 send_baota()(311-323 行)更厉害:它接收的是一个服务器本地文件路径(不是上传的临时文件),代码只做了一次 os.path.exists() 检查,没有任何后缀或目录限制,/etc/shadow、panel/data/default.db、root/.ssh/id_rsa 都能当”待检样本”发出去(用户在面板里手动上传文件的链路)。也就是说,只要面板接口被调用,它就等价于”读本地任意文件并外传”。
P0-2 POST 参数奉旨免检:改个参数名就是一次 WAF 绕过
触发条件:任何一次 POST 请求,只要参数名落进白名单,或参数名长度超过 64 字符。
代码位置:4.7.lua:1668-1678(continue_key 函数)
如果说上面的文件外传是内鬼行为,那这条过滤规则就是给黑客铺红毯。Nginx 免费防火墙 8.4 在检查 POST 请求参数时,很贴心地写了一个白名单:
(你在面板安装免费防火墙时,安装脚本会自动从宝塔的 CDN 拉取 free_waf_data.zip——这是防火墙的数据文件,4.7.lua 就是其中给 Nginx 用的脚本)
function continue_key(key)
if method ~='POST' then return true end
key = tostring(key)
if string.len(key) > 64 then return false end; -- <--- 参数名的长度超过 64 个字符
local keys = {"content","contents","body","msg","file","files","img","newcontent","message","subject","kw","srchtxt",""} -- <--- 参数名叫这些名字的话
for _,k in ipairs(keys)
do
if k == key then return false end; -- <--- 直接返回 false
end
return true;
end
啥意思呢:就是在处理 POST 请求时,只要参数名的长度超过 64 个字符,或者参数名叫 kw(搜索关键字)、body(正文)、content(内容)、msg(留言)、file 之类的名字……它就直接返回 false。
然后呢,你注意看啊,它在外层的处理逻辑里,可是只要返回 false,就让整个参数直接跳过检测的!
我在测试机上跑了几个测试:
- 对照组(常规参数):POST
id=<script>alert(1)</script>→ 拦截(拦截了XSS,正常工作) - 实测用例:POST
[65个字符的长参数名]=union select 1,2,3→ 放行(绕过成功!) - 实测用例:POST
kw=union select 1,2,3→ 放行(绕过成功!) - 实测用例:POST
body=<script>alert(1)</script>→ 放行(绕过成功!)
同一个 XSS 载荷,参数名叫 id 就被 403 铁拳制裁,但是只需要改一个名字,咱们叫 kw 或者 body,防火墙就当场直接给放行了。
我琢磨了一下作者可能的心路历程:”啊啊啊啊,规则怎么总误伤常见的正常程序啊,有了,我直接把它们设置成白名单不就减少误拦截率了嘛。”
然后直接把最容易在XSS上出事的正文、留言、搜索框全设置为免检了?一次 WAF 绕过就这么简单,这 WAF 到底在防什么?
PS:而且他这白名单也没白到位,如果开着防火墙的 POST 检测,像是这篇文章这种正文代码块中有涉及加解密参数、外源加载命令的文章,反而会被他的POST检测拦截掉。攻击者能走白名单,管理自己反而被拦截是什么鬼啊。
P0-3 远端接口注入改写 config.json:一次响应就能把 WAF 配置写坏
触发条件:网络遭遇中间人劫持,或宝塔的接口被投毒,返回了非预期结构的 JSON。
代码位置:free_waf_main.py:936-942(test_check_zhilist 函数)
在同步所谓”云端蜘蛛列表”时,插件代码写成了这样:
# free_waf_main.py 第 936-942 行
url = 'http://www.bt.cn/api/bt_waf/getSpiders'
data_list = json.loads(public.httpPost(url, data22, timeout=3))
if data_list:
for i22 in data_list:
path = "/www/server/free_waf/%s.json" % i22 # <--- 哪有这么写的?
if os.path.exists(path):
ret = json.loads(public.ReadFile(path))
localhost_json = list(set(ret).union(data_list[i22]))
public.WriteFile(path, json.dumps(localhost_json))
宝塔 Nginx 免费防火墙先从明文 HTTP 接口拉取一个字典,然后拿字典的 Key(i22)直接拼接文件路径。
作者大概非常笃定,返回的 Key 只会是 1、2 这种编号,所以代码里没有任何校验和限制!
如果链路上遭遇中间人劫持,或者远端接口返回一个 {"config": ["x"]},会发生什么?
咱自己当自己的中间人实际测试一下呗,原本结构完好的 config.json 字典对象({"open": true, "log": true, ...})瞬间被变成了一个JSON 字符串数组:["retry_cycle", "log", "retry", "open", "x", ...]。

注意,它此时还是一个合法的 JSON。语法完全正确,json.decode 照样能解析。问题出在类型上——现在解析出来的是个数组,不是字典了。配置结构一旦被破坏,Lua 在加载时读到的 config['open'] 就是 nil。
紧接着,4.7.lua:2054 的逻辑被触发:
if not config['open'] then return false end
WAF 就不再执行后面那一整套检测了。
那到底会怎样?开始我以为会直接让WAF失效,实测了一下,解决比”直接让WAF失”还更麻烦:
| 阶段 | 操作 | 实测结果 |
|---|---|---|
| ① 文件改坏了,不 reload | config.json 变成数组 |
WAF 完全不受影响,SQLi 照样 403。因为 config 是 init_by_lua 阶段读的,只改文件不会触发重载 |
② 执行 nginx -s reload |
触发新 worker | 40 次 SQLi 请求,35 次放行、5 次 403。新旧 worker 的 Lua VM 并存,请求落到哪个 worker,结果就不同 |
| ③ 继续访问 | — | 出现站点返回 000(连接失败),nginx 重启后恢复 |
也就是说:它不是”立刻就让WAF关了”,是”下次你改Nginx配置或打开防火墙页面改了东西关闭后开始漏拦截,而且漏得很随机”。
这个”不确定”本身就是灾难:同一个风险,有时拦有时放,看日志定位原因的难度大幅提升。
再说面板这边的情况,宝塔 Nginx 免费防火墙的界面会变成这样的:防火墙已经开启的绿色开关还在 + 一个”请求过程发现错误!”的弹窗 + 拦截统计和安全天数全空。所以你可能怀疑防火墙面板坏了,但可能不会怀疑防火墙已经不工作了。
P0-4 算术验证码 ?btwaf=nil 秒破:白送 1 小时免检金牌
触发条件:站点开启了「CC 增强模式」(cc.increase),触发验证码后,在 URL 后面挂一个 ?btwaf=nil。
代码位置:4.7.lua:573-582(security_verification 函数)
这里得先补一个前提,这个「CC 增强模式」默认是关闭的,在默认配置下根本不会被激活:这部分代理代码cc2() 的第一行是 if not site_config[server_name]['cc']['increase'] then return false end,而全新安装的 site.json 里,站点的 cc 只有 open / cycle / limit / endtime 四个可用键值,压根没有 increase 字段。我是手动加了 "increase": true 再 reload,才让这段逻辑跑起来的。(属于残留的无用逻辑,但可以看出作者那时候的代码水平)
前提说完了,我们来看实际代码:
function security_verification()
if uri_request_args['btwaf'] then
vn3,_ = ngx.shared.free_waf:get(ip)
if tostring(vn3) == uri_request_args['btwaf'] then -- <--- 这就直接比了?
ngx.shared.free_waf:delete(ip)
ngx.shared.free_waf:set(ip,666,3600) -- <--- 贴心的1小时免检
return false
end
end
注意看第 4 行:它在拿到缓存值 vn3 后,它完全没有判断 vn3 ~= nil,而是直接把它转成字符串去跟 URL 传进来的 ?btwaf= 比较!
蛤?在 Lua 里,tostring(nil) 的返回值正是字符串 "nil" 好不!
在测试机实测:触发了 CC 防护验证码时,只要在 URL 后面挂上 ?btwaf=nil,校验当场通过!

实测记录(对照组很关键):
| 请求 | 结果 |
|---|---|
127.0.0.2 首次触发 + ?btwaf=nil |
404 放行 |
127.0.0.2 第二次(不带参数) |
404,因为 666 还在生效 |
127.0.0.3 首次(对照) |
弹验证码 4 + 18 = ? |
127.0.0.5 首次(对照) |
弹验证码 17 + 17 = ? |
通过之后,代码下一行还贴心地附赠一道护身符:ngx.shared.free_waf:set(ip, 666, 3600)。只要拿到这个 666 标记,接下来整整 1 小时内,该 IP 会被视为受信任访客,跳过所有 CC 频率检测。
所以刷量脚本连识别验证码的步骤都省了,直接在请求后缀挂上 ?btwaf=nil 就能畅行无阻。当然前提是你手动去把这个”增强模式”打开了——默认配置下这段逻辑压根不执行,我只是想吐槽下这个代码写的真的是有点毛糙。
真要防刷流,还是去看对抗刷流攻击的那套 Nginx 打法吧,至少规则是自己写的、自己看得懂。
P0-5 宝塔面板 access_key 泄露:核心凭据明文 HTTP 外传
触发条件:管理员打开防火墙插件设置面板,触发 get_total_all 逻辑。getSpiders 受 session 限制每天一次,addSpider 则是每次都会跑。
代码位置:free_waf_main.py:929-946, 978-983
如果你在看上边第 3 个问题时稍微往上看几行就会发现一个更加神奇的逻辑:
# free_waf_main.py 第 929-946 行
def test_check_zhilist(self, get):
try:
userInfo = json.loads(public.ReadFile('/www/server/panel/data/userInfo.json'))
data22 = {"access_key": userInfo['access_key'], "uid": userInfo['uid']}
url = 'http://www.bt.cn/api/bt_waf/getSpiders' # <--- http明文上传
data_list = json.loads(public.httpPost(url, data22, timeout=3))
# free_waf_main.py 第 978-983 行
userInfo = json.loads(public.ReadFile('/www/server/panel/data/userInfo.json'))
data22 = {"type": i, "ip_list": json.dumps(tt),"access_key": userInfo['access_key'], "uid": userInfo['uid']}
url='http://www.bt.cn/api/bt_waf/addSpider' # <--- 还是http明文上传
if len(tt)>=1:
public.httpPost(url, data22)
public.WriteFile('/www/server/free_waf/zhizhu'+i+'.json',"")
session_id = self.__get_md5("free_waf_test" + time.strftime('%Y-%m-%d'))
if not session_id in session:
self.test_check_zhilist(None)
getSpiders 这条每个管理员每天只会触发一次;而下面的 addSpider 部分,只要 zhizhu*.json 里有内容,每次打开面板都会触发。
不管是每天一次还是每次吧,咱们的结论不会变:它会把 access_key 和 uid 装进 JSON,直接通过明文 HTTP 发送给了远端接口(http://www.bt.cn/api/bt_waf/getSpiders 和 addSpider)。
这东西发给宝塔服务器本身的行为是没问题的,这属于插件的鉴权措施,宝塔面板里的很多收费插件也有类似的逻辑,来验证你是否有启用插件的权限。
但问题是,宝塔 Nginx 免费防火墙用的是 HTTP,是明文不加密的,中间链路随便搞点劫持嗅探,你的宝塔官方账号凭据就直接拱手送人了。
而且这事还有下半场:addSpider 拿回来的那个”蜘蛛 IP 列表”会被写进 zhizhu*.json,而 4.7.lua 的 zhizu_chekc() 会把这些 IP 直接塞进 ngx.shared.spider 全量放行。HTTP 明文通道是能被中间人劫持注入的,就等于能远程往防火墙的”免检名单”里塞人——这其实比凭据泄露本身更吓人。
P0-6 打开首页即以 root 编译 C 扩展的供应链狂欢
触发条件:系统里缺 /usr/local/lib/lua/5.1/cjson.so,然后你打开一次插件设置页。
代码位置:free_waf_main.py:1163-1188(__check_cjson 函数)
# free_waf_main.py:1163行-1188行
def __check_cjson(self):
cjson = '/usr/local/lib/lua/5.1/cjson.so'
if os.path.exists(cjson):
if os.path.exists('/usr/lib64/lua/5.1'):
if not os.path.exists('/usr/lib64/lua/5.1/cjson.so'):
public.ExecShell("ln -sf /usr/local/lib/lua/5.1/cjson.so /usr/lib64/lua/5.1/cjson.so");
if os.path.exists('/usr/lib/lua/5.1'):
if not os.path.exists('/usr/lib/lua/5.1/cjson.so'):
public.ExecShell("ln -sf /usr/local/lib/lua/5.1/cjson.so /usr/lib/lua/5.1/cjson.so");
return True
c = '''wget -O lua-cjson-2.1.0.tar.gz http://download.bt.cn/install/src/lua-cjson-2.1.0.tar.gz -T 20
tar xvf lua-cjson-2.1.0.tar.gz
rm -f lua-cjson-2.1.0.tar.gz
cd lua-cjson-2.1.0
make
make install
cd ..
rm -rf lua-cjson-2.1.0
ln -sf /usr/local/lib/lua/5.1/cjson.so /usr/lib64/lua/5.1/cjson.so
ln -sf /usr/local/lib/lua/5.1/cjson.so /usr/lib/lua/5.1/cjson.so
/etc/init.d/nginx reload
'''
public.writeFile('/root/install_cjson.sh', c) # <--- 脚本内容里含 http 下载
public.ExecShell('cd /root && bash install_cjson.sh') # <--- 不校检直接root权限运行了
return True
明明安装包里已经带了编译好的 cjson.so(install.sh:109-111 会把它复制到 /usr/local/lib/lua/5.1/、/usr/lib64/lua/5.1/、/usr/lib/lua/5.1/),但偏偏作者以防万一写了一个校验函数:每次打开插件首页(get_total_all() 第一行就是它),如果发现系统路径缺了文件,就在 /root 下生成一个 Shell 脚本,通过明文 http:// 下载源码包,并以 root 权限就地执行 make && make install。
不是,这是什么胆子啊,用 HTTP 拉取云端文件,然后看都不看就写入本地文件,直接用 root 权限编译并执行了?
P1 级漏洞:不起眼,但每一个都能绕过 WAF
到了 P1 级别,看似是一些”虽然存在但不太好直接利用”的问题,但这几个逻辑依然让人哭笑不得:
P1-1 硬编码 hackcnm 木马免杀后门
代码位置:free_waf_main.py:120-140
在遍历站点文件准备查杀木马时,作者留下了这样一段神仙逻辑:
# free_waf_main.py 第 133-140 行
def getdir_list(self, get):
path = get.path
if os.path.exists(path):
pc = 'hackcnm' # <--- 不是,这啥玩意?
rs = self.getdir(path, pc)
return rs
而在递归查询站点根目录搜索文件时:
elif str(l.lower())[-4:] == '.php' and str(dir + '/' + l).find(pc) == -1:
lis.append(dir + '/' + l)
啥?只要路径或者文件名里包含 hackcnm,Webshell 查杀就直接跳过该文件!?
所以,只要把 Webshell 命名为 hackcnm.php,或者扔在 hackcnm/ 目录下,在木马查杀里就能享受到永久免检待遇了???
P1-2 伪造蜘蛛 PTR:一条假 PTR 就能绕过大量防护
代码位置:4.7.lua:1953-2026, 2095-2103
宝塔 Nginx 免费防火墙为了不误伤搜索引擎蜘蛛,设计了 PTR 反查机制,这确实是个很好的机制。但是,但是啊,在匹配域名时,使用的是无头无尾的模糊匹配,比如对百度的:string.find(ptrdname, "baidu")。只要含有baidu就行
而且更离谱的是,一旦请求被判定为合法的搜索引擎蜘蛛,WAF 在 run_btwaf() 里会把完整检测步骤里 18 个函数中的 14 个直接跳过:drop()(黑名单)、drop_abroad()(海外阻断)、cc() / cc2()(CC 防护)、user_agent()(UA检测)、url()(GET防护)、cookie()(cookie检测)、ThinkPHP_RCE5_0_23()、ThinkPHP_3_log()、scan_black(),以及站点级 URL 检测——只留下 args()、post()、post_data()、post_data_chekc() 四个参数级检测。
还记得前边「P0-2」是什么问题不?「处理 POST 请求时,只要参数名的长度超过 64 个字符,或者参数名叫 kw(搜索关键字)、body(正文)、content(内容)、msg(留言)、file 之类的名字,POST检测就可以跳过」
所以只要攻击者给自己的一台 VPS 配个类似 baidu.攻击者自己的域名.com 的 PTR 记录,再带上百度蜘蛛的 UA 发起请求,这套防火墙的防护模块就只剩args()对他有效了。
百度起码是匹配的 baidu,你猜对 360 爬虫它匹配的是什么?是 0,是数字 0 啊!!
123-45-60-78.static.isp.com 这种默认 rDNS 里带个 0 太常见了,虽然不能说人人都能变成 360 的蜘蛛,但这个几率真的不低,毕竟为了格式补位,PTR 里含有0的几率实在是太高了。
P1-3 本地 min_route 零鉴权一键清空黑名单
代码位置:4.7.lua:1777-1786
Lua 里开了一个本地管理路由:
function min_route()
if ngx.var.remote_addr ~= '127.0.0.1' then return false end
if uri == '/get_btwaf_drop_ip' then
return_message(200,get_btwaf_drop_ip())
elseif uri == '/remove_btwaf_drop_ip' then
return_message(200,remove_btwaf_drop_ip())
elseif uri == '/clean_btwaf_drop_ip' then
return_message(200,clean_btwaf_drop_ip())
end
end
它只判断 remote_addr == '127.0.0.1',完全没有任何 Token 校验……我该说点什么好呢。
当然这个洞的利用要分场景,不能一概而论:
- 前面没有反代:
remote_addr就是真实客户端 IP,外部直接打GET /clean_btwaf_drop_ip是打不进来的; - WAF 前面还有一层本机反代(Caddy / Varnish / 另一层 Nginx):攻击者的请求到 WAF 这层时,
remote_addr全是127.0.0.1,这时候一打一个准; - 最可能的是 SSRF:服务器上任何一个能发 HTTP 请求的东西(PHP 的
curl、图片代理、webhook 回调、被拿下的低权限进程),只要能访问http://127.0.0.1/,就能把封禁名单清空。
也就是说,它用”来源地址”当”身份凭证”,把”本机来源”等同于”可信来源”。
P2 级漏洞:工程架构层面的全面崩溃
如果说上面的漏洞是安全意识淡薄,那 P2 级问题就是纯正的软件工程事故:
- 【并发】OpenResty 全局变量大乱炖(协程串号):
4.7.lua里的run_btwaf()定义了十几个核心变量(ip、uri、server_name、request_header),全都没加local声明! 这些变量统统挂在全局_G表里。当系统发起异步 DNS 查询协程挂起时,只要有并发请求进来,全局变量瞬间就被后一个请求覆盖。你的 IP、我的 URL 混在同一个 Worker 里,误封、漏封全看运气。(此条未做实测——测试需要足够的并发测试资源) -
【磁盘】每次拦截无锁全量读写
total.json:每次触发拦截,都要把整个统计文件全量重写一遍。
| 场景 | 请求数 | total 增量 |
磁盘写入 |
|---|---|---|---|
| 正常请求(404) | 200 | 0 | 220 KB |
| 拦截请求(403),串行 | 200 | +200(100%) | 1.18 MB |
| 拦截请求(403),并发 | 200 | +120(60%) | — |
说人话就是,会漏统计数据,多个 Worker 同时并行读-改-写,后写的会直接覆盖先写的。从而使得统计不可信,你看着面板上那个”已拦截 XXX 次”,实际可能少了一半。
- 【内存】108MB 内存加载 + 灾难性回溯正则(ReDoS):在检测文件上传时,竟然允许把高达 108MB 的请求体整个拖进 Lua 内存,并用带有多个无边界贪婪匹配(
.+)的畸形正则去跑匹配,虽然不能下一定会出事的结论,但确实存在可 ReDoS 攻击的点位。 -
【证书】下载扫描结果时关闭了证书校验: 上传文件给百度的接口检测webshell时走 HTTPS,这时候
curl默认会验证证书;但是”取扫描结果”时,却又特意把证书验证给关了。这导致中间人可以伪造返回的扫描结果:把真 webshell 说成”未查出风险”,或者把正常文件说成木马。 -
【白名单】你以为你开了禁止国外 IP 就真进不来了? 其实作者偷偷写了 5 个硬编码的白名单 IP:
91.199.212.132/133/148/151/176。我查了一下,91.199.212.0/24归属 AS48447 SECTIGO(英国,2008 年 RIPE 分配),是 CA 机构做 AutoSSL 域名控制验证的节点——所以这5个IP不是后门,防火墙之所以给白名单,是怕你开了”禁止海外访问”后,面板的证书续签功能炸了。但怎么保证这 5 个 IP 永远不会变呢?IP 一回收重分配,新持有者就自动继承了这份免检权。 -
【XSS】存在反射 XSS。 位于站点访客看到的 403 错误页,站长自己倒是没事
-
【权限】试图规则库全局可写:代码里是
chmod -R 666 /www/server/free_waf/rule和chmod 777 /www/wwwlogs/free_waf_log,归属www:www。不过实测部署后目录是drwx------ www:www(700)、文件-rwx------——幸好宝塔的兜底目录策略把脚本意图覆盖掉了。 -
【写文件】任意文件写入 JSON:
s_Name/ruleName未校验直接拼rule/xxx.json,攻击者可以玩路径穿越../穿越,以 root 向任意可写位置落 JSON,配置投毒好帮手啊。 -
【启动判断】神一样的判断 Nginx 成功启动的方式:判断 Nginx 成功居然是检测
netstat输出中有没有出现字符串80(netstat -nltp | grep nginx | grep 80 | wc -l)。这导致两个效果:
- ①Nginx 还好好的呢,只是没监听 80 端口(比如纯 443 站点、或者 HTTP 使用了非 80 端口),
is_status判 0,安装脚本会误以为把 Nginx 搞炸了,会走安装失败清理流程——删掉free_waf.conf、重启 nginx、删掉/www/server/panel/plugin/free_waf这个插件目录(脚本自己也在里面),然后提示”安装失败”。注意它没删/www/server/free_waf主目录,会有残留文件。(把安装成功判定为安装失败) - ② Nginx 确实挂了,别忘了
netstat输出里还有 PID 字段呢,万一 PID 含80(如18042/nginx),脚本会因为匹配到 80 而判定”安装完成”,从 PID 的分布概率上来说误判率 4~5% 吧(把安装失败判定为安装成功)
作为站长,宝塔防火墙该怎么卸载和善后?
宝塔 Nginx 免费防火墙的普及率有多高,就不用我说了吧,我感觉基本只要在用这个面板的都会装一个。下面这份卸载清单,装过的都建议照着走一遍。
先花 30 秒思考一下三个问题:
- 服务器上装过这个插件没有?
- 装完之后点过「木马查杀」没有?点过 = 大概率已经有文件被静默发出去了。
/www/server/panel/plugin/free_waf/webshell.log这个文件存在吗?存在且有内容 = 确认已经静默发出去了
三条里中了一条,按下面的步骤操作,先确保自己的机器别成为筛子:

1. 立即卸载并清理残留
打开宝塔面板 → 软件商店 → 已安装,找到这个免费防火墙,停用并卸载。随后登录终端,先逐行确认下面的路径没写错——rm -rf 没有回收站,手一抖就是运维事故了——再执行清理:
# 检查并清理残留规则与运行目录(不放心就去面板的文件管理里手动删)
rm -rf /www/server/free_waf
rm -rf /www/server/panel/plugin/free_waf
rm -f /root/install_cjson.sh
# 检查 Nginx 配置中是否已解除挂载(确认没有残留 access_by_lua_file)
grep -rn "free_waf" /www/server/panel/vhost/nginx/
nginx -t && nginx -s reload
2. 重置宝塔官方账户凭据(access_key 泄露后必做)
由于该插件会后台通过明文 HTTP 发送你的 userInfo.json,强烈建议登录宝塔官方后台(bt.cn),进入用户中心:
- 解绑再重新绑定面板,或重置当前账号的 API 密钥 / Access Key。
3. 排查被外传的站点文件
如果你之前在面板里点过”木马查杀”,去看一下这个查杀日志:
cat /www/server/panel/plugin/free_waf/webshell.log
凡是在这个日志里留下过名字的 .php 文件,都应当视为文件已经明文上传。(严格说 send_baota2 若因网络问题发送失败,文件未必被发出去过,但你没有证据证明它没送达,所以按”已泄露”处理。)
如果里面包含你的生产数据库配置文件(例如包含数据库密码、Redis 密码、第三方支付 Secret),请立刻全部改密码!
4. 寻找靠谱的替代方案
如果你需要基础的防 CC 和简单防护:
- 善用 Nginx 原生模块:现在的 AI 这么聪明,你把异常日志发给它,让它给你写 Nginx 规则,配合 Cloudflare 等成熟 CDN,足以挡掉绝大部分攻击。(让登录页物理隐身那篇就是这么干的,思路可以直接搬。)
- 如果要上 WAF:优先选择源码完全透明、社区活跃维护的开源方案(如 Coraza、ModSecurity、雷池、南墙或者开源 Lua 脚本),并且牢记:规则文件的权限必须设为
root:root 644,决不能图省事给它挂上可写权限。
最后吐槽两句:免费的防火墙,最贵的账单
免费的往往才是最贵的。这话我上次聊那个藏了 13 年的 Nginx 上古漏洞时就说过一遍,这次又应验了。
很多个人站长用宝塔,图的就是个开箱即用、省心省事。遇到”免费版 Nginx 防火墙”,很多人想都不想就装上去了,以为给自己服务器雇了个保镖。
谁能想到,这哪是保镖啊?
- 它把家门钥匙挂在大门把手上面(
continue_key只要参数名对上就直接免检放行); - 它把家里的账本、相册和房产证顺丰包邮寄给了陌生人(
send_baota2全自动静默上传); - 贼来踩点的时候,只要有个带
0的面具,它就主动递上一杯茶,顺手给发了一张 VIP 通行证(判定为360的爬虫,免检)。

审查报告的全文我就不发了,我就列个数字:完整报告里是 P0 级 13 个、P1 级 16 个、P2 级 12 个,这里只挑了最能说明问题的几条。
从本质上来说,宝塔 Nginx 免费防火墙这些漏洞,看起来都不像是恶意埋的雷(不存在故意制造漏洞或后门的行为),但作者为了使用方便、兼容性和不误判,留下来太多可以绕过防火墙的地方,说实话这东西要是自己写了给自己用的倒也算了,但是被大家这样广泛在生产环境使用,会给用户一种虚假的安全感。
不要建立:开源=安全、隐私的错误思想,更可能的情况是,确实开源了,但有技术的人不会用这个项目,没技术的看不懂这个项目,漏洞和问题代码就这么明晃晃的放在那里,很多年都无人发现。
以及宝塔面板因此收集了这么多年的用户文件,就算用户到宝塔的 HTTP 链路从来没有被劫持过,文件真的只发给了宝塔面板的官方接口http://www.bt.cn/api/panel/btwaf_submit,宝塔自身是否妥善保存了这些被自动上传的“待检测”文件呢?
虽然作者免费这么多年,并且坚持无偿更新的好心要领,但是面对这套代码,我除了苦笑,也就只剩那句老话了:
毁灭吧,赶紧的,累了。


老何
2026-10-26 13:41
还好换服务器用了 1panel
obaby
2026-10-26 13:40
少一点依赖,多一点安全
Sheep5
2026-10-26 12:10
看不懂,但宝塔这个插件也太差劲了吧。