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가 어떻게 동작하는지 확인할 수 있다.
(최신 문서를 찾을 수 없어서 이미 갖고 있던 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_quantum, dsi->commands + i + 1, dsi->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
|
그림으로 먼저 표현해 보았다.

먼저 첫 번째 바이트는 옵션의 타입을 지정하며, 이 값은 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_error, logtype_dsi, "option %"PRIu8" too large: %zu", cmd, option_len); exit(EXITERR_CLNT); } switch (cmd) { case DSIOPT_ATTNQUANT: if (option_len != sizeof(dsi->attn_quantum)) { LOG(log_error, logtype_dsi, "option %"PRIu8" bad length: %zu", cmd, option_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
|
패치된 코드에서는 cmd와 option_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 server, client; 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_quantum, datasize, server_quantum; uint16_t serverID, clientID; uint8_t *commands; /* DSI recieve buffer */ uint8_t data[DSI_DATASIZ]; /* DSI reply buffer */ size_t datalen, cmdlen; off_t read_count, write_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 server, client; 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_quantum, datasize, server_quantum; u_int16_t serverID, clientID; char *status; u_int8_t commands[DSI_CMDSIZ], data[DSI_DATASIZ]; size_t statuslen; size_t datalen, cmdlen; off_t read_count, write_count; uint32_t flags; /* DSI flags like DSI_SLEEPING, DSI_DISCONNECTED */ const char *program; int socket, serversock; /* 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[] = { NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, /* 0 - 7 */ NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, /* 8 - 15 */ NULL, NULL, afp_login, afp_logincont, afp_logout, NULL, NULL, NULL, /* 16 - 23 */ NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, /* 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_debug, logtype_afpd, "<== Start AFP command: %s", AfpNum2name(function)); AFP_AFPFUNC_START(function, (char *)AfpNum2name(function)); err = (*afp_switch[function])(obj, (char *)dsi->commands, dsi->cmdlen, (char *)&dsi->data, &dsi->datalen); AFP_AFPFUNC_DONE(function, (char *)AfpNum2name(function)); LOG(log_debug, logtype_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_error, logtype_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_error, logtype_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 의 값은 아래 그림과 같이 표현될 수 있다.

이 주소를 정확히 알아내기 위해서는 다음과 같은 제약이 필요하다:
dsi->commands가 가리키는 페이지 앞에 할당된 공간이 존재하지 않아야 하거나,dsi->commands가 가리키는 페이지 앞에 공간이 할당되어 있지만, 해당 영역에 PROT_READ 권한이 없어야 한다.
왜 이런 제약이 필요한지는 Fig. 3 을 보면서 이해해보자.
두 번째 바이트가 0x10 의 배수임을 알고 있기 때문에, 두 번째 바이트를 0x00 부터 0xF0까지 브루트포싱하면서 값을 알아낼 수 있다.

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

위 그림을 예시로 살펴보면, 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 프로세스 메모리 매핑을 출력한 것이다.
'Analysis > 1-day' 카테고리의 다른 글
| CVE-2020-6383 [V8] (0) | 2020.09.11 |
|---|