wargame.kr의 16번째 문제인 web chatting이다.
트래픽을 감소시키는 방향에 대해 개발자의 관점에서 생각해보라는 힌트가 주어져 있다. 일단 Start 버튼을 눌러서 문제를 확인해보자.
BlueCHAT이라는 서비스 이름과 함께 아이디를 입력하는 간단한 입력 폼이 주어져 있다. HTML 소스를 확인해도 아이디를 입력하도록 하는 로직 말고는 별다른 점을 찾을 수 없었다. 간단하게 아이디를 입력하고 들어가 보면 다음과 같은 채팅방이 나타난다.
재밌는 점은 왼쪽 아래에 자신의 아이디를 나타내는 부분은 쌍따옴표(")에 의해 잘리는 것을 볼 수 있었다. 다른 문자들은 이스케이프 되어 그대로 표시되었으나 일반 채팅에는 따옴표, 큰따옴표나 역슬래시 등 SQL Injection을 시도해볼 만한 문자들은 이스케이프 되는지 별다른 영향을 끼치지 못하는 것을 볼 수 있었다.
채팅창의 경우 '#' 문자를 입력하면 해당 문자 이후의 문자열들은 전부 잘려서 채팅에 나타나는 것을 확인할 수 있었다. 만약 이 '#' 문자가 주석으로 작동하고 있다면 쿼리 에러가 나거나 채팅이 정상적으로 표시되지 않아야 하지만 그렇지 않은걸 보니 아마 다른 방식에 의해서 삭제되는 것으로 보인다.
채팅창 분석은 이쯤하고 네트워크 부분을 분석해보면 다음처럼 주기적으로 어떤 요청이 발생하는 것을 볼 수 있었다.
chat.php는 현재 채팅창 페이지고 blueh4g_js.js는 이 페이지에서 사용하는 스크립트일 것이다. 그렇다면 나머지 chatlog.php에 보내진 요청들은 무엇이고 어떤 값을 반환하고 있을까? 일단 xhr 타입이기 때문에 자바스크립트에서 주기적으로 보내지는 요청이라고 추측해볼 수 있다.
개발자 도구에서 확인한 정보는 다음과 같다.
-
"http://wargame.kr:8080/web_chatting/chatlog.php"에 대한 GET 요청
-
요청 파라미터는 정수 값으로 추측되는 t 하나
-
요청 시 포함되는 쿠키는 세션 ID, 채팅 ID
-
getchatlog() 함수에서 시작됨
-
5자리 정수 반환
요청에 대한 반환 값은 59395라는 의미를 알 수 없는 정수였다.
이는 어디에 사용되는 것일까? 좀 더 자세히 알아보기 위해 이 요청이 시작된 getchatlog() 함수를 살펴보았다.
<script>
var xmlhttp, ni, iq = 0, brtype = 1;
function getchatlog(type) {
xmlhttp = new XMLHttpRequest();
if (type == 1) {
xmlhttp.onreadystatechange = getni;
xmlhttp.open("GET", "chatlog.php?t=1");
} else if (type == 2) {
xmlhttp.onreadystatechange = chatprint;
xmlhttp.open("GET", "chatview.php?t=1&ni=" + ni);
}
xmlhttp.send(null);
}
...
</script>
기존의 코드는 보기 불편하기 때문에 Online JavaScript beautifier에서 다듬었다. 지금 살펴볼 getchatlog() 함수에서는 XMLHttpRequest() 함수를 호출하여 xhr 요청을 직접 보내고 있다. 매개변수로 받은 type 값에 따라 요청을 전송하는 url이 달라지는데 1번 타입의 경우 chatlog.php의 파라미터 t에 1 값을 포함하여 전송한 후 onreadystatechange 이벤트 핸들러에 getni 함수를 등록한다. 2번 타입의 경우 chatview.php의 파라미터 t에 1 값과 ni에 변수 ni 값을 포함하여 전송한 후 이벤트 핸들러에 chatprint 함수를 등록한다.
이 onreadystatechange 이벤트 핸들러는 XMLHttpRequest 객체, 여기서는 xmlhttp의 readystate가 변경되었을 때 호출되는 함수로 MDN 문서에 따르면 readystate는 다음과 같은 종류를 가진다.
-
0(uninitialized): request가 초기화되지 않음
-
1(loading): 서버와의 연결이 성사됨
-
2(loaded): 서버가 request를 받음
-
3(interactive): request를 처리하는 중
-
4(complete): request에 대한 처리가 끝났으며 응답 준비가 완료됨
즉 XMLHttpRequest로 보낸 요청이 준비되거나, 서버가 받았거나, 요청이 완료됐거나 하면서 상태가 변할 때마다 객체 내부의 readystate 상태가 변하게 되고 이벤트 핸들러로 등록된 함수가 호출되는 것이다. 그럼 이 getchatlog()는 어떤 타입으로 실행되는 것일까? 이는 현재 페이지의 body 태그에 등록된 init() 함수에서 알 수 있었다.
<script>
var xmlhttp, ni, iq = 0, brtype = 1;
...
function init() {
cwifr = document.getElementById("ifr").contentWindow;
cwifr.document.body.style.backgroundColor = "fff";
// cwifr.document.designMode="on";
var temp = get_br_blue();
if (temp == "CHR" || temp == "SAF") {
brtype = 2;
}
getchatlog(1);
setInterval("getchatlog(1)", 1000);
document.getElementById("firstf").focus();
}
</script>
사용자가 BlueCHAT에 로그인해서 이 웹페이지가 로딩되면 위의 init() 함수를 수행한다. 단순 스타일 설정과 get_br_blue() 함수 호출 및 그에 따른 brtype 변수 설정, 위에서 본 getchatlog(1) 함수 호출, setInterval() 함수 호출을 통한 getchatlog(1) 함수의 주기적 호출을 수행하고 있다. 처음 보는 함수인 get_br_blue()는 아까 네트워크에서 본 blueh4g_js.js 스크립트에 포함된 함수로 사용자 브라우저 종류를 확인하여 그에 따른 브라우저 타입을 반환하고 있다. 브라우저 타입 자체는 문제를 원활하게 풀 수 있도록 하기 위한 것이기 때문에 신경 쓸 부분이 아니다.
getchatlog(), setInterval() 함수로 확인할 수 있는 것은 getchatlog() 함수에 매개변수 1을 넘겨서 이를 1000ms, 즉 1초마다 호출하고 있다는 것이다. 이는 1초마다 chatlog.php의 파라미터 t에 1 값을 포함하여 GET 요청을 하고 있는 것인데 그럼 매 초마다 수행될 이벤트 핸들러인 getni 함수가 어떤 동작을 하는지 분석해보자.
<script>
var xmlhttp, ni, iq = 0, brtype = 1;
...
function getni() {
if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {
var tni = parseInt(xmlhttp.responseText);
if (tni != ni && iq != 0) {
getchatlog(2);
}
ni = tni;
iq = 1;
}
}
...
</script>
이 함수에서는 오직 XHR 요청의 readyState가 4일 때, 즉 요청에 대한 처리가 끝났고 상태 코드가 200일 때, 즉 해당 요청이 성공했을 때만 동작하고 있다. 요청에 대한 성공적인 응답을 받았다면 응답 텍스트에서 parseInt() 함수를 사용하여 정수 값을 파싱 한 후 tni 변수에 저장하고 있다. 위에서 분석한 결과 요청에 대한 반환 값은 59395 같은 5자리 정수 값이었는데 이를 파싱 하여 내부 변수에 저장하고 있는 것이다. 이후 전역 변수인 ni와 비교하여 다르다면, 그리고 iq 변숫값이 0이 아니라면 getchatlog(2) 함수를 호출하고 있다. 이후 ni 변수에 tni 변숫값을 저장하고 iq 변수값을 1로 설정하고 있다.
만약 이 함수가 처음 호출되었다면 전역 변수 iq가 0으로 초기화된 이상 tni 변수를 파싱 하고 끝날 것이다. 이후부터는 iq 변수가 1값으로 설정되었기 때문에 호출될 때마다 tni 변수를 파싱하고 ni 변수와 다르다면 getchatlog(2) 함수를 호출할 것인데 그렇다면 tni 변수와 ni 변수는 무슨 관계일까? 추측으로는 무언가 상태가 바뀌었는지 아닌지를 확인하는 것 같다. 예를 들면 티켓 같은 건데 현재 사용자가 가지고 있는 티켓(ni)이 서버 측의 티켓(tni)과 다르다면 getchatlog(2) 함수를 호출하여 무언가를 업데이트시키는 게 아닐까? ni 변수에 항상 tni 변숫값을 저장하고 있기 때문에 억측일지도 모르지만 단순 코딩 습관 문제일 수도 있다.
위에서 분석한 결과에 따르면 getchatlog(2) 함수는 chatview.php에 파라미터 t에 1을, 파라미터 ni에 ni 변수값을 포함하여 요청을 전송한다. 만약 ni 변수와 tni 변숫값이 다르다면 현재 ni 값을 파라미터로 포함해서 요청하는 것인데 이 경우 수행되는 이벤트 핸들러인 chatprint 함수가 어떤 동작을 하는지 분석해보자.
<script>
var xmlhttp, ni, iq = 0, brtype = 1;
...
function chatprint() {
if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {
var temp = xmlhttp.responseText;
if (brtype == 2) {
cwifr.document.body.innerHTML += temp;
} else {
cwifr.document.write(temp);
}
cwifr.document.body.scrollTop = cwifr.document.body.scrollHeight;
}
}
...
</script>
역시 XHR 요청이 잘 수행됐을 때만 호출되며 이때 응답 텍스트를 temp 변수에 저장하고 있다. 브라우저 타입에 따라 수행되는 코드가 약간 달라지는데 둘 다 현재 웹페이지에 temp 변수에 담긴 텍스트를 추가하고 있다는 것은 동일하다. 그렇다면 이 temp 변수에는 무슨 문자열이 들어있을까? chatprint라는 함수 이름으로 생각해 볼 때 다른 사람이 입력한 채팅이나 내가 입력한 채팅이 전송되었을 때 이를 채팅방에 표시하기 위해 필요한 텍스트라고 추측할 수 있다. 어쨌든 웹상에서 정적인 HTML 문서로 채팅방이 구현된 만큼 채팅이 추가되려면 텍스트를 담은 태그가 추가되거나 변경돼야 하기 때문이다.
확인을 위해 직접 채팅을 보내보자 다음과 같은 요청이 개발자 도구에 감지되었다.
chatlog.php에 이번에는 t가 아닌 data 파라미터에 우리가 입력한 문자열을 매개변수로 전달하고 있었다. 아까 채팅창에서 '#'이 들어간 문자열을 입력하면 '#' 뒤의 문자들이 사라지는 이유가 이것(anchor) 때문이었다. 그럼 이 data 파라미터를 포함한 요청은 어디서 시작된 걸까? 확인해 보니 채팅창 입력 폼에 onsubmit 속성으로 sayf()라는 함수가 달려있었다.
<script>
var xmlhttp, ni, iq = 0, brtype = 1;
...
function sayf(f) {
var xmlhttp2 = new XMLHttpRequest();
var pdata = "chatlog.php?" + encodeURI("data=" + f.saydata.value);
xmlhttp2.open("GET", pdata);
xmlhttp2.send(null);
f.saydata.value = "";
f.saydata.focus();
return false;
}
...
</script>
여기서도 XHR 요청을 보내는데 chatlog.php에 data 파라미터에 우리가 입력한 문자열을 URI 인코딩하여 삽입한 후 GET으로 요청하고 있었다. 확인을 위해 직접 url에 입력해보니 입력한 값들이 그대로 채팅창에 출력되는 것을 볼 수 있었다.
'#' 문자도 %23으로 인코딩해서 보내니 정상적으로 출력되는 것을 볼 수 있었다. 아까 따옴표나 기타 여러 문자들도 그대로 출력됐으니 이 채팅 데이터 입력 부분에서는 SQL Injection에 대한 기본적인 필터링이 되어있다고 볼 수 있을 것이다. 그렇다면 어떤 부분을 분석해봐야 할까? 일단 채팅을 입력했을 때 요청된 chatview.php를 살펴보기로 했다.
채팅창에 표시되는 채팅 형식으로 우리가 입력한 데이터가 돌아온 것을 볼 수 있다. 아마 우리가 입력하면 우리 화면에 먼저 출력하는 게 아니라 서버에 데이터 전송 -> 채팅에 참여한 인원들에게 브로드캐스트 하는 로직이라 추측할 수 있다. 위에서 분석한 자바스크립트 함수들을 살펴보면 이 chatview.php에 대한 요청은 getchatlog() 함수에서 2번 타입으로 호출되었을 때만 수행된다. 그렇다면 getchatlog(2) 함수는 어디서 호출될까? 쭉 살펴보면 getni() 함수에서 호출되며 이는 아까 봤듯이 onreadystatechange 이벤트 핸들러에 등록된 함수로 getchatlog(1)에서 호출된다. 정확히는 XHR 객체의 이벤트 핸들러로 호출되는 것이다.
정리해보면 주기적(1초)으로 호출되는 getchatlog(1) 함수에서 XHR 객체에 등록한 getni() 함수가 특정 조건(tni 변수와 ni 변수의 차이)이 맞으면 getchatlog(2) 함수를 호출해서 새로운 채팅 메시지를 받아와서 채팅방에 그려주고 우리가 입력한 채팅 메시지는 sayf() 함수를 호출해서 서버로 전송한다는 것을 알 수 있었다.
그런데 chatview.php의 파라미터로 ni 변수는 왜 넘기는 것일까? 위에서 추측한 대로 만약 이 ni 변수나 tni 변수가 티켓 같은 역할을 하는지 확인해보기 위해 변숫값의 변화를 조사해보았다.
직접 XHR 객체를 생성하여 채팅 메시지를 전송했을 때 전후 ni 변숫값을 비교해보니 1씩 증가하는 것을 알 수 있었다. 그렇다면 이렇게 추측해볼 수 있다.
위의 그림처럼 채팅을 입력하면 sayf() 함수에서 채팅 메시지를 서버로 보내고 서버에서는 메시지를 수신, 보관하고 티켓값(받은 메시지의 개수라던지)을 증가시킨다. 그리고 1초마다 호출되는 우리의 getni() 함수에서 서버에 요청하여 받은 티켓값(파싱 된 tni 변수)이 클라이언트 측의 티켓값(ni 변수)과 다른 것을 확인하고 getchatlog(2) 함수를 호출하여 현재 티켓값을 파라미터로 포함하여 요청, 새로 전송된 메시지를 받아 출력한다.
그럼 여기서 SQL Injection을 시도해보고자 한다면 어느 부분을 공격할 수 있을까? chatview.php나 chatlog.php에 파라미터로 넘길 수 있는 부분은 한정되어 있으니 일단 확실히 SQL Injection 필터링이 적용되어 있다고 추측되는 부분만 제외하면 몇 군데 남지 않을 것이다.
-
chatlog.php
-
t: 1로 고정되어 있음. (X)
-
data: 클라이언트 측에서 입력한 문자열에 따라 다름. SQL Injection 필터링되어있음. (X)
-
-
chatview.php
-
t: 1로 고정되어 있음. (X)
-
ni: 클라이언트 측에서 유지하고 있는 변숫값에 따라 다름.
-
정리해본 결과 위처럼 chatview.php의 ni 파라미터에 SQL Injection을 시도해 보기로 했다.
처음에는 ni 파라미터에 1 값을 넘겨줬는데 한참 동안 웹페이지가 로딩되면서 매우 긴 목록의 채팅 로그가 출력되고 있었다. 아마 추측한 대로 ni 변수가 티켓 역할을 수행한다면 이를 1로 설정할 경우 맨 처음 채팅부터 불러오기 때문에 약 6만 개의 채팅 로그를 불러올 것이며 이렇게까지 할 필요는 없기 때문에 로딩을 중단하고 현재 ni 값과 유사한 값을 시도해보았다.
59350 정도로 입력해본 결과 채팅창에서 볼 수 없었던 다른 접속자들의 채팅들도 출력되는 것을 볼 수 있었다. 그렇다면 ni 파라미터에 숫자 대신 문자열이나 따옴표, 주석 문자 등을 입력해보면 어떨까? 따옴표, 큰따옴표, 주석('#', '-- -'), 세미콜론(';') 등을 입력해보았지만 어떤 결괏값도 얻을 수 없었다. 하지만 이를 SQL 쿼리 측면에서 생각해보면 어떨까?
현재 chatview.php에서는 우리가 입력한 티켓값(ni)부터 서버 측의 현재 티켓값까지의 채팅 로그들을 출력하고 있다. 데이터베이스에 저장된 수많은 채팅 로그들 중 일정 구간의 로그만 얻으려면 어떻게 SQL 쿼리를 작성해야 할까? 추측해볼 수 있는 것은 WHERE 조건절에서 특정 필드 값과 비교하는 것이다.
예를 들어 데이터베이스에 채팅 로그들이 위처럼 저장되어 있다고 할 때 idx, 즉 인덱스가 1인 채팅부터 불러오고자 한다면 어떻게 할 수 있을까? 테이블 이름이 chat_log라 할 때 다음처럼 실행할 수 있다.
select chat from chat_log where idx>=1
위의 쿼리에서 '1'이 얼마나 채팅을 불러올지 결정하게 된다. 이 '1'은 실제 쿼리에서는 우리가 파라미터로 넘긴 ni 변숫값이 될 것이다. 위처럼 59350을 입력했다면 서버 측의 티켓값(59400 정도?) 순서대로 테이블에 채팅 로그가 정렬되어 있다고 할 때(또는 ORDER BY로 정렬되었을 때) 59350번째 채팅 로그부터 마지막 채팅까지 모두 불러올 수 있는 것이다. 그렇다면 이 '1'은 쿼리의 맨 마지막에 있으니 여기에 union select를 사용하여 SQL Injection을 할 수 있지 않을까? 시도해보니 다음과 같이 쿼리 결과에 5개의 칼럼이 존재한다는 것을 알 수 있었다.
추측했던 쿼리가 틀렸는지 모든 채팅 로그가 표시되고 있었다. 하지만 union select 1부터 union select 1,2,3,4까지 반응이 없다가 union select 1,2,3,4,5에서 위처럼 어떤 반응을 얻을 수 있었기 때문에 이를 이용하여 Blind SQL Injection + Union SQL Injection을 수행할 수 있을 것이다. 우선 확인을 위해 union 앞의 조건을 "and false"를 이용해 거짓으로 만들고 1,2,3,4,5만 출력시켜보았다.
1과 4는 출력되지 않고 2는 사용자 이름, 3은 채팅, 5는 IP 주소에 표시되는 것을 볼 수 있었다. 그러면 2, 3의 위치에 information_schema.columns의 table_name, column_name을 넣어볼 수 있지 않을까?
성공적으로 모든 테이블의 모든 칼럼에 대한 정보를 얻을 수 있었다. 사용자 테이블은 맨 마지막에 있으니 제일 아래로 내려가면 다음과 같은 테이블과 칼럼을 발견할 수 있었다.
해당 테이블에 담긴 데이터를 조회해보니 다음과 같이 플래그를 얻을 수 있었다.
이 문제는 개발자의 관점에서 생각해보라는 힌트는 SQL 쿼리를 추측할 때가 돼서야 느낄 수 있었다. 문제를 풀 때는 몰랐지만 지금처럼 풀이를 작성하면서 다시 되새겨보니 깨달은 경우였다. 아직도 SQL Injection 문제에는 약하지만 그래도 LoS를 풀면서 조금씩 향상되고 있다는 것을 느낀 게 원래는 이 문제를 어디를 공략해야 할지 도저히 모르겠어서 스킵하고 다른 문제를 풀고 있었다. 그렇지만 이 포스트를 쓰기 위해 오늘 다시 시도해본 결과 chatlog.php, chatview.php 두 사이트에 GET으로 전달되는 파라미터가 공격 벡터인 것을 파악하여 위처럼 blind sqli를 시도하여 풀 수 있었다. 정말 못 풀 줄 알았지만 이렇게 풀 수 있어서 매우 감격스럽다.
'챌린지 > Wargame.kr' 카테고리의 다른 글
bughela - SimpleBoard (0) | 2021.01.09 |
---|---|
bughela - pyc decompile (0) | 2021.01.06 |
bughela - EASY_CrackMe (0) | 2020.12.31 |
bughela - php? c? (0) | 2020.12.29 |
bughela - img recovery (0) | 2020.12.28 |