Windows 编程中的安全函数指针和回调
本文档解释了函数指针和回调在 Windows 应用程序编程接口 (API) 中的用法。
- 下载示例 1:C 函数指针和回调函数源代码 - 7.64 KB
- 下载示例 2:C++ 静态和非静态函数指针源代码 - 7.06 KB
- 下载示例 3:动态链接库 (DLL) 函数指针源代码 - 8.87 KB
- 下载示例 4:动态链接库函数指针客户端源代码 - 6.86 KB
- 下载示例 5:不安全函数指针示例源代码 - 7.43 KB
- 下载示例 6:安全函数指针示例源代码 - 7.08 KB
引言
函数指针是指向函数的指针,类似于指向变量的指针。用户可以在解引用后使用所需的参数调用函数指针。函数指针可以使用 C 或 C++ 语言实现。函数指针对于后期绑定、实现回调和将函数作为参数传递给另一个函数非常有用。本文档解释了函数指针和回调在 Windows 应用程序编程接口 (API) 中的用法。
C++ 支持以下两种类型的函数指针:
- 静态成员函数指针
- 非静态成员函数指针
C 语言支持的函数指针类似于 C++ 的静态成员函数。应用程序可以定义函数地址的变量名,并通过该变量名进行调用。
// define a function pointer and initialize to NULL
void (*FunctionPointer)(int, int, int) = NULL; // C
C 语言函数指针可以使用可变参数来处理未知数量的参数。
int (*ptr)(...);
C++ 中指向静态成员函数的指针与指向 C 函数的指针相同。
// define a function pointer and initialize to NULL
int (TMyClass::*pt2Member)(float, char, char) = NULL; // C++
int (TMyClass::*pt2ConstMember)(float, char, char) const = NULL; // C++
开发人员可以使用 typedef
来声明函数指针。typedef
定义可用于初始化函数指针和通过函数指针调用函数。
// declare typedef for array of integer and integer arguments
typedef void (*FunctionPointer)(int arr[], int);
//Define the function pointer
FunctionPointer fpdisplay = NULL;
// Assign Display function address to function pointer and call the function
fpdisplay = &Display;
if(fpdisplay)
(*fpdisplay)(arr,9);
Don Clugston 的 成员函数指针和最快的 C++ 委托 文章更深入地解释了 C++ 成员函数指针。
调用约定
调用约定用于将参数传递给函数以及从内存中清理堆栈。函数指针声明应与实际函数调用约定匹配。Microsoft C/C++ 编译器支持 __cdecl
、__clrcall
、__stdcall
、__fastcall
和 __thiscall
调用约定。__stdcall
调用约定用于调用 Win32 API 函数。__cdecl
是 C 和 C++ 程序的默认调用约定。__cdecl
调用约定生成的代码比 __stdcall
大。它要求每个函数都包含清理代码。
WinDef.h 定义了调用约定宏。CALLBACK
、WINAPI
、APIPRIVATE
和 PASCAL
都使用 __stdcall
调用约定。函数和函数指针应使用相同的调用约定。如果开发者没有明确指定,Microsoft C/C++ 编译器将采用 __decl
调用约定。但是,如果开发者使用任何 Win32 API,则应使用 __stdcall
调用约定。
回调
回调函数是指向被传递给另一个函数的函数指针,第二个函数可以通过该函数指针参数来调用该函数。第二个函数在不知道参数函数的情况下直接调用函数指针。每个回调函数都有自己的原型。因为每个函数的函数名、返回类型和参数都不同。所有 C++ 成员函数都有一个 internal
的 this 指针来访问类的成员。因此,开发者在 C++ 中使用回调函数时无需使用此指针。但是,当我们使用非成员函数和 static
函数时,我们必须显式使用回调函数。这两者看起来相同的原因是 C 没有引用,因此您无法发送引用。
函数指针和回调函数之间的区别
否 | 函数指针 | 回调 |
1 | 函数指针是指向函数的指针。 | 回调函数是作为函数参数传递的函数指针,当事件发生时可以被调用。 |
2 | 示例 vtable 是函数指针数组 |
线程结束时调用的回调函数。 |
3 | 函数指针是函数的地址 | 回调函数是以函数指针作为参数传递的,当发生某些事情时,调用者会进行回调。 |
示例 1 解释了 C 函数指针和回调函数。BubbleSort
对元素数组进行排序,Display
显示整数数组。BinarySearch
和 LinearSearch
是用于搜索元素的两个回调函数。二分搜索仅适用于已排序的元素,并且比线性搜索花费的时间少(性能更好)。线性搜索适用于已排序或未排序的元素数组。
/****************************************************************
The calling function takes a single callback as a parameter.
*******************************************************************/
void Search(int (*SelectSearch)(int arr[], int, int,int),int arr[],
int value,int left,int right) {
int result = SelectSearch(arr,value,left,right);
if(result == -1)
printf("Search element not found!\n");
else
printf("Element %d found in %d position!\n", value,result);
}
上述函数将函数指针用作参数,并使用函数指针来调用相应的搜索函数。主函数在对未排序的输入元素进行排序后调用 BinarySearch
。
安全的 CRT
“安全的 string
处理”解释了安全 C 运行时库 string
处理函数的基础知识。安全的 CRT 还提供了一组安全算法函数。C 运行时算法使用回调函数来实现元素比较。
否 | function | 安全版本 | 描述 |
1 | bsearch |
bsearch_s |
二分搜索 |
2 | _lfind |
_lfind_s |
线性搜索 |
3 | _lsearch |
_lsearch_s |
如果未找到则添加到数组的线性搜索 |
4 | qsort |
qsort_s |
快速排序 |
上述排序和搜索函数的比较函数指针将接收三个参数。这些 C 运行时库函数使用 __cdecl
调用约定。比较函数将接收两个参数。第一个参数是指向搜索键的指针,第二个参数是指向要与键进行比较的数组元素的指针。
安全版本比较函数将接收三个参数。参数 compare
是指向用户提供的例程的指针,该例程比较两个数组元素并返回一个值来指定它们之间的关系。安全版本比较还会接收上下文指针。上下文参数使得执行线程安全排序更加容易。与其使用必须同步以确保线程安全的 static
变量,不如为每次排序传递不同的上下文参数。比较函数可能在快速排序过程中被调用一次或多次。MSDN 提供了上述安全版本 CRT 算法的示例。
Visual Studio 2008 C 运行时库在 Windows 2000、Windows XP、Server 2003 和 Vista 上运行。Visual Studio 2010 C 运行时库支持 Windows XP(带 SP2、SP3)和 Windows Server 2003(带 SP1、SP2)、Vista、Windows Server 2008 和 Windows 7。安全 C 运行时库随 Visual Studio 一起提供。如果开发者需要 C 运行时库,可以从 Microsoft 网站下载 [12,13]。
Windows 中函数指针和回调函数的列表
Windows 应用程序编程接口 (API) 在许多地方使用函数指针和回调函数。(本文档仅讨论用户界面应用程序 Windows API。Windows 也可能在其他 Windows 编程 API 中使用函数指针和回调函数。)每个 Windows 应用程序都使用 WNDCLASS
/WNDCLASSEX
结构,通过 RegisterClass
/RegisterClassEx
Windows API 来注册 Windows 应用程序。它包含窗口类信息。回调函数作为窗口注册过程的一部分进行注册。WNDCLASS
/WNDCLASSEX
结构在 lpfnWndProc
参数中注册 Windows 回调函数 WndProc
。CreateWindow
函数创建一个窗口,该函数指定窗口类(WNDCLASS/EX
)、窗口标题、窗口样式和窗口大小。
ATOM MyRegisterClass(HINSTANCE hInstance)
{
WNDCLASSEX wcex;
wcex.cbSize = sizeof(WNDCLASSEX);
wcex.style = CS_HREDRAW | CS_VREDRAW;
wcex.lpfnWndProc = WndProc;
wcex.cbClsExtra = 0;
wcex.cbWndExtra = 0;
wcex.hInstance = hInstance;
wcex.hIcon = LoadIcon(hInstance,
| MAKEINTRESOURCE(IDI_SIMPLEWIN32));
wcex.hCursor = LoadCursor(NULL, IDC_ARROW);
wcex.hbrBackground = (HBRUSH)(COLOR_WINDOW+1);
wcex.lpszMenuName = MAKEINTRESOURCE(IDC_SIMPLEWIN32);
wcex.lpszClassName = szWindowClass;
wcex.hIconSm = LoadIcon(wcex.hInstance,
MAKEINTRESOURCE(IDI_SMALL));
return RegisterClassEx(&wcex);
}
WindProc
是在 WNDCLASS
结构中使用的函数。WndProc
用于处理所有窗口消息并处理所有消息。如果它在 WndProc
中找不到任何消息,它会调用默认窗口过程。DefWindowProc
函数调用默认窗口过程,为应用程序未处理的任何窗口消息提供默认处理。
LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
GetClassLong
函数用于检索窗口类的 WNDCLASS
信息。SetClassLong
函数用于替换指定类(该类属于指定窗口)的额外类内存或 WNDCLASSEX
结构中指定偏移量的 32 位(long)值。S/GetClassLong
函数 nIndex GCL_WNDPROC
替换与类关联的窗口过程的地址。
以下 Windows API 函数使用回调和函数指针。下表仅讨论函数和函数目的。
DialogBox |
DialogProc |
应用程序定义的用于处理发送到模态或非模态对话框的消息的回调函数。 |
消息和消息队列 | SendAsyncProc |
应用程序定义的用于 SendMessageCallback 函数的回调函数。 |
SendMessageCallback |
将指定的消息发送到窗口。 | |
定时器 | TimerProc |
应用程序定义的用于处理 WM_TIMER 消息的回调函数。 |
窗口类 | WNDCLASS/EX |
lpfnWndProc 指向用于处理所有窗口消息的窗口过程。 |
窗口过程 | WindowProc |
应用程序定义的用于处理发送到窗口的消息的函数。WindowProc 是应用程序定义函数名的占位符。 |
CallWindowProc |
CallWindowProc 函数将消息信息传递给指定的窗口过程。 |
|
窗口属性 | EnumProps |
通过将窗口属性列表中的所有条目逐个传递给指定的回调函数来枚举它们。 |
EnumPropsEx |
通过将窗口属性列表中的所有条目逐个传递给指定的回调函数来枚举它们。 | |
PropEnumProc |
应用程序定义的用于 EnumProps 函数的回调函数。 |
|
PropEnumProcEx |
应用程序定义的用于 EnumPropsEx 函数的回调函数。 |
|
Windows | EnumChildProc |
应用程序定义的用于 EnumChildWindows 函数的回调函数。它接收子窗口句柄,是应用程序定义函数名的占位符。 |
EnumChildWindows |
通过将每个子窗口的句柄依次传递给应用程序定义的回调函数,来枚举属于指定父窗口的子窗口。 | |
EnumThreadWindows |
通过将每个窗口的句柄依次传递给应用程序定义的回调函数,来枚举与线程关联的所有非子窗口。 | |
EnumThreadWndProc |
应用程序定义的用于 EnumThreadWindows 函数的回调函数。它接收与线程关联的窗口句柄。 |
|
EnumWindows |
通过将每个顶层窗口的句柄依次传递给应用程序定义的回调函数,来枚举屏幕上所有顶层窗口。 | |
EnumWindowsProc |
应用程序定义的用于 EnumWindows 或 EnumDesktopWindows 函数的回调函数。它接收顶层窗口句柄。 |
动态链接库
动态链接库是 Windows 可执行文件,其中包含一组函数。DLL 只在内存中加载一次,进程可以引用 DLL 中可用的函数来执行。如果没有进程引用 DLL,它将从内存中卸载。DLL 可以隐式或显式加载。显式 DLL 使用函数指针在内存中加载函数。隐式链接使用库(*.lib)文件,并在可执行文件加载到内存时加载 DLL。显式链接直接使用 DLL,并在需要时使用 LoadLibrary (Ex)
系列函数加载 DLL。
与隐式链接相比,显式链接具有以下优点:
- 当应用程序在运行时不知道需要加载的 DLL 名称时,显式链接非常有用。
- 隐式链接需要 *.lib、*.h 和 *.dll 文件来加载 DLL。但是,显式链接只需要 DLL 文件。
- 当隐式链接的 DLL 在启动时未能加载时,应用程序将失败。但是,显式链接使用
LoadLibrary
加载 DLL,并且可以进行处理并继续。 - 如果应用程序包含许多 DLL,隐式链接将在加载应用程序时花费大量时间。但是,隐式链接会在需要时加载 DLL。
进程调用 LoadLibrary
/LoadLibraryEx
来在内存中加载 DLL。MFC 应用程序使用 AfxLoadLibrary
来加载显式链接的 DLL。如果 MFC 应用程序使用 AfxLoadLibrary
函数加载 DLL,则应调用 AfxFreeLibrary
来卸载扩展 DLL。如果函数能够成功加载 DLL,它将返回该函数的 HANDLE
。如果 DLL 已经在内存中加载,它将增加引用计数并返回 HANDLE
。如果找不到入口点,它将简单地返回 NULL
。一旦获得了 DLL 的 HANDLE
,用户就可以调用 GetProcAddress
函数来检索导出函数的地址并运行该函数或变量。它需要从 LoadLibrary
返回的 DLL 模块句柄以及函数名或函数导出序号。
函数指针没有任何编译时检查。函数指针的 typedef
有助于检查导出函数原型中的类型安全。如果 DLL 使用模块定义(*.def)文件构建,GetProcAddress
可以使用序号。如果 DLL 包含许多导出的 DLL 函数,这会稍微快一些。
typedef int (__cdecl *FUNCTIONPROCADD)(int,int);
// do
FUNCTIONPROCADD ProcAdd;
BOOL bResult, bSuccess = FALSE;
// Get a handle to the DLL module.
HINSTANCE hinstLib = LoadLibrary(TEXT("winadd.dll"));
// If the handle is valid, try to get the function address.
if (hinstLib != NULL)
{
ProcAdd = (FUNCTIONPROCADD) GetProcAddress(hinstLib, "add");
// If the function address is valid, call the function.
if (NULL != ProcAdd)
{
bSuccess = TRUE;
(ProcAdd) (3,6);
}
// Free the DLL module.
bResult = FreeLibrary(hinstLib);
}
// If unable to call the DLL function, use an alternative.
if (!bSuccess)
printf("Message printed from executable\n");
安全指针编码
我们已经讨论了将函数指针用作参数的函数指针和回调函数。我们生活在一个不安全的世界。缓冲区溢出漏洞为攻击者提供了攻击代码并改变正常流程的选项。攻击者甚至可能作为我们应用程序函数流程的一部分来运行其他应用程序。当程序调用函数时,它会创建一个新的堆栈帧,其中包含函数参数、返回地址、帧指针、异常处理程序帧和局部变量。良好的编码实践建议使用局部变量或按引用传递,而不是全局变量。如果需要,仅使用不可变变量或指针。函数指针在全局声明以访问函数。如果函数指针是全局声明的,则函数指针将一直存在直到应用程序终止。
Microsoft 安全生命周期 (5.0) 建议对长期存在的指针进行编码。不安全指针会导致缓冲区溢出攻击。全局作用域的函数指针和全局指针是长期存在的指针。长期存在的指针是不安全指针。开发人员或设计人员必须识别长期存在的指针。Microsoft Windows 提供 EncodePointer()
和 DecodePointer()
函数,使用一种秘密技术对给定进程的指针进行加密和解密。如果攻击者试图覆盖指针,编码后的指针将不允许攻击。EncodePointer()
/DecodePointer()
函数从 Windows XP SP2 及更高版本(客户端操作系统)和 Windows Server 2003 SP1(服务器操作系统)开始可用。EncodePointer()
/DecodePointer()
函数是 Windows 函数,Microsoft 将这些函数用于 CRT 内部,并可能使用其他 API 来编码全局指针。Windows 使用这些函数来编码未处理的异常过滤器地址、长期存在的函数指针、长期运行的堆分配内存、内核执行间接调用等。
编码函数通过 XOR
操作与随机数进行运算。EncodePointer
/DecodePointer
函数使用应用程序的进程信息块,而 EncodeSystemPointer
/DecodeSystemPointer
使用每次系统重启的值。Michael Howard 的博客解释了 XOR 编码操作的算法。Michael Howard 的 [4, 5] 博客更详细地解释了用于 XOR 运算的算法。
假设 search
是一个函数,而 FunctionPointer
是为函数指针定义的 typedef
。它在内存中存在很长时间,并在应用程序终止时才回收内存。search
函数可能需要很长时间才能执行,而函数指针被声明为全局的。攻击者可能通过缓冲区溢出攻击来攻击此函数指针。攻击者可以更改返回地址并添加用于执行某些 shell 代码或其他应用程序。EncodePointer
接收 PVOID
Windows 数据类型,并返回相同的 Void
(PVOID
)数据类型指针。一旦指针被编码,攻击者就无法调用或更改全局函数指针地址。开发者可以按以下语法编码和解码函数指针:
// search function
void Search() {
// do something
}
typedef void (*FunctionPointer )(void);
// do something
FunctionPointer fp1 = (FunctionPointer)EncodePointer(&Search);
// Do something
// Do something
FunctionPointer fp2 = (FunctionPointer)DecodePointer(fp2);
if (fp2)
(*fp2)();
EncodePointer
返回编码后的指针值。攻击者无法攻击编码后的指针。但是,开发者可以使用 DecodePointer
函数和一个编码后的指针来解码指针。Michael Howard [4, 5] 不建议使用所有全局函数指针,因为它可能会影响应用程序性能。
编码指针用于 crt\src\tidtable.c CRT 文件。该文件使用操作系统机制编码指针以防止劫持。EncodePointer
/DecodePointer
仅支持 x86 机器。tidtable.c 提供了 _encode_pointer
/_decode_pointer
函数来编码和解码 CRT 函数。但是,这些函数是未文档化的,并且不由 CRT DLL 导出。以下 _encode_pointer
/_decode_pointer
在 internal.h 中定义,用于 CRT 的内部辅助函数。
/* internal helper functions for encoding and decoding pointers */
void __cdecl _init_pointers();
_CRTIMP void * __cdecl _encode_pointer(void *);
_CRTIMP void * __cdecl _encoded_null();
_CRTIMP void * __cdecl _decode_pointer(void *);
示例
- NT 堆内部指针
- 矢量化异常处理程序指针
- 长期存在的函数指针
结论
函数指针是 C 和 C++ 编程中的一个重要概念。Windows 应用程序编程接口 (Windows API) 大量使用函数指针和回调函数。因此,Windows 程序员应该了解函数指针和回调函数,无论是否在编程中使用它们。Microsoft 安全开发生命周期 (SDL) 也推荐并提供了 EncodePointer
/EncodeSystemPointer
和 DecodePointer
/DecodeSystemPointer
函数来进行安全指针操作。指针可能是函数指针或变量指针。不安全指针会导致缓冲区溢出攻击。C/C++ 在初始化或在函数之间移动数据时不支持数组边界检查。缓冲区溢出攻击可以覆盖函数返回地址。当函数返回时,它不是跳回调用者,而是跳到攻击者代码。堆栈溢出可用于更改程序流程或获取操作系统环境的权限。堆溢出涉及应用程序分配的堆内存分配函数。
摘要
- 为
delete
对象赋NULL
。这可以避免“悬空”指针。 - 对长期存在的
static
或全局指针进行Encode
和Decode
。encode
指针对每个进程使用不同的值。因此,攻击者无法预测用于缓冲区溢出攻击的encode
逻辑。- Windows 消息和运行时动态链接库大量使用函数指针。因此,请使用
Encode
和Decode
指针以实现安全的指针访问。 - 加载动态链接库 (DLL) 必须安全地加载 DLL 并执行函数。
- Windows 消息和运行时动态链接库大量使用函数指针。因此,请使用
- 如果应用程序使用 STL,请检查安全函数是否可用。
- 遵循 Microsoft 安全开发生命周期 (SDL) 5.0 指南
关注点
- Microsoft 安全开发生命周期
- MSDN Visual C++ 2008
- MSDN 杂志
- 防范指针诡计(某种程度上!)
- 防范指针诡计(重述!)
- 击败编译器级别的缓冲区溢出保护
- Microsoft 安全开发生命周期 (SDL) – 流程指南
- 函数指针教程
- MSDN
- Windows ISV 软件安全防御
- 成员函数指针和最快的 C++ 委托
- Microsoft Visual C++ 2008 可再发行组件包 (x86)
- Microsoft Visual C++ 2010 可再发行组件包 (x86)
- 如何获取 Visual C++ 6.0 运行时组件
历史
- 2011年5月1日:初版