오래전 이야기/Security

보안 취약점 사례 - 쿼리에 의한 병목

리눅스 엔지니어였던 2008. 12. 3. 17:07

얼마 전 담당했던 서버의 보안 취약점 사례.
이 취약점은 의외로 자주 발견되는 취약점이기에, 이렇게 포스트로 적는다. 보통 경험이 짧은 서버 개발자가 일으키는 가장 초보적인 취약점이기 때문.
서버 개발 경험이 많은 사람은 이미 알만한 내용이므로 굳이 볼 필요는 없다.

이 서버는 어쩌다 한번씩 마비되는 문제를 갖고 있었다. 버그를 찾아봤지만, 버그를 못찾겠단다. 공격으로 판단할만한 과다 트래픽도 없었으며, 그만한 CPU 점유율도 발생하지 않았단다. 예외 발생도 없고 그냥 마비된단다.
헌데 항상 마비되는 시점은 동접이 많고 서비스가 활발히 이루어지는 시점이라, 패킷 덤핑을 통해 분석하려니 분량이 너무 많아 대책이 없는 상황. 사실 패킷 덤핑 분석까지 갔다면 거의 OTL orz ___ 상황이라는 의미.

이유는 간단했다.

공 격자는 정상적인 클라이언트가 아닌 별도의 TCP 데이터 송신 툴(Non-client Bot)을 다수 사용하여 서버에 동시 접속, 미리 분석한 정상적인 프로토콜을 이용하여 잘못된 계정 정보로써 로그인 정보를 보냈다. 헌데 다수의 로그인 메시지를 하나의 패킷에 뭉쳐서 보냈던 것. 그것도 일정 시간 간격에 연속으로. 물론 결과적인 트래픽은 트래픽 공격으로 보이지도 않을 정도로 매우 작다.

이게 서버에 어떤 영향을 끼치는지 한번 정리해보자.

일단 보편적 서버가 가진 로그인 절차의 기본 뼈대를 한번 보자.

(C:클라이언트, S:서버)
1. C : 로그인 정보를 메시지로써 포맷팅 후 서버로 전달하여 로그인 요청.
2. S : 수신&분리 후 Integrity checking 및 ID 식별 후 분기.
3. S : 로그인 정보를 이용하여 DB 쿼리 실시.
4. S : 조회 완료시 결과를 C에 전달 및 서비스 세션개체 초기화, 기타등등...
5. C : 결과에 따른 추가 동작 결정.

일단 이 마비된다는 서버가 가진 문제는, 2번의 과정에서 중복 로그인 요청인지의 판단이 생략되었다는 것, 그리고 3번의 DB 쿼리가 blocking call 이었다는 것이다.

보 편적인 서비스에서 동일한 서비스에 대한 로그인을 2회 이상 할 필요는 없다. 그러므로 1회를 초과하는 로그인 요청은 반드시 무시해야 한다. 이 때 그냥 끊는 방법도 있지만, 클라이언트 개발자의 실수 등등에 의해 2회 이상 요청될 수 있으므로(UI엔진상의 문제로 특정 상황에 2회 연속 클릭 등...) 그냥 무시해주는게 좋다. 안그래도 바쁜 애들 괜히 귀찮게 하지 말자.

헌데 이 서버는 이 조치가 없었으며, 수신 데이터에서 로그인 요청 메시지가 분리되는 족족 이 계정 정보로써 DB 쿼리를 걸었던 것이다.
(당연히 ID가 없는 계정이므로, DB의 탐색 시간은 최악이 될 가능성이 높다.)

게 다가 이 DB blocking call 을 처리하는 쓰레드가 서비스 처리까지 하고 있었으므로, 로그인 정보 쿼리가 끊임없이 누적되어 몰리면서 쿼리 루프에 발목이 잡혀 서비스 코드를 거의 실행하지 못했던 것. 이 상황이 발생/진행 후 마비를 가져오는 시간은 무척 짧으며, 상황 발생시 어차피 맛간 상황! 미친 척 하고 디버그에 break 를 걸고 들어가봐도 정상적인 로그인 처리 과정만이 보일 뿐이다.

해결책은 다음과 같았다.
A. 2번 과정에서 1회를 초과하는 로그인 요청을 무시하는 것.
B. DB 쿼리를 별도의 쓰레드로 분리하여 비동기 방식으로 변환. 이를 위해 서비스 로직 코드를 비동기 쿼리 구조로 대폭 변경.
* B 항목은 부분은 뒤에 추가적으로 설명을 하겠다.
C. 추가적인 프로토콜에 의한 반복쿼리 요청 가능성을 분석하여, 이에 대한 skip/kill 코드를 추가하는 것.

DB 접근에 있어 개인적으로 생각하는 가장 이상적이고 효율적인 방법은, 서버가 DB에 직접 연결하지 않고 HTTP:Keep-Alive 프로토콜을 통해 DB서버에 설치된 미들웨어 웹서버를 거쳐 쿼리를 처리하는 것이지만, 그건 처음부터 그렇게 설계되어야 가능한 얘기고 이미 DB 라이브러리와 쿼리문, 결과 분석 코드를 물고 있는 서버는 그렇게 수정하긴 힘들다.

어떤 서버 개발자는 DB blocking call 코드에 대한 이 공격을 막기 위해, 서버에 수신된 1회의 요청에 대한 응답이 송신되기 전에 추가적 요청이 올 경우 아예 연결을 끊도록 프로토콜을 설계하는 경우도 있다.
하 지만 이 경우 요청간의 간격이 길어져, 클라이언트의 요청 가능한 메시지의 종류가 대폭 줄어들게 된다. 이를 해결하기 위해선 결국 서버는 클라이언트가 요청할 가능성이 있는 정보를 죄다 능동적으로 꾸준히 전송해주는 수 밖에... 결국 이 오버헤드는 동접 증가에 따른 서버의 성능 저하를 가져온다.
게다가 이렇게 설계한다 하더라도, 다수의 Bot이 요청-응답 시간 간격에 맞춰 동시에 쿼리를 발생시킬 경우엔 역시 DB blocking call 을 들어가는 쓰레드는 마비되거나 렉을 유발할 가능성이 있다.

그 러므로 절대로 서비스 쓰레드가 DB 쿼리를 위한 blocking call 을 호출하게 하지 말자. 물론 선형 구조가 로직을 분리할 필요가 없어 편할지 모르나, 자칫 잘못하면 이 쿼리로 인한 blocking time의 누적으로 인해 서비스 전체가 마비될 수 있기 때문이다.

특히 동접과 함께 늘어나는 쿼리의 양에 의한 blocking call time의 누적은, 쿼리와 무관한 서비스의 처리까지도 지연시키는 요소가 된다.
이 걸 서비스 쓰레드를 늘림으로써 해결하려 한다면, 그로 인한 서비스 쓰레드간의 mutex 와 context switching 에 의한 또 다른 오버헤드를 발생시킬 뿐, 결코 궁극적 해결책은 되지 못한다.(이렇게 하려면 차라리 웹 기반으로 만들자.)

이제 이 비동기 쿼리 구조에 대한 상세한 설명을 할 차례.

일 단 열심히 수업만 들으며, 열심히 토익/플 점수 맞추고, 열심히 학점 때우고, 시키는대로 과제만 열심히 한 갓 졸업한 싱싱한 수습 서버개발자에게 테스트 삼아 간단한 서버를 만들어보라 시키면, DB쿼리를 요구하는 메시지 처리부가 대략 다음과 같이 나온다.
(뭐 물론 이런 싱싱한 개발자에게 서버, 아니 개발 자체를 맡기는 경우는 드물다. 보통 경력자를 쓰기에. 헌데 경력자도 경력자 나름...)

void UserObj::OnReqQuery( MSG_OBJ &msg )
{
    QueryResult
result = db_query_blocking( msg );

   
if( result._isOkay ) {
        proceedResult(
result );
        SendOkayMsg(
result );
        return;
    }
    SendFailMsg(
result );
}


뭐 일반적인 어플리케이션이나 클라이언트 개발자의 기준으로는 특별할게 없어보이며 지극히 정상적인 코드.
하지만 이건 C++ 서버 코드다. 그러므로 문제가 있다.
이 구조로 동작하기 위해선
db_query_blocking() 이 쿼리 결과를 반환할 때까지, 즉 DBMS가 쿼리를 처리하고 결과를 돌려주기 전까지 이 메쏘드의 호출 쓰레드는 blocking에 들어가야만 한다.
(이건 간단히 생각하면 불가피한 얘기다. 꼭 필요한 정보를 얻지 못했다면 어떻게 로직상 다음 코드를 수행하겠는가?)
또한 쿼리가 결과정보를 필요치 않는 UPDATE 나 INSERT 형태로만 구성되었을 경우, 이 쿼리의 완료 대기를 위한 blocking time 은 불필요한 시간 낭비가 된다.

물론 단일 DB쿼리의 blocking time 은 그리 길지 않다.
문 제는 C++로 서버를 개발하는 이유는 1백단위에서 1천단위에 이르는 막대한 동접 유지와 함께 실시간에 가까운 고속의 서비스 처리를 구현하려는 목적이기 때문이다. 그 외엔 요즘 시대에 굳이 높은 비용에 리스크를 감수하며 C++로 서버를 개발할 이유가 없다.
이런 상황에 이 메시지의 수신 빈도가 매우 높다면? 그 빈도수만큼 blocking time 은 누적된다. 그리고 싱싱한 개발자의 수준으론 이 메쏘드를 호출할 쓰레드는 분명 DB쿼리를 요구하지 않는 서비스 코드까지 처리할테니, 결국 그 누적된 blocking time 은 그런 서비스까지 지연시키는 요소로 작용한다. 게다가 이 포스트에서 지적하는 취약점을 발생시킬 여지도 존재.

이를 피하려 자연스럽게 생각하는 가장 기본적인 방식은 중복요청의 무조건적 금지. 헌데 그렇게 설계하면 프로토콜이 상당히 빡빡해진다. 게다가 막노동에 시달리는 클라이언트 개발자 입장에서 요청 전에 이전 요청의 응답 수신 여부를 확인 후 예약해야 하므로... 처리 지연을 피하기 위해 로직을 비선형으로 코딩해야 할 가능성이 높아지는 등 살짝 짜증나는 일이 된다.

그래서 아래와 같이 서버 개발자가 분리를 하라는 것이다.

void UserObj::OnReqQuery( MSG_OBJ &msg )
{

    AsyncResult result = db_query_async( msg, this ); // 비동기 쿼리 신청을 삽입.
   
if( result._isOkay )
return;

    SendFailMsg(
result );
}


void UserObj::OnResultQuery( QueryResult &result )
{
    if( result._isOkay ) {
        proceedResult(
result );
        SendOkayMsg(
result );
        return;
    }
    SendFailMsg(
result );
}


제반조건
A. 당연한 얘기지만 db_query_async() 는 반드시 별도의 쓰레드/호스트에 의해 돌아가는 DB query sub system 의 인터페이스여야만 한다. 결과를 대기하며 블러킹을 거는게 아니라, 비동기 쿼리 신청만을 수행하고 바로 반환되도록 제작되어야 한다.

B. DB query sub system 은 결과 수신 후 통보시, db_query_async() 로써 전달받은 this 에 대한 OnResultQuery() 호출을 수행해야 한다. 단 이 호출 쓰레드는 반드시
 a.UserObj 를 접근하는 쓰레드와 동일하거나(별도의 쓰레드가 아닌 M/W에 소켓통신으로써 쿼리를 처리하는 경우)
  b. 동일하지 않다면 완료통보 매커니즘을 준비/사용하거나(별도의 DB쿼리 쓰레드가 sock demuxing IOCP에 PQCS로써 통보, 이를 GQCS로써 받는 등의 경우)
  c. 이게 정말 힘들다면 UserObj 를 접근하는 쓰레드와의 mutex 를 준비(이런 경우는 없다. 장담한다. 이건 개발자에 문제가 있다.)
위  사항중 하나를 충족해야 한다.


주의사항
A. 완료통보 처리자의 OnResultQuery() 호출 전, 반드시 UserObj 개체의 유효성 검사가 선행되어야 하며 이게 가능해야 한다.
비동기 쿼리의 처리 도중 언제든 UserObj 개체의 연결이 종료될 수 있다. 만약 쿼리 완료통보 시점에서 UserObj 가 무효하다면, 이는 당연히 오류 발생의 원인이 된다.
이를 위해 반드시 UserObj 등 쿼리 완료통보를 받을 개체의 할당은 개체 자체의 상존성을 확보하기 위해 FIFO 구조의 pool 등을 사용하며, 이에 대한 유효성을 확인 가능하게 설계할 것!

B.
DB query sub system 설계시 쿼리의 직렬화에 유의할 것.
만약 다수의 비동기 쿼리 처리자가 있을 경우, 쿼리의 요청 순서와 쿼리의 실제 처리 순서는 다를 수 있다. 그러므로 이에 대한 직렬화를 처리할 수 있는 매커니즘과 인터페이스를 갖출 것.
예컨데, DB query sub system 이 단순히 2개의 쿼리 쓰레드에 대한 Round-Robin 방식의 할당으로 설계되었다고 가정하자.
이 때 UPDATE 만을 수행하는 쿼리 U1과 U2를 로직코드에서 연속으로 요청하며, 반드시 U1 처리 후 U2가 처리되어야 한다고 가정하자.
이 경우 U1, U2 가 요청 순서대로 수행되리라는 보장은 없다. thread context switching 을 결정하는건 주사위신이다. 이미 진행중인 쿼리가 어느 쓰레드쪽에서 먼저 끝날지도 알 수 없다.
그러므로 반드시 쿼리 직렬화 채널 개념을 갖춘 인터페이스를 준비하여, 직렬화가 필요한 쿼리의 순서가 보장되도록 할 것.

이와 관련된 소스를 공개할 수 있으면 좋겠는데, 애석하게도 공개 가능한 소스가 없다.
언젠가 시간 나면 준비해서 수정할지도...



***
바 이너리 서버의 DB 쿼리에 HTTP:Keep-Alive 프로토콜로써 DB에 설치된 웹 미들웨어 서버를 거치게 할 경우 IOCP 등의 socket demuxer를 통해 쿼리 결과를 통보받기에, 서비스 쓰레드와의 동기화도 상당히 편해지고 효율적이 된다. 게다가 DBMS 자체 혹은 stored procedure 변경에도 바이너리 서버 코드의 변경필요가 거의 없다는 장점과, abstract-concrete 2중 프로토콜을 이용하여 잦은 변경이 가해지는 서비스 로직의 유지보수를 비용이 적게 드는 웹프로그래머에게 전담시킬 수 있게 된다.

stored procedure 담당 입장에서도 동일한 서비스 코드를 웹서비스용과 바이너리 서버용으로 따로 만들 필요 없이, 웹 미들웨어 프로그래머가 결과를 abstract protocol 에 맞춰 변경해주도록 작성하면 되므로, 관리가 상당히 편해진다.

이 방식이 느린 웹서버를 거치기에 다수의 쿼리가 몰리면 성능이 저하된다 생각할지 모르지만, 실제론 비동기 DB쿼리를 위한 쓰레드를 서버에서 제거하며, DB conn/xaction 유지에 필요한 부하를 서버로부터 제거할 수 있어 오히려 서버의 자체 서비스 성능을 개선시킨다. 막상 적용해보면 이 웹 기반 M/W가 예상보다 빠르다는걸 알 수 있다.
그리고 요즘 웹서버는 충분히 빠르다. 과다 동접 처리 성능이 떨어질 뿐이지만, 바이너리 서버는 굳이 이 웹서버에 부담을 줄만큼의 동접을 유지할 이유가 없다.
또한 잦은 유지보수가 필요한 서비스 로직의 수정을 덜떨어진 C++이 아닌, 싸고 빠르고 똑똑한 웹 프로그래머에게 맡길 수 있다는 메리트는 상당히 거부하기 힘든 부분.
(물론 이는 설계 능력에 따라 상황이 달라지긴 하지만...)

이 때 미들웨어와 DB의 port blocking system은 필수.

==============================
< 출처: http://blog.naver.com/hanzo69?Redirect=Log&logNo=130038387362 >