Home > 졸업프로젝트 > UEFI DXE 바이너리 취약점 분석기 프로젝트_6

UEFI DXE 바이너리 취약점 분석기 프로젝트_6
UEFI Ghidra

프로젝트 최종 분배

 프로젝트 최종 인원 분배는 다음과 같이 결정했다.

  1. Ring -2 취약점 분석 및 스크립트 작성, 최종 문서화
  2. DXE Dispatcher 취약점 분석 및 스크립트 작성
  3. Ring 0 취약점 분석 및 스크립트 작성
  4. 취약점 코드 생성 및 테스트
  5. 스크립트 자동화 및 최종 문서화

이렇게 나눴고 난 1번 역할을 담당하기로 하였다.

Intellij Ghidra 설정 방법

 스크립트를 담당할 조원들끼리는 Ghidra가 Java로 쓰인 만큼 Java로 스크립트를 작성하기로 이야기를 나눴다. 사실 Jython으로 스크립트를 짜다가 Java로 넘어가는 바람에 설정에서 삽질을 조금 했다…
설정은 다음과 같다.

  1. Intellij 설치 및 프로젝트 생성
  2. 파일 -> 프로젝트 구조 -> 모듈
  3. + 클릭 -> JAR 또는 디렉토리
  4. 아래 디렉토리 내부 JAR 파일들을 전부 임포트하기
    (Mac Homebrew 설치 기준(opt/homebrew/Cellar/Ghidra/12.02/libexec/Ghidra)를 기준으로 한다.)
    (아마 윈도우 역시 비슷한 경로에 있을 것으로 생각된다…)
    (본인이 일단 다 받고 본 부분도 없지 않아 있어 어떤건 필요하지 않을 수도 있고, 더 필요한게 있을 수도 있다.)
    1. Features/Base/lib
    2. Framework/SoftwareModeling/lib
    3. Framework/Generic/lib
    4. Framework/Docking/lib
    5. Framework/Project/lib
    6. Features/Decompiler/lib
    7. 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) 실행 여부를 찾는 로직 쪽으로 가볼지를 고민해볼 것 같다.