2020년 들어서 첫 글을 1월에 작성한 것이 아니라 2월에 작성하게 된 것에 큰 유감을 느낀다. 하지만 아직 늦지 않았으니, 회사 업무 조정으로 인해 바쁜 1월을 보냈다는 변명을 해보고 글을 시작해본다.
현재 맡고 있는 프로젝트에는 내부인과 외주 인원이 작성한 코드가 상당부분 섞여있고, 주기적으로 코드정비 기간을 가져 안좋은 스타일로 작성되어있거나 규격에 맞지 않게 작성된 흑마법들을 정리하여 코드 품질을 유지하려는 노력을 기울이고 있다. 누구나 배우면 Spring framework로 개발을 할 수 있고, Java나 여타 웹 기반 프로젝트를 하는데 알려진 자료도 많다.
하지만 해야할 것과 하면 안되는 것을 구분하고, 그 이유를 찾는데에는 노력이 필요하다. 일상적 업무를 탈 없이 잘 진행하는 것도 능력이고, 큰 개발건에서 맡은 소임을 다해 열심히 하는 것도 능력이지만, 더 큰 능력을 얻기 위해선 어떤 부정적 현상에 대해 하지마 라고 대답하는 것보다 이유를 함께 말해줄 수 있는 사람이 되어야 한다고 생각한다.
오늘은 아무 생각없이 웹 개발하면 쓰이던 Apache Tomcat와 한글과 관련된 문제를 하나 살펴보려고 한다.
CVE-2016-6816
앞의 CVE는 Common Vulnerabilities and Exposures
의 줄임말이다. 공식 사이트는 https://cve.mitre.org/ 인데, 대외적으로 공개된 보안 취약점에 연도별 고유번호를 부여하여 목록으로 만든 정보를 제공해주는 사이트이다. 기본적으로 하이픈(-) 가운데는 취약점이 공개된 해이고, 뒷자리는 해당 취약점의 고유 일련번호이다.
CVE로 공개된 시점에서 해당 보안 취약점은 패치가 되어, 해당 패치가 공개된 경우가 많다. 따라서 충분히 보안 업데이트를 잘 따라간 서버에 대해 공개된 저 정보로 해킹을 해보겠다는 건 재미없는 일이 될 수 있다. 물론 함부로 보안 업데이트를 놓친 서버를 악의적으로 털어서는 안된다. 인터넷 진흥원 등을 통하여 해당 서비스의 취약점을 제보하고 업데이트를 독려하도록 하자.
아무튼 이게 중요한 건 아니고, 해당 일련 번호로 검색해보면 다음과 같은 취약점이 나온다.
The code in Apache Tomcat 9.0.0.M1 to 9.0.0.M11, 8.5.0 to 8.5.6, 8.0.0.RC1 to 8.0.38, 7.0.0 to 7.0.72, and 6.0.0 to 6.0.47 that parsed the HTTP request line permitted invalid characters. This could be exploited, in conjunction with a proxy that also permitted the invalid characters but with a different interpretation, to inject data into the HTTP response. By manipulating the HTTP response the attacker could poison a web-cache, perform an XSS attack and/or obtain sensitive information from requests other then their own.
우리말로 옮기면 다음과 같다.
Apache Tomcat 특정 버전에 포함된 코드 중 HTTP request를 분석하는 코드가, HTTP request에서 허용하지 않은 유효하지 않은 문자를 허용하는 코드가 있다. 이 특성은 유효하지 않은 문자를 활용해 의도하지 않은 기능을 동작시키는 프록시 서버를 통해 악용될 수 있으며, 결과적으로 HTTP response에 불특정 데이터를 삽입할 수 있게 한다. HTTP response를 변조시켜 웹 캐시를 오염시키거나 XSS 공격을 수행할 수도 있고, request에 있던 민감한 정보를 유출시킬 수 있다.
공개된 시점에선 이미 이 문제는 수정되어있을 것이다. 이 수정이 이루어진 Tomcat을 사용해 URL 쿼리스트링에 한글을 이스케이프하지 않고 전송[1]하면, Tomcat은 400 응답코드를 보내며 요청을 처리할 수 없는 사유를 알려준다. 예를 들어 아래와 같은 URL에 오류를 낸다.
1 | http://localhost/api/endpoint/echo.do?message=냥냥애옹고양이귀여움 |
ChangeLog를 확인하게 된 계기
확인하게 된 계기는, 서두에서 하면 안되는 일 중에 하나를 외주 인원이 해두고 간 코드가 오류를 일으키기 시작하면서였다.
아래와 같은 자바스크립트 코드를 통해 동적으로 팝업을 열기 위한 URL을 생성하는 코드가 있었다.
1 | var newUrl = "http://example.com/bbs/surveyForm.do?id=" + surveyId + "&username=" + username; |
뭐가 들어올지 모르겠지만 아무튼 띄어쓰기 하나만 넣어도 난리가 날 것 같은, 쓰지 않았으면 하는 코드 패턴이다. 일단 코드를 작성할 당시 가정했던 값만 들어온다면 큰 문제는 없다. 그리고 더불어, Tomcat 버전이 보안 업데이트를 위해 올라가지만 않았더라도 이 코드는 전혀 문제를 일으키지 않았을 것이다.
하지만 결국 업데이트의 날은 왔고, 잘 되던 서비스가 갑자기 안열린다는 제보를 받고 살펴보니 저런식으로 코드가 작성되어있고, Tomcat은 400 오류를 띄우고 있었다. 당시 Tomcat이 출력한 메시지는 아래와 같다.
Invalid character found in the request target. The valid characters are defined in RFC 7230 and RFC 3986
다른 분 통해서 사내 메신저로 받은 메시지에는 ‘RFC 7230과 RFC 3986에 대해 혹시 아는 것 있으십니까?’ 라고 와있던 덕분에 꽤 당황했던 기억이 난다. 이 메시지는 Tomcat의 메시지 properties 파일에 저장되어있다. 어떤 순간에 출력되는지 확인해볼 필요가 있다. Tomcat은 다행스럽게 오픈소스 소프트웨어이고, 소스코드가 공개되어있다. 무엇이 바뀌었는지 살펴보자.
Tomcat SVN 리비전 1767675
Tomcat ChangeLog[2]를 해당 CVE 번호로 검색해보면 Tomcat SVN의 리비전 1767675에서 패치가 완료되었다는 이야기가 있다.
Important: Information Disclosure CVE-2016-6816
The code that parsed the HTTP request line permitted invalid characters. This could be exploited, in conjunction with a proxy that also permitted the invalid characters but with a different interpretation, to inject data into the HTTP response. By manipulating the HTTP response the attacker could poison a web-cache, perform an XSS attack and/or obtain sensitive information from requests other then their own.
This was fixed in revision 1767675.
This issue was reported to the Apache Tomcat Security Team on 11 October 2016 and made public on 22 November 2016.
실제 사이트에선 저 1767675에 링크가 걸려있고, 링크를 누르면 해당 SVN 커밋을 확인할 수 있다. 여기서 Tomcat에 출력했을 메시지가 있을 것 같은 LocalStrings.properties
파일을 열어 저 문장을 검색해보면, 다음과 같은 줄(47번째 줄)이 나온다. 등호(=) 좌측이 아마 메시지의 ID가 될 것이다.
iib.invalidRequestTarget=Invalid character found in the request target. The valid characters are defined in RFC 7230 and RFC 3986
이제 남은 Java 파일들을 iib.invalidRequestTarget
으로 뒤져보자. 결국 코드 수정은 비슷하게 이루어져 있으므로 보통 이런 지점에서 메시지 ID를 호출할 것이다.
1 | } else if (HttpParser.isNotRequestTarget(buf[pos])) { |
이 지점이 포함된 메소드의 이름이 parseRequestLine
이다. 이제 HTTP request를 파싱(분석)하다가 저 메시지를 띄우며 Tomcat이 오류를 냈다는 걸 알 수 있다. 그리고 CVE에서 적힌 설명이 무엇인지 약간 감이 온다. 해당 메시지가 표시되려면, HttpParser.isNotRequestTarget(buf[pos])
조건문이 참이어야 한다. 이때, 한글은 이 조건을 참으로 만드는 요인 중 하나라는 결론이 온다. 왜냐면 한글을 넣었을 때 이 문제가 생겼으니까.
참고로 buf
변수는 다음과 같이 정의되어있다. (해당 커밋 기준 InternalAprInputBuffer 파일 59번째 행)
1 | buf = new byte[headerBufferSize]; |
즉, HttpParser.isNotRequestTarget(buf[pos])
라는 코드는 각 바이트 하나하나를 검증 메소드에 보내서 검증하겠다는 의미를 내포하고 있다. 파라미터에 입력한 한글은 바이트 단위로 분해되어 각 바이트가 Tomcat에 의해 평가될 것이다.
HttpParser.isNotRequestTarget(buf[pos])
이 메소드는 스태틱 메소드로써, HttpParser.java
에 정의되어있다.
1 | public static boolean isNotRequestTarget(int c) { |
앞에서 보았다시피 buf[pos]
에서 나오는 값은 byte
타입의 값인데, int
타입의 표현 범위가 훨씬 넓으므로 자동으로 변환될 수 있다. 즉, 저 메소드를 설명하면 각 바이트가 HTTP request에서 쓸 수 있는 바이트인지 검사하는 역할을 수행한다고 볼 수 있다. 이 방법을 구현하기 위해, IS_NOT_REQUEST_TARGET
이라는 배열에 플래그가 켜져있는지 검사하는 방법을 사용한다. 예를 들어 입력값이 10진수 0
이고 IS_NOT_REQUEST_TARGET[0]
이 true
라면 0
은 허용되지 않는 바이트 값임을 나타낸다.
이때 이 바이트 값은 ASCII 코드상의 값에 대응된다. 다시 정리하면, 허용되지 않는 문자를 거르겠다는 것이다. 그러면 어떤 문자들이 허용(false로 플래그가 표시되어야함)되고, 어떤 문자들이 금칙(true로 플래그가 표시되어야함)문자인지 IS_NOT_REQUEST_TARGET
을 초기화하는 코드를 살펴본다.
더불어, IS_NOT_REQUEST_TARGET
의 배열크기보다 더 큰 값을 검사하려고 하면 ArrayIndexOutOfBoundsException
이 일어난다는 것도 당연하게 확인할 수 있다.
1 | private static final int ARRAY_SIZE = 128; |
IS_NOT_REQUEST_TARGET
변수의 실체는 boolean
배열이고, 그 크기는 128
이다. 0x00
부터 0x7F
까지, ASCII 코드 범위를 표현하기에 참 좋은 범위이다. omitted
라고 표시된 부분은 지면상 코드가 너무 길어 생략한 부분이니 양해 부탁드린다.
static
블럭 안에 선언되어있으므로, 이 Java 코드가 클래스로더에 의해 처음 적재될 때 이 부분이 한번 실행되어 앞으로 쓸 수 있도록 준비된다. 이때 어떤 ASCII 코드값이 IS_NOT_REQUEST_TARGET
에서 금지되는지 보면, IS_CONTROL
에 해당하는 제어문자들, Tomcat에서 지정한 ASCII 문자 내 특수 문자들과 127보다 더 큰 코드 값에 대해서 처리 불가능으로 마크하고 있다.
요약하면, ASCII 범위 내의 문자만 받을 것이고 Tomcat이 허용한 문제 외에는 받지 않겠다는 것이다. 그러면 우리의 한글은 이 범위에 들어있을까?
Python으로 간단히 EUC-KR과 UTF-8일때 홍길동
이라는 문자의 바이트 표현을 살펴보면 다음과 같다.
1 | list(map(int, '홍길동'.encode('utf-8'))) |
127 보다 적은 값이 없다. 당연히 이 문자는 업데이트된 Tomcat에서 URL 또는 HTTP request 상에서 허용되지 않음을 알 수 있다. 결국 한글로 파라미터를 적을 수 없다. 이와 같은 만행을 저지를 수 없다는 것이다. 이제 왜 Tomcat이 그런 오류를 냈는지 이해할 수 있을 것이다.
1 | http://localhost/api/endpoint/echo.do?message=냥냥애옹고양이귀여움 |
맺으면서
결국 올바른 해법은 하나이다. URL을 올바르게 생성하는 것이다. 그리고 더 크게 보자면 HTTP request를 올바르게 보내는 것이다.
찾아보니 2016년도에 공개된 CVE인데, 이제야 이걸 찾아보게 된 것에 대한 미묘한 느낌과 올바르지 않은 URL을 만들어 이걸 경험해준 해당 코드에 대한 묘한 분노가 일었던 시간이었다.
글로 한번 다시 정리해보려니 생각보다 분량이 너무 커져서 힘들다. 그냥 프로젝터에 화면 띄우고 말로 설명해줄 땐 편했는데.