FlawOpen

flawopen.com/xss/Java

● CWE-79 · 높음
보안 연구 · FlawOpen

Java XSS: Spring MVC·Thymeleaf 출력 보호

th:utext와 이스케이프되지 않은 JSP 출력은 삽입된 JavaScript를 실행할 수 있습니다. 출력 문맥별로 인코딩하고 허용 목록으로 리치 텍스트를 정화하세요(CWE-79).

💡 알기 쉬운 설명 (ELI5)

경기장 전광판에는 일반 이름 입력란과 별도의 서식 표시 모드가 있습니다. Thymeleaf의 th:text는 입력된 이름을 문자 그대로 처리하지만 th:utext는 렌더러가 값을 전광판용 HTML 지시로 해석하게 합니다. 브라우저는 경기장 사이트의 text/html을 받아 해당 사이트의 권한으로 마크업을 표시합니다. 사용자 텍스트는 이스케이프된 출력에 두고, 서식이 꼭 필요하면 이스케이프하지 않는 템플릿 슬롯에 넣기 전에 정화하세요.

핵심 개념 및 용어

Thymeleaf th:text vs th:utext
th:text는 값을 HTML 이스케이프하고, th:utext(이스케이프하지 않은 텍스트)는 마크업으로 삽입합니다. Thymeleaf 인라인 형식에서 [[${x}]]는 이스케이프하고 [(${x})]는 하지 않습니다. 사용자 입력에 연결된 th:utext는 값을 미리 새니타이즈하지 않았다면 XSS입니다.
JSP ${} vs <c:out>
JSP 템플릿 텍스트에서 ${param.q}는 이스케이프 없이 출력됩니다. <c:out value="${param.q}"/>나 ${fn:escapeXml(param.q)}는 이스케이프합니다. <%= request.getParameter("q") %> 같은 스크립틀릿은 절대 이스케이프하지 않습니다.
Encode.forHtml / forJavaScript
OWASP Java Encoder는 출력 문맥마다 메서드가 있습니다. 요소 내용에는 Encode.forHtml, 따옴표로 감싼 속성에는 forHtmlAttribute, JavaScript 문자열 리터럴에는 forJavaScript, URL 파라미터에는 forUriComponent를 씁니다. 스크립트 안에 넣는 값을 HTML 인코딩만 해서는 부족합니다.
OWASP Java HTML Sanitizer
사용자가 준 HTML에는 Sanitizers.FORMATTING.and(Sanitizers.LINKS) 같은 정책을 씁니다. 태그와 속성의 허용 목록을 두고 스크립트, 이벤트 핸들러, javascript: URL을 제거합니다. 인코딩하면 태그가 글자로 보이지만, 새니타이즈하면 안전한 태그는 그대로 동작합니다.
@ResponseBody returning text/html
produces = text/html로 String을 반환하는 @RestController 메서드는 응답에 그대로 쓰입니다. 템플릿 엔진이 돌지 않으므로 아무것도 이스케이프되지 않습니다. 뷰 템플릿을 쓰거나, 이어 붙이는 모든 값을 인코딩하세요.

프레임워크 매핑

단계별 공격 실행 흐름

단계 1

입력이 요청 파라미터로 도착

Spring이 쿼리 문자열이나 폼 본문의 q, user, bio를 String 인자에 바인딩합니다. 피해자에게 조작한 링크를 보낼 수도 있고, 제출된 bio가 다른 사용자에게 표시될 수도 있습니다.

단계 2

문자열 연결이 응답을 조립

컨트롤러가 값을 HTML이나 <script> 블록에 붙입니다. 무해한 테스트 값 <img src=x onerror=alert(1)>이면 반환 문자열에 실제 태그가 들어가고, ';alert(1);//이면 스크립트 블록에 새 문장이 생깁니다.

단계 3

브라우저가 그것을 파싱

메서드가 text/html을 선언했으므로 브라우저는 본문을 페이지로 렌더링합니다. 이미지가 실패해 onerror가 발생하거나, 스크립트 블록이 주입된 문장을 실행합니다.

단계 4

스크립트가 사이트로서 실행

코드는 애플리케이션의 출처(origin)로 실행되어 DOM을 읽고, 피해자의 세션 쿠키로 애플리케이션 엔드포인트를 호출하며, 사용자가 보는 내용을 바꿀 수 있습니다.

소스 코드 비교: 취약한 구현 vs 보안 패치

취약한 구현
// ProfileController.java: 각 엔드포인트가 요청 데이터를 HTML에 이어 붙인다
@RestController
public class ProfileController {

    // HTML 문맥: 쿼리 값이 마크업으로 삽입된다
    @GetMapping(value = "/search", produces = MediaType.TEXT_HTML_VALUE)
    public String search(@RequestParam String q) {
        return "<h1>Results for " + q + "</h1>";
    }

    // JavaScript 문맥: user의 따옴표가 문자열 리터럴을 끝낸다
    @GetMapping(value = "/widget", produces = MediaType.TEXT_HTML_VALUE)
    public String widget(@RequestParam String user) {
        return "<script>const user = '" + user + "';</script>";
    }

    // 리치 텍스트가 태그와 핸들러를 그대로 가진 채 돌아온다
    @PostMapping(value = "/preview", produces = MediaType.TEXT_HTML_VALUE)
    public String preview(@RequestParam String bio) {
        return "<div class=\"bio\">" + bio + "</div>";
    }
}
보안 강화 패치
// ProfileController.java: 각 값을 문맥에 맞게 인코딩하거나 새니타이즈한다
@RestController
public class ProfileController {

    private static final PolicyFactory BIO_POLICY = Sanitizers.FORMATTING.and(Sanitizers.LINKS);

    // forHtml은 요소 내용용으로 < > & " '를 인코딩한다
    @GetMapping(value = "/search", produces = MediaType.TEXT_HTML_VALUE)
    public String search(@RequestParam String q) {
        return "<h1>Results for " + Encode.forHtml(q) + "</h1>";
    }

    // forJavaScript는 따옴표, 백슬래시, </script> 시퀀스를 이스케이프한다
    @GetMapping(value = "/widget", produces = MediaType.TEXT_HTML_VALUE)
    public String widget(@RequestParam String user) {
        return "<script>const user = '" + Encode.forJavaScript(user) + "';</script>";
    }

    // 새니타이저는 서식과 링크만 남기고 나머지는 버린다
    @PostMapping(value = "/preview", produces = MediaType.TEXT_HTML_VALUE)
    public String preview(@RequestParam String bio) {
        return "<div class=\"bio\">" + BIO_POLICY.sanitize(bio) + "</div>";
    }
}

엔지니어링 및 시스템 보안 강화 체크리스트

출처