はじめに
Elastic Security Labs は最近、Windows バイナリの 1 つで署名検証の問題に遭遇しました。実行可能ファイルは、標準の継続的インテグレーション (CI) プロセスの一環として signtool.exe
を使用して署名されましたが、この場合は、出力ファイルが次のエラー メッセージで署名の検証に失敗しました。
オブジェクトのデジタル署名の形式が正しくありません。技術的な詳細については、セキュリティ情報 MS13-098 を参照してください。
MS13-098 のドキュメントはあいまいですが、不正な形式の Authenticode 署名に関連する潜在的な脆弱性について説明しています。この新しいエラーを説明する可能性のある明らかな変更は何もなかったため、原因を調査して問題を解決する必要がありました。
この問題は、署名された Windows バイナリの 1 つに影響を与えていることが確認されましたが、どのバイナリにも影響を与える可能性があります。この研究は、将来同じ問題に遭遇する可能性のある他の人への参考として公開しています。
診断
さらに調査するために、問題のある実行可能ファイルに対して 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
フラグが設定されたのかを理解するには、さらに調査する必要がありました。
このフィールドは、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 を呼び出します。この関数の 5 番目のパラメーターによって返される値は、 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
関数を手動で解析したところ、最初の4つのパラメータはImageGetCertificateData
のパラメータと一致しますが、最後に出力パラメータが1つ追加されていることが示されました。
この関数を呼び出して、未知の5番目のパラメータに対してチェックを実行できるようにする簡単なテストプログラムを作成しました。
#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の講演を見つけましたが、これはまさにこのシナリオを説明しており、私たちの仮説を裏付けているようです。
Microsoft の修正には、この種の悪用を示す可能性のある既知のバイト パターンがないか PE 署名ブロックをスキャンすることが含まれていました。
誤検知の調査
さらにデバッグしたところ、上記のリストの EGGA
マーカーを含む署名データにより、バイナリにフラグが立てられていることがわかりました。
上記のマーカーのリストのコンテキストでは、 EGGA
署名は、 ALZipと呼ばれるアーカイブ形式で使用される特定のヘッダー値に関連しているようです。私たちのコードでは、このファイル形式は一切使用されていません。
Microsoft のヒューリスティックは、 EGGA
の存在を、悪意のあるアーカイブ データが PE 署名に埋め込まれている証拠として扱いました。実際には、そのようなものは何も存在しませんでした。署名ブロック自体には、ハッシュ化されたデータの一部としてこれらの 4 バイトが含まれていました。
このような衝突は珍しいことですが、ページハッシュ(/ph
)により、その可能性が高まりました。ページ ハッシュは、署名ブロックのサイズを拡大することで、偶然一致の表面積を増やし、ヒューリスティックをトリガーする可能性を高めます。
バイナリには自己抽出ルーチンが含まれていなかったため、 EGGA
へのヒットは誤検知でした。その文脈では、警告はファイルの整合性には関係ありませんでした。つまり、 /rmc
でファイルに再署名して、予期された検証を復元しても安全でした。
まとめ
セキュリティブロックに追加することで、署名を壊すことなく追加のデータをPEファイルに埋め込むことができることはよく知られています。一部の正規の ソフトウェア製品 でさえ、これを利用して、署名された実行可能ファイルにユーザー固有のメタデータを埋め込んでいます。ただし、Microsoft が 2012 年に導入されたにもかかわらず、この特定の悪意のあるケースを検出するためのヒューリスティックを実装したことは知りませんでした。
元のエラーメッセージは非常に曖昧で、動作を説明するのに役立つドキュメントや参考文献をオンラインで見つけることができませんでした。関連するレジストリ値を検出した後 (PECertInvalidMarkers
) を検索しても、結果はゼロでした。
私たちが発見したのは、Microsoft が特定の悪用事例に対抗するために 10 年以上前に署名ブロックのヒューリスティック スキャンを追加したことです。これらのヒューリスティックは、ハードコードされた「無効なマーカー」のリストに存在し、その多くは古いインストーラーやアーカイブ形式に関連付けられています。バイナリは、ページハッシュを有効にして署名したときに、たまたまこれらのマーカーの1つと衝突し、明確なドキュメントがなく、基になるレジストリキーや検出ロジックへのパブリック参照がない状態で検証の失敗が発生しました。
2018 年の未解決の Visual Studio 開発者コミュニティの投稿が 1 つあることを除いて、この障害モードに関するオンラインでの議論がなかったため、初期診断は困難でした。この分析を公開することで、同じ問題に遭遇する可能性のある他の人に技術的な参照点を提供したいと考えています。私たちの場合、この問題を解決するには、この分野の外では通常ほとんど実行する必要のない詳細なトラブルシューティングが必要でした。コード署名を自動化するチームにとって重要な教訓は、署名検証チェックを早期に統合し、ヒューリスティックマーカー検出がエッジケースの失敗につながる可能性があることを認識することです。
参考資料
著者はXの@x86matthewで見つけることができます 。