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의 도움을 일부 받아 작성한 코드기도 하고 아직 검증을 하지 않은 코드인 만큼 해당 스크립트를 리팩토링 및 더 나은 아이디어가 있다면 수정을 해볼 예정이다.