로딩중...
-
UEFI DXE 바이너리 취약점 분석기 프로젝트_5
edk2 맥북 빌드 정리 결과 Linux와 동일하되 컴파일 옵션을 gcc가 아닌 XCODE5 정도로 수정해주면 되어 이전 게시글을 참조하면 좋을 것 같다.
Ghidra
UEFI DXE 드라이버를 바이너리 단에서 P-Code로 올려서 취약점 분석을 하겠다는 계획에 Ghidra 공부는 필수적이였다. Ghidra는 미국 국가안보국(NSA)이 개발한 역어셈블러 프레임워크로, 위키리크스에 의해 정체가 공개되어 2019년에 오픈소스로 공개되었다. 비슷한 역할로 IDA Pro가 있고 일반적으로 IDA Pro가 업계 표준으로 꼽히지만, IDA Pro는 매우 비싼 가격을 가진 반면 Ghidra는 오픈 소스이면서 디스어셈블 및 디컴파일러 기능, 또한 java, python 등을 통한 스크립트 기능을 지원하여 분석 기능을 자동화할 수 있다.
P-Code
Ghidra에서 P-Code 설정을 켠 뒤 OVMF의 아무 DXE 드라이버를 열어본 화면이다. 다른 디스어셈블러와 달리 어셈블리 아래에 파란 글씨로 뭔가 잔뜩 있는 것을 볼 수 있다. 저 파란 코드들이 해당 어셈블리에 대한 P-Code다.
Ghidra 공식 사이트에선 P-Code를 리버싱을 위해 디자인된 레지스터 전이 언어(출처)라고 설명하고 있다. 쉽게 설명하면 LLVM IR과 같이 고수준 언어와 기계어 사이 다리 역할을 하는 언어라고 볼 수 있다. 다만 차이점으로 P-Code는 다른 IR과 달리 기계어를 고수준 언어로 “올리는” 과정에서의 중간 언어로, 기계어가 가지는 복잡함을 걷어내고 추상화된 논리로 복원하는 것에 의의를 가진다. 사실 위 이미지만 보면 그냥 어셈블리어보다 복잡해 보이지만, 이는 해당 어셈블리어가 어떤 행동을 하는지 등을 풀어서 설명하는 것이다. 예를 들어 Call 명령어는
스택 주소를 8만큼 줄여라(INT_SUB)
돌아올 주소를 스택 메모리에 저장해라(STORE)
해당 함수로 가라(CALL)
이 세 가지 과정이 이뤄지는 것이다. 그리고 P-Code는 이를 풀어서 보여주는 것이다. 아마 우리는 여기서 실제로 오염된 데이터들을 전파시킬 수 있는 명령어들인 CALL, COPY, LOAD, STORE 등을 중점적으로 봐야하지 않을까…싶다.
P-Code를 보면 (register, 0x20, 8), (const, 0x0, 1), (unique, 0x2c180, 1)과 같은 방식으로 값들이 표현이 되는 것을 볼 수 있다. 이는 (저장 방식, 오프셋, 크기)로 해석할 수 있으며, 각각
(register, 0x20, 8) : 레지스터 0x20번째 칸부터 8바이트 크기
(const, 0x0, 1) : 숫자 0(상수의 offset은 곧 값이다.)
(unique, 0x2c180, 1) : Ghidra가 만든 임시변수 0x2c180번에 있는 1바이트짜리 임시 값
으로 해석할 수 있다. 그러므로 지금까지 정리한 내용 및 P-Code가 3-Address 기반의 IR을 따르고 있다는 점을 합쳐서 P-Code 몇 개를 해석해보면
(register, 0x20b, 1) = COPY (const, 0x0, 1)
=>0x20b번째 레지스터에 숫자 0을 1바이트만큼 복사해라
(unique, 0x2c180, 8) = INT_AND (unique, 0x70280, 8), (const, 0xff, 8)
=> 0x70280번 임시변수와 0xff를 AND 연산한 결과를 0x2c180에 담아라
정도로 해석할 수 있을 것이다.
그래서 뭘 하지…
개인적으로 공부하며 5인 팀을 어떻게 나눠야할지 고민을 했다. 개인적으로 했던 생각은
취약점 분석 스크립트 개발(Using Ghidra Script)(2인)
탐지 검증을 위한 취약한 코드 생성
탐지 오판 여부 검증
결과 문서화 담당
의 역할 분담이 어떨까… 조심스레 생각해 보고 있고, 만약 이러한 형태라면 취약점 분석 로직을 스크립트화 하는 1번 역할을 담당해보고 싶다는 생각을 하고 있다.
분석 방법 고민
취약점 분석에 어떤 방법을 도입할지 고민하고 있다. 당장 생각나는 것들은 Pattern Matching, Taint Analysis, Symbolic Execution 정도가 생각나고, 해커가 공격의 진입점이 될 수 있는 부분(GetVariable() 함수를 통한 NVRAM 버퍼 오버플로우, CommBuffer 등)에 오염을 걸고 P-Code의 CALL, COPY, LOAD, STORE 등의 전파가 될 수 있는 부분들을 추적해나가며 찾아나가는 형식의 Taint Analysis를 통한 분석을 해보는 것으로 시작하는 것이 좋지 않을까 생각하고 있다.
하지만 CommBuffer 구조체가 드라이버 별로 다르게 생긴 것으로 알고 있어서 이 부분에 대한 파싱 부분에 고민을 하고 있다. 부가적으로는 WSMT가 있다 하더라도 SmmIsBufferOutsideSmmValid() 함수를 호출하는지 여부를 확인하기 위한 Pattern Matching 기능 정도를 부가적으로 가져갈 수 있지 않을까 라는 생각을 하고 있다.
GetVariable() 함수 찾기
Ghidra와 친해질 겸과 동시에 실제로 Buffer Overflow를 일으킬 가능성이 높은 함수인 GetVariable() 함수의 빌드 후 올라간 주소를 직접 하나씩 들어가보며 찾아보았다. 해당 부분은 취약점 분석기 스크립트 제작에 있어 중요한 부분이 되지 않을까 싶다..
GetVariable() 함수는 NVRAM(비휘발성 메모리) 내의 원하는 데이터를 읽어 인자로 넣어준 메모리 버퍼에 값을 채워주는 함수다. 이는 gRT(Global Runtime Services Table) 내에 구현되어있는 함수로, DXE 드라이버들이 메모리 내 상태를 읽을 때 해당 함수를 호출해 사용하는 중요한 함수지만, 함수 구현 코드를 보면
EFI_STATUS
EFIAPI
VariableServiceGetVariable (
IN CHAR16 *VariableName,
IN EFI_GUID *VendorGuid,
OUT UINT32 *Attributes OPTIONAL,
IN OUT UINTN *DataSize,
OUT VOID *Data OPTIONAL
)
{
...
if (*DataSize >= VarDataSize) {
if (Data == NULL) {
Status = EFI_INVALID_PARAMETER;
goto Done;
}
CopyMem (Data, GetVariableDataPtr (Variable.CurrPtr, mVariableModuleGlobal->VariableGlobal.AuthFormat), VarDataSize);
...
}
...
}
이 부분에서 CopyMem 함수가 문제가 된다. 해당 함수의 Data는 값을 써넣을 목적지 역할을 수행하지만 해당 목적지가 안전한 영역인지에 대한 처리를 수행하는 로직은 없는데, 예를 들어 해커가 특정 변수 크기를 키우고 그 곳에 Payload를 집어넣은 뒤 검증 절차를 밟지 않은 SMM 핸들러를 실행시켜 GetVariable() 함수를 통해 해당 Payload를 읽어오면 Payload가 리턴 주소 등을 덮어씌워버리면서 Ring -2 권한으로 실행되는 것이다. 그러므로 해당 함수를 호출하는 SMM 핸들러에 이를 처리하는 SmmIsBufferOutsideSmmValid() 함수가 없다면 문제가 발생할 수 있을 것이다.
한번 Ghidra에서 VariableSmm이란 파일에서 GetVariable() 함수를 찾아보았다. 먼저 해당 파일을 UEFITool을 통해 추출하고, 이 파일을 Ghidra를 통해 열어보았다.
잘 모르겠지만 entry라고 적혀있는 것으로 보았을 떄 시작 지점임을 알 수 있었다. 시작 지점은 edk2/MdePkg/Library/UefiApplicationEntryPoint/ApplicationEntryPoint.c 파일에 적혀 있었으며, 구조는
EFI_STATUS
EFIAPI
_ModuleEntryPoint (
IN EFI_HANDLE ImageHandle,
IN EFI_SYSTEM_TABLE *SystemTable
)
{
EFI_STATUS Status;
if (_gUefiDriverRevision != 0) {
if (SystemTable->Hdr.Revision < _gUefiDriverRevision) {
return EFI_INCOMPATIBLE_VERSION;
}
}
ProcessLibraryConstructorList (ImageHandle, SystemTable);
Status = ProcessModuleEntryPointList (ImageHandle, SystemTable);
ProcessLibraryDestructorList (ImageHandle, SystemTable);
return Status;
}
다음과 같았다. 그러므로 ProcessModuleEntryPointList() 함수에 들어가보기로 했다. 위 함수에 해당하는 FUN_00008f6f 함수로 가보자.
해당 함수 역시 굉장히 단순한 구조로 이뤄져있었고, 그 외 특이점으로 “AutoGen.c” 라는 인자를 넣은 에러 처리 함수를 호출하는 것을 볼 수 있는데, 해당 링크에 보면 빌드 시스템이 자동으로 Wrapper로 감싼 것을 볼 수 있었다. 다시 한번 FUN_0000565d 함수로 이동해보자.
FUN_0000565d 함수 역시 껍질만 있는 함수였고, 해당 함수가 FUN_000065d8을 호출하는 것을 보고 들어갔더니 드디어 무언가 코드가 잔뜩 나온 것을 볼 수 있었다. 여기부턴 Gemini의 도움을 받아서 해결해보았다. DAT_00090650이 SMM 모드 전용 시스템 테이블인 gMmst(Global SMM System Table)라 하여 Ghidra에 ghidra-firmware-utils라는 플러그인을 설치한 뒤 DAT_00090650의 이름을 gMmst, 타입을 EFI_SMM_SYSTEM_TABLE2 *로 변경한 결과
VariableSmm.c 파일의 MmVariableServiceInitialize() 함수와 동일함을 알 수 있었다. 해당 C 코드를 보면 gMmst의 SmmInstallProtocolInterface 함수의 네번째 인자가 gSmmVariable을 받는 것을 볼 수 있고, 해당 gSmmVariable의 첫 번째 인자가 GetVariable()임을 알 수 있었고, 두 번째 인자가 GUID임을 알 수 있다.
해당 논리를 따라 타고 들어가 확인해본 결과 해당 함수의 위치 및 GUID를 알아낼 수 있었다!
다음으로는 Gemini가 위 분석을 Ghidra 스크립트화도 가능하다는 이야기를 해서 한번 시도해보지 않을까…싶다
-
UEFI DXE 바이너리 취약점 분석기 프로젝트_4
WSMT
중첩된 포인터에 따른 SMRAM 오염을 설명하기 전 WSMT에 대해 공부를 할 필요가 있었다. WSMT는 Windows SMM Security Mitigation Table의 줄임말로써 지난 시간에 이야기했던 ACPI 테이블 중 하나다. 이는 시스템 펌웨어가 SMM 소프트웨어에서 보안이 잘 지켜졌는지를 확인하도록 OS에게 이야기하는 역할이라고 볼 수 있다.
WSMT의 구조로, 총 3가지 Flag가 있는 것을 확인할 수 있고, 각 플래그들은
FIXED_COMM_BUFFERS : OS가 지정한 고정 CommBuffer만 사용하는가
COMM_BUFFER_NESTED_PTR_PROTECTION : CommBuffer의 중첩 포인터까지 검증하는가
SYSTEM_RESOURCE_PROTECTION : 시스템 주요 설정들을 잘 보호하는가
세 가지를 담고 있다.
여기서 큰 문제가 발생하는데, WSMT는 위 세가지 문제에 대해 “검증”을 하는 것이 아닌 각 제조사가 해당 플래그를 켰는지 “확인”만 한다. 즉 제조사들은 이를 준수하지 않았더라도 Flag를 True로 뒀다면 OS는 그것을 믿고 별도 검증 기능 없이 바로 해당 핸들러를 수행하게 된다.
SMRAM Corruption using Nested Pointer
중첩된 포인터를 이용한 SMRAM 오염은 CommBuffer를 통해 값을 받고, 또 CommBuffer에 값을 저장할 때 이중(또는 그 이상) 포인터를 사용하는 과정에서 생기는 문제를 의미한다. 아래 예제는 CVE-2023-5058 사례로, 후지쯔 펌웨어, 또는 레노버 Yoga Slim 7 Pro에 들어간 UEFI에 발생한 취약점이다.
EFI_STATUS __fastcall ChildSwSmiHandler(
EFI_HANDLE DispatchHandle,
const void *Context,
_QWORD *CommBuffer,
UINTN *CommBufferSize)
{
...
Ptr2 = (CommBuffer[22] + 8);
for ( i = *Ptr2; i != Ptr2; i = *i )
{
i[24] = 0; // unchecked write (SMRAM corruption)
i[4] = 0; // unchecked write (SMRAM corruption)
i[6] = 0; // unchecked write (SMRAM corruption)
}
...
}
코드의 일부다. 위 코드를 보면 Ptr2에 CommBuffer가, 그리고 그 안에 있는 값들을 별도 검사 없이 사용하는 모습을 볼 수 있다. 물론 CommBuffer가 SMRAM에 침범하는지 검사가 이뤄졌을 것이고, 통과가 되어 CommBuffer를 사용했을 것이다. 하지만 만약 해커가 CommBuffer 내부 24, 4, 6번 등에 악의적인 Payload를 심었다면 해당 핸들러는 CommBuffer만 검사하고 내부 요소들은 검사하지 않았으므로 해당 Payload들이 SMM 권한을 얻은 채 실행될 것이다.
본격 UEFI 개발 환경 테스트해보기
아직 앞으로 어떻게 연구가 이뤄질지 모르지만 인터넷과 제미나이 등과 함께 UEFI 개발 환경을 만들어보았다.
환경은
노트북 : MacBook M4 Pro 16
가상환경 : VirtualBox, Ubuntu 24.04.02 LTS
UEFI : Tianocore edk2
기존 계획은 상대적 구형 버전으로 다운받으려고 했지만 오류가 너무나도 많이 터지는 바람에….. 일단은 최신 버전으로 만들어 보았다.
(자료 참고는 여기와 여기를 참고했습니다.)
먼저 터미널에서 의존성 패키지를 설치해준다.
sudo apt install build-essential uuid-dev acpica-tools git nasm python3-setuptools gcc-x86-64-linux-gnu
build-essential : 빌드 도구(make 등) 모음
uuid-dev : GUID 식별 라이브러리
acpica-tools : ACPI 컴파일러
nasm : 어셈블리 컴파일러
gcc-x86-64-linux-gnu : ARM 환경에서 x86-64 버전으로 컴파일하기 위해 설치
이후 폴더를 하나 만들고 해당 폴더 안에서 edk2 파일을 clone 해준다.
mkdir uefi_test
cd uefi_test
git clone https://github.com/tianocore/edk2.git
cd edk2
이후 서브모듈을 최신화해주고 빌드 툴을 컴파일해준다.
git submodule --init --recursive
make -C BaseTools
이후 환경설정 파일을 생성한 뒤 빌드를 진행한다.
source edksetup.sh
build -p OvmfPkg/OvmfPkgX64.dsc -a X64 -t GCC5
(이 과정에서 ARM 기반이라 그런지 오류가 매우 많이 발생했습니다. 이 때 저는 Conf/target.txt를 열어 다운받았던 gcc-x86-64-linux-gnu로 사용 컴파일러들을 바꾸는 등의 작업을 통해 설치할 수 있었습니다.)
이후 빌드가 성공하면 Build/OvmfX64/DEBUG_GCC5/FV 내에 Ovmf.fd라는 파일이 생기게 된다. 이 파일을 QEMU를 통해 실행할 수 있다.
qemu-system-x86_64 \
-bios Build/OvmfX64/DEBUG_GCC5/FV/OVMF.fd \
-net none
실행에 성공하면 다음과 같은 화면이 나오는 것을 볼 수 있다!
한 가지 아쉬운 점으론 취약점 분석을 할 예정인 만큼 최신 버전이 아닌 구버전을 다운받아 보려 했는데 오류가 매우 많이 발생하여 아쉬웠다. 아마 다음 주 목표는 구버전 다운로드를 목표로 하지 않을까 싶다.
(2026.01.26 11시 45분. 가상환경 없이 맥 환경에서 2021년 2월 버전 EDK2 빌드 성공!)
내 UEFI에 간단한 파일 올려보기
이제 여기서 UEFI Shell 부분에 코드를 추가해서 내 맘대로 일부 수정을 해보고 빌드를 해보자. 내가 수정을 해볼 부분은 ShellPkg/Application/Shell 내의 Shell.c 파일로, 해당 파일은 UEFI의 SHell을 담당하고 있는 파일이라고 볼 수 있다. 이 파일 내의 UefiMain() 함수를 찾아준다. UefiMain 함수는 마치 C나 Rust의 main() 처럼 해당 UEFI 파일의 시작점이 되는 부분이라고 볼 수 있다. 이 부분에 Print() 함수를 통해 내가 원하는 것을 출력시킬 것이다.
(Print() 함수는 C의 printf()와 비슷한 EDK2에서 제공하는 출력 함수다. 출력할 땐 L을 붙여 글자당 2바이트임을 알려준다.)
위와 같이 UefiMain 함수 내에 다음과 같이 입력한 뒤 저장하고 다시 빌드를 한 뒤 QEMU로 UEFI를 실행해보자.
build -p OvmfPkg/OvmfPkgX64.dsc -a X64 -t GCC5
qemu-system-x86_64 -bios Build/OvmfX64/DEBUG_GCC5/FV/OVMF.fd -net none
Shell이 시작될 때 내가 입력한 내용이 출력되는 것을 볼 수 있다!
-
UEFI DXE 바이너리 취약점 분석기 프로젝트_3
Memory Map
메모리 맵은 시스템의 RAM과 특정 영역의 분포를 나타낸 표로, OS가 메모리를 정상적으로 사용할 수 있도록 UEFI는 이 정보를 커널에 전달한다. 쉽게 생각해서
여기부터 여기까지는 중요한 부분이야!, 여기부터 여기까지는 사용해도 돼!
를 OS에게 알려주는 부분이라고 볼 수 있다.
흔히 생각하는 버퍼 오버플로우나 Null-Pointer 역참조, 또는 지난 시간에 공부했던 Callout 공격이나 CommBuffer 공격들은 대부분 결국 엉뚱한 메모리를 건들면서 생기는 문제들이라 볼 수 있다.
Windows에서 vmware를 통해 직접 EFI Shell을 실행한 뒤 memmap 명령어를 통해 메모리를 관찰한 결과다. 뭔가 정말 많이 떠있어 읽기 힘든 것을 볼 수 있다.
UEFI.org에서 가져온 각 영역에 대한 설명이다. 내용들이 매우 많아보이지만 차근차근 보자.
가장 먼저 Mnemonic 부분이다. 이 부분은 각 영역이 무엇인지에 대한 이름 정도라고 볼 수 있다.
Type
이름
설명
0
EfiReservedMemoryType
아무튼 예약된 메모리
1
EfiLoaderCode
OS 로더 코드가 올라갔던 곳
2
EfiLoaderData
OS 로더가 실행중에 쓴 데이터 영역
3
EfiBootServicesCode
부팅 단계에서만 필요한 드라이버들의 코드
4
EfiBootServicesData
부팅 단계에서만 필요한 드라이버들의 데이터
5
EfiRuntimeServicesCode
OS 실행 중에도 불려야하는 서비스들의 코드
6
EfiRuntimeServicesData
OS 실행 중에도 불려야하는 서비스들의 데이터
7
EfiConventionalMemory
여유 공간
8
EfiUnusableMemory
메모리 테스트 중 오류가 발견된 공간
9
EfiACPIReclaimMemory
ACPI 테이블의 공간
10
EfiACPIMemoryNVS
ACPI NVS 메모리
11
EfiMemoryMappedIO
하드웨어 장치의 레지스터와 연결된 주소
12
EfiMemoryMappedIOPortSpace
I/O 포트의 번역기
13
EfiPalCode
서버용 CPU의 펌웨어 코드
14
EfiPersistentMemory
영구 메모리 구역
(출처)
오른쪽 ACPI Address Range Type 부분은 일명 OS가 이 구간은 써도 되는지에 대해 UEFI가 OS에 알려주는 부분이다.
AddressRangeReserved : UEFI가 사용하고 있는 메모리. 맘대로 수정하면 안된다
AddressRangeMemory : 부팅이 끝난 뒤 초기화되는 메모리. 마음대로 사용 가능
AddressRangeACPI : ACPI 테이블(하드웨어 정보 등에 관한 테이블). 정보를 다 가져간 뒤엔 OS 사용 가능
AddressRangeNVS : ACPI NVS 메모리(시스템 전원 관리 및 절전 모드 작동 등에 사용). 맘대로 수정 불가
AddressRangePersistentMemory : 비휘발성. 컴퓨터를 껐다 켜도 데이터가 남아있는 구역
(출처)
지난 주차에서 공부했던 내용을 생각하며 우리가 여기서 봐야 하는건
SMM 코드가 드라이버가 외부에 있는 주소를 사용하려 하는가
정도가 될 것이라 생각한다.
지난 시간에 공부했든 SMM 코드는 SMRAM 내 코드가 아니면 실행하면 안된다. 하지만 이 SMM이 BS_Code나 RT_Code를 실행한다면?
만약 해커가 악의적으로 Ring 0 권한을 탈취 후 RT_Code의 쓰기 방지를 풀고 Payload를 심었거나, 무방비 구역인 BS_Code구역에 Payload를 심었고, 이 부분을 SMM이 Call을 한다면?
이게 바로 SMM Callouts 공격이 되는 것이다.
이 링크는 AMD의 SMM Callout에 대한 실제 CVE로, 입력 버퍼에 유효성 검사가 존재하지 않아 SMM Callout 취약점이 발생할 수도 있다는 것을 의미한다.
Save State
지난 시간 SMM에 대해 공부했을 때 Save State에 대한 부분을 작성하지 않았던 것 같아 추가적으로 작성하기로 하였다. Save State는 SMRAM의 일부분으로, SMRAM의 어떤 부분을 덮어쓰면 위험한데? 라는 질문의 대답이 될 수 있을 것이다.
(출처)
SMRAM의 구조이다. Save State는 SMI가 걸려 SMM 모드로 들어가는 순간의 레지스터 값 등을 Save State라는 곳에 저장하고, 일을 마치면 저장한 값을 다시 불러오는 데에 사용한다.
Low SMRAM Corruption
(출처)
지난 시간 공부했던 CommBuffer 공격이 SMM이 있는 SMRAM을 침범하는 공격이라고 하였다. 물론 CommBuffer를 SMRAM에 할당받지 못하도록 방어하는 다양한 보호 기법들이 있다. SmmIsBufferOutsideSmmValid 함수가 바로 이런 보호 기법 중 하나다. SmmIsBufferOutsideSmmValid 함수는 인텔의 표준 UEFI 레퍼런스 구현체인 EDK2에 구현되어있는 함수로, CommBuffer가 SMRAM을 침범했는지 여부를 확인해주는 함수다.
가끔 SmmIsBufferOutsideSmmValid 함수를 개발 과정에서 까먹고 넣지 않는 경우가 있거나, 또는 CommBuffer 크기를 넘겨주지 않는 경우, 또는 핸들러 자체가 너무 허술할 경우 등이 있는데, 이런 경우 공격 대상이 될 수 있다. 대표적 사례로 Low SMRAM Corruption 공격이 있다.
위 함수와 같은 핸들러가 취약한 SMI 핸들러의 예시가 될 수 있는데, 빨간 박스를 보면 CommBuffer의 범위에 따른 유효성 검사 없이 그냥 채워져만 있으면 통과를 시켜준다. 또한 노란 박스를 통해 CommBuffer의 시작 주소에 64비트, 즉 8바이트를 냅다 주는 것을 알 수 있다! 이 핸들러에 Low SMRAM 공격을 할 수 있다. 간략하게 순서는 다음과 같다.
CommBuffer를 SMRAM 바로 밑에 위치시킨다.(SMRAM - 1)
CommBuffer의 크기를 1바이트와 같이 작은 숫자로 설정한다.
취약한 SMI 핸들러를 작동시킨다.
그렇게 되면 위 그림과 같이 겉으로 보기엔 아무 이상 없는 것처럼 보이므로 SmmIsBufferOutsideSmmValid 함수도 통과하여 SMI 핸들러로 들어오게 될 것이다. 그런데 이 핸들러가 SMRAM 한 칸 밑에 있는 CommBuffer를 8바이트로 늘려버린다면?
위와 같이 SMRAM을 CommBuffer가 침범하게 된다.
Nested Pointer를 통한 SMRAM 침범 공격 문제 공부중…
-
UEFI DXE 바이너리 취약점 분석기 프로젝트_2
SMM
SMM(System Management Mode)는 x86 및 x86-64 프로세서의 작동 모드로, 해당 모드는 OS가 실행되는 동안 OS의 아래에서 저수준 시스템 관리 작업을 수행하는 데 사용된다.
초기엔 부팅 관련 Phase에서만 사용되었지만 요즘에는 하드웨어 보호 및 제어, 각 제조사별 하드웨어 기능(키보드 백라이트 조절, 배터리 수명 모드 등)과 같은 곳에서도 사용된다.
SMM의 설계도를 보면 SMM은 내부에서만 사용되는 것이 아닌 일반 모드들과도 연결이 되는데, 이 일반 모드와 SMM을 연결해주는 것이 바로 SMI(System Management Interrupt)이다. SMI가 발생하면 SMRAM이라는 SMM이 있는 전용 메모리 공간으로 들어가 SMM을 불러오는 것이다. 이 때 SMM에게 이 정보를 같이 처리해주세요! 라는 정보를 함께 넘기게 되는데 이를 CommBuffer라고 한다.
SMM이 이번 프로젝트에서 왜 중요하다고 생각한지 설명하기 전 CPU의 특권 레벨(Privilege Level)에 대해 설명할 필요가 있다. 특권 레벨이란 어떤 시점에서의 CPU의 권한 상태를 나타내는지, 다시 말해 CPU가 어떤 명령을 실행할 수 있는지, 메모리 어느 범위까지 도달할 수 있는지의 정도를 말한다.
특권 레벨은 Ring 0~3까지 구성되며, 더 낮은 숫자로 내려갈수록 할 수 있는 것들이 많아진다. Ring 3은 우리가 흔히 사용하는 카카오톡과 같은 응용 프로그램이 해당되며, 여기선 하드웨어 조작, 타 프로그렘 메모리 읽기 등이 금지된다. Ring 0은 커널 모드로, OS, 하드웨어 드라이버 등이 이 단계에 해당된다. 여기선 모든 메모리의 접근, 하드웨어 직접 접근, Ring 3 프로그램의 강제 종료 등의 컴퓨터 내에서의 대부분의 것들을 수행할 수 있다.
(카카오톡이나 줌 등에서 웹캠 키고 마이크 킬 수 있잖아요! : 그것은 Ring 3이 Ring 0에게 켜달라고 요청을 하는 것이다.)
근데 여기서 SMM은 Ring 0인 커널보다도 더 낮은 숫자인 Ring -2에 위치한다.
(Ring -1은 OS를 담당하는 가상화 하이퍼바이저를 의미한다고 한다.)
Ring -2에 해당하는 SMM이 실행되면 OS 마저도 멈추고 SMM에 해당하는 작업을 처리하게 된다. 이는 메모리 및 장치 리소스에 대한 제한 없는 접근 권한을 가지므로 이 부분이 악성 코드들의 공격 경로로 자주 사용된다.
(출처)
CommBuffer 공격
다시 SMM으로 돌아와보자. SMM이 존재하는 SMRAM은 물리적으론 RAM에 존재하지만 논리적으로는 격리가 되어 만약 이 공간에 Ring -2보다 높은 숫자를 가진 레벨으로 접근하려 하면 쓰레기 값만 보여주거나 칩셋에서 자체 차단을 하게 된다. 그런데 만약 해커가 악의적 목적으로 CommBuffer에 덮어씌울 주소로 SMRAM 내부 주소를 입력한다면? 그리고 SMM이 해당 주소에 대한 검사를 하지 않고 바로 받아드린다면?
외부 오염된 데이터가 SMRAM으로 들어오면서 해커가 Ring -2의 권한을 탈취할 수도 있는 것이다.
SMM Callouts
SMM Callouts 공격은 위 CommBuffer 공격과 반대로 SMM Code가 SMRAM 경계 밖에 있는 함수를 호출할 때 발생한다. 원래 SMM Code는 SMRAM 내에서만 실행이 되어야 한다. 하지만 SMM Code가 외부에 있는 함수를 호출한다면? 그리고 그 외부 함수 주소를 타고 가보니 해커가 심어둔 악성코드의 주소라면?
악성 코드가 Ring -2라는 최상위 권한으로 실행되게 되는 것이다.
악성코드가 Ring -2로 실행되게 된다면 OS보다 더 높은 권한으로 실행됨으로써 해당 악성코드는 OS를 아무리 포맷해도 하드디스크가 아닌 메인보드 펌웨어 칩에 실리게 되어 치료가 불가능해질 수도 있다. 또한 악성코드를 탐지하는 백신들 역시 Ring 0에서 실행되므로 Ring -2에 실린 악성코드를 탐지할 수도 없게 된다. 그리고 이를 방지하기 위해 우리가 만들 분석기의 역할은 두가지.
1. 들어온 CommBuffer에 대한 검사를 수행하는가?
2. SMM Code가 외부 함수를 호출하려고 하는가?
를 감지할 수 있다면 훌륭한 분석기가 되지 않을까 생각하고 있다.
PE
UEFI의 분석을 위해서는 PE에 대한 공부가 필수적이라 생각한다.
지난번에 뜯어본 .efi 파일의 맨 처음 부분을 보면 MZ라는 것이 적혀있는 것을 볼 수 있는데, MZ가 바로 PE파일임을 나타내는 signature이다. 이를 통해 UEFI의 DXE 드라이버들이나 SMM 모듈들은 PE의 구조를 따르는 것을 알 수 있다.
PE를 통해서 알 수 있는 정보들이 매우 많은데,
1. 섹션의 구분을 알 수 있다.
몇번지부터 code인지, 몇번지부터 data인지 등을 여기서 알 수 있다.
2. 해당 코드의 진짜 시작점(AddressOfEntryPoint)을 알아낼 수 있다.
3. 주소 재배치 계산 정보가 여기에 담겨있다.
이 부분이 틀리면 메모리 번지수 계산이 전부 망가지게 된다.
PE 헤더에 어떤 내용들이 담겨있는지를 여기서 전부 다 다루진 않지만, UEFI뿐 아니라 PE 전반적으로 중요한 부분들을 위주로 다뤄보겠다.
IMAGE_DOS_HEADER
이곳은 DOS 파일과의 하위 호환성을 위한 공간이다.
Signature(e_magic) : “MZ”가 아닐 경우 해당 파일을 실행하지 않는다.
Offset to New EXE Header : 실제 PE 헤더의 시작 Offset이 담겨있다.
IMAGE_FILE_HEADER
여기엔 해당 PE 파일의 기본적인 정보들이 담겨있다.
Machine : 컴퓨터 아키텍처의 유형이 적혀있다. x64인지, x86인지, ARM인지 등이 담겨있다.
Number Of Sections : .text, .data, .reloc와 같은 섹션이 몇 개 있는지를 알려준다.
Size of Optional Header : 다음에 나올 IMAGE_OPTIONAL_HEADER의 크기를 나타낸다.
Characterstics : 해당 파일의 속성값이 담겨있다.
IMAGE_OPTIONAL_HEADER
여기엔 해당 PE 파일의 부가적이지만 분석에 필수적인 정보들이 담겨있다.
Magic : 32비트인지(0x10B), 64비트인지(0x20B)가 적혀있다.
Size of Code : 코드의 크기를 나타낸다.
AddressOfEntryPoint : 실제 파일이 메모리에서 시작되는 지점을 나타낸다.
BaseOfCode : 실제 코드가 시작되는 번지수를 나타낸다.
ImageBase : 실제 가상 메모리에 올라가는 번지수를 나타낸다.
Section Alignment : 섹션 및 파일의 정렬을 위한 최소 단위를 나타낸다.
Size of Image : 해당 파일이 메모리에 로딩된 순간의 전체 크기를 나타낸다.
SubSystem : 해당 파일이 GUI인지, 드라이버인지, CLI 등인지를 나타낸다.
Number of Data Directiory : DataDirectory의 개수를 나타낸다.
이 때 BaseOfCode, AddressOfEntryPoint, ImageBase의 차이가 처음에 헷갈렸는데,
ImageBase : 실제 가상 메모리에 올라가는 번지수를,
BaseOfCode : ImageBase에서 코드부분 시작 지점까지 얼마나 떨어져 있는지를,
AddressOfEntryPoint : 실제 프로그램을 실행할 때 제일 처음 시작되는 부분(Main함수)이 얼마나 떨어져 있는지를 나타낸다.
IMAGE_SECTION_HEADER
여기선 각 Section들의 속성들을 나타낸다.
Virtual Size : 메모리 내에서 해당 섹션이 차지하는 크기를 나타낸다.
RVA : 메모리에서 해당 섹션의 시작 주소의 offset을 나타낸다.(RVA : Relative Virtual Address)
Size of Raw Data : 파일에서의 섹션의 크기를 나타낸다.
Pointer to Raw data : 파일에서의 해당 섹션의 offset을 나타낸다.
Characteristics : 해당 섹션의 속성(읽기 전용인지, 읽고 쓰기 전부 가능한지 등)을 나타낸다.
이 때 Section으론
.text : 프로그램의 실행 코드가 담겨있음.
.data : 읽고 쓰기 모두 가능한 Data Section. 초기화된 전역변수 및 static 변수 위치.
.rdata : 읽기 전용 Data Section. const 및 문자열 상수 등이 위치.
.bss : 초기화되지 않은 전역변수가 담겨있음.
.idata : Import할 DLL 및 API 관련 정보가 담긴 Section. IAT가 여기에 위치한다.
.didat : DLL 단위 지연 로딩을 위한 Section.
.edata : Export할 API가 담긴 Section.
.rsrc : 리소스 관련 Data가 담긴 Section(아이콘, 커서 등).
.reloc : 기본 재배치 정보들을 담고 있는 Section.
주소 계산법
PE에 적혀있는 주소들은 가상 주소에 해당된다. 해당 주소는 실제 메모리에 올라가면 주소가 바뀌게 되므로, 적혀있는 주소를 그대로 참조하는 것이 아닌 실제 주소와 가상 주소 사이 Offset을 계산해준 뒤 해당 Offset만큼 더해준 위치에 접근을 해주면 된다. 해당 Offset의 계산은 다음과 같다.
Offset = Load Address - ImageBase
하지만 이 Offset을 무작정 써먹을 순 없고, 약간의 계산을 추가적으로 해줘야 한다. 일반적으로 DataDirectory의 6번째(DataDirectory[5])에 재배치에 관한 테이블이 존재한다.
위 그림이 바로 재배치 테이블이다. 해당 테이블의 data에 있는 값들이 바로 재배치를 해야 할 RVA 주소들로, 이 주소를 파일 위치(RAW)로 바꾼 뒤 해당 주소가 가르키는 값에 Offset을 더하는 과정을 반복해줘야 한다.
이 때 RVA를 RAW로 바꾸는 공식은 아래와 같다.
RAW = RVA - VA + PTRD
이 때 VA는 해당 RVA가 속한 Section 헤더의 RVA를, PTRD는 해당 Section 헤더의 Pointer To Raw Data를 뜻한다.
-
UEFI DXE 바이너리 취약점 분석기 프로젝트_1
UEFI란
UEFI(Unified Extensible Firmware Interface, 통일 확장 펌웨어 인터페이스)란 기존 BIOS를 대체하기 위해 나온 규격이다.
기존 Legacy BIOS는 CPI 실행 과정에서 16비트 모드(키보드 탐색만 지원)에서만 실행이 될 수 있었다.
이는 메모리 주소 공간이 1MB로 제한되므로 CPU의 속도가 아무리 빨라져도 부팅 초기화 단계에서 구식 CPU의 수준에서만 동작할 수 있었다.
구형 BIOS의 모습이다. 마우스 등의 장치로는 불가능하고 오직 키보드로만 탐색할 수 있었다.
또한 BIOS가 사용하는 MBR 방식은 주소를 32비트로 관리함에 따라 최대 2TB 용량의 저장 장치만 사용할 수 있었다. 이런 문제를 해결하기 위해 UEFI가 등장했다고 보면 될 것 같다.
내용은 이 링크를 통해 많이 공부하였다.
UEFI 부팅 순서
(BIOS의 부팅 순서는 따로 설명하지 않겠다.)
UEFI 부팅 순서를 나타낸 그림이다.
1. SEC(Security) 단계
시스템 전원이 켜지자마자 가장 먼저 실행되는 단계. CPU가 전원을 받아 reset vector에 처음으로 명령어를 받아온다. 이 과정은 16비트 real mode의 instruction을 실행하는데, 이 작업은 프로세서를 보호 모드로 전환한다. 또한 갓 컴퓨터에 전원이 들어온 상태이므로 메인 메모리가 아직 초기화되지 않았으므로 CPU의 L1/L2 캐시를 임시 RAM으로 사용해 C 코드를 실행할 준비를 하며, 그리고 초기 펌웨어 코드가 변조되지 않았는지 검증하는 Pre-Verifier 단계를 수행한다.
2. PEI(Pre-EFI Initialization) 단계
이 단계에서 메인 메모리를 포함한 칩셋들을 초기화한다.
3. DXE(Driver Execution Enviroment) 단계
우리가 중점적으로 볼 부분이다.
PEI 단계에서 메모리가 초기화되어 사용할 수 있는 상태가 되었으므로 본격적으로 드라이버들을 Load한다. 실행 파일은 PE32, PE32+(64비트) 파일이 사용된다. 이 단계에서 각 모듈들을 열거하고 실행하는 Dispatcher가 존재하는데, 각 모듈들은 USB, 그래픽, 네트워크, 파일 시스템 등 기능들을 불러오게 된다.
(*HOB(Hand-Off Block) : PEI에서 DXE로 넘어갈 때 전달되는 구조체로, 이 안에 메모리 유효 범위, 부팅 볼륨 등의 정보가 담겨있으므로 이 부분을 분석하는 것이 시작점이 되지 않을까… 싶다)
4. BDS(Boot Device Selection) 단계
부팅 정책에 따라 GPT 디스크 및 EFI 시스템 파티션(OS 부트로더)을 찾는다. 이 때 윈도우라면 bootmgfw.efi, 리눅스라면 grub.efi 등 각 운영체제별 부트로더 정보들이 있고, 이를 찾아 메모리에 로드하는 단계가 된다. 우리가 컴퓨터를 켜서 BIOS 설정 환경으로 넘어가기 위해 F2나 Del키를 연타하게 되는데 이 설정 환경이 여기서 실행되게 된다.
5. TSL(Transient System Load) 단계
OS 부트로더는 이미 실행중이지만, 이 단계까지 아직 UEFI의 Boot Services를 이용할 수 있다. 부트로더가 최종적으로 커널 메모리에 올라가 실행 환경이 구축되었다면 ExitBootServices() 함수를 호출하여 제어권을 OS에 완벽히 넘기고 UEFI는 부트 프로세스를 종료한다.
6. RT(Run Time) 단계
여기부턴 OS가 완전히 컴퓨터의 제어권을 잡고 실행하게 된다. UEFI는 이 때 시스템 시간 가져오기, 시스템 리셋, 디바이스 드라이버 로드 등 일부 Runtime Service들만 사용하게 된다.
7. AL(After Life) 단계
시스템이 종료되는 시점이다.
간단히 실제 DXE 살펴보기
제미나이와 함께 간단하게 DXE .efi 파일이 어떻게 되어있는지 살펴보았다.
사용 모델은 DELL XPS 15 9560의 UEFI를 여기서 다운받았다.
다운을 받으면 .exe 형태의 파일이 다운받아진다. 이를 추출하기 위해 깃허브에 올라와있는 추출기를 다운받아 실행하였다.
추출이 완료되면 폴더가 하나 생기고 폴더 안에는 위 이미지와 같이 추출된 펌웨어들이 .bin의 형태로 저장되어 있는 것을 볼 수 있다.
이 중 “System BIOS with BIOS Guard v1.24.0.bin” 이란 파일이 분석 대상이라고 판단. 이 파일을 UEFITool을 통해 열어보았다.
해당 파일을 UEFITool을 통해 열었을 때 위와 같은 화면이 나옴을 알 수 있었다.
앞으로 이 부분들을 우리가 분석해나가야 할 것들이 될 것이다.
Touch background to close