로딩중...
-
UEFI DXE 바이너리 취약점 분석기 프로젝트_10
추가 수정 로직
회의 이후 방식을 다시 바꿨다. 기존 방식의 문제점으로는 XREF를 타고 올라가는 과정에서 주소 + 0xd0 이런 식으로 타고 가는 것들이 존재하기 때문에 Ghidra는 이게 그냥 숫자 덧셈인지, 포인터인지 전혀 알 수 없다. 그러므로 Ghidra는 이를 단순 상수라고 생각하므로 XREF가 끊기게 되고, 찾지 못하게 되는 것이였다. 그리고 그렇다고 모든 함수를 다 타겟으로 넣었던 기존 방식으로 돌아가기엔 gBS를 호출 가능한 entry 영역까지 같이 찾아버리게 되는 문제가 있다.
그래서 생각한 방식은 옛날 방식과 이번 방식을 섞어서 만들어냈다. 먼저 Ghidra의 모든 함수들을 타겟으로 하여 무작정 전부 다 가져온다. 여기까지는 옛날 방식과 동일하다. 그리고 이 함수들을 가지고 한번, 두번 더 걸러내는데, 해당 함수 내에서 간접 호출을 하는 경우, 즉 CALLIND 라는 P-Code를 호출하는 경우를 찾는다. 또 이 함수들을 가지고 한번 더 걸러내는데, 만약 0xE0을 꺼내온 지점이 있다면 Root Handler로, 그렇지 않은 것들 중 EFI_SMM_SW_DISPATCH2_PROTOCOL의 GUID를 호출하는 지점이 있다면 Child Handler로 간주하여 해당 함수들을 타고 들어가 핸들러 주소만 가지고 나와서 타겟으로 등록한다.
핸들러를 호출하는 함수를 잡는 것이 아닌 핸들러 자체를 잡았을 때 장점이 존재하는데, 바로 이 과정에서 “더 이상 부팅 시점에서 gBS를 맘대로 사용해도 된다.”란 조건을 생각하지 않아도 된다. 해당 핸들러를 호출하는 지점이 어디인지 판단할 필요 없이 해당 핸들러 안에 gBS가 있는지 없는지만 확인하면 되는 것이다.
SmmCalloutHunter_v4_0_1 코드
(3.18일 수정. JSON 저장 위치 수정)
SmmCalloutHunter_v4_0_2 코드
지난 번 탐지하지 못했던 드라이버 분석 결과
지난 주에는 탐지하지 못했던 팀원이 생성한 드라이버에 대해 잘 잡는 것을 확인할 수 있었다!
추가 변경사항
아래는 지난 주 회의에서 나왔던 변경사항 및 회의 결과 리스트다.
timestamp는 스크립트를 돌린 날짜 시분초가 아닌 스크립트를 돌리는 데 걸린 시간으로 한다.
파일명은 (잡고 있는 취약점)Hunter_v(버전)으로 하며, 이름은 파스칼 케이스로, 버전 숫자는 0_0_0의 형태로 표기한다.
팀원간 깃허브 레포지토리를 하나 개설하였다.
테스트 데이터 생성
일단 잠시 코드 로직은 미뤄두고, 지금까지 만든 내 분석 스크립트를 평가해보고자 싶었다. 레드팀 역할을 너무나도 잘 해주는 형의 일손을 약간(?)은 덜어주고자… 생성형 AI를 사용해 테스트 데이터 총 20개를 뽑았다. 10개는 정상, 10개는 취약 코드로 하여 만들었다.
파일명
타겟 시나리오
test_1
gSmst->SmmLocateProtocol 호출
test_1_bad
gBS->LocateProtocol 호출
test_2
gSmst->SmmAllocatePool 호출
test_2_bad
gBS->AllocatePool 호출
test_3
gSmst->SmmFreePool 호출
test_3_bad
gBS->FreePool 호출
test_4
gSmst->SmmLocateHandle 호출
test_4_bad
gBS->LocateHandle 호출
test_5
gSmst->LocateHandleBuffer 호출
test_5_bad
gBS->LocateHandleBuffer 호출
test_6
gSmst->SmmHandleProtocol 호출
test_6_bad
gBS->HandleProtocol 호출
test_7
gSmst I/O 연산 수행
test_7_bad
gBS->LocateProtocol(SATA) 레거시 서비스 찾기
test_8
자식 함수 생성 및 안전한 SMM 서비스 호출
test_8_bad
자식 함수 생성 이후 자식 함수에서 gBS 호출
test_9
정상적 gSmst 호출
test_9_bad
전역변수에 gBS 포인터 호출
test_10
SMM 영역 포인터 사용
test_10_bad
entry에서 gBS->AllocatePool로 받아온 포인터 전역변수에 저장. 받아온 포인터 런타임에 호출
각 데이터에 대한 설명은 위 표와 같고, 데이터는 여기에 만들어 올려두었다.
빌드를 수행하는 방법은 다음과 같다.
먼저 만들고 싶은 드라이버의 수만큼 폴더를 생성한다. 본인은 edk2/Ovmfpkg/ 아래에 저장해두었다.
각 폴더 하위에는 .inf 파일과 취약점 드라이버 소스인 .c가 하나씩 들어간다.
## @file
# Test Driver: test_1
##
[Defines]
INF_VERSION = 0x00010005
BASE_NAME = test_1
FILE_GUID = 22334455-6677-4889-9AAB-BCCDDEEFF011
MODULE_TYPE = DXE_SMM_DRIVER
PI_SPECIFICATION_VERSION = 0x0001000A
VERSION_STRING = 1.0
ENTRY_POINT = test_1EntryPoint
[Sources]
test_1.c
[Packages]
MdePkg/MdePkg.dec
[LibraryClasses]
UefiDriverEntryPoint
SmmServicesTableLib
# UefiBootServicesTableLib
DebugLib
[Protocols]
gEfiSmmSwDispatch2ProtocolGuid
[Depex]
gEfiSmmSwDispatch2ProtocolGuid
.inf 파일은 다음과 비슷한 형태로 구성되며, 본인은 드라이버명, BASE_NAME, ENTRY_POINT, SOURCE, 그리고 FILE_GUID를 수정해주었다. 특히 GUID는 다른 파일과 겹치면 안된다.
이후 드라이버 C 코드를 짜준다.
#include <PiSmm.h>
#include <Library/UefiDriverEntryPoint.h>
#include <Library/SmmServicesTableLib.h>
#include <Library/DebugLib.h>
#include <Protocol/SmmSwDispatch2.h>
EFI_STATUS EFIAPI test1Handler (
IN EFI_HANDLE DispatchHandle,
IN CONST VOID *Context, // 선택사항 (SMI 번호 등의 정보)
IN OUT VOID *CommBuffer, // 벙커 밖(OS)과 통신하는 메모리 버퍼
IN OUT UINTN *CommBufferSize // 통신 버퍼의 크기
)
{
VOID *Protocol;
gSmst->SmmLocateProtocol (&gEfiSmmSwDispatch2ProtocolGuid, NULL, &Protocol);
return EFI_SUCCESS;
}
EFI_STATUS EFIAPI test_1EntryPoint (
IN EFI_HANDLE ImageHandle,
IN EFI_SYSTEM_TABLE *SystemTable
)
{
EFI_STATUS Status;
EFI_SMM_SW_DISPATCH2_PROTOCOL *SwDispatch;
EFI_SMM_SW_REGISTER_CONTEXT SwContext;
EFI_HANDLE DispatchHandle;
Status = gSmst->SmmLocateProtocol (&gEfiSmmSwDispatch2ProtocolGuid, NULL, (VOID **)&SwDispatch);
if (EFI_ERROR (Status)) return Status;
SwContext.SwSmiInputValue = 0x01;
Status = SwDispatch->Register (SwDispatch, test1Handler, &SwContext, &DispatchHandle);
return Status;
}
(여기서 SwSmiInputValue는 전부 다 다른 값을 넣어주라고 했는데 이유는 잘 모르겠다…)
그렇게 다 코드를 짜줬다면 다음으로 OvmfPkg 내 OvmfPkgX64.dsc, OvmfPkgX64.fdf 파일 두 개를 수정해준다.
OvmfPkgX64.dsc 파일 맨 아래에 위 이미지처럼 만든 드라이버의 inf 파일 경로를 입력해주고,
OvmfPkgX64.fdf 파일의 [FV.DXEFV] 아래에 위 이미지처럼 INF + 만든 드라이버의 inf 파일 경로를 입력해준다.
이후 항상 빌드하듯이
source edksetup.sh
build -p OvmfPkg/OvmfPkgX64.dsc -a X64 -t XCODE5 -D SMM_REQUIRE=TRUE -D SECURE_BOOT_ENABLE=TRUE
를 입력해주면 빌드가 진행이 되고, 만들어진 드라이버는 Build/OvmfX64/DEBUG_XCODE5/X64 아래에 만든 드라이버들이 .efi 파일로 예쁘게 잘 만들어진 것을 볼 수 있을 것이다!
(이 때 경로와 빌드 명령어는 사용 OS 및 컴파일러에 따라 달라진다. 본인은 M4 pro Mac + XCODE5를 통해 빌드하였다.)
테스트 결과
만든 20개 데이터를 Ghidra에 올린 뒤 각각 전부 테스트 해보았고, 결과는 20개중 19개를 잘 맞췄다. 틀린 하나는 test_10_bad였다.
test_10_bad는 전역변수를 하나 만든 뒤 entry에서 전역 변수에 gBS의 주소를 담고, 런타임 핸들러에서 해당 전역변수를 불러와 사용한다. 이때 우리가 만든 스크립트는 전역변수를 만났을 때 전역변수의 주소와 gBS 주소를 비교하고, 둘은 당연히 다르므로 안전하다고 판단하고 넘어가게 되는 것이다.
-
UEFI DXE 바이너리 취약점 분석기 프로젝트_9
추가 문제점
저번에 찾은 스크립트를 다른 팀원이 테스트를 해봤고, 해당 스크립트에 약간의 추가 문제점을 찾을 수 있었다. 먼저 SMI는 접근 방식이 하드웨어적 접근과 소프트웨어적 접근으로 발생한다.
하드웨어적 인터럽트 : CPU 온도 상승, 노트북 뚜껑 닫히고 열림 센서 작동 등 하드웨어에서 직접적으로 발생하는 인터럽트
소프트웨어적 인터럽트 : OS가 요청하여 SMM 모드로 들어갈 때 OS가 소프트웨어적으로 요청하는 인터럽트
하지만 SMI를 알리기 위한 핀은 CPU에 단 하나만 연결되어 있으므로, CPU에 SMI 신호가 왔을 때 해당 신호가 하드웨어 신호인지, 소프트웨어 신호인지 알 수 없다. 그래서 이 때 SmmBase2가 나와서 하드웨어와 연결된 레지스터들을 다 찾아서 전기 신호가 들어왔는지 아닌지 여부를 판단한다. 그 중 전기 신호가 들어온 레지스터가 있다면 하드웨어가 요청한 인터럽트로 판단, 그렇지 않다면 소프트웨어 인터럽트로 판단을 진행한다. 이 과정이 바로 Root SMI Handler가 되는 것이다. 그리고 판단한 결과가 하드웨어 인터럽트면 각각 하드웨어에 맞는 디스패처로, 소프트웨어 인터럽트라면 다른 특정 디스패처(EFI_SMM_SW_DISPATCH2_PROTOCOL) 등을 호출하게 되는데 이것들이 Child SMI Handler가 된다.
하지만 이렇게만 봤을 땐 결국 SmmBase2를 거칠텐데 왜 얘를 거치지 않고 넘어가냐는 궁금증이 생겼다. 이 부분이 바로 처음 분석할 때 이야기하던 Autogen.c라는 파일이다. 이는 코드를 다 짜고 빌드를 하면 컴파일러가 자동으로 삽입하는 코드인데, 이 안에 SmmServicesTableLibConstructor라는 함수가 존재한다. 이 함수가 바로 SmmBase2를 찾아서 InSmm을 호출한 뒤 gSmst의 주소를 찾아서 전역 변수로 올리는 과정을 거치게 되고, 해당 핸들러들은 gSmst를 사용하기 위해 굳이 gBS->LocateProtocol을 통해 SmmBase2를 찾고, InSmm을 통해 SMRAM 내 영역인지 판단한 뒤 GetSmstLocation을 통해 gSmst를 받아오는, 매번 불편한 일을 하지 않아도 되는 것이다. 그리고 우리의 코드는 바로 이 부분을 놓친 것이라 생각했다.
SmmCalloutHunter_v3
SmmCalloutHunter_v3.java 링크
추적 로직은 가볍게 작성했다. 기존 gSmst->SmiHandlerRegister에서 Smi 핸들러 함수를 찾은 것과 더불어 EFI_SMM_SW_DISPATCH2_PROTOCOL의 guid를 호출하는 곳들을 전부 찾은 뒤 XREF를 따라 올라가 함수들을 만나면 등록하는 로직을 추가했다.
그런데…
왜 잡지 못한지에 대해선 뒤에서 잘 설명해줄 것이다…
-
UEFI DXE 바이너리 취약점 분석기 프로젝트_8
코드 개선사항
이전에 짰던 gBS 탐지 스크립트는 아래와 같은 문제를 가지고 있었다.
ghidra가 SMM 런타임 함수를 찾지 못한다.
이는 entry부터 찾아들어가는 ghidra의 특성과 달리 SMI를 통해 강제로 점프하여 들어오는 곳이라 참조가 끊겨 생기는 문제로 추측한다.
부팅 중에는 gBS가 실행되어도 된다.
하지만 기존 코드는 모든 함수를 다 돌아버리면서 부팅용 함수든 런타임용 함수든 전부 다 검사해버려 오탐(False Positive)가 발생한다.
나중에 자동화 과정에서 이게 SMM 단에서 실행되는지, 일반 DXE 드라이버인지 알 수 없다.
추후 자동화 통합 과정에서 검사를 수행할 드라이버엔 SMM 드라이버도, DXE 드라이버도 모두 존재한다. 그러므로 gBS를 사용해도 되는 일반 DXE 드라이버들과 구별을 하기 위해 초반 처리 로직이 필요하다.
개선 로직
개선 로직은 다음과 같이 개선했다.
먼저 검사 로직을 수행하기 전에 프로그램 내에 gSmmBase2ProtocolGuid가 있는지 확인한다.
만약 없다면 이는 SMM 드라이버가 아닌 것으로 간주, 검사를 수행하지 않고 스크립트를 종료한다.
SMM 드라이버라면 검사 시작 전 해당 프로그램의 모든 바이너리를 순회한다.
순회한 각 코드의 P-Code를 뽑아내고, 뽑아낸 P-Code에 간접 호출(CALLIND)가 있는지 찾는다.
만약 간접 호출이 있고, 0xE0 오프셋을 더한다면 SMM 핸들러를 등록하는 것으로 간주한다.
만약 해당 함수가 등록되어있지 않으면 등록을 해준다.
이후 gBS 주소 찾기 및 Taint Analysis를 수행한다.
이 때 모든 함수를 찾던 기존 Taint Analysis가 아닌 찾아낸 핸들러들을 대상으로 한다.
SmmCalloutHunter_v2.java 링크
왜?
(굉장히 휴리스틱한 접근들로 로직을 잡고 있다…)
먼저 어떻게 함수들을 찾았는지다. SMI이 울렸을 때 실행되는 함수들은 gSmst->SmmHandlerRegister에 저장되어 있다. 이 때 이는 gSmst에서 0xE0만큼 떨어져 있다. 원래라면 추적을 두번, 세번, 네번 이어 가야 gSmst를 찾아낼 수 있지만, 일단 빠르게 결과부터 보기 위해 0xE0을 더해서 호출하는 로직만 찾아서 찾는, 일종의 찍기를 한 것이다.(……)
다음으로 런타임 함수들만 잡는 방법이였다. 바이너리 자체에는 어떤게 부팅 중 실행되는지, 어떤게 런타임에 실행되는지 알 수 없다. 하지만 부팅 중에 실행되지 않는 함수들은 entry에 연결되지 않는다는 점을 생각했다. 그러면 어디에 저장되는가. 사실 이 저장되는 위치가 바로 gSmst->SmmHandlerRegister다! 이 안에 있는 모든 CALL들을 가져와 리스트, 또는 이터레이터 형태로 저장 후 순회하는 방식으로 바꾸면 된다.
마지막으로 SMM 드라이버인지 판단하여 SMM 드라이버가 아닌 일반 DXE 드라이버라면 로직을 나가는 경우에 대한 처리다. SMM 드라이버는 gEfiSmmBase2ProtocolGuid를 코드 내에 반드시 가지고 있다. 이는 EFI 전역 환경 내에서 SMM 환경에서 제공하는 서비스들을 담은 프로토콜의 GUID로, SMM 드라이버는 gEfiSmmBase2ProtocolGuid를 통해 SmmBase2라는 프로토콜에 접근이 가능하다. 이 프로토콜에 특정 SMM 함수를 요청하면 프로토콜은 InSmm() 함수를 통해 해당 코드가 SMRAM에 들어있는지를 확인하게 된다. 이 InSmm()이 TRUE로 나오면 프로토콜은 GetSmstLocation()을 통해 gSmst의 실제 주소를 넘겨주게 되고, SMM 드라이버는 gSmst에 있는 함수를 간접 호출함으로써 SMM 코드를 실행하게 된다.
이 때 gSmst의 위치를 찾는 것이 아닌 gEfiSmmBase2ProtocolGuid를 찾는 이유는 다음과 같다. gSmst는 펌웨어가 실행된 뒤 주소가 결정이 되는, 즉 매번 주소값이 바뀌는 값이다. 이 값을 찾기 위해선 스크립트를 실행할 때 마다 코드를 역추적해가며 값을 찾아야 한다. 하지만 gEfiSmmBase2ProtocolGuid는 전 세계 모든 펌웨어가 공통적으로 가지는 고유값이므로 코드를 디컴파일할 필요도 없이 프로그램 내에 해당 GUID가 존재하는지 아닌지만 찾으면 된다.
수정 후 각 함수별 코드 요약
함수명
역할
trackSystemTable()
Def-Use Chain을 타면서 gBS를 찾는 함수
checkIfStoredToGlobal()
Def-Use Chain을 타다가 전역 변수로 넘어가는지 여부를 추적하는 함수
trackValueToMemory()
메모리에서 읽은 값(LOAD)이 gBS 전역 변수로 이어지는지 확인하는 함수
scanForSmmCallouts()
찾은 SMI 핸들러들의 주소 리스트를 받아 Call-Graph를 수집하여 gBS를 호출하는지 파악한 뒤 JSON으로 결과를 출력하는 함수
buildRuntimeCallGraph()
핸들러들이 호출하는 모든 런타임 함수들을 수집하는 함수
isTaintedByGBS()
인자로 받은 node에서부터 역추적을 해나가며 끝이 gBS인지 확인하는 함수
findAndCreateSmiHandlers()
코드 전체를 순회하며 SMI 핸들러를 찾고, 발견 즉시 기드라에 함수로 등록하는 함수
hasOffsetE0()
0xE0을 더하는 연산이 존재하는지 확인하는 함수
getParameterVarnode()
특정 함수의 몇 번째 인자가 가지는 노드를 반환하는 함수
saveJsonToFile()
결과를 JSON으로 변환하는 함수
saveJsonToFileIfError()
오류가 발생한 분기의 에러를 JSON으로 반환하는 함수
아마 다음 주? 이번 주? 부터는 지금까지 너무 코드 짜는 것에 치중을 해서 짰던 것 같아서 잠시 이론적인 정리를 해두고, 취약점 코드를 만들고 검증하는 팀원들을 조금 도와줘야 할 것 같다.
-
UEFI DXE 바이너리 취약점 분석기 프로젝트_7
GCM쪽 취약점 추정부 자세히 분석하기
저번에 찾았던 GCM 암호화 과정에서 진짜 취약점이 발생할 수 있는지를 좀 더 분석해봤다.
해당 부분이 현재 의심을 하고 있는 코드이다. 이때 FUN_003d71 옆 XREF를 보면 총 4군데에서 해당 함수를 호출하는 것을 볼 수 있다. 우선 제일 처음 호출하는 부분을 보았다.
(이 부분부터 각 부분이 어떤 함수인지 이해하는 과정에서 애를 좀 먹었다..)
OpenSSL의 구조는 다음과 같다.
최상위에 OpenSSL의 초기화 및 세팅을 담당하는 함수가 존재한다. 해당 함수는 시스템 부팅 시점에 호출되어 대칭 키 알고리즘, 비대칭 키 알고리즘, 해싱 알고리즘 등 큰 범주들의 알고리즘 단위로 등록을 하는 함수를 호출한다.
각 알고리즘 단위로 등록하는 함수들은 부모 함수의 지시를 받아 라이브러리가 가진 각 알고리즘에 해당하는 알고리즘들의 구조체 주소를 가져와 해시 테이블에 매핑을 한다.
각 알고리즘들의 구조체에는 해당 알고리즘의 초기화 함수, 실행 함수, 복호화 함수 등의 포인터 주소들이 들어있다.
이 함수들은 각 알고리즘을 실제 수행하는 파이프라인을 실행하는 함수다.
그 안에 실제 연산을 수행하는 함수들이 존재한다.
이러한 구조는 OpenSSL 라이브러리 내의 수많은 암호화 API들을 하나로 묶어주기 위한 구조로써, 이때 이런 구조를 EVP(EnVeloPe)라고 한다. 내용은 이 블로그 및 OpenSSL 깃허브에서 참고하였다.
다시 본론으로 돌아가면 우린 거꾸로 올라가고 있는 중이므로 의심을 한 부분이 실제 연산을 수행하는 함수임을, 방금 본 함수는 해당 함수를 호출하여 파이프라인을 수행하는 상태 머신 정도로 볼 수 있을 것이다. 그리고 예상이 맞다면 해당 함수를 호출하는 부분은 구조체가 될 것이다. 해당 함수를 호출하는 첫 번째 부분으로 들어가보자.
역시나 구조체가 있는 것을 확인할 수 있었다. 초기 의심을 하던 함수들 역시 해당 구조체로 이어져 있었다. 그리고 해당 구조체를 사용하는 함수는 하나였고,
해당 함수는 OpenSSL 초기화 과정에서 큰 분류의 알고리즘 내에 속하는 알고리즘들을 해싱 테이블에 등록하는 함수였다. 이 함수를 호출하는 부분 역시 하나였으며,
그 함수는 바로 OpenSSL의 초기화 함수였다.
초기화 함수의 디컴파일된 코드와 edk2 안에 있는 OpenSSL의 init.c 코드와 비교해보면 동일한 형태를 띄고 있는 것을 확인할 수 있었다.
구조 파악은 얼추 끝났고, 이제 남은 건 그래서 실제로 해당 부분에 접근이 가능한가일 것이다. 여기서 나올 수 있는 경우는 세 가지라고 생각하였다.
SMI 핸들러에서 함수 포인터가 담긴 구조체를 호출하는 뭔가가 있다.
SMI 핸들러가 해당 상태 머신으로 데이터를 보내는 무언가가 있다.
SMI 핸들러가 런타임에 OpenSSL의 초기화 루틴 함수를 호출한다.
여기서 1번, 2번은 위에서 확인한 바와 같이 통하는 길이 초기화 루틴 함수 외에는 존재하지 않았다. 그리고 3번의 경우 설령 초기화 함수를 실행한다 하더라도 해당 초기화 함수는 파싱 테이블에 함수 주소들을 등록만 하는 역할을 수행할 뿐 실제 해당 포인터를 실행시키는 부분은 존재하지 않았다. 이에 따라 지금까지의 결론을 아래와 같이 내렸다.
2021년 2월에 릴리즈된 edk2의 VariableSmm 내에서 OpenSSL의 GCM 암호화 부분에서 SMM Callout 취약점이 발생할 수 있는 바이너리가 존재하였다. 하지만 런타임에 SMI 핸들러 중 해당 알고리즘을 사용하기 위해 호출을 하는 부분은 보이지 않았고, OpenSSL의 초기화 함수가 트리거될 수는 있어 보이나 이는 테이블에 등록만 하는 역할을 수행하고 실제 해당 암호화 로직을 실행하지는 않았다. 결론적으로 SMM Callout 취약점을 일으킬 수 있는 로직은 물리적으로 존재해 보이나 런타임에 이를 악용하기 위해 접근할 수 있는 지점이 존재하지 않는, 즉 죽은 코드(Dead Code)로 추측하며, 이의 원인으로 OpenSSL의 암호화 API들을 하나로 묶어주기 위한 구조인 EVP에서 기인된 문제라 생각한다. 하지만 제조사 내부자, 또는 펌웨어 공급망을 가진 누군가가 핸들러 내부에 악의적으로 해당 부분을 호출하는 코드를 작성한다면 위험할 수도 있지 않을까…생각된다.
특권 레벨(Privilege Level) 다시 살펴보기
참고 1
참고 2
자료를 찾아보기도 하고 Ring -2 단계인 SMM 쪽을 파다 보니 특권 레벨 링 간의 규칙(?)을 정리해두는게 좋을 것 같아 서술한다.
특권 레벨에 대해 지난 번보다 좀 더 자세하게 적어보려 한다. 먼저 특권 레벨을 소유하는 주체이다. 특권 레벨을 소유하는 주체는 고정된 어떤 프로그램이 아닌 해당 코드를 실행하는 CPU와 메모리가 주체가 된다. CPU 내의 해당 CPU의 상태를 기록하는 레지스터 중 CPL(Current Privilege Level)이라는 값이 기록이 된다. 이 때 CPU가 실행하는 instruction이 Ring 3이라면 CPL == 3, Ring 0이라면 CPL == 0이 되는 것이다. 그리고 CPU가 접근하려는 대상은 결국 메모리가 되는데, 이 메모리 내의 세그먼트, 또는 페이지마다 DPL(Descriptor Privilege Level)이란 값이 존재한다. 이는 해당 공간이 해당 특권 레벨까지 접근이 가능하다라는 정보를 알려주는 것으로, 명령이 들어오면 CPL과 DPL을 비교하여 접근 가능한 특권 레벨인지 비교하여 접근 가능한지를 판단한다. 그리고 만약 User Mode에서 커널에 부탁함으로써 Ring 3이 Ring 0에 해당하는 메모리를 읽으면서 발생하는 문제를 막기 위해 RPL(Requested Previlege Level)을 도입하였다. 이는 부탁을 한 주체의 특권 레벨로써, DPL과 RPL, CPL을 모두 비교하여 접근 가능 여부를 결정한다.
또한 특권 레벨 링 간에도 규칙이 존재했다.
호출
기본적으로 낮은 권한을 가진 코드는 높은 권한을 가진 함수(또는 메모리 주소)를 절대 CALL 또는 JMP할 수 없다. 만약 호출하고 싶다면 다음 방법을 사용해야 한다.
Ring 3 -> Ring 0 : SYSCALL, SYSENTER, 또는 interrupt
Ring 0 -> Ring -1 : VMCALL
Ring 0 -> Ring -2 : SMI 신호
접근
상위 권한을 가진 레벨은 자기보다 낮은 레벨의 메모리 공간을 마음대로 읽고 쓸 수 있다. 예를 들어 Ring 0은 Ring 3의 모든 공간을 접근할 수 있으며, 지금 하고 있는 SMM 역시 Ring 0 ~ Ring 3 까지의 모든 공간에 접근할 수 있으며, 반대는 불가능하다.
실행
상위 권한은 하위 권한 메모리를 읽을 순 있지만, 하위 권한의 코드를 실행할 순 없다. 이는 낮은 영역에 악성 코드를 심고, 상위 권한에서 실행하도록 시킴으로써 상위 권한으로 코드를 실행시키는 것을 막게 하기 위함이다.
gBS와 SMM과의 관계
UEFI 펌웨어는 gBS(global Boot Service)와 gRT(global Runtime Service) 로 크게 두 가지 서비스로 나눌 수 있다고 했다. gBS는 부팅 단계에서만 사용되고 부팅이 끝나면 OS가 해당 서비스가 있던 공간을 마음대로 사용할 수 있다. gRT는 시간 읽기, 변수 쓰기 등 컴퓨터 생애주기 전 과정에서 사용되는 서비스들이다.
OS가 메모리에 다 올라가고 나면 OS가 ExitBootServices()라는 함수를 호출한다. 이 순간부터 gBS가 점유하던 메모리 공간은 OS가 사용할 수 있게 된다. 즉, 해당 공간은 Ring 0, 또는 그보다 더 낮은 권한을 가지는 것이다. 여기서 문제가 발생하는데, SMM 핸들러(SMI)는 부팅이 끝난 뒤에도 컴퓨터 내에서 역할을 수행하는데, 이에 따라 당연히 SMM은 gBS를 호출하면 안되는 것이다. 하지만 개발자들이 무심코 편의를 위해 gBS를 사용하는 경우가 가끔 있는데, 이 코드가 런타임에 실행된다면 SMM은 값이 변경된 gBS가 가졌던 메모리 주소에 CALL을 날리게 되는 것이다.
gBS는 SystemTable 내에 위치하며, X64 기준 0x60(10진수 96)만큼 떨어져 있다.
typedef struct {
///
/// The table header for the EFI System Table.
///
EFI_TABLE_HEADER Hdr;
///
/// A pointer to a null terminated string that identifies the vendor
/// that produces the system firmware for the platform.
///
CHAR16 *FirmwareVendor;
///
/// A firmware vendor specific value that identifies the revision
/// of the system firmware for the platform.
///
UINT32 FirmwareRevision;
///
/// The handle for the active console input device. This handle must support
/// EFI_SIMPLE_TEXT_INPUT_PROTOCOL and EFI_SIMPLE_TEXT_INPUT_EX_PROTOCOL.
///
EFI_HANDLE ConsoleInHandle;
///
/// A pointer to the EFI_SIMPLE_TEXT_INPUT_PROTOCOL interface that is
/// associated with ConsoleInHandle.
///
EFI_SIMPLE_TEXT_INPUT_PROTOCOL *ConIn;
///
/// The handle for the active console output device.
///
EFI_HANDLE ConsoleOutHandle;
///
/// A pointer to the EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL interface
/// that is associated with ConsoleOutHandle.
///
EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *ConOut;
///
/// The handle for the active standard error console device.
/// This handle must support the EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL.
///
EFI_HANDLE StandardErrorHandle;
///
/// A pointer to the EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL interface
/// that is associated with StandardErrorHandle.
///
EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *StdErr;
///
/// A pointer to the EFI Runtime Services Table.
///
EFI_RUNTIME_SERVICES *RuntimeServices;
///
/// A pointer to the EFI Boot Services Table.
///
EFI_BOOT_SERVICES *BootServices;
///
/// The number of system configuration tables in the buffer ConfigurationTable.
///
UINTN NumberOfTableEntries;
///
/// A pointer to the system configuration tables.
/// The number of entries in the table is NumberOfTableEntries.
///
EFI_CONFIGURATION_TABLE *ConfigurationTable;
} EFI_SYSTEM_TABLE;
해당 위치는 MdePkg/Include/Uefi/UefiSpec.h에 있음을 볼 수 있다.
취약점 탐색 로직 계획
취약점 탐색 방법은 다음과 같이 계획했다.
Reaching Definition을 통해 SystemTable이 어디까지 도달하는지를 분석한다.
이 때 SystemTable은 entry의 두 번째 인자를 가지고 찾는다.
또한 reaching Definition은 P-Code를 통해 순회하며, 이는 별도 로직 작성 필요 없이 Ghidra가 제공하는 getDescendants() 함수를 사용하면 조금 더 편하게 분석할 수 있다.
(단, getDescedants() 함수는 해당 함수 스코프 내에서만 유효하며, 만약 CALL 등을 통해 다른 함수로 넘어갔다면 해당 함수로 넘어가서 다시 재귀적으로 확인해야 한다. 또한 정확히 말하면 getDescendants() 함수가 Reaching Definition을 구현하는 함수는 아니지만, Ghidra가 자체적으로 SSA 형태로 저장함으로써 각 use가 유일한 def에 연결되므로 해당 함수를 통해 Reaching Definition을 나타낸다 볼 수 있다.)
Reaching Definition을 통해 SystemTable이 gBS까지 도달하는지, 그리고 gBS가 어디에 저장되는지 등의 일종의 Def-Use Chain을 얻어낸다.
SMM이 런타임에 CALL을 호출할 경우, 이 Use가 Def, 즉 gBS에 도달하는지를 보는 Use-Def Chain을 수행한다.
이 과정에서 Taint Analysis를 수행한다. 이 때는 getDef() 함수를 사용하면 조금 더 편하게 분석할 수 있다.
(참고) Def-Use Chain과 Use-Def Chain
Def-Use Chain과 Use-Def Chain은 모두 Data Flow 표현으로써, 값이 어떻게 흘러가는지를 어떤 관점으로 보느냐에 대한 차이 정도로 해석할 수 있을 것이다. 둘은 모두 어떤 변수를 사용한다면 그 변수는 정의되어 있을 것이라는 사실을 기반으로 사용된다. Def는 “정의”로써, 변수에 값이 할당되는 지점을, Use는 “사용”으로써 해당 변수가 읽히는 지점을 나타낸다. Def-Use Chain은 어떤 정의가 어디에서 사용이 되는가?를 연결한 것으로, 정의 생성 지점에서 순방향으로 내려가면서 파악하는 것이다. 즉 정의가 영향을 미치는 범위를 알고 싶을 때 사용한다. Use-Def Chain은 반대로 특정 사용이 어떤 정의에서 온 것인가?를 연결한 것으로, 사용 지점에서 역방향으로 올라가며 파악하는 것이다. 사용 값의 출처를 알고 싶을 때 사용된다.
Ghidra 유용 함수 정리
함수
인자
반환타입
설명
DecompInterface.openProgram()
program
boolean
현재 분석할 프로그램을 디컴파일러에 연결하는 초기화 함수
Program.getSymbolTable()
X
SymbolTable
해당 프로그램에 속한 심볼 테이블 객체를 반환하는 함수
SymbolTable.getExternalEntryPointIterator()
X
AddressIterator
EntryPoint 주소만 순회하는 이터레이터를 반환하는 함수
getFunctionAt()
Address
Function
해당 주소에서 시작하는 함수 객체를 가져오는 함수
Varnode.getDescendants()
X
Iterator<PcodeOp>
해당 Varnode 값을 입력으로 사용하는 모든 P-Code를 가져오는 함수
PcodeOp.getOpcode()
X
int
해당 P-Code의 Opcode를 반환하는 함수
PcodeOp.getOutput()
X
Varnode
P-Code가 생성하는 결과값 Varnode를 반환하는 함수
PcodeOp.getNumInputs()
X
int
해당 P-Code의 피연산자의 개수
Program.getAddressFactory()
X
AddressFactory
해당 프로그램의 모든 주소 공간을 가져오는 함수
AddressFactory.getDefaultAddressSpace()
X
AddressSpace
기본 주소 공간을 반환하는 함수
AddressSpace.getAddress()
long
Address
Offset 숫자를 Address 객체로 반환하는 함수
Varnode.getOffset()
X
long
해당 Varnode가 가리키는 Offset 값을 반환하는 함수
DecompInterface.decompileFunction()
Function, int, TaskMonitor
DecomefileResults
Function을 디컴파일한 결과를 얻는 함수
Program.getFunctionManager()
X
FunctionManager()
프로그램 함수 목록을 관리하는 객체인 FunctionManager를 반환하는 함수
getFunction()
boolean
FunctionIterator
프로그램을 순회 가능한 Iterator로 반환. true면 오름차순, false면 내림차순. 기본은 true
DecompileResults.getHighFunction()
X
HighFunction
디컴파일을 통해 생성된 IR을 가져오는 함수
HighFunction.getPcodeOps()
X
Iterator<PcodeOpAST>
P-Code를 순회하기 위한 Iterator를 반환하는 함수
Varnode.getDef()
X
PcodeOp
해당 Varnode를 정의한 P-Code 연산을 반환하는 함수
탐지 스크립트 구현
이를 기반으로 위 로직을 가지고 프로토타입 형태의 코드를 짰다.
다운로드 링크
(코드가 너무 길어 다운로드 링크로 대체한다.)
(기본적인 Ghidra 스크립트 문법 및 구성은 여기를 참고하였다.)
VariableSmm.efi 파일에 스크립트를 돌려본 결과 gBS 주소를 잘 찾아준 것을 확인할 수 있었고 여기서는 취약점이 발생하지 않는다 감지하였다.
다음 주에는 해당 코드를 가지고 실제 취약한 임시 드라이버를 하나 만들고 빌드한 후, 넣었을 때 감지를 잘 하는지를 확인해보고, LLM의 도움을 일부 받아 작성한 코드기도 하고 아직 검증을 하지 않은 코드인 만큼 해당 스크립트를 리팩토링 및 더 나은 아이디어가 있다면 수정을 해볼 예정이다.
-
UEFI DXE 바이너리 취약점 분석기 프로젝트_6
프로젝트 최종 분배
프로젝트 최종 인원 분배는 다음과 같이 결정했다.
Ring -2 취약점 분석 및 스크립트 작성, 최종 문서화
DXE Dispatcher 취약점 분석 및 스크립트 작성
Ring 0 취약점 분석 및 스크립트 작성
취약점 코드 생성 및 테스트
스크립트 자동화 및 최종 문서화
이렇게 나눴고 난 1번 역할을 담당하기로 하였다.
Intellij Ghidra 설정 방법
스크립트를 담당할 조원들끼리는 Ghidra가 Java로 쓰인 만큼 Java로 스크립트를 작성하기로 이야기를 나눴다. 사실 Jython으로 스크립트를 짜다가 Java로 넘어가는 바람에 설정에서 삽질을 조금 했다…
설정은 다음과 같다.
Intellij 설치 및 프로젝트 생성
파일 -> 프로젝트 구조 -> 모듈
+ 클릭 -> JAR 또는 디렉토리
아래 디렉토리 내부 JAR 파일들을 전부 임포트하기
(Mac Homebrew 설치 기준(opt/homebrew/Cellar/Ghidra/12.02/libexec/Ghidra)를 기준으로 한다.)
(아마 윈도우 역시 비슷한 경로에 있을 것으로 생각된다…)
(본인이 일단 다 받고 본 부분도 없지 않아 있어 어떤건 필요하지 않을 수도 있고, 더 필요한게 있을 수도 있다.)
Features/Base/lib
Framework/SoftwareModeling/lib
Framework/Generic/lib
Framework/Docking/lib
Framework/Project/lib
Features/Decompiler/lib
Framework/Utility/lib
위와 같이 잘 진행했다면
위 이미지와 같이 Ghidra 문법에 자동완성이 잘 잡힌다!
간단한 탐지 스크립트 작성
@Override
public void run() throws Exception {
println("=== SMM Indirect Call 검증기 (Safety Check 탐지) 시작 ===");
InstructionIterator instructions = currentProgram.getListing().getInstructions(true);
int suspiciousCount = 0;
int highRiskCount = 0;
while (instructions.hasNext()) {
if (monitor.isCancelled()) break;
Instruction instr = instructions.next();
// 1. CALL 명령어 찾기
if (!instr.getMnemonicString().equalsIgnoreCase("CALL")) continue;
// 2. 오퍼랜드 분석 (RAX, RBX 등 레지스터 호출인지?)
String opString = instr.getDefaultOperandRepresentation(0);
// "0x..." 고정 주소 호출이나 "[...]" 메모리 참조는 일단 패스 (복잡하니까)
// 오직 "CALL RAX", "CALL R12" 같은 순수 레지스터 호출만 집중 타격!
boolean isRegisterCall = !opString.contains("[") && !opString.startsWith("0x");
if (isRegisterCall) {
// 호출에 사용된 레지스터 가져오기 (예: RAX)
Register callReg = instr.getRegister(0);
if (callReg != null) {
//핵심: 뒤로 15줄 검색해서 CMP나 TEST가 있는지 확인!
boolean isChecked = hasSafetyCheck(instr, callReg, 15);
if (!isChecked) {
println(String.format("🚨 [초위험/No-Check] 주소: %s | 코드: %s | (검증 로직 발견 못함)",
instr.getAddress(), instr.toString()));
highRiskCount++;
} else {
println(String.format("✅ [안전 추정] 주소: %s | 코드: %s | (검증 로직 있음)",
instr.getAddress(), instr.toString()));
}
suspiciousCount++;
}
}
}
println("==========================================");
println("분석 완료: 총 " + suspiciousCount + "개 중 " + highRiskCount + "개가 '검증 없는 위험 호출'로 보입니다.");
}
// 뒤로 걸어가면서 검사 로직(CMP, TEST) 찾는 함수
private boolean hasSafetyCheck(Instruction startInstr, Register targetReg, int maxSteps) {
Instruction current = startInstr.getPrevious(); // 바로 윗줄부터 시작
int steps = 0;
while (current != null && steps < maxSteps) {
String mnemonic = current.getMnemonicString();
// 1. 비교(CMP)나 테스트(TEST) 명령어를 찾음
if (mnemonic.equalsIgnoreCase("CMP") || mnemonic.equalsIgnoreCase("TEST")) {
// 2. 그 명령어가 우리가 의심하는 레지스터(targetReg)를 쓰는지 확인
Object[] opObjects = current.getOpObjects(0); // 첫 번째 오퍼랜드
for (Object op : opObjects) {
if (op instanceof Register && ((Register) op).equals(targetReg)) {
return true; // 안전장치 발견!
}
}
}
// 만약 레지스터 값이 여기서 덮어씌워졌다면(MOV RAX, ...), 그 위는 볼 필요 없음 (추적 끊김)
if (mnemonic.equalsIgnoreCase("MOV") || mnemonic.equalsIgnoreCase("LEA")) {
Object[] resultObjects = current.getResultObjects();
for (Object res : resultObjects) {
if (res instanceof Register && ((Register) res).equals(targetReg)) {
return false; // 값을 막 대입하고 바로 호출함 -> 위험!
}
}
}
current = current.getPrevious(); // 한 줄 더 위로
steps++;
}
return false; // 끝까지 검사 로직 못 찾음
}
일단 감이라도 좀 잡아보기 위해 제미나이와 함께 스크립트를 하나 짜봤다. 코드 아이디어는 일단 P-Code 없이 매우 단순한 형태로, instruction 중 CALL을, 그 중에서도 레지스터를 호출하는 부분을 발견한다면 해당 instruction 위로 15개 instruction을 확인해서, 그 중 TEST나 CMP가 있는지 확인을 하고 검사를 하는 부분이 없다면 안전하지 않다고 판단을 하거나, 또는 그 사이에 해당 레지스터 값이 덮어씌워지는지 여부를 확인한다.
대상은 edk2-stable202102버전을 빌드한 뒤 OVMF.md 파일을 만들었고, 그 중에서 VariableSmm DXE 드라이버를 UEFITool로 추출한 다음 Ghidra로 켜서 해당 스크립트를 작성하였다.
그런데..
여기부터는 제 개인적인 생각과 추측들을 작성했습니다! 또한 만약 추측이 잘못되었다고 생각하시면 언제든 이야기해주시면 감사하겠습니다. 또한 자료를 찾고 추측하는 과정에서 수시로 글을 수정해서 내용이 중구난방일 수 있습니다…ㅠㅠ
일단 아래 내용을 설명하기 전에 SMM Callout이 뭔지, VariableSmm이 어떤 역할을 수행하는지 짚고 넘어가보자. SMM Callout 취약점은 SMM 코드가 SMRAM 밖의 영역에 있는 코드를 실행하며 발생하는 취약점이다. 해커가 악의적 payload를 메모리에 심고, SMM에게 해당 코드를 실행하도록 했을 때, SMM이 해당 코드를 검증 없이 실행한다면, 공격자는 SMM의 높은 권한을 이용해 시스템을 완전히 장악할 수 있다. VariableSmm은 UEFI 펌웨어에서 시스템 변수 관리를 담당하는 드라이버로, SMM 모드에서 실행되며 시스템 변수에 대한 접근과 관리를 수행한다. 이 드라이버가 SMM Callout 취약점에 노출된다면, 공격자는 VariableSmm이 호출하는 외부 코드를 악용하여 SMM 권한으로 악성 코드를 실행할 수 있다.
Ghidra 스크립트와 친해지기 위해 일단 제미나이와 함께 짠 위 스크립트를 실행하였다.
스크립트를 돌린 뒤의 결과다. 결과 중 유독 CALL R13에서 반복적으로 위험 로직이 잡힌 것이 이상하게 보여서 해당 주소를 타고 들어가보았다.
여기서 CALL R13 코드에서 8칸 위로 가보면 MOV R13, qword ptr [param_1 + 0x28] 을 통해 R13에 첫 번째 인자의 일부 값이 담기고, 이후로 TEST나 CMP 등 별도 로직 없이 그대로 CALL이 이뤄진 것을 볼 수 있다! 일단 우리는 오픈소스로 공개되어 있는 edk2를 분석하고 있으므로 ghidra가 만들어준 디컴파일 코드를 가지고 제미나이와 함께 해당 코드가 edk2의 어떤 코드인지 찾아보았고, 해당 코드가 암호화 코드인 CryptoPkg/Library/OpensslLib/openssl/crypto/modes/gcm128.c라 추측하였다.
int CRYPTO_gcm128_encrypt(GCM128_CONTEXT *ctx,
const unsigned char *in, unsigned char *out,
size_t len)
{
const union {
long one;
char little;
} is_endian = { 1 };
unsigned int n, ctr, mres;
size_t i;
u64 mlen = ctx->len.u[1];
block128_f block = ctx->block; //여기!!
void *key = ctx->key;
...
(*block) (ctx->Yi.c, ctx->EKi.c, key); //펑!!!!
...
}
해당 부분이 문제의 부분으로, block128_f block = ctx->block; 코드에서 block 변수에 ctx->block 값을 별도 검사 로직 없이 가져오고, 이후 별도 검사 로직 없이 (*block) (ctx->Yi.c, ctx->EKi.c, key);를 통해 실행을 하게 된다고 추측하였다.
해당 코드가 VariableSmm에 왜 들어있는지 생각해보았는데, 우선 OpenSSL은 오픈소스 보안 라이브러리로, 다양한 암호화 알고리즘, 프로토콜 등을 제공하고 있다. 그리고 VariableSmm은 시스템 변수를 관리하는 드라이버로써, 보안과 밀접하게 관련된 변수, 예를 들어 Secure Boot 키 등을 다루게 된다. 이 과정에서 안전한 데이터 저장을 위해 암호화를 사용한다고 생각하였다. 이 때 위 OpenSSL 라이브러리 코드를 일반적인 상황에서 사용한다면 상관없지만 VariableSmm은 Ring -2 단계에서 실행되므로 공격자가 악의적으로 조작된 payload를 통해 ctx->block에 악성 코드를 심을 수 있다면, 해당 코드가 검증 없이 실행될 수 있기 때문에 SMM Callout 취약점이 발생할 수 있다고 생각하였다.
하지만 결론부터 말하면 해당 부분이 취약점이 되진 않을 것이라 생각한다.
When the attribute EFI_VARIABLE_AUTHENTICATED_WRITE_ACCESS is set, but the EFI_VARIABLE_TIME_BASED_AUTHENTICATED_WRITE_ACCESS is not set (i.e. when the EFI_VARIABLE_AUTHENTICATION descriptor s used), then the* Data buffer shall begin with an instance of the authentication descriptor AuthInfo prior to the data payload and DataSize should reflect the data and descriptor size. The authentication descriptor is not part of the variable data and is not returned by the subsequent calls to GetVariable. The caller shall digest the Monotonic Count value and the associated data for the variable update using the SHA-256 1-way hash algorithm. The ensuing the 32-byte digest will be signed using the private key associated w/ the public 2048-bit RSA key PublicKey described in the EFI_CERT_BLOCK_RSA_2048_SHA256 structure.
UEFI Specification 2.10 Variable Authentication
위 글은 UEFI.org의 Variable Authentication 부분 내용 중 일부로, 공개 키 방식인 RSA, 그리고 해시(SHA)를 사용하도록 명세하고 있다. 즉 비밀 키 방식인 GCM은 표준이 아닌 것이다. 따라서 찾았던 코드는 아마 링커가 SHA 및 RSA를 가져오기 위해 OpensslLib 라이브러리를 가져오는 과정에서 딸려들어온 코드가 아닐까 생각하고 있다.
아마 다음으로는 이번 스크립트를 조금 더 발전시켜 보거나 SMM 실행 중 gBS(global Boot Service) 실행 여부를 찾는 로직 쪽으로 가볼지를 고민해볼 것 같다.
-
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