
CVE-2022-37969 튜토리얼: CVE의 내부 원인이 아닌 커널 익스플로잇 방법론에 초점을 맞춤
이 문서는 Windows 익스플로잇과 관련된 일반적인 사항을 명확히 하기 위해 작성되었습니다. CVE-2022-37969에 적용된 기본 개념을 설명합니다. 최종 결과물은 동작하는 PoC입니다. CVE의 모든 측면을 설명하지는 않지만, 재사용 가능한 코드 조각을 제공하고 많은 일반 익스플로잇에서 찾을 수 있는 메커니즘을 설명합니다.
대상 독자는 기초 리버스 엔지니어, 동작하는 개념 증명 소스 코드를 테스트하고 기본 Windows Internals를 이해하려는 익스플로잇 개발자입니다. 더 깊이 배우기 위한 참조점을 제공합니다.
요구 사항: 기본 커널 디버깅, 기본 리버스 엔지니어링, 기본 Windows 내부 구조, c/c++ 프로그래밍 기술
프로그램은 머신에서 실행되는 코드 조각입니다. 일반적으로 프로그램은 데이터(입력)를 받아 입력을 사용해 계산을 수행하고 데이터(출력)를 생성합니다. 대부분의 프로그램은 사람이 작성하므로 버그가 있습니다. 버그는 올바르게 작성되지 않은 일부 소스 코드로 인해 발생합니다(프로그래머가 입력으로 무엇인가를 하려 했지만, 결과 코드는 의도한 결과와 달랐습니다). 대부분의 버그는 제품 출시 전에 수정되지만 일부는 남아 있습니다. 버그에는 여러 유형이 있고 어떤 것은 다른 것보다 발견하기 어렵기 때문입니다.
Windows는 컴퓨터 프로그램이고, 사람이 작성했으며, 따라서 버그가 있습니다. 왜 중요할까요? Windows 시스템은 은행 계좌, 의료 데이터베이스 등 민감한 데이터를 처리하는 프로그램을 실행할 수 있기 때문입니다. 일부 버그는 제한된 데이터에 불법적으로 접근하는 데 사용될 수 있습니다(이것이 익스플로잇의 좋은 사례입니다).
버그에는 여러 종류가 있으며, 유용한 것도 있고 그렇지 않은 것도 있습니다. 일반적으로 버그는 잘못 작성된 코드 줄과 결합하여 잘못된 출력이나 프로그램 동작을 생성하는 프로그램 입력으로 인해 발생합니다. 그 입력을 찾는 것이 보안 전문가(또는 해커)의 일입니다. 다음 단계는 결과로 나타난 잘못된 출력/동작을 평가하고 "유용하게 사용할 수 있는가?"라는 질문에 답하는 것입니다. 이 지점에서 버그는 다양한 범주로 분류됩니다. 예를 들어 어떤 버그는 일부 데이터 구조를 손상시켜 대상 컴퓨터를 재시작하게 만들 수 있습니다. 그 유용성은 제한적입니다. 어떤 버그는 입력이 제한된 파일에 대한 접근 권한을 제어하는 메모리 영역에 기록되도록 할 수 있습니다. 이런 종류의 버그가 더 유용합니다.
그래서 해커는 가능한 모든 버그 집합에서 자신의 목적에 가장 유용한 부분집합을 찾습니다. 일반적으로 문제는 "시스템을 깨뜨리지 않으면서 접근 수준을 높여 이득을 볼 수 있도록 대상 프로그램에 특수하게 제작된 입력을 제공할 수 있는가?"입니다.
이 비기술적 서론 이후 튜토리얼의 범위를 다음과 같이 정식화할 수 있습니다: 잘못된 입력을 받아들이고, 개발자의 잘못된 코드로 인해 일반 사용자에서 관리자로 권한을 불법적으로 상승시킬 수 있는 Windows 프로그램을 찾을 수 있는가?
대상 프로그램: Windows CLFS (Common Log File System Driver)
익스플로잇 이름: CVE-2022-37969
유형: 로컬 권한 상승
취약한 ISO 다운로드: 여기서 다운로드
Windows 주소 공간은 대략 사용자 공간(일반 프로그램 실행)과 커널 공간(운영 체제 자체 및 하드웨어 구성 요소 소프트웨어-->드라이버 실행)으로 나뉩니다. 일반 사용자는 커널 공간에 접근할 수 없지만, 일반 사용자 프로그램이 커널 코드의 일부(시스템 호출, 드라이버 프로시저)에 접근할 수 있는 메커니즘이 있습니다. 왜 접근이 필요할까요? OS 설계자가 제공하는 안전하고 통제된 방식으로 OS와 상호작용하기 위해서입니다.
일부 드라이버는 사용자가 제공한 데이터 입력을 사용하여 커널 공간 데이터 구조를 조작합니다. 입력이 버그를 유발하면 커널이 손상될 수 있습니다. 한 가지 사례가 Common Log File System Driver입니다. 특수한 입력을 사용하여 드라이버가 사용자의 권한 액세스 수준을 보유한 커널 데이터 구조를 변경하고 일반 사용자를 관리자로 덮어쓰도록 강제할 수 있습니다.
관리자 권한으로 상승시키려면 무엇을 수정해야 합니까?
최종 목표를 염두에 두고 시작합니다. Windows는 시스템에서 실행 중인 각 프로세스에 대한 정보를 _EPROCESS라는 커널 데이터 구조에 저장합니다. _Eprocess 예시
중요한 필드 중 하나는 struct _EX_FAST_REF Token입니다. 이것은 해당 프로세스의 권한 수준을 참조하는 데이터를 가리키는 또 다른 데이터 구조입니다. 아래 그림에서 System 프로세스는 시스템 토큰을, Explorer 프로세스는 일반 사용자 토큰을 가지고 있습니다.

따라서 Explorer.exe의 권한을 상승시키려면 System의 _EPROCESS-->Token 값을 Explorer의 _EPROCESS-->Token으로 복사해야 합니다. 우리는 System Token을 우리 프로그램의 Token으로 복사하고, 권한이 상승된 프로세스에서 명령 프롬프트를 실행함으로써 비슷한 작업을 수행할 것입니다(자식 프로세스는 부모 프로세스의 토큰을 상속합니다).
이러한 작업을 완료하려면 다음 메커니즘이 필요합니다:
소개: 수년에 걸친 Windows의 특성: 새로운 취약점이 발견됨에 따라 Windows는 이를 완화하기 위한 패치가 필요했습니다. 또한 새로운 기술의 등장과 함께 Windows는 경쟁력을 유지하기 위해 업데이트가 필요했습니다. 중요한 요구 사항 중 하나는 이전 버전과의 하위 호환성이었습니다. 그리고 때로는 난독성을 통해 보안을 달성하기도 했습니다. 데이터 구조와 함수 정의는 매뉴얼에서 제거되었지만 기능은 그대로 남아 있었습니다. 리버스 엔지니어링을 통해 연구원들은 이러한 기능을 다양한 목적으로 사용할 수 있었습니다.
_EPROCESS의 커널 주소를 찾기 위해 문서화되지 않은 함수 NtQuerySystemInformation를 사용할 것입니다(매개변수는 링크 참조). SystemInformationClass 매개변수를 사용하여 검색하려는 정보의 종류를 지정할 수 있습니다. SystemExtendedHandleInformation 값(#define SystemExtendedHandleInformation 0x40)을 지정하여 일반 프로세스 정보를 검색할 것입니다.
NtQuerySystemInformation을 사용할 때의 주의점은 반환되는 데이터의 길이를 미리 알 수 없다는 것이지만, NtQuerySystemInformation에는 이를 돕는 메커니즘이 있습니다. 필요한 데이터에 대해 잘못된 크기의 배열로 호출하면 ERROR와 함께 요청했어야 하는 올바른 데이터 크기를 반환합니다. 이를 사용하여 다음과 같이 프로세스 정보를 올바르게 읽을 수 있습니다.
SystemInformationLength 매개변수로 NtQuerySystemInformation을 호출합니다.ReturnLength 매개변수 값을 읽습니다.SystemInformationLength 값으로 NtQuerySystemInformation을 다시 호출합니다.반환되는 데이터 구조는 PSYSTEM_HANDLE_INFORMATION_EX 유형입니다. 이것은 문서화되지 않은 데이터 구조입니다.(링크 참조) 이 구조는 SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX 데이터 구조로 이어지며, 해당 프로세스의 _Eprocess 데이터 구조 커널 주소를 Object 필드에 보관합니다.
따라서 논리는 모든 PSYSTEM_HANDLE_INFORMATION_EX 요소를 반복하고, SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX의 UniqueProcessId 필드를 원하는 프로세스 PID와 비교한 다음 해당 Object 필드를 선택하여 커널에서 해당 _Eprocess 주소를 찾는 것입니다.
코드 스니펫:

NtQuerySystemInformation은 함수 포인터로 선언되며, 해당 주소는 런타임에 loadlibrary와 getprocaddress를 통해 동적으로 얻습니다.
typedef NTSTATUS(WINAPI* ptr_NtQuerySystemInformation)(int, PVOID, ULONG, PULONG);ntdll=LoadLibrary(L"ntdll.dll");MyNtQuerySystemInformation = (ptr_NtQuerySystemInformation)GetProcAddress(ntdll, "NtQuerySystemInformation");Token을 읽고 쓰기 위해서는 clfsw32.sys 및 NamedPipes 내부의 취약점에 의존해야 합니다.
이 부분은 다소 블랙박스이며 다른 문서에 자세히 설명되어 있지만, 익스플로잇 과정에 대한 기본적인 이해를 얻기 위해 이 기능에 필요한 최소한의 지식을 설명하겠습니다. 파이프는 프로세스 간 통신 메커니즘입니다. 프로세스는 파이프를 사용하여 서로 정보를 전달할 수 있습니다. 파이프는 사용자 공간에서 채울 수 있는 일부 필드가 있는 커널 데이터 구조로 표현됩니다. 예를 들어 Pipe Attributes가 있습니다.
(나중에 이것을 CLSF 취약점과 연결하여 사용자 공간에서 커널 공간에 대한 임의 읽기/쓰기를 얻을 것입니다)
더 자세한 내용은 fengshui-spraying-big-kids-pool을 참조하십시오. 메커니즘을 단순화하여 설명하면 다음과 같습니다.
커널은 할당하려는 크기에 따라 두 가지 방식으로 메모리를 할당합니다. 객체 <4KB+헤더인 경우 small-pool, 객체 >4KB+헤더인 경우 big-pool입니다. Big-pool 페이지는 사용자 공간에서 열거할 수 있기 때문에 중요합니다. 즉, 일반 사용자가 Big-Pool 페이지를 포함하는 모든 커널 시작 주소를 찾을 수 있습니다.
어떻게? 각 Big-Pool 페이지에는 Tag라는 필드가 있습니다(저장된 데이터 유형에 대한 정보를 얻는 데 사용할 수 있음). 시스템의 모든 Big-Pool 페이지는 NtQuerySystemInformation을 SystemInformationClass 매개변수의 값으로 SystemBigPoolInformation을 사용하여 열거할 수 있습니다. 그런 다음 모든 페이지에서 Tag로 필터링하여 관심 있는 Big-Pool 페이지의 주소를 얻을 수 있습니다. 예를 들어 CLFS는 'Clfs' 태그가 있는 Big-Pool 페이지를 사용합니다. 커널에서 CLFS 객체가 할당된 모든 페이지의 주소를 얻을 수 있습니다.

다시 파이프로 돌아가서, 우리는 같은 방식으로 작업할 수 있습니다. Big-Pool 메커니즘을 사용할 만큼 큰 파이프를 할당하고, big pool 페이지를 열거하고, 파이프의 특정 Tag를 검색하여 해당 페이지를 필터링할 수 있습니다.
따라서 커널이 우리 파이프를 할당한 위치를 사용자 공간으로 유출할 수 있습니다. 커널로 들어가는 데이터를 제어하고 읽는 것은 어떨까요?
이를 위해 Pipe Attributes에 의존합니다. Big-Pool 태그와 매우 유사하게, Pipe Attribute는 파이프를 설명하는 정보를 포함할 수 있는 배열입니다(사용자가 채움). 문서화되지 않은 함수 NtFsControlFile을 사용하면 pipe-attribute 데이터 구조에 임의로 읽고 쓸 수 있습니다. 문서화되지 않았기 때문에 PipeAttribute 벡터를 설정한 다음 읽는 개념 증명만 제공됩니다. 읽기/쓰기 기본 요소에서 허용되는 유일한 수정은 입력/출력 버퍼의 내용과 크기를 제어하는 것입니다.
여기서는 big-pool 페이지(0x2000)를 사용할 만큼 큰 파이프를 할당하고, 입력 및 출력 버퍼를 제어된 값으로 설정합니다. 주의: 입력 값의 처음 2바이트는 동작하려면 반드시 0x5a 0x00이어야 합니다.

여기서는 이전에 커널에 썼던 내용을 다시 읽습니다. 역시 출력 버퍼만 변경합니다.

그리고 결과:

다음은 커널에 있는 파이프 big-pool 페이지의 내용입니다. big-pool 페이지를 쿼리하여 커널에서 파이프 데이터 구조의 시작 부분을 찾았습니다. pipe_begin+0x20 주소에는 입력 버퍼+0x2를 가리키는 포인터가 있습니다.

파이프 속성 읽기 함수를 호출하면 OS는 다음을 수행합니다.
왜 유용할까요? pipe_begin+0x20의 포인터를 System 프로세스 보안 토큰의 위치로 교체하고 파이프 속성 읽기를 호출할 수 있다고 상상해 보십시오. 사용자 공간에서 해당 값을 유출할 수 있습니다. 교체는 CLFS.SYS 취약점을 통해 이루어집니다.
이 기술을 이해하는 것은 익스플로잇을 이해하는 데 중요합니다.
대부분의 익스플로잇은 본질적으로 결정적이지 않고 확률적입니다. 취약점을 활용하는 코드가 올바르더라도 익스플로잇은 작동하지 않을 수 있습니다. 대상 프로그램을 불안정한 상태에 두는 익스플로잇 개발자는 익스플로잇 실행 후 시스템이 충돌하지 않도록 해야 합니다. 익스플로잇될 때 프로그램 내부의 변숫값에 의존하는 주소에서 읽을 수 있는 기능을 제공하는 프로그램을 상상해 보십시오.
예:
target=0x1000000+ var_1&0xff+ var_2&0xff00라고 가정합니다.
해커는 target, var_1, var_2를 제어할 수 없습니다.
하지만 익스플로잇은 target 주소에서 읽을 수 있는 기능을 제공합니다.
0x1000000과 0x100FFFF 사이의 어디에서든 읽을 수 있으며, 어떤 경우에는 유용할 수도 있고 그렇지 않을 수도 있습니다.
예2:
어떤 익스플로잇이 특정 패턴과 일치하는 주소에서 QWORD를 읽어 그 내용을 보안 토큰 값에 넣을 수 있는 기능을 제공한다고 가정합니다. 이전에 System.exe의 보안 토큰 값을 이미 얻었다고 가정합니다. 익스플로잇을 활용하여 권한을 상승시키려면 어떻게 해야 할까요?
read_addr=0x1000000+alfa&0xFFFF00
alfa 매개변수를 제어할 수 없습니다.
주제에서 벗어나지만 매우 중요: 커널은 그 순간 커널 코드를 실행 중인 프로세스에 해당하는 사용자 공간에 접근할 수 있습니다.
어떤 주소에서 읽을 수 있을까요? 0x1000000, 0x1000100(alfa=1), 0x1000200(alfa=2), ..., 0x1FFFF00(alfa=ffff00)입니다.
익스플로잇이 확실히 성공하도록 프로그래머는 다음을 수행해야 합니다.
memory=virtualalloc(dest=0x1000000,size=0x1000000,....)for (i=0x1000000;i<0x2000000;i+=0x100) ((QWORD*)i)[0]=system_token_value;이것이 실제로 메모리 스프레이입니다. 익스플로잇의 결과인 확률적 표현식의 모든 가능한 값과 일치하는 특정 패턴을 따르는 메모리 조작입니다.
우리의 경우 이것이 작동하기 위한 요구 사항은 주소 0x1000000에 메모리를 할당할 수 있다는 것입니다. 이것은 실제 익스플로잇의 경우에도 우리가 처리해야 하는 제한 사항입니다.
몇 가지 기술적 측면이 정리되었으니 이제 CLFS에 대한 기본적인 이해가 필요합니다.Microsoft 링크. CLFS는 애플리케이션 로그, 데이터베이스 로그, 트랜잭션 등에 사용됩니다.
익스플로잇은 일반적으로 작동하기 위해 특정 메모리 레이아웃이 필요합니다. 레이아웃의 형태는 익스플로잇이 발생하는 정확한 시점에 프로그램 내부의 변숫값에 의해 결정됩니다.
이 튜토리얼은 취약한 코드나 로그 파일 시스템의 형식을 자세히 설명하지 않습니다. 익스플로잇을 생성하는 프로세스에 대한 기본적인 이해를 제공할 것입니다.
로그 파일은 특정 형식을 가지며 CLSF 드라이버 및 dll API로 상호작용할 수 있는 특별한 종류의 파일입니다. 이 시스템에는 로그 컨테이너라는 개념도 있습니다. 로그 컨테이너도 로그 파일이지만, 메모리에서 기본 로그 파일(추가된 대상 로그 파일)에 연결됩니다.
이 구성은 다음과 같이 동작합니다.
이 작업은 기본 로그 파일 내에 새로운 메모리 공간을 할당하도록 강제하고, 기본 로그 파일의 메모리 레이아웃 안에 있는 다양한 객체를 수정합니다. 메모리 레이아웃의 수정은 개념적으로 다음과 같습니다.

중요한 데이터 구조/파일을 사용하기 전에 CLFS 드라이버는 로그 파일의 형식에 대한 무결성 검사를 수행합니다.
두 번째 항목이 취약점의 시작점입니다. 공격자는 이전에 생성된 로그 파일을 수정하고, 해시를 다시 계산하고, 해시 필드를 편집하여 드라이버가 무결성 테스트를 통과하도록 만들 수 있습니다. 수정은 파일 헤더 길이와 관련됩니다. 신중하게 조작된 값 집합은 그렇지 않으면 실패하고 오류를 발생시킬 범위-길이 검사를 통과시킵니다. 따라서 드라이버는 가짜 길이를 유효한 것으로 받아들이고 정상적으로 코드 실행을 계속합니다. 가짜 길이는 파일 내에서 하드코딩된 주소가 기록될 오프셋을 계산하는 데 사용됩니다. 이로써 공격자는 이전 쓰기 작업이 발생할 주소를 제어할 수 있습니다.
이 취약점은 익스플로잇을 완성하기 위해 pipe-kernel-read-write와 연결되어야 합니다. 시각적 증거:

앞의 그림에서 우리는 부적절한 범위 검사를 담당하는 CLFS.sys 내의 AllocSymbol 함수를 볼 수 있습니다. 취약한 길이 검사는 0xC0000023 오류 코드를 반환하는 검사입니다.(BaseLogRecord + v9 + Size_1 + 0x1338 > v8 + *(0x68)) 조건식의 입력은 부분적으로 공격자에 의해 제어됩니다. 정상적으로는 실패할 검사를 통과시키는 값을 수식에 주입할 수 있습니다. v8과 v9를 제어함으로써 조건의 진리값을 FALSE로 조작하여 오류 반환을 막고, 공격자가 제어하는 v9 값을 사용하여 v10 변수의 값을 임의로 계산할 수 있습니다.
V10은 값으로 0을 설정하는 memset에 추가로 사용됩니다. 따라서 공격자는 메모리를 임의로 0으로 설정할 수 있는 능력을 얻습니다. 이것이 전부는 아닙니다. 최종 반환 전에 *a3=v10이 실행됩니다. A3는 AllocSymbol 함수에 주소로 전달되는 매개변수입니다. 따라서 AllocSymbol의 호출자는 AllocSymbol에서 반환된 후 V10의 값을 받습니다.
AllocSymbol의 호출자는 FindSymbol입니다.

FindSymbol 내부에서 V33은 AllocSymbol에서 a3라는 이름의 매개변수입니다. 따라서 v33은 v10의 값을 얻습니다. 그런 다음 v33 주소의 값이 상수(0xc1fdf006)로 설정되고, 추가로 0x30 값이 앞에 붙습니다.

따라서 공격자는 임의의 메모리 위치를 상수로 설정할 수 있는 능력을 얻습니다.
왜 이것이 중요할까요? 함수 포인터의 기본값을 사용자 공간과 커널 공간 모두에서 접근 가능한 상수 주소로 덮어쓸 수 있고, 해당 포인터가 호출될 것이 보장된다면 코드 실행 제어권을 얻을 수 있습니다. 이것이 일반적인 아이디어이지만, 더 자세히 설명될 기술적 문제점들이 있습니다.
CClfsContainer* pContainer;라는 포인터가 있습니다. 해당 컨테이너가 해제되면 부모 파일에서 정리 작업이 수행되며 pContainer를 역참조하여 [[pContainer]+0x18] 및 [[pContainer]+0x8] 값을 함수 포인터로 사용합니다.따라서 pContainer를 제어하고 추가 및 삭제를 위한 컨테이너별 API를 실행할 수 있으면 제어 흐름을 리디렉션할 수 있습니다.
pContainer는 어디에 있을까요?
CLFS 파일 구조는 문서화되어 있지 않지만, 개인적으로 파일 구조를 리버싱하려는 시도는 존재합니다.
로그 파일 구조의 조감도:

그리고 베이스 블록의 상세 보기:

구조와 메모리 뷰 간의 차이점:
CLFS 파일은 Clfs 태그 값을 가진 big-pool 페이지에 할당됩니다. 사용자 공간에서 clfs 로그 주소를 얻으면(파이프 개체의 커널 공간 주소를 얻을 때 사용한 것과 동일한 방법), 커널은 이전 그림의 0x800 오프셋에서 시작하는 베이스 블록의 주소를 반환합니다. 0xb98 오프셋에는 regContainers라는 데이터 구조가 있습니다. 이것은 32비트 값의 배열입니다. 각 값은 하나의 컨테이너와 연관되며, 컨테이너의 내부 데이터 구조가 베이스 블록 파일에 위치하는 오프셋(0x870부터 카운트)을 나타냅니다.
컨테이너 파일의 메모리 레이아웃은 다음과 같습니다:
CLFS_CONTAINER_CONTEXT structure
CLFS_CONTAINER_CONTEXT structure에서 0x18 오프셋에 우리의 목표인 pContainer가 있습니다.
분명히 pContainer에 대한 오프셋은 일정하며, 이는 regContainers 배열의 첫 번째 요소입니다. 그 값은 0x1468입니다.
pContainer를 역참조하고 함수 포인터로 접근하는 것이 익스플로잇의 핵심입니다. 이 작업은 CLFS.SYS 드라이버의 Remove container 함수 내부에서 발생합니다. 따라서 컨테이너가 제거될 때 익스플로잇이 트리거됩니다.
증거:

실제로 pContainer 포인터가 함수 포인터로 접근된다는 것을 보여주기 위해 컨테이너 제거 단계를 검토해 보겠습니다.
GetBaseLogRecord API를 호출합니다. 이 API는 베이스 블록의 커널 주소 + 0x70을 반환합니다(파일 포인터를 헤더 너머로 이동합니다).BaseRecord_1)의 복사본이 초기화됩니다.a4의 또 다른 복사본이 v10으로 초기화됩니다.LODWORD(a4) = *( (_DWORD*)BaseLogRecord_1 + StartingIndex_1 + 0xCA); 파일에 컨테이너가 하나만 추가된 경우 StartingIndex_1은 0입니다. GetBaseLogRecord가 반환하는 값은 DWORD(32비트) 벡터로 캐스팅되고 0xCA 요소만큼 인덱싱됩니다. (_DWORD*)BaseLogRecord_1 + 0 + 0xCA는 단순히 BaseLogRecord_1에 0xCA 값을 더하는 것이 아닙니다. 캐스팅 때문에 이것은 BaseLogRecord_1[0xCA]와 동일합니다. 이는 이에 해당하는 디스어셈블된 코드 mov eax, [rdi+r12*4+328h]에 반영되어 있습니다. 여기서 rdi는 베이스, r12는 StartingIndex_1(32비트 요소(DWORD)의 길이인 4가 곱해지고 0xCA*0x4인 0x328이 더해진다는 점에 유의)입니다.a4의 최종 값은 BaseBlock+0x70+0+328=(BaseBlock+0x398)이며, 이는 RgConrainers의 첫 번째 값으로 이동합니다 (0x398+0x800=0xB98).
pContainer에 해당하는 구조체의 세 번째 필드입니다.82 & 83행에서 pContainer가 역참조되고 0x18 및 0x8이 더해지며 함수 포인터로 해석되어 실행됩니다. (call cs:__guard_dispatch_icall_fptr는 실제로 jmp eax 명령어에 대한 호출입니다.)이것은 pConainter 값을 다시 작성함으로써 드라이버의 제어 흐름을 변경하고 공격자가 제어하는 주소를 가리키도록 할 수 있음을 증명합니다.
앞서 설명했듯이, 익스플로잇 실행의 성공을 보장하기 위해 미리 정해진 값 패턴으로 메모리 일부를 채워야 하는 경우가 있습니다. 공격자는 익스플로잇 시점에 프로그램 상태를 구성하는 변수의 일부만 제어할 수 있기 때문입니다.
앞선 간단한 메모리 스프레이 예시에서는 특정 조건을 충족시키기 위해 벡터에 쓰인 값만 사용했습니다.
실제 사례에서는 조건이 더 복잡합니다. 로그 파일을 커널 메모리에 서로 간의 알려진 오프셋을 두고 특정 순서로 배치해야 합니다.
우선, 루프에서 많은 로그 파일을 만들고 OS가 이들을 위해 메모리를 어떻게 할당하는지 연구해 보겠습니다.
실험에서는 다음 패턴을 사용합니다:
다음 코드가 이를 수행합니다:

그리고 결과:

주소를 정렬해 보겠습니다:

주소 사이의 오프셋을 검토하면 할당과 관련된 의사 패턴이 보입니다: 서로 간에 일정한 오프셋을 가지는 연속적인 할당이 있습니다. 예를 들어 ffffd80faf444000에서 ffffd80faf4ee000까지 두 연속 할당 사이의 오프셋은 0x11000입니다.
이 가정은 익스플로잇 작동에 중요하며, 이후 이 가정에 의존하게 됩니다.
또 다른 가정: 0x11000만큼 떨어진 일부 페이지를 살펴보겠습니다. 예: ffffd80faf444000,ffffd80faf455000,ffffd80faf466000,ffffd80faf477000,ffffd80faf488000,ffffd80faf499000. 모든 페이지는 열린 CLFS 파일에 해당합니다. 파일 하나를 닫으면 페이지가 할당 해제되고 해당 주소의 메모리는 비게 됩니다. 메모리에서는 다음과 같이 보일 것입니다(ffffd80faf466000의 페이지에 해당하는 파일을 닫는다고 가정):
ffffd80faf444000,ffffd80faf455000,xxxxxxxxxxxxxxxx,ffffd80faf477000,ffffd80faf488000,ffffd80faf499000
파일을 다시 열면 OS는 높은 확률로 같은 주소(ffffd80faf466000)에 페이지를 할당하여 구멍을 채우고 메모리를 연속적으로 만들 것입니다. --> 이 역시 익스플로잇 실행에 중요합니다.
이제 사용자 공간에서 우리가 제어하는 주소로 *pConainter 포인터를 덮어쓰기 위해 사용할 전략의 도식을 소개합니다.


앞의 그림에서 start(aux1)+0x11000=start(A),start(A)+0x11000=start(B),start(B)+0x11000=start(aux2)입니다.
Logfile A를 사용하여 Logfile B의 *pcontainer 포인터를 덮어쓰고, 닫은 뒤 삭제하는 특수 코드를 사용하여 Logfile B를 닫는 것으로 익스플로잇을 트리거합니다.
B에 올바른 컨테이너 파일이 있음을 알리는 필드를 할당하고 업데이트하기 위해 Logfile B에 로그 컨테이너를 추가합니다. aux2를 B의 컨테이너로 추가할 수 있습니다.
디스크에서 편집할 수 있도록 Logfile A를 닫습니다. OS가 우선적으로 채울 메모리 구멍을 만들기 위해 A와 B는 0x11000 간격 파일들의 최대 시퀀스 중간에 위치하도록 선택하는 것이 중요합니다. 그렇게 하지 않으면 익스플로잇이 실패합니다.
무결성을 유지하기 위해 A의 해시를 다시 계산하고, 해시 필드를 수정한 다음 A를 다시 엽니다. 그리고 커널이 A를 같은 주소에 배치하기를 기대합니다. 그렇지 않으면 익스플로잇이 실패합니다.
일반 로그 파일로 A에 대해 AddLogContainer를 호출하여 B의 *pContainer 포인터 덮어쓰기를 트리거합니다.
B 파일을 삭제하여 RemoveConainter가 호출되도록 하고, 이제 손상되어 사용자가 할당한 코드를 가리키는 B의 *pContainer로 실행이 전달되게 합니다.
다음 그림은 앞서 언급한 단계를 나타냅니다.

먼저 50개의 CLFS 파일을 할당하는 것으로 시작합니다. 각 할당 후 커널 메모리의 모든 CLFS 페이지를 쿼리하고 각 파일에 할당된 모든 주소 목록을 만듭니다. 다음으로 서로 0x11000만큼 떨어진 두 파일(도식의 A와 B)을 식별합니다. (그리고 이를 first, second 변수에 저장합니다.)

주소를 식별한 후 0x11000만큼 떨어진 두 파일을 검색합니다: A->first, B->second

다음으로 A(first)를 닫고 디스크에서 편집(헤더를 변형)한 후 다시 엽니다.

이 단계는 AllocSymbol에서 익스플로잇 가능한 코드를 트리거할 때 B의 *pConainter 포인터를 덮어쓰게 되는 정확한 값을 계산해야 하므로 추가 설명이 필요합니다.
AllocSymbol 및 FindSymbol 함수에 대한 앞선 분석에서 우리는 A 파일을 IF 조건의 논리값을 FALSE로 덮어쓰고, B의 *pContainer 위치 포인터에 쓰기를 유도할 값을 v9 변수에 주입하는 방식으로 수정해야 한다는 것을 알고 있습니다.
먼저 v9를 어떤 값으로 설정해야 할까요?
AllocSymbol은 v10 (쓰기 대상 주소)을 다음과 같이 계산합니다: v10=BaseLogRecord + v9 + 0x1338. 이는 A의 주소 공간 맥락에서 계산됩니다. 따라서 BaseLogRecord는 A의 커널 페이지 주소 + 0x70입니다. v9는 공격자가 제어하며 0x1338은 상수입니다.
B의 *pContainer는 커널 페이지 주소를 기준으로 어디에 있을까요?
이것은 앞서 자세히 설명했습니다. B의 커널 페이지 주소에서 0x398을 더해 B의 regContainter 벡터에 도달하고, 첫 번째 요소로 인덱싱하여 첫 번째 CONTEINER_CONTEXT 구조체까지의 오프셋을 얻습니다. 앞서 언급했듯이 첫 번째 컨테이너의 인덱스는 0x1468로 결정되었습니다(B BaseBlock +0x70부터 카운트).
따라서 B의 *pContainer 위치는 B's Kernel_Address + 0x70 + 0x1468 + 0x18 (third element in CONTEINER_CONTEXT structure)입니다.
B's Kernel_Address=A's Kernel Address+0x11000 (메모리를 이렇게 구성했기 때문입니다)
A's BaseRecordAddress=A's Kernel Address+0x70
V10=A's Kernel Address+0x70+v9+0x1338
v10은 B의 *pContainer를 덮어써야 합니다.
V10은 다음과 같아야 합니다: v10=B's Kernel_Address + 0x70 + 0x1468 + 0x18 (third element in CONTEINER_CONTEXT structure)
B의 Kernel_Address를 대입하면: v10=A's Kernel Address+0x110000+x70 + 0x1468 + 0x18
v10을 정리하면: A's Kernel Address+0x70+v9+0x1338=A's Kernel Address+0x11000+x70 + 0x1468 + 0x18
v9에 대해 풀면: v9 = 0x11000+0x70+0x1468+0x18-0x1338-0x70=0x11148
이제 A에서 어떤 필드를 덮어써야 할까요? --> 필드에는 두 가지 범주가 있습니다:
AllocSymbol의 IF 조건이 FALSE로 평가되도록 강제하는 필드1번 범주의 필드는 자세히 다루지 않겠습니다. V9는 A 파일 내 디스크의 0x1b98 오프셋에 있는 필드에 해당합니다. 이 필드들이 왜 익스플로잇을 트리거하는지에 대한 자세한 내용은 더 이상 다루지 않습니다. 관심이 있다면 독자가 직접 읽어보시기 바랍니다.
수정해야 하는 값은 다음과 같습니다:

리틀 엔디안에서 사용되는 값은 0x11148 대신 0x11149입니다. 그 이유는 *pContainer의 상위 옥텟을 제어해야 하기 때문입니다. 최하위 옥텟은 X0(10, 20, 30...) 형태가 됩니다. 이 조건을 고려하여 각 값에 메모리 스프레이를 수행할 수 있습니다.
v10은 길이 0xa0의 메모리 영역을 00으로 메모리셋하는 데에도 사용됩니다.

그런 다음 같은 주소에서 상수로 덮어씁니다.

상수 값은 0x30c1fdf006X0 형태입니다.
이 작업은 A 파일에 로그 컨테이너를 추가할 때 발생합니다. 그러나 A의 필드 수정 후 해시 재계산 과정은 다루지 않았습니다.
디스크에 있는 로그 파일의 뷰는 메모리의 뷰와 다소 다릅니다. 우리는 길이가 0x7a00이고 디스크의 파일 오프셋 0x800에서 시작하는 base block의 내용만 수정합니다. 베이스 블록을 해시하는 데 사용되는 알고리즘은 CRC32입니다. 해시 값을 보관하는 필드도 베이스 블록 내부의 0x80c 오프셋에 저장됩니다.
해시를 재계산하는 절차:
0x80c에 있는 기존 CRC32 값을 0으로 설정합니다.0x800에서 시작하는 길이 0x7a00의 베이스 블록에 대한 CRC32를 계산합니다.코드 실행을 사용자 공간으로 라우팅하려면 다음이 필요합니다:
*pConainter 값이 0x30c1fdf006X0 형태의 상수 값으로 덮어쓰기 트리거됩니다.RemoveContainer API가 트리거되지 않기 때문입니다.
*pContainer를 사용하여 실행 흐름을 리디렉션할 대상인 사용자 메모리에는 몇 가지 조건이 적용됩니다. 프로그램이 커널 모드일 때 사용자 공간에서 임의로 명령어 실행을 시작할 수는 없습니다.
이 섹션의 목적은 시스템 충돌(BSOD)을 일으키지 않으면서 사용자 공간에서 SystemToken 값을 유출하는 것입니다.
커널의 주소에서 값을 읽어 사용자 공간에 저장하려면 어떻게 해야 할까요? 튜토리얼의 Pipe 섹션을 간단히 되짚어 보겠습니다:

여기서 우리는:
파이프 개체와 시스템 토큰 값을 얻으려는 목표를 연결하는 아이디어는 NtFsControlFile PipeReadAttribute를 사용하여 PipeWrite Attribute로 커널에 전달한 정보를 담고 있는 버퍼의 시작 부분 대신 시스템 토큰의 주소에서 읽는 것입니다.
즉, 주소 PIPE_BEGIN+=0x20에 있는 포인터 값을 시스템 토큰이 위치한 주소로 수정해야 합니다.
이 주소는 튜토리얼 초기 단계에서 EPROCESS 구조를 찾아 파싱했을 때 얻은 알려진 주소입니다.
여기서 고정된 위치의 파이프 구조에 쓸 수 있는 메커니즘을 찾아야 합니다.
이를 위해 CLFS AddLogConainer 함수를 익스플로잇하여 얻은 코드 리디렉션을 사용합니다. 원시 교체를 수행하는 셸코드를 작성하는 것으로 충분하다고 생각할 수 있지만, 테스트되지는 않았지만 확실히 작동하지 않을 수 있습니다. 드라이버가 커널 컨텍스트에서 실행되고 사용자 프로세스 영역의 코드를 실행하기 때문입니다.
이 제한을 우회하려면 임의의 값에 쓰기를 수행하는 커널 ROP를 찾아야 합니다. 즉, 함수의 끝에 있고 ret 명령어로 끝나는 일부 커널 코드 조각을 찾아 그 주소를 리디렉션 대상으로 제공해야 합니다. 이렇게 하면 코드는 여전히 커널에 의해 실행됩니다.
이것이 주요 아이디어이지만, CLFS 드라이버가 *pConainer 내부의 코드를 호출하는 방식에서 제한이 발생합니다:

사용 가능한 메모리 패턴을 만들려면 RemoveContiner 함수가 *pContainer 코드에 접근하는 방식을 연구해야 합니다.
앞의 그림에서 *pContainer를 역참조하고 이를 함수 포인터로 사용하는 코드 부분을 강조했습니다.
RDI는 AllocSymbol을 익스플로잇하여 *pcontainer 내부에 쓰인 상수입니다. 보시다시피 상수 값은 완전히 고정되어 있지 않으며 첫 번째 바이트에서 달라집니다(형태는 6X0, X는 무엇이든 될 수 있음).
mov rax, [rdi]는 상수 값을 역참조합니다. 유효하지 않은 읽기(및 BSOD)를 일으키지 않으려면 rdi가 유효한 주소를 가리키는 포인터를 보유하도록 해야 합니다. 이를 위해 0x30C1FDF00000부터 최소 0x30C1FDF006FF까지 사용자 메모리를 스프레이해야 합니다. 이는 VirtualAlloc으로 메모리 청크를 할당하여 수행됩니다. 물론 시스템이 어떤 이유로든 메모리를 할당할 수 없으면 익스플로잇은 실패합니다.
LPVOID dirtySpray = VirtualAlloc((LPVOID)0x000030c1fdf006d0, 0x1000000, 0x3000, 0x4);
그런 다음 가능한 모든 X 값에 대해 0x30C1FDF000X0부터 0x30C1FDF00FX0 위치에 유효한 사용자 메모리에 해당하는 다른 값을 저장합니다. 임의의 값을 0x5000000이라고 가정하겠습니다. 이렇게 하면 0x30C1FDF006X0 형태의 모든 rdi에 대해 rax가 0x5000000과 같아지도록 보장합니다.
이 단계 후에는 두 번의 역참조 연산이 더 있습니다: mov rax, [rax+0x18] 및 mov rax ,[rax+0x8]
이 주소들에서 메모리가 여전히 일관되도록 하려면 0x5000008과 0x5000018에 커널 함수에 대한 포인터(ROP1 및 ROP2, 이후에 계산될 예정)를 저장해야 합니다.
이것이 스프레이 패턴을 생성하는 알고리즘입니다.

참고: ROP를 도입하면 패턴이 변경됩니다. ROP도 고려해야 할 매개변수를 가지기 때문입니다. 그러나 지금까지는 잘못된 메모리 주소 지정을 방지하기 위한 최소한의 구성입니다.
이것이 디버거에서 스프레이된 메모리 1단계가 보이는 모습입니다(0x5000000 값이 어디에 배치되는지 관찰하세요-->X0 정렬):

이것이 0x5000000 주소의 메모리 모습입니다(자세히 설명할 ROP들).

그리고 커널 디버거에서 코드 리디렉션 과정:

디버거에서 RDI 레지스터 값과 명령 포인터를 확인하세요. RDI를 한 번 역참조하면 0x5000000이 되어 RAX에 저장됩니다. [RAX+0x18]은 두 번째 ROP이고, 더 아래에는 [RAX+0x8]이 첫 번째 ROP의 주소가 됩니다.
이 단계에서 우리는 함께 연결할 다음과 같은 익스플로잇 조각들을 보유하고 있습니다:
이 시점의 목표는 코드 리디렉션을 파이프의 특성 버퍼에 대한 포인터를 시스템 토큰의 주소로 덮어쓸 코드 조각과 연결하고, 파이프 특성 읽기를 사용하여 사용자 공간에서 정보를 되돌려 받는 것입니다.앞서 언급했듯이 재지정되는 코드는 커널 안에 있어야 합니다. 게다가 사용할 수 있는 함수는 단 두 개뿐입니다. 이 튜토리얼에서는 이 두 특정 함수가 어떻게 발견되었는지 다루지는 않지만, 아마도 자주 사용되는 ROP 후보 목록이 있을 것입니다.
우리는 두 가지 함수를 살펴볼 것입니다:
ntoskrnl.exe의 SeSetAccessStateGenericMapping - 두 번째 호출 ([rax+0x8])CLFS.SYS의 ClfsEarlierLsn - 첫 번째 호출 ([rax+0x18])ClfsEarlierLSn 분석:

이 함수가 잘못된 매개변수로 호출되었을 때의 유일한 역할은 EDX를 0xFFFFFFFF로 설정하고 반환하는 것입니다. 이것이 왜 필요한지는 두 번째 ROP를 분석할 때 분명해질 것입니다.
SeSetAccessStateGenericMapping 분석:

이것은 한 줄씩 자세히 논의해야 합니다.
입력: 이 함수에 진입할 때:
0x30C1FDF006X0 상수입니다.0x30C1FDF00YX8 형태로 항상 0x8 정렬되어 있으며, 0 정렬이고 0x5000000을 포함하는 0x30C1FDF006X0와 충돌하지 않습니다.함수 코드 실행:
mov rax, [rcx+48h]는 0x30C1FDF00YX8을 역참조하여 그 값을 RAX로 이동합니다.movups xmm0, xmmword ptr [rdx]는 RDX에서 16바이트를 XMM0 레지스터로 이동합니다. 여기서 ClfsEarlierLSn으로부터 RDX는 0xFFFFFFFF임을 기억하세요.movdqu xmmword ptr [rax+8], xmm0는 XMMO의 값을 [RAX+0x8]로 이동합니다.본질적으로 이것은 주소에서 읽어 커널 내부에 저장합니다. 다음과 같이 해석할 수 있습니다:

그러므로 파이프의 속성 버퍼 포인터를 시스템 토큰에 대한 포인터로 덮어쓰려면 0xFFFFFFFF에 16옥텟을 할당하고 거기에 시스템 토큰의 주소를 저장해야 합니다. 그런 다음 0x30C1FDF00YX8의 메모리를 목적지 주소 -0x8의 값으로 반복적으로 스프레이해야 합니다. 즉, 파이프 속성 버퍼까지의 오프셋 값 -0x8입니다. 즉 PIPE_KERNEL_PAGE_ADDRESS+0x20-0x8입니다.
실제 코드에서 이것이 어떻게 달성되는지는 다음과 같습니다:

설명되지 않은 또 다른 측면: 사용자 공간에서 ROP의 커널 주소를 어떻게 얻을 수 있을까요?
이것은 트릭입니다. 먼저 다음 매개변수와 함께 NtQuerySystemInformation을 사용하여 모든 커널 모듈의 베이스 주소를 얻을 수 있습니다: NtQuerySystemInformation(SystemModuleInformation, (HANDLE)ModuleInfo, ModuleInfoSize, &retlen)
그런 다음 ModuleInfo->Modules[i].Name 값을 필터링하여 필요한 라이브러리와 일치시킵니다. 이렇게 하면 커널의 베이스 주소를 얻을 수 있습니다.
그런 다음 베이스 주소와 익스포트 주소 사이의 오프셋이 실행 파일이 로드된 주소 공간에 관계없이 일정하다는 사실을 이용합니다.
LoadLibrary를 사용하여 사용자 공간에서 모듈을 로드하고(그렇게 할 수 있습니다) Getprocaddress를 사용하여 익스포트 주소를 얻습니다. Export와 사용자 베이스 사이의 델타를 계산하고 이를 커널 베이스(NtQuerySystemInformation을 사용해 찾은)에 더합니다. 따라서 사용자 공간에서 커널 공간에 로드된 익스포트의 주소를 얻을 수 있습니다.
속성(attribute)에 대한 포인터가 손상되어 시스템 토큰의 위치를 가리키도록 설정되면, 올바른 매개변수와 함께 MyNtFsControlFile을 호출하면 해당 주소에서 읽어 사용자 공간으로 시스템 토큰 값을 유출할 수 있습니다.

시스템 토큰의 올바른 값을 확보했다면, 익스플로잇을 완료하려면 그 값을 우리 자신의 프로세스 대신에 써야 합니다. 즉, 익스플로잇을 실행하는 프로세스의 토큰을 시스템 토큰의 값으로 덮어써야 합니다.
이 변경을 수행하는 데 필요한 단계에는 새로운 기술이나 방법론이 포함되지 않지만, 이전 코드의 재사용에 의존합니다. 우리 자신의 토큰 값을 덮어쓰는 알고리즘은 커널의 특정 주소에 쓰기 작업을 수행하는 것을 의미합니다. 이는 ROP 함수 SeSetAccessStateGenericMapping과 ClfsEarlierLSn에 의해 달성됩니다. 즉, CLFS 익스플로잇을 두 번째로 트리거해야 합니다. 맞습니다. 컨테이너 할당과 메모리 스프레이를 두 번째로 수행해야 하며 OS가 충돌하지 않기를 바라야 합니다.
우리 자신의 토큰 값을 덮어쓰는 단계:
두 번째에는 커널로부터 읽기가 필요 없으므로 이번에는 파이프를 사용할 필요가 없습니다.
이 단계들이 추가 지식을 요구하지 않으므로, 튜토리얼은 nt authority\system 권한을 가진 cmd.exe의 최종 증거와 함께 여기서 끝날 수 있습니다.

이 튜토리얼의 주요 초점은 CVE 자체의 내부 구조가 아니라 Windows 권한 상승 예제를 만드는 과정을 사용자 친화적으로 설명하는 것이었습니다. 많은 익스플로잇이 메모리 스프레이 또는 특정 Windows 데이터 구조 작업과 같은 동일한 방법과 구성 요소를 공유합니다. 그리고 대부분의 경우 이론적인 Windows 내부 지식을 갖는 것과 취약점을 활용하는 알고리즘을 효과적으로 작성하는 것 사이에는 큰 간격이 있습니다.