以前在写了写壳基础篇,如今就来完成写壳高级篇。没有基础篇的知识,那理解高级篇就比较困难。有了写壳基础后,才能在其基础上逐步实现高级功能,加壳的目的主要是防止别人破0解,而想要别人很难破0解,我认为要在花指令、混淆和指令虚拟化上大量的时间及脑力才能作到,这个比较费脑力费时间。我在此就说说一些能快速入门的反调试技术,下面说的难度将逐渐提高。ios
主要工具: VS201七、x64dbg、OD算法
实验平台:win10 64位
实现功能:反调试、IAT加密、Hash加密、动态解密。windows
顾名思义,就是阻止别人调试程序,在PEB结构中有一个BegingDebugged标志位专门用于检测是否处于调试状态,为1则处于调试状态,用VS2017测试下列程序:数组
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
#include "pch.h"
#include <iostream>
#include <windows.h>
/
/
反调试
1
bool
PEB_BegingDebugged()
{
bool
BegingDebugged
=
false;
__asm
{
mov eax, fs:[
0x30
];
/
/
获取PEB
mov al, byte ptr ds : [eax
+
0x2
];
/
/
获取Peb.BegingDebugged
mov BegingDebugged, al;
}
return
BegingDebugged;
/
/
若是为
1
则说明正在被调试
}
int
main()
{
if
(PEB_BegingDebugged())
{
MessageBoxA(
0
,
"正在被调试"
,
0
,
0
);
return
0
;
}
std::cout <<
"Hello World!\n"
;
getchar();
}
|
敲完代码后按Ctrl+F5直接运行能够输出Hello World!,按F5以调试方式运行程序则会弹出反调试窗口,说明这种方法检测是否正在被调试成功!
安全
可是坏消息是用某些论坛的OD运行这程序依然能正常运行,检测不到反调试,如今的各个平台的OD基本都有一个插件是StrongOD,他能干掉PEB中全部检测反调试的标志位!因此以上方法基本被淘汰了。函数
因此这里介绍一个能够在OD中也能反调试的方法,有一个能够同时在0环和3环运行的函数NtQueryInformationProcess,它的主要做用是查看进程相关的各类信息,在这把它用于检测调试。
咱们给它第二个参数传入ProcessDebugPort,当输出的查询信息为0xFFFFFFFF时,则此程序处于被调试状态。代码以下:工具
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
#include "pch.h"
#include <iostream>
#include <windows.h>
#include <winternl.h>
#pragma comment(lib,"ntdll.lib")
/
/
反调试
2
bool
NQIP_ProcessDebugPort()
{
int
nDebugPort
=
0
;
NtQueryInformationProcess(
GetCurrentProcess(),
/
/
目标进程句柄
ProcessDebugPort,
/
/
查询信息的类型
&nDebugPort,
/
/
输出查询的信息
sizeof(nDebugPort),
/
/
查询类型的大小
NULL);
return
nDebugPort
=
=
0xFFFFFFFF
? true : false;
}
int
main()
{
if
(NQIP_ProcessDebugPort())
{
MessageBoxA(
0
,
"正在被调试"
,
0
,
0
);
return
0
;
}
std::cout <<
"Hello World!\n"
;
getchar();
}
|
编译生成程序后使用OD打开再运行,弹出窗口,反调试成功,我在这测试了15PB的OD和吾爱破n解的OD均能有效。掌握了反调试的方法,咱们能够把它放在壳代码的各个角落,检测到调试就立刻退出程序,多放置几个阴人位置,这样就能增长破n解的难度了!
oop
要对IAT加密前提条件是对PE文件比较熟悉。
IAT也就是导入函数的地址表,程序在加载到内存后IAT中填充的都是函数的地址,使用OD打开随意exe文件,我这就打开QQ.exe,找到第一个HEX数据是FF15开头的CALL代码,右键查看内存地址。
从图中知:若是IAT没加密反汇编代码一目了然就能看见用的的什么API,加密的目的就是即便调用函数也不能一眼就看出调用的是什么函数(有字符串提示)。
内存窗口的地址是IAT每一个元素的地址,数值一列是IAT存储的数据,注释中也解释了它们是什么API的地址。
IAT加密原理就是:测试
加密后再查看反汇编代码就没有字符串提示了,再查看该地址内存数据也没有字符串注释了,效果图以下:
优化
在汇编窗口Ctrl+G输入04A90000查看,其中的代码有花指令以下:
去掉花指令后其汇编代码最终成功调用真正函数的地址了,就是:
具体操做就是在壳代码解压缩,解密后,再进行IAT修复加密,遍历IAT代码我就不解释了。
IAT加密源码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
|
/
/
修复并加密IAT
void MendIAT()
{
HMODULE hBase
=
(HMODULE)g_hModule;
auto pImport
=
(PIMAGE_IMPORT_DESCRIPTOR)(g_Sc.dwImportRVA
+
(DWORD)hBase);
/
/
外层遍历模块
while
(pImport
-
>Name)
{
/
/
获取当前模块地址
HMODULE hModule
=
MyLoadLibraryExA((char
*
)(pImport
-
>Name
+
(DWORD)hBase),
0
,
0
);
if
(pImport
-
>FirstThunk)
{
/
/
IAT的地址
PDWORD IAT
=
PDWORD(pImport
-
>FirstThunk
+
(DWORD)hBase);
DWORD ThunkRva
=
0
;
if
(pImport
-
>OriginalFirstThunk
=
=
0
)
ThunkRva
=
pImport
-
>FirstThunk;
else
ThunkRva
=
pImport
-
>OriginalFirstThunk;
PIMAGE_THUNK_DATA pThunk
=
(PIMAGE_THUNK_DATA)(ThunkRva
+
(DWORD)hBase);
/
/
函数的名字
char
*
dwFunName
=
0
;
/
/
内层遍历模块中的函数
while
(pThunk
-
>u1.Ordinal)
{
/
/
序号导入
if
(pThunk
-
>u1.Ordinal &
0x80000000
)
{
dwFunName
=
(char
*
)(pThunk
-
>u1.Ordinal &
0x7fffffff
);
}
/
/
名称导入
else
{
PIMAGE_IMPORT_BY_NAME pImportByName
=
(PIMAGE_IMPORT_BY_NAME)
(pThunk
-
>u1.Ordinal
+
(DWORD)hBase);
dwFunName
=
pImportByName
-
>Name;
}
/
/
获取每一个函数的地址
DWORD dwFunAddr
=
(DWORD)SysGetProcAddress(hModule, dwFunName);
/
/
*
*
加密函数地址
*
*
dwFunAddr ^
=
0x13973575
;
LPVOID AllocMem
=
(PDWORD)MyVirtualAlloc(NULL,
0x20
, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
/
/
构造一段花指令解密的ShellCode
byte OpCode[]
=
{
0xe8
,
0x01
,
0x00
,
0x00
,
0x00
,
0xe9
,
0x58
,
0xeb
,
0x01
,
0xe8
,
0xb8
,
0x8d
,
0xe4
,
0xd8
,
0x62
,
0xeb
,
0x01
,
0x15
,
0x35
,
0x75
,
0x35
,
0x97
,
0x13
,
0xeb
,
0x01
,
0xff
,
0x50
,
0xeb
,
0x02
,
0xff
,
0x15
,
0xc3
};
/
/
把dwFunAddr写入到解密的ShellCode中
OpCode[
11
]
=
dwFunAddr;
OpCode[
12
]
=
dwFunAddr >>
0x8
;
OpCode[
13
]
=
dwFunAddr >>
0x10
;
OpCode[
14
]
=
dwFunAddr >>
0x18
;
/
/
拷贝数据到申请的内存
MyRtlMoveMemory(AllocMem, OpCode,
0x20
);
/
/
修改保护属性
DWORD dwProtect
=
0
;
MyVirtualProtect(IAT,
4
, PAGE_EXECUTE_READWRITE, &dwProtect);
/
/
把获取到的加密函数地址填充在导入地址表里面
*
(IAT)
=
(DWORD)AllocMem;
MyVirtualProtect(IAT,
4
, dwProtect, &dwProtect);
+
+
IAT;
+
+
pThunk;
}
}
+
+
pImport;
}
}
|
为何要进行Hash加密?由于在逆向工做者逆向的过程当中,字符串信息对他们来讲很重要,若是看见了一个API函数的字符串,那么他大概就能知道这段代码大概的功能了,垂手可得就能破0解掉,为了阻止这种事件发生,那么Hash加密在这就能发挥出很大做用。
众所周知1个字节是8位,这表明他表示2的8次方个数,也就是256种可能,若是咱们把它的一个数据表明一个系统中的函数(API),至关于给函数一个序号,那么1个字节就能存储256个函数的信息,那2个字节就能存储2的16次方也就是65536个API函数,这真是大大的好消息, windows系统中的API函数也就几千个,2个字节存储其所有API函数信息真是绰绰有余。
而让这2个字节的数据表明一个函数,这个数据咱们称它为Hash值,所以须要设计一个算法。我在这设计是方法是定义一个2字节类型(short)的数据,分别把nHash值先左移11位再右移5位后相加,再加上API函数中一个字符的Ascii码,以此循环遍历完整个API函数的全部字符,获得一个咱们须要的Hash值。在以前写壳基础篇中提到过壳代码中的API是动态获取的,那么咱们在动态获取的时候使用Hash值更能提升隐蔽性,使破0解者不易发现咱们所要使用的是哪一个函数。
具体Hash加密代码以下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
#include "pch.h"
#include <iostream>
int
main()
{
while
(true)
{
/
/
用于保存
Hash
值
unsigned short nHash
=
0
;
char arr[
50
]
=
{},
*
p;
p
=
arr;
printf(
"请输入API: "
);
scanf_s(
"%s"
, arr,
50
);
while
(
*
p)
{
/
/
先左移
11
位再右移
5
位相加后再加上该字符的Ascii
nHash
=
((nHash <<
11
) | (nHash >>
5
));
nHash
=
nHash
+
*
p;
p
+
+
;
}
printf(
"Hash值为:0x%X\n"
, nHash);
}
return
0
;
}
|
使用方法是首先使用上述代码对咱们须要使用API函数进行Hash加密获得Hash值,而后再写一个Hash值对比字符串的函数(解密),使用该值和系统中的API函数对比,和谁相等,咱们就把这个函数的地址获取取出。这样咱们就隐晦的获得了所需的函数的地址。
Hash解密代码以下,须要传入2个参数,1是对比函数的地址,2是Hash值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
/
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
Hash
对比
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
/
_Hash_CmpString:
/
/
(char
*
strFunName,
int
nDigest)
push ebp;
mov ebp, esp;
sub esp,
0x4
;
push ebx;
push ecx;
push edx;
mov dword ptr[ebp
-
0x4
],
0
;
xor eax, eax;
xor ecx, ecx;
mov esi, [ebp
+
0x8
];
/
/
strFunName
_Start:
mov al, [esi
+
ecx];
test al, al;
jz _End;
mov edi, [ebp
-
0x4
];
shl edi,
0xb
;
/
/
11
的
16
进制为b
mov edx, [ebp
-
0x4
];
shr edx,
0x5
;
or
edi, edx;
add edi, eax;
mov[ebp
-
0x4
], edi;
inc ecx;
jmp _Start;
_End:
mov esi, [ebp
+
0xc
];
/
/
获取
hash
值
and
edi,
0xffff
;
/
/
取低
16
位
cmp
edi, esi;
/
/
对比
hash
mov eax,
0x1
;
je _Over;
xor eax, eax;
_Over:
pop edx;
pop ecx;
pop ebx;
mov esp, ebp;
pop ebp;
ret
0x8
;
|
上述代码有大大的优化空间,比较懒我就不弄了。
有了Hash加解密,就能够本身实现一个GetProcAddress函数了,在这以后须要获取任何API函数就用本身实现的GetProcAddress函数,这样就是达到更加隐蔽的获取API函数的目的,学会了Hash加解密咱也就脱离了小白的行列了。
代码以下,参数1是所需API的模块基址,参数2是Hash值:(纯汇编获取更能锻炼基本功!)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
|
/
/
自写的GetProcAddress
DWORD MyGetProcAddress(HMODULE hModule,
int
nDigest)
{
DWORD GetProcAddr
=
0
;
__asm
{
jmp _Start_Fun;
/
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
自写的GetProcAddress
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
/
_Fun_GetProcAddress:
/
/
(dword ImageBase,
int
nDiegst)
push ebp;
mov ebp, esp;
sub esp,
0xc
;
push edx;
/
/
保存寄存器
push ebx;
mov edx, [ebp
+
0x8
];
/
/
DLL基地址如kernel32
mov esi, [edx
+
0x3c
];
/
/
Dos头的e_lfanew
lea esi, [edx
+
esi];
/
/
PE头VA(NT头)
mov esi, [esi
+
0x78
];
/
/
Import表的RVA
lea esi, [edx
+
esi];
/
/
Import表的VA
mov edi, [esi
+
0x1c
];
/
/
EAT的RVA .AddressOfFunctions
lea edi, [edx
+
edi];
/
/
EAT的VA
mov[ebp
-
0x4
], edi;
/
/
EAT的VA保存到局部变量
1
mov edi, [esi
+
0x20
];
/
/
ENT的RVA AddressOfNames
lea edi, [edx
+
edi];
/
/
ENT的VA
mov[ebp
-
0x8
], edi;
/
/
ENT的VA
-
>Local2
mov edi, [esi
+
0x24
];
/
/
EOT的RVA
lea edi, [edx
+
edi];
/
/
EOT的VA
mov[ebp
-
0xc
], edi;
/
/
EOT的VA
-
>Local3
xor ecx, ecx;
jmp _First;
_Begin:
inc ecx;
_First:
mov esi, [ebp
-
0x8
];
/
/
ENT
mov esi, [esi
+
ecx
*
4
];
/
/
EN RVA
lea esi, [edx
+
esi];
/
/
EN VA
push[ebp
+
0xc
];
push esi;
call _Hash_CmpString;
test eax, eax;
jz _Begin;
/
/
不是则循环
mov esi, [ebp
-
0xc
];
/
/
EOT
xor ebx, ebx;
mov bx, [esi
+
ecx
*
2
];
/
/
函数所对应的序号
mov esi, [ebp
-
0x4
];
/
/
EAT
mov esi, [esi
+
ebx
*
4
];
/
/
EA RVA
lea eax, [edx
+
esi];
/
/
函数的地址
pop ebx;
pop edx;
mov esp, ebp;
pop ebp;
retn
0x8
;
/
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
Hash
对比
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
/
_Hash_CmpString:
/
/
(char
*
strFunName,
int
nDigest)
push ebp;
mov ebp, esp;
sub esp,
0x4
;
push ebx;
push ecx;
push edx;
mov dword ptr[ebp
-
0x4
],
0
;
xor eax, eax;
xor ecx, ecx;
mov esi, [ebp
+
0x8
];
/
/
strFunName
_Start:
mov al, [esi
+
ecx];
test al, al;
jz _End;
mov edi, [ebp
-
0x4
];
shl edi,
0xb
;
mov edx, [ebp
-
0x4
];
shr edx,
0x5
;
or
edi, edx;
add edi, eax;
mov[ebp
-
0x4
], edi;
inc ecx;
jmp _Start;
_End:
mov esi, [ebp
+
0xc
];
/
/
获取
hash
值
and
edi,
0xffff
;
cmp
edi, esi;
/
/
对比
hash
mov eax,
0x1
;
je _Over;
xor eax, eax;
_Over:
pop edx;
pop ecx;
pop ebx;
mov esp, ebp;
pop ebp;
ret
0x8
;
_Start_Fun:
pushad;
push nDigest;
push hModule;
call _Fun_GetProcAddress;
mov GetProcAddr, eax;
popad;
}
return
GetProcAddr;
}
|
动态获取函数例子(下面的Hash值是乱填的,意思意思下):
1
2
3
|
MyLoadLibraryExA
=
(FuLoadLibraryExA)MyGetProcAddress(g_hKernel32,
0xC0D8
);
g_hUser32
=
MyLoadLibraryExA(
"user32.dll"
,
0
,
0
);
MyMessageBoxW
=
(FuMessageBoxW)MyGetProcAddress(g_hUser32,
0x1E38
);
|
加入动态解密的壳,这无疑是强度较高的壳了,它可以在目标程序运行起来以后,动态的对代码段进行解密。先运行一段代码解密后一部分的代码,而后再运行解密后的代码,能够往复循环,这样破0解者只能看见运行着的代码的附近的代码,隔得远的代码处于加密状态,这样就须要花费大量的时间才能破0解了,固然想要实现这种高强度,仍是须要花费不少时间去设计的,并且要求咱们对x86汇编语言有比较深入理解,这我就分享下我对动态解密理解。
下面我直接根据一个案列来分析动态解密流程:
为了方便演示效果,我在VS中用汇编以动态获取API方式写了一段功能是弹窗的代码,效果如图
将生成EXE文件用0x32dbg(或者OD)打开后找到弹窗功能的汇编代码,扣取出该段代码16进制字节。扣取字节的操做是在x32dbg中选中该段代码后,右键->复制->数据->C样式ShellCode字符串
OD中选中代码后右键->数据转换->C++->字节
把这些字节存在一个字符串数组中,直接用我这个就好了:
1
|
char ShellCode[]
=
"\x60\x83\xEC\x60\xEB\x55\x4D\x65\x73\x73\x61\x67\x65\x42\x6F\x78\x41\x00\x45\x78\x69\x74\x50\x72\x6F\x63\x65\x73\x73\x00\x4C\x6F\x61\x64\x4C\x69\x62\x72\x61\x72\x79\x45\x78\x41\x00\x47\x65\x74\x50\x72\x6F\x63\x41\x64\x64\x72\x65\x73\x73\x00\x75\x73\x65\x72\x33\x32\x2E\x64\x6C\x6C\x00\x48\x65\x6C\x6C\x6F\x20\x47\x72\x65\x61\x74\x20\x4E\x61\x74\x75\x72\x65\x21\x00\xD9\xEE\xD9\x74\x24\xF4\x5A\x64\x8B\x35\x30\x00\x00\x00\x8B\x76\x0C\x8B\x76\x1C\x8B\x36\x8B\x5E\x08\x52\x53\xE8\x5A\x00\x00\x00\x8B\xC8\x51\x52\x8D\x42\xC3\x50\x53\xFF\xD1\x5A\x59\x52\x50\x51\x53\xE8\x01\x00\x00\x00\x61\x55\x8B\xEC\x83\xEC\x0C\x8B\x55\x14\x33\xC9\x8D\x72\xE1\x51\x51\x56\xFF\x55\x10\x8B\x55\x14\x8D\x4A\xAB\x51\x50\xFF\x55\x0C\x33\xC9\x8B\x55\x14\x8D\x5A\xEC\x51\x53\x53\x51\xFF\xD0\x8B\x55\x14\x8D\x72\xB7\x56\xFF\x75\x08\xFF\x55\x0C\x51\xFF\xD0\x8B\xE5\x5D\xC2\x10\x00\x55\x8B\xEC\x83\xEC\x0C\x52\x53\x8B\x55\x08\x8B\x72\x3C\x8D\x34\x32\x8B\x76\x78\x8D\x34\x32\x8B\x7E\x1C\x8D\x3C\x3A\x89\x7D\xFC\x8B\x7E\x20\x8D\x3C\x3A\x89\x7D\xF8\x8B\x7E\x24\x8D\x3C\x3A\x89\x7D\xF4\x33\xC0\xEB\x01\x40\x8B\x75\xF8\x8B\x34\x86\x8D\x34\x32\x8B\x5D\x0C\x8D\x7B\xD2\xB9\x0E\x00\x00\x00\xF3\xA6\x75\xE7\x8B\x75\xF4\x33\xDB\x66\x8B\x1C\x46\x8B\x75\xFC\x8B\x34\x9E\x8D\x04\x32\x5B\x5A\x8B\xE5\x5D\xC2\x08\x00"
;
|
在0x32dbg(或者OD)中选中汇编代码段后右下角会显示选中的字节大小。
下面写一个加密该字符串的代码,编译的时候VS项目属性中配置“C/C++ -> 代码生成 -> 安全检查(禁用GS)”,“链接器 -> 高级 -> 数据执行保护DEP(关闭)”。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
/
/
加密函数
void Encoder(char
*
pData,
int
nSize)
{
/
/
加密密钥
int
nOutKey
=
0x15
;
/
/
加密后的缓冲区
unsigned char
*
pBuffer
=
NULL;
pBuffer
=
(unsigned char
*
)new char[nSize
+
1
];
/
/
对每一个字节进行加密
for
(
int
j
=
0
; j < nSize; j
+
+
)
{
pBuffer[j]
=
pData[j] ^ nOutKey;
}
/
/
打印出每一个字节
for
(
int
i
=
0
; i < nSize; i
+
+
)
{
printf(
"\\x%02X"
,pBuffer[i]);
}
}
int
main()
{
/
/
调用ShellCode查看是否能正常运行
__asm {
lea eax, ShellCode;
push eax;
ret;
}
/
/
加密
Encoder(ShellCode,
257
);
getchar();
}
|
加密后会获得一个字节数组,咱们把它复制下来存到另外一个数组中,而后把他放在解密代码的屁股后面。
开始写解密代码,解密代码才是动态解密中的核心点,重中之重。
这里要说一下GetPC技术,GetPC技术翻译为中文也就是获取指针计数器。在x86汇编中实际上就是获取当前代码EIP的技术。我这用的是call 指令,call xxx指令至关于 push 下一行代码的EIP + jmp xxx。 那么咱们直接把XXX改成下一行指令的地址就能获取当前EIP 内联汇编代码为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
/
/
解密函数
__asm {
call _Next;
/
/
跳到下一行,并把EIP压入栈
_Next:
pop eax;
/
/
得到当前EIP
lea esi, [eax
+
0x22
];
/
/
生成后在OD中查看上一行到最后一行解密代码的长度
xor ecx, ecx;
mov cx,
0x13e
;
/
/
要解密的字节长度
_DeCode:
mov al, byte ptr ds : [esi
+
ecx];
xor al,
0x15
;
/
/
解密密钥
mov byte ptr ds : [esi
+
ecx], al;
loop _DeCode;
xor[esi
+
ecx],
0x15
;
jmp esi;
}
|
这里要注意的是这段代码后面要紧跟加密后的代码。 在实际的壳代码中,先把须要加密的代码加密后和解密代码组装起来就能够达到动态解密的功能。