从WAF开发角度看文件上传的攻与防

现在市面上大部分文章都是站在攻击方的角度来分析WAF文件上传功能的绕过,这篇文章站在防御方,也就是WAF开发者角度来分析WAF文件上传的攻与防。

一、前言

这几天在重写JXWAF的文件上传处理模块,初期只关注了功能实现,写完后碰巧看到【技术分享】文件上传和WAF的攻与防(https://www.secpulse.com/archives/66100.html)这篇文章,发现成了标准的绕过样本,赶紧把防绕过的代码补上。现在市面上大部分文章都是站在攻击方的角度来分析WAF文件上传功能的绕过,这篇文章站在防御方,也就是WAF开发者角度来分析WAF文件上传的攻与防。

二、代码分析

以下为核心的功能实现代码:

if content_type and  ngx.re.find(content_type, [=[^multipart/form-data; boundary=]=],"oij") and tonumber(ngx.req.get_headers()["Content-Length"]) ~= 0 then
local form, err = upload:new()
local _file_name = {}
local _form_name = {}
local _file_type = {}
local t ={}
local _type_flag = "false"
if not form then
ngx.log(ngx.ERR, "failed to new upload: ", err)
ngx.exit(500)
end
ngx.req.init_body()
ngx.req.append_body("--" .. form.boundary)
local lasttype, chunk
local count = 0
while true do
count = count + 1
local typ, res, err = form:read()
if not typ then
ngx.say("failed to read: ", err)
return nil
end
if typ == "header" then
chunk = res[3]
ngx.req.append_body("\r\n" .. chunk)
if res[1] == "Content-Disposition" then
local _tmp_form_name = ngx.re.match(res[2],[=[(.+)\bname="([^"]+)"(.*)]=],"oij")
local _tmp_file_name = ngx.re.match(res[2],[=[(.+)filename="([^"]+)"(.*)]=],"oij")
if _tmp_form_name then
table.insert(_form_name,_tmp_form_name[2]..count)
end
if _tmp_file_name then
table.insert(_file_name,_tmp_file_name[2])
end

end
if res[1] == "Content-Type" then
table.insert(_file_type,res[2])
_type_flag = "true"
chunk = string.format([=[Content-Type: %s]=],res[2])
ngx.req.append_body("\r\n" .. chunk)
end
end
if typ == "body" then
chunk = res
if lasttype == "header" then
ngx.req.append_body("\r\n\r\n")
end
ngx.req.append_body(chunk)
if _type_flag == "true" then
_type_flag = "false"
t[_form_name[#_form_name]] = ""
else
if lasttype == "header" then
t[_form_name[#_form_name]] = res
else
t[_form_name[#_form_name]] = ""
end
end
end
if typ == "part_end" then
ngx.req.append_body("\r\n--" .. form.boundary)
end
if typ == "eof" then
ngx.req.append_body("--\r\n")
break
end
lasttype = typ
end

对于文件上传功能的检测,主要关注两点,一个是文件名的获取,另一个是文件类型的获取。

首先来谈谈第一种绕过情形

4.11 Header在boundary前添加任意字符

问题出现在对Content-Type的匹配上,上述代码对Content-Type使用的匹配正则为”^multipart/form-data; boundary=”",从功能实现上来说,正则这样写没啥问题,但是当出现类似上述的情况时,比如Content-Type: multipart/form-data; jxwafboundary=—————————9585485410332,即可绕过正则,因为正则要求; boundary中间只能存在一个空格,不符合就无法匹配,当不匹配时一般就被当成普通POST请求处理,无法获取上传文件相关参数的情况下,自然也就无法防护。

针对这种绕过方法,防绕过也比较简单,初期的方法是将正则修改为”^multipart/form-data;.*?boundary=”,后来觉得搞不好会有乱七八糟的后端语言支持其他奇葩方式,就干脆将正则改为”^multipart/form-data”。

4.1去掉引号

4.2 双引号变成单引号

4.3 大小写

4.4 空格

4.6 交换name和filename的顺序

4.8 多个filename

4.9 多个分号

4.13 name和filename添加任意字符串

以上都可以归类为正则绕过的问题,所以统一来讲。

首先来看用于文件名获取的正则”(.+)filename=”([^"]+)”(.*)”

正常匹配如下图

TIM图片20180413152537.png

可以成功匹配到,现在试试第一种绕过方法,去掉引号

TIM2.png

可以看到,正则匹配到filename=“就结束了,匹配失败,经测试可以成功上传文件

3.png

单引号一样的情况,匹配失败

4.png

大小写匹配失败,但是因为开启了大小写不敏感的匹配模式,所以上述代码不存在该问题,不过如果上述代码改为

ngx.re.match(res[2],[=[(.+)filename="([^"]+)"(.*)]=])

没有指定匹配模式的话,就存在该问题

5.png

在空格匹配失败

6.png

交换name和filename的顺序匹配成功

7.png

多个filename匹配成功,但是只获取了最后一个filename的值,成功绕过检测

8.png

多个分号匹配成功,无法绕过

9.png

name和filename添加任意字符串,匹配成功

4.7 多个boundary

这种特殊些,跟正则没啥关系,上述代码不存在该问题,主要是因为采用数组将所有的文件名参数都存入,所以当出现多个boundary的情况,可以确保文件名不会出现丢失或者只获取后面文件名的情况,一家人就要整整齐齐。

想要复现该绕过,只需要以name为键,filename为值,那么当多个boundary的时候,后面的数值就会覆盖之前的导致绕过的情况。

三、绕过修复

上面主要讲了攻击绕过的情况,接下来谈谈如何修复的问题。

首先,以上的绕过方法不是全部,肯定还有其他绕过方法,没有最”猥琐”只有更”猥琐”,人老了比不了小年轻天天有精力研究各种新知识。所以如果发现一起在修复一起的话就太被动。既然这样,那么我们只需要把他们拉到跟我们一样的水平,就能以丰富的经验来解决绕过这个问题。

解决方法可以概况为一句话: 我得不到的,你也别想要

以下为修复后的代码:

	if content_type and  ngx.re.find(content_type, [=[^multipart/form-data]=],"oij") and tonumber(ngx.req.get_headers()["Content-Length"]) ~= 0 then
		local form, err = upload:new()
		local _file_name = {}
		local _form_name = {}
		local _file_type = {}
		local t ={}
		local _type_flag = "false"
		if not form then
			ngx.log(ngx.ERR, "failed to new upload: ", err)
			ngx.exit(500)	
		end
		ngx.req.init_body()
		ngx.req.append_body("--" .. form.boundary)
		local lasttype, chunk
		local count = 0
		while true do
			count = count + 1
			local typ, res, err = form:read()
                if not typ then
                    ngx.say("failed to read: ", err)
                	return nil
                end
				if typ == "header" then
				--	chunk = res[3]
				--	ngx.req.append_body("\r\n" .. chunk)
                    if res[1] == "Content-Disposition" then
                    	local _tmp_form_name = ngx.re.match(res[2],[=[(.+)\bname=[" ']*?([^"]+)[" ']*?]=],"oij")
						local _tmp_file_name =  ngx.re.match(res[2],[=[(.+)filename=[" ']*?([^"]+)[" ']*?]=],"oij")
                    	if _tmp_form_name  then
                        	table.insert(_form_name,_tmp_form_name[2]..count)
						end
						if _tmp_file_name  then
							table.insert(_file_name,_tmp_file_name[2])
						end
						if _tmp_form_name and _tmp_file_name then
							chunk = string.format([=[Content-Disposition: form-data; name="%s"; filename="%s"]=],_tmp_form_name[2],_tmp_file_name[2])
							ngx.req.append_body("\r\n" .. chunk)
						elseif _tmp_form_name then
							chunk = string.format([=[Content-Disposition: form-data; name="%s"]=],_tmp_form_name[2])
							 ngx.req.append_body("\r\n" .. chunk)
						else
							ngx.log(ngx.ERR,"Content-Disposition ERR!")
							ngx.exit(503)
						end

                	end
                	if res[1] == "Content-Type" then
                    	table.insert(_file_type,res[2])
						_type_flag = "true"
						chunk = string.format([=[Content-Type: %s]=],res[2])
						ngx.req.append_body("\r\n" .. chunk)
                	end
            	end
				if typ == "body" then
					chunk = res
					if lasttype == "header" then
						ngx.req.append_body("\r\n\r\n")
					end
					ngx.req.append_body(chunk)
                    if _type_flag == "true" then
                        _type_flag = "false"
						t[_form_name[#_form_name]] = ""
					else
						if lasttype == "header" then
							t[_form_name[#_form_name]] = res
						else
							t[_form_name[#_form_name]] = ""
						end
                    end
				end
				if typ == "part_end" then 
					ngx.req.append_body("\r\n--" .. form.boundary)
				end
				if typ == "eof" then
					ngx.req.append_body("--\r\n")
                    break
				end
				lasttype = typ
		end  

最开始的解决方案是把正则的问题修复好,然后思前想后觉得这也不是事,就想出这么个釜底抽薪的办法,直接按照标准上传的格式重写整个上传请求,这样就能保证,只要经过规则检查过的参数,才能出现在后端接受的请求中,如果WAF解析后得到的是畸形的参数值,比如获取的文件名为空,那么这样虽然不会被WAF的规则干掉,但是到后端也没有实际意义。

四、总结

JXWAF已经开源

有兴趣的可以看看:https://github.com/jx-sec/jxwaf

对WAF开发感兴趣的可以加群交流

QQ群 730947092

感谢MSLRC的技术分享。

取消
Loading...
css.php