Elastic Security Labs

이상하게 변형된 인증코드 서명 조사하기

잘못된 인증코드 서명 뒤에 숨겨진 휴리스틱 발견하기

16분 읽기보안 운영
이상하게 변형된 인증코드 서명 조사하기

서문

Elastic 보안 연구소는 최근 Windows 바이너리 중 하나에서 서명 유효성 검사 문제를 발견했습니다. 실행 파일은 표준 CI(지속적 통합) 프로세스의 일부로 signtool.exe 을 사용하여 서명되었지만, 이 경우 출력 파일에 다음 오류 메시지와 함께 서명 유효성 검사에 실패했습니다:

개체의 디지털 서명이 잘못되었습니다. 기술적인 자세한 내용은 보안 게시판 MS13-098을 참조하세요.

MS13-098에 대한 문서는 모호하지만 잘못된 Authenticode 서명과 관련된 잠재적 취약성을 설명합니다. 저희 측에서는 이 새로운 오류를 설명할 만한 명백한 변경 사항이 없었기 때문에 원인을 조사하고 문제를 해결해야 했습니다.

이 문제는 서명된 Windows 바이너리 중 하나에 영향을 미치는 것으로 확인되었지만, 모든 바이너리에 영향을 미칠 수 있습니다. 향후 동일한 문제에 직면할 수 있는 다른 사람들을 위해 이 연구 결과를 공개합니다.

진단

추가 조사를 위해 문제가 있는 실행 파일에 대해 Windows WinVerifyTrust 함수를 호출하는 기본 테스트 프로그램을 만들어 서명의 유효성을 수동으로 확인했습니다. 그 결과 오류 코드 TRUST_E_MALFORMED_SIGNATURE 와 함께 실패한 것으로 나타났습니다.

WinVerifyTrust 는 복잡한 함수이지만 디버거를 연결한 후 다음과 같은 지점에서 오류 코드가 설정되고 있음을 발견했습니다:

dwReserved1 = psSipSubjectInfo->dwReserved1;
if(!dwReserved1)
    goto LABEL_58;
v40 = I_GetRelaxedMarkerCheckFlags(a1, v22, (unsigned int *)&pvData);
if(v40 < 0)
    break;
if(!pvData)
    v42 = 0x80096011;    // TRUST_E_MALFORMED_SIGNATURE

위와 같이 psSipSubjectInfo->dwReserved10 이 아닌 경우 코드는 I_GetRelaxedMarkerCheckFlags 를 호출합니다. 이 함수가 데이터를 반환하지 않으면 코드가 TRUST_E_MALFORMED_SIGNATURE 오류를 설정하고 종료합니다.

문제가 있는 바이너리로 코드를 살펴보니 dwReserved1 가 실제로 1 로 설정되어 있는 것을 확인했습니다 . 올바르게 서명된 바이너리에 대해 동일한 테스트를 실행하면 이 값은 항상 0 이며, I_GetRelaxedMarkerCheckFlags 으로의 호출을 건너뜁니다.

I_GetRelaxedMarkerCheckFlags 을 살펴보면 특정 속성의 존재 여부를 간단히 확인하는 것을 확인할 수 있습니다: 1.3.6.1.4.1.311.2.6.1. 온라인 검색을 통해 이 객체 식별자(OID)가 SpcRelaxedPEMarkerCheck 로 표시되어 있다는 사실 외에는 별다른 정보를 찾을 수 없었습니다.

__int64 __fastcall I_GetRelaxedMarkerCheckFlags(struct _CRYPT_PROVIDER_DATA *a1, DWORD a2, unsigned int *a3)
{
    unsigned int v4; // ebx
    CRYPT_PROVIDER_SGNR *ProvSignerFromChain; // rax
    PCRYPT_ATTRIBUTE Attribute; // rax
    signed int LastError; // eax
    DWORD pcbStructInfo; // [rsp+60h] [rbp+18h] BYREF

    pcbStructInfo = 4;
    v4 = 0;
    *a3 = 0;
    ProvSignerFromChain = WTHelperGetProvSignerFromChain(a1, a2, 0, 0);
    if(ProvSignerFromChain)
    {
        Attribute = CertFindAttribute(
            "1.3.6.1.4.1.311.2.6.1",
            ProvSignerFromChain->psSigner->AuthAttrs.cAttr,
            ProvSignerFromChain->psSigner->AuthAttrs.rgAttr);
        if(Attribute)
        {
            if(!CryptDecodeObject(
                a1->dwEncoding,
                (LPCSTR)0x1B,
                Attribute->rgValue->pbData,
                Attribute->rgValue->cbData,
                0,
                a3,
                &pcbStructInfo))
            {
                return HRESULT_FROM_WIN32(GetLastError());
            }
        }
    }

    return v4;
}

바이너리에는 이 속성이 없었기 때문에 함수가 데이터를 반환하지 않고 오류가 발생했습니다. 함수 이름은 이전에 signtool.exe 에서 보았던 선택적 매개변수를 떠올리게 했습니다:

/rmc - 완화된 마커 검사 시맨틱으로 PE 파일에 서명하도록 지정합니다. PE가 아닌 파일에는 플래그가 무시됩니다. 확인 중에 서명의 특정 인증된 섹션은 유효하지 않은 PE 마커 검사를 건너뛰게 됩니다. 이 옵션은 취약점이 도입되지 않도록 MSRC 사례 MS12-024의 세부 사항을 신중하게 고려하고 검토한 후에만 사용해야 합니다.

분석 결과, '완화된 마커 검사' 플래그(/rmc)를 사용하여 실행 파일에 다시 서명하는 것이 의심되었고, 예상대로 서명이 유효했습니다.

근본 원인 분석

위의 해결 방법을 통해 당장의 문제는 해결되었지만 근본적인 원인은 아니었습니다. 애초에 내부적으로 dwReserved1 플래그가 설정된 이유를 파악하기 위해 추가 조사가 필요했습니다.

이 필드는 MSDN에 문서화되어 있는 SIP_SUBJECTINFO 구조의 일부이지만 안타깝게도 이 경우에는 큰 도움이 되지 않았습니다:

이 필드가 설정된 위치를 찾기 위해 거꾸로 작업하여 dwReserved1 이 여전히 0 인 지점, 즉 플래그가 설정되기 전의 지점을 확인했습니다. dwReserved1 필드에 하드웨어 중단점(쓰기 시)을 설정하고 실행을 재개했습니다. SIPObjectPE_::GetMessageFromFile 함수에서 중단점에 도달했습니다:

__int64 __fastcall SIPObjectPE_::GetMessageFromFile(
    SIPObjectPE_ *this,
    struct SIP_SUBJECTINFO_ *a2,
    struct _WIN_CERTIFICATE *a3,
    unsigned int a4,
    unsigned int *a5)
{
    __int64 v5; // rcx
    __int64 result; // rax
    DWORD v8; // [rsp+40h] [rbp+8h] BYREF

    v5 = *((_QWORD*)this + 1);
    v8 = 0;
    result = ImageGetCertificateDataEx(v5, a4, a3, a5, &v8);
    if((_DWORD)result)
        a2->dwReserved1 = v8;

    return result;
}

이 함수는 imagehlp.dll 에서 내보내는 ImageGetCertificateDataEx API를 호출합니다. 이 함수의 다섯 번째 파라미터가 반환하는 값은 dwReserved1 에 저장됩니다. 이 값은 궁극적으로 우리가 관찰해 온 방식에 따라 PE가 "기형" 으로 간주되는지 여부를 결정합니다.

안타깝게도 ImageGetCertificateDataEx 은 MSDN에 문서화되어 있지 않습니다. 그러나 이전 변형인 ImageGetCertificateData이 문서화되어 있습니다:

BOOL IMAGEAPI ImageGetCertificateData(
  [in]      HANDLE            FileHandle,
  [in]      DWORD             CertificateIndex,
  [out]     LPWIN_CERTIFICATE Certificate,
  [in, out] PDWORD            RequiredLength
);

이 함수는 PE 헤더에서 IMAGE_DIRECTORY_ENTRY_SECURITY 디렉터리의 콘텐츠를 추출합니다. ImageGetCertificateDataEx 함수를 수동으로 분석한 결과 처음 네 개의 매개변수는 ImageGetCertificateData 의 매개변수와 일치하지만 마지막에 출력 매개변수가 하나 더 추가되었습니다.

이 함수를 호출하고 알 수 없는 다섯 번째 매개변수에 대해 검사를 수행할 수 있는 간단한 테스트 프로그램을 작성했습니다:

#include <stdio.h>
#include <windows.h>
#include <imagehlp.h>

int main()
{
    HANDLE hFile = NULL;
    DWORD dwCertLength = 0;
    WIN_CERTIFICATE *pCertData = NULL;
    DWORD dwUnknown = 0;
    BOOL (WINAPI *pImageGetCertificateDataEx)(HANDLE FileHandle, DWORD CertificateIndex, LPWIN_CERTIFICATE Certificate, PDWORD RequiredLength, DWORD *pdwUnknown);

    // open target executable
    hFile = CreateFileA("C:\\users\\matthew\\sample-executable.exe", GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
    if(hFile == INVALID_HANDLE_VALUE)
    {
        printf("Failed to open input file\n");
        return 1;
    }

    // locate ImageGetCertificateDataEx export in imagehlp.dll
    pImageGetCertificateDataEx = (BOOL(WINAPI*)(HANDLE,DWORD,LPWIN_CERTIFICATE,PDWORD,DWORD*))GetProcAddress(LoadLibraryA("imagehlp.dll"), "ImageGetCertificateDataEx");
    if(pImageGetCertificateDataEx == NULL)
    {
        printf("Failed to locate ImageGetCertificateDataEx\n");
        return 1;
    }

    // get required length
    dwCertLength = 0;
    if(pImageGetCertificateDataEx(hFile, 0, NULL, &dwCertLength, &dwUnknown) == 0)
    {
        if(GetLastError() != ERROR_INSUFFICIENT_BUFFER)
        {
            printf("ImageGetCertificateDataEx error (1)\n");
            return 1;
        }
    }

    // allocate data
    printf("Allocating %u bytes for certificate...\n", dwCertLength);
    pCertData = (WIN_CERTIFICATE*)malloc(dwCertLength);
    if(pCertData == NULL)
    {
        printf("Failed to allocate memory\n");
        return 1;
    }

    // read certificate data and dwUnknown flag
    if(pImageGetCertificateDataEx(hFile, 0, pCertData, &dwCertLength, &dwUnknown) == 0)
    {
        printf("ImageGetCertificateDataEx error (2)\n");
        return 1;
    }

    printf("Finished - dwUnknown: %u\n", dwUnknown);

    return 0;
}

다양한 실행 파일에 대해 실행한 결과, 알 수 없는 반환 값은 "깨진" 실행 파일의 경우 1, 올바르게 서명된 바이너리의 경우 0 이었습니다. 이를 통해 문제가 ImageGetCertificateDataEx 함수 내 어딘가에서 발생한 것으로 확인되었습니다.

이 함수에 대한 추가 분석 결과 알 수 없는 플래그가 다른 내부 함수( IsBufferCleanOfInvalidMarkers)에 의해 설정되고 있는 것으로 나타났습니다.

...
if(!IsBufferCleanOfInvalidMarkers(v25, v15, pdwUnknown))
{
    LastError = GetLastError();
    if(!pdwUnknown)
        goto LABEL_34;
}
...

IsBufferCleanOfInvalidMarkers 함수를 정리한 후 다음을 관찰했습니다:

DWORD IsBufferCleanOfInvalidMarkers(BYTE *pData, DWORD dwLength, DWORD *pdwInvalidMarkerFound)
{
    if(!_InterlockedCompareExchange64(&global_InvalidMarkerList, 0, 0))
        LoadInvalidMarkers();

    if(!RabinKarpFindPatternInBuffer(pData, dwLength, pdwInvalidMarkerFound))
        return 1;

    SetLastError(0x80096011); // TRUST_E_MALFORMED_SIGNATURE

    return 0;
}

이 함수는 "유효하지 않은 마커가 아직 로드되지 않은 경우 LoadInvalidMarkers 를 사용하여" 의 글로벌 목록을 로드합니다. imagehlp.dll 에는 하드코딩된 기본 마커 목록이 포함되어 있지만 레지스트리에서 다음 경로의 사용자 정의 목록도 확인합니다:

HKEY_LOCAL_MACHINE\Software\Microsoft\Cryptography\Wintrust\Config\PECertInvalidMarkers

이 레지스트리 값은 기본적으로 존재하지 않는 것으로 보입니다.

그런 다음 이 함수는 전체 PE 서명 데이터에서 검색을 수행하여 이러한 마커를 찾습니다. 일치하는 항목이 발견되면 pdwInvalidMarkerFound1 으로 설정되어 앞서 언급한 psSipSubjectInfo->dwReserved1 값에 직접 매핑됩니다.

유효하지 않은 마커 덤프하기

마커는 imagehlp.dll 내부의 문서화되지 않은 구조에 저장됩니다. 위에서 언급한 RabinKarpFindPatternInBuffer 함수를 리버스 엔지니어링한 후, 전체 마커 목록을 덤프하는 작은 도구를 만들었습니다:

#include <stdio.h>
#include <windows.h>

int main()
{
    HMODULE hModule = LoadLibraryA("imagehlp.dll");

    // hardcoded address - imagehlp.dll version:
    // 509ef25f9bac59ebf1c19ec141cb882e5c1a8cb61ac74a10a9f2bd43ed1f0585
    BYTE *pInvalidMarkerData = (BYTE*)hModule + 0xC4D8;

    BYTE *pEntryList = (BYTE*)*(DWORD64*)(pInvalidMarkerData + 20);
    DWORD dwEntryCount = *(DWORD*)pInvalidMarkerData;
    for(DWORD i = 0; i < dwEntryCount; i++)
    {
        BYTE *pCurrEntry = pEntryList + (i * 18);
        BYTE bLength = *(BYTE*)(pCurrEntry + 9);
        BYTE *pString = (BYTE*)*(DWORD64*)(pCurrEntry + 10);
        for(DWORD ii = 0; ii < bLength; ii++)
        {
            if(isprint(pString[ii]))
            {
                // printable character
                printf("%c", pString[ii]);
            }
            else
            {
                // non-printable character
                printf("\\x%02X", pString[ii]);
            }
        }
        printf("\n");
    }

    return 0;
}

그 결과 다음과 같은 결과가 나타났습니다:

PK\x01\x02
PK\x05\x06
PK\x03\x04
PK\x07\x08
Rar!\x1A\x07\x00
z\xBC\xAF'\x1C
**ACE**
!<arch>\x0A
MSCF\x00\x00\x00\x00
\xEF\xBE\xAD\xDENull
Initializing Wise Installation Wizard
zlb\x1A
KGB_arch
KGB2\x00
KGB2\x01
ENC\x00
disk%i.pak
>-\x1C\x0BxV4\x12
ISc(
Smart Install Maker
\xAE\x01NanoZip
;!@Install@
EGGA
ArC\x01
StuffIt!
-sqx-
PK\x09\x0A
"\x0B\x01\x0B
-lh0-
-lh1-
-lh2-
-lh3-
-lh4-
-lh5-
-lh6-
-lh7-
-lh8-
-lh9-
-lha-
-lhb-
-lhc-
-lhd-
-lhe-
-lzs-
-lz2-
-lz3-
-lz4-
-lz5-
-lz7-
-lz8-
<#$@@$#>

예상대로, 이것은 이전 설치 프로그램 및 압축 아카이브 형식과 관련된 마법 값 목록으로 보입니다. 이는 특정 설치 프로그램이 영향을 받는다는 것을 암시하는 MS13-098의 설명과 일치합니다.

저희는 이 문제가 자동 압축 해제 실행 파일과 관련이 있다고 생각했습니다. 실행 파일이 디스크에서 자체 데이터를 읽고 임베디드 아카이브(예: ZIP 파일)를 검색하는 경우, 공격자는 서명 데이터 자체는 해싱할 수 없기 때문에 서명을 무효화하지 않고 서명 섹션에 악성 데이터를 추가할 수 있습니다. 특히 파일 끝에서 거꾸로 스캔하는 경우, 취약한 실행 파일이 원본 데이터보다 먼저 악성 데이터를 찾을 수 있습니다.

나중에 저희는 이 정확한 시나리오를 설명하며 저희의 가설을 뒷받침하는 것으로 보이는 이고르 글뤽스만의 오래된 RECon 강연() 을 발견했습니다. 2012

Microsoft의 수정 사항에는 이러한 유형의 악용을 나타낼 수 있는 알려진 바이트 패턴을 PE 서명 블록에서 검색하는 것이 포함되었습니다.

오탐 조사하기

추가 디버깅 결과, 위 목록의 EGGA 마커가 포함된 서명 데이터로 인해 바이너리가 플래그가 지정되고 있음을 발견했습니다:

위의 마커 목록의 맥락에서 EGGA 서명은 ALZip이라는 아카이브 형식에서 사용하는 특정 헤더 값과 관련된 것으로 보입니다. 저희 코드에서는 이 파일 형식을 사용하지 않습니다.

Microsoft의 휴리스틱은 EGGA 의 존재를 악성 아카이브 데이터가 PE 서명에 포함되었다는 증거로 간주했습니다. 실제로는 그런 일은 없었습니다. 서명 블록 자체에 해시된 데이터의 일부로 이 4바이트가 포함되었습니다.

이와 같은 충돌은 드문 경우이지만 페이지 해싱(/ph)으로 인해 발생 가능성이 더 높아졌습니다. 페이지 해싱은 서명 블록의 크기를 확장함으로써 일치하는 항목의 표면적을 증가시키고 휴리스틱을 트리거할 가능성을 높입니다.

바이너리에는 자동 압축 해제 루틴이 포함되어 있지 않았으므로 EGGA 에 대한 히트는 오탐지였습니다. 이러한 맥락에서 경고는 파일의 무결성과는 아무런 관련이 없습니다. 즉, 예상 유효성 검사를 복원하기 위해 /rmc 으로 파일에 다시 서명하는 것이 안전하다는 의미입니다.

결론

보안 블록에 추가 데이터를 추가하여 서명을 손상시키지 않고 PE 파일에 추가 데이터를 포함할 수 있다는 것은 잘 알려져 있습니다. 일부 합법적인 소프트웨어 제품에서도 이 점을 악용하여 서명된 실행 파일에 사용자별 메타데이터를 삽입합니다. 하지만 2012년에 도입된 휴리스틱은 특정 악성 사례를 탐지하기 위해 구현되었음에도 불구하고 Microsoft에서 이를 구현했다는 사실을 알지 못했습니다.

원래 오류 메시지는 매우 모호했고, 온라인에서 해당 동작을 설명하는 데 도움이 되는 문서나 참고 자료를 찾을 수 없었습니다. 발견 후 관련 레지스트리 값(PECertInvalidMarkers)을 검색해도 결과가 나오지 않았습니다.

Microsoft가 특정 악용 사례에 대응하기 위해 10여 년 전에 서명 블록에 휴리스틱 스캔 기능을 추가했다는 사실을 발견했습니다. 이러한 휴리스틱은 하드코딩된 '유효하지 않은 마커' 목록에 존재하며, 이 중 상당수는 오래된 설치 프로그램 및 아카이브 형식에 연결되어 있습니다. 페이지 해싱이 활성화된 상태에서 서명할 때 바이너리가 이러한 마커 중 하나와 충돌하여 명확한 문서도 없고 기본 레지스트리 키 또는 탐지 로직에 대한 공개 참조도 없는 유효성 검사에 실패하는 일이 발생했습니다.

이 오류 모드에 대한 온라인 토론이 2018년의 해결되지 않은 Visual Studio 개발자 커뮤니티 게시물 한 건을 제외하고는 없었기 때문에 초기 진단이 어려웠습니다. 이 분석 결과를 공개함으로써 동일한 문제를 겪을 수 있는 다른 사람들에게 기술적 참조점을 제공하고자 합니다. 저희의 경우 문제를 해결하려면 이 공간 외부에서는 거의 시도하지 않는 심층적인 문제 해결이 필요했습니다. 코드 서명을 자동화하는 팀에게 중요한 교훈은 서명 유효성 검사를 조기에 통합하고 휴리스틱 마커 감지가 에지 케이스 실패로 이어질 수 있다는 점에 유의해야 한다는 것입니다.

추가 참고 자료

저자는 X에서 @x86matthew로만날 수 있습니다.