前言
观察一个女孩对人的戒备心有多严心思有多重,就大抵能猜测她曾经被伤害的有多么的深和痛。女孩子是这样,waf也是这样。
在本次分析中,返回false则意味着跳出当前检测方法。return_message,return_error2和lan_ip
则表示拦截或者封锁IP
init.lua
-
• 基础环境初始化 -
• 加载各类规则 -
• ipv6黑白名单初始化 -
• 加载自定义规则(一般没人加载) -
• 载入支付宝、微信支付的IP(会是一个可以利用的点吗) -
• 各类配置,如body_size=800000,检测base64,get/post请求参数最大为1000 -
• 处理CC和蜘蛛爬虫爬虫 -
• 加载cms规则
waf.lua
进入bt waf_ru
n函数
获取传入的请求包的各项数据,如请求方法,url,请求头等
,其中请求头的最大数量为20000(ngx.req.get_headers(20000))
检测user_agent和referer
,如果没有则修改为btwaf_null和btwaf_referer_null
并且存入上下文中
获取客户端IP(无法伪造),获取最大的get参数量(ngx.req.get_uri_args(100000))
,是否与初始化冲突?
获取cookie
等其他信息,貌似没有什么太值得关注的信息,先暂时跳过。(64-129Line)
关键的黑白名单检测如ip,ua以及url
。其中route.lua中的route函数
可以关注,ip的检测目前暂时没有发现问题。url白名单可以看到对于phpmyadmin是直接放行的
(ngx.var.document_root=='/www/server/phpmyadmin')
静态文件检查
(ngx.re.find(ngx.ctx.uri,"\.(js|css|gif|jpg|jpeg|png|bmp|swf|ico|woff|woff2|webp|mp4|mp3)$","isjo")
这些是直接放行的
检测是否是蜘蛛,如果是,会进行一系列较为宽松的检查:
args.args(),post.post(),upload.post_data(),pload.post_data_chekc()
由于我们一般不是蜘蛛,所以会进入else。
关键字拦截,请求方法检测拦截(url_request_mode.json
为空,默认不检查),检测header
method
放行策略
(if method=='PROPFIND'or method=='PROPPATCH'or method=='MKCOL'or method=='CONNECT'or method=='SRARCH'or method=='REPORT'thenreturnfalseend)
user-agent以及cookie超长依然放行
(if i=='cookie'or i=='user-agent'thenreturnfalseend )
cc跳过,然后是封禁国外访问其中
(if ngx.ctx.ip=='91.199.212.132'or ngx.ctx.ip=='91.199.212.133'or ngx.ctx.ip=='91.199.212.148'or ngx.ctx.ip=='91.199.212.151'or ngx.ctx.ip=='91.199.212.176')
这几个放行,以及自定义配置的国内禁止访问。
自定义地区和国家封禁,恶意地址封禁,hw模式的readonly,idc标签拦截,非浏览器请求拦截,恶意爬虫user-agent拦截,衔接三个cc拦截我直接跳过。
文件下载拦截:
[ [1, "\.\./\.\./", "文件包含", 0,100], [1, "\.(htaccess|mysql_history|bash_history|DS_Store|idea|user\.ini)", "网站备份文件", 0], [1, "\.(bak|inc|old|mdb|sql|php~|swp|java|class)$", "网站备份文件", 0], [1, "^/(vhost|bbs|host|wwwroot|www|site|root|backup|data|ftp|db|admin|website|web).*\.(rar|sql|zip|tar\.gz|tar)$", "网站备份文件", 0], [1, "/(hack|shell|spy|phpspy)\.php$", "恶意脚本", 0]]
cookie拦截:
[ [1,"base64_decode\(","PHP代码执行",0], [1,"\$_(GET|post|cookie|files|session|env|phplib|GLOBALS|SERVER)\[","PHP代码执行",0]]
get请求参数超过10000个拦截。
遍历请求头查看是否存在扫描器ua
(random-agent
的重要性不用说了吧)。
拦截2个tp的漏洞,具体规则可以自己看因为不是重点。
然后是一个被命名为nday的transfer-encoding
拦截,其中有一个规则是如果ip来自cdn则放行不检测(有没有想到什么呢),规则:“if ngx.ctx.request_header['transfer-encoding']
” 。
post.lua
基础检查:
if ngx.ctx.method == "GET"thenreturnfalseendlocal content_length=tonumber(ngx.ctx.request_header['content-length'])if content_length == nilthenreturnfalseendlocal content_type = ngx.req.get_headers(20000)["content-type"]ifnot content_type thenreturnfalseendiftype(content_type)~='string'thenreturn Public.return_message(200,'Header Content-Type Error')end
检查是否是上传包,如果是的话直接跳出;
content-type
不包含application的不检查,然后读取请求体
if content_type and ngx.re.find(content_type, 'multipart',"oij") thenreturnfalseendif content_type andnot ngx.re.find(content_type, 'application',"oij") thenreturnfalseend ngx.req.read_body()
然后由于我们主要的目的是要测文件上传所以剩余的部分就不看了,其他主要拦截:
-
• 参数总量超过10000时拦截 -
• 单个参数长度超过80万字符时拦截 -
• POST参数总数超过8000时拦截 -
• 拦截base64编码的PHP文件上传 -
• 拦截base64编码的JSP文件上传 -
• test/admin用户弱密码检测。
upload.lua
post_data()
检查content-length: (if content_length >108246867 then return false end )
get_boundary()
想了想还是不全贴代码只放关键代码即可
多个content-type
返回报错:
local header = ngx.ctx.request_header["content-type"]ifnot header thenreturnnilendiftype(header) == "table"thenreturn Public.return_message(200,'content-type ERROR')end
匹配规范的上传ct:
if ngx.re.find(header,[[multipart]],'ijo') thenifnot ngx.re.match(header,'^multipart/form-data; boundary=',"jo") thenreturn Public.return_message(200,'content-type ERROR')end
更加规范的匹配
-
• -^ 表示从字符串开头匹配 -
• multipart/form-data; boundary= 必须严格匹配这个固定格式 -
• .+ 表示boundary值必须有至少一个字符 -
• jo 选项启用PCRE JIT编译和优化:
local multipart_data=ngx.re.match(header,'^multipart/form-data; boundary=.+',"jo")ifnot multipart_data thenreturn Public.return_message(200,"Btwaf Boundary Error") end
不能包含连续的双引号
if ngx.re.match(multipart_data[0],'""',"jo") thenreturn Public.return_message(200,"Btwaf Boundary Double Quotation Mark Error")end
统计等号的数量,大于1个报错:
local check_file=ngx.re.gmatch(multipart_data[0],[[=]],'ijo')local ret={}whiletruedolocal m, err = check_file()if m thentable.insert(ret,m)elsebreakendendiftype(ret)~='table'thenreturnfalseendif(Public.arrlen(ret)>=2) thenreturn Public.return_message(200,"multipart/form-data ERROR")endreturn header
检查完boundary
读取上传的文件内容,这里感觉会有问题:
if boundary then ngx.req.read_body() -- 读取请求体local data = ngx.req.get_body_data() -- 尝试从内存获取请求体ifnot data then-- 如果内存中没有 data=ngx.req.get_body_file() -- 尝试从临时文件获取if data==nilthenreturnfalseend data=Public.read_file_body(data) -- 读取临时文件内容endfunctionpublic.read_file_body(filename)if filename==nilthenreturnnilendlocal fp = io.open(filename,'r') -- 打开文件if fp == nilthenreturnnilendlocal fbody = fp:read("*a") -- 读取全部内容 fp:close() -- 关闭文件if fbody == ''thenreturnnilendreturn fbodyend
一系列的匹配操作,此处是贪婪模式
:
local data233=string.gsub(data,'r','') -- 移除所有回车符-- 匹配所有Content-Disposition开头的头部local tmp4 = ngx.re.gmatch(data,[[Content-Disposition.+]],'ijo')-- 匹配带有文件名的Content-Disposition头部local tmp5 = ngx.re.gmatch(data,[[Content-Disposition: form-data; name=".+"; filename=".+"rnContent-Type:]],'ijo')-- 匹配文件名为空的Content-Disposition头部local tmp6 = ngx.re.gmatch(data,[[Content-Disposition: form-data; name=".+"; filename=""rnContent-Type:]],'ijo')-- 将所有匹配结果存入对应数组local ret3={} -- 存储所有Content-Disposition头部whiletruedolocal m, err = tmp4() if m thentable.insert(ret3,m) elsebreakendendlocal ret5={} -- 存储带文件名的头部whiletruedolocal m, err = tmp5() if m thentable.insert(ret5,m) elsebreakendendlocal ret6={} -- 存储空文件名的头部whiletruedolocal m, err = tmp6() if m thentable.insert(ret6,m) elsebreakendend
upload.from_data(ret3,ret5,ret6)
随后进入 upload.from_data(ret3,ret5,ret6)
进行检查
这一段比较变态,后续需要持续关注
functionupload.from_data(data,data2,data3)-- data是所有Content-Disposition头-- data2是带文件名的-- data3是空文件名的-- 检查data数组是否为空if Public.arrlen(data) ==0thenreturnfalseendlocal count=0-- 遍历所有Content-Disposition头部for k,v inpairs(data) do-- 检查是否包含文件上传字段,区分大小写if ngx.re.match(v[0],'filename=',"jo") then-- 验证文件上传格式是否合法-- 必须符合以下两种格式之一:-- 1. Content-Disposition: form-data; name="xxx"; filename=""-- 2. Content-Disposition: form-data; name="xxx"; filename="yyy"ifnot ngx.re.match(v[0],'Content-Disposition: form-data; name="[^"]+"; filename=""r*$','ijo') thenifnot ngx.re.match(v[0],'Content-Disposition: form-data; name="[^"]+"; filename="[^"]+"r*$','ijo') then ngx.ctx.is_type='恶意上传' upload.return_error2(v[0],'4.5')endend count=count+1-- 检查文件扩展名是否合法 upload.disable_upload_ext(v[0])end-- 如果启用了from_data检查if Config['from_data'] then-- 检查普通表单字段格式ifnot ngx.re.match(v[0],'filename=',"jo") andnot ngx.re.match(v[0],'Content-Disposition: form-data; name="[^"]+"r*$','ijo') then ngx.ctx.is_type='http包非法'-- name字段不能为空ifnot ngx.re.match(v[0],[[Content-Disposition: form-data; name=""]],'ijo') then upload.return_error2(v[0],'5')endendendend-- 验证文件字段数量是否匹配local len_count=Public.arrlen(data2)+Public.arrlen(data3)if count ~=len_count then ngx.ctx.is_type='http包非法' upload.return_error2('','6')endend
from-data
检测完就是新的一轮匹配,先检测混淆格式:
-- 匹配标准的文件上传头部格式local tmp2 = ngx.re.gmatch(data,[[Content-Disposition.+filename=.+]],'ijo')-- 匹配被故意分散/混淆的文件上传头部格式-- 比如: Content-Disposition: f o r m-d a t a; n a m e=xxx; f i l e n a m e=yyylocal tmp3 = ngx.re.gmatch(data,[[Content-Disposition.+s*fr*n*or*n*rr*n*mr*n*-r*n*dr*n*ar*n*tr*n*ar*n*s*;r*n*s*nr*n*ar*n*mr*n*e=r*n*.+;s*fn*s*r*in*s*r*ln*s*r*en*s*r*nn*s*r*an*s*r*mn*s*r*en*s*r*=.+n*s*r*]],'ijo')-- 收集所有匹配结果local ret={} -- 存储标准格式的匹配结果whiletruedolocal m, err = tmp2() if m thentable.insert(ret,m) elsebreakendendlocal ret2={} -- 存储混淆格式的匹配结果whiletruedolocal m, err = tmp3() if m thentable.insert(ret2,m) elsebreakendend-- 对混淆的上传头部进行安全检查upload.disable_upload_ext2(ret2)
disable_upload_ext2
进入到disable_upload_ext2
,注意这里是检测的混淆的格式,如果不是那种类型的混淆这段可以跳过。
functionupload.disable_upload_ext2(ext)-- 检查每个匹配到的项目for i,k inpairs(ext) dofor i2,k2 inpairs(k) do-- 检查 filename= 字段是否重复出现local check_file = ngx.re.gmatch(k2,[[filename=]],'ijo')if Public.arrlen(ret)>1then upload.return_error(1) -- 发现重复,判定为攻击end-- 检查文件名格式是否合法ifnot ngx.re.match(k2,[[filename=""]],'ijo') andnot ngx.re.match(k2,[[filename=".+"]],'ijo') then upload.return_error(2)else-- 转换为小写进行后续检查 k2 = string.lower(k2)-- 检查文件扩展名是否在黑名单中local disa = Site_config[server_name]['disable_upload_ext']if BTWAF_OBJS.request_check.is_ngx_match(ret_disa,k2,'post') then ngx.ctx.is_type = '恶意上传' IpInfo.lan_ip('upload','上传非法PHP文件被系统拦截,并且被封锁IP3'..' >> '..k2)returntrueendendendendend
之后又是一大堆的各种乱七八糟的拦截,很多很多的重复过滤重复的检测的操作,我真的不知道是故意的还是因为是屎山代码,我中断了好几次我真的想放弃。
在执行完disabel_upload_ext2
的这个检查方法后,还有一大串代码,我先贴到下面,然后再分解:
if Public.arrlen(ret)==0and Public.arrlen(ret2)>0then upload.return_error(3)end ret=upload.gusb_string(ret)for k,v inpairs(ret) do upload.disable_upload_ext(v)endlocal tmp2=ngx.re.match(data,[[Content-Type:[^+]{100}]],'ijo')if tmp2 and tmp2[0] then upload.data_in_php(tmp2[0])endlocal av=ngx.re.match(boundary,"=.+","jo")ifnot av then IpInfo.write_log('upload','content_type_null') Public.return_html(Config['post']['status'],BTWAF_RULES.post_html)endlocal header_data=ngx.re.gsub(av[0],'=','')if #header_data>200then upload.return_error(5)end data=string.gsub(data,'n','') data=string.gsub(data,'t','')local tmp_pyload2 = ngx.re.match(data,'Content-Disposition:.+r--','ijo')if tmp_pyload2==nilthenreturnfalseendlocal tmpe_data2=Public.split2(tmp_pyload2[0],header_data)if Public.arrlen(tmpe_data2)>0thenif Config['from_data'] then upload.disable_upload_ext3(tmpe_data2,1)endend data=string.gsub(data,'r','')local tmp_pyload = ngx.re.match(data,'Content-Disposition:.+Content-Type:','ijo')if tmp_pyload==nilthenreturnfalseendlocal tmpe_data=Public.split2(tmp_pyload[0],header_data)if Public.arrlen(tmpe_data)>0thenif Config['from_data'] then upload.disable_upload_ext3(tmpe_data,2)endendendreturnfalse
gusb_string
首先解释一下gusb_string
函数,这个函数的主要功能就是把一下特殊符号替换成baota字符,是的很骚:
functionupload.gusb_string(table)-- 定义需要替换的特殊字符列表local ret={"-","]","@","#","&","_","{","}"}local ret2={}-- 检查输入表是否为空,如果为空则直接返回if Public.arrlen(table)==0thenreturntableendfor _,v inpairs(table) do-- 遍历特殊字符列表,将表中每个元素的特殊字符替换为'baota'for _,v2 inpairs(ret) doif ngx.re.find(v[0],v2,"jo") then v[0]=ngx.re.gsub(v[0],v2,'baota',"jo")endend-- 使用Lua模式匹配替换其他特殊字符 v[0]=string.gsub(v[0],'%[','baota') -- 替换左方括号 v[0]=string.gsub(v[0],'%(','baota') -- 替换左圆括号 v[0]=string.gsub(v[0],'%)','baota') -- 替换右圆括号 v[0]=string.gsub(v[0],'%+','baota') -- 替换加号 v[0]=string.gsub(v[0],'%$','baota') -- 替换美元符号 v[0]=string.gsub(v[0],'%?','baota') -- 替换问号end-- 返回处理后的表returntableend
disable_upload_ext
搞完这顿后,又会进入又一个拓展的检测disable_upload_ext
,其实之前也调用过。这个函数主要检查的就是后缀了,并且会全部转换成小写,还会获取用户自定义的黑后缀进行拦截:
functionupload.disable_upload_ext(ext)-- 获取当前请求的服务器名称local server_name = ngx.ctx.server_name-- 如果传入参数为空,则直接返回falseifnot ext thenreturnfalseend-- 如果传入参数是字符串类型,进行特定文件扩展名检查iftype(ext)=='string'then-- 将扩展名转为小写,便于统一比较 ext = string.lower(ext)-- 检查是否包含危险文件类型(.user.ini, .htaccess, .php, .jsp)if ngx.re.match(ext,'\.user\.ini',"jo") or ngx.re.match(ext,'\.htaccess',"jo") or ngx.re.match(ext,'\.php',"jo") or ngx.re.match(ext,'\.jsp',"jo") then-- 设置拦截类型为webshell防御 ngx.ctx.is_type='webshell防御'-- 记录日志并封锁IP IpInfo.lan_ip('upload','上传非法文件被系统拦截,并且被封锁IP')returntrueendend-- 检查站点配置是否存在ifnot Site_config[server_name] thenreturnfalseend-- 获取站点配置中禁止上传的文件扩展名列表local disa=Site_config[server_name]['disable_upload_ext']local ret={}-- 将每个禁止的扩展名前加上.转义,构建正则表达式模式for _,k inipairs(disa) dotable.insert(ret,"\."..k) end-- 使用BTWAF对象检查上传内容是否包含禁止的扩展名if BTWAF_OBJS.request_check.is_ngx_match(ret,ext,'post') then-- 设置拦截类型为webshell防御 ngx.ctx.is_type='webshell防御'-- 记录日志并封锁IP,同时记录具体的违规内容 IpInfo.lan_ip('upload','上传非法PHP文件被系统拦截,并且被封锁IP2'..' >> '..ext)returntrueendend
data_in_php
检查请求中的 Content-Type
头,如果匹配到长度至少为100字符且不包含加号的内容,则调用 data_in_php
函数进行进一步检查。
local tmp2=ngx.re.match(data,[[Content-Type:[^+]{100}]],'ijo')if tmp2 and tmp2[0] then upload.data_in_php(tmp2[0])end
直接返回false
functionupload.data_in_php(data)returnfalseend
检查 boundary
参数是否包含等号后跟随内容,如果不符合则记录日志并返回错误页面。
local av=ngx.re.match(boundary,"=.+","jo")ifnot av then IpInfo.write_log('upload','content_type_null') Public.return_html(Config['post']['status'],BTWAF_RULES.post_html)end
从 boundary
中移除等号,并检查处理后的长度是否超过200字符,如果超过则拦截请求。
local header_data=ngx.re.gsub(av[0],'=','')if #header_data>200then upload.return_error(5)end
disable_upload_ext3
移除请求数据中的换行符和制表符,防止绕过检测。匹配请求中的 Content-Disposition
头到结束边界,如果没有匹配到则返回 false。
然后使用split2
函数按 boundary
分割内容。
如果分割后的数组不为空且配置中启用了 from_data
检查,则调用 disable_upload_ext3
函数进行扩展检查。
data=string.gsub(data,'n','') data=string.gsub(data,'t','')local tmp_pyload2 = ngx.re.match(data,'Content-Disposition:.+r--','ijo')if tmp_pyload2==nilthenreturnfalseendlocal tmpe_data2=Public.split2(tmp_pyload2[0],header_data)if Public.arrlen(tmpe_data2)>0thenif Config['from_data'] then upload.disable_upload_ext3(tmpe_data2,1)endend
移除请求数据中的回车符,然后匹配从 Content-Disposition
到 Content-Type
的内容,如果没有匹配到则返回 false。
然后再次使用 split2
函数按 boundary
分割内容。
如果分割后的数组不为空且配置中启用了 from_data 检查,则再次调用 disable_upload_ext3
函数进行扩展检查,但参数不同。
data=string.gsub(data,'r','')local tmp_pyload = ngx.re.match(data,'Content-Disposition:.+Content-Type:','ijo')if tmp_pyload==nilthenreturnfalseendlocal tmpe_data=Public.split2(tmp_pyload[0],header_data)if Public.arrlen(tmpe_data)>0thenif Config['from_data'] then upload.disable_upload_ext3(tmpe_data,2)endend
上述两段代码调用了两次disable_upload_ext3
,但是第二个参数不同,分别为1和2。
disable_upload_ext3
函数为第三个检测ext的函数,也是最长的,快200行,说明伤得不轻,按照规律应该是第三次被绕过后新增的防护函数。
我一度看了想吐,大家也别看了,我用ai总结了他会拦截哪些变异的上传包:
functionupload.disable_upload_ext3(ext,check)-- 获取当前请求的服务器名称local server_name = ngx.ctx.server_name-- 检查输入参数是否为空或非表格类型ifnot ext thenreturnfalseendiftype(ext) ~= 'table'thenreturnfalseend-- 遍历表格中的每个元素进行检查for i2,k2 inpairs(ext) do-- 使用正则表达式查找非单词字符后跟filename=的模式local check_file = ngx.re.gmatch(k2,[[(W)filename=]],'ijo')local ret = {}-- 收集所有匹配项whiletruedolocal m, err = check_file()if m thentable.insert(ret,m)elsebreakendend-- 如果在一个元素中找到多个filename=,则认为是异常请求if Public.arrlen(ret) > 1then upload.return_error(6)end-- 根据check参数值执行不同的检查逻辑if check == 1then-- 如果没有找到filename=if Public.arrlen(ret) == 0thenifnot k2 thenreturnfalseend-- 检查Content-Disposition格式是否异常if ngx.re.match(k2,[[Content-Disposition: form-data; name=".+\"r]],"jo") then upload.return_error2('','0.1')end-- 检查Content-Disposition头是否过长local kkkkk = ngx.re.match(k2,[[Content-Disposition:.{200}]],'ijo')ifnot kkkkk then-- 检查Content-Disposition格式是否符合标准ifnot ngx.re.match(k2,[[Content-Disposition: form-data; name=".+"rr]],'ijom') or ngx.re.match(k2,[[Content-Disposition: form-data; name=".+"rr;name=]],'ijo') or ngx.re.match(k2,[[Content-Disposition: form-data; name=".+"rr;s*r*n*ns*r*n*as*r*n*ms*r*n*es*r*n*=]],'ijo') or ngx.re.match(k2,[[Content-Disposition: form-data; name=".+"rs*;]],'ijo') then-- 移除回车符 k2 = string.gsub(k2,'r','')-- 检查是否包含filename字段if ngx.re.match(k2,[[filename=]],'ijo') then ngx.ctx.is_type = '恶意上传'return IpInfo.lan_ip('upload','非法上传请求已被系统拦截,并且被封锁IP1') end-- 检查name字段格式ifnot ngx.re.match(k2,[[Content-Disposition: form-data; name=""]],'ijo') andnot ngx.re.match(k2,'^Content-Disposition: form-data; name=".+"','ijo') thenreturn upload.return_error2('','1')endendelse-- 如果Content-Disposition头过长,使用截取的部分进行检查 k2 = kkkkk[0]-- 检查格式是否符合标准ifnot ngx.re.match(k2,[[Content-Disposition: form-data; name=".+"rr]],'ijom') or ngx.re.match(k2,[[Content-Disposition: form-data; name=".+"rr;name=]],'ijo') or ngx.re.match(k2,[[Content-Disposition: form-data; name=".+"rr;s*r*n*ns*r*n*as*r*n*ms*r*n*es*r*n*=]],'ijo') or ngx.re.match(k2,[[Content-Disposition: form-data; name=".+"rs*;]],'ijo') then k2 = string.gsub(k2,'r','')if ngx.re.match(k2,[[filename=]],'ijo') then ngx.ctx.is_type = '恶意上传'return IpInfo.lan_ip('upload','非法上传请求已被系统拦截,并且被封锁IP2') endreturn upload.return_error2('','2')endend-- 再次检查是否包含filename字段if k2 then k2 = string.gsub(k2,'r','')if ngx.re.match(k2,[[filename=]],'ijo') then ngx.ctx.is_type = '恶意上传'return IpInfo.lan_ip('upload','非法上传请求已被系统拦截,并且被封锁IP3') endend-- 提取并检查name字段的值if ngx.re.match(k2,[[Content-Disposition: form-data; name="(.+)"r]],'ijos') thenlocal tttt = ngx.re.match(k2,[[Content-Disposition: form-data; name="(.+)"rs]],'ijos')if tttt == nilthenreturnfalseendif #tttt[0] > 200thenreturnfalseendif tttt[1] == nilthenreturnfalseend-- 清理name值中的特殊字符 tttt[1] = string.gsub(tttt[1],'n','') tttt[1] = string.gsub(tttt[1],'t','') tttt[1] = string.gsub(tttt[1],'r','')-- 检查name值中是否包含name=,可能是注入攻击if ngx.re.match(tttt[1],'name=','ijo') thenreturn upload.return_error2(tttt[1],tttt[1]) endend-- 检查表单数据中的内容if ngx.re.match(k2,[[rr(.+)rr]],'ijos') thenlocal tttt = ngx.re.match(k2,[[rr(.+)rr]],'ijos')if tttt == nilthenreturnfalseendif #tttt[0] > 200thenreturnfalseendif tttt[1] == nilthenreturnfalseend-- 清理内容中的特殊字符 tttt[1] = string.gsub(tttt[1],'n','') tttt[1] = string.gsub(tttt[1],'t','') tttt[1] = string.gsub(tttt[1],'r','')-- 检查内容中是否包含name=,可能是注入攻击if ngx.re.match(tttt[1],'name=','ijo') thenreturn upload.return_error2(tttt[1],tttt[1]) endendelse-- 如果找到了filename=,执行以下检查ifnot k2 thenreturnfalseend-- 移除回车符 k2 = string.gsub(k2,'r','')local k3 = ""-- 检查Content-Disposition头是否过长local kkkkk = ngx.re.match(k2,[[Content-Disposition:.{500}]],'ijo')ifnot kkkkk then-- 提取Content-Disposition到Content-Type之间的内容 k3 = ngx.re.match(k2,[[Content-Disposition:.+Content-Type:]],'ijo') ngx.ctx.is_type = '恶意上传'ifnot k3 thenreturn IpInfo.lan_ip('upload','非法上传请求已被系统拦截,并且被封锁IP4') end-- 检查Content-Disposition格式是否符合标准ifnot ngx.re.match(k2,[[Content-Disposition: form-data; name=".+"; filename=""Content-Type:]],'ijo') andnot ngx.re.match(k2,[[Content-Disposition: form-data; name=".+"; filename=".+"Content-Type:]],'ijo') then ngx.ctx.is_type = '恶意上传'return IpInfo.lan_ip('upload','非法上传请求已被系统拦截,并且被封锁IP5')endelse-- 如果Content-Disposition头过长,使用截取的部分进行检查 k3 = ngx.re.match(kkkkk[0],[[Content-Disposition:.+Content-Type:]],'ijo')ifnot k3 thenreturnfalseend-- 检查格式是否符合标准ifnot ngx.re.match(k3[0],[[Content-Disposition: form-data; name=".+"; filename=""Content-Type:]],'ijo') andnot ngx.re.match(k3[0],[[Content-Disposition: form-data; name=".+"; filename=".+"Content-Type:]],'ijo') then ngx.ctx.is_type = '恶意上传'return IpInfo.lan_ip('upload','非法上传请求已被系统拦截,并且被封锁IP7')endend-- 检查站点配置是否存在if Site_config[server_name] == nilthenreturnfalseend-- 获取禁止上传的文件扩展名列表local disa = Site_config[server_name]['disable_upload_ext']local ret_disa = {}-- 构建正则表达式模式for _,k inipairs(disa) dotable.insert(ret_disa,"\."..k) end-- 检查是否包含禁止的文件扩展名if BTWAF_OBJS.request_check.is_ngx_match(ret_disa,k3,'post') then ngx.ctx.is_type = '恶意上传' IpInfo.lan_ip('upload','上传非法PHP文件被系统拦截,并且被封锁IP4')end-- 检查Content-Disposition头是否过长if #k3[0] > 500then-- 检查是否包含多个form-data字段local ret10 = {}local tmp10 = ngx.re.gmatch(k3[0],'form-data',"jo")whiletruedolocal m, err = tmp10() if m thentable.insert(ret10,m) elsebreakendendiftonumber(Public.arrlen(ret10)) > 1thenreturnfalseendif ngx.re.match(k3[0],'--$') thenreturnfalseendreturn Public.return_message(200,'error1->The upload file name is too long')end-- 统计特殊字符的数量,检查格式是否合法local tmp8 = ngx.re.gmatch(k3[0],'"',"jo")local tmp9 = ngx.re.gmatch(k3[0],'=',"jo")local tmp10 = ngx.re.gmatch(k3[0],';',"jo")local ret8 = {}local ret9 = {}local ret10 = {}whiletruedolocal m, err = tmp8() if m thentable.insert(ret8,m) elsebreakendendwhiletruedolocal m, err = tmp9() if m thentable.insert(ret9,m) elsebreakendendwhiletruedolocal m, err = tmp10() if m thentable.insert(ret10,m) elsebreakendend-- 检查引号、等号和分号的数量是否符合预期iftonumber(Public.arrlen(ret9)) ~= 2andtonumber(Public.arrlen(ret8)) ~= 4andtonumber(Public.arrlen(ret10)) ~= 2then upload.return_error2('','10')endendelse-- 如果check不等于1,执行以下检查if Public.arrlen(ret) == 0thenreturnfalseelse-- 检查Content-Disposition头是否过长local kkkkk = ngx.re.match(k2,[[Content-Disposition:.{500}]],'ijo')local k3 = ""ifnot kkkkk then-- 提取Content-Disposition到Content-Type之间的内容 k3 = ngx.re.match(k2,[[Content-Disposition:.+Content-Type:]],'ijo')ifnot k3 thenreturn upload.return_error(7) end-- 检查是否包含反斜杠,可能是注入攻击if ngx.re.match(k2,[[Content-Disposition: form-data; name=".+\"]]) then upload.return_error2('','10.33')end-- 检查Content-Disposition格式是否符合标准ifnot ngx.re.match(k2,[[Content-Disposition: form-data; name=".+"; filename=""Content-Type:]],'ijo') andnot ngx.re.match(k2,[[Content-Disposition: form-data; name=".+"; filename=".+"Content-Type:]],'ijo') then ngx.ctx.is_type = '恶意上传'return IpInfo.lan_ip('upload','非法上传请求已被系统拦截,并且被封锁IP5')endelse-- 如果Content-Disposition头过长,使用截取的部分进行检查if ngx.re.match(kkkkk[0],[[Content-Disposition: form-data; name=".+\"]],"jo") then upload.return_error2('','10.33')end-- 提取Content-Disposition到Content-Type之间的内容 k3 = ngx.re.match(kkkkk[0],[[Content-Disposition:.+Content-Type:]],"jo")ifnot k3 thenreturnfalseend-- 检查格式是否符合标准ifnot ngx.re.match(k3[0],[[Content-Disposition: form-data; name=".+"; filename=""Content-Type:]],'ijo') andnot ngx.re.match(k3[0],[[Content-Disposition: form-data; name=".+"; filename=".+"Content-Type:]],'ijo') then ngx.ctx.is_type = '恶意上传'return IpInfo.lan_ip('upload','非法上传请求已被系统拦截,并且被封锁IP7')endend k3 = k3[0]-- 检查filename字段格式ifnot ngx.re.match(k3,[[filename=""Content-Type]],'ijo') andnot ngx.re.match(k3,[[filename=".+"Content-Type]],'ijo') then upload.return_error(8)else-- 提取filename的值进行检查local check_filename = ngx.re.match(k3,[[filename="(.+)"Content-Type]],'ijo')if check_filename thenif check_filename[1] then-- 检查文件名中是否包含危险字符串if ngx.re.match(check_filename[1],'name=','ijo') thenreturn upload.return_error(9) endif ngx.re.match(check_filename[1],'php','ijo') thenreturn upload.return_error(10) endif ngx.re.match(check_filename[1],'jsp','ijo') thenreturn upload.return_error(11) endendend-- 检查Content-Disposition头是否过长if #k3 >= 500then ngx.ctx.is_type = '文件名过长' IpInfo.bt_ip_filterwrite_log('upload','上传的文件名太长了,被系统拦截')return Public.return_message(200,'The uploaded file name is too long')end-- 转换为小写进行检查 k3 = string.lower(k3)-- 检查站点配置是否存在if Site_config[server_name] == nilthenreturnfalseend-- 获取禁止上传的文件扩展名列表local disa = Site_config[server_name]['disable_upload_ext']-- 检查是否包含禁止的文件扩展名if BTWAF_OBJS.request_check.is_ngx_match(disa,k3,'post') then ngx.ctx.is_type = '恶意上传' IpInfo.lan_ip('upload','上传非法PHP文件被系统拦截,并且被封锁IP1'..' >> '..k3)returntrueendendendendendend
大boss ext3
函数拦截的数据包主要有:
多重 filename 字段攻击
通过检测一个表单项中出现多个 filename= 字符串来防御。
Content-Disposition: form-data; name="file"; filename="test.jpg" filename="shell.php"
畸形的 Content-Disposition 格式
检测引号转义或不匹配的情况,防止绕过文件名检查。
Content-Disposition: form-data; name="file"
过长的 Content-Disposition 头
通过检测 Content-Disposition:.{200} 和 Content-Disposition:.{500} 来防御头部过长的攻击。
畸形的表单字段名称格式
检测重复的 name 参数或格式不正确的 name 参数。
Content-Disposition: form-data; name="file";;name="shell.php"
隐藏的 filename 字段
检测在表单项中隐藏的 filename 字段。
Content-Disposition: form-data; name="file"...filename=shell.php
分散的恶意字符
通过正则表达式s*r*n*ns*r*n*as*r*n*ms*r*n*es*r*n*=
检测字符之间插入空白字符的情况。
Content-Disposition: form-data; name="file"; filename="shell.php"
文件名中包含恶意字符串
检测文件名中包含 name= 等可能用于注入的字符串。
Content-Disposition: form-data; name="file"; filename="name=shell.php"
特定扩展名的文件
检测上传的文件是否包含 PHP、JSP 等危险扩展名。
Content-Disposition: form-data; name="file"; filename="shell.php"Content-Disposition: form-data; name="file"; filename="shell.jsp"
过长的文件名
通过检测文件名长度超过 500 字符来防御。
Content-Disposition: form-data; name="file"; filename="very_long_filename_over_500_characters..."
畸形的 Content-Type 字段
检测 Content-Type 与文件扩展名不匹配的情况。
Content-Disposition: form-data; name="file"; filename="shell.jpg"Content-Type: application/x-php
特殊字符绕过
检测文件名中包含反斜杠等特殊字符的情况。
Content-Disposition: form-data; name="file"; filename="shell.php"
如你所见。
post_data_chekc()
在结束了漫长的post_data()
检查后,我们来到了post_data_chekc
。
细心的你可能以为的单词拼写错了,但是其实原生代码里就是写的这样。
很难受,这又是一个又臭又长的检测函数,又重复检测了之前检测过的内容。
他先是检查是否启用了文件上传防护,这个只要是付费版默认是开的。
然后获取请求头中的 content-length
并转换为数字。
如果没有 content-length 头或者内容长度超过约 100MB (108246867 字节),则直接返回 false。
判断请求方法是否为post,然后读取相关信息。
我觉得这里的逻辑似乎有点问题“if content_length >108246867 then return false end
”这要满足这个条件就不会进行下一步的检查。
ifnot Config['file_upload'] ornot Config['file_upload']['open'] thenreturnfalseendlocal content_length=tonumber(ngx.ctx.request_header['content-length'])ifnot content_length thenreturnfalseendif content_length >108246867thenreturnfalseendif ngx.ctx.method =="POST"thenlocal return_post_data=upload.return_post_data2()ifnot return_post_data thenreturnfalseendif return_post_data==3thenreturnfalseend ngx.req.read_body()local request_args2=ngx.req.get_body_data()ifnot request_args2 then request_args2=ngx.req.get_body_file()if request_args2==nilthenreturnfalseend request_args2=Public.read_file_body(request_args2)end
检查请求体,content-type
是否存在,检查content-type
是否是字符串(这里不知为何检查了两遍?)
使用 multipart 库
创建一个新的解析器对象,传入请求体数据和 HTTP Content-Type 头部。
如果创建失败(例如,格式不正确),则返回 false。
这里又是新的一轮的上传协议标准化检测。
之后检查请求体的边界是否被正确结束,正常来说一定是以--结束。
否则报错:“btwaf is from-data error”
,这个在测试waf中多次出现过的。
检查Content-Disposition: form-data 出现的次数,大于一报错:btwaf is from-data error2
检查后缀是否为php和jsp,Content-Disposition 头部的格式是否正确,文件名是否超过1000
个字符,检查文件名中是否包含“name=
"能否给你绕waf带来启发呢?
ifnot request_args2 thenreturnfalseendifnot ngx.ctx.request_header['content-type'] thenreturnfalseendiftype(ngx.ctx.request_header['content-type']) ~= "string"theniftype(ngx.ctx.request_header['content-type']) ~= "string"then upload.return_error(13)endendlocal p, err = multipart.new(request_args2, ngx.var.http_content_type)ifnot p thenreturnfalseendifnot ngx.re.match(upload.ReadFileHelper(p['body']),upload.is_substitution(upload.ReadFileHelper(p['boundary2']))..'--$','ijo') thenreturn Public.return_message(200,"btwaf is from-data error")endlocal site_count=0local array = {}whiletruedolocal part_body, name, mime, filename,is_filename,header_data = p:parse_part()if header_data thenlocal header_data_check=ngx.re.gmatch(header_data,[[Content-Disposition: form-data]],'ijo')local ret={}whiletruedolocal m, err = header_data_check()if m thentable.insert(ret,m)elsebreakendendif Public.arrlen(ret)>1thenreturn Public.return_message(200,"btwaf is from-data error2")endendifnot is_filename thenbreakend site_count=site_count+1if is_filename thenlocal filename_data=ngx.re.match(is_filename,'filename.+','ijo')if filename_data then ngx.ctx.is_type='webshell防御'if ngx.re.match(filename_data[0],'php','ijo') thenreturn IpInfo.lan_ip('upload','上传非法PHP文件被系统拦截,并且被封锁IP13') endif ngx.re.match(filename_data[0],'\.jsp','ijo') thenreturn IpInfo.lan_ip('upload','上传非法PHP文件被系统拦截,并且被封锁IP14') endif Config['from_data'] thenifnot ngx.re.match(is_filename,'^Content-Disposition: form-data; name=".+"; filename=".+"Content-Type:','ijo') andnot ngx.re.match(is_filename,'^Content-Disposition: form-data; name=".+"; filename=""Content-Type:','ijo') then ngx.ctx.is_type='恶意上传' ngx.var.waf2monitor_blocked="恶意上传文件"ifnot ngx.re.match(is_filename,'^Content-Disposition: form-data; name="filename"$',"ijo") andnot ngx.re.match(is_filename,'^Content-Disposition: form-data; name=".+"$',"ijo") thenreturn upload.return_error(20)endendendendif(#is_filename)>1000then ngx.var.waf2monitor_blocked="恶意上传" ngx.ctx.is_type="文件名过长" IpInfo.lan_ip('upload','非法上传文件名长度超过1000被系统拦截,并封锁IP15') endendif filename ~=nilthen ngx.ctx.is_type='webshell防御'if ngx.re.match(filename,'php','ijo') thenreturn IpInfo.lan_ip('upload','上传非法PHP文件被系统拦截,并且被封锁IP15') endif ngx.re.match(filename,'\.jsp','ijo') thenreturn IpInfo.lan_ip('upload','上传非法PHP文件被系统拦截,并且被封锁IP16') endif ngx.re.match(filename,'name=','ijo') thenreturn upload.return_error(15) endif (#filename)>=1000then ngx.var.waf2monitor_blocked="恶意上传文件" IpInfo.lan_ip('upload','非法上传文件名长度超过1000被系统拦截,并封锁IP15') endendif name ==nilthenif part_body thenif #part_body>30then array[upload.utf8sub(part_body,1,30)]=part_bodyelse array[part_body]=part_bodyendendelseif #name >300then upload.return_error(16)endif filename ==nilthenif upload.table_key(array,name) thenfor i=1, 1000doifnot upload.table_key(array,name..'_'..i) thenif #name>30then array[upload.utf8sub(name,1,30)..'_'..i]=part_body else array[name..'_'..i]=part_body endbreakendendelseif #name >30then array[upload.utf8sub(name,1,30)]=part_bodyelse array[name]=part_bodyendendiftype(part_body)=='string'thenif (#part_body) >=400000then ngx.ctx.is_type="参数过长" IpInfo.write_log('sql',name..' 参数值长度超过40w已被系统拦截') Public.return_html(Config['post']['status'],BTWAF_RULES.post_html)returntrueendendelse
接下来会检测文件上传的内容,这些你千万别包含
这一大串规则还是挺唬人的。但是你懂吧。
if ngx.re.find(part_body,[["php"]],'ijo') or ngx.re.find(part_body,[['php']],'ijo') or ngx.re.find(part_body,[[<?]],'ijo') or ngx.re.find(part_body,[[phpinfo(]],'ijo') or ngx.re.find(part_body,[[$_SERVER]],'ijo') or ngx.re.find(part_body,[[<?php]],'ijo') or ngx.re.find(part_body,[[fputs]],'ijo') or ngx.re.find(part_body,[[file_put_contents]],'ijo') or ngx.re.find(part_body,[[file_get_contents]],'ijo') or ngx.re.find(part_body,[[eval(]],'ijo') or ngx.re.find(part_body,[[$_POST]],'ijo') or ngx.re.find(part_body,[[$_GET]],'ijo') or ngx.re.find(part_body,[[base64_decode(]],'ijo') or ngx.re.find(part_body,[[$_REQUEST]],'ijo') or ngx.re.find(part_body,[[assert(]],'ijo') or ngx.re.find(part_body,[[copy(]],'ijo') or ngx.re.find(part_body,[[create_function(]],'ijo') or ngx.re.find(part_body,[[preg_replace(]],'ijo') or ngx.re.find(part_body,[[preg_filter(]],'ijo') or ngx.re.find(part_body,[[system(]],'ijo') or ngx.re.find(part_body,[[header_register_callback(]],'ijo') or ngx.re.find(part_body,[[curl_init(]],'ijo') or ngx.re.find(part_body,[[curl_error(]],'ijo') or ngx.re.find(part_body,[[fopen(]],'ijo') or ngx.re.find(part_body,[[stream_context_create(]],'ijo') or ngx.re.find(part_body,[[fsockopen(]],'ijo') then
之后还会调用go语言写的检测引擎最后做一次检测
iftype(part_body) =='string'and part_body ~=nilthenif ngx.re.find(part_body,[["php"]],'ijo') or ngx.re.find(part_body,[['php']],'ijo') or ngx.re.find(part_body,[[<?]],'ijo') or ngx.re.find(part_body,[[phpinfo(]],'ijo') or ngx.re.find(part_body,[[$_SERVER]],'ijo') or ngx.re.find(part_body,[[<?php]],'ijo') or ngx.re.find(part_body,[[fputs]],'ijo') or ngx.re.find(part_body,[[file_put_contents]],'ijo') or ngx.re.find(part_body,[[file_get_contents]],'ijo') or ngx.re.find(part_body,[[eval(]],'ijo') or ngx.re.find(part_body,[[$_POST]],'ijo') or ngx.re.find(part_body,[[$_GET]],'ijo') or ngx.re.find(part_body,[[base64_decode(]],'ijo') or ngx.re.find(part_body,[[$_REQUEST]],'ijo') or ngx.re.find(part_body,[[assert(]],'ijo') or ngx.re.find(part_body,[[copy(]],'ijo') or ngx.re.find(part_body,[[create_function(]],'ijo') or ngx.re.find(part_body,[[preg_replace(]],'ijo') or ngx.re.find(part_body,[[preg_filter(]],'ijo') or ngx.re.find(part_body,[[system(]],'ijo') or ngx.re.find(part_body,[[header_register_callback(]],'ijo') or ngx.re.find(part_body,[[curl_init(]],'ijo') or ngx.re.find(part_body,[[curl_error(]],'ijo') or ngx.re.find(part_body,[[fopen(]],'ijo') or ngx.re.find(part_body,[[stream_context_create(]],'ijo') or ngx.re.find(part_body,[[fsockopen(]],'ijo') thenlocal php_version=7if Site_config[ngx.ctx.server_name]['php']~=nilthen php_version=tonumber(Site_config[ngx.ctx.server_name]['php'])endif php_engine.php_detected(part_body,php_version)==1then ngx.var.waf2monitor_blocked="webshell防御" ngx.ctx.is_type='webshell防御' IpInfo.lan_ip('upload','webshell防御.拦截木马上传,并被封锁IP')endendend
php_engine
local _M = {}local bit = require"bit"local ffi = require"ffi"local ffi_new = ffi.newlocal ffi_string = ffi.stringlocal lib, loadedffi.cdef[[ typedef signed char GoInt8; typedef unsigned char GoUint8; typedef short GoInt16; typedef unsigned short GoUint16; typedef int GoInt32; typedef unsigned int GoUint32; typedef long long GoInt64; typedef unsigned long long GoUint64; typedef GoInt64 GoInt; typedef GoUint64 GoUint; typedef size_t GoUintptr; typedef float GoFloat32; typedef double GoFloat64; typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1]; typedef struct { const char *p; ptrdiff_t n; } _GoString_; typedef _GoString_ GoString; typedef void *GoMap; typedef void *GoChan; typedef struct { void *t; void *v; } GoInterface; typedef struct { void *data; GoInt len; GoInt cap; } GoSlice; GoUint8 PHPDetected(GoString path, GoInt versions); GoUint8 XssParse(GoString text); struct NadyParse_return { GoUint8 r0; char* r1; }; struct NadyParse_return NadyParse(GoString data, GoString urlinfo);]]localfunction_loadlib()if (notloaded) thenlocalpath=BTWAF_INC..'/php_engine.so'if WAF_SYSTEM=="arm"thenpath=BTWAF_INC..'/php_engine_arm.so'endlocal ok=false ok,lib=pcall(function()return ffi.load(path)end)ifnot ok then lib=falseendif (lib) thenloaded = truereturntrueelsereturnfalseendelsereturntrueendendfunction_M.php_detected(string,version)if (notloaded) thenif (not _loadlib()) thenreturnfalseendendif #string>500*1024thenreturnfalseendlocal info = ffi.new("GoString", {string, #string})return lib.PHPDetected(info,version)endfunction_M.xss_detected(string)if (notloaded) thenif (not _loadlib()) thenreturnfalseendendlocal goStr = ffi.new("GoString", {string, #string})return lib.XssParse(goStr)endfunction_M.nday_detected(str,urlinfo)if (notloaded) thenif (not _loadlib()) thenreturnfalseendendlocal data = ffi.new("GoString", {str, #str})local url = ffi.new("GoString", {urlinfo, #urlinfo})local ret = lib.NadyParse(data,url)return ret.r0,ffi.string(ret.r1)endreturn _M
到这里大概几个检测的函数就都过了一遍了。
结语
这篇文章不是终点,waf的分析和绕过任重而道远。
希望能通过这次的代码梳理更加清晰的学习和了解到对手的拦截方式和规则(目前只是皮毛so文件还没看),也希望可以通过研究规则来学习绕过的经验(毕竟宝塔公司绕waf能力还是很强的),里面的每一条规则可能都是曾经被用来通杀强吃各种商业waf的手法的,非常值得学习。
还要感谢a牛的指点(太笨了还是没悟出来),之后还会继续研究并且发表出来和兄弟们一起学习研究。
原文链接
https://www.t00ls.com/articles-73512.html
原文始发于微信公众号(T00ls安全):宝塔bt_waf 拦截流程详细分析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论