본문으로 바로가기

CVE-2018-1160 [Netatalk]

category Analysis/1-day 2020. 10. 21. 21:08

CVE-2018-1160

Netatalk before 3.1.12 is vulnerable to an out of bounds write in dsi_opensess.c. This is due to lack of bounds checking on attacker controlled data. A remote unauthenticated attacker can leverage this vulnerability to achieve arbitrary code execution.

 

# Environment

Netatalk 3.0 - 3.1.11

 

# Analysis

Netatalk는 AFP (Apple Filing Protocol) 의 구현체이다. Apple 개발자 사이트나 레퍼런스 문서에서 AFP가 어떻게 동작하는지 확인할 수 있다.

Apple Filing Protocol Reference (TP40003548 4.0.3).pdf
4.61MB

(최신 문서를 찾을 수 없어서 이미 갖고 있던 2012년 버전만 업로드합니다.)

 

이 취약점은 DSIOpenSession 에서 option parse를 진행할 때 사용자 입력을 검증하지 않아서 발생한다.

문제가 발생한 곳은 libatalk/dsi/dsi_opensess.c 의 dsi_opensession 함수에서 DSIOPT_ATTNQUANT 를 parse 하는 부분이다.

 

30
31
32
33
34
35
36
37
38
39
40
41
42
  /* parse options */
  while (i < dsi->cmdlen) {
    switch (dsi->commands[i++]) {
    case DSIOPT_ATTNQUANT:
      memcpy(&dsi->attn_quantumdsi->commands + i + 1dsi->commands[i]);
      dsi->attn_quantum = ntohl(dsi->attn_quantum);

    case DSIOPT_SERVQUANT/* just ignore these */
    default:
      i += dsi->commands[i] + 1/* forward past length tag + length */
      break;
    }
  }
libatalk/dsi/dsi_opensess.c

 

그림으로 먼저 표현해 보았다.

 

Figure 1. DSIOpenSession option structure

 

먼저 첫 번째 바이트는 옵션의 타입을 지정하며, 이 값은 1일 때는 서버의 quantum을 설정하는 DSIOPT_ATTNQUANT, 0일 때는 서버 측의 quantum 값을 요청하는 DSIOPT_SERVQUANT 를 각각 의미한다.

(서버 응답으로 항상 서버의 quantum 값을 전송하므로 DSIOPT_SERVQUANT 옵션은 사실상 의미가 없다.)

 

option length를 사용자가 직접 설정할 수 있다는 점을 기억해 둬야 한다. 이 길이값에 따라서 사용자가 원하는 크기의 데이터를 전송할 수 있다.

 

다음으로, Netatalk 3.1.12 에서 이 루틴이 어떻게 패치되었는지 살펴보도록 한다.

 

34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
  /* parse options */
  while (i + 1 < dsi->cmdlen) {
    cmd = dsi->commands[i++];
    option_len = dsi->commands[i++];

    if (i + option_len > dsi->cmdlen) {
      LOG(log_errorlogtype_dsi"option %"PRIu8" too large: %zu",
          cmdoption_len);
      exit(EXITERR_CLNT);
    }

    switch (cmd) {
    case DSIOPT_ATTNQUANT:
      if (option_len != sizeof(dsi->attn_quantum)) {
        LOG(log_errorlogtype_dsi"option %"PRIu8" bad length: %zu",
            cmdoption_len);
        exit(EXITERR_CLNT);
      }
      memcpy(&dsi->attn_quantum, &dsi->commands[i], option_len);
      dsi->attn_quantum = ntohl(dsi->attn_quantum);

    case DSIOPT_SERVQUANT/* just ignore these */
    default:
      break;
    }

    i += option_len;
  }
libatalk/dsi/dsi_opensess.c

 

패치된 코드에서는 cmdoption_len 이라는 변수를 새로 도입했다. 각각이 Fig. 1 의 Option Type과 Option Length를 나타낸다. option_len 을 사용한 두 가지 검사 로직이 추가되었다.

 

1. 현재 포인터 위치에 option_len 을 더한 값이 DSI의 전체 데이터 크기를 넘지 않아야 한다.

2. 옵션이 DSIOPT_ATTNQUANT 일 경우, option_len 의 값은 항상 4로 고정되어야 한다.

 

58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
/* child and parent processes might interpret a couple of these
 * differently. */

typedef struct DSI {
    struct DSI *next;             /* multiple listening addresses */
    AFPObj   *AFPobj;
    int      statuslen;
    char     status[1400];
    char     *signature;
    struct dsi_block        header;
    struct sockaddr_storage serverclient;
    struct itimerval        timer;
    int      tickle;            /* tickle count */
    int      in_write;          /* in the middle of writing multiple packets,
                                   signal handlers cant write to the socket */

    int      msg_request;       /* pending message to the client */
    int      down_request;      /* pending SIGUSR1 down in 5 mn */

    uint32_t attn_quantumdatasizeserver_quantum;
    uint16_t serverIDclientID;
    uint8_t  *commands/* DSI recieve buffer */
    uint8_t  data[DSI_DATASIZ];    /* DSI reply buffer */
    size_t   datalencmdlen;
    off_t    read_countwrite_count;
    uint32_t flags;             /* DSI flags like DSI_SLEEPING, DSI_DISCONNECTED */
    int      socket;            /* AFP session socket */
    int      serversock;        /* listening socket */

    /* DSI readahead buffer used for buffered reads in dsi_peek */
    size_t   dsireadbuf;        /* size of the DSI readahead buffer used in dsi_peek() */
    char     *buffer;           /* buffer start */
    char     *start;            /* current buffer head */
    char     *eof;              /* end of currently used buffer */
    char     *end;

#ifdef USE_ZEROCONF
    char *bonjourname;      /* server name as UTF8 maxlen MAXINSTANCENAMELEN */
    int zeroconf_registered;

#endif

    /* protocol specific open/close, send/receive
     * send/receive fill in the header and use dsi->commands.
     * write/read just write/read data */

    pid_t  (*proto_open)(struct DSI *);
    void   (*proto_close)(struct DSI *);
DSI;
include/atalk/dsi.h

 

위는 DSI 구조체이다. 앞에서 확인했던 바에 따르면, attn_quantum 필드의 위치에 최대 0xFF 크기의 데이터를 쓸 수 있다. 그런데 attn_quantum 의 타입은 uint32_t 로, 실제로 4바이트가 넘는 크기의 값을 쓰면 뒷 부분의 필드의 값을 임의로 바꿀 수 있다. (datasize, server_quantum, serverID, clientID, commands)

 

commands 포인터를 조작하면 임의의 주소에 값을 쓸 수 있게 된다.

 

한편, 취약점이 발생하는 버전은 1.4.99 - 3.1.11 이지만 3.x 이외 버전에서는 full RCE exploit이 불가능하다.

이는 2.x와 3.x의 DSI 구조체가 다르기 때문이다. afpd 2.2.3 에서 DSI 구조체는 다음과 같이 정의되어 있다.

 

58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
/* child and parent processes might interpret a couple of these
 * differently. */

typedef struct DSI {
  AFPObj *AFPobj;
  dsi_proto protocol;
  struct dsi_block header;
  struct sockaddr_storage serverclient;
  struct itimerval timer;
  int      tickle;        /* tickle count */
  int      in_write;      /* in the middle of writing multiple packets,
                             signal handlers cant write to the socket */

  int      msg_request;   /* pending message to the client */
  int      down_request;  /* pending SIGUSR1 down in 5 mn */

  u_int32_t attn_quantumdatasizeserver_quantum;
  u_int16_t serverIDclientID;
  char      *status;
  u_int8_t  commands[DSI_CMDSIZ], data[DSI_DATASIZ];
  size_t statuslen;
  size_t datalencmdlen;
  off_t  read_countwrite_count;
  uint32_t flags;             /* DSI flags like DSI_SLEEPING, DSI_DISCONNECTED */
  const char *program;
  int socketserversock;

  /* protocol specific open/close, send/receive
   * send/receive fill in the header and use dsi->commands.
   * write/read just write/read data */

  pid_t  (*proto_open)(struct DSI *);
  void   (*proto_close)(struct DSI *);

  /* url registered with slpd */
#ifdef USE_SRVLOC
  char srvloc_url[512];
#endif

#ifdef USE_ZEROCONF
  char *bonjourname;      /* server name as UTF8 maxlen MAXINSTANCENAMELEN */
  int zeroconf_registered;

#endif

  /* DSI readahead buffer used for buffered reads in dsi_peek */
  size_t dsireadbuf/* size of the DSI readahead buffer used in dsi_peek() */
  char *buffer;
  char *start;
  char *eof;
  char *end;
DSI;
include/atalk/dsi.h

 

commands 가 DSI 구조체 내에 정적 배열로 선언되어 있고, 대신 status 를 조작할 수 있다.

하지만 다른 코드에서 status 로 write를 하는 경우는 발생하지 않기 때문에 dsi_getstatus 에서 data leak을 할 때 말고는 쓸 수 있는 상황이 존재하지 않는다.

 

# PoC (PC Control, Non-PIE) [펌웨어 탐색 중]

특정 환경에서는, afpd를 PIE로 컴파일하지 않아 바이너리가 고정된 주소를 가지고 있기 때문에 AAW를 쉽게 성공시킬 수 있다.

PoC는 Seagate Business NAS 4 Bay의 펌웨어에 있는 afpd를 대상으로 했으며, 버전은 2.2.3이다.

(2.x exploit 불가능으로 3.x vulnerable 한 ARM 펌웨어를 찾고 있습니다.)

 

commands를 옮기기 좋은 위치는 preauth_switch 이다. AFP Command는 afp_switch의 함수 포인터 배열을 참조하고, 값이 NULL이 아니면 함수를 실행시킨다. 로그인 전 사용할 수 있는 함수는 preauth_switch에 저장되며, 로그인 후 afp_switch는 postauth_switch로 바뀐다. 

 

57
58
59
60
61
62
63
64
65
66
67
68
69
70
...
129
/*
 * Routines marked "NULL" are not AFP functions.
 * Routines marked "afp_null" are AFP functions
 * which are not yet implemented. A fine line...
 */

static AFPCmd preauth_switch[] = {
    NULLNULLNULLNULL,
    NULLNULLNULLNULL,                    /*   0 -   7 */
    NULLNULLNULLNULL,
    NULLNULLNULLNULL,                    /*   8 -  15 */
    NULLNULLafp_loginafp_logincont,
    afp_logoutNULLNULLNULL,                /*  16 -  23 */
    NULLNULLNULLNULL,
    NULLNULLNULLNULL,                    /*  24 -  31 */
...
AFPCmd *afp_switch = preauth_switch;
etc/afpd/switch.c

 

618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
                /* send off an afp command. in a couple cases, we take advantage
                 * of the fact that were a stream-based protocol. */

                if (afp_switch[function]) {
                    dsi->datalen = DSI_DATASIZ;
                    dsi->flags |= DSI_RUNNING;

                    LOG(log_debuglogtype_afpd"<== Start AFP command: %s"AfpNum2name(function));

                    AFP_AFPFUNC_START(function, (char *)AfpNum2name(function));
                    err = (*afp_switch[function])(obj,
                                                  (char *)dsi->commandsdsi->cmdlen,
                                                  (char *)&dsi->data, &dsi->datalen);

                    AFP_AFPFUNC_DONE(function, (char *)AfpNum2name(function));
                    LOG(log_debuglogtype_afpd"==> Finished AFP command: %s -> %s",
                        AfpNum2name(function), AfpErr2name(err));

                    dir_free_invalid_q();

                    dsi->flags &= ~DSI_RUNNING;

                    /* Add result to the AFP replay cache */
                    replaycache[rc_idx].DSIreqID = dsi->clientID;
                    replaycache[rc_idx].AFPcommand = function;
                    replaycache[rc_idx].result = err;
                } else {
etc/afpd/afp_dsi.c

 

이 preauth_switch 배열을 덮어씌워서 계정을 인증시키지 않고도 AFP Command를 실행시킬 수 있게 된다.

 

===================RIP↓==================

preauth_switch는 afp_switch에서 주소를 찾을 수 있다.

 

.data:000A2184                 EXPORT afp_switch
.data:000A2184 afp_switch      DCD unk_A1984           ; DATA XREF: LOAD:00015B60↑o
.data:000A2184                                         ; afp_over_dsi:loc_1C468↑o ...

 

0xA1984의 주소로 dsi->command를 옮기고 preauth_switch[1] 에 afp_getsrvrinfo의 주소인 0x3DEF4를 써서 서버 정보를 가져올 것이다.

 

+ ARM 바이너리를 qemu를 사용해 에뮬레이션하면, afp.conf에서 포트를 설정하더라도 다른 포트로 연결이 된다.

 

 

# PoC (Address Leak, PIE)

afpd가 PIE로 컴파일되어 있으면 바이너리 또는 라이브러리의 주소를 알아내야 할 필요가 있다.

 

afpd는 유저 입력이 들어오면 해당 입력을 처리하기 위해 clone으로 child process를 만든다.

clone을 통해 프로세스가 복제되면서, child와 parent는 항상 같은 메모리 매핑 상태를 유지하고 있다. 이 때 child에서 SIGSEGV가 발생하면 연결이 끊기는 점을 활용하여, dsi->commands 값을 1바이트씩 브루트포싱할 수 있다.

 

dsi->commands 의 할당은 dsi_tcp.c의 dsi_init_buffer 함수에서 이루어진다.

 

86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
/*
 * Allocate DSI read buffer and read-ahead buffer
 */

static void dsi_init_buffer(DSI *dsi)
{
    if ((dsi->commands = malloc(dsi->server_quantum)) == NULL) {
        LOG(log_errorlogtype_dsi"dsi_init_buffer: OOM");
        AFP_PANIC("OOM in dsi_init_buffer");
    }

    /* dsi_peek() read ahead buffer, default is 12 * 300k = 3,6 MB (Apr 2011) */
    if ((dsi->buffer = malloc(dsi->dsireadbuf * dsi->server_quantum)) == NULL) {
        LOG(log_errorlogtype_dsi"dsi_init_buffer: OOM");
        AFP_PANIC("OOM in dsi_init_buffer");
    }
    dsi->start = dsi->buffer;
    dsi->eof = dsi->buffer;
    dsi->end = dsi->buffer + (dsi->dsireadbuf * dsi->server_quantum);
}
libatalk/dsi/dsi_tcp.c

 

dsi->server_quantum 값은 Netatalk configuration에서 설정된다. 만약 이 값이 libc의 mp_.mmap_threshold 값 이상이 되면, dsi->commands 는 arena가 아니라 mmap으로 별도 할당된 공간에 위치하게 된다. (mp_.mmap_threshold 의 기본값은 DEFAULT_MMAP_THRESHOLD_MIN, 즉 0x20000 이다.)

 

Netatalk documentation의 Chapter 5. Manual Pages - afp.conf 항목을 살펴보면, server quantum의 기본값은 0x100000 이라고 쓰여져 있다. 즉, 기본적으로 dsi->commands 는 mmap으로 매핑된다는 사실을 알 수 있다.

 

mmap으로 할당되면 dsi->commands 의 값은 아래 그림과 같이 표현될 수 있다.

 

Figure 2. Layout of dsi->commands value 

 

이 주소를 정확히 알아내기 위해서는 다음과 같은 제약이 필요하다:

  • dsi->commands 가 가리키는 페이지 앞에 할당된 공간이 존재하지 않아야 하거나,
  • dsi->commands 가 가리키는 페이지 앞에 공간이 할당되어 있지만, 해당 영역에 PROT_READ 권한이 없어야 한다.

왜 이런 제약이 필요한지는 Fig. 3 을 보면서 이해해보자.

 

두 번째 바이트가 0x10 의 배수임을 알고 있기 때문에, 두 번째 바이트를 0x00 부터 0xF0까지 브루트포싱하면서 값을 알아낼 수 있다.

 

Figure 3. Brute-force to leak the second byte of dsi->commands

 

처음으로 SIGSEGV가 발생하지 않는 (연결이 끊기지 않는) 입력이 dsi->commands 의 두 번째 바이트 값이다.

 

세 번째 바이트는 0x00 부터 0xFF 까지 값을 넣어보면서 알아낼 수 있다. dsi->server_quantum 값이 0x100000 이라면, malloc chunk의 헤더로 인해 실제 할당받는 크기는 0x101000 이 되어, 0x00 부터 0xFF 까지 요청을 보내보면 최소 17개 이상의 연속된 요청에서 연결이 끊기지 않는다.

 

Figure 4. Brute-force to leak the third byte of dsi->commands

 

위 그림을 예시로 살펴보면, 0x3B 부터 0x4B 까지 17개의 요청을 보내면 연결이 끊기지 않음을 알 수 있다.

0x3B~0x4B 범위 밖의 값을 넣어도, 해당 주소가 라이브러리나 다른 매핑된 영역의 주소를 가리켜서 연결이 끊기지 않는 경우가 생길 수 있다. dsi->commands 의 영역과 다른 영역이 연속해서 위치하지 않으면 서버의 응답을 바탕으로 정확한 주소를 구할 수 있지만, 그렇지 않을 경우 유효한 값을 짐작해야 한다.

 

이 과정에서 요청을 보냈을 때 서버의 반응을 기록해 둔다. 이 기록은 이후 네 번째와 다섯 번째 바이트를 구할 때 사용된다.

네 번째 (또는 다섯 번째) 바이트를 0x00 부터 0xFF 까지 브루트포싱할 때, 각 바이트에 대해서 세 번째 바이트를 0x00 부터 0xFF 까지 다시 돌려본다. 만약 이전에 기록했던 서버의 응답과 1개라도 다른 것이 있다면, 해당 바이트는 올바르지 않은 값이므로 건너뛴다. 이 과정으로 false positive를 줄여 정확한 값을 도출해낼 수 있다.

 

이 방법에 대한 실제 공격 코드는 [pwnable.tw] CVE-2018-1160 글에 작성되었다.

 

Ubuntu 20.04 에서 기본 설정으로 주소를 구한 결과이다. 디버깅으로 확인한 결과 실제 dsi->commands 값은 0x7fcc943be010 이고, 위 방법으로 구한 값은 0x7fcc94000010 이다.

 

아래 텍스트 파일은 테스트 환경의 afpd 프로세스 메모리 매핑을 출력한 것이다.

mm.txt
0.02MB

'Analysis > 1-day' 카테고리의 다른 글

CVE-2020-6383 [V8]  (0) 2020.09.11