Elastic Security Labs

调查神秘的畸形 Authenticode 签名

揭示畸形Authenticode签名背后隐藏的启发式方法

阅读需16分钟安全运营
调查神秘的畸形 Authenticode 签名

简介

Elastic Security Labs 最近遇到了我们的一个 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->dwReserved1不是0 ,则代码调用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标志。

该字段是SIP_SUBJECTINFO结构的一部分,该结构在 MSDN 上有记录- 但不幸的是,它在这种情况下并没有太大帮助:

为了找到此字段的设置位置,我们反向工作并确定了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 签名数据进行搜索,查找任何这些标记。如果找到匹配项,则pdwInvalidMarkerFound设置为1 ,它直接映射到前面提到的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 文件),则攻击者可能会将恶意数据附加到签名部分而不会使签名无效 - 因为签名数据无法对自身进行哈希处理。这可能会导致易受攻击的可执行文件在原始数据之前找到恶意数据,特别是当它从文件末尾向后扫描时。

后来,我们发现了Igor Glücksmann 在 2012 上发表的一篇旧 RECon 演讲,其中描述了这一确切场景,似乎证实了我们的假设。

微软的修复包括扫描 PE 签名块以查找可能表明此类滥用的已知字节模式。

调查误报

经过进一步调试,我们发现该二进制文件由于签名数据包含上述列表中的EGGA标记而被标记:

在上述标记列表的上下文中, EGGA签名似乎与名为ALZip的存档格式所使用的特定标头值相关。我们的代码不使用这种文件格式。

微软的启发式方法将EGGA的存在视为恶意存档数据已嵌入 PE 签名的证据。但实际上,这样的事并不存在。签名块本身恰好包含这四个字节作为散列数据的一部分。

此类冲突并不常见,但页面哈希( /ph )使这种情况更有可能发生。通过扩大签名块的大小,页面散列增加了巧合匹配的表面积,并增加了触发启发式的可能性。

该二进制文件不包含任何自解压例程,因此对EGGA命中是误报。在这种情况下,警告与文件的完整性无关。这意味着可以安全地使用/rmc重新签署文件以恢复预期的验证。

结论

众所周知,可以通过将附加数据附加到安全块来将附加数据嵌入到 PE 文件中,而不会破坏其签名。甚至一些合法的软件产品也利用这一点将用户特定的元数据嵌入到签名的可执行文件中。然而,我们并不知道微软已经实施了启发式方法来检测这种特定的恶意情况,尽管它们早在 2012 年就被引入。

原始错误消息非常模糊,我们无法在线找到任何有助于解释该行为的文档或参考资料。即使在发现它之后搜索相关的注册表值( PECertInvalidMarkers )也没有任何结果。

我们发现,微软十多年前就添加了对签名块的启发式扫描,以应对特定的滥用情况。这些启发式方法驻留在“无效标记”的硬编码列表中,其中许多与过时的安装程序和存档格式相关。当我们的二进制文件在启用页面散列进行签名时,碰巧与其中一个标记发生冲突,导致验证失败,没有明确的文档,也没有对底层注册表项或检测逻辑的公开引用。

除了2018 年 Visual Studio 开发者社区中一篇未解决的帖子外,网上没有关于这种故障模式的讨论,这让最初的诊断变得困难。通过发布此分析,我们希望为可能遇到同样问题的其他人提供技术参考点。在我们的案例中,解决这个问题需要进行深度故障排除,而这个领域之外的人通常很少有人需要这样做。对于自动化代码签名的团队来说,关键的教训是尽早集成签名验证检查,并意识到启发式标记检测可能会导致边缘情况失败。

更多参考资料

可以在 X 上的@x86matthew 找到作者。