先放代码: kazutoiris/cef-hook,可以边看帖子边看代码。欢迎 Star、Fork、Follow!
背景
随着 Web 技术的飞速发展,越来越多的桌面应用程序开始将 Web 内容嵌入到自身的界面中。为了解决这个问题,Chromium Embedded Framework(CEF)应运而生。CEF 是一个开源项目,它提供了一个框架,使得开发者可以将 Chromium 浏览器引擎嵌入到自己的应用程序中,从而轻松地在桌面应用中显示 Web 页面。通过 CEF,开发者可以在应用中利用浏览器的强大功能,同时保持对应用界面的完全控制。
然而,大部分发布版的软件都禁用了开发者工具,对于逆向调试来说会很棘手。有时也可能会遇到需要修改或监控 CEF 内部功能的需求,这时钩子技术便显得尤为重要。钩子技术可以让我们打开禁用的开发者工具,拦截并修改函数的执行流程,通常用于调试、监控或实现一些定制功能。
CEF
CEF 的发展源自于 Chromium 项目,Chromium 是 Google 推出的开源浏览器项目,也是 Google Chrome 浏览器的基础。Chromium 的设计初衷是提供一个快速、稳定、安全的浏览器框架,同时支持广泛的平台。
随着越来越多的开发者希望将 Chromium 浏览器集成到他们的桌面应用中,CEF 项目应运而生。CEF 本质上是 Chromium 的一个封装,它为开发者提供了 API,使得开发者可以轻松将 Web 内容嵌入到应用程序中。
CEF 的关键特点是:
- 支持多平台,Windows、Mac、Linux 都可以使用。
- 提供了强大的 Web 技术支持,包括 HTML5、CSS3、JavaScript 和 WebGL 等。
- 支持多进程架构,通过 Renderer 和 Browser 进程的隔离,提高应用的稳定性和安全性。
vcpkg
vcpkg 是一个开源的 C++ 包管理工具,由 Microsoft 开发。它最初发布于 2016 年,旨在简化 C++ 项目的依赖管理,并提供跨平台的包管理功能。通过 vcpkg,开发者可以轻松地安装和管理第三方库,避免手动配置和编译依赖项的繁琐工作。
vcpkg 的发展历程大致如下:
- 初期阶段:vcpkg 的目标是解决 Windows 平台上 C++ 项目的依赖管理问题。最初,它主要针对 Windows 提供支持,但很快就扩展到其他平台(如 Linux 和 macOS)。
- 跨平台支持:随着 CMake 和 vcpkg 的深度集成,vcpkg 逐渐成为跨平台 C++ 开发的标准工具之一,支持包括 Linux、macOS、Android、iOS 等在内的多个平台。
- 集成与发展:如今,vcpkg 已经成为一个广泛应用的工具,尤其在开源 C++ 项目中。它通过与 CMake 的深度集成,帮助开发者自动下载、编译和管理第三方依赖库。
vcpkg 解决了 C++ 开发中的一个重要问题,即跨平台和多平台的第三方库管理,它简化了依赖项的安装和配置,大大提高了开发效率。vcpkg 和 CMake 有良好集成,在加载 CMake 项目的时候会自动安装项目依赖,而不需要手动执行 vcpkg 命令。
Detours
Detours 是一个微软开源的库,最初由 Microsoft Research 开发,用于拦截 Windows API 函数调用。其核心功能是通过修改目标函数的地址(也就是通过修改函数指针),使得开发者能够在不修改目标代码的情况下,插入自己的代码逻辑。这种技术通常用于调试、监控、性能分析、或者实现自定义的行为。
Detours 采用了“函数钩子”技术,允许开发者将自己的代码插入到其他程序的函数执行流程中。它的应用范围非常广泛,常见的场景包括:
- 监控:拦截某些系统调用或应用程序函数,进行数据采集和监控。
- 调试:拦截并修改程序执行流,用于程序调试。
- 性能分析:记录函数调用的频率、参数和执行时间等信息。
Detours 是一个非常强大的工具,特别适合 Windows 平台的应用程序开发。
CMake
由于 Visual Studio 的一些历史遗留原因,导致 .sln
和 .vcxproj
文件的可读性很差,没有办法进行手写。不过,随着 2019 版本发布,VS 内置了对 CMake 的支持,而 CMakeLists.txt
手写还是很舒服的。因此,本项目使用了 CMake 的构建方式,而不是传统的 VS 构建方式。CMake 是一个开源的跨平台构建系统,最早由 Kitware 公司于 2000 年开发。CMake 使得开发者可以通过一个统一的脚本配置文件来管理多平台的编译过程,而不需要针对不同的编译器和平台编写复杂的 Makefile 文件。
CMake 的发展经历了以下几个阶段:
- 早期阶段:CMake 最初的设计是为了支持跨平台的 C++ 开发,特别是在 UNIX 和 Windows 平台之间。
- 广泛应用:随着 CMake 的功能不断完善,它逐渐成为开源软件项目的首选构建系统,特别是在 C++ 社区中。
- 现代阶段:如今,CMake 已经不仅仅支持 C++,它支持的语言已经扩展到 Python、Fortran、CUDA 等。CMake 的跨平台支持(包括 Windows、Linux、macOS 等)使其成为工业界和学术界广泛使用的工具。
CMake 的优势在于:
- 跨平台支持:开发者只需要编写一次 CMake 脚本,就能在不同的平台上生成相应的构建文件(如 Makefile、Visual Studio 项目文件等)。
- 易于集成第三方库:CMake 能够方便地处理第三方库的集成和依赖关系。
- 灵活性:CMake 提供了丰富的命令和宏,能够应对复杂的构建需求。
逆向过程
以某星学习通为例,它是大学生使用频率较高的学习软件。随着软件版本更新,客户端加入了防作弊机制,禁用了开发者工具,导致一些脚本(如搜题插件)不再可用。为了重新启用开发者工具或自定义功能,需要通过钩子技术进行逆向操作。
其实这个项目有很长时间,很早之前就打算分享的,但是因为懒(自己要用),一直没有公开。不过现在用不到了,所以拿出来分享一下。
Cef 的逆向在一些文章中其实都有说,像 基于钉钉探索针对CEF框架的一些逆向思路、将js代码注入到第三方CEF应用程序的一点浅见 等等都有说。但是考试客户端和普通的应用程序有很大不同:
- 时间:启动前有可能有很长的时间来操作,但是一旦启动客户端后实际操作的时间很有限(毕竟**又不瞎)。
- 兼容性:很难确定版本,也很难找齐所有的版本(不能保证版本升没升级)。
因此,需要一个通用所有版本的解决方案,直接修改主程序的方案被 PASS 掉了,剩下的要么是 loader 远程注入,要么是 DLL 劫持。不过远程注入不一定能兼容所有版本,因此尝试使用 DLL 劫持实现。为了方便起见,这里使用 Detours 来挂钩 CEF 中的事件和 Windows API 函数。
确定版本
首先是要确定 cef 的版本。由于 cef 随着 chromium 的升级,有一些函数的基址也在发生变化。不过好在实际操作中,客户端的内核版本不会随本体的升级而升级(估计也是为兼容性考虑的,毕竟 32 位的 WinXP 也要用)。
使用 horsicq/Detect-It-Easy 加载 libcef.dll
,可以看到这个模块兼容 Windows XP 32 位的系统。
切到版本页,可以发现版本是 75.1.4+g4210896+chromium-75.0.3770.100
。在 chromiumembedded/cef:4210896 可以很快找到对应提交。这也是一个好消息,起码不是经过魔改的源代码。Chrome 的 49 版本是最后一个兼容 Windows XP 的版本了,而这边兼容到了 75 版本,估计也是做了不少努力。
加载 CXExam.exe
主程序,看一下导出表,可以发现加载了不少系统 DLL。而这些 DLL 都可以用于劫持。为了方便起见,这里直接选了 WINMM.dll
作为劫持对象。
钩取函数实现
劫持 DLL 有一些的工具可以辅助生成代码。这里使用 strivexjun/AheadLib-x86-x64 直接生成 WINMM.dll
的导出表结构。
生成的代码如下所示:
//
// created by AheadLib
// github:https://github.com/strivexjun/AheadLib-x86-x64
//
#include "pch.h"
#include <Shlwapi.h>
#include <string>
#pragma comment( lib, "Shlwapi.lib")
#pragma comment(linker, "/EXPORT:Noname2=_AheadLib_Unnamed2,@2,NONAME")
#pragma comment(linker, "/EXPORT:mciExecute=_AheadLib_mciExecute,@3")
// 省略
FARPROC pfnAheadLib_Unnamed2;
FARPROC pfnAheadLib_mciExecute;
// 省略
static
HMODULE g_OldModule = NULL;
VOID WINAPI Free()
{
if (g_OldModule)
{
FreeLibrary(g_OldModule);
}
}
BOOL WINAPI Load()
{
TCHAR tzPath[MAX_PATH];
TCHAR tzTemp[MAX_PATH * 2];
//
// 这里是否从系统目录或当前目录加载原始DLL
//
//GetModuleFileName(NULL,tzPath,MAX_PATH); //获取本目录下的
//PathRemoveFileSpec(tzPath);
GetSystemDirectory(tzPath, MAX_PATH); //默认获取系统目录的
lstrcat(tzPath, TEXT("\\winmm.dll"));
g_OldModule = LoadLibrary(tzPath);
if (g_OldModule == NULL)
{
wsprintf(tzTemp, TEXT("无法找到模块 %s,程序无法正常运行"), tzPath);
MessageBox(NULL, tzTemp, TEXT("AheadLib"), MB_ICONSTOP);
}
return (g_OldModule != NULL);
}
FARPROC WINAPI GetAddress(PCSTR pszProcName)
{
FARPROC fpAddress;
CHAR szProcName[64];
TCHAR tzTemp[MAX_PATH];
fpAddress = GetProcAddress(g_OldModule, pszProcName);
if (fpAddress == NULL)
{
if (HIWORD(pszProcName) == 0)
{
wsprintfA(szProcName, "#%s", pszProcName);
pszProcName = szProcName;
}
wsprintf(tzTemp, TEXT("无法找到函数 %hs,程序无法正常运行"), pszProcName);
MessageBox(NULL, tzTemp, TEXT("AheadLib"), MB_ICONSTOP);
ExitProcess(UINT(-2));
}
return fpAddress;
}
BOOL WINAPI Init()
{
pfnAheadLib_Unnamed2 = GetAddress(MAKEINTRESOURCEA(2));
pfnAheadLib_mciExecute = GetAddress("mciExecute");
// 省略
return TRUE;
}
extern DWORD WINAPI ThreadProc(LPVOID lpThreadParameter);
BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved)
{
(void)pvReserved;
if (dwReason == DLL_PROCESS_ATTACH)
{
DisableThreadLibraryCalls(hModule);
if (Load() && Init())
{
TCHAR szCurName[MAX_PATH];
GetModuleFileName(NULL, szCurName, MAX_PATH);
PathStripPath(szCurName);
//是否判断宿主进程名
if (endsWith(szCurName, TEXT(".exe")))
{
//启动补丁线程或者其他操作
HANDLE hThread = CreateThread(NULL, NULL, ThreadProc, NULL, NULL, NULL);
if (hThread)
{
CloseHandle(hThread);
}
}
}
}
else if (dwReason == DLL_PROCESS_DETACH)
{
Free();
}
return TRUE;
}
EXTERN_C __declspec(naked) void __cdecl AheadLib_Unnamed2(void)
{
__asm jmp pfnAheadLib_Unnamed2;
}
EXTERN_C __declspec(naked) void __cdecl AheadLib_mciExecute(void)
{
__asm jmp pfnAheadLib_mciExecute;
}
// 省略
可以看到导出表的结构已经被完整生成出来了,只需要实现 ThreadProc
补丁线程就可以实现钩子了。
编写钩子函数
在钩子中需要完成以下功能:
- 钩取 CEF 函数:通过钩取
libcef.dll
中的 on_key_event
和 on_load_end
事件,可以拦截 CEF 中的键盘输入事件和页面加载完成事件,用于加载自定义函数。
- 钩取 Windows API:钩取
user32.dll
中的 SetWindowDisplayAffinity
函数,可以控制窗口是否允许截屏。
因为 libcef.dll
中的导出表没有被混淆,因此可以直接搜索函数。
首先需要钩住 cef_browser_host_create_browser
函数。在创建浏览器体的时候,会提供 _cef_client_t
结构体,这个结构体中就有所需要的各类事件。
DetourAttach(&g_cef_browser_host_create_browser, (PVOID)hook_cef_browser_host_create_browser);
键盘事件会携带 _cef_key_event_t
结构体,其中成员 type
为按键类型(如按下或释放),成员windows_key_code
为按键码值。在按下 F12
的时候就会调用 show_dev_tools
函数显示开发者工具,其他情况则执行脚本显示弹窗。
int CEF_CALLBACK hook_cef_on_key_event(
struct _cef_keyboard_handler_t* self,
struct _cef_browser_t* browser,
const struct _cef_key_event_t* event,
cef_event_handle_t os_event) {
auto cef_browser_host = browser->get_host(browser);
if (event->type == KEYEVENT_RAWKEYDOWN) {
OutputDebugString((TEXT("[CefHook] Pressed: ") + (IntToString(event->windows_key_code)) + TEXT("\n")).c_str());
if (event->windows_key_code == 18) {
OutputDebugString(TEXT("[CefHook] Show dev tools\n"));
cef_window_info_t windowInfo{};
cef_browser_settings_t settings{};
cef_point_t point{};
SetAsPopup(&windowInfo);
cef_browser_host->show_dev_tools(cef_browser_host, &windowInfo, 0, &settings, &point);
}
else {
_cef_frame_t* frame = browser->get_main_frame(browser);
string_t eval_str = TEXT("alert(\"You pressed: ") + IntToString(event->windows_key_code) + TEXT("\");");
cef_string_t eval{};
func_cef_string_from_ptr(eval_str.c_str(), eval_str.length(), &eval);
cef_string_t url{};
string_t url_str = TEXT("");
func_cef_string_from_ptr(url_str.c_str(), url_str.length(), &url);
frame->execute_java_script(frame, &eval, &url, 0);
}
}
return reinterpret_cast<decltype(&hook_cef_on_key_event)>
(g_cef_on_key_event)(self, browser, event, os_event);
}
页面加载事件主要用于模拟油猴脚本的加载。油猴脚本中可以通过设置 @run-at
注解来设置脚本运行的时机。如果设置为 document-end
,则和 on_load_end
事件一致。
void CEF_CALLBACK hook_cef_on_load_end(
struct _cef_load_handler_t* self,
struct _cef_browser_t* browser,
struct _cef_frame_t* frame,
int httpStatusCode
) {
(void)browser;
(void)self;
OutputDebugString((TEXT("Load end event, statusCode = ") + IntToString(httpStatusCode) + TEXT("\n")).c_str());
string_t eval_str = TEXT("alert(\"Load end event, statusCode = ") + IntToString(httpStatusCode) + TEXT("\");");
cef_string_t eval{};
func_cef_string_from_ptr(eval_str.c_str(), eval_str.length(), &eval);
cef_string_t url{};
string_t url_str = TEXT("");
func_cef_string_from_ptr(url_str.c_str(), url_str.length(), &url);
frame->execute_java_script(frame, &eval, &url, 0);
};
当然,除了可以钩住 libcef.dll
外,也可以钩住其他系统函数。这里以 user32.dll
中的 SetWindowDisplayAffinity
为例。可以通过切换代码实现反反截屏功能(上面的图就是这么截下来的),也可以强制反截屏。
BOOL WINAPI hook_set_window_display_affinity(HWND hWnd, DWORD dwAffinity) {
OutputDebugString(L"[CefHook] hook_set_window_display_affinity\n");
if (dwAffinity == WDA_NONE) {
return true;
}
return reinterpret_cast<decltype(&hook_set_window_display_affinity)>
(g_set_window_display_affinity)(hWnd, dwAffinity);
}
项目组织
本项目是在 Windows 环境下进行构建,因此使用被誉为“宇宙最强 IDE”的 Visual Studio 最为合适。然而,Visual Studio 的项目管理较为复杂,手动编辑 .sln
和 .vcxproj
文件并不现实。每次修改参数时都需要通过图形界面进行调整,不仅效率低下,而且有时很容易忘记具体改动的内容。幸运的是,从 Visual Studio 2019 开始,官方引入了对 CMake 的支持。使用 CMake 进行配置,不仅比直接操作 Visual Studio 的项目文件更方便,还能够跨版本使用,避免了 VS 项目文件与特定版本绑定的问题。
本案例依赖于 CEF 项目,因此需要将 CEF 作为引用文件夹引入。需要注意的是,虽然 CEF 编译依赖于 Chromium,但实际上在本案例中只依赖了一个 Chromium 的头文件,并不需要克隆整个 Chromium 仓库。
cmake_minimum_required (VERSION 3.10)
project(CefHook)
add_definitions(-DUNICODE -D_UNICODE)
include_directories(${CMAKE_SOURCE_DIR}/cef)
include_directories(${CMAKE_SOURCE_DIR}/chromium)
add_subdirectory(src)
在 src
目录下包含了本项目的所有源代码。我们需要将当前目录下的所有 C 和 C++ 文件添加到编译列表中,并设置链接器参数,同时引入 Detours
库。以下是相关的 CMake 配置:
find_path(DETOURS_INCLUDE_DIRS "detours/detours.h")
find_library(DETOURS_LIBRARY detours REQUIRED)
file(GLOB SOURCES "*.cpp" "*.c")
add_library(CefHook SHARED ${SOURCES})
set_target_properties(CefHook PROPERTIES
LINKER_LANGUAGE C
OUTPUT_NAME "winmm"
PREFIX ""
)
target_include_directories(CefHook PRIVATE ${DETOURS_INCLUDE_DIRS})
target_link_libraries(CefHook PRIVATE ${DETOURS_LIBRARY})
在这段代码中,首先查找当前目录下所有的 C 和 C++ 源代码文件,并添加到 SOURCES
中。接着,设置输出名称为 winmm.dll
。最后,将 Detours
库的头文件目录和库文件链接到项目中。
完成上述配置后,可以选择两种方式来构建项目:
- 使用 Visual Studio 打开项目文件夹:Visual Studio 会自动识别 CMake 配置并进行构建。
- 使用命令行构建:可以直接通过命令行使用 CMake 进行构建。
需要注意的是,若在 Visual Studio 中打开项目,通常会自动绑定 vcpkg。但如果是在命令行中构建,则需要手动指定 vcpkg 的路径。
编译完成后,将会得到一个名为 winmm.dll
的动态链接库文件。将该文件复制到客户端应用程序的根目录下即可。
总结
本案例使用了 Detours 来实现 CEF 应用中的函数钩取,最后的 DLL 尺寸小于 50KB,非常精简。通过钩取 CEF 和 Windows API 中的关键事件,可以灵活地控制应用的行为,进行性能分析、调试,或实现自定义功能。
本案例中的所有代码均开源在 kazutoiris/cef-hook,欢迎 Star、Fork、Follow!
相关客户端可以在网上搜索到,这里不再提供。此外,本文仅用于技术研究和学习,任何实际应用请遵守相关法律法规和使用条款。