VoIP 오픈 소스 라이브러리인 PJSIP에서의 버퍼 오버플로우

안녕하세요. 보안실(애플리케이션 보안팀)에서 LINE 서비스의 보안 평가를 담당하고 있는 김영성(Facebook, Twitter)입니다.

이번 포스팅에서는 VoIP 오픈 소스 라이브러리인 PJSIP의 취약점(CVE-2017-16872, AST-2017-009)에 대해 다루고자 합니다. PJSIP는 표준 프로토콜인 SIP, SDP, RTP, STUN, TURN 및 ICE를 구현한 멀티미디어 통신 라이브러리입니다. IP-PBX, VoIP 게이트웨이 등에서 널리 사용되는 Asterisk 프레임워크에서도 PJSIP 기반으로 SIP 스택을 구현했습니다.

이번에 발견한 취약점은 64-bit 환경에서 클라이언트로부터 받은 SIP 요청을 처리할 때, signed int형을 unsigned long 형으로 변환하는 과정 중에, 정수의 부호 확장을 고려하지 않은 것이 원인이며, 결과적으로 이 취약점은 버퍼 오버플로우를 발생시킬 수 있습니다. 취약점을 확인한 후 PJSIP 개발팀에 문의했지만 보안 이슈를 제보할 수 있는 창구가 없어, Asterisk security 대응 창구제보했습니다. 이후 Asterisk의 개발자인 George Joseph씨와 패치에 대해 논의했고, pjproject 2.7.1 버전에 패치(PJSIP 패치, Asterisk 패치)가 적용되었습니다. 이 기회를 빌어 패치 작업을 진행해 주신 George씨께 감사드립니다.

형 변환 시 발생하는 부호 확장이란?

C 언어에서는 함수를 호출할 때 함수에 정의된 매개변수의 자료형과 다른 자료형의 값을 인자로 넘기면, 암묵적으로 해당 인자 값의 자료형이 함수에 정의된 매개변수의 자료형으로 변환되는 이른 바, 형 변환이 발생합니다. 예를 들어, function(int a)라는 함수를 호출할 때, 매개변수로 char형의 인자 값을 넘긴다면 인자 값은 int형으로 변환됩니다. char형의 데이터가 int형 으로 변환될 때, 데이터의 크기(1 바이트)가 int형의 크기(4 바이트)로 확장되면서 추가 ‘공간’이 생깁니다. char형은 부호를 지닌 자료형인데, 부호는 char형 데이터의 최상위 비트로 나타냅니다. 형 변환으로 인해 추가적으로 생긴 공간은 기존의 char형 값의 부호를 보존하기 위해 사용되는데, char형 값의 최상위 비트의 값이 0이었으면 0으로, 1이었으면 1로 채워지며, 이러한 현상을 부호 확장이라고 합니다.

일례로, -5라는 값을 가진 char 형식의 데이터를 int 형식으로 변환해 볼까요? -5는 음수이기 때문에, 2의 보수 체계에 따라, 헥사 값으로 0xFB가 됩니다. 이 값의 자료형이 정수가 되면 추가로 3 바이트의 ‘공간’이 생기며, 이 공간을 1로 채우게 됨에 따라, 다음의 그림과 같이 -5는 0xFFFFFFFB로 변환됩니다.

sign_extension_1

그렇다면, signed char 형에서 부호가 다른 unsigned int 형으로 형을 변환할 때는 어떻게 될까요? 앞선 예제와 같이 -5를 가지고 살펴 보겠습니다. -5의 값을 가진 signed char 형식의 데이터가 unsigned int형으로 변환되면서 변환된 값은 위 예제와 마찬가지로 0xFFFFFFFB가 되지만, 자료형이 양수이므로 데이터의 10진수 값은 양수 값인 4294967291이 됩니다. 이러한 예기치 못한 값의 변화는 경계 체크 로직 우회를 허용한다거나 버퍼 오버플로우를 발생시키는 원인이 될 수 있으므로 주의가 필요합니다.

sign_extension_2

부호 확장으로 인한 의도치 않은 값 변형

제대로 처리되지 않은 형 변환으로 인해 발생하는 PJSIP의 취약점을 살펴보도록 하겠습니다.

다음은 sip_transaction.c 파일의 일부입니다. create_tsx_key_2543() 함수 내에서 pj_utoa() 함수를 호출하고 있습니다. 호출 시 pj_utoa() 함수의 첫 번째 인자로 CSeq 번호를 넘기고 있는데, CSeq 번호, 즉 rdata->msg_info.cseq->cseq의 자료형은 signed int입니다.

// pjsip/src/pjsip/sip_transaction.c

static pj_status_t create_tsx_key_2543( ... )
{
...
    /* Add CSeq (only the number). */
    len = pj_utoa(rdata->msg_info.cseq->cseq, p); // rdata->msg_info.cseq->cseq : pj_int32_t
                                                  // (defined: pjsip/include/pjsip/sip_msg.h)

하지만 pj_utoa() 함수의 첫 번째 매개변수인 val의 자료형은 다음과 같이 unsigned long으로 정의되어 있습니다. 따라서 CSeq 번호는 signed int 형에서 unsigned long 형으로 변환됩니다.

// pjlib/src/pj/string.c

PJ_DEF(int) pj_utoa(unsigned long val, char *buf)
{
    // Note that passing a negative `val` can result in a large unsigned long value.
    // In 64bit machines, the size of unsigned long is 8 bytes.
    // Max unsigned long = 0xFFFFFFFFFFFFFFFF = 18446744073709551615

    return pj_utoa_pad(val, buf, 0, 0);
}

만약 pj_utoa() 함수의 첫 번째 인자로 음수가 전달된다면, 예를 들어 -1이 전달된다면, pj_utoa() 함수 내에서 val 변수는 부호 확장을 거치면서 다음의 그림과 같이 부호가 없어지면서 의도한 바와 달리 상당히 큰 수가 되어버립니다. 일반적으로 64-bit 환경에서는 unsigned long 형의 크기는 8 바이트로, 최대 값은 18446744073709551615입니다.

sign_extension_utoa

pj_utoa() 함수에서 호출하는 pj_utoa_pad() 함수는 pj_utoa() 함수의 첫 번째 매개변수로 전달받은 val 값을 10진수 형태의 문자열로 바꿔 두 번째 매개변수인 buf에 저장합니다. val 변수가 가질 수 있는 최대값은 18446744073709551615이므로, buf에는 최대 21 바이트의 문자열("18446744073709551615" + NULL)이 쓰일 수 있습니다. 즉, 버퍼(buf)는 최소 21 바이트의 크기가 필요합니다.

하지만, 앞서 소개한 create_tsx_key_2543() 함수 내에서는 pj_utoa() 함수가 CSeq의 번호 정보나 Via Port의 번호 정보를 담는 버퍼의 크기를 계산할 때, CSeq 번호나 Via Port 번호를 각각 21 바이트가 아니라 9 바이트로 간주합니다.

// pjsip/src/pjsip/sip_transaction.c

/*
 * Create key from the incoming data, to be used to search the transaction
 * in the transaction hash table.
 */
static pj_status_t create_tsx_key_2543( ... )
{
...
    /* Calculate length required. */
    // Note that Cseq, Via port are calculated as 9bytes each.
    len_required = method->name.slen +      /* Method */
                   9 +                      /* CSeq number */
                   ...
                   9 +                      /* Via port. */
                   16;                      /* Separator+Allowance. */
    key = p = (char*) pj_pool_alloc(pool, len_required);

...
    /* Add CSeq (only the number). */
    len = pj_utoa(rdata->msg_info.cseq->cseq, p);    // rdata->msg_info.cseq->cseq : pj_int32_t
                                                     // Implicit type casting from int to unsigned long
    p += len;
    *p++ = SEPARATOR;

...
    len = pj_utoa(rdata->msg_info.via->sent_by.port, p); // rdata->msg_info.via->sent_by.port : int
                                                         // Implicit type casting from int to unsigned long
    p += len;
    *p++ = SEPARATOR;

    *p++ = '\0';
...
    return PJ_SUCCESS;
}

create_tsx_key_2543() 함수의 len_required 변수는 pj_pool_alloc() 함수를 통해 할당될 힙 영역의 크기를 의미하며, CSeq 번호와 Via port 번호는 크기가 각각 9 바이트로 간주되었습니다. 하지만 pj_utoa() 함수에 전달되는 CSeq 번호(rdata->msg_info.cseq→cseq) 인수나 Via 포트(rdata→msg_info.via→sent_by.port) 인수가 음수면 9 바이트가 아닌 21 바이트의 문자열을 쓰게 되는 상황이 발생할 수 있고, 이에 따라 버퍼 오버플로우를 발생시키게 됩니다.

만약 CSeq 번호나 Via 포트 번호 값이 정상적으로 양수 값이라고 가정해 봅시다. 4 바이트 int형을 10진수 형태의 문자열로 변환하면 최대 11 바이트(“2147483647″+ NULL)가 필요합니다. 역시 len_required 변수를 계산할 때 간주한 CSeq 번호의 크기나 Via 포트 값의 크기인 9 바이트는 부족합니다. 하지만 len_required 변수를 산정하는데 마지막으로 추가된 Separator와 여유분의 16 바이트가 합산되어 힙 영역에 여유가 있습니다. 때문에, CSeq 번호나 Via 포트 번호가 9 바이트로 간주되었다 해도 CSeq 번호나 Via 포트 번호가 정상적으로 양수라면 문제가 발생하지 않습니다.

그러면, 문제는 CSeq 번호나 Via 포트 값이 음수일 때입니다. CSeq 번호나 Via 포트 값이 어디에서 음수로 바뀌게 되는 것일까요? SIP 메시지에 담겨 온 CSeq 헤더 필드를 파싱하는 다음의 코드를 살펴보겠습니다. 다음의 코드에서 pj_strtoul() 함수가 호출되는 부분을 보시면, 파싱된 10진수의 문자열 형식의 CSeq 번호를 int형으로 변환합니다.

// pjsip/src/pjsip/sip_parser.c

/* Parse CSeq header. */
static pjsip_hdr* parse_hdr_cseq( pjsip_parse_ctx *ctx )
{
    pj_str_t cseq, method;
    pjsip_cseq_hdr *hdr;
...
    // Parses only the numeric value of the CSeq header field and store it in cseq variable.
    pj_scan_get( ctx->scanner, &pconst.pjsip_DIGIT_SPEC, &cseq); //

    // Converts a cseq value, which is a string in decimal form, to an integer.
    // Type casting from 'unsigned long' to 'pj_int32_t'
    // 'hdr->cseq' can be set to a negative integer.
    hdr->cseq = pj_strtoul(&cseq); // hdr->cseq : pj_int32_t
                                   // (defined: pjsip/include/pjsip/sip_msg.h)
...
}

그럼, pj_strtoul() 함수의 구현부를 살펴보겠습니다. 문자열로 전달된 CSeq 번호는 unsigned long 형으로 파싱되어 반환됩니다.

// pjlib/src/pj/string.c

PJ_DEF(unsigned long) pj_strtoul(const pj_str_t *str)
{
    unsigned long value;
    unsigned i;

    PJ_CHECK_STACK();

    value = 0;
    for (i=0; i<(unsigned)str->slen; ++i) {
        if (!pj_isdigit(str->ptr[i]))
            break;
        value = value * 10 + (str->ptr[i] - '0');
    }
    return value;
}

앞서 살펴보았던 CSeq 헤더 필드를 파싱하는 코드입니다. 방금 전 다뤘던 pj_strtoul() 함수가 반환하는 unsigned long 형의 값이 int형인 hdr->cseq로 할당됩니다.

// pjsip/src/pjsip/sip_parser.c

/* Parse CSeq header. */
static pjsip_hdr* parse_hdr_cseq( pjsip_parse_ctx *ctx )
{
...

    // Converts a cseq value, which is a string in decimal form, to an integer.
    // Type casting from 'unsigned long' to 'pj_int32_t'
    // 'hdr->cseq' can be set to a negative integer.
    hdr->cseq = pj_strtoul(&cseq); // hdr->cseq : pj_int32_t
                                   // (defined: pjsip/include/pjsip/sip_msg.h)
...
}

CSeq 번호는 결국 unsigned long 형에서 int형으로 형 변환되면서 다음과 같이 음수로 설정될 수 있습니다.

sign_extension_utoa

이러한 자료형 변환과 부호 확장으로 인해 값이 의도치 않게 변경되는 문제는 create_tsx_key_2543() 함수 외에도 PJSIP 코드의 여러 곳에서 발생하는 것을 확인했으며 패치를 통해 모두 해결되었습니다.

Proof of concept

부호 확장으로 인해 PJSIP에서는 어떤 취약점이 발생할까요? 부호 확장으로 인해 버퍼 오버플로우가 발생될 수 있음을 확인할 수 있어야 하겠지요. PJSIP에서는 성능을 위해 별도의 힙 할당 방식(힙 할당자를 wrapping하여 구현)을 사용하는데, 이러한 방식 때문에 버퍼 오버플로우가 발생해도 프로그램에 영향을 미치지 않거나 AddressSanitizer로도 오버플로우를 탐지하지 못할 수 있습니다. PJSIP의 힙 할당자는 먼저 사이즈가 큰 블록 하나를 힙에 할당한 뒤, 동적 메모리 요청이 있을 때마다 해당 블록의 남은 공간에 청크를 할당합니다. 이 구조를 바탕으로 취약점을 증명하려면, 즉 프로그램이 오동작하게 하려면, 할당되는 청크에서 오버플로우 되는 값이 블록의 남은 영역(다음 그림에서 end−cur의 크기)보다 커야 합니다.

pool

하지만, 부호 확장으로 인한 버퍼 오버플로우는 오버플로우될 수 있는 바이트의 크기에 제한이 있기 때문에, end에서 cur까지의 간격이 0에 가까워지도록 청크를 할당함으로써, 제한된 바이트를 이용해 다음 블록까지 오버플로우 될 수 있도록 해야 합니다. 즉, pj_pool_alloc() 함수에서 할당하는 공간을 end–cur 만큼 지정해야, 부호 확장으로 인한 오버플로우가 다음 블럭의 헤더를 오버런하는 것을 재현할 수 있습니다. 할당하는 청크의 크기를 조정하기 위해 방법을 찾아본 결과, create_tsx_key_2543() 함수에서 method->name.slen의 값을 변경하는 방법으로 end−cur 크기가 0에 가까워지도록 청크의 크기를 조절할 수 있었습니다. method->name.slen의 값은 다음의 SIP 메시지 예제에서와 같이 CSeq 필드의 메서드바(method) 문자열을 길게 작성함으로써 변경할 수 있습니다.

OPTIONS sip:3 SIP/2.0
f: <sip:2>
t: <sip:1>
i: a
CSeq: 18..(Cseq)..514 AAAAAA..(method)..AAAAAAA
v: SIP/2.0/U 4:186..(port)..14

보다 자세한 개념 증명(Proof of Concept) 코드와 AddressSanitizer log는 이곳에서 확인해 보세요.

마치며

SIP 메시지 헤더의 CSeq 필드, TTL 필드, 포트 번호 등의 정수를 파싱한 코드는 모두 잠정적으로 버퍼 오버플로우의 영향을 받았습니다. 따라서 해당 필드 값을 파싱할 때 음수 값을 갖지 못하도록 필드 값의 범위를 검증하도록 패치되었습니다. 정수 관련 버그는 자주 발생하지만 쉽게 눈에 보이지 않으므로, 문제가 발생될 수 있는 상황을 인지하고 주의할 필요가 있습니다. C 언어에서 정수로 인해 발생할 수 있는 취약점들에 관심이 있다면 SEI CERT C Coding에서 확인해 보세요.

Related Post