VB一直以来被认为有以下优缺点:优点是上手快、开发效率高;缺点是能力有限,运行效率低。VB被其它语言的拥护者诟病的有很多,不支持指针,不支持重载,不支持内联汇编等等等等。当我们享受着VB的简单时,却发现我们的发挥空间越来越小。的确,简单和功能强大这两者本身就是一对矛盾。那怕一行代码不写,仅仅起动运行一个空窗体这样简单动作,VB在底下就为我们做了大量复杂的工作(决不仅仅是注册窗口类、显示窗口、起动消息循环这么简单),而这些工作对程序员来说是透明的。
由于本人的水平有限,以及相关硬件的制约(我的台式电脑CPU还是PII450,只能装WIN2000),以下的结论没特殊声明只对WIN2000有效。
好了,开始吧!我们需要的软件,VB6+SP6,反编译软件W32dsm89(VC也行);相关知识:熟悉VB,最好知道C语言中的指针,堆栈,当然,不懂也不要紧,相关知识笔者都会一一给出解释的。
一.基本概念
1、CopyMemory
如CopyMemory的声明,它是定义在Kernel32.dll中的RtlMoveMemory这个API,32位C函数库中的memcpy就是这个API的包装。它的功能是将从Source指针所指处开始的长度为Length的内存拷贝到Destination所指的内存处。它不会管我们的程序有没有读写该内存所应有的权限,一但它想读写被系统所保护的内存时,我们就会得到著名的Access Violation Fault(内存越权访问错误),甚至会引起更著名的general protection (GP) fault(通用保护错误) 。所以,在进行本系列文章里的实验时,请注意随时保存你的程序文件,在VB集成环境中将"工具"->"选项"中的"环境"选项卡里的"启动程序时"设为"保存改变",并记住在"立即"窗口中执行危险代码之前一定要保存我们的工作成果。
2、VatPtr/StrPtr
它们是VB提供给我们的宝贝,它们是VBA函数库中的隐藏函数。VarPtr返回的是变量的地址,StrPtr返回的是BSTR指向的Unicode字符数组的地址。下面详细阐述一下BSTR。
假设变量str位于地址aaaa处,而这个字符数组在地址xxxx处,它是变量str的内容。
为了看到以下的内容:
VarPtr=aaaa
StrPtr=xxxx
我们只要运行以下的代码:
Dim lng as Long
Dim I as Integer
Dim s as String
Dim b(1 to 10) as Byte
Dim sp as Long, vp as Long
S=”Help”
sp=StrPtr(s)
Debug.Print “StrPtr:” & sp
vp=VarPtr(s)
Debug.Print “VarPtr:” & vp
‘验证vp=aaaa和sp=xxxx
CopyMemory lng,Byval vp,4
Debug.print lng=sp
‘查看sp包含的字符数组的地址,从那个地址复制一个字节数组然后打印
CopyMemory b(1),ByVal sp,10
For I=1 to 10
Debug.print b(i)
Next I
输出结果是:
StrPtr=xxxx
VarPtr=aaaa
True
104 0 101 0 108 0 112 0 0 0
为什么要隐藏VatPtr/StrPtr?因为VB开发小组不鼓励我们用指针。以下就是VarPtr函数在C和汇编语言里的样子:
在C里样子是这样的:
long VarPtr(void* pv){
return (long)pv;
}
所对就的汇编代码就两行:
mov eax,dword ptr [esp+4]
ret 4 '弹出栈里参数的值并返回。
之所以让大家了解VarPtr的具体实现,是想告诉大家它的开销并不大,因为它们不过两条指令,即使加上参数赋值、压栈和调用指令,整个获取指针的过程也就六条指令。当然,同样的功能在C语言里,由于语言的直接支持,仅需要一条指令即可。但在VB里,它已经算是最快的函数了,所以我们完全不用担心使用VarPtr会让我们失去效率!速度是使用指针技术的根本要求。
一句话,VarPtr返回的是变量所在处的内存地址,也可以说返回了指向变量内存位置的指针,它是我们在VB里处理指针最重要的武器之一。
3、ByVal和ByRef
ByVal传递的参数值,而ByRef传递的参数的地址。在一般程序中我们很少关心两者的区别,就算是传递了参数地址,只要在代码中小心不出现赋值语句,也是没有影响的。但是在一些api的应用中,规定要ByVal应用的,典型的应用就是CopyMemory。
'体会ByVal和ByRef
Sub TestCopyMemory()
Dim l As Long
l = 5
Note: CopyMemory ByVal VarPtr(l), 40000, 4
Debug.Print l
End Sub
上面标号Note处的语句的目的,是将l赋值为40000,等同于语句l=40000,你可以在"立即"窗口试验一下,会发现l的值的确成了40000。
实际上上面这个语句,翻译成白话:
-----------------------------------------------------------------
就是从保存常数40000的临时变量处拷贝4个字节到变量k所在的内存中。
-----------------------------------------------------------------
现在我们来改变一个Note处的语句,若改成下面的语句:
Note2: CopyMemory ByVal VarPtr(l), ByVal 40000, 4
这句话的意思就成了,从地址40000拷贝4个字节到变量l所在的内存中。由于地址40000所在的内存我们无权访问,操作系统会给我们一个Access Violation内存越权访问错误,告诉我们"试图读取位置0x00009c40处内存时出错,该内存不能为'Read'"。
我们再改成如下的语句看看。
Note3: CopyMemory VarPtr(l), 40000, 4
这句话的意思就成了,从保存常数40000的临时变量处拷贝4个字节到到保存变量k所在内存地址值的临时变量处。这不会出出内存越权访问错误,但k的值并没有变。
我们可以把程序改改以更清楚的休现这种区别:
'看看我们的东西被拷贝到哪儿去了
Sub TestCopyMemory()
Dim i As Long, l As Long
l = 5
i = VarPtr(l)
NOTE4: CopyMemory i, 40000, 4
Debug.Print l
Debug.Print i
i = VarPtr(l)
NOTE5: CopyMemory ByVal i, 40000, 4
Debug.Print l
End Sub
程序输出:
5
40000
40000
由于NOTE4处使用缺省的ByRef,传递的是i的地址(也就是指向i的指针),所以常量40000拷贝到了变量i里,因此i的值成了40000,而l的值却没有变化。但是,在NOTE4前有:i=VarPtr(l),本意是要把i本身做为一个指针来使用。这时,我们必须如NOTE5那样用ByVal来传递指针i,由于i是指向变量l的指针,所以最后常量40000被拷贝了变量l里。
希望你已经理解了这种区别,在后面问题的讨论中,我们会使用上述的概念。
4.堆栈
对在子程序调用的过程中进行参数传递的概念和分析。
一般在程序中,参数的传递是通过堆栈进行的,也就是说,调用者把要传递给子程序(或者被调用者)的参数压入堆栈,子程序在堆栈取出相应的值再使用,比如说,如果你要调用 MessageBox(hWnd,lpText,lpCaption,UType),编译后的最终代码可能是:
push MB_OK
push offset szCaption
push offset szText
push hWnd
call MessageBox
也就是说,调用者首先把参数压入堆栈,然后调用子程序,在完成后,由于堆栈中先前压入的数不再有用,调用者或者被调用者必须有一方把堆栈指针修正到调用前的状态。参数是最右边的先入堆栈还是最左边的先入堆栈、还有由调用者还是被调用者来修正堆栈都必须有个约定,不然就会产生不正确的结果,这就是我在前面使用“可能”这两个字的原因:各种语言中调用子程序的约定是不同的。在C和VB中的缺省约定是: StdCall,也就是说,在 API 或子程序中,最右边的参数先入堆栈,然后子程序在返回的时候负责校正堆栈。下面看看子函数/过程被调用时的堆栈情况:
parameter n (第n个参数,最右边的参数)
...
parameter 2(第2个参数)
parameter 1 (第1个参数)
return address (返回地址)
二、函数指针的模拟
VB可以用Declare声明来调用标准DLL的外部函数,但是其局限性也很明显:利用Declare我们只能载入在设计时通过Lib和Alias字句指定的函数指针!而不能在运行时指定由我们自己动态载入的函数指针),不能用Declare语句来调用任意的函数指针。当我们想动态调用外部函数的时候,就必须考虑采用其他的辅助方法,来完成这个任务了。
以下是摘自网上的一段VB调用ASM来实现函数指针的代码和解释。
原理:
1)使用LoadLibrary加载DLL;
2)GetProcAddress获得函数指针;
以上两步得到了预加载函数的指针,但是VB中没有提供使用这个指针的方法。我们可以通过一段汇编语言,来完成函数指针的调用!
3)通过汇编语言,把函数的所有参数压入堆栈,然后用Call待(调)用函数指针就可以了。
实现以上功能的主要程序:
'加载Dll
LibAddr = LoadLibrary(ByVal "user32")
'获得函数指针
ProcAddr = GetProcAddress(LibAddr, ByVal "MessageBoxA")
'原型为MessageBox(hWnd, lpText, lpCaption, uType)
'---以下为Assembly部分---
push uType
push lpCaption
push lpText
push hWnd
call ProcAddr
'--------------------
FreeLibrary LibAddr'释放空间
下面是动态调用MessageBoxA的源代码,上面的步骤被封装到RunDll32函数中,可放到模块(CallAPIbyName.bas)中:
Dim s1() As Byte, s2() As Byte
Dim ret As Long
s1 = StrConv("Hello~World", vbFromUnicode)
s2 = StrConv("VBNote", vbFromUnicode)
ret = RunDll32("user32", "MessageBoxA", hwnd, VarPtr(s1(0)), VarPtr(s2(0)), 0&)
CallAPIbyName.bas中的源代码:
Option Explicit
Private Declare Function LoadLibrary Lib "kernel32" Alias "LoadLibraryA" (ByVal lpLibFileName As String) As Long
Private Declare Function GetProcAddress Lib "kernel32" (ByVal hModule As Long, ByVal lpProcName As String) As Long
Private Declare Function CallWindowProc Lib "User32" Alias "CallWindowProcA" (ByVal lpPrevWndFunc As Long, ByVal hWnd As Long, ByVal Msg As Long, ByVal wParam As Long, ByVal lParam As Long) As Long
Private Declare Function FreeLibrary Lib "kernel32" (ByVal hLibModule As Long) As Long
Private Declare Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (lpDest As Any, lpSource As Any, ByVal cBytes As Long)
Public m_opIndex As Long '写入位置
Private m_OpCode() As Byte 'Assembly 的OPCODE
Public Function RunDll32(LibFileName As String, ProcName As String, ParamArray Params()) As Long
Dim hProc As Long
Dim hModule As Long
ReDim m_OpCode(400 + 6 * UBound(Params)) '保留用来写m_OpCode
'读取API库
hModule = LoadLibrary(ByVal LibFileName)
If hModule = 0 Then
MsgBox "Library读取失败!"
Exit Function
End If
'取得函数地址
hProc = GetProcAddress(hModule, ByVal ProcName)
If hProc = 0 Then
MsgBox "函数读取失败!", vbCritical
FreeLibrary hModule
Exit Function
End If
'执行Assembly Code部分
RunDll32 = CallWindowProc(GetCodeStart(hProc, Params), 0, 1, 2, 3)
FreeLibrary hModule '释放空间
End Function
Private Function GetCodeStart(ByVal lngProc As Long, ByVal arrParams As Variant) As Long
'---以下为Assembly部分--
'作用:将函数的参数压入堆栈
Dim lngIndex As Long, lngCodeStart As Long
'程序起始位址必须是16的倍数
'VarPtr函数是用来取得变量的地址
lngCodeStart = (VarPtr(m_OpCode(0)) Or &HF) + 1
m_opIndex = lngCodeStart - VarPtr(m_OpCode(0)) '程序开始的元素的位置
'前面部分以中断点添满
For lngIndex = 0 To m_opIndex - 1
m_OpCode(lngIndex) = &HCC 'int 3
Next lngIndex
'--------以下开始放入所需的程序----------
'将参数push到堆栈
'由于是STDCall CALL 参数由最后一个开始放到堆栈
For lngIndex = UBound(arrParams) To 0 Step -1
AddByteToCode &H68 'push的机器码为H68
AddLongToCode CLng(arrParams(lngIndex)) '参数地址
Next lngIndex
'call hProc
AddByteToCode &HE8 'call的机器码为HE8
AddLongToCode lngProc - VarPtr(m_OpCode(m_opIndex)) - 4 '函数地址 用call的定址
'-----------结束所需的程序--------------
'返回呼叫函數
AddByteToCode &HC2 'ret 10h
AddByteToCode &H10
AddByteToCode &H0
GetCodeStart = lngCodeStart
End Function
Private Sub AddLongToCode(lData As Long)
'将Long类型的参数写到m_OpCode中
CopyMemory m_OpCode(m_opIndex), lData, 4
m_opIndex = m_opIndex + 4
End Sub
Private Sub AddIntToCode(iData As Byte)
'将Integer类型的参数写道m_OpCode中
CopyMemory m_OpCode(m_opIndex), iData, 2
m_opIndex = m_opIndex + 2
End Sub
Private Sub AddByteToCode(bData As Byte)
'将Byte类型的参数写道m_OpCode中
m_OpCode(m_opIndex) = bData
m_opIndex = m_opIndex + 1
End Sub
需要解释的是Call的调用,反编译成汇编是Call XXXXXXXX,对应的ASM则是E8 AAAAAAAA。注意Call指令是相对的跳转,Cpu在解释执行到E8 AAAAAAAA时是基于当前的地址的,AAAAAAAA=XXXXXXXX-(当前的地址),这就解释了以下这句代码:AddLongToCode lngProc - VarPtr(m_OpCode(m_opIndex)) - 4。
好了,相信看懂上一节内容的读者结合刚才的解释对上述代码理解起来也是比较容易的。
注:从Visual Basic 5.0开始Basic语言引入了一个重要的特性:AddressOf运算符。这个运算符能够让VB程序员直接将自己的函数指针送出。但是,我们可以送出函数指针,但却没人能将函数指针送给我们。事实上,我们甚至不能给我们自己送函数指针,因为VB根本就不支持调用函数指针。(好拗口啊!)
三、可执行文件的自删除
(一) Bat文件法
假设要删除的是C:\MYDIR\SelfDel.EXE
新建一个.bat文件,在文件中写入下面的代码:
:Repeat
del "C:\MYDIR\ SelfDel.EXE"
if exist " SelfDel.EXE" goto Repeat
rmdir "C:\MYDIR"
del "\DelUS.bat"
以上代码是一个循环体,在循环中调用删除文件的方法,一直到文件被删除为止。具体代码我就不解释了。不过它又两个缺点:首先,可执行文件是被删除了,但是多了个.Bat文件,还是删不干净,这和我们的初衷不符。其次,对于中间有空格的可执行文件,如“Self Del.exe”这样调用就不能删除了。
(二) 重启删除法
MoveFileEx "C:\MYDIR\ SelfDel.EXE ",0,MOVEFILE_DELAY_UNTIL_REBOOT 这样,操作系统把要删除的文件登记,在下次重启时把它删除。
这可以说部分实现了我们的愿望。事实上,很多的商业软件都在安装完成时要求“请重新启动计算机以完成安装过程!”,就是用重启来删除相关的文件的。好了,这种方法的缺点也是显而易见的,必须重启电脑才能完成。同样不是我们想要的。
(三) 堆栈法
在给出具体代码以前,我们现说说相关原理。
在Win32中,执行程序最终都是要调用CreateProcess进行。
BOOL CreateProcess(
LPCTSTR lpApplicationName,
// pointer to name of executable module
LPTSTR lpCommandLine, // pointer to command line string
LPSECURITY_ATTRIBUTES lpProcessAttributes, // process security attributes
LPSECURITY_ATTRIBUTES lpThreadAttributes, // thread security attributes
BOOL bInheritHandles, // handle inheritance flag
DWORD dwCreationFlags, // creation flags
LPVOID lpEnvironment, // pointer to new environment block
LPCTSTR lpCurrentDirectory, // pointer to current directory name
LPSTARTUPINFO lpStartupInfo, // pointer to STARTUPINFO
LPPROCESS_INFORMATION lpProcessInformation // pointer to PROCESS_INFORMATION
);
当你在lpApplicationName中传入可执行文件的字符串时,操作系统会做以下的事情:
1. 找到可执行文件
2. 调用CreateFile()函数打开文件句柄
3. 用打开的句柄调用CreateFileMapping(),将程序的代码和数据映射到进程的地址空间中。
知道了上述步骤,想来你就认为删除文件就是反过来做,
1. 调用UnmapViewOfFile解除文件映射
2. 调用CloseHanle()关闭打开的文件句柄
3. DeleteFile()删除文件
(上述步骤1与步骤2可以互换)
听起来很完美,不是吗?但实际上并不能完成我们的目标,Why?当UnmapViewOfFile将可执行文件的模块从内存卸载后,模块所在的内存空间已经还给操作系统了,就算是执行指令的Eip还是指向原来的地址,准备执行CloseHandle或者是DeleteFile,但是那个地址已经被操作系统保护起来了,“皮之不存,毛将焉附”。看来我们到了一条死胡同。不过,进程结束时操作系统可不仅仅是关闭文件句柄,解除文件内存映象,还要恢复堆栈,释放对dll文件的引用,这样,我们就可以把代码放在堆栈中来执行。
24 0
20 0
16 offset buf
12 address of ExitProcess
8 module
4 address of DeleteFile
0 address of UnmapViewOfFile
module是模块的句柄,buf是可执行文件的路径。当调用RET返回到了UnmapViewOfFile,也就是栈里的偏移0所指的地方.当进入UnmapViewOfFile的流程时,栈里见到的是返回地址DeleteFile和module.也就是说调用完毕后返回到了DeleteFile的入口地址.当返回到DeleteFile时,看到了ExitProcess的地址,也就是返回地址.和参数EAX,而EAX则是buffer.buffer存的是EXE的文件名.由GetModuleFileName(module, buf, benb(buf))返回得到.执行了DeleteFile后,就返回到了ExitProcess的函数入口.并且参数为0而返回地址也是0.0是个非法地址.如果返回到地址0则会出错.而调用ExitProcess则应该不会返回。
错不了,但我们还有一道难题,VB并不支持内联汇编,怎么才能构造这样一个堆栈并执行呢?我们可以申请一块可读写并执行内存,把我们的代码以二进制方式写入。当然可以,不过笔者这里向你介绍另外一种方法。我们可以构造这样一个函数并调用Public Sub DummyFun1(ByVal a1 As Long, ByVal a2 As Long, ByVal ptrToBuf As Long, ByVal ExitPro As Long, ByVal module As Long, ByVal delfile As Long, ByVal UnmapFile As Long),看看这时的堆栈:
28 0
24 0
20 offset buf
16 address of ExitProcess
12 module
8 address of DeleteFile
4 address of UnmapViewOfFile
0 address of return address
八九不离十了,就是多了个return address。接下来把这个return address清除掉就行了。还记得StdCall吗,“子函数负责清栈”,只要调用另一个函数,返回时使它多清除4个字节的堆栈空间就可以了。
以下是完整代码:
一个窗体,一个按钮控件
窗体代码:
Dim Addr_ExitProcess As Long
Dim Addr_DeleteFile As Long
Dim Addr_UnmapViewOfFile As Long
Dim hm As Long
Private Sub Form_Load()
'
lret As Long
hm = LoadLibrary(ByVal "Kernel32.dll")
'Print Hex(hm)
lret = GetProcAddress(hm, ByVal "UnmapViewOfFile")
Addr_UnmapViewOfFile = lret
'Print Hex(Addr_UnmapViewOfFile)
lret = GetProcAddress(hm, ByVal "DeleteFileA")
Addr_DeleteFile = lret
'Print Hex(Addr_DeleteFile)
lret = GetProcAddress(hm, ByVal "ExitProcess")
Addr_ExitProcess = lret
'Print Hex(Addr_ExitProcess)
End Sub
Private Sub Command1_Click()
Dim s As String, lret As Long
Dim hardcore As Long
hm = GetModuleHandle(vbNullString)
If hm = 0 Then MsgBox "Err get module "
'Print Hex(hm)
s = String$(MAX_PATH, 0)
lret = GetModuleFileName(hm, s, LenB(s))
s = StrConv(s, vbFromUnicode)
hardcore = 4
'Print s
Dim f1 As Long, f2 As Long, f3 As Long, f4 As Long
Call DummyFun2
f1 = GetFunAddress(AddressOf DummyFun1)
f2 = GetFunAddress(AddressOf DummyFun2)
Call MyCall2(f1, f2)
'Print f1
'Print f2
lret = CloseHandle(hardcore)
'If lret Then MsgBox "Close handle success"
'DestroyWindow Me.hwnd
Call DummyFun1(ByVal Addr_UnmapViewOfFile, ByVal Addr_DeleteFile, ByVal hm, ByVal Addr_ExitProcess, ByVal StrPtr(s), ByVal 0, ByVal 0)
End Sub
模块代码:
Public Declare Function GetProcAddress Lib "kernel32" (ByVal hModule As Long, ByVal lpProcName As String) As Long
Public Declare Function DestroyWindow Lib "user32" (ByVal hwnd As Long) As Long
Public Declare Function LoadLibrary Lib "kernel32" Alias "LoadLibraryA" (ByVal lpLibFileName As String) As Long
Public Declare Function GetModuleHandle Lib "kernel32" Alias "GetModuleHandleA" (ByVal lpModuleName As String) As Long
Public Declare Function CloseHandle Lib "kernel32" (ByVal hObject As Long) As Long
Public Declare Function GetModuleFileName Lib "kernel32" Alias "GetModuleFileNameA" (ByVal hModule As Long, ByVal lpFileName As String, ByVal nSize As Long) As Long
Public Declare Function WriteProcessMemory Lib "kernel32" (ByVal hProcess As Long, lpBaseAddress As Any, lpBuffer As Any, ByVal nSize As Long, lpNumberOfBytesWritten As Long) As Long
Public Declare Function GetCurrentProcess Lib "kernel32" () As Long
Public Const MAX_PATH = 260
Public Sub DummyFun1(ByVal a1 As Long, ByVal a2 As Long, ByVal ptrToBuf As Long, ByVal ExitPro As Long, ByVal module As Long, ByVal delfile As Long, ByVal UnmapFile As Long)
DummyFun1 = 1
DummyFun1 = 1
DummyFun1 = 1
DummyFun1 = 1
DummyFun1 = 1
DummyFun1 = 1
DummyFun1 = 1
End Sub
Public Sub DummyFun2()
DummyFun2 = 1
DummyFun2 = 1
DummyFun2 = 1
DummyFun2 = 1
DummyFun2 = 1
End Sub
Public Function GetFunAddress(ByVal FAddress As Long) As Long
GetFunAddress = FAddress
End Function
Public Sub MyCall2(lpFunOrigin As Long, lpFunReplace As Long)
Dim bt As Byte, b As Byte, i As Integer, b2 As Byte
Dim ln As Long
Dim lret As Long
bt = &HE8 'asm code for 'call'
b = &HC2 'asm code for 'ret 0040'
i = &H4
b2 = &HC3 'asm code for 'ret '
ln = lpFunReplace - lpFunOrigin
lret = WriteProcessMemory(GetCurrentProcess, ByVal lpFunOrigin, bt, 1, 0)
lret = WriteProcessMemory(GetCurrentProcess, ByVal (lpFunOrigin + 1), (ln - 5), 4, 0)
lret = WriteProcessMemory(GetCurrentProcess, ByVal (lpFunOrigin + 5), b2, 1, 0)
lret = WriteProcessMemory(GetCurrentProcess, ByVal (lpFunReplace), b, 1, 0)
lret = WriteProcessMemory(GetCurrentProcess, ByVal (lpFunReplace + 1), i, 2, 0)
End Sub
在调用了MyCall2后
DummyFun1在内存中的代码是:
Call DummyFun2
Ret
DummyFun2在内存中的代码是:
Ret 0040
当执行了DummyFun1中的Call DummyFun1后的堆栈:
32 0
28 0
24 offset buf
20 address of ExitProcess
16 module
12 address of DeleteFile
8 address of UnmapViewOfFile
4 address of return address
0 address of return address in DummyFun1
接下来执行ret 0040,这时Eip回到了DummyFun1中了,而堆栈中的address of return address被清除了,再执行就是我们的UnmapViewOfFile代码了。(注意:一定要生成Exe文件再执行,不要在VB的编译环境中执行上述代码,否则,后果自负)
怎么样,任务完成了,是不是很有成就感?但是这只能在windows2000下执行,在98/XP下就不行了。XP下由于handle(4)不再对应于EXE的IMAGE了,所以不能成功。98下只要把UnmapViewOfFile替换成FreeLibrary就行了。
(四) 远程线程法
这是本人知道的最复杂的方法,涉及进程、线程、代码的重定位等等,VB实现起来比较复杂,有实力、有兴趣的读者可以自行编写。
本文地址:http://com.8s8s.com/it/it44166.htm