춤추는 개발자

[Spring] XSS필터와 Surrogate pair 본문

Developer's_til/스프링 프레임워크

[Spring] XSS필터와 Surrogate pair

Heon_9u 2023. 11. 10. 21:29
728x90
반응형

✅ 세줄 요약

🛠 특정 조건에서만 조회 기능이 동작하지 않는 이슈 확인.
🛠 기능 동작 과정을 거슬러 올라간 결과, Emoji 데이터가 포함된 경우와 클라이언트로 반환하기 직전에 지나가는 XSS 필터에서 문제 원인을 발견.
🛠 UTF-16의 특별한 인코딩 방식인 Surrogate pair를 기반으로 문제 해결.

 

✅ 배경과 상황

[ 에러 상황 ]

담당하고 있는 고객 포탈 사이트에서 특정 조건에만 조회 기능이 동작하지 않는 이슈가 있었다. 가장 먼저, 서버 로그를 살펴보니 아래와 유사한 내용이 남겨져 있었다.

2023-11-10 20:08:17.435 WARN 16980 --- [nio-8080-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: x != java.lang.Character; nested exception is com.fasterxml.jackson.databind.JsonMappingException: x != java.lang.Character (through reference chain: com.study.blog.XSS.ResponseDto["title"])]

 

[ 원인을 찾은 과정 ]

 에러 로그만 봤을 때는 MessageConvertor에 이상이 있음을 인지했다. 다음으로 의심해본 것은 혹시 DB상에 다른 데이터가 있는건가? 라는 의문을 가졌다. 실제로 운영 DB를 확인해보니, 조회가 안되는 데이터마다 Emoji가 포함된 것을 확인했다.

 

 DB에는 Emoji 데이터가 저장되는 것을 확인했고, 에러 로그에는 DB관련 내용이 없으니 조회(SELECT) 쿼리가 정상 작동했을거라 가정하고, Controller에서 반환하기 바로 직전 로그를 찍어본 결과, 역시 정상적으로 출력되는 것을 확인했다.

 

 위에 과정을 바탕으로 서버에서 클라이언트로 데이터를 전달하는 과정 즉, Convertor나 데이터를 필터링하는 과정에서 문제가 있을 것이라 판단하였다.

 

 Config 패키지의 코드들을 둘러보던 중, 몇달 전 보안취약점으로 XSS 필터를 적용했던 로직이 생각났다. 분명 클라이언트로 데이터를 반환하기 직전 MessageConvertor가 해당 필터를 거쳐 동작하기 때문에 HTMLCharacterEscapes 클래스를 집중적으로 검토했다.

 

 XSS 필터와 Emoji에 대해 찾아보던 중 Surrogate pair라는 키워드가 눈에 들어왔다.

 

Surrogate pair

 

자바 String의 기본 인코딩은 UTF-16으로 2바이트로 유니코드 문자를 표현한다. 하지만, 2바이트를 넘어가는 경우 UTF-16은 특별한 규칙을 사용해 2개의 2바이트 즉, 4바이트로 문자를 표현한다.

 여기서 말하는 특별한 규칙이 바로 Surrogate pair다. Surrogate는 High / Low로 나눠져 있어서 앞에 오는 문자(U+D800 ~ U+DBFF)를 High, 그 뒤에 붙는 문자(U+DC00 ~ U+DFFF)를 Low라고 한다. 유니코드 영역 중, Surrogate 문자만 모아놓은 별도의 영역이 존재하는 덕분에 가능한 방식이다.

 실제 예시를 들자면, 😍 이모지는 코드 포인트가 U+1F60D고, 이는 2바이트를 초과한 값입니다. 따라서 UTF-16에서 U+D83D(High Surrogate), U+DE0D(Low Surrogate)를 이어붙인 Surrogate Pair로 표현된다.

 

✅ 결과 비교

 실무 코드를 첨부할 수 없으니, 비슷하게 직접 재현한 코드를 통해 확인해보겠다. 먼저, 기본적인 XSS 필터는 아래처럼 간단하게 구현했다.

public class HTMLCharacterEscapes extends CharacterEscapes {

    private final int[] escapeCodesForAscii;

    public HTMLCharacterEscapes() {
        this.escapeCodesForAscii = CharacterEscapes.standardAsciiEscapesForJSON();

        escapeCodesForAscii['>'] = CharacterEscapes.ESCAPE_NONE;
        escapeCodesForAscii['<'] = CharacterEscapes.ESCAPE_NONE;
        escapeCodesForAscii['&'] = CharacterEscapes.ESCAPE_NONE;
        escapeCodesForAscii['/'] = CharacterEscapes.ESCAPE_NONE;
        escapeCodesForAscii['('] = CharacterEscapes.ESCAPE_NONE;
        escapeCodesForAscii[')'] = CharacterEscapes.ESCAPE_NONE;
        escapeCodesForAscii['#'] = CharacterEscapes.ESCAPE_NONE;
        escapeCodesForAscii['\''] = CharacterEscapes.ESCAPE_NONE;
    }

    @Override
    public int[] getEscapeCodesForAscii() {
        return escapeCodesForAscii;
    }

    @Override
    public SerializableString getEscapeSequence(int i) {
        return new SerializedString(StringEscapeUtils.escapeHtml4(Character.toString((char) i)));
    }
}

 

 

이제 여기에 Emoji가 포함된 데이터를 호출하기 위한 API를 아래처럼 구성하고, 포스트맨을 통해 호출했다.

@GetMapping("/emoji")
public ResponseDto getEmojiObject() {
    log.info("#### get Emoji Data ####");
    return ResponseDto.builder()
            .title("title🤍")
            .body("body❤")
            .code("200💌")
            .build();
}

 

 

호출 결과는 맨 처음에 언급했던 에러 로그와 함께 다음과 같은 응답이 포스트맨에 찍혔다.

{
    "timestamp": "2023-11-10T11:08:17.447+0000",
    "status": 500,
    "error": "Internal Server Error",
    "message": "Could not write JSON: x != java.lang.Character; nested exception is 
    com.fasterxml.jackson.databind.JsonMappingException: x != java.lang.Character 
    (through reference chain: com.study.blog.XSS.ResponseDto[\"title\"])",
    "path": "/api/xss/emoji"
}

 

 

이제 여기에 유니코드를 UTF-16으로 특별한 방식인 Surrogate pair를 적용해보겠다. HTMLCharacterEscapes에서 반환값을 Character단위로 가져오는 getEscapeSequence를 다음과 같이 수정한다.

@Override
public SerializableString getEscapeSequence(int i) {
    char ch = (char) i;
        if(Character.isHighSurrogate(ch) || Character.isLowSurrogate(ch)) {
        StringBuilder sb = new StringBuilder();
        sb.append("\\u");
        sb.append(String.format("%04x", i));
        return new SerializedString(sb.toString());
    }

    return new SerializedString(StringEscapeUtils.escapeHtml4(Character.toString((char) i)));
}

 

Character가 High 또는 Low Surrogate라면, 해당 값은 유니코드로 판단하고, 값을 변환해서 반환한다. 변환 로직에서 append하는 값은 다음을 의미한다.

  • \\u: 유니코드
  • String.format("%04x", ch): %04x는 정수형을 받는다는 의미로, 앞에 04는 width가 4인데, 남는 공간은 0으로 채운다는 것을 의미

 

이렇게 수정하고 나면 Emoji 데이터를 반환하는 API가 아래처럼 정상 작동하는 것을 확인할 수 있다.

{
    "title": "title🤍",
    "body": "body❤",
    "code": "200💌"
}


// 이모지 데이터를 반환하기 전, 로그
com.study.blog.XSS.HTMLCharacterEscapes  : \ud83e
com.study.blog.XSS.HTMLCharacterEscapes  : \udd0d
com.study.blog.XSS.HTMLCharacterEscapes  : \ud83d
com.study.blog.XSS.HTMLCharacterEscapes  : \udc8c

 

✅ 추가

XSS 필터

 

 브라우저에서 입력창에 스크립트를 인위적으로 삽입하여 서버로 데이터를 전달, 해당 스크립트가 화면에 직접적으로 뿌려져 해당 스크립트가 실행되는 공격이다. 예를 들어 <script>alert(1111)</script>라는 태그를 입력 파라미터로 삽입하면, 실제로 alert창이 뜨게 된다.

 

XSS로 인해 발생되는 피해 종류

사용자의 개인정보 탈취 Keylogger 형태의 스크립트를 사용하여 키보드 입력 값 탈취
사용자의 쿠키정보 탈취 Document.cookie를 사용하여 해당 사용자의 쿠키 값 탈취
피싱 사이트로 강제 이동 Location.href등을 사용하여 페이지 강제 이동
악성코드 다운로드 및 실행 악성코드 다운로드 링크로 이동 및 낮은 보안수준에서 악성코드 강제 실행

 

XSS의 공격 종류

종류 대상/특징
Reflected XSS URL 접근 대상
Stored XSS 불특정 다수
DOM Based XSS URL 접근 대상 / 서버를 거치지 않음

 

✅ 마무리

 해당 이슈에 대응하면서 예전보다 논리적으로(?) 문제에 접근할 수 있게 됐다. 기능이 동작하는 과정을 작게 나눠서 의심 / 가정 / 확신하는 것을 기반으로 문제 원인을 빠르게 찾을 수 있었다. 또한, UTF-16이나 유니코드와 같은 인코딩 규칙을 비교하며 이해할 수 있었다.

 

✅ Reference

https://velog.io/@power0080/XSS-필터와-이모지

https://wildeveloperetrain.tistory.com/300

https://blog.hoseung.me/2022-08-25-emoji-and-unicode

https://gomguk.tistory.com/61

728x90
반응형