SMBGhost

지난 3월, MSRC(Microsoft Security Research Center)에서 srv2.sys 드라이버의 SMBv3 패킷 압축과 관련된 취약점에 대한 정보를 공개하였습니다.

Untitled

해당 취약점은 SMBGhost(CVE-2020-0796)로 불리며, 사용자 인증을 거치지 않고 리모트에서 Windows 시스템 장악이 가능한 점 때문에 큰 주목을 받았습니다.

타임라인을 간략히 살펴보면 취약점의 Write-up은 4월, PoC 공개(chompie)는 6월에 이루어졌으며, 디펜스는 그보다 앞선 5월 해당 취약점의 RCE PoC를 개발하였습니다.

이 글에서는 공개된 PoC보다 더 다양한 환경에서 그리고 더 높은 성공률을 가진 익스플로잇의 개발 과정과 그 과정에서 발견할 수 있었던 Unauthenticated Remote DOS 취약점을 소개합니다.


취약점 상세 정보

Srv2.sys 드라이버의 압축 해제 과정, 취약점 상세 정보, 가상 메모리 쓰기 프리미티브, 물리/가상 메모리 읽기 프리미티브, PTE 조작 등의 정보들은 상세하게 정리된 글들이 공개되어 있으므로 이 글에선 간략하게 설명하도록 하겠습니다. [1] [2]

취약점 정보

  • 공격자가 전송한 패킷 헤더에 존재하는 데이터 길이 값의 검증 부재로 인한 Integer overflow 발생
  • 공격자는 Integer overflow를 통해 패킷 압축 해제를 위해 할당되는 버퍼(SRVNET_BUFFER)의 크기를 조절할 수 있음

쓰기 프리미티브

  • 공격자는 Integer overflow를 이용하여 패킷 압축 해제를 위해 할당되는 버퍼(SRVNET_BUFFER)의 크기를 공격자의 데이터 크기보다 작게 할당할 수 있음
  • 할당된 버퍼(SRVNET_BUFFER)보다 압축 해제된 패킷 데이터 크기가 더 크므로, 버퍼 오버플로우 발생
  • 패킷 데이터는 임의로 조작 가능하며, 버퍼 오버플로우를 통해 덮이는 값 중에는 UserBuffer 포인터가 존재
  • UserBuffer 포인터는 압축 해제 후, memcpy(UserBuffer, RawData, sizeof(RawData)); 형식으로 사용됨
    • RawData 또한 공격자가 전송하는 값이기 때문에, 임의 주소 쓰기가 가능

읽기 프리미티브

  • srv2.sys 드라이버는 패킷 처리 중 오류가 발생할 경우, 요청에 사용된 버퍼를 재사용하여 응답을 전송하는 특성이 있음
  • 공격자가 전송하는 패킷 헤더의 값을 적절하게 설정할 시 패킷 데이터의 압축 해제 후, 압축 해제된 데이터가 저장되기 시작하는 위치를 조작 가능
  • 압축 해제 루틴에서 예외 처리를 유발하는 바이트를 삽입하여 어느 바이트까지 압축 해제할 것인지 조절 가능
  • 이를 통해 할당된 버퍼(SRVNET_BUFFER)의 헤더 중 pMDL 필드만 변조 후, 예외 처리를 통해 pMDL 필드가 변조된 버퍼 할당 해제
    • 변조된 pMDL 필드는 버퍼 재할당 시 초기화되지 않고 그대로 남아있게 됨
  • 이후 잘못된 유저 인증 요청 등, 부적절한 패킷을 전송하여 오류 메시지 반환받도록 유도
    • 잘못된 유저 인증 요청에 사용되는 버퍼는 pMDL을 조작한 버퍼와 동일한 버퍼
    • 오류 메시지 처리 시 조작된 pMDL의 물리 메모리 주소를 읽어 공격자에게 전송

커널 쉘 코드 저장

  • Windows에는 KUSER_SHARED_DATA라는 이름의, 모든 버전에서 동일한 주소의 커널 가상 메모리에 할당되는 페이지가 존재
  • 쓰기 프리미티브를 통해 해당 주소에 커널 쉘 코드를 작성

NX 비트 제거

  • 물리 메모리 탐색을 통해 PML4의 베이스 주소 획득 [3]
  • 획득한 PML4 베이스 주소를 기반으로 읽기 프리미티브를 통해 쉘 코드가 저장된 KUSER_SHARED_DATA 페이지의 권한을 관리하는 PTE의 주소와 값 획득
  • 쓰기 프리미티브를 통해 PTE 값에 존재하는 NX 비트 제거
    • PTE 값의 NX 비트 제거 시, PTE가 가리키는 가상 메모리 페이지에 실행 권한이 추가됨


RIP Handling

이전 과정까지 쓰기 프리미티브를 통해 커널 쉘 코드를 메모리로 옮기고, PTE 조작을 통해 쉘 코드가 존재하는 메모리 페이지에 실행 권한을 추가했습니다.

이제 쉘 코드를 호출하기 위해, 커널 메모리에 존재하는 함수 포인터를 변조하여 원래 함수가 아닌, 쉘 코드를 호출하도록 만들어 줘야 합니다.

여기서는 전통적으로 Windows 커널 익스플로잇에서 사용하는 HAL 영역의 함수 포인터 테이블인 HalpInterruptController(이하 Hal 테이블)를 사용합니다.

Hal 테이블의 주소를 획득하는 방법을 찾아본 결과, 아래와 같은 정보들을 얻었습니다.

  • Hal 테이블은 Windows 10에서 주로 물리 메모리 0x1000번지에 존재함 [4]
  • 특정한 바이트 패턴을 가지고 있는 Low Stub을 찾은 후, 바이트 패턴으로부터 고정 오프셋 위치에 존재하는 Hal 테이블 주소를 획득할 수 있음 [5]

위 정보를 통해 다음과 같은 두 가지 방식을 통해 HalpInterruptController의 주소를 획득하도록 전략을 세웠습니다.

  1. 물리 메모리 0x1000번지에 HalpInterruptController 테이블이 존재하는지 확인.
  2. 방법 (1)이 실패했을 경우, 물리 메모리를 탐색하며 Low Stub 바이트를 찾아 HalpInterruptContoller 주소 획득

Untitled 1

테이블 베이스 주소를 획득한 후, 변조할 함수 포인터로는 HalpApicRequestInterrupt 함수 포인터를 선정하였습니다. 해당 함수 포인터는 다양한 커널 루틴에서 반복적으로 호출되므로, 포인터 변조 후 임의로 호출해주는 과정 없이도 커널 루틴에 의해 호출되는 특징을 가지고 있습니다.

하지만 다양한 Windows 버전/환경의 테스트를 거쳐 HalpApicRequestInterrupt 함수 포인터가 테이블 베이스 주소부터 가변 오프셋 위치에 존재하는 것을 확인할 수 있었습니다.

이 오프셋 계산을 위해 해당 테이블에 존재하는 값들의 패턴을 살펴보았고, 다음과 같은 패턴을 확인할 수 있었습니다.

Untitled 2

우선 Hal 테이블 베이스로부터 랜덤한 위치에 존재하는 HalpDefaultPcIoSpace 포인터가 존재하며, HalpDefaultPcIoSpace 포인터의 주소(address #1)로부터 HalpApicRequestInterrupt 포인터 주소까지의 오프셋은 항상 고정입니다.

그리고 HalpDefaultPcIoSpace 포인터의 주소(address #1)로부터 0x30바이트 만큼 떨어진 위치에 HalpDefaultPcIoSpace 포인터의 주소(address #1)를 가리키는 포인터(address #2)가 존재합니다.

여기서 (address #2) 주소에 존재하는 값은 (address #2 - 0x30) 이므로 다음과 같은 Pseudo-code를 통해 HalpApicRequestInterrupt의 주소를 획득할 수 있습니다.


table_base = addr_of(HalpInterruptController)
# Search memory from table_base to +0x1000
for address in range(table_base, table_base + 0x1000, 8):
    value = read(address)
    # If this condition is true, it's address #2!
    if(value == address - 0x30):
        print('[+] Found HalpApicRequestInterrupt');
        pHalpApicRequestInterrupt = address + 0xD8
        return pHalpApicRequestInterrupt

이후 획득한 HalpApicRequestInterrupt 포인터 주소를 쓰기 프리미티브를 통해 커널 쉘 코드의 주소로 변조하여, Lab 환경의 원격 코드 실행을 이루어 낼 수 있었습니다.

여기까지의 내용은 RCE Write-up과 동일한 방식으로 진행하였으나, 이제부터의 내용에서 공개 Write-up / 공개 PoC와 다른 부분을 설명하겠습니다.


Unreliable exploit?

우리는 Lab 환경의 익스플로잇 검증 이후 다양한 버전/환경에서 테스트를 진행하기 시작했습니다.

그리고 테스트 중, 다음과 같은 환경들에서 정상적인 익스플로잇 동작이 이루어지지 않는 것을 확인할 수 있었습니다.

  • CPU core 개수에 따른 익스플로잇 동작 실패
    • 1Core / 8Core 등 Lab과 다른 환경에서의 익스플로잇 성공률 저하
  • 디버깅 설정에 따른 익스플로잇 동작 실패
    • Lab 환경에서는 KDNET을 통해 디버깅을 진행하며 익스플로잇을 개발하였습니다.
    • 디버깅 설정이 이루어져 있지 않은 환경은 물론, Serial 디버깅 환경에서도 원활한 익스플로잇이 이루어지지 않는 것을 확인할 수 있었습니다.

해당 이슈는 공개된 PoC(Chompie)에서도 동일하게 발생하는 것을 확인하였고, 익스플로잇 과정에서 획득하는 값들과 커널 동작을 디버깅하기 시작했습니다.

그 결과, 일부 환경에서 다음과 같은 문제가 발생하는 것을 확인할 수 있었습니다.

  • 일부 환경에서 HalpInterruptController 주소를 획득하는 2가지 전략이 모두 실패
    • 물리 메모리 0x1000번지에 존재하던 Hal 테이블이 존재하지 않는 경우
    • 물리 메모리 탐색을 통해 찾을 수 있던 Low Stub 바이트를 발견되지 않는 경우

따라서 기존의 전략들이 통하지 않을 때 사용할 새로운 전략이 필요했습니다.

우리는 Hal 테이블 주소를 찾기 위해 커널 디버깅과 함께 물리 메모리를 덤프하여 살펴본 후, Hal 테이블 주소에 일정한 패턴이 있음을 발견하였습니다.

여러 번의 검증을 통해 매우 높은 확률로 Hal 테이블 주소를 획득할 수 있음을 확인할 수 있었습니다.

// HalpInterruptController 테이블 베이스 주소: fffff79d`40000000
kd> dq hal!HalpInterruptController l1
#    fffff802`1c13c120  fffff79d`40000570
// 물리 메모리 패턴 확인
kd> !dq 6000 l2
#    6000 fffff79d`4000d000 fffff79d`40003000 // 패턴 발견 #1
kd> !dq f000 l2
#    f000 fffff79d`4000e000 fffff79d`40004000 // 패턴 발견 #2
kd> !dq 10000 l2
#   10000 fffff79d`40010000 fffff79d`4000d000 // 패턴 발견 #3

Untitled 3

그 결과, Hal 테이블 주소를 가리키는 패턴을 다음 Pseudo-code를 통해 찾도록 전략을 추가하였습니다.

# Search memory from 0x1000 to 0x100000
for address in range(0x1000, 0x100000, 0x1000):
    value1 = read(address)
    value2 = read(address+8)
    # Check pattern
    if (value1 & 0xfffffffff0000000 == value2 & 0xfffffffff0000000 and
        value1 & 0xffffff0000000000 == 0xfffff70000000000):
        print('[+] Found HalpApicRequestInterrupt')
        HalpInterruptController = value1 & 0xfffffffff0000000
        return HalpInterruptController

이 전략을 통해 기존 Lab 환경에서의 RCE 익스플로잇은 물론, 공개 PoC에서도 해결하지 못한 일부 환경에서의 익스플로잇 불가능 이슈를 해결할 수 있었습니다.


SMBv3.1.1 Unauthenticated Remote DoS

우리는 이어서 익스플로잇 코드의 수정을 거치며 다양한 버전에서 테스트를 진행했습니다.

그러던 중, Windows 10 1903의 일부 버전에서 읽기 프리미티브 사용 시 커널 패닉이 발생하고, 이로 인해 익스플로잇 코드가 정상적으로 동작하지 않는 것을 확인하였습니다.

재밌는 점은 같은 익스플로잇 코드를 사용하여도 Windows 10 1909의 모든 버전과 Windows 10 1903의 9월 패치 적용 이후 버전에선 커널 패닉이 발생하지 않는다는 것이었습니다.

따라서 우리는 Windows 10 Build 18362.30부터 18362.720 직전까지 Windows 1903에서 커널 디버깅을 진행하여, 그 원인을 찾을 수 있었습니다.

문제의 원인은 구 버전의 srv2.sys 드라이버의 Srv2ProcCompleteRequest 함수에 존재합니다.

기존에 작성한 익스플로잇 코드는 읽기 프리미티브 과정에서 대상 시스템의 메모리를 읽어오기 위해 SMB2 SESSION_SETUP 패킷을 COMPRESSION_TRANSFORM 패킷으로 압축하여 전송하도록 작성되어 있었습니다.

이때 임의 주소의 메모리를 읽기 위해 전송한 압축된 SESSION_SETUP 패킷을 드라이버의 Srv2ProcCompleteRequest 함수에서 처리하는 중, Null-Ptr-Dereference 취약점이 존재함을 확인할 수 있었습니다.

image

취약점을 확인한 후, CVE 번호가 부여된 취약점인지 확인해보기 위해 MSRC Acknowlegements와 srv2.sys 드라이버의 취약점 목록을 살펴보았지만, CVE 번호가 부여되지 않은 것으로 확인되었습니다.

해당 취약점은 압축된 SESSION_SETUP 패킷을 처리하는 중 발생하므로 읽기 프리미티브에서 사용하는 요청 패킷을 SMB2 Negotiation 패킷으로 변경하여 DOS 취약점 발생 포인트를 우회하여 익스플로잇을 진행하였습니다.


마치며

이 글에서는 더 안정적인 SMBGhost PoC 개발 과정을 소개했습니다.

결국 SMBGhost 익스플로잇의 안정성은 Windows 버전, 커널 디버깅 설정, 심지어는 CPU Core의 개수 등의 다양한 환경 조건에 따라 변동되는 메모리 주소를 얼마나 더 정확하게 예측하느냐에 달려 있었고, 우리는 다양한 환경에서 동일하게 존재하는 패턴을 발견하여 더 안정적이고 많은 환경에서 동작하는 PoC를 개발하였습니다.


레퍼런스

[1] “I’ll ask your body”: SMBGhost pre-auth RCE abusing Direct Memory Access structs

[2] Exploiting SMBGhost (CVE-2020-0796) for a Local Privilege Escalation: Writeup + POC

[3] Getting Physical: Extreme abuse of Intel based Paging Systems - Part 2 - Windows

[4] Windows 10 HAL’s Heap – Extinction of the “HalpInterruptController” Table Exploitation Technique

[5] Getting Physical with USB Type-C: Windows 10 RAM Forensics and UEFI Attacks