XSS 漏洞到底是什么,說實話我講不太清楚。但是可以通過遇到的現象了解一下。在前端Form表單的輸入框中,用戶沒有正常輸入,而是輸入了一段代碼:</input><img src=1 onerror=alert1>
?這個正常保存沒有問題。問題出在了列表查詢的時候,上面的代碼就生效了,由于圖片的地址亂寫的,所以這個alert就起作用了來看圖。
?
那根據這個原理,實際上如果沒有做任何的限制,有心人就可以為所欲為了。可以在里面嵌入一些關鍵代碼,把你的信息拿走。確實是個很嚴重的問題。
解決思路
既然是因為輸入框中輸入了不該輸入的東西,那自然就萌生一些想法:
-
校驗輸入內容,不允許用戶輸入特殊字符,特殊標簽
-
允許用戶輸入,但是保存的時候將特殊的字符直接替換為空串
-
允許用戶輸入,將特殊字符轉譯保存。
第一種方法,特殊字符過濾。既然要過濾特殊字符,那就得自己把所有的特殊字符列出來進行匹配,比較麻煩,而且要定義好什么才是特殊字符?況且用戶本身不知道什么是特殊字符。突如其來的報錯,會讓用戶有點摸不著頭腦,不是很友好。
第二種方法,特殊字符替換為空串。未免有點太暴力。萬一真的需要輸入一點特殊的字符,保存完查出來發現少了好多東西,人家以為我們的BUG呢。也不是很好的辦法。
第三種辦法,特殊字符轉譯。這個辦法不但用戶數據不丟失,而且瀏覽器也不會執行代碼。比較符合預期。
那辦法確定了,怎么做呢?前端來做還是后端來做?想了想還是要后端來做。畢竟使用切面或者Filter可以一勞永逸。
心路歷程
經過抄襲,我發現了一些問題,也漸漸的有了一些理解。下面再說幾句廢話:
查到的預防XSS攻擊的,大多數的流程是:
-
攔截請求
-
重新包裝請求
-
重寫
HttpServletRequest
中的獲取參數的方法 -
將獲得的參數進行XSS處理
-
攔截器放行
于是我就逮住一個抄了一下。抄襲完畢例行測試,發現我用@RequestBody
接受的參數,并不能過濾掉特殊字符。怎么肥四?大家明明都這么寫。為什么我的不好使?
這個時候突然一個想法萌生。SpringMVC在處理@RequestBody
類型的參數的時候,是不是使用的我重寫的這些方法呢?(getQueryString()
、getParameter(String name)
、getParameterValues(String name)
、getParameterMap()
)。打了個日志,發現還真不是這些方法。
于是搜索了一下Springboot攔截器獲取@RequestBody
參數,碰到了這篇文章。首先的新發現是Spring MVC 在獲取@RequestBody
參數的時候使用的是getInputStream()
方法。嗯?(斜眼笑)那我是不是可以重寫這個方法獲取到輸入流的字符串,然后直接處理一下?
說干就干,一頓操作。進行測試。發現直接JSON 轉換的報錯了。腦裂。估計是獲得的字符串在轉換的時候把不該轉的東西轉譯了,導致不能序列化了。眼看就要成功了,一測回到解放前。
該怎么辦呢?其實思路是沒錯的,就是在獲取到流之后進行處理。但是錯就錯在處理的位置。果然處理的時間點很重要。(就像伴侶一樣,某人出現的時間點很重要)。那既然不能在現在處理,那就等他序列化完畢之后再處理就好了。那怎么辦呢?難道要寫一個AOP 攔截到所有的請求?用JAVA反射處理?
正在迷茫的時候,看到了一篇文章,知識增加了。原來可以在序列化和反序列化的時候進行處理。
最終實現
看一下最終的代碼實現(有些導入的包被我刪了)
重新包裝Request的代碼
import?org.apache.commons.text.StringEscapeUtils;
import?org.slf4j.Logger;
import?org.slf4j.LoggerFactory;import?javax.servlet.ReadListener;
import?javax.servlet.ServletInputStream;
import?javax.servlet.http.HttpServletRequest;
import?javax.servlet.http.HttpServletRequestWrapper;
import?java.io.BufferedReader;
import?java.io.ByteArrayInputStream;
import?java.io.IOException;
import?java.io.InputStreamReader;
import?java.nio.charset.StandardCharsets;
import?java.util.Map;/***?重新包裝一下Request。重寫一些獲取參數的方法,將每個參數都進行過濾*/
public?class?XSSHttpServletRequestWrapper?extends?HttpServletRequestWrapper?{private?static?final?Logger?logger?=?LoggerFactory.getLogger(XSSHttpServletRequestWrapper.class);private?HttpServletRequest?request;/***?請求體?RequestBody*/private?String?reqBody;/***?Constructs?a?request?object?wrapping?the?given?request.**?@param?request?The?request?to?wrap*?@throws?IllegalArgumentException?if?the?request?is?null*/public?XSSHttpServletRequestWrapper(HttpServletRequest?request)?{super(request);logger.info("---xss?XSSHttpServletRequestWrapper?created-----");this.request?=?request;reqBody?=?getBodyString();}@Overridepublic?String?getQueryString()?{return?StringEscapeUtils.escapeHtml4(super.getQueryString());}/***?The?default?behavior?of?this?method?is?to?return?getParameter(String*?name)?on?the?wrapped?request?object.**?@param?name*/@Overridepublic?String?getParameter(String?name)?{logger.info("---xss?XSSHttpServletRequestWrapper?work??getParameter-----");String?parameter?=?request.getParameter(name);if?(StringUtil.isNotBlank(parameter))?{logger.info("----filter?before--name:{}--value:{}----",?name,?parameter);parameter?=?StringEscapeUtils.escapeHtml4(parameter);logger.info("----filter?after--name:{}--value:{}----",?name,?parameter);}return?parameter;}/***?The?default?behavior?of?this?method?is?to?return*?getParameterValues(String?name)?on?the?wrapped?request?object.**?@param?name*/@Overridepublic?String[]?getParameterValues(String?name)?{logger.info("---xss?XSSHttpServletRequestWrapper?work??getParameterValues-----");String[]?parameterValues?=?request.getParameterValues(name);if?(!CollectionUtil.isEmpty(parameterValues))?{//?經?“@Belief_7”?指正?這種方式不能更改parameterValues里面的值,要換成下面👇的寫法//for?(String?value?:?parameterValues)?{//????logger.info("----filter?before--name:{}--value:{}----",?name,?value);//????value?=?StringEscapeUtils.escapeHtml4(value);//????logger.info("----filter?after--name:{}--value:{}----",?name,?value);//?}for?(int?i?=?0;?i?<?parameterValues.length;?i++)?{?parameterValues[i]?=?StringEscapeUtils.escapeHtml4(parameterValues[i]);?}?}return?parameterValues;}/***?The?default?behavior?of?this?method?is?to?return?getParameterMap()?on?the*?wrapped?request?object.*/@Overridepublic?Map<String,?String[]>?getParameterMap()?{logger.info("---xss?XSSHttpServletRequestWrapper?work??getParameterMap-----");Map<String,?String[]>?map?=?request.getParameterMap();if?(map?!=?null?&&?!map.isEmpty())?{for?(String[]?value?:?map.values())?{/*循環所有的value*/for?(String?str?:?value)?{logger.info("----filter?before--value:{}----",?str,?str);str?=?StringEscapeUtils.escapeHtml4(str);logger.info("----filter?after--value:{}----",?str,?str);}}}return?map;}/*重寫輸入流的方法,因為使用RequestBody的情況下是不會走上面的方法的*//***?The?default?behavior?of?this?method?is?to?return?getReader()?on?the*?wrapped?request?object.*/@Overridepublic?BufferedReader?getReader()?throws?IOException?{logger.info("---xss?XSSHttpServletRequestWrapper?work??getReader-----");return?new?BufferedReader(new?InputStreamReader(getInputStream()));}/***?The?default?behavior?of?this?method?is?to?return?getInputStream()?on?the*?wrapped?request?object.*/@Overridepublic?ServletInputStream?getInputStream()?throws?IOException?{logger.info("---xss?XSSHttpServletRequestWrapper?work??getInputStream-----");/*創建字節數組輸入流*/final?ByteArrayInputStream?bais?=?new?ByteArrayInputStream(reqBody.getBytes(StandardCharsets.UTF_8));return?new?ServletInputStream()?{@Overridepublic?boolean?isFinished()?{return?false;}@Overridepublic?boolean?isReady()?{return?false;}@Overridepublic?void?setReadListener(ReadListener?listener)?{}@Overridepublic?int?read()?throws?IOException?{return?bais.read();}};}/***?獲取請求體**?@return?請求體*/private?String?getBodyString()?{StringBuilder?builder?=?new?StringBuilder();InputStream?inputStream?=?null;BufferedReader?reader?=?null;try?{inputStream?=?request.getInputStream();reader?=?new?BufferedReader(new?InputStreamReader(inputStream));String?line;while?((line?=?reader.readLine())?!=?null)?{builder.append(line);}}?catch?(IOException?e)?{logger.error("-----get?Body?String?Error:{}----",?e.getMessage(),?e);}?finally?{if?(inputStream?!=?null)?{try?{inputStream.close();}?catch?(IOException?e)?{logger.error("-----get?Body?String?Error:{}----",?e.getMessage(),?e);}}if?(reader?!=?null)?{try?{reader.close();}?catch?(IOException?e)?{logger.error("-----get?Body?String?Error:{}----",?e.getMessage(),?e);}}}return?builder.toString();}
}
定義過濾器
import?org.slf4j.Logger;
import?org.slf4j.LoggerFactory;import?javax.servlet.*;
import?javax.servlet.http.HttpServletRequest;
import?java.io.IOException;/***?Filter?過濾器,攔截請求轉換為新的請求*/
public?class?XssFilter?implements?Filter?{private?static?final?Logger?logger?=?LoggerFactory.getLogger(XssFilter.class);/***?初始化方法*/@Overridepublic?void?init(FilterConfig?filterConfig)?throws?ServletException?{logger.info("----xss?filter?start-----");}/***?過濾方法*/@Overridepublic?void?doFilter(ServletRequest?request,?ServletResponse?response,?FilterChain?chain)?throws?IOException,?ServletException?{ServletRequest?wrapper?=?null;if?(request?instanceof?HttpServletRequest)?{HttpServletRequest?servletRequest?=?(HttpServletRequest)?request;wrapper?=?new?XSSHttpServletRequestWrapper(servletRequest);}if?(null?==?wrapper)?{chain.doFilter(request,?response);}?else?{chain.doFilter(wrapper,?response);}}
}
注冊過濾器
注冊過濾器我了解到的有兩種方式。我用的下面的這種
一種通過@WebFilter
注解的方式來配置,但這種啟動類上要加@ServletComponentScan
?注解來指定掃描路徑
另外一種就是以Bean 的方式來注入(不知道放哪里,就把Bean放到啟動類里面)
/***?XSS?的Filter注入*?用來處理getParameter的參數*?@return*/
@Bean
public?FilterRegistrationBean?xssFilterRegistrationBean(){FilterRegistrationBean?filterRegistrationBean?=?new?FilterRegistrationBean();filterRegistrationBean.setFilter(new?XssFilter());filterRegistrationBean.setOrder(1);filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST);filterRegistrationBean.setEnabled(true);filterRegistrationBean.addUrlPatterns("/*");return?filterRegistrationBean;
}
上面配的是使用request.getParameter()
的時候生效的,但是當我使用@RequestBody
來接收參數的時候是不行的,所以還得有下面的代碼:
處理請求中的JSON數據
import?com.fasterxml.jackson.core.JsonParser;
import?com.fasterxml.jackson.core.JsonProcessingException;
import?com.fasterxml.jackson.databind.DeserializationContext;
import?com.fasterxml.jackson.databind.JsonDeserializer;
import?org.apache.commons.text.StringEscapeUtils;
import?java.io.IOException;/***?反序列化,用來處理請求中的JSON數據*?處理RequestBody方式接收的參數*/
public?class?XssJacksonDeserializer?extends?JsonDeserializer<String>?{@Overridepublic?String?deserialize(JsonParser?jp,?DeserializationContext?ctxt)?throws?IOException,?JsonProcessingException?{return?StringEscapeUtils.escapeHtml4(jp.getText());}
}
處理返回值的JSON數據
import?com.fasterxml.jackson.core.JsonGenerator;
import?com.fasterxml.jackson.databind.JsonSerializer;
import?com.fasterxml.jackson.databind.SerializerProvider;
import?org.apache.commons.text.StringEscapeUtils;
import?java.io.IOException;/***?處理向前端發送的JSON數據,將數據進行轉譯后發送*/
public?class?XssJacksonSerializer?extends?JsonSerializer<String>?{@Overridepublic?void?serialize(String?value,?JsonGenerator?jgen,?SerializerProvider?provider)?throws?IOException?{jgen.writeString(StringEscapeUtils.escapeHtml4(value));}
}
注冊、配置自定義的序列化方法
@Override
public?void?extendMessageConverters(List<HttpMessageConverter<?>>?converters)?{Jackson2ObjectMapperBuilder?builder?=?new?Jackson2ObjectMapperBuilder();ObjectMapper?mapper?=?builder.build();/*注入自定義的序列化工具,將RequestBody的參數進行轉譯后傳輸*/SimpleModule?simpleModule?=?new?SimpleModule();//?XSS序列化simpleModule.addSerializer(String.class,?new?XssJacksonSerializer());simpleModule.addDeserializer(String.class,?new?XssJacksonDeserializer());mapper.registerModule(simpleModule);converters.add(new?MappingJackson2HttpMessageConverter(mapper));
}
測試
所有東西都配置完了,接下來進行愉快的測試階段了。
我依然在輸入框中輸入這段代碼</input><img src=1 onerror=alert1>
并進行保存。來看一下數據庫中的保存結果:
圖片
可以看到數據庫中保存的數據,已經經過轉譯了。那查詢一下列表是什么樣的呢?
可以看到兩條數據,上面的是我們經過轉譯的,正常的展示出來了。而下面的是沒經過轉譯的,直接空白,并且給我彈了個窗。
總結
-
就是注意要分情況處理。
-
攔截器處理一部分,并注意攔截器的注冊方式
-
Jackson的方式處理另一部分,也是注意配置方式
補充
代碼經過驗證后,發現了一個問題。今天來補充一下。問題是這樣的:
如果使用@RequestBody
的形式接受參數,也就是需要使用自定義的序列化方式。然而有時候,我們的業務需要傳遞一些JSON串到后端,如{\"username\":\"zx\",\"pwd\":\"123\"}
(注意這是個字符串)。但是因為我不管三七二十一直接暴力轉譯,導致里面的雙引號以及其他符號都被轉譯了。那么當我們拿到這個字符串之后,再自己反序列化的時候就會出錯了。
為了解決這個問題,我在自定義的序列化方法中判斷了一下這個字段的值是否是JSON形式,如果是JSON形式,那就不做處理,直接返回,以保證能夠順利反序列化。判斷是否是JSON的方式,我選擇最簡單的,判斷首尾是否是{ } [ ]
的組合。代碼如下:
public?class?XssJacksonDeserializer?extends?JsonDeserializer<String>?{@Overridepublic?String?deserialize(JsonParser?jp,?DeserializationContext?ctxt)?throws?IOException,?JsonProcessingException?{//?判斷一下?值是不是JSON的格式,如果是JSON的話,那就不處理了。/*判斷JSON,可以用JSON.parse但是所有字段都Parse一下,未免有點太費性能,所以粗淺的認為,不是以{?或者[?開頭的文本都不是JSON*/if?(isJson(jp.getText()))?{return?jp.getText();}return?StringEscapeUtils.escapeHtml4(jp.getText());}/***?判斷字符串是不是JSON**?@param?str*?@return*/private?boolean?isJson(String?str)?{boolean?result?=?false;if?(StringUtil.isNotBlank(str))?{str?=?str.trim();if?(str.startsWith("{")?&&?str.endsWith("}"))?{result?=?true;}?else?if?(str.startsWith("[")?&&?str.endsWith("]"))?{result?=?true;}}return?result;}
}
但是經過這樣的改動之后,可能又沒那么安全了。所以還是要看自己的取舍了。