首页
社区
课程
招聘
SetUnhandledExceptionFilter 的讨论
发表于: 2004-12-28 01:34 23564

SetUnhandledExceptionFilter 的讨论

2004-12-28 01:34
23564

现在有好多壳用 SetUnhandledExceptionFilter 安装了最后异常处理例程来愚弄 Ollydbg,
一开始确实难倒了我等菜鸟, 幸好后来有位俄罗斯高人写了个插件, 解决了这个问题, 但一直想知道原因.
最近抽空把 Hume 大侠的 SEH 文章反反复复看了好几遍, 又看了插件的 README, 总算有点明白了.
把他写出来, 请各位大侠看看, 多多指点.

(一) 发生异常时系统的处理顺序(by Jeremy Gordon, Hume):

    1.系统首先判断异常是否应发送给目标程序的异常处理例程,如果决定应该发送,并且目标程序正在被调试,则系统
    挂起程序并向调试器发送EXCEPTION_DEBUG_EVENT消息.

    2.如果你的程序没有被调试或者调试器未能处理异常,系统就会继续查找你是否安装了线程相关的异常处理例程,如果
    你安装了线程相关的异常处理例程,系统就把异常发送给你的程序seh处理例程,交由其处理.

    3.每个线程相关的异常处理例程可以处理或者不处理这个异常,如果他不处理并且安装了多个线程相关的异常处理例程,
        可交由链起来的其他例程处理.

    4.如果这些例程均选择不处理异常,如果程序处于被调试状态,操作系统仍会再次挂起程序通知debugger.

    5.如果程序未处于被调试状态或者debugger没有能够处理,并且你调用SetUnhandledExceptionFilter安装了最后异
    常处理例程的话,系统转向对它的调用.

    6.如果你没有安装最后异常处理例程或者他没有处理这个异常,系统会调用默认的系统处理程序,通常显示一个对话框,
    你可以选择关闭或者最后将其附加到调试器上的调试按钮.如果没有调试器能被附加于其上或者调试器也处理不了,系统
    就调用ExitProcess终结程序.

    7.不过在终结之前,系统仍然对发生异常的线程异常处理句柄来一次展开,这是线程异常处理例程最后清理的机会.

插件的 README:

THEORY
When  exception happens, Win2K/XP gives control to NTDLL!KiUserExceptionFilter,
which  in turn calls KERNEL32!UnhandledExceptionFilter. This function checks if
faulty  software  is  being  debugged.  If  not, UnhandledExceptionFilter calls
softwares' exception handler.

SOLUTION:
Patch  KERNEL32!UnhandledExceptionFilter  so that softwares' exception handler
is  always  called.  Used  signatures  work  for both Win2K SP4/WinXP SP1, and
hopefully for all other versions.

(二) 参考 Hume 的例子写的第一个 MASM 程序, 没有最终异常处理例程

;//============================  SEH1.EXE  ============================

.386
.model flat, stdcall
option casemap :none  ; case sensitive

include c:\masm32\include\windows.inc
include c:\masm32\include\kernel32.inc
include c:\masm32\include\user32.inc

includelib c:\masm32\lib\kernel32.lib
includelib c:\masm32\lib\user32.lib

;//============================

.data

szCap1     db "Thread SEH 1",0
szMsg1     db "We are in thread SEH handler 1.  ",0

szCap2     db "Thread SEH 2",0
szMsg2     db "We are in thread SEH handler 2.  ",0

szCap     db "SEH example",0
szMsg     db "It would never get here.",0

.code
start:

;//========prog begin====================

    ASSUME FS:NOTHING
    push    offset pThread_Handler1
    push    fs:[0]      
    mov     fs:[0],esp                            ;//建立SEH的基本ERR结构
   
    push    offset pThread_Handler2
    push    fs:[0]      
    mov     fs:[0],esp                            ;//建立SEH的基本ERR结构                                         
                                                   
    xor    ecx,ecx
    mov    eax,200     
    cdq
    div    ecx
                                                  ;//以下永远不会被执行
    invoke    MessageBox,NULL,addr szMsg,addr szCap,MB_OK+MB_ICONEXCLAMATION
    invoke    ExitProcess,NULL
        

;//============================
pThread_Handler1:
    invoke    MessageBox,NULL,addr szMsg1,addr szCap1,MB_OK+MB_ICONINFORMATION
   
    mov    eax,1                                 ;//ExceptionContinueSearch,不处理,由其他例程或系统处理
    ;mov    eax,0                                ;//ExceptionContinueExecution,表示已经修复,可从异常发生处继续执行
    ret                                          ;//这里如果返回0,你会陷入死循环,不断跳出对话框....

pThread_Handler2:
    invoke    MessageBox,NULL,addr szMsg2,addr szCap2,MB_OK+MB_ICONINFORMATION
   
    mov    eax,1                                 ;//ExceptionContinueSearch,不处理,由其他例程或系统处理
    ;mov    eax,0                                ;//ExceptionContinueExecution,表示已经修复,可从异常发生处继续执行
    ret                                          ;//这里如果返回0,你会陷入死循环,不断跳出对话框....
   
;//=============================Prog Ends==============
end start

SEH1.EXE 有两个线程异常处理例程, 但都不处理除零异常.

运行 SEH1.EXE , 你将会看到 5 次对话框, 其中第三次是系统默认对话框, 最后两次是线程异常展开.

(三) 参考 Hume 的例子写的第二个 MASM 程序, 有最终异常处理例程

;//===============================   SEH2.EXE  =========================

.386
.model flat, stdcall
option casemap :none  ; case sensitive

include c:\masm32\include\windows.inc
include c:\masm32\include\kernel32.inc
include c:\masm32\include\user32.inc

includelib c:\masm32\lib\kernel32.lib
includelib c:\masm32\lib\user32.lib

;//============================

.data

szCap1     db "Thread SEH 1",0
szMsg1     db "We are in thread SEH handler 1.  ",0

szCap2     db "Thread SEH 2",0
szMsg2     db "We are in thread SEH handler 2.  ",0

szCap0     db "Final SEH",0
szMsg0     db "We are in final SEH handler.  ",0

szCap     db "SEH example",0
szMsg     db "It would never get here.",0

.code
start:

;//========prog begin====================

    ASSUME FS:NOTHING
    push    offset pThread_Handler1
    push    fs:[0]      
    mov     fs:[0],esp                            ;//建立SEH的基本ERR结构
   
    ;lea    eax,Final_Handler
    ;invoke SetUnhandledExceptionFilter,eax        ;//调用SetUnhandledExceptionFilter来安装final SEH
                                                  ;//原型很简单SetUnhandledExceptionFilter proto
                                                  ;//pTopLevelExceptionFilter:DWORD
    push    offset pThread_Handler2
    push    fs:[0]      
    mov     fs:[0],esp                            ;//建立SEH的基本ERR结构, 只是说明两种异常先后加载的顺序对系统没影响                                          
                                                   
    xor    ecx,ecx
    mov    eax,200     
    cdq
    div    ecx
                                                  ;//以下永远不会被执行
    invoke    MessageBox,NULL,addr szMsg,addr szCap,MB_OK+MB_ICONEXCLAMATION
    invoke    ExitProcess,NULL
        

;//============================
pThread_Handler1:
    invoke    MessageBox,NULL,addr szMsg1,addr szCap1,MB_OK+MB_ICONINFORMATION
   
    mov    eax,1                                 ;//ExceptionContinueSearch,不处理,由其他例程或系统处理
    ;mov    eax,0                                ;//ExceptionContinueExecution,表示已经修复,可从异常发生处继续执行
    ret                                          ;//这里如果返回0,你会陷入死循环,不断跳出对话框....

pThread_Handler2:
    invoke    MessageBox,NULL,addr szMsg2,addr szCap2,MB_OK+MB_ICONINFORMATION
   
    mov    eax,1                                 ;//ExceptionContinueSearch,不处理,由其他例程或系统处理
    ;mov    eax,0                                ;//ExceptionContinueExecution,表示已经修复,可从异常发生处继续执行
    ret                                          ;//这里如果返回0,你会陷入死循环,不断跳出对话框....
   
Final_Handler:
    invoke    MessageBox,NULL,addr szMsg0,addr szCap0,MB_OK+MB_ICONEXCLAMATION
     
    mov    eax,EXCEPTION_EXECUTE_HANDLER        ;//==1 这时不出现非法操作的讨厌对话框, 已经处理了, 可以结束了.
    ;mov    eax,EXCEPTION_CONTINUE_SEARCH       ;//==0 出现,这时是调用系统默认的异常处理过程,程序被终结了
    ;mov    eax,EXCEPTION_CONTINUE_EXECUTION    ;//==-1 表示已经修复,可从异常发生处继续执行, 因为我们并没有修复ecx
    ret                                         ;//     所以不断产生异常,不断调用这个例程, 出现对话框,陷入死循环,

;//=============================Prog Ends==============
end start

SEH2.EXE 有两个线程异常处理例程, 一个最终异常处理例程, 都不处理除零异常.

运行 SEH2.EXE , 还是会看到 5 次对话框, 但是其中第三次换成我们自己的了, 最后两次还是线程异常展开.
还要注意最终异常处理例程不会展开.

(四) 下面我们开始用 OD 调试 SEH2.EXE, 注意先不要安装插件, 不要忽略异常.

加载后, OD 停在 004010C0.

004010C0 >/$  68 16114000   PUSH SEH2.00401116                       ;  SE handler installation
004010C5  |.  64:FF35 00000>PUSH DWORD PTR FS:[0]
004010CC  |.  64:8925 00000>MOV DWORD PTR FS:[0],ESP

004010D3  |.  8D05 48114000 LEA EAX,DWORD PTR DS:[401148]
004010D9  |.  50            PUSH EAX                                 ; /pTopLevelFilter => SEH2.00401148
004010DA  |.  E8 89000000   CALL <JMP.&kernel32.SetUnhandledExceptio>; \SetUnhandledExceptionFilter

004010DF  |.  68 2F114000   PUSH SEH2.0040112F                       ;  SE handler installation
004010E4  |.  64:FF35 00000>PUSH DWORD PTR FS:[0]
004010EB  |.  64:8925 00000>MOV DWORD PTR FS:[0],ESP

004010F2  |.  33C9          XOR ECX,ECX
004010F4  |.  B8 C8000000   MOV EAX,0C8
004010F9  |.  99            CDQ
004010FA  |.  F7F1          DIV ECX                                  ; // 除零异常

004010FC  |.  6A 30         PUSH 30                                  ; /Style = MB_OK|MB_ICONEXCLAMATION|MB_APPLMODAL
004010FE  |.  68 9B104000   PUSH SEH2.0040109B                       ; |Title = "SEH example"
00401103  |.  68 A7104000   PUSH SEH2.004010A7                       ; |Text = "It would never get here."
00401108  |.  6A 00         PUSH 0                                   ; |hOwner = NULL
0040110A  |.  E8 5F000000   CALL <JMP.&user32.MessageBoxA>           ; \MessageBoxA

0040110F  |.  6A 00         PUSH 0                                   ; /ExitCode = 0
00401111  \.  E8 4C000000   CALL <JMP.&kernel32.ExitProcess>         ; \ExitProcess

00401116  /$  6A 40         PUSH 40                                  ; /Style = MB_OK|MB_ICONASTERISK|MB_APPLMODAL; Structured exception handler
00401118  |.  68 14104000   PUSH SEH2.00401014                       ; |Title = "Thread SEH 1"
0040111D  |.  68 21104000   PUSH SEH2.00401021                       ; |Text = "We are in thread SEH handler 1.  "
00401122  |.  6A 00         PUSH 0                                   ; |hOwner = NULL
00401124  |.  E8 45000000   CALL <JMP.&user32.MessageBoxA>           ; \MessageBoxA
00401129  |.  B8 01000000   MOV EAX,1
0040112E  \.  C3            RETN

0040112F  /$  6A 40         PUSH 40                                  ; /Style = MB_OK|MB_ICONASTERISK|MB_APPLMODAL; Structured exception handler
00401131  |.  68 43104000   PUSH SEH2.00401043                       ; |Title = "Thread SEH 2"
00401136  |.  68 50104000   PUSH SEH2.00401050                       ; |Text = "We are in thread SEH handler 2.  "
0040113B  |.  6A 00         PUSH 0                                   ; |hOwner = NULL
0040113D  |.  E8 2C000000   CALL <JMP.&user32.MessageBoxA>           ; \MessageBoxA
00401142  |.  B8 01000000   MOV EAX,1
00401147  \.  C3            RETN

00401148   .  6A 30         PUSH 30                                  ; /Style = MB_OK|MB_ICONEXCLAMATION|MB_APPLMODAL
0040114A   .  68 72104000   PUSH SEH2.00401072                       ; |Title = "Final SEH"
0040114F   .  68 7C104000   PUSH SEH2.0040107C                       ; |Text = "We are in final SEH handler.  "
00401154   .  6A 00         PUSH 0                                   ; |hOwner = NULL
00401156   .  E8 13000000   CALL <JMP.&user32.MessageBoxA>           ; \MessageBoxA
0040115B   .  B8 01000000   MOV EAX,1
00401160   .  C3            RETN

不要运行, 先看看 OD 的 SEH chain 窗口,  如下, 系统已经为我们设好了一个异常处理例程.

SEH chain of main thread
Address    SE handler
0012FFE0   kernel32.7C5C1F44                                       ; 记住这里, 称其为系统线程异常处理例程
                                                                   ; 关键的地方.

F9 运行,  OD 停在 004010FA, 除零异常. 再看一下 SEH chain 窗口.

SEH chain of main thread
Address    SE handler
0012FFB4   SEH2.0040112F
0012FFBC   SEH2.00401116
0012FFE0   kernel32.7C5C1F44

我们按照 OD 的提示 SHIFT+F7 走, 注意不是 SHIFT+F9

77F9FF60 >  8B4C24 04       MOV ECX,DWORD PTR SS:[ESP+4]           ; ntdll.KiUserExceptionDispatcher
77F9FF64    8B1C24          MOV EBX,DWORD PTR SS:[ESP]
77F9FF67    51              PUSH ECX
77F9FF68    53              PUSH EBX
77F9FF69    E8 6F6CFFFF     CALL ntdll.77F96BDD                    ; F7

我们发现系统把控制权交给了 ntdll.KiUserExceptionDispatcher,
而不是 README 里说的 KiUserExceptionFilter, 这也许是说法上的差异吧.

我们一路 F7 走

77F96BDD    55              PUSH EBP
77F96BDE    8BEC            MOV EBP,ESP
77F96BE0    83EC 60         SUB ESP,60
77F96BE3    53              PUSH EBX
77F96BE4    56              PUSH ESI
77F96BE5    8D45 F4         LEA EAX,DWORD PTR SS:[EBP-C]
77F96BE8    57              PUSH EDI
77F96BE9    50              PUSH EAX
77F96BEA    8D45 F8         LEA EAX,DWORD PTR SS:[EBP-8]
77F96BED    50              PUSH EAX
77F96BEE    E8 C8FFFFFF     CALL ntdll.77F96BBB                    ; F8
77F96BF3    E8 DEFFFFFF     CALL ntdll.77F96BD6                    ; F8
77F96BF8    8365 FC 00      AND DWORD PTR SS:[EBP-4],0
77F96BFC    8BD8            MOV EBX,EAX
77F96BFE    83FB FF         CMP EBX,-1
77F96C01    74 71           JE SHORT ntdll.77F96C74
77F96C03    8B75 08         MOV ESI,DWORD PTR SS:[EBP+8]

77F96C06    3B5D F8         CMP EBX,DWORD PTR SS:[EBP-8]           ; // 循环处理线程异常链
77F96C09    8D43 08         LEA EAX,DWORD PTR DS:[EBX+8]
77F96C0C    0F82 40AF0100   JB ntdll.77FB1B52
77F96C12    3B45 F4         CMP EAX,DWORD PTR SS:[EBP-C]
77F96C15    0F87 37AF0100   JA ntdll.77FB1B52
77F96C1B    F6C3 03         TEST BL,3
77F96C1E    0F85 2EAF0100   JNZ ntdll.77FB1B52
77F96C24    F605 5604FD77 8>TEST BYTE PTR DS:[77FD0456],80
77F96C2B    0F85 AAAE0100   JNZ ntdll.77FB1ADB
77F96C31    FF73 04         PUSH DWORD PTR DS:[EBX+4]
77F96C34    8D45 F0         LEA EAX,DWORD PTR SS:[EBP-10]
77F96C37    50              PUSH EAX
77F96C38    FF75 0C         PUSH DWORD PTR SS:[EBP+C]
77F96C3B    53              PUSH EBX
77F96C3C    56              PUSH ESI
77F96C3D    E8 3AFFFFFF     CALL ntdll.77F96B7C                   ; F7, 见下面
77F96C42    F605 5604FD77 8>TEST BYTE PTR DS:[77FD0456],80
77F96C49    8BF8            MOV EDI,EAX
77F96C4B    0F85 A0AE0100   JNZ ntdll.77FB1AF1
77F96C51    395D FC         CMP DWORD PTR SS:[EBP-4],EBX
77F96C54    0F84 A5AE0100   JE ntdll.77FB1AFF
77F96C5A    8BC7            MOV EAX,EDI
77F96C5C    33C9            XOR ECX,ECX
77F96C5E    2BC1            SUB EAX,ECX
77F96C60    0F84 8A030000   JE ntdll.77F96FF0
77F96C66    48              DEC EAX
77F96C67    0F85 9FAE0100   JNZ ntdll.77FB1B0C
77F96C6D    8B1B            MOV EBX,DWORD PTR DS:[EBX]
77F96C6F    83FB FF         CMP EBX,-1                          ; // 0FFFFFFFFh
77F96C72  ^ 75 92           JNZ SHORT ntdll.77F96C06            ; // 是不是最后一个线程异常处理例程

77F96B7C    BA AE6DF977     MOV EDX,ntdll.77F96DAE
77F96B81    55              PUSH EBP
77F96B82    8BEC            MOV EBP,ESP
77F96B84    FF75 0C         PUSH DWORD PTR SS:[EBP+C]
77F96B87    52              PUSH EDX
77F96B88    64:FF35 0000000>PUSH DWORD PTR FS:[0]
77F96B8F    64:8925 0000000>MOV DWORD PTR FS:[0],ESP
77F96B96    FF75 14         PUSH DWORD PTR SS:[EBP+14]            ; pDispatch
77F96B99    FF75 10         PUSH DWORD PTR SS:[EBP+10]            ; pContext
77F96B9C    FF75 0C         PUSH DWORD PTR SS:[EBP+C]             ; pException_Registration_Record
77F96B9F    FF75 08         PUSH DWORD PTR SS:[EBP+8]             ; pException_Record
77F96BA2    8B4D 18         MOV ECX,DWORD PTR SS:[EBP+18]         
77F96BA5    FFD1            CALL ECX                              ; 线程异常处理例程, F7 跟进,
77F96BA7    64:8B25 0000000>MOV ESP,DWORD PTR FS:[0]
77F96BAE    64:8F05 0000000>POP DWORD PTR FS:[0]
77F96BB5    8BE5            MOV ESP,EBP
77F96BB7    5D              POP EBP
77F96BB8    C2 1400         RETN 14                               ; ret to 77F96C42

我们将在 77F96BA5 进入线程处理例程, 一共三次.

前两次是我们自己的代码, 都不处理除零异常, 返回 EAX=1.
最后一次, 就是前面记下的 系统预设的线程处理例程 7C5C1F44, F7 进去

7C5C1F44    55              PUSH EBP
7C5C1F45    8BEC            MOV EBP,ESP
7C5C1F47    83EC 08         SUB ESP,8
7C5C1F4A    53              PUSH EBX
7C5C1F4B    56              PUSH ESI
7C5C1F4C    57              PUSH EDI
7C5C1F4D    55              PUSH EBP
7C5C1F4E    FC              CLD

7C5C1F4F    8B5D 0C         MOV EBX,DWORD PTR SS:[EBP+C]        ; pException_Registration_Record
7C5C1F52    8B45 08         MOV EAX,DWORD PTR SS:[EBP+8]        ; pException_Record
7C5C1F55    F740 04 0600000>TEST DWORD PTR DS:[EAX+4],6         ; 异常标志, =6 表示正展开中.
7C5C1F5C    75 77           JNZ SHORT kernel32.7C5C1FD5

7C5C1F5E    8945 F8         MOV DWORD PTR SS:[EBP-8],EAX
7C5C1F61    8B45 10         MOV EAX,DWORD PTR SS:[EBP+10]       ; pContext
7C5C1F64    8945 FC         MOV DWORD PTR SS:[EBP-4],EAX

7C5C1F67    8D45 F8         LEA EAX,DWORD PTR SS:[EBP-8]
7C5C1F6A    8943 FC         MOV DWORD PTR DS:[EBX-4],EAX
7C5C1F6D    8B73 0C         MOV ESI,DWORD PTR DS:[EBX+C]
7C5C1F70    8B7B 08         MOV EDI,DWORD PTR DS:[EBX+8]
7C5C1F73    83FE FF         CMP ESI,-1
7C5C1F76    74 56           JE SHORT kernel32.7C5C1FCE
7C5C1F78    8D0C76          LEA ECX,DWORD PTR DS:[ESI+ESI*2]
7C5C1F7B    837C8F 04 00    CMP DWORD PTR DS:[EDI+ECX*4+4],0
7C5C1F80    74 3A           JE SHORT kernel32.7C5C1FBC
7C5C1F82    56              PUSH ESI
7C5C1F83    55              PUSH EBP
7C5C1F84    8D6B 10         LEA EBP,DWORD PTR DS:[EBX+10]
7C5C1F87    FF548F 04       CALL DWORD PTR DS:[EDI+ECX*4+4]    ; F7 进入 7C598940

7C598940    8B45 EC         MOV EAX,DWORD PTR SS:[EBP-14]
7C598943    8B08            MOV ECX,DWORD PTR DS:[EAX]
7C598945    8B09            MOV ECX,DWORD PTR DS:[ECX]
7C598947    894D E4         MOV DWORD PTR SS:[EBP-1C],ECX
7C59894A    50              PUSH EAX
7C59894B    E8 8C330000     CALL kernel32.UnhandledExceptionFilter     ; 我们来到了 README 所说的第二个函数, F7 进去看看.
7C598950    C3              RETN

7C59BCDC >  55              PUSH EBP                                   ; kernel32.UnhandledExceptionFilter
7C59BCDD    8BEC            MOV EBP,ESP
7C59BCDF    6A FF           PUSH -1
7C59BCE1    68 C02E577C     PUSH kernel32.7C572EC0
7C59BCE6    68 441F5C7C     PUSH kernel32.7C5C1F44
7C59BCEB    64:A1 00000000  MOV EAX,DWORD PTR FS:[0]
7C59BCF1    50              PUSH EAX
7C59BCF2    64:8925 0000000>MOV DWORD PTR FS:[0],ESP
7C59BCF9    51              PUSH ECX
7C59BCFA    51              PUSH ECX
7C59BCFB    81EC D8020000   SUB ESP,2D8
7C59BD01    53              PUSH EBX
7C59BD02    56              PUSH ESI
7C59BD03    57              PUSH EDI
7C59BD04    8965 E8         MOV DWORD PTR SS:[EBP-18],ESP
7C59BD07    8B75 08         MOV ESI,DWORD PTR SS:[EBP+8]
7C59BD0A    8B06            MOV EAX,DWORD PTR DS:[ESI]
7C59BD0C    8138 050000C0   CMP DWORD PTR DS:[EAX],C0000005
7C59BD12    75 1B           JNZ SHORT kernel32.7C59BD2F
7C59BD14    33DB            XOR EBX,EBX
7C59BD16    3958 14         CMP DWORD PTR DS:[EAX+14],EBX
7C59BD19    74 16           JE SHORT kernel32.7C59BD31
7C59BD1B    FF70 18         PUSH DWORD PTR DS:[EAX+18]
7C59BD1E    E8 E8FEFFFF     CALL kernel32.7C59BC0B
7C59BD23    83F8 FF         CMP EAX,-1
7C59BD26    75 09           JNZ SHORT kernel32.7C59BD31
7C59BD28    0BC0            OR EAX,EAX
7C59BD2A    E9 8E020000     JMP kernel32.7C59BFBD
7C59BD2F    33DB            XOR EBX,EBX                                        ; EBX = 0 ;
7C59BD31    895D C8         MOV DWORD PTR SS:[EBP-38],EBX                      ; ProecssInfo = 0;
7C59BD34    53              PUSH EBX                                           ; NULL
7C59BD35    6A 04           PUSH 4                                             ; sizeof(ProcessInfo)
7C59BD37    8D45 C8         LEA EAX,DWORD PTR SS:[EBP-38]
7C59BD3A    50              PUSH EAX                                           ; &ProcessInfo
7C59BD3B    6A 07           PUSH 7                                             ; ProcessDebugPort
7C59BD3D    E8 7BBBFFFF     CALL kernel32.GetCurrentProcess                    ; 返回 hProcess                  
7C59BD42    50              PUSH EAX                                          
7C59BD43    FF15 B810577C   CALL DWORD PTR DS:[<&ntdll.NtQueryInformationProce>; ntdll.ZwQueryInformationProcess
7C59BD49    3BC3            CMP EAX,EBX                                        ; EAX = 0 表示成功, EAX = -1 表示不成功
7C59BD4B    7C 09           JL SHORT kernel32.7C59BD56
7C59BD4D    395D C8         CMP DWORD PTR SS:[EBP-38],EBX                      ; [EBP-38]= -1 有调试器, 0 没有
7C59BD50    0F85 49020000   JNZ kernel32.7C59BF9F                              ; 所以我们只要把 [EBP-38] 清零
                                                                               ; 就可以瞒过系统, 使其以为目前未在调试状态
                                                                               ; 这就是插件的作用吧.

上面 ZwQueryInformationProcess 某个进程是否正被ring3调试器所调试。
Blowfish 班主写过一篇文章, 可以参考.

enum PROCESS_INFO_CLASS      { ProcessDebugPort = 7  };

typedef struct _PROCESS_DEBUG_PORT_INFO
{
    HANDLE DebugPort;
}    PROCESS_DEBUG_PORT_INFO;

PROCESS_DEBUG_PORT_INFO ProcessInfo;

ZwQueryInformationProcess(GetCurrentProcess( ), ProcessDebugPort, &ProcessInfo, sizeof(ProcessInfo), NULL)

我们把 [EBP-38]清零, 到 401148(Final_Handler) 下断, F9 , 看看发生了什么, 我们在 OD 里来到了 Final_Handler.

没有插件, 我们也可以对付最终异常处理例程了.

(五) 我的结论

一. 程序没有被调试

    0.线程建立时, 系统安装一个 系统线程异常处理例程

    1.异常发生时, Win2K/XP 控制权转移给 NTDLL.KiUserExceptionDispatcher

    2.如果你安装了线程相关的异常处理例程, 系统就把异常发送给你的处理例程,交由其处理.

    3.每个线程相关的异常处理例程可以处理或者不处理这个异常,如果他不处理并且安装了多个线程相关的异常处理例程,
      可交由链起来的其他例程处理.

    4.如果这些例程均选择不处理异常,系统线程异常处理例程将起作用, 调用 ZwQueryInformationProcess 判断是否被调试,
      没有调试并且你调用SetUnhandledExceptionFilter安装了最后异常处理例程的话,系统转向对它的调用.

    5.如果你没有安装最后异常处理例程或者他没有处理这个异常,系统会调用默认的系统处理程序,通常显示一个对话框,
      你可以选择关闭或者最后将其附加到调试器上的调试按钮.
      如果没有调试器能被附加于其上或者调试器也处理不了,系统就调用ExitProcess终结程序.

    6.不过在终结之前,系统仍然对发生异常的线程异常处理句柄来一次展开,这是线程异常处理例程最后清理的机会.
      一般只用来释放资源, 不要试图修复什么. 注意只对线程异常有展开.

二. 程序被调试

    0.线程建立时, 系统安装一个系统线程异常处理例程

    1.异常发生时, 系统挂起程序并向调试器发送 EXCEPTION_DEBUG_EVENT 消息.
      
    2.调试器未能处理异常(比如我们在 OD 里按Shift+F7), 控制权转移给 NTDLL.KiUserExceptionDispatcher
      如果你安装了线程相关的异常处理例程,系统就把异常发送给你的程序seh处理例程,交由其处理.
     
    3.每个线程相关的异常处理例程可以处理或者不处理这个异常,如果他不处理并且安装了多个线程相关的异常处理例程,
      可交由链起来的其他例程处理.

    4.如果这些例程均选择不处理异常,系统线程异常处理例程将起作用, 调用 ZwQueryInformationProcess 判断是否被调试,
     
    5.由于被调试, 操作系统仍会再次挂起程序通知debugger.
  
   
其实这里, 我也有很多似懂非懂的地方, 各位大侠请多指点.

   
附件:exception.rar


[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课

最后于 2024-6-16 15:29 被kanxue编辑 ,原因:
收藏
免费 7
支持
分享
最新回复 (23)
雪    币: 690
活跃值: (1841)
能力值: ( LV9,RANK:250 )
在线值:
发帖
回帖
粉丝
2
好文章,学习。
2004-12-28 07:32
0
雪    币: 214
活跃值: (100)
能力值: ( LV6,RANK:90 )
在线值:
发帖
回帖
粉丝
3
这个结论 xDream恐怕早就有了吧,所以才有他那插件?
2004-12-28 08:37
0
雪    币: 398
活跃值: (1078)
能力值: ( LV9,RANK:970 )
在线值:
发帖
回帖
粉丝
4
最初由 randomizer 发布
这个结论 xDream恐怕早就有了吧,所以才有他那插件?


这个插件最早是一个俄罗斯人写的, 但在 XP SP2 下有点问题.

xDream 大侠不知道了是修改了一下, 还是重写了一个,
我不清楚, 因为我是 2000 SP4.

或许你早就用上了这个插件, 但你知道原因吗?
如果你不知道, 那么这篇文章对你也许有用.
如果你知道, 那你为什么不早点写出来, 害的我分析了半天.:)
2004-12-28 08:56
0
雪    币: 214
活跃值: (100)
能力值: ( LV6,RANK:90 )
在线值:
发帖
回帖
粉丝
5
大侠不要生气,我只不过这么随便一说
2004-12-28 09:17
0
雪    币: 332
活跃值: (479)
能力值: ( LV9,RANK:330 )
在线值:
发帖
回帖
粉丝
6
能否请把插件提供一下,打包在你的那个里面就行啊。
2004-12-28 09:28
0
雪    币: 398
活跃值: (1078)
能力值: ( LV9,RANK:970 )
在线值:
发帖
回帖
粉丝
7
附件:OD_UnhExcFlt.zip

这个插件不能用于 XP SP2, 请找 xDREAM 的那个.
2004-12-28 09:34
0
雪    币: 392
活跃值: (909)
能力值: ( LV9,RANK:690 )
在线值:
发帖
回帖
粉丝
8
非常好的文章,原理很清楚
2004-12-28 09:36
0
雪    币: 116
活跃值: (220)
能力值: ( LV12,RANK:370 )
在线值:
发帖
回帖
粉丝
9
兄弟的论述不错。。。表达清晰。。。

赞一个。。。;)
2004-12-28 09:46
0
雪    币: 2384
活跃值: (766)
能力值: (RANK:410 )
在线值:
发帖
回帖
粉丝
10
好文。收藏!
2004-12-28 10:00
0
雪    币: 223
活跃值: (106)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
11
强!学习!:)
2004-12-28 10:40
0
雪    币: 557
活跃值: (2303)
能力值: ( LV9,RANK:2130 )
在线值:
发帖
回帖
粉丝
12
:D Good,
2004-12-28 11:27
0
雪    币: 61
活跃值: (160)
能力值: ( LV9,RANK:170 )
在线值:
发帖
回帖
粉丝
13
好文啊,:D
2004-12-28 11:56
0
雪    币: 149
活跃值: (344)
能力值: ( LV8,RANK:130 )
在线值:
发帖
回帖
粉丝
14
学习中...:D
2004-12-28 12:15
0
雪    币: 203
活跃值: (11)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
15
不错,条理性很好。
学习!
2004-12-28 13:59
0
雪    币: 213
活跃值: (16)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
16
插件好像是把
CALL DWORD PTR DS:[<&ntdll.NtQueryInformationProce>; ntdll.ZwQueryInformationProcess
7C59BD49    3BC3            CMP EAX,EBX                                        ; EAX = 0 表示成功, EAX = -1 表示不成功
7C59BD4B    7C 09           JL SHORT kernel32.7C59BD56
7C59BD4D    395D C8         CMP DWORD PTR SS:[EBP-38],EBX                      ; [EBP-38]= -1 有调试器, 0 没有
7C59BD50    0F85 49020000   JNZ kernel32.7C59BF9F                              ; 所以我们只要把 [EBP-38] 清零
///////////////////////////////

后面哪里NOP掉,不是清零,可以用OD看看
2004-12-28 16:32
0
雪    币: 98745
活跃值: (201039)
能力值: (RANK:10 )
在线值:
发帖
回帖
粉丝
17
支持!!!
2004-12-28 21:26
0
雪    币: 398
活跃值: (1078)
能力值: ( LV9,RANK:970 )
在线值:
发帖
回帖
粉丝
18
Windows异常处理流程

                                                                                       SoBeIt

    先来说说异常和中断的区别。中断可在任何时候发生,与CPU正在执行什么指令无关,中断主要由I/O设备、处理器时钟或定时器等硬件引发,可以被允许或取消。而异常是由于CPU执行了某些指令引起的,可以包括存储器存取违规、除0或者特定调试指令等,内核也将系统服务视为异常。中断和异常更底层的区别是当广义上的中断(包括异常和硬件中断)发生时如果没有设置在服务寄存器(用命令号0xb向8259-1中断控制器0x20端口读出在服务寄存器1,用0xb向8259-2中断控制器的0xa0端口读出在服务寄存器2)相关的在服务位(每个在服务寄存器有8位,共对应IRQ 0-15)则为CPU的异常,否则为硬件中断。

    下面是WINDOWS2000根据INTEL x86处理器的定义,将IDT中的前几项注册为对应的异常处理程序(不同的操作系统对此的实现标准是不一样的,这里给出的和其它一些资料不一样是因为这是windows的具体实现):

    中断号    名字        原因        
    0x0    除法错误        1、DIV和IDIV指令除0
                2、除法结果溢出
    0x1    调试陷阱        1、EFLAG的TF位置位
                2、执行到调试寄存器(DR0-DR4)设置的断点
                3、执行INT 1指令
    0x2    NMI中断        将CPU的NMI输入引脚置位(该异常为硬件发生非屏蔽中断而保留)
    0x3    断点        执行INT 3指令
    0x4    整数溢出        执行INTO指令且OF位置位
    0x5    BOUND边界检查错误    BOUND指令比较的值在给定范围外   
    0x6    无效操作码    指令无法识别
    0x7    协处理器不可用    1、CR0的EM位置位时执行任何协处理器指令        
                2、协处理器工作时执行了环境切换   
    0x8    双重异常        处理异常时发生另一个异常
    0x9    协处理器段超限    浮点指令引用内存超过段尾
    0xA    无效任务段    任务段包含的描述符无效(windows不   
使用TSS进行环境切换,所以发生该异常说明有其它问题)
    0xB    段不存在        被引用的段被换出内存
    0xC    堆栈错误        1、被引用内存超出堆栈段限制
                2、加载入SS寄存器的描述符的present位置0
    0xD    一般保护性错误    所有其它异常处理例程无法处理的异常
    0xE    页面错误        1、访问的地址未被换入内存
                2、访问操作违反页保护规则
    0x10    协处理器出错    CR0的EM位置位时执行WAIT或ESCape指令
    0x11    对齐检查错误    对齐检查开启时(EFLAG对齐位置位)访问未对齐数据
   
    其它异常还包括获取系统启动时间服务int 0x2a、用户回调int 0x2b、系统服务int 0x2e、调试服务int 0x2d等系统用来实现自己功能的部分,都是通过异常的机制,触发方式就是执行相应的int指令。

    这里给出几个异常处理中重要的结构:

    陷阱帧TrapFrame结构(后面提到的异常帧ExceptionFrame结构其实也是一个KTRAP_FRAME结构):

    typedef struct _KTRAP_FRAME {
            ULONG   DbgEbp;         
            ULONG   DbgEip;      
            ULONG   DbgArgMark;   
            ULONG   DbgArgPointer;
            ULONG   TempSegCs;
            ULONG   TempEsp;
            ULONG   Dr0;
            ULONG   Dr1;
            ULONG   Dr2;
            ULONG   Dr3;
            ULONG   Dr6;
            ULONG   Dr7;
            ULONG   SegGs;
            ULONG   SegEs;
            ULONG   SegDs;
            ULONG   Edx;
            ULONG   Ecx;
            ULONG   Eax;
            ULONG   PreviousPreviousMode;
            PEXCEPTION_REGISTRATION_RECORD ExceptionList;
            ULONG   SegFs;
            ULONG   Edi;
            ULONG   Esi;
            ULONG   Ebx;
            ULONG   Ebp;
            ULONG   ErrCode;
            ULONG   Eip;
            ULONG   SegCs;
            ULONG   EFlags;
            ULONG   HardwareEsp;   
            ULONG   HardwareSegSs;
            ULONG   V86Es;         
            ULONG   V86Ds;  
            ULONG   V86Fs;
            ULONG   V86Gs;   
    } KTRAP_FRAME;

    环境Context结构:

    typedef struct _CONTEXT {
            ULONG ContextFlags;
            ULONG   Dr0;
            ULONG   Dr1;
            ULONG   Dr2;
            ULONG   Dr3;
            ULONG   Dr6;
            ULONG   Dr7;
            FLOATING_SAVE_AREA FloatSave;
            ULONG   SegGs;
            ULONG   SegFs;
            ULONG   SegEs;
            ULONG   SegDs;
            ULONG   Edi;
            ULONG   Esi;
            ULONG   Ebx;
            ULONG   Edx;
            ULONG   Ecx;
            ULONG   Eax;
            ULONG   Ebp;
            ULONG   Eip;
            ULONG   SegCs;   
            ULONG   EFlags;
            ULONG   Esp;
            ULONG   SegSs;
            UCHAR   ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
    } CONTEXT;

    异常记录ExceptionRecord结构:

    typedef struct _EXCEPTION_RECORD {
            NTSTATUS ExceptionCode;
            ULONG ExceptionFlags;
            struct _EXCEPTION_RECORD *ExceptionRecord;
            PVOID ExceptionAddress;
            ULONG NumberParameters;
            ULONG_PTR ExceptionInformatio[EXCEPTION_MAXIMUM_PARAMETERS];
        } EXCEPTION_RECORD;

    当发生异常后,CPU记录当前各寄存器状态并在内核堆栈中建立陷阱帧TrapFrame,然后将控制交给对应异常的陷阱处理程序。当陷阱处理程序能处理异常时,比如缺页时通过调页程序MmAccessFault将页换入物理内存后通过iret返回发生异常的地方。但大多数无法处理异常,这时先是调用CommonDispatchException在内核堆栈中建立异常记录ExceptionRecord和异常帧ExceptionFrame。ExceptionRecord很重要,它记录了异常代码、异常地址以及一些其它附加的参数。然后调用KiDispatchException进行异常的分派。这个函数是WINDOWS下异常处理的核心函数,负责异常的分派处理。

    KiDispatchException的处理流程(每当异常被某个例程处理时处理的例程将返回TRUE到上一个例程,未处理则返回FALSE。当任何一个例程处理了异常返回TRUE时,则KiDispatchException正常返回):

    在进行用户态内核态的异常的分派前,先判断异常是否来自用户模式,是的话将Context.ContextFlags(这时候Context结构还刚初始化完,还未赋初值) or上CONEXT_FLOATING_POINT,意味着对来自用户模式的异常总是尝试分派浮点状态,这样可以允许异常处理程序或调试器检查和修改协处理器的状态。然后从陷阱帧中取出寄存器值填入Context结构,并判断是否是断点异常(int 0x3和int 0x2d),如果是的话先将Context.Eip减一使它指向int 0x3指令(无论是由int 0x3还是由int 0x2d引起的异常,因为前面的陷阱处理程序里已经改变过TrapFrame里面的Eip了)。然后判断异常是发生于内核模式还是用户模式,根据不同模式而采取不同处理过程。

    如果异常发生于内核模式,会给予内核调试器第一次机会和第二次机会处理异常。当异常被处理后就将设置好陷阱帧并返回到陷阱处理程序,在那里iret返回发生异常的地方继续执行。

    内核模式异常处理流程为:

    (第一次机会)判断KiDebugRoutine是否为空,不为空就将Context、陷阱帧、异常记录、异常帧、发生异常的模式等压入栈并将控制交给KiDebugRoutine。
        |
        |
        |
    若KiDebugRoutine为空(正常的系统这里不为空。正常启动的系统KiDebugRoutine为KdpStub,在Boot.ini里加上/DEBUG启动的系统的KiDebugRoutine为KdpTrap。如果这里为空的话会因为处理不了DbgPrint这类int 0x2d产生的异常而导致系统崩溃)或者KiDebugRoutine未处理异常,则将Context结构和异常记录ExceptionRecord压栈并调用内核模式的RtlDispatchException在内核堆栈中查找基于帧的异常处理例程。
        |
        |
        |        
    RtlDispatchException调用RtlpGetRegistrationHead从fs:[0](0xffdff000)处获取当前线程异常处理链表指针,并调用RtlpGetStackLimits从0xffdff004和0xffdff008取出当前线程堆栈底和顶。然后开始由异常处理链表指针遍历链表查找异常处理例程(若在XP和2003下先处理VEH再处理SEH),其实这就是SEH,只是和用户态有一点不同是既没有顶层异常处理例程(TOP LEVEL SEH)也没有默认异常处理例程。然后对每个当前异常处理链表指针检查判断堆栈是否有效(是否超出了堆栈范围或者未对齐)及堆栈是否是DPC堆栈。若0xffdff80c处DpcRoutineActive为TRUE且堆栈顶和底在0xffdff81c处取出的DpcStack到DpcStack-0x3000(一个内核堆栈大小),若是则更新堆栈顶和底为DpcStack和DpcStack-0x3000并继续处理,否则将异常记录结构里的异常标志ExceptionRecord.ExceptionFlags设置EXCEPTION_STACK_INVALID表示为无效堆栈并返回FALSE。
        |
        |
        |
        |
    调用异常处理链表上的异常处理例程之前会在异常处理例程链表上插入一个新的节点,对应的异常处理例程是用来处理嵌套异常,也就是在处理异常时发生另一个异常。处理后
RtlDispatchException判断异常处理例程的返回值:
    若为ExceptionContinueExecution,若异常标志ExceptionRecord.ExceptionFlags未设置EXCEPTION_NONCONTINUABLE不可恢复执行,则返回TRUE到上一层,否则在做了一些工作后调用RtlRaiseException进入到KiDispatchException的第二次机会处理部分。
    若为ExceptionContinueSearch,则继续查找异常处理例程。
    若为ExceptionNestedException,嵌套异常。保留当前异常处理链表指针为内层异常处理链表并继续查找异常处理例程。当发现当前异常处理链表地址大于保留的内层异常处理链表时,表示当前的异常处理链表比保留的更内层(因为堆栈是由高向低扩展的,地址越高则入栈越早,表示更内层),则将其值赋予内层异常处理链表指针,除了第一次赋初值外发生修改保留的内层异常处理链表指针这种情况表示嵌套了不止一次的异常。当搜索到新的异常处理链表指针和保留的内层异常处理链表指针相同,则清除ExceptionRecord.ExceptionFlags的嵌套异常位(&(~EXCEPTION_NESTED_CALL)),表示嵌套的异常已经处理完。
    其它的返回值都视为无效,调用RtlRaiseException回到KiDispatchException的第二次机会处理部分。
    当异常处理链表指针为0xffffffff时,异常处理例程链表已到头。
        |
        |
        |
    若RtlDispatchException无法处理异常,(第二次机会)判断KiDebugRoutine是否为空,不为空则交给KiDebugRoutine处理。
        |
        |
        |
    当所有例程都无法处理异常时,调用KeBugCheckEx蓝屏,错误代码为KMODE_EXCEPTION_NOT_HANDLED,表示谁也没有处理异常,系统视这个异常为无法恢复的致命异常。至此内核模式下异常处理完毕。

    若异常发生在用户模式,同样给予调试器第一次机会和第二次机会调试。只不过因为调试器是用户态调试器,所以通过LPC发送消息给会话管理器smss.exe,再由会话管理器将消息转发给调试器。

    用户模式异常处理流程:

    若KiDebugRoutine不为空,则不为空就将Context、陷阱帧、异常记录、异常帧、发生异常的模式等压入栈并将控制交给KiDebugRoutine。当处理完毕用Context设置陷阱帧并返回到上一级例程。(第一次机会)否则把异常记录压栈并调用DbgkForwardException,在DbgkForwardException里判断当前线程ETHREAD结构的HideFromDebugger成员如果为FALSE(为TRUE表示该异常对用户调试器不可见)则向当前进程的调试端口(DebugPort)发送LPC消息。
        |
        |
        |
    当上一步无法处理异常时将Context结构拷贝到用户堆栈,在堆栈中设置一个陷阱帧,陷阱帧的Eip为Ke(i)UserExceptionDisptcher((i)表示这个函数的Ke和Ki打头的符号其实是一回事),接着返回陷阱处理程序,由陷阱处理程序iret返回用户态执行Ke(i)UserExceptionDispatcher(这个函数虽然是Ke(Ki)打头,却不是内核里的函数。同样性质特殊的还有Ke(i)RaiseUserExceptionDispatcher、Ke(i)UserCallbackDispatcher、Ke(i)UserApcDispatcher。它们的共同特点就是不是被调用的,而是由内核例程设置了陷阱帧TrapFrame.Eip为该函数后iret执行到这里的)。Ke(i)UserExceptionDisptcher调用RtlDispatchException(用户态下的)寻找堆栈中基于帧的异常处理例程(若在XP和2003下先处理VEH再处理SEH),这个流程大家应该很熟了,就是搜索SEH链表,若都不处理就调用顶层异常处理(TOP LEVEL SEH)例程。当再无法处理时就调用默认异常处理例程终止进程(有VC时这里就换成了VC)。有点不同的是用户态下的RtlDispatchException只判断返回值是ExceptionContinueExecution还是ExceptionContinueSearch。若RtlDispatchException找到异常处理例程能够处理异常,则调用ZwContinue按照设置好的Context结构继续执行,否则调用ZwRaiseException,并且把第三个布尔参数设为FALSE,表示进入第二次机会处理。
        |
        |
        |
    ZwRaiseException经过一系列调用最后直接调用KiDispatchException,由于把布尔值FirstChance设置为FALSE,在KiDispatchException里直接进入第二次机会处理。
        |
        |
        |
    (第二次机会)向进程的DebugPort发消息,若无法处理,则改向进程的ExceptionPort发消息(这里同样如果该异常对用户调试器不可见,则只会发送到ExceptionPort)。DebugPort和ExceptionPort的区别在于,若向ExceptionPort发消息,先停止目标进程所有线程的执行,直到收到回应消息后线程才恢复执行,而向DebugPort发消息则不需要停止线程运行。还有DebugPort是向会话管理器发消息,而ExceptionPort是向Win32子系统发消息,当向ExceptionPort发消息时,已经不给用户态调试器任何机会了:)。
        |
        |
        |
    当异常还是无法处理,则终止当前线程,并调用KeBugCheckEx蓝屏,错误代码为KMODE_EXCEPTION_NOT_HANDLED。至此用户模式下异常处理完毕。

    有一点要说明的是,这里只是列出了操作系统的流程。如果现在去看比方说驱动或者一个应用程序里,加入了__try/__except代码,却没发现SEH上的节点对应的异常处理例程和自己写的有啥关系。其中的原因就是因为M$的编译器(比方说VC++、DDK)在系统内部封装了SEH的机制。在异常处理例程链表节点上的异常处理例程实际上是_except_handler3,这个函数自己在内部又实现了一套类似SEH的机制,就是通过对每个函数建立一个表,包括了该函数中所有__try块对应的过滤异常例程指针(__try()括号里的对应函数,如果是括号里是EXCEPTION_EXECUTE_HANDLER之类的则该过滤异常例程很简单地把eax赋为相应的1、0或-1然后返回,对应了EXCEPTION_EXECUTE_HANDLER、EXCEPTION_CONTINUE_SEARCH、EXCEPTION_CONTINUE_EXECUTE)和处理例程指针(__except{}和__finally{}里面代码的地址)。所以对应每个被调用到的函数都在SEH链表上注册一个节点,并建立一张表。象诸如EXCEPTION_EXECUTE_HANDLER、EXCEPTION_CONTINUE_SEARCH、EXCEPTION_CONTINUE_EXECUTE实际上是返回给_except_handler3的返回值,而不是返回给RtlDispatchException的。

    看得有点晕吧,呵呵。总结一下流程(指异常未被处理的完整流程,若异常在任一个环节被处理都从该环节上退出继续原来正常程序代码的执行):

    内核模式下:
    KiDispatchException->(第一次机会)KiDebugRoutine->RtlDispatchException在内核堆栈寻找SEH(VEH)->(第二次机会)KiDebugRoutine->KeBugCheckEx

    用户模式下:
    KiDispatchException->KiDebugRoutine->(第一次机会)发送消息到进程调试端口->RtlDispatchException在用户堆栈寻找SEH(VEH)->ZwRaiseException回到KiDispatchException->(第二次机会)发送消息到进程调试或异常端口->KeBugCheckEx。

    就这么简单,呵呵。举个例子来说明异常的处理。
   
    我们程序中通过加入int 0x3来对程序进行调试,那么int 0x3产生的异常是怎么处理的呢?

    若int 0x3发生在内核模式下(驱动程序里),又分为内核调试器是否加载。内核调试器未加载时,KiDebugRoutine(KdpStub)不处理int 0x3异常,则在堆栈中搜索由驱动程序编写者注册的SEH,若也没有对int 0x3异常的处理,则只好调用KeBugCheckEx蓝屏,错误代码是KMODE_EXCEPTION_NOT_HANDLED(谁都不干活,系统只好悲愤地自我了结了)。若有内核调试器加载,则KiDebugRoutine(KdpTrap)可以处理int 0x3异常,异常处理到这里被正常返回。处理方法是将当前的处理器状态(比如各寄存器等重要信息)发送给通过串口相连的主机上的内核调试器,并一直在等待内核调试器的回应,系统这时在KdpTrap里调用一个Kd函数KdpSendWaitContinue循环等待来自串口的数据(内核调试器和被调试系统通过串口联系),直到内核调试器下达继续执行的命令,系统可以正常从int 0x3后面一条指令执行。

    若int 0x3发生在用户模式下,也分为用户调试器是否加载。用户调试器未加载时,KiDebugRoutine不处理int 0x3异常,且用户进程无DebugPort,将记录异常的相关结构拷入用户堆栈交由用户模式的RtlDispatchException搜索用户堆栈中的SEH。若也没有对int 0x3异常的处理,则调用默认异常处理例程,默认情况下是将发生异常的进程终止。若有用户调试器加载,则向进程的DebugPort或ExceptionPort发送有关异常的LPC消息并判断发送函数的返回状态,若用户调试器继续执行,则返回STATUS_SUCCESS,内核视为异常已解决继续执行。

    这里也说明了一点,就是相同的错误,发生在?核态下比发生在用户态下致命得多。其它异常的处理流程基本也一样。少数不一样的异常象DebugPrint、DebugPrompt、加载和卸载symbol等是通过调用DebugService的(这实际上是通过产生一个异常int 0x2d)。可以在KdpStub中就被处理(处理很简单,只是把Context结构的Eip加一略过当前int 0x2d后那条int 0x3指令),若在KdpTrap中被处理则是和内核调试器更进一步的交互。关于KdpStub、KdpTrap和DebugService更详细的介绍参见我的另一篇文章《windows内核调试器原理浅析》。

后记:
    前几天翻硬盘时找出了这篇半年前写了大半的文章。当时因为还有些不明白的东西,所以没写完,结果一放就放了半年。现在翻出来写完后弄明白了不少东西,也把心得拿出来供大家参考,就当是抛砖引玉吧:)

QQ:27324838
EMAIL:kinvis@hotmail.com
2004-12-29 19:16
0
雪    币: 398
活跃值: (1078)
能力值: ( LV9,RANK:970 )
在线值:
发帖
回帖
粉丝
19
Windows内核调试器原理浅析

                                                                                       SoBeIt

    前段时间忽然对内核调试器实现原来发生了兴趣,于是简单分析了一下当前windows下主流内核调试器原理,并模仿原理自己也写了个极其简单的调试器:)

                WinDBG
   
    WinDBG和用户调试器一点很大不同是内核调试器在一台机器上启动,通过串口调试另一个相联系的以Debug方式启动的系统,这个系统可以是虚拟机上的系统,也可以是另一台机器上的系统(这只是微软推荐和实现的方法,其实象SoftICE这类内核调试器可以实现单机调试)。很多人认为主要功能都是在WinDBG里实现,事实上并不是那么一回事,windows已经把内核调试的机制集成进了内核,WinDBG、kd之类的内核调试器要做的仅仅是通过串行发送特定格式数据包来进行联系,比如中断系统、下断点、显示内存数据等等。然后把收到的数据包经过WinDBG处理显示出来。   

    在进一步介绍WinDBG之前,先介绍两个函数:KdpTrace、KdpStub,我在《windows异常处理流程》一文里简单提过这两个函数。现在再提一下,当异常发生于内核态下,会调用KiDebugRoutine两次,异常发生于用户态下,会调用KiDebugRoutine一次,而且第一次调用都是刚开始处理异常的时候。

    当WinDBG未被加载时KiDebugRoutine为KdpStub,处理也很简单,主要是对由int 0x2d引起的异常如DbgPrint、DbgPrompt、加载卸载SYMBOLS(关于int 0x2d引起的异常将在后面详细介绍)等,把Context.Eip加1,跳过int 0x2d后面跟着的int 0x3指令。

    真正实现了WinDBG功能的函数是KdpTrap,它负责处理所有STATUS_BREAKPOINT和STATUS_SINGLE_STEP(单步)异常。STATUS_BREAKPOINT的异常包括int 0x3、DbgPrint、DbgPrompt、加载卸载SYMBOLS。DbgPrint的处理最简单,KdpTrap直接向调试器发含有字符串的包。DbgPrompt因为是要输出并接收字符串,所以先将含有字符串的包发送出去,再陷入循环等待接收来自调试器的含有回复字符串的包。SYMBOLS的加载和卸载通过调用KdpReportSymbolsStateChange,int 0x3断点异常和int 0x1单步异常(这两个异常基本上是内核调试器处理得最多的异常)通过调用KdpReportExceptionStateChange,这两个函数很相似,都是通过调用KdpSendWaitContinue函数。KdpSendWaitContinue可以说是内核调试器功能的大管家,负责各个功能的分派。这个函数向内核调试器发送要发送的信息,比如当前所有寄存器状态,每次单步后我们都可以发现寄存器的信息被更新,就是内核调试器接受它发出的包含最新机器状态的包;还有SYMBOLS的状态,这样加载和卸载了SYMBOLS我们都能在内核调试器里看到相应的反应。然后KdpSendWaitContinue等待从内核调试器发来的包含命令的包,决定下一步该干什么。让我们来看看KdpSendWaitContinue都能干些什么:

        case DbgKdReadVirtualMemoryApi:
            KdpReadVirtualMemory(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdReadVirtualMemory64Api:
            KdpReadVirtualMemory64(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdWriteVirtualMemoryApi:
            KdpWriteVirtualMemory(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdWriteVirtualMemory64Api:
            KdpWriteVirtualMemory64(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdReadPhysicalMemoryApi:
            KdpReadPhysicalMemory(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdWritePhysicalMemoryApi:
            KdpWritePhysicalMemory(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdGetContextApi:
            KdpGetContext(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdSetContextApi:
            KdpSetContext(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdWriteBreakPointApi:
            KdpWriteBreakpoint(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdRestoreBreakPointApi:
            KdpRestoreBreakpoin(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdReadControlSpaceApi:
            KdpReadControlSpace(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdWriteControlSpaceApi:
            KdpWriteControlSpace(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdReadIoSpaceApi:
            KdpReadIoSpace(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdWriteIoSpaceApi:
            KdpWriteIoSpace(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdContinueApi:
            if (NT_SUCCESS(ManipulateState.u.Continue.ContinueStatus) != FALSE) {
                return ContinueSuccess;
            } else {
                return ContinueError;
            }
            break;

        case DbgKdContinueApi2:
            if (NT_SUCCESS(ManipulateState.u.Continue2.ContinueStatus) != FALSE) {
                KdpGetStateChange(&ManipulateState,ContextRecord);
                return ContinueSuccess;
            } else {
                return ContinueError;
            }
            break;

        case DbgKdRebootApi:
            KdpReboot();
            break;

        case DbgKdReadMachineSpecificRegister:
            KdpReadMachineSpecificRegister(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdWriteMachineSpecificRegister:
            KdpWriteMachineSpecificRegister(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdSetSpecialCallApi:
            KdSetSpecialCall(&ManipulateState,ContextRecord);
            break;

        case DbgKdClearSpecialCallsApi:
            KdClearSpecialCalls();
            break;

        case DbgKdSetInternalBreakPointApi:
            KdSetInternalBreakpoint(&ManipulateState);
            break;

        case DbgKdGetInternalBreakPointApi:
            KdGetInternalBreakpoint(&ManipulateState);
            break;

        case DbgKdGetVersionApi:
            KdpGetVersion(&ManipulateState);
            break;

        case DbgKdCauseBugCheckApi:
            KdpCauseBugCheck(&ManipulateState);
            break;

        case DbgKdPageInApi:
            KdpNotSupported(&ManipulateState);
            break;

        case DbgKdWriteBreakPointExApi:
            Status = KdpWriteBreakPointEx(&ManipulateState,
                                          &MessageData,
                                          ContextRecord);
            if (Status) {
                ManipulateState.ApiNumber = DbgKdContinueApi;
                ManipulateState.u.Continue.ContinueStatus = Status;
                return ContinueError;
            }
            break;

        case DbgKdRestoreBreakPointExApi:
            KdpRestoreBreakPointEx(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdSwitchProcessor:
            KdPortRestore ();
            ContinueStatus = KeSwitchFrozenProcessor(ManipulateState.Processor);
            KdPortSave ();
            return ContinueStatus;

        case DbgKdSearchMemoryApi:
            KdpSearchMemory(&ManipulateState, &MessageData, ContextRecord);
            break;

    读写内存、搜索内存、设置/恢复断点、继续执行、重启等等,WinDBG里的功能是不是都能实现了?呵呵。

    每次内核调试器接管系统是通过调用在KiDispatchException里调用KiDebugRoutine(KdpTrace),但我们知道要让系统执行到KiDispatchException必须是系统发生了异常。而内核调试器与被调试系统之间只是通过串口联系,串口只会发生中断,并不会让系统引发异常。那么是怎么让系统产生一个异常呢?答案就在KeUpdateSystemTime里,每当发生时钟中断后在HalpClockInterrupt做了一些底层处理后就会跳转到这个函数来更新系统时间(因为是跳转而不是调用,所以在WinDBG断下来后回溯堆栈是不会发现HalpClockInterrupt的地址的),是系统中调用最频繁的几个函数之一。在KeUpdateSystemTime里会判断KdDebuggerEnable是否为TRUE,若为TRUE则调用KdPollBreakIn判断是否有来自内核调试器的包含中断信息的包,若有则调用DbgBreakPointWithStatus,执行一个int 0x3指令,在异常处理流程进入了KdpTrace后将根据处理不同向内核调试器发包并无限循环等待内核调试的回应。现在能理解为什么在WinDBG里中断系统后堆栈回溯可以依次发现KeUpdateSystemTime->RtlpBreakWithStatusInstruction,系统停在了int 0x3指令上(其实int 0x3已经执行过了,只不过Eip被减了1而已),实际已经进入KiDispatchException->KdpTrap,将控制权交给了内核调试器。

    系统与调试器交互的方法除了int 0x3外,还有DbgPrint、DbgPrompt、加载和卸载symbols,它们共同通过调用DebugService获得服务。

NTSTATUS DebugService(
        ULONG   ServiceClass,
        PVOID   Arg1,
        PVOID   Arg2
    )
{
    NTSTATUS    Status;

    __asm {
        mov     eax, ServiceClass
        mov     ecx, Arg1
        mov     edx, Arg2
        int     0x2d
        int     0x3   
        mov     Status, eax
    }
    return Status;
}

ServiceClass可以是BEAKPOINT_PRINT(0x1)、BREAKPOINT_PROMPT(0x2)、BREAKPOINT_LOAD_SYMBOLS(0x3)、BREAKPOINT_UNLOAD_SYMBOLS(0x4)。为什么后面要跟个int 0x3,M$的说法是为了和int 0x3共享代码(我没弄明白啥意思-_-),因为int 0x2d的陷阱处理程序是做些处理后跳到int 0x3的陷阱处理程序中继续处理。但事实上对这个int 0x3指令并没有任何处理,仅仅是把Eip加1跳过它。所以这个int 0x3可以换成任何字节。
   
    int 0x2d和int 0x3生成的异常记录结(EXCEPTION_RECORD)ExceptionRecord.ExceptionCode都是STATUS_BREAKPOINT(0x80000003),不同是int 0x2d产生的异常的ExceptionRecord.NumberParameters>0且ExceptionRecord.ExceptionInformation对应相应的ServiceClass比如BREAKPOINT_PRINT等。事实上,在内核调试器被挂接后,处理DbgPrint等发送字符给内核调试器不再是通过int 0x2d陷阱服务,而是直接发包。用M$的话说,这样更安全,因为不用调用KdEnterDebugger和KdExitDebugger。

    最后说一下被调试系统和内核调试器之间的通信。被调试系统和内核调试器之间通过串口发数据包进行通信,Com1的IO端口地址为0x3f8,Com2的IO端口地址为0x2f8。在被调试系统准备要向内核调试器发包之前先会调用KdEnterDebugger暂停其它处理器的运行并获取Com端口自旋锁(当然,这都是对多处理器而言的),并设置端口标志为保存状态。发包结束后调用KdExitDebugger恢复。每个包就象网络上的数据包一样,包含包头和具体内容。包头的格式如下:

        typedef struct _KD_PACKET {
            ULONG PacketLeader;
           USHORT PacketType;
            USHORT ByteCount;
            ULONG PacketId;
            ULONG Checksum;
        } KD_PACKET, *PKD_PACKET;
   
    PacketLeader是四个相同字节的标识符标识发来的包,一般的包是0x30303030,控制包是0x69696969,中断被调试系统的包是0x62626262。每次读一个字节,连续读4次来识别出包。中断系统的包很特殊,包里数据只有0x62626262。包标识符后是包的大小、类型、包ID、检测码等,包头后面就是跟具体的数据。这点和网络上传输的包很相似。还有一些相似的地方比如每发一个包给调试器都会收到一个ACK答复包,以确定调试器是否收到。若收到的是一个RESEND包或者很长时间没收到回应,则会再发一次。对于向调试器发送输出字符串、报告SYMBOL情况等的包都是一接收到ACK包就立刻返回,系统恢复执行,系统的表现就是会卡那么短短一下。只有报告状态的包才会等待内核调试器的每个控制包并完成对应功能,直到发来的包包含继续执行的命令为止。无论发包还是收包,都会在包的末尾加一个0xaa,表示结束。

    现在我们用几个例子来看看调试流程。

    记得我以前问过jiurl为什么WinDBG的单步那么慢(相对softICE),他居然说没觉得慢?*$&$^$^(&(&(我ft。。。现在可以理解为什么WinDBG的单步和从操作系统正常执行中断下来为什么那么慢了。单步慢是因为每单步一次除了必要的处理外,还得从串行收发包,怎么能不慢。中断系统慢是因为只有等到时钟中断发生执行到KeUpdateSystemTime后被调试系统才会接受来自WinDBG的中断包。现在我们研究一下为什么在KiDispatchException里不能下断点却可以用单步跟踪KiDispatchException的原因。如果在KiDispatchException中某处下了断点,执行到断点时系统发生异常又重新回到KiDispatchException处,再执行到int 0x3,如此往复造成了死循环,无法不能恢复原来被断点int 0x3所修改的代码。但对于int 0x1,因为它的引起是因为EFLAG寄存中TF位被置位,并且每次都自动被复位,所以系统可以被继续执行而不会死循环。现在我们知道了内部机制,我们就可以调用KdXXX函数实现一个类似WinDBG之类的内核调试器,甚至可以替换KiDebugRoutine(KdpTrap)为自己的函数来自己实现一个功能更强大的调试器,呵呵。

                SoftICE

    SoftICE的原理和WinDBG完全不一样。它通过替换正常系统中的中断处理程序来获得系统的控制权,也正因为这样它才能够实现单机调试。它的功能实现方法很底层,很少依赖与windows给的接口函数,大部分功能的实现都是靠IO端口读写等来完成的。

    SoftICE替换了IDT表中以下的中断(陷阱)处理程序:

    0x1:    单步陷阱处理程序
    0x2:    NMI不可屏蔽中断
    0x3:    调试陷阱处理程序
    0x6:    无效操作码陷阱处理程序
    0xb:    段不存在陷阱处理程序
    0xc:    堆栈错误陷阱处理程序
    0xd:    一般保护性错误陷阱处理程序
    0xe:    页面错误陷阱处理程序
    0x2d:    调试服务陷阱处理程序
    0x2e:    系统服务陷阱处理程序
    0x31:    8042键盘控制器中断处理程序
    0x33:    串口2(Com2)中断处理程序
    0x34:    串口1(Com1)中断处理程序
    0x37:    并口中断处理程序
    0x3c:    PS/2鼠标中断处理程序
    0x41:    未使用

    (这是在PIC系统上更换的中断。如果是APIC系统的话更换的中断号有不同,但同样是更换这些中断处理程序)

    其中关键是替换了0x3 调试陷阱处理程序和0x31 i8042键盘中断处理驱动程序(键盘是由i8042芯片控制的),SoftICE从这两个地方获取系统的控制权。
   
    启动softICE服务后SoftICE除了更换了IDT里的处理程序,还有几点重要的,一是HOOK了i8042prt.sys里的READ_PORT_UCHAR函数,因为在对0x60端口读后,会改变0x64端口对应控制寄存器的状态。所以在SoftICE的键盘中断控制程序读了0x60端口后并返回控制权给正常的键盘中断控制程序后,不要让它再读一次。还有就是把物理内存前1MB的地址空间通过调用MmMapIoSpace映射到虚拟的地址空间里,里面包括显存物理地址,以后重画屏幕就通过修改映射到虚拟地址空间的这段显存内容就行了。

    如果显示模式是彩色模式,那么显存起始地址是0xb8000,CRT索引寄存器端口0x3d4,CRT数据寄存器端口0x3d5。如果显示模式是单色模式,那么显存起始地址是0xb0000,CRT索引寄存器端口0x3b4,CRT数据寄存器端口0x3b5。首先写索引寄存器选择要进行设置的显示控制内部寄存器之一(r0-r17),然后将参数写到其数据寄存器端口。

    i8042键盘控制器中断控制驱动程序在每按下一个键和弹起一个键都会被触发。SoftICE在HOOK了正常的键盘中断控制程序获得系统控制权后,首先从0x60端口读出按下键的扫描码然后向0x20端口发送通用EOI(0x20)表示中断已结束,如果没有按下激活热键(ctrl+d),则返回正常键盘中断处理程序。如果是按下热键则会判断控制台(就是那个等待输入命令的显示代码的黑色屏幕)是否被激活,未被激活的话则先激活。然后设置IRQ1键盘中断的优先级为最高,同时设置两个8259A中断控制器里的中断屏蔽寄存器(向0x21和0xa1发中断掩码,要屏蔽哪个中断就把哪一位设为1),只允许IRQ1(键盘中断)、IRQ2(中断控制器2级联中断,因为PS/2鼠标中断是归8259A-2中断控制器管的,只有开放IRQ2才能响应来自8259A-2管理的中断)、IRQ12(PS/2鼠标中断,如果有的话),使系统这时只响应这3个中断。新的键盘和鼠标中断处理程序会建立一个缓冲区,保存一定数量的输入扫描信息。当前面的工作都完成后会进入一段循环代码,负责处理键盘和鼠标输入的扫描码缓冲区,同时不断地更新显存的映射地址缓冲区重画屏幕(这段循环代码和WinDBG里循环等待从串口发来的包的原理是一样的,都是在后台循环等待用户的命令)。这段循环代码是在激活控制台的例程里调用的,也就是说当控制台已被激活的话正常流程不会再次进入这段循环代码的(废话,再进入系统不就死循环了)。当有一个新的键按下时,都会重新调用一遍键盘中断处理程序,因为控制台已激活,所以它只是简单地更新键盘输入缓冲区内容然后iret返回。它并不会返回正常的键盘中断处理程序,因为那样会交出控制权(想证明这点也很简单,在SoftICE里断正常的键盘中断处理程序,然后g,1秒后在这里断下,这是我们可以F10,如果SoftICE会把控制权交给正常的键盘中断处理程序的话,在这里早就发生死循环了)。鼠标中断驱动也是一样。这个时候实际iret返回到的还是那段循环代码里面,所以被调试的代码并不会被执行,除非按下了F10之类的键,它会指示退出循环返回最开始时的中断处理程序,然后再iret返回最开始中断的地方。当然,因为设置了EFLAG里的TF位,执行了一个指令又会通过单步的处理程序进入那段循环的代码。

    而处理int 0x3也差不多,若没有激活控制台则先激活并屏蔽除了键盘、鼠标及8259A-2中断控制器外的所有中断,然后进入那段循环代码。

    作为对比同样来看一下在SoftICE里处理int 0x3和单步的过程。当执行到int 0x3时,激活控制台并屏蔽中断,然后将int 0x3指令前后范围的指令反汇编并写入显存映射地址空间,并把最新的寄存器值也写进去,最后在后台循环等待键盘输入命令。当命令是F10时,设置好EFLAG的TF位,清除8259A中断控制器里的中断屏蔽寄存器,开放所有中断,将控制台清除,从循环代码中返回新键盘(或int 0x3)中断处理程序,然后再返回到正常键盘(或int 0x3)中断处理程序,由这里iret到被中断代码处执行。执行了一个指令后因为发生单步异常又进入后台循环代码。

    SoftICE里的单步比WinDBG要快得多的原因很简单,SoftICE只需要把反汇编出来的代码和数据经过简单处理再写入显存映射地址缓冲区里刷新屏幕就可以继续执行了,省略了串行的发包收包,怎么会不快。而中断系统更快,按下键中断就会发生,根本不用象WinDBG等时钟中断才能把系统断下来。
   
   
后记:
   
    好象说得很简单,其实一个内核调试器实现起来极其复杂,没说得再详细,一是因为题目就叫“浅析”,就是类似于科普的东西;二是水平和时间有限(主要原因^^);三是真要详细写起来就不是这几千字能说得明白的东西了。还有,反汇编ntice.sys真是一项艰巨的任务,比分析漏洞要复杂N倍,刚开始没着门道时真看得我头昏眼花。在此特别感谢Syser的作者,牛人就是牛人,在我对SoftICE工作原理的认识还处于混沌状态时,几句话点醒了我^^。因为水平有限难免有很多错漏,还忘高手指出:)

QQ:27324838
Email:27324838
2004-12-29 19:20
0
雪    币: 213
活跃值: (16)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
20
哇靠。
不错,真不错!!!!
收藏!!!
2004-12-29 19:55
0
雪    币: 398
活跃值: (1078)
能力值: ( LV9,RANK:970 )
在线值:
发帖
回帖
粉丝
21
最初由 d1y2j3 发布
哇靠。
不错,真不错!!!!
收藏!!!


看的我云里雾里
2004-12-29 20:07
0
雪    币: 398
活跃值: (1078)
能力值: ( LV9,RANK:970 )
在线值:
发帖
回帖
粉丝
22
看样子对底层东西感兴趣的人不多. :(
2004-12-31 10:25
0
雪    币: 149
活跃值: (344)
能力值: ( LV8,RANK:130 )
在线值:
发帖
回帖
粉丝
23
最初由 simonzh2000 发布
看样子对底层东西感兴趣的人不多. :(

还在消化中,不敢乱说话。:D
2004-12-31 12:38
0
雪    币: 210
活跃值: (40)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
24
写得不错,就是最后的结论有点差强人意,希望有大虾出来解释.
2005-6-26 20:46
0
游客
登录 | 注册 方可回帖
返回
//