FortiGate RCE 연구에 알아두면 좋은 기본적인 사실들입니다.
사실 몇 년 전에 고등학교 다니면서 하던 거라 그렇게 새롭거나 어려운 내용은 아니긴 한데, 알고 있는 걸 기록할 겸 해서 써봅니다.
실제 취약점을 대상으로 개발한 익스플로잇 코드와 함께 구체적으로 설명할 수 있다면 좋겠지만, 국내 법률상 허용되지 않는 부분이라 아쉽네요.
제가 현재까지 분석한 기기들은 x86-64, ARMv7(Thumb-2), ARM32(non-Thumb)을 사용하는 경우로 나뉘었습니다.
주목할 만한 특징으로, ARM계열은 힙 버퍼 오버플로우 익스플로잇에서 대체로 JOP를 선행해야 하고, 유용한 가젯 수는 x86-64 >>> ARMv7-T > ARM(non-Thumb) 정도로 체감됩니다.
ARM은 RISC 특유의 고정 길이 명령어 구조로 인해 메모리 익스플로잇이 조금 더 까다로워집니다. T버전이 일반 버전보다 가젯 수가 많은 이유도 같습니다.(부분적으로 CISC스러운 구조 때문)
x86-64는 어떤 CPU를 사용하는지 모르겠으나, ARMv7의 경우 상용 Cortex-A 계열 네트워크/임베디드용 프로세서 또는 자체 코어, Thumb가 빠진 ARM ISA를 사용하는 경우 대개 Fortinet 자체 설계 코어(SOC3등으로 알려진 것)인 것으로 보입니다.
변형된 구버전 리눅스 커널입니다.
기본 구조는 전형적인 임베디드 리눅스지만, 기능은 상당히 빠지거나 변형된 형태입니다.
분석했던 모든 펌웨어에서 NX는 있으나 ASLR은 없었습니다.
이 경우 모든 가젯 주소는 펌웨어와 기기만 같다면 재활용 가능합니다.
최근 Fortinet이 일부 장치와 펌웨어에서 ASLR을 도입했다는 내용을 들었는데, 최신 펌웨어에 접근할 방법이 제한적이라 확인은 못 하겠네요.
Fortinet은 펌웨어를 유료로 배포하기 시작했는데, 아마 몇 번의 심각한 RCE 취약점이 발견된 직후에 유로로 전환된 것으로 기억합니다.
(Hint - 이란, 러시아에서는 Fortinet 서비스가 제공되지 않습니다. 그럼 그들의 FortiGate 펌웨어는 어떻게 최신으로 유지될까요...?)
FortiGate에는 init이 2개가 있습니다.
흔히 아는 리눅스에서 초기화를 수행하는 그 init과, FortiGate의 애플리케이션 레벨 전반을 담당하는 거대한 /bin/init 바이너리입니다.
sslvpnd또한 해당 바이너리에 단순히 symlink로 연결되어 있기에, 사실상 둘은 동일합니다.
취약점을 찾고 싶다면 /bin/init을 먼저 추출하고 분석해보세요.
고의적인 난독화인지는 모르겠지만, 리버싱했을 때 많은 함수들이 동적으로 호출되며 제어 흐름을 추적하기 까다롭게 되어 있습니다.
알려진 문자열과 엔드포인트명 등을 기반으로 string search부터 시작하는 것을 추천합니다.
이것은 heap-based buffer overflow에 대한 익스플로잇 일반론이기도 합니다.(다만 실제로 정확히 아래와 같은 path를 선택하면 write-what-where primitive가 필요합니다.)
아키텍처는 제가 익숙한 ARM32로 가정합니다.
가장 잘 알려진 방식은 OpenSSL의 SSL 구조체 내부의 함수 포인터를 원하는 것으로 덮어쓰는 방식입니다.
먼저, FortiGate의 기본 힙 할당자인 jemalloc의 특성을 이용한 heap grooming이 선행되어야 합니다.
jemalloc는 버퍼 간 경계나 헤더가 할당된 영역 자체에 쓰여지지 않으며 arena와 chunk라는 독특한 구조를 가집니다.
arena는 메모리 경합 상태를 줄이기 위해 코어 수만큼 부여되는 독립적인 메모리 공간이며, chunk는 그 하위에 존재하는 실제로 heap 영역으로 할당되는 메모리 공간입니다.
jemalloc는 메모리 파편화와 페이징으로 인한 비효율을 방지하기 위해 heap 메모리 할당 요청을 받으면 정확히 그만큼을 할당하는 것이 아닌 미리 만들어진 크기 테이블에서 요청된 크기에 해당하는 더 크거나 같은 특정 크기를 할당합니다. (예를 들어, 요청된 크기가 0xF10이라면 0x1000을 할당하는 식)
그리고 이 chunk들은 각각 동일한 페이지에 인접하게 할당됩니다.
할당된 후에 해당 메모리가 free()된다면 이후 동일한 chunk 크기가 할당될 때 그 자리에 우선해서 할당됩니다.
이 특성은 heap layout을 훨씬 예측하기 쉽게 만들어줍니다.
예를 들어, 공격자가 0x1000크기의 chunk를 마음대로 할당하고 해제할 방법을 찾았으며, SSL 구조체의 chunk 크기가 0x1000일 경우(ARMv7 특정 버전에서의 실제 사례),
공격자는 0x1000크기의 chunk 할당과 SSL 구조체 생성을 번갈아 트리거하며 아래와 같은 메모리 구조를 만들 수 있습니다.
|Arbitrary|SSL|Arbitrary|SSL|Arbitrary|SSL|Arbitrary|SSL|
여기에서 공격자는 자신이 할당한 chunk들 중 하나를 해제합니다.
그러면 아래와 같은 모양이 됩니다.
|Arbitrary|SSL|Free!|SSL|Arbitrary|SSL|Arbitrary|SSL|
그리고 이제 SSL 구조체를 덮어쓰고 싶다면, 공격자는 아주 쉽게 가능합니다.
할당 크기와 오버플로우 크기, 내용을 조절할 수 있는 취약점을 찾은 후 동일한 chunk 구역에 할당되도록 조절하여 아래와 같이 덮어쓸 수 있습니다.
|Arbitrary|SSL|Malicious!|SSL(Overwritten!)|Arbitrary|SSL|Arbitrary|SSL|
이후 SSL re-handshake를 유발하고 덮어쓴 함수 포인터로부터 JOP chaining을 통해 SP 레지스터를 제어 가능한 힙으로 설정하고(stack pivoting) ROP chaining을 통해 목적을 달성할 수 있습니다.
이 방법은 6.x와 7.x에서만 동작하며, 제 경우 5.x 버전 일부에서는 SSL 구조체의 크기가 매우 작았기 때문에 다른 primitive가 필요했습니다.
OpenSSL 라이브러리 버전이나 빌드 옵션에 따라 구조체의 크기가 달라지기에 각 펌웨어 릴리즈에 대해 정확한 할당 크기를 확인할 필요가 있습니다. (libssl, libcrypto를 추출해서 정적 분석만으로 알아낼 수 있습니다.)
https://devco.re/blog/2019/08/09/attacking-ssl-vpn-part-2-breaking-the-Fortigate-ssl-vpn/
https://bishopfox.com/blog/exploit-cve-2022-42475 (참고로, 해당 취약점은 size_t와 uint32 혼용으로 인한 wrap-around로 발생하는 거라 ARMv7에서는 발생하지 않습니다.)
더 있는데 오래 전이라 어디서 봤는지 기억이 안 나네요.
레퍼런스를 다시 찾느라 구글링하는 시간이 본문을 쓰는데 걸린 시간보다 많은 것 같습니다.