V8에서의 제어 흐름 무결성
제어 흐름 무결성(CFI)은 제어 흐름 하이재킹을 방지하려는 보안 기능입니다. 이 아이디어는 공격자가 프로세스의 메모리를 손상시키더라도 추가적인 무결성 검사를 통해 임의의 코드를 실행하지 못하도록 하는 것입니다. 이 블로그 포스트에서는 V8에서 CFI를 활성화하기 위해 수행한 작업에 대해 논의하고자 합니다.
Chrome의 인기로 인해 0-day 공격의 귀중한 대상이 되었으며, 우리가 본 대부분의 실제 취약점 공격은 초기 코드 실행을 얻기 위해 V8을 대상으로 합니다. V8 취약점 공격은 일반적으로 비슷한 패턴을 따릅니다: 초기 버그가 메모리 손상을 초래하지만, 종종 초기 손상은 제한적이며 공격자는 전체 주소 공간에서 임의의 읽기/쓰기 방법을 찾아야 합니다. 이를 통해 공격자는 제어 흐름을 하이재킹하고 다음 단계의 익스플로잇 체인을 실행하기 위한 쉘코드를 실행합니다. 이는 Chrome 샌드박스에서 탈출하려고 시도합니다.
공격자가 메모리 손상을 쉘코드 실행으로 전환하지 못하도록 하기 위해 V8에 제어 흐름 무결성을 도입하고 있습니다. 이는 JIT 컴파일러가 있는 상태에서 특히 도전적입니다. 런타임에 데이터를 기계 코드로 변환하면, 손상된 데이터가 악성 코드로 변환되지 않도록 보장해야 합니다. 다행히도 현대 하드웨어 기능은 손상된 메모리를 처리하면서도 견고한 JIT 컴파일러를 설계할 수 있는 기반을 제공합니다.
다음으로, 문제를 세 가지 별도 부분으로 나누어 살펴보겠습니다:
- Forward-Edge CFI는 함수 포인터나 가상 함수 테이블 호출과 같은 간접 제어 흐름 전환의 무결성을 확인합니다.
- Backward-Edge CFI는 스택에서 읽은 반환 주소가 유효한지 확인해야 합니다.
- JIT 메모리 무결성은 런타임에 실행 가능한 메모리에 기록된 모든 데이터를 검증합니다.
Forward-Edge CFI
간접 호출과 점프를 보호하기 위해 두 가지 하드웨어 기능을 사용하려고 합니다: 착륙 패드와 포인터 인증.
착륙 패드
착륙 패드는 유효한 분기 대상으로 표시할 수 있는 특별한 명령어입니다. 활성화되면 간접 분기는 착륙 패드 명령으로만 점프할 수 있으며, 그렇지 않으면 예외가 발생합니다. 예를 들어 ARM64에서는 착륙 패드가 Armv8.5-A에서 도입된 분기 목표 식별 (BTI) 기능과 함께 사용할 수 있습니다. BTI 지원은 이미 V8에서 활성화되어 있습니다. x64의 경우, 착륙 패드는 제어 흐름 집행 기술(CET)의 간접 분기 추적(IBT) 부분과 함께 도입되었습니다.
그러나 간접 분기를 위한 모든 잠재적 대상으로 착륙 패드를 추가하는 것만으로는 거친 단계의 제어 흐름 무결성만 제공하며, 여전히 공격자에게 많은 자유를 제공합니다. 우리는 함수 시그니처 검사를 추가함으로써 (호출 지점에서의 인수 및 반환 유형이 호출된 함수와 일치해야 함) 제한을 더욱 강화할 수 있으며, 또한 런타임에 불필요한 착륙 패드 명령을 동적으로 제거할 수도 있습니다. 이러한 기능은 최근의 FineIBT 제안의 일부이며, 운영 체제 채택을 기대하고 있습니다.
포인터 인증
Armv8.3-A는 포인터 인증(PAC)을 도입하여 포인터의 사용되지 않는 상위 비트에 서명을 포함시킬 수 있습니다. 서명이 포인터 사용 전에 확인되므로, 공격자는 간접 분기로 임의의 위조된 포인터를 제공할 수 없습니다.
Backward-Edge CFI
반환 주소를 보호하기 위해 두 가지 별도의 하드웨어 기능, 즉 섀도 스택과 PAC을 사용하려고 합니다.
섀도 스택
Intel CET의 섀도 스택 및 Armv9.4-A의 가드 컨트롤 스택(GCS)을 사용하면, 반환 주소만을 위한 별도의 스택을 가질 수 있으며, 이 스택은 악성 쓰기로부터 하드웨어 보호를 받을 수 있습니다. 이러한 기능은 반환 주소 덮쓰기를 방지하는 강력한 보호를 제공하지만, 최적화/비최적화 및 예외 처리를 수행하는 동안 반환 스택을 합법적으로 수정해야 하는 경우를 처리해야 합니다.
포인터 인증 (PAC-RET)
간접 분기와 유사하게, 포인터 인증은 반환 주소가 스택에 푸시되기 전에 서명하는 데 사용할 수 있습니다. 이는 ARM64 CPU에서 이미 V8에서 활성화되었습니다.
Forward-edge CFI와 Backward-edge CFI를 위한 하드웨어 지원을 사용하는 부작용으로 인해 성능 영향을 최소화할 수 있습니다.
JIT 메모리 무결성
JIT 컴파일러의 CFI에서의 독특한 도전 과제 중 하나는 런타임에 실행 가능한 메모리에 기계 코드를 작성해야 한다는 것입니다. JIT 컴파일러는 메모리에 쓰기가 가능해야 하지만 공격자의 메모리 쓰기 프리미티브가 이를 수행하지 못하도록 메모리를 보호해야 합니다. 단순한 접근 방식은 페이지 접근 권한을 일시적으로 변경하여 쓰기 접근을 추가/제거하는 것입니다. 하지만 이는 기본적으로 경합 상태가 발생하는데, 이는 공격자가 두 번째 스레드에서 동시에 임의의 쓰기를 트리거할 수 있다고 가정해야 하기 때문입니다.
스레드별 메모리 권한
현대적인 CPU에서는 현재 스레드에만 적용되고 사용자 공간에서 빠르게 변경할 수 있는 메모리 권한의 다른 뷰를 가질 수 있습니다. x64 CPU에서는 메모리 보호 키(pkeys)를 사용하여 이를 구현할 수 있으며, ARM은 Armv8.9-A에서 권한 오버레이 확장(permission overlay extensions)을 발표했습니다. 이를 통해 예를 들어 별도의 pkey로 태그를 지정하여 실행 가능한 메모리에 대한 쓰기 접근을 세밀하게 토글할 수 있습니다.
이제 JIT 페이지는 더 이상 공격자가 쓰기 불가능하지만, JIT 컴파일러는 여전히 생성된 코드를 메모리에 써야 합니다. V8에서 생성된 코드는 힙에 위치한 AssemblerBuffers에 존재하며, 이는 공격자가 대신 손상시킬 수 있습니다. AssemblerBuffers도 같은 방식으로 보호할 수 있지만, 이는 문제를 단순히 다른 곳으로 옮기는 것에 불과합니다. 예를 들어, 이 경우 AssemblerBuffer 포인터가 있는 메모리도 보호해야 합니다. 사실 보호된 메모리에 쓰기 접근을 활성화하는 코드는 모두 CFI 공격 표면을 구성하며 매우 신중하게 코딩되어야 합니다. 예를 들어, 보호되지 않은 메모리에서 가져온 포인터에 쓰는 것은 게임 오버입니다. 공격자가 이를 사용하여 실행 가능한 메모리를 손상시킬 수 있기 때문입니다. 따라서 우리의 설계 목표는 이러한 중요한 섹션의 수를 가능한 한 줄이고, 섹션 내부의 코드를 짧고 독립적으로 유지하는 것입니다.
제어 흐름 검증
모든 컴파일러 데이터를 보호하고 싶지 않다면 CFI의 관점에서 이를 신뢰할 수 없는 것으로 간주할 수 있습니다. 실행 가능한 메모리에 무엇이든 쓰기 전에 임의의 제어 흐름으로 이어지지 않도록 검증해야 합니다. 예를 들어 쓰여진 코드가 syscalls 명령을 수행하지 않거나 임의 코드로 점프하지 않는 것을 포함합니다. 물론 현재 스레드의 pkey 권한을 변경하지 않는 것도 확인해야 합니다. 코드가 임의의 메모리를 손상시키는 것을 방지하려는 것이 아니라 코드가 손상되었다면 공격자가 이미 이 기능을 가지고 있다고 가정할 수 있기 때문입니다. 안전하게 이러한 검증을 수행하려면, 보호된 메모리에 필요한 메타데이터를 유지하고 스택의 지역 변수를 보호해야 합니다. 이러한 검증이 성능에 미치는 영향을 평가하기 위해 일부 예비 테스트를 실행했습니다. 다행히 검증은 성능에 민감한 코드 경로에서 발생하지 않으며, jetstream 또는 speedometer 벤치마크에서 성능 저하를 관찰하지 못했습니다.
평가
공격 보안 연구는 모든 완화 설계의 필수적인 부분이며, 우리는 보호를 우회하는 새로운 방법을 계속해서 찾고 있습니다. 다음은 가능할 것으로 생각되는 공격과 이를 해결하기 위한 아이디어의 몇 가지 예입니다.
손상된 Syscall 인자
앞서 언급했듯이, 공격자가 다른 실행 중인 스레드와 동시에 메모리 쓰기 프리미티브를 트리거할 수 있다고 가정합니다. 만약 다른 스레드가 syscall을 수행한다면, 메모리에서 읽는 경우 일부 인자는 공격자가 제어할 수 있습니다. Chrome은 제한적인 syscall 필터를 사용하지만, 여전히 CFI 보호를 우회할 수 있는 몇 가지 syscalls가 존재합니다.
예를 들어, sigaction은 신호 핸들러를 등록하는 syscall입니다. 연구 중 우리는 Chrome에서 CFI 준수 방식으로 sigaction 호출이 접근 가능하다는 것을 발견했습니다. 인자가 메모리로 전달되기 때문에 공격자는 이 코드 경로를 트리거하여 신호 핸들러 함수를 임의의 코드로 지정할 수 있습니다. 다행히도 이는 쉽게 해결할 수 있습니다. 초기화 후 sigaction 호출 경로를 차단하거나 syscall 필터로 차단하면 됩니다.
또 다른 흥미로운 예는 메모리 관리 syscalls입니다. 예를 들어 스레드가 손상된 포인터로 munmap을 호출하면 공격자는 읽기 전용 페이지를 해제할 수 있으며, 이후 mmap 호출이 이 주소를 재사용하여 페이지에 쓰기 권한을 효과적으로 추가할 수 있습니다. 일부 운영체제는 메모리 밀봉(memory sealing)으로 이 공격을 방지하는 보호 기능을 이미 제공합니다. Apple 플랫폼은 VM_FLAGS_PERMANENT 플래그를 제공하며 OpenBSD는 mimmutable syscall을 제공합니다.
신호 프레임 손상
커널이 신호 핸들러를 실행할 때 현재 CPU 상태를 사용자 공간 스택에 저장합니다. 두 번째 스레드가 저장된 상태를 손상시키면 커널이 이 상태를 복원하게 됩니다. 사용자 공간에서 이를 방어하는 것은 신호 프레임 데이터가 신뢰할 수 없는 경우 어려워 보입니다. 그 시점에서 항상 종료하거나 알려진 저장 상태로 신호 프레임을 덮어써야 복귀할 수 있습니다. 더 유망한 접근법은 스레드별 메모리 권한을 사용하여 신호 스택을 보호하는 것입니다. 예를 들어, pkey-tagged sigaltstack은 악의적인 덮어쓰기를 방지할 수 있지만, CPU 상태를 저장할 때 커널이 일시적으로 쓰기 권한을 허용해야 할 것입니다.
v8CTF
위에서 언급한 것은 우리가 해결하기 위해 작업하고 있는 잠재적 공격 사례의 몇 가지 예에 불과하며, 보안 커뮤니티로부터 더 많이 배우고 싶습니다. 이것이 흥미롭다면 최근 출시된 v8CTF에 도전해 보십시오! V8을 공격하고 보상을 받으세요. n-day 취약점을 대상으로 한 익스플로잇도 명시적으로 적용 대상입니다!