최근의 CFI 연구, 더욱 제한된 CFG 생성...보안성과 성능 동시 향상 꾀해
[보안뉴스= 정동재 KAIST CSRC 악성코드 분석팀장] 제어 흐름(Control Flow)이란 프로그램의 실행 흐름에 따른 순서를 나타낸 것으로, 공격자가 원하는 악성행위를 수행하기 위해 제어 흐름을 탈취(Control Flow Hijacking)하는 것은 오랜 기간 공격자의 목표가 되었습니다. 제어 흐름 탈취 공격은 매우 보편적으로 사용되고 있지만, 이에 대한 완벽한 방어 기술은 아직 존재하지 않습니다. 현재까지도 제어 흐름 탈취 공격과 방어에 관한 연구가 활발히 이루어지고 있지만, 아직 미흡한 상황입니다. 그러면 제어흐름이 야기하는 문제점과 이러한 문제점을 해결해 나가는 방안에 대해 살펴보도록 하겠습니다.

[이미지=utoimage]
제어 흐름 탈취 공격을 막기 위한 대표적인 연구가 제어 흐름 무결성(CFI: Control Flow Integrity)입니다. CFI 이론은 제어 흐름 탈취 공격에 대한 대표적인 방어기법으로 다소 오래되고 친숙하지 않은 주제이지만, 오랜 역사를 지닌 만큼 시스템을 보호하기 위한 중요한 보안 기법이자, 모든 시스템에 보편적으로 적용할 수 있는 범용적인 방식입니다.
CFI는 제어 흐름 탈취 시 나타나는 비정상적 실행 흐름을 근원적으로 차단하기 위한 방식으로, 프로그램의 제작자가 의도한 흐름대로 실행되는지 검증하는 방식입니다. CFI는 실행 전에 정적 분석을 통해 생성된 정상적인 프로그램의 실행 흐름에 대한 제어 흐름 그래프(CFG: Control Flow Graph)를 기반으로 특정 분기문에서 분기 가능한 목표 지점(Allowed Target)들을 미리 추출합니다. 그리고 분기문이 발생하는 시점의 분기 목표 위치가 미리 분석된 유효 지점들 중의 하나인지 여부를 판단함으로써, 해당 분기의 무결성을 동적으로 검증하게 됩니다.
이론적으로는 제어 흐름 탈취 공격을 완벽히 막을 수 있지만, 현실에서는 이를 구현하기가 매우 어렵습니다. 그 이유는 정상적인 프로그램의 제어 흐름 그래프를 완벽하게 그릴 수 없기 때문입니다. 또한, CFI의 보안 강도와 시스템 성능이 반비례한다는 것 또한 큰 장벽입니다.
한편, 최근에는 CFI의 실용적인 구현체가 등장하고 있습니다. 실용적인 방식은 완벽한 보안을 제공할 순 없지만, 많은 공격을 효과적으로 막을 수 있다는 장점이 존재합니다. 대표적으로 컴파일러인 GCC와 Clang에서도 제어 흐름 무결성을 기본적으로 제공하고 있으며, 다양한 CFI 옵션이 존재합니다. 이 옵션들은 무엇을 의미하는 것일까요?
먼저, GCC에서는 Fcf-Protection 명령어를 통해 CFI를 지원합니다. 옵션에는 Full, Branch, Return이 존재하며, 이 각각의 옵션은 제어 흐름 명령어(Control-Flow Transfer Instruction) 종류에 따라 구분합니다. 제어 흐름 명령어를 Indirect Call/Jump 같은 간접 분기문(Indirect Branch)과 Return 명령어로 구분하여 이들 각각에 대한 보안을 제공합니다.
Clang에서는 Fsanitize 명령어를 통해 제어 흐름 명령어를 더욱 세분화해 제공합니다. 제어 흐름 명령어를 Virtual Call, Non-Virtual Call, Indirect Function Call, Member Function Pointer call로 구분하고 각각에 대한 보안을 제공합니다. 이 밖에도 포인터가 다형성 클래스(Polymorphic Class)를 캐스트하는 경우, 올바른 동적 유형의 개체를 상속받아 만들어졌는지에 대한 검사를 제공합니다.
하지만, 이러한 실용적인 방식은 여전히 한계점이 존재합니다. CFI의 보안 강도는 얼마나 세밀한 CFG를 만드는지에 따라 결정될 정도로 자세한 CFG를 생성하는 것은 중요한 문제입니다. 자세한 CFG를 생성하기 위해서 모든 제어 흐름 명령어에 대한 정확한 분석이 필요합니다. 하지만 보안 강도를 높이기 위해 필요한 모든 제어 흐름 명령어에 대한 분석에는 문제점이 존재합니다.
첫 번째 문제점은 보호해야 할 모든 대상에 대해 완벽하게 분석할 수 없다는 것입니다. 컴파일러에서 기본적으로 제공되는 옵션들을 살펴보면 크게 Indirect Call/Jump와 Return으로 구분할 수 있습니다. 이들은 각각 Forward-Edge와 Backward-Edge로 불리며, 이 두 종류를 모두 보호해야지만 완벽한 보안을 제공할 수 있습니다. Backward-Edge의 경우 공격자는 Return에 의해 복귀할 때 복귀할 주소에 대한 정보는 스택에 저장된다는 것을 이용, 이 값을 변조해 제어 흐름 탈취 공격을 일으킵니다. 이 공격은 스택에 있는 복귀 주소에 대한 정보를 다른 안전한 장소에 복제해두고, 복귀시 스택의 값과 비교함으로써 변조 유무를 파악하는 방식으로 보호할 수 있습니다.

▲정동재 KAIST 사이버보안연구센터 악성코드 분석팀 팀장[사진=KAIST]
하지만, Forward-Edge의 경우에는 한계점이 존재합니다. Indirect Call/Jump를 생성시키는 대표적인 예로 포인터를 들 수 있습니다. 예를 들어, 함수 포인터가 존재한다면 이 함수 포인터가 호출할 수 있는 후보 함수들의 대상은 정적 분석을 통해 정확하게 파악하기 어렵습니다. 완벽하게 분석할 수 없으므로, 미탐(False Negative)을 줄이기 위해 분석 결과가 실제 CFG보다 더욱 대략적으로(Over-Approximate) 나타나며, 실제보다 더 큰 CFG가 생성된다는 것을 의미합니다. 그 결과, 분석하기 모호한 흐름들은 정상적인 흐름이 아니지만 CFG에 포함되고, 공격자는 이를 통해 우회 공격이 가능하게 되어 취약하게 됩니다.
또 다른 문제점으로는 CFI의 보안성은 성능과 반비례하는 경향이 있다는 것입니다. 보안성을 높이고자 더욱 자세한 CFG를 그리게 되면, 실행 중에 무결성을 검사하는 루틴이 더욱 복잡해져 성능이 하락하기 때문입니다. 실제로, 최신 리눅스 커널, 윈도우의 운영체제들 및 최근에 발표된 Intel의 차세대 모바일 프로세서인 Tiger Lake에 탑재된 CET(Control-Flow Enforcement Technology) 기술들조차도 성능에 중점을 둔 방식으로, 제어 흐름에 대한 완벽한 보안을 제공하지 못하고 있습니다.
최근의 CFI 연구 동향 또한 이러한 상황을 극복하고자 더욱 제한된 CFG를 생성함으로써 보안성을 극도로 향상시키는 동시에 하드웨어의 도움을 받아 향상된 성능을 보장할 수 있는 방식으로 중점을 두고 있습니다. 그렇다면 이론적으로 완벽한 CFI가 실제로 구현된다면 해킹이 불가능할까요? 이에 관한 이야기는 다음 연재에서 나눠보도록 하겠습니다.
[글_ 정동재 KAIST 사이버보안연구센터 악성코드 분석팀 팀장]
<저작권자: 보안뉴스(www.boannews.com) 무단전재-재배포금지>