一、前言
灵感来自于一次群里的讨论,因为现在市面上关于自学习功能的公开资料比较少,加之没有碰过相应的设备,只能自己瞎想来构思功能实现,码完功能后发现效果还不错,特此分享。
首先来看白名单功能的描述,各大厂商对白名单功能的定义有些微差别,但大体上是一致的,总结起来就是利用各种技术实现如下目标:
1、无需用户干预,或是只需用户少量操作。
2、降低waf漏报率。
3、提升检测速度(下篇讲)
从目前公开的资料来看,各大厂商主要还是基于概率统计学为核心来实现上述功能,通过对网站的流量进行安全分析与收敛来实现网站的规则自适应配置或防护模型的生成。
然而根据群里讨论的反馈,基于概率统计学来生成模型普遍存在两个问题,一个是学习时间太长,或者说,在业务频繁变更的场景下,模型生成成本较高,. 例如上线新业务或者业务变更的时候,进行自学习,结果还没学习结束,又有新功能上线,导致算法模型一直无法收敛。还有就是每次业务变更都需重写学习生成模型,导致业务一当变动就得重新训练。第二个则是误报率较高,一当线上业务被误拦截,用户即下线该功能,进而使功能成为摆设,这个问题我认为得分为两个方面来看,一个是很多用户并非专业的安全人员,导致无法对功能的一些参数为针对网站的情况进行定制配置,另外一个我认为是概率统计学在一些业务场景下存在的缺陷。例如用户评论场景,在学习过程中,用户一般输入都不会超过100字符,那么模型就会认为该参数在100字符以内是合理,当用户输入超过100字符,比如500字符时,就可能触发模型进而被拦截。概率统计学对于输入的数值范围,或者字符的识别都可能存在一样问题,虽然理论上只要数据量足够,学习的时间足够长,是能优化这块的问题。但是现实跟理论的差距还是有距离的···
总结一下就是
1、不敢用 //担心误报阻断业务
2、不好用 //学习成本较高
以上分析为瞎扯,理解有误欢迎讨论。
针对概率统计学存在的情况,所以jxwaf的自学习模式不打算用概率统计学来做,就像空肥皂盒和电风扇故事里的那样,这次要实现的就是那个小风扇,后面在慢慢升级为机器手臂。
二、功能实现
首先来谈谈功能防护的核心逻辑,这涉及到一个问题,什么样的输入是攻击?也就是如何区分用户输入的是正常数据,还是攻击语句。根据经验,像sql注入,XSS,远程执行等,都是将用户输入的数据,当成程序的执行逻辑去处理,进而导致攻击发生。所以如果我们站在这个方面去去思考,就很清晰了,以lua为例,是如何区分数据和执行逻辑的呢?这里有个绕过关键是就是特殊字符,你不可能输入"asdasdasdas1312312"然后让程序去理解执行,必须输入类似"local a=b"的字符串,这样解样器通过空格和=符号,语义分析后才知道你要干什么,所以防护的关键就是将用户输入的特殊字符给拎出来进而进行识别分析。
目前jxwaf的自学习模式已经实现了三个维度的分析,分别如下:
正则自适应匹配
长度分析
词法分析
jxwaf的自学习模式分为学习过程和检测过程,先说第一个维度正则自适应匹配,核心代码如下:
学习过程的正则自适应匹配函数:
local function white_learn_rx(value)
local learn_value = value
local level
local level_one = _white_config.level_one or [=[\W]=]
local level_two = _white_config.level_two or [==[[^\w ,"().]]==]
local level_three = _white_config.level_three or [==[[^\w ,"().@-_/:]]==]
local level_four = _white_config.level_four or [==[[<>'\*%#~?;]]==]
if (not ngx.re.find(learn_value, level_one,"oij")) then
level = 1
elseif (not ngx.re.find(learn_value, level_two,"oij")) then
level = 2
elseif (not ngx.re.find(learn_value, level_three,"oij")) then
level = 3
elseif (not ngx.re.find(learn_value, level_four,"oij")) then
level = 4
else
level = 6
end
return level
end
在该维度主要是对web请求的参数值进行筛选,进而自动匹配生成适合该参数值的白名单正则表达式,一共分为五层:
第一层,参数值为数字字母和_组成
第二层,参数值为数字字母和_,“().组成 //含常用字符
第三层,参数值为数字字母和_,“().@-/组成 //含常见特殊字符
第四层,参数值不包含<>‘*%#~?; // 不含危险特殊字符
第五层,你赢了 //含任意字符
当用户的web请求过来的时候,正则自适应匹配函数会自行进行识别,进而返回相应函数的匹配值,当相同url的请求再次接收时,又再次进行一次匹配,如果匹配的数值高于之前的匹配结果,那么对应参数的匹配值将被重置。举个例子,如第一次输入数据为a="AAABBB2123123",那么参数a的匹配值为1,如果后续输入的数据为"AAVBBJKKKAD@",那么匹配到的值为3,针对该参数a的匹配将被重置为3,即在web请求学习的过程中,对数据的检测只会越来越宽松,而不会越收越紧,这样的好处是可以显著的降低误报的情况,即宁可放过不错杀,而漏报的情况,则是通过用户人工审核来处理。例如输入的数据被识别为6,即可以包含任意字符的场景,那么可以通过用户人工审核进行调整,如果人工审核分析发现该参数值为固定值,比如用户ID类的数据,那么可以将6强行修改为1,并设置为不可修改即可。以目前线上业务的复杂情况,指望靠一种技术完美解决安全问题是不可能的,所以引入用户人工审核是有必要的,当然就算不审核,因为宁可放过不错杀的原则,至少能保证业务不受影响,代价则是漏报率会高些,但是配合后续两个维度,以及黑名单规则的防护及其他系统的防护,整体风险是可以接受的。
检测过程的正则自适应匹配函数
local function white_check_rx(value,level)
local check_value = value
local check_level = level
local level_one = _white_config.level_one or [=[\W]=]
local level_two = _white_config.level_two or [==[[^\w ,"().]]==]
local level_three = _white_config.level_three or [==[[^\w ,"().@-/:]]==]
local level_four = _white_config.level_four or [==[[<>'\*%#~?;]]==]
local result = nil
if check_level == 1 and ngx.re.find(check_value, level_one,"oij") then
result = 1
elseif check_level == 2 and ngx.re.find(check_value, level_two,"oij") then
result = 2
elseif check_level == 3 and ngx.re.find(check_value, level_three,"oij") then
result = 3
elseif check_level == 4 and ngx.re.find(check_value, level_four,"oij") then
result = 4
elseif type(check_level) == string and ngx.re.find(check_value,check_level,"oij") then
result = 5
else
end
return result
end
该函数相比于学习函数,最大的区别在于,在检测阶段,支持用户自定义正则,一旦匹配到自学习模式记录的值为字符串类型的时候,将使用自定义正则进行检测,用户可以在管理页面通过该功能针对一些特定的业务场景进行定制防护,提高自学习模式的通用性。
该维度可以简单的理解为为每个URL中的每个参数独立生成白名单规则。比起直接配置全局的白名单规则,能实现更低的误报率,。
接下来讲长度维度,长度维度涉及到的配置不多,如下:
Learn length bypass 值为 true或者false 如果为true 那么检测到异常只告警不拦截
Learn length bypass length 值为数字 默认值为30 在检测模式下 如果输入的数据长度小于该值将不进行处理
Learn length limit 值为数字 默认值为1500 用户输入的参数值最大长度,以中文字符3字节来算,1500相当于500个中文字符,即正常留言功能最大长度
Learn length update conut 值为数字 默认值为5 自学习模式中长度参数的迭代次数,例如长度更新了5次,则说明该参数名的值没有固定长度,长度值将被设置为Learn length limit的值
长度维度的学习跟正则维度是一样的,当新的参数值长度比旧的参数值长度大时,会覆盖旧长度,这么处理同样是基于降低误报的考虑。
最后是词法分析维度,以下为词法分析的核心处理函数:
local function _process_string(arg)
local result = {}
local _arg = arg
for count=1,#_arg,1 do
local tmp = string.byte(_arg,count)
if (tmp >= 0 and tmp <= 31) or tmp == 127 then
-- control
if char ~= 'c' then
table.insert(result,'c')
char = 'c'
end
elseif (tmp >= 48 and tmp <= 57) or (tmp >= 65 and tmp <= 90) or (tmp >= 97 and tmp <= 122) or tmp > 127 then
-- normal
if char ~= 'n' then
table.insert(result,'n')
char = 'n'
end
elseif (tmp >= 32 and tmp <=34) or tmp == 40 or tmp == 41 or tmp == 44 or tmp == 46 or tmp == 64 or tmp == 95 or tmp == 63 then
-- usual char (space) ! " () _ @ . ,?
if char ~= 'u' then
table.insert(result,'u')
char = 'u'
end
elseif (tmp >= 35 and tmp <= 39 ) or tmp == 42 or tmp == 43 or tmp== 45 or tmp == 47 or (tmp >= 58 and tmp <= 62) or (tmp >= 91 and tmp <=96) or (tmp >= 123 and tmp <= 126) then
-- anomaly char
if char ~= 'a' then
table.insert(result,'a')
char = 'a'
end
else
ngx.log(ngx.ERR,"process string error ",_arg)
end
end
return table.concat(result)
end
词法分析的思路是,通过将字符转换为ascii的十进制,之后在进行分类,分类规则如下:
0~31及127(共33个)是控制字符或通信专用字符,标记为 c ,即 control的意思
数字,字母,中文等分为一类,标记为n, normal的意思
空格 ! " () _ @ . ,? 标记为u ,即usual ,常见字符的意思
其他诸如’*{}等标记为a,即anomaly,异常字符的意思
在进行分类转换的同时,相同的特征将被合并为一个,例如,输入为"aaaaaabbbbb哇哇哇哇",将被转换为n,"你好啊",也将被转换为n,而"你 好啊",则将被转换为,nun
跟长度维度一样,词法分析维度一样有迭代次数限制,默认词法分析最多存储5个特征,当特征数多于5个时,也即意味该参数没有固定的特征模式,该维度的检测也将进入bypass模式.
关于全局配置
白名单模式的全局配置如下:
Force_reject 值为 true或者false 默认为false 如果为true,未被学习到的参数和url请求访问时将被拒绝
Igonre uri 值为正则 当Force_rejec为true时,匹配该正则的url请求将被放行
check_reject 值为 true或者false 默认为true 未能通过检测的请求将被拒绝,设置为false则只记录日志不阻断
learn_count 默认为1000 当同一URL请求,经过1000次的学习迭代后,针对该url的学习数据才会生效
三 案例分析
絮絮叨叨说完细节后,接下来进行案例分析,以dvwa的登陆接口为例进行分析.
登陆接口uri请求为 /login.php
参数为
username=admin&password=password&Login=Login&user_token=2e6713d4ee3f91b9d56e17b0bf180478
第一次学习完后的数据为
{"Login":[1,5,["n"],"true"],"user_token":[1,32,"["n"],"true"],"username":[1,5,["n"],"true"],"password":[1,8,["n"],"true"]}
数组的第一位为正则自适应匹配的值,第二位为长度.第三位为对应的词法特征,第四位为长度迭代次数.
接下来输入2000条数据进行自学习,因2000次的迭代次数超过默认数据迭代次数learn_count值1000,所以成功生成学习数据,学习完后的数据为
{"Login":[1,5,["n"],"true"],"user_token":[1,32,"["n"],"true"],"username":[3,21,["n","nunun"],"false"],"password":[6,32,["n","nu","nua","aun","na","nana"],"false"]}
Login参数的值为固定值"Login",所以经过一段时间的学习后,学习数据跟第一次学习的数据是一样的
user_token为32位的十六进制数据,所以经过学习过程后,一样没有变化
username参数经过学习过程后,正则自适应的匹配值由1变化为了3,长度则达到了21,并且因为超过默认5次的迭代次数,长度维度的检测生效,词法分析维度则由之前的n,增加了nunun
通过查看日志发现,最后引起迭代的数据为,testtesttest@test.com
即testtesttest@test.com因存在@和句号导致正则自适应的匹配值由1变化为了3,长度则因多次变化导致失效
词法分析的转换过程如下:
testtesttest转化为n
@转化为u
test转化为n
.转化为u
com转化为n
所以最后的结果为nunun
password参数经过学习后,正则自适应的匹配值由1变化为了6,即该参数包含任意字符,长度维度一样失效,词法分析维度因包含超过5个特征一样处于失效状态.
学习结束后,因数据中password参数的正则自适应的匹配值为6,属于"异常"状态,所以在waf管理界面中会提醒用户对该参数进行审计,如发现异常可进行调整.
例如如果Login参数因学习过程中被人进行sql注入攻击了,其参数值变为Login' and 1=1,那么其数据将变化为
"Login":[6,14,["n","naununun"],"true"],那么就可以通过管理员手动进行调整来修复这种"异常"情况,当然管理员也可以选择忽略,那么自学习模式对该参数的防护将处于失效状态,但是至少不会影响业务,后续在调整即可。
学习模式结束后即进入检测模式
为保险起见,可以将Force_reject 和check_reject 设置为false,那么异常请求将只被记录日志而不会被拦截,待稳定运行一段时间后在开启拦截即可.
检测模式还涉及到另外一个问题,即新业务上线的情况.
有新业务上线的情况下,只需将Force_reject设置为false,那么针对新出现的url或者参数将会自动生成相应的学习数据并在waf管理界面进行提醒,管理员经过审计后,选择通过即可更新现有的接口数据.
通过这种方式即可实现增量更新,而无需每次上线新业务都重写学习.
说完整个流程,最后来谈谈绕过的情况.
以username为例,
SQL注入 ' and 1=(select @@VERSION)
命令执行 ls -al
代码执行 phpinfo()
' and 1=(select @@VERSION)
存在' 正则自适应的匹配值为6
长度为24
词法分析的特征值为 aununununu
三个维度均不符合学习后的数据,拒绝请求
ls -al
存在空格和- 正则自适应的匹配值为3
长度为6
词法分析的特征值为 nun
正则维度和长度维度符合.词法分析维度不符合,请求拒绝
phpinfo()
存在() 正则自适应的匹配值为2
长度为9
词法分析的特征值为nu
正则维度和长度维度符合.词法分析维度不符合,请求拒绝
从上述分析来看 想要同时绕过三个维度的检测,还是有难度的,如果有绕过思路欢迎交流.
四 总结
先前提到过基于概率统计学来进行自学习建模可能存在模型无法收敛及误报的问题,那么本文解决这两个问题的思路为1、 增量更新,即支持边检测边学习 2、 宁放过不错杀原则 ,然有得必要失,引入新的解决方案的同时也会带来新的解决问题
1 提高了用户的介入度
2 提高了漏报率
相对于因此产生的收益,我认为代价是可以接受的,而且通过优化管理页面的审计功能,优化默认的配置,可以降低这两方面带来的影响,从而增加功能的通用性。漏报率通过黑名单+全局白名单+自学习+机器学习整体防护机制来控制,最重要的是该功能实现过程并无耗性能的操作,性能消耗也就相当于多了一两条规则的程度,可以实现"锦上添花"。
该功能目前已经开发完毕,将在jxwaf1.0正式版发布时上线,届时欢迎测试。