xss 공격방어

22_gas·2024년 6월 21일

2023.3.20작성

개요

웹 개발자로 살게되면 보안취약점에 걸렸다고 전화를 많이 받게 된다. 매년 검출 소프트웨어가 업데이트 되기 때문에 기존에는 걸리지 않았더라도 안심할 수 없다.

오늘은 XSS(Cross Site Scripting) 공격에 대한 방어를 해보도록 하겠다.

XSS(Cross Site Scripting) 란?

공격자가 상대방 브라우저에 스크립트가 실행되도록 하여 세션을 가로채거나, 사이트를 변조하거나, 악의적 컨텐츠를 삽입하거나 피싱 공격을 진행하는것을 말한다. 

간단하게 사용자가 javascript코드를 저장해도 페이지를 띄웠을때는 그게 실행되면 안돼야 한다는 것이다.
Ex) 사용자의 이름이 'alert(1);' 라고 해도 화면에는 해당 문장이 출력되야하며 alert가 실행되면 안된다.

공격방식(검출방식)

보통 취약점 검출은 아래와 같은 코드를 입력할수 있는곳에 입력하고 저장한뒤 코드가 동작하는지 확인하는 방식으로 진행된다.

<script>alert("xss");</script>

<img src=xyz onClick="alert('xss');"/>

<img src=xyz onLoad="location.href='https://google.com'" />

<img src=xyz onMouseOver="window.open('https://googlg.com');" />

공격 방어

방어라는것은 위의 스크립트가 동작하지 않도록 하면 된다.

데이터저장시에 문자열들을 치환하여 저장되도록 하는 방식을 사용하며, 이때 Filter를 이용한다.

문자치환문자
&&amp;
>&lt;
<&gt;
(&#40;
)&#41;

lucy-xss-filer를 사용했었으나 커스터마이징이 어려워 Filter를 추가하는 방향으로 개발을 진행했다.

다음 스텝을 따라가면 쉽게 Filter를 적용시킬수 있을것이다.

xss 방어 적용

먼저 2가지 클래스가 필요하다.

1, Filter를 implements 하는 클래스
필터 chain 를 적용

2, HttpServletRequestWrapper를 상속받는 클래스
request에서 파라미터나 rawData를 꺼낼때 xss를 실제로 적용

1. 커스텀 Filter 생성

Filter가 쉬우니 이걸 먼저 만들도록 하자

//필터 이름은 알아서 만들자
public class XssCustomFilter implements Filter{
	@Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
    
    	//((HttpServletRequest) req).getRequestURI(); 
        //이 코드로 URL를 분석하여 특정 URL에서는 filter가 동작하지 않도록 할 수 있다.        
        
    	HttpServletRequestBodyWrapper wrapper = new HttpServletRequestBodyWrapper((HttpServletRequest)req);
        chain.doFilter(wrapper, res);
    }
	
    @Override
    public void init(FilterConfig filterConfig) {}

    @Override
    public void destroy() {}

}

2. BodyWrapper 생성

GET 호출과 POST 호출 전부 처리해야하므로 코드가 좀 많이 추가된다.(주석참고)

public class HttpServletRequestBodyWrapper extends HttpServletRequestWrapper {

    private String body;
    private byte[] rawData;
    private String path = "";

    public HttpServletRequestBodyWrapper(HttpServletRequest request) throws IOException {
        super(request);
        this.path = request.getRequestURI();
        String _method = request.getMethod();
        String _contentType = request.getContentType();
        
        //method가 "POST"인데 ContentType이 null인경우가 있어서 처리해줫다.
        if(_method == null) _method = "";
        if(_contentType == null) _contentType = "";

        //ContentType이 contains인이유는 ContentType이 "application/json; charset=utf8"인 경우가 존재했기때문이다.
        if(_method.equalsIgnoreCase("post") && _contentType.contains("application/json")) {
            InputStream is = request.getInputStream();
            this.rawData = replaceXSS(IOUtils.toByteArray(is));
        }
    }

	//request에서 get방식일때 parameter값을 읽게 되고 
    //그때 xss 치환해주기위해 해당 코드를 넣어줬다.
    @Override
    public String getParameter(String name) {
        return replaceXSS(super.getParameter(name));
    }
    
    //get 방식 처리
    @Override
    public Map<String, String[]> getParameterMap() {
        Map<String, String[]> params = super.getParameterMap();
        if(params != null) {
            params.forEach((key, value) -> {
                for(int i=0; i<value.length; i++) {
                    value[i] = replaceXSS(value[i]);
                }
            });
        }
        return params;
    }


	//get 방식 처리
    @Override
    public String[] getParameterValues(String name) {
        String[] params = super.getParameterValues(name);
        if(params != null) {
            for(int i=0; i<params.length; i++) {
                params[i] = replaceXSS(params[i]);
            }
        }
        return params;
    }

    //POST방식의 처리
    // POST 방식의 호출은 requestBody 안에 데이터가 있기 때문에
    //생성자에서 rawData에 데이터가 들어있으므로 return해준다.
    //근데 해보면 new를 안하고 return할경우 '읽음'처리 되는순간 inputStream이 없어지므로 
    //copy하여 return해준다.  => 저도 잘 몰라요
    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }
    
    @Override
    public ServletInputStream getInputStream() throws IOException{
        if(this.rawData == null){
            return super.getInputStream();
        }
        final ByteArrayInputStream bis = new ByteArrayInputStream(this.rawData);

        return new ServletInputStreamImpl(bis);
    }

    private String getBody(HttpServletRequest request) throws IOException {
        StringBuilder sb = new StringBuilder();

        try (
                InputStream inputStream = request.getInputStream();
                BufferedReader br = new BufferedReader(new InputStreamReader(inputStream))
        ) {
            char[] charBuffer = new char[128];
            int bytesRead = -1;
            while ((bytesRead = br.read(charBuffer)) > 0) {
                sb.append(charBuffer, 0, bytesRead);
            }
        }
        return sb.toString();
    }

	//inputStream를 새로 생성하여 return해주기위한 내부 class
    class ServletInputStreamImpl extends ServletInputStream {
        private InputStream is;

        public ServletInputStreamImpl(InputStream bis) {
            is = bis;
        }

        public int read() throws IOException {
            return is.read();
        }

        public int read(byte[] b) throws IOException {
            return is.read(b);
        }

        @Override
        public boolean isFinished() {
            return false;
        }

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

        @Override
        public void setReadListener(final javax.servlet.ReadListener readListener) {}
    }

    //Xss 공격방어
    private byte[] replaceXSS(byte[] data) {
        String strData = new String(data);
        strData = strData.replaceAll("<", "&lt;")
                        .replaceAll(">", "&gt;")
                        .replaceAll("(", "&#40;")
                        .replaceAll(")", "&#41;")
                        .replaceAll("(?i)javascript", "")
                    ;


        return strData.getBytes();
    }

    private String replaceXSS(String value) {
        if(value != null) {
            value = value.replaceAll("<", "&lt;")
                        .replaceAll(">", "&gt;")
                        .replaceAll("(", "&#40;")
                        .replaceAll(")", "&#41;")
                        .replaceAll("(?i)javascript", "")
                        ;
        }
        return value;
    }
}

3. web.xml 에 만든 필터 추가

<filter>
    <filter-name>xssCustomFilter</filter-name>
    <filter-class>com.gas.secure.XssCustomFilter</filter-class>
</filter>

<filter-mapping>
    <filter-name>xssCustomFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

이렇게 되면 GET호출이든 POST 호출이든 XSSFiler가 적용된다.

4. jsp에 적용하기

이제 DB에는 "&lt; &gt;..." 등과 같은 데이터로 저장되어있다. 이제 이걸 화면에 표출해야 하는데 각각 방식에 따라 표현방식이 다르다

화면에 데이터가 표현되는 2가지 방식이 존재 하는데

  1. div, label, span태그와 같이 태그 하위의 정보가 위치하여 화면에 표시되는 방식

  2. input, textarea 처럼 테그 안에 value로 지정되어 화면에 표시되는 방식

1번의 경우 DB데이터를 곧바로 화면에 표시하면된다.

$('#div').append(result);

<div><c:out value="${result.data}" /></div>

화면에는 ">"로 예상한대로 표시된다.

문제는 2번의 경우 인데 

$('#input').val(result);
$('#textarea').val(result);

이렇게 될경우 input태그 안에 데이터도 <로 바뀌기 때문에 아래의 처럼 표현된다.

이때는 javascript에서 서버에서 넘겨받은 result값을 바꿔주는 작업을 추가로 진행해서 input태그안에 데이터를 넣어주어야 한다.

var characterUnescapes = function(value) {
    var str = value.replace(/\&lt\;/g, '<')
    	.replace(/\&gt\;/g, '>')
        .replace(/\&quot\;/g, '"')
        .replace(/\&rsquo\;/g, "'")
        .replace(/\<script\>/ig, '&lt;script&gt;')
        .replace(/\<\/script\>/ig, '&lt;/script&gt;');
    return str;
};

$('#input').val(characterUnescapes(result));

이상 끝

profile
전 아직 모르는게 많아요

0개의 댓글