개요
https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-21768
처음 해보는 원데이 취약점 분석입니다.
보통 윈도우 관련된 심각한 취약점들은 옛날 버전의 운영체제에서 주로 발생하는 경우를 많이 봤는데,
이 취약점은 특이하게도 Windows 11 22H2 및 Windows Server 2022 의 패치되지 않은 버전에서 발생하는 취약점 입니다.
이 취약점은 WinSock 용 Windows AFD(Ancillary Function Driver)인 afd.sys에서 발생하고
로컬 사용자에서 NT AUTHORITY\SYSTEM(가장 높은권한)으로 권한 상승(LPE) 을 허용합니다.
AFD는 네트워크의 프로그램 간 통신 채널의 끝점인 네트워크 소켓 관리를 담당합니다. 소켓을 사용하면 프로그램이 네트워크 연결을 통해 데이터를 보내고 받을 수 있습니다.
취약점은 AFD 드라이버가 사용자 모드 입력/출력(I/O) 작업을 처리하는 방식에 존재합니다. 특히 이 취약성은 공격자가 AFD 드라이버에 악의적인 입출력 제어(IOCTL) 요청을 보낼 수 있도록 허용하며, 이로 인해 상승된 권한으로 임의 코드가 실행될 수 있습니다.
취약점 분석
취약점이 발생하는 원인을 알아내기 위해 취약한 버전의 afd.sys파일과 패치된 afd.sys파일을 비교해보면
패치 전에는 특정 구조체에 데이터를 쓰는것이 가능했지만
패치가 되고나서는 iPreviousMode 값이 0일때(호출이 커널에서 시작됨)만 값을 쓰고
만약 iPreviousMode 가 1이라면 ProbeForWrite 함수를 통해서 구조체의 포인터가
사용자 모드 내에 정상적으로 위치해있는지 검사합니다.
패치 전에는 이 부분에 대해서 인지하지 못했던것인가 싶지만,
패치 전에도 AfdNotifyRemoveIoCompletion 함수의 첫번째 인자로 iPreviousMode 값을 전달해줬고
해당 함수의 다른 로직에서 이 부분에 대해 검사하는 로직이 있으므로
취약점이 발생하는 부분에 검사로직을 추가하는것을 깜빡했다고 판단할수 있습니다.
만약 공격자가 해당 구조체에 커널 주소를 쓸수 있다면 임의의 커널 Write-Where 프리미티브를 생성할 수 있습니다.
현재로서는 어떤 값이 기록되는지는 명확하지 않지만, 어떤 값이라도 잠재적으로 로컬 권한 상승에 사용될 수 있습니다.
취약점 트리거
어디에서 취약점이 터지는것인지 확인했으므로 이제 이 취약점을 어떻게 트리거 하는지 분석해야 합니다.
취약점이 존재하는 함수를 호출하는 함수는 AfdNotifySock 함수가 보입니다.
AfdNotifySock 함수에 대한 참조는 당장 보이지 않고 AfdIrpCallDispatch라고하는 함수포인터 테이블에 위치해 있습니다.
이 테이블에서는 AFD 드라이버에 대한 디스패치 루틴이 포함되어 있습니다.
디스패치 루틴은 DeviceIoControl함수를 호출하여 win32 앱에 대한 요청을 처리하는데에 주로 사용됩니다.
각 기능의 제어코드는 AfdIoctlTable 에서 찾을수 있지만 이 포인터는 AfdIrpCallDispatch 에 존재하지 않습니다.
https://recon.cx/2015/slides/recon2015-20-steven-vittitoe-Reverse-Engineering-Windows-AFD-sys.pdf
Recon 2015 발표에서 나온 자료에 따르면 AFD 전용 디스패치 테이블은 2개가 있다고 합니다.
하나는 아까 말한 AfdIrpCallDispatch 테이블이고, 두번째는 AfdImmediateCallDispatch 테이블 입니다.
AfdNotifySock 함수를 호출하기 위해서는 AfdIoctlTable 에 있는 제어코드중에서 어떤 인덱스의 데이터가
AfdNotifySock 를 의미하는지 알아야 하는데,
이것은 AfdNotifySock 함수의 포인터 주소값과 AfdImmediateCallDispatch 테이블 주소값의 오프셋 차이를 계산하고
8을 나눠주면 AfdIoctlTable의 인덱스를 알수 있습니다.
계산해보면 73이 나오는데
해당 테이블은 int형 배열이고 요소가 76개 있으므로 뒤에서부터 세보면
0x12127 이라는 값이 바로 AfdNotifySock 함수의 제어코드라는것을 확인해볼수 있습니다.
그리고 AfdNotifySock 함수의 제어코드가 AfdIoctlTable에 맨 마지막에 있다는 점에서, 이 함수의 제어코드가
가장 최근에 정의 된것이라고 추측해볼수 있습니다.
AfdNotifySock에 해당하는 Winsock 함수가 무엇인지 알지 못하므로 커널 코드를 직접 리버싱 해야합니다.
https://www.x86matthew.com/view_post?id=ntsockets
Winsock를 사용하지 않고 AFD 드라이버를 직접 호출하여 소켓작업을 수행하는 일부 코드가 있습니다.
IOCTL 요청을 만들기 위해 TCP 소켓에 대한 핸들을 만드는 좋은 템플릿입니다.
위 사이트에 게시된 코드들을 참고해서 익스플로잇을 작성할수 있다고 합니다.
이 취약점은 알수없는 구조체 내에서 드라이버에 대한 확인되지 않은 포인터를 전달할수 있기 때문에 발생합니다.
이 구조체는 lpInBuffer 매개변수를 통해서 사용자 모드 프로그램에서 직접 전달되고
AfdNotifySock 함수의 네 번째 매개변수로 전달면서
AfdNotifyRemoveIoCompletion 함수의 세 번째 매개변수로 전달됩니다.
하지만 지금 당장은 AfdNotifyRemoveIoCompletion 함수에 원하는 구조체 데이터를 전달할수 있는지 모르기 때문에
계속해서 리버싱 해보면서 실행흐름을 분석해야합니다.
우선 AfdNotifySock 함수에서 부터 분석해보면
시작부분에서 위와같은 검사가 보이는데
이 검사는 구조체의 크기가 48바이트와 같지 않으면 goto문을 통해서 함수가 실패로 끝나는것으로 파악됩니다.
그 다음으로는 해당 구조체의 여러 필드값들을 검사하여 하나라도 조건에 부합하지 않으면 바로 점프해버리고
https://learn.microsoft.com/ko-kr/windows-hardware/drivers/ddi/wdm/nf-wdm-obreferenceobjectbyhandle
다음 동작에서는 ObReferenceObjectByHandle 함수를 호출시킵니다.
이 함수를 호출시킬때 첫번째 인자로 구조체의 첫번째 필드값을 사용합니다.
이 함수의 반환값이 음수인경우(작업이 실패했을경우) 에는 올바른 로직으로 접근하지 못하기 때문에
무조건 이 함수의 작업을 성공적으로 반환해야합니다.
그러려면 이 함수에 유효한 핸들을 전달해야만 합니다.
하지만 winapi 함수들을 사용해서 해당 유형의 개체를 만드는 방법은 당장 보이지 않으므로
NtCreateIoCompletion 함수를 사용해서 만들어야 합니다.
그 다음으 구조체의 필드중 하나인 dwCounter값 만큼 반복하는 반복문이 있습니다.
이 반복문에서는 구조체의 필드에서 유효한 사용자 모드 포인터가 포함되어있는지 검사하고
루프가 반복될때마다 포인터가 증가하므로
dwCounter 값을 1로 설정하고 유효한 주소로 포인터를 채우면 이후 여러가지 로직을 거쳐서
아래와 같이 AfdNotifyRemoveIoCompletion 함수를 호출할수 있습니다.
이제 AfdNotifyRemoveIoCompletion 함수로 들어가보면
먼저 구조체의 특정 필드를 검사했을때 0인지 아닌지 검사를 한뒤
0이 아니라면 해당 값을 32와 곱한뒤 구조체의 다른 필드와 함께 ProbeForWrite함수에 매개변수로 전달됩니다.
그리고 유효한 사용자모드 포인터인 pData2값과 dwLen(1) 를 사용해서 추가적으로 구조체를 채울수있고 검사를 통과합니다.
마지막으로 취약한 코드에 도달하기 전에 통과해야하는 로직은 아래와 같은데
여기서 IoRemoveIoCompletion 함수의 반환값이 0이여야만 해당 코드에 도달할수 있습니다.
일단 자료상에서는 반환값이 0이 되려면 IoCompletionObject 매개변수에 대해서 완료 레코드를 사용할수 있게되거나
함수에 매개변수가 전달되는 제한시간이 만료될 경우에 0이 된다고 합니다.
여기서는 삽입하는 구조체를 사용해서 단순히 제한시간을 만료시키는것 만으로는
공격을 성공시키기 충분하지 않으므로 사용 가능한 완료 레코드가 하나 이상 있어야 합니다.
제가 참고하고있는 PoC에서는 NtSetIoCompletion 함수를 사용하면 IoCompletionObject에서
I/O 보류 카운터를 수동으로 증가시킨다고 합니다.
전에 만든 IoCompletionObject에서 이 함수를 호출하면 IoRemoveCompletion함수의 동작이 성공하게 됩니다.
위와같은 과정을 거치면 취약점에 도달할수 있으므로 쓰기위한 임의의 주소로 구조체의 필드를 채울수 있습니다.
주소에서 쓰는 값은 포인터가 IoRemoveIoCompletion 함수에 대한 호출로 전달되는 정수를 가져옵니다.
IoRemoveIoCompletion함수는 이 정수 값을 KeRemoveQueueEx함수 호출의 반환값으로 설정합니다.
PoC 에서는 해당하는 쓰기값은 0x1입니다.
제가 참고하고있는 자료에서는 이 이상의 자세한 리버싱이 필요하진 않다고 판단했고
KeRemoveQueueEx함수의 반환값이 대기열에서 제거된 항목 수라고 추측한것이 정확하다는것을 알았고
IoCompletionObject에서 NtSetIoCompletion을 추가로 호출하여 쓰기 값을 임의로 증가시킬 수 있음을 확인했다고 합니다.
임의의 커널주소에 고정값 1을 쓸수 있으므로 이것을 통해서 커널 읽기/쓰기가 가능해졌습니다.
이 취약점은 맨 처음에 언급되었듯이 Windows 11 22H2 및 Windows Server 2022에서 발생하기 때문에
Windows I/O 링 개체 손상을 활용하여 프리미티브가 작성되었다고 합니다.
그렇기 때문에 해당 익스플로잇은 Yarden Shafir라는 사람이 작성한 코드들을 활용해서 작성된것으로 알려져있습니다.
사용자가 I/O 링을 초기화하면 사용자 공간과 커널 공간에 각각 하나씩 두 개의 개별 구조가 생성됩니다.
이 구조체는 아래와 같은데
이 커널 개체는 nt!_IORING_OBJECT에 매핑 되어있으며 RegBuffersCount 및 RegBuffers라는 두 개의 필드가 있고
초기화 하면 0이 됩니다.
갯수는 I/O링에 대해 I/O 작업이 대기할 수 있는 정도를 나타냅니다.
그리고 다른 매개변수들은 현재 대기중인 작업 목록에 대한 포인터 입니다.
사용자 공간 측면에서 kernelbase!CreateIoRing함수를 호출했을때 성공하면 I/O 링 핸들이 반환됩니다.
이 핸들은 아직 문서화 되지 않은 구조체 HIORING 에 대한 포인터 입니다.
아래는 Yarden Shafir라는 분이 직접 프리미티브를 작성하면서 알아냈던 구조체의 정의입니다.
typedef struct _HIORING {
HANDLE handle;
NT_IORING_INFO Info;
ULONG IoRingKernelAcceptedVersion;
PVOID RegBufferArray;
ULONG BufferArraySize;
PVOID Unknown;
ULONG FileHandlesCount;
ULONG SubQueueHead;
ULONG SubQueueTail;
};
위의 블로그에서 설명하는 취약점을 보면 알수 있다시피, RegBuffersCount 및 RegBuffers 필드를 업데이트 할수 있는 경우 표준 I/O 링 api를 사용해서 커널 메모리를 읽고 쓸수 있습니다.
그리고 처음부터 설명했던 취약점 트리거를 활용하면 원하는 모든 커널 주소에 0x1을 쓸수 있습니다.
그러므로 I/O 링 프리미티브를 설정하기 위해서 취약점을 두 번 트리거해서 두 개의 필드를 덮어야 합니다.
첫 번째 트리거에서 RegBuffersCount를 0x1로 덮습니다.
그리고 두 번째 트리거에서는 RegBuffers를 사용자 공간에 할당할수있는 주소로 설정합니다.
여기서는 0x100000000 값으로 덮었습니다.
이제 남은 작업은 사용자 공간 주소에서 위조된 IOP_MC_BUFFER_ENTRY 구조체에 대한 포인터를 작성해서
I/O 작업을 대기시키는 작업이 있습니다. 참고로 항목 수는 RegBuffersCount와 같아야 합니다.
해당 작업을 아래와 같이 요약할수 있는데
이러한 nt!_IOP_MC_BUFFER_ENTRY 중에서 하나가 아래 캡쳐에 나와있습니다. 작업의 대상은
커널 주소(0xfffff8052831da20)이고 이 경우 작업의 크기는 0x8 바이트 입니다. 이것이 읽기/쓰기 작업인지
구조에서 알수는 없습니다. 작업의 방향은 I/O 요청을 대기열에 넣는 데 사용된 api에 따라 다릅니다.
kernelbase!BuildIoRingReadFile 함수를 쓰면 임의의 커널 쓰기가 발생하고
kernelbase!BuildIoRingWriteFile 함수를 쓰면 임의의 커널 읽기가 발생합니다.
이제 임의 쓰기를 위해서는 파일 핸들에서 데이터를 읽고 해당 데이터를 커널 주소에 쓰는 I/O 작업이 할당됩니다.
그리고 임의 읽기를 수행하기 위해서는 I/O 작업에서 커널 주소에있는 데이터를 읽고
해당 데이터를 파일 핸들에 쓰는 작업을 수행합니다.
https://github.com/chompie1337/Windows_LPE_AFD_CVE-2023-21768
이 원데이 취약점 PoC를 공개한 사람의 PoC를 살펴보면
앞부분까지는 위에서부터 설명했던 방식으로 임의의 커널 읽기/쓰기 프리미티브를 성공시킨뒤
ioring_lpe 함수에서 SYSTEM(PID 4) 과 같은 상승된 프로세스의 커널 주소를 유출시키고
권한 상승을 시키고 싶은 프로세스의 커널 주소도 유출시킵니다.
커널 영역에 위치해있는 SYSTEM 프로세스의 토큰값을 읽어들인뒤
원하는 프로세스의 토큰값에 SYSTEM 프로세스의 토큰값을 씁니다.
이렇게 되면 최종적으로 커널 드라이버에서는 이 사실을 인지하지 못한채 특정 프로세스의 권한이 상승되는것입니다.
PoC 시연
마지막으로 CVE-2023-21768 를 직접 시연해봤습니다.
대상 운영체제는 Windows 11 22H2 입니다.