The olden standby was to read the target runtime version from the file with GetFileVersion(). It will fail with ERROR_BAD_FORMAT when the executable file does not contain the CLR header. Works in any bitness and any target architecture of the assembly. You'll have to use it like this:
#define USE_DEPRECATED_CLR_API_WITHOUT_WARNING
#include <mscoree.h>
#pragma comment(lib, "mscoree.lib")
#include <assert.h>
bool IsExeFileDotNet(LPCWSTR filename)
{
WCHAR buf[16];
HRESULT hr = GetFileVersion(filename, buf, 16, NULL);
assert(hr == S_OK || hr == HRESULT_FROM_WIN32(ERROR_BAD_FORMAT));
return hr == S_OK;
}
Do note the use of USE_DEPRECATED_CLR_API_WITHOUT_WARNING to suppress a deprecation error, the MSCorEE api is liable to disappear in a future major release of .NET.
The non-deprecated way is to use ICLRMetaHost::GetFileVersion(), disadvantage is that it can only work when the machine has .NET 4 installed. Not exactly a major problem today. Looks like this:
#include <Windows.h>
#include <metahost.h>
#include <assert.h>
#pragma comment(lib, "mscoree.lib")
bool IsExeFileDotNet(LPCWSTR filename)
{
ICLRMetaHost* host;
HRESULT hr = CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (void**)&host);
assert(SUCCEEDED(hr));
if (hr == S_OK) {
WCHAR buf[16];
DWORD written;
hr = host->GetVersionFromFile(filename, buf, &written);
assert(hr == S_OK || hr == HRESULT_FROM_WIN32(ERROR_BAD_FORMAT));
host->Release();
}
return SUCCEEDED(hr);
}
Other techniques that poke the executable file directly to look for the CLR header in the file are mentioned in this Q+A. How future-proof they may be is very hard to guess.