Making the Debugging of UDRLs (a bit) Easier
免责声明:本博客文章仅用于教育和研究目的。提供的所有技术和代码示例旨在帮助防御者理解攻击手法并提高安全态势。请勿使用此信息访问或干扰您不拥有或没有明确测试权限的系统。未经授权的使用可能违反法律和道德准则。作者对因应用所讨论概念而导致的任何误用或损害不承担任何责任。
对 UDRL-VS 框架的一个简单增强:在运行时支持 loader 中的调试字符串日志输出
引入了这个 Visual Studio 项目作为 Cobalt Strike UDRL 开发的模板后,确实带来了一些小技巧,让恶意代码开发者的生活稍微轻松点。开发 Position Independant Code(位置无关代码,PIC)确实非常烦人,限制也很多。比如,无法用传统方式定义字符串。该项目通过巧妙的预处理器宏(如PIC_STRING()
)来简化字符串定义。其它有用的特性还包括PRINT()
宏、Debug
构建模式等。更多细节可参考上面那篇博客。
但实际用下来,调试 UDRL 依然很痛苦。根本上讲,PIC 代码不是一个可执行文件(executable)。虽然 UDRL-VS 模板提供的Debug
构建在某些场景下有用,但它生成的.exe 并不等同于你在Release
模式下拿到的 UDRL。实际调试时,我发现直接在 WinDbg 里调试Release
模式下的 PIC blob 更有价值,而不是去调试Debug
模式下的可执行文件。
调试字符串(Debug Strings)
我的调试手法其实很原始,基本就是到处插printf("Heren");
、printf("Here2n");
。但做恶意代码开发时,很多情况下你根本没有控制台(console)输出的条件。对于 UDRL 来说,printf()
本身也不可用。
如果你用Debug
模式编译 UDRL,会得到一个可执行文件,里面有PRINT()
宏,可以让你把内容printf()
到控制台。但这些在Release
模式下都用不上,因为你只会生成一个 PIC blob。
这种情况下,替代 console 输出的办法就是用OutputDebugString()
API把日志打到 WinDbg 里。只要你把 WinDbg attach 到 payload 所在进程,就能看到这些日志。我的日志内容通常就是一堆Here111
、Here0
、Here123
这种。
实际上,Sleepmask-VS 就用到了这个特性。如果你定义了ENABLE_LOGGING
(参考这里),它会提供类似能力。
你会得到一个DLOGF()
宏,封装了这个 API,帮你处理变参、格式化字符串等麻烦事。
但 UDRL-VS 模板里没有类似的 helper。不幸的是,DLOGF
的例子不能直接用在 UDRL 里,因为 UDRL 不能像下面这样用传统字符串:
DLOG("I reached here n");
因此你必须使用类似下面的方式,通过前面提到的PIC_STRING
辅助宏将所有内容放在.text
段中:
PIC_STRING(mystring, "I reached here n"); //expands to constexpr char mystring[] {'I', ' ', 'r', 'e',... };
DLOG(mystring);
如果每个调试输出都要写两行代码,这确实有点烦人。
你不能像这样把这两行语句包装成一个单行宏:
因为这样会导致每次调用时都重新定义 mystring
。
宏魔法 (Macro Magic)
__LINE__
宏会自动展开为调用处的行号。我们可以利用这个特性来创建每次调用时都唯一的变量名,从而避免宏重复定义相同的 mystring
。
以下代码片段展示了如何定义这样的变量:
现在,VARLINE(myvar)
宏会展开为 varline123
或 varline1337
这样的包含行号的唯一变量名。第二行代码看起来可能有些冗余,但这是确保所有宏按正确顺序展开并得到预期结果的必要步骤。
这个图示可能更直观地展示了这个过程:
通过这种在宏中创建唯一变量名的能力,我们可以定义一个 DLOGF()
宏,它会自动展开成我们想要的形式。
LOGF("I reached here n");
现在确实会展开为以下两行代码:
PIC_STRING(mystring123, "I reached here n");
DLOG(mystring123);
代码实现
由于 UDRL-VS 不是开源项目,我无法直接分享完整代码,但以下是我修改的代码片段。
我在 Utils.h
中添加了上述宏定义,用于实现自定义的 DLOGF
宏。该宏会在调用 DLOG
之前自动处理 PIC_STRING
相关操作:
// this uses some Preprocessor Macro magic to essentially be able to use dlog
// with a PIC_STRING, which requires a new variable everytime, otherwise two invocations
// of DLOGF() will result in a variable redefinition.
// The solution here is to use __LINE__ to create a variable containing the line number,
// ensuring that each PIC_STRING relies on a unique variable name
// add the line number to x to create a unique name
voiddlog(constchar* format, ...);
dlog()
函数与前面提到的 Sleepmask-VS 中的实现非常相似。不过在这里,我们需要调用 OutputDebugStringA
,因此在运行时(在 Utils.cpp
中)会这样解析:
voiddlog(constchar* format, ...){
va_list arglist;
va_start(arglist, format);
char buff[1024];
typedefint(WINAPI* VSPRINTF_S)(char*, size_t, constchar*, va_list); typedefvoid(WINAPI* OUTPUTDEBUGSTRINGA)(LPCSTR);
constexpr DWORD NTDLL_HASH = CompileTimeHash("ntdll.dll");
constexpr DWORD KERNEL32_HASH = CompileTimeHash("kernel32.dll");
constexpr DWORD sprintf_s_hash = CompileTimeHash("vsprintf_s");
constexpr DWORD OutputDebugStringA_hash = CompileTimeHash("OutputDebugStringA");
_PPEB pebAddress = (_PPEB) _readgsqword (0x60) ;
_PPEB pebAddress = (_PPEB)__readfsdword(0x30);
VSPRINTF_S fnVsprintf_s = (VSPRINTF_S)GetProcAddressByHash(pebAddress, NTDLL_HASH, vsprintf_s_hash);
OUTPUTDEBUGSTRINGA fnOutputDebugStringA = (OUTPUTDEBUGSTRINGA) GetProcAddressByHash(pebAddress, KERNEL32_HASH, OutputDebugStringA_hash);
int len = fnVsprintf_s(buff, 1024, format, arglist);
if (len > 0) {
fnOutputDebugStringA(buff);
}
va_end (arglist);
}
结论
现在你可以在最终形态下运行你的 UDRL (User Defined Reflective Loader) 并通过 WinDbg 进行观察。当 UDRL 被注入到 Notepad 中时,打印出如下 3 条语句的效果如下:
DLOGF("Test DLOGF: 0x%pn", anInterestingAddress);
我发现这种方法比在 Debug
和 Release
构建之间来回切换,并试图找出为什么某个功能在一个版本中工作而在另一个版本中不工作要方便得多!
原文始发于微信公众号(securitainment):让 UDRL 调试(稍微)简单一点
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论