软件调试

c++

Posted by YiMiTuMi on November 17, 2021

调试

调试对象: 用于调试器与被调试程序之间建立起联系。

调试的本质:被调试程序触发异常–调试器接管异常的过程。

调试器与被调试程序之间建立起联系

当使用调试器调试未启动的程序时,调用:

CreateProcess

当使用调试器调试已启动程序时,调用:

DebugActiveProcess

注意:这两个函数都是调试器的进程在调用。

_DEBUG_OJECT 结构体

typedef struct _DEBUG_OBJECT
{
	KEVENT EventsPrasent;   //用于指示有调试事件发生
	FAST_MUTEX Mutex;       //用于同步互斥对象
	LIST_ENTRY EventList;   //保存调试事件的链表

	union
	{
		ULOG Flage;        //标志调试信息是否已读      
		struct
		{
			UCHAR DebuggetInactive:1;
			UCHAR KillProcessOnExit:1;
		};
	};
}DEBUG_OBJECT, *PDEBUG_OBJECT

_DEBUG_OJECT的本质–桥:

调试器进程                                           被调试进程

 -----                                                -----
 -----                                                ----- 存储位置:EPROCESS的DebugPort
 、、、、、 存储位置 TEB + 0xF24                           ----- 存储内容:_DEBUG_OJECT对象的地址(0环)
 -----  保存一个_DEBUG_OJECT对象句柄                    -----
 -----                                                -----
 -----                                               


						_DEBUG_OJECT
							------
						    ------ 调试事件 -> 调试事件

所以,调试器进程通过_DEBUG_OJECT这个对象和被调试进程建立链接,被调试进程保存的是 _DEBUG_OJECT对象的地址因为是在0环。

DebugActiveProcess执行流程

DebugActiveProcess调用方式(调试器的进程调用):

//先创建对象DebugOject,然后将调试进程与DebugOject对象关联起来 (3环关联)
<1> Kernel32!DbgUiConnectToDbg()  //主要是创建内核对象
		
		ntdll!DbgUiConnectToDbg

			ntdll!ZwcreateObject() -- 从这进0环

				nt!NtCreateDebugObject() -- 创建一个 DebugOject对象 (在这挂钩可以反调试)

//将被调试进程与DebugOject对象关联起来 (0环关联)
<2> Kernet32!DbgUiDebugActiveProcess(被调试进程句柄)

		ntdll!DbgUiDebugActiveProcess(被调试进程句柄)

			ntdll!NtDebugActiveProcess(被调试进程句柄,调试器进程TEB + 0xF24) -- 进0环

				nt!NtDebugActiveProcess(HANDLE ProcessHandele, HANDLE DebugObjectHandle)

DebugActiveProcess分析:

 ; BOOL __stdcall DebugActiveProcess(DWORD dwProcessId)
                 public DebugActiveProcess
 DebugActiveProcess proc near            ; DATA XREF: .text:off_7C802654↑o

 dwProcessId     = dword ptr  8

                 mov     edi, edi
                 push    ebp
                 mov     ebp, esp
                 call    7C88089Fh       //ds:__imp_DbgUiConnectToDbg 调用这个函数  
                 test    eax, eax        
				 //上面先调用DbgUiConnectToDbg函数一直到内核后,创建一个DEBUG_OBJECT内核对象(结构体)
				 //后返回一个内核对象的句柄,与调试器进程建立链接(即保存这个对象的句柄)											 

                 jge     short loc_7C85B043   //判断对象是否创建成功
                 push    eax             ; Status
                 call    sub_7C8093FD      //报错
                 xor     eax, eax
                 jmp     short loc_7C85B075
 ; ---------------------------------------------------------------------------

 loc_7C85B043:                           ; CODE XREF: DebugActiveProcess+C↑j
                 push    esi
                 push    [ebp+dwProcessId] ; ProcessHandle
                 call    sub_7C85AFC5 //调用 processIdtoHandle, 根据进程ID获取进程句柄(获取被调试进程的)
                 mov     esi, eax
                 test    esi, esi
                 jz      short loc_7C85B074 //获取失败

                 push    edi
                 push    esi             ; Process句柄--被调试进程句柄
                 call    7C880894h       //ds:__imp_DbgUiDebugActiveProcess 这个地方会调用这个函数
				 //__imp_DbgUiDebugActiveProcess 将被调试进程与DebugOject对象关联起来 (0环关联)
				 
				
                 push    esi             ; Handle
                 mov     edi, eax
                 call    ds:NtClose
                 test    edi, edi
                 jge     short loc_7C85B070
                 push    edi             ; Status
                 call    sub_7C8093FD
                 xor     eax, eax
                 jmp     short loc_7C85B073
 ; ---------------------------------------------------------------------------

 loc_7C85B070:                           ; CODE XREF: DebugActiveProcess+39↑j
                 xor     eax, eax
                 inc     eax

 loc_7C85B073:                           ; CODE XREF: DebugActiveProcess+43↑j
                 pop     edi

 loc_7C85B074:                           ; CODE XREF: DebugActiveProcess+25↑j
                 pop     esi

 loc_7C85B075:                           ; CODE XREF: DebugActiveProcess+16↑j
                 pop     ebp
                 retn    4
 DebugActiveProcess endp

DbgUiConnectToDbg - ntdll.dll 分析

DbgUiConnectToDbg解析:

DbgUiConnectToDbg函数调用 ZwCreateDebugObject 进入0环创建一个 DebugObjeck内核对象,内核函数返回到3环一个句柄,当前句柄保存到 [TEB + 0F24h] 的位置即 TEB.DbgSsReserved[2]。(调试器调用)

当反调试时,可以通过判断 [TEB + 0F24h] 的值是否为空来确定当前调试器是否在调试,DbgUiConnectToDbg函数的调用者是调试器,所以我们取的TEB是调试器进程的。

DbgUiConnectToDbg分析:

 ; _DWORD __stdcall DbgUiConnectToDbg()
                 public _DbgUiConnectToDbg@0
 _DbgUiConnectToDbg@0 proc near          ; DATA XREF: .text:off_7C923428↑o

 var_18          = dword ptr -18h
 var_14          = dword ptr -14h
 var_10          = dword ptr -10h
 var_C           = dword ptr -0Ch
 var_8           = dword ptr -8
 var_4           = dword ptr -4

                 mov     edi, edi
                 push    ebp
                 mov     ebp, esp
                 sub     esp, 18h
                 xor     ecx, ecx
                 mov     eax, large fs:18h   //eax所指向的位置为:TEB.self,指向自己的指针,eax = TEB
                 cmp     [eax+0F24h], ecx    //判断 TEB.DbgSsReserved[2] 是否为0
                 jnz     short loc_7C96FF3D

                 mov     [ebp+var_18], 18h
                 mov     [ebp+var_14], ecx
                 mov     [ebp+var_C], ecx
                 mov     [ebp+var_10], ecx
                 mov     [ebp+var_8], ecx
                 mov     [ebp+var_4], ecx
                 mov     eax, large fs:18h //eax所指向的位置为:TEB.self,指向自己的指针 eax = TEB

                 push    1
                 lea     ecx, [ebp+var_18]
                 push    ecx
                 push    1F000Fh
                 add     eax, 0F24h      // DebugObject对像的句柄保存到的位置:TEB.DbgSsReserved[2]
                 push    eax
                 call    _ZwCreateDebugObject@16 ; ZwCreateDebugObject(x,x,x,x) //当前函数进入内核创建一个DebugObject对像
                 mov     ecx, eax                                               //通过eax 返回一个 DebugObject对像 的句柄

 loc_7C96FF3D:                           ; CODE XREF: DbgUiConnectToDbg()+16↑j
                 mov     eax, ecx                                               //如果调用了 ZwCreateDebugObject后,
																				//将返回的句柄保存到 
																				//large fs:0F24h = TEB.DbgSsReserved[2] 的位置
																				//否则 eax = 0                                
                 leave 
                 retn
 _DbgUiConnectToDbg@0 endp

因为DbgUiConnectToDbg函数在3环,操作系统是不可能返回一个内核对象的地址给3环的,所以 ZwCreateDebugObject 函数进入内核创建内核对象后返回一个DebugObject对像的句柄。

DbgUiDebugActiveProcess - ntdll.dll 分析

DbgUiDebugActiveProcess函数接受2个参数(被调试进程句柄 和 DebugObject对象句柄)后,通过调用 NtDebugActiveProcess函数(系统调用)进入内核。

 ; int __stdcall DbgUiDebugActiveProcess(HANDLE Handle)
                 public _DbgUiDebugActiveProcess@4
 _DbgUiDebugActiveProcess@4 proc near    ; DATA XREF: .text:off_7C923428↑o

 Handle          = dword ptr  8

                 mov     edi, edi
                 push    ebp
                 mov     ebp, esp
                 push    esi
                 mov     eax, large fs:18h      //获取TEB
                 push    dword ptr [eax+0F24h]  //TEB + 0xF24h 保存DebugObject对象句柄
                 push    [ebp+Handle]           //被调试进程句柄
                 call    _NtDebugActiveProcess@8 ; NtDebugActiveProcess(x,x)
				 //NtDebugActiveProcess 函数接受2个参数: 被调试进程句柄 和 DebugObject对象句柄
				 //通过 NtDebugActiveProcess 进入内核

                 mov     esi, eax
                 test    esi, esi
                 jl      short loc_7C9700B8
                 push    [ebp+Handle]    ; Handle
                 call    _DbgUiIssueRemoteBreakin@4 ; DbgUiIssueRemoteBreakin(x)
                 mov     esi, eax
                 test    esi, esi
                 jge     short loc_7C9700B8
                 push    [ebp+Handle]
                 call    _DbgUiStopDebugging@4 ; DbgUiStopDebugging(x)

 loc_7C9700B8:                           ; CODE XREF: DbgUiDebugActiveProcess(x)+1E↑j
                                         ; DbgUiDebugActiveProcess(x)+2C↑j
                 mov     eax, esi
                 pop     esi
                 pop     ebp
                 retn    4
 _DbgUiDebugActiveProcess@4 endp

NtDebugActiveProcess - Ntoskrnl.dll 分析

NtDebugActiveProcess流程:

1)根据被调试进程句柄获取被调试进程地址_EPROCESS

2)根据DebugObject对象句柄获取DebugObject对象的地址

3)调用函数ExAcquireRundownProtection发送一个假的进程创建消息

4)调用函数DbgkpSetProcessDebugObject将调试对象与被调试进程关联起来,即将DebugObject对象地址保存到被调试进程的_EPROCESS.DebugPort

NtDebugActiveProcess分析:

 ; NTSTATUS __stdcall NtDebugActiveProcess(HANDLE Process, HANDLE DebugObject) //被调试进程句柄 和 DebugObject对象句柄
 _NtDebugActiveProcess@8 proc near       ; DATA XREF: .text:0040B78C↑o

 AccessMode      = byte ptr -4
 Process         = dword ptr  8
 DebugObject     = dword ptr  0Ch

                 mov     edi, edi
                 push    ebp
                 mov     ebp, esp
                 push    ecx

                 mov     eax, large fs:124h  //KPRCB.CurrentThread获取当前线程的_EThread,即调试器线程_EThread
                 mov     al, [eax+140h]      //_EThread.PreviousMode 获取先前模式
                 push    0               ; HandleInformation
                 mov     [ebp+AccessMode], al  //保存到历史变量
                 lea     eax, [ebp+Process]    //参数一:被调试进程句柄
                 push    eax             ; Object                 // 用于保存返回的内核对象
                 push    dword ptr [ebp+AccessMode] ; AccessMode  //先前模式
                 push    _PsProcessType  ; ObjectType             //转换成内核对象类型(EPROCESS)
                 push    800h            ; DesiredAccess
                 push    [ebp+Process]   ; Handle                 //被调试进程句柄
                 call    _ObReferenceObjectByHandle@24 ; ObReferenceObjectByHandle(x,x,x,x,x,x) //跟据句柄获得内核对象
                 test    eax, eax
                 jl      locret_584294  //转化失败
				 //以上函数将被调试进程的句柄转化成内核对象即 _EPROCESS = eax, 保存到 ebp+Process的位置

                 push    ebx
                 push    esi
                 mov     eax, large fs:124h   //KPRCB.CurrentThread获取当前线程的_EThread,即调试器线程_EThread
                 mov     esi, [ebp+Process]   //被调试进程对象

                 cmp     esi, [eax+44h]       //eax + 44 = _EThread._KAPC_STATE.Process(提供CR3的进程) 和 被调试进程对象比较
                 jz      short loc_584284     //相同跳转结束(即调试的是当前进程)

                 cmp     esi, _PsInitialSystemProcess //判断是否是系统初始化进程
                 jz      short loc_584284     //相同跳转结束

                 push    0               ; HandleInformation
                 lea     eax, [ebp+Process]                       //被占用 ebp+Process = _DEBUG_OBJECT(DebugObject对象)
                 push    eax             ; Object                 // 用于保存返回的内核对象, ebp+Process = _EPROCESS
                 push    dword ptr [ebp+AccessMode] ; AccessMode //先前模式
                 push    _DbgkDebugObjectType ; ObjectType       //转换成内核对象类型(_DEBUG_OBJECT)
                 push    2               ; DesiredAccess
                 push    [ebp+DebugObject] ; Handle              //DebugObject对象句柄
                 call    _ObReferenceObjectByHandle@24 ; ObReferenceObjectByHandle(x,x,x,x,x,x) //跟据句柄获得内核对象
                 mov     ebx, eax
                 test    ebx, ebx    //转化失败
                 jl      short loc_584289
				 //以上函数将被调试进程的句柄转化成内核对象即 _DEBUG_OBJECT = eax

                 push    edi
                 lea     edi, [esi+80h]   //_EThread
                 mov     ecx, edi        ; RunRef
                 call    @ExAcquireRundownProtection@4 ; ExAcquireRundownProtection(x)  //请求停运保护,锁住共享对象避免在访问时被删除
                 test    al, al
                 jz      short loc_584274
				 //ExAcquireRundownProtection 例程试图获取共享对象上的运行停止保护,以便调用者可以安全地访问该对象。  
				 


                 lea     eax, [ebp+DebugObject]
                 push    eax             ; int       //DebugObject对象句柄
                 push    [ebp+Process]   ; FastMutex  //_DEBUG_OBJECT对象
                 push    esi             ; PROCESS    //被调试进程对象 - _EPROCESS
                 call    _DbgkpPostFakeProcessCreateMessages@12 ; DbgkpPostFakeProcessCreateMessages(x,x,x)
				 //发送一个假的进程创建消息

                 push    [ebp+DebugObject] ; Object  //DebugObject对象句柄
                 push    eax             ; int       //DebugObject对象
                 push    [ebp+Process]   ; PVOID     //_DEBUG_OBJECT对象
                 push    esi             ; PVOID	 //被调试进程对象 - _EPROCESS
                 call    _DbgkpSetProcessDebugObject@16 ; DbgkpSetProcessDebugObject(x,x,x,x) 
				 //将调试对象与被调试进程关联起来

                 mov     ecx, edi        ; RunRef
                 mov     ebx, eax
                 call    @ExReleaseRundownProtection@4 ; ExReleaseRundownProtection(x)  //取消停运保护
                 jmp     short loc_584279
 ; ---------------------------------------------------------------------------

 loc_584274:                             ; CODE XREF: NtDebugActiveProcess(x,x)+80↑j
                 mov     ebx, 0C000010Ah

 loc_584279:                             ; CODE XREF: NtDebugActiveProcess(x,x)+A5↑j
                 mov     ecx, [ebp+Process] ; Object
                 call    @ObfDereferenceObject@4 ; ObfDereferenceObject(x)
                 pop     edi
                 jmp     short loc_584289
 ; ---------------------------------------------------------------------------

 loc_584284:                             ; CODE XREF: NtDebugActiveProcess(x,x)+47↑j
                                         ; NtDebugActiveProcess(x,x)+4F↑j
                 mov     ebx, 0C0000022h

 loc_584289:                             ; CODE XREF: NtDebugActiveProcess(x,x)+6E↑j
                                         ; NtDebugActiveProcess(x,x)+B5↑j
                 mov     ecx, esi        ; Object
                 call    @ObfDereferenceObject@4 ; ObfDereferenceObject(x)
                 pop     esi
                 mov     eax, ebx
                 pop     ebx

 locret_584294:                          ; CODE XREF: NtDebugActiveProcess(x,x)+33↑j
                 leave
                 retn    8
 _NtDebugActiveProcess@8 endp

DbgkpSetProcessDebugObject函数部分分析:

				 //edi保存被调试进程的_EPROCESS, ebx = 0
                 cmp     [edi+0BCh], ebx     //判断 _EPROCESS.DebugPort 是否为空
                 jnz     short loc_584068    //不为空则结束

 loc_583FF5:                             ; CODE XREF: DbgkpSetProcessDebugObject(x,x,x,x)+CF↓j
                 mov     eax, [ebp+arg_4]   //eax = _DEBUG_OBJECT对象地址
                 mov     ecx, [ebp+Object] ; Object
                 mov     [edi+0BCh], eax   //_EPROCESS.DebugPort = _DEBUG_OBJECT对象地址, 关联起来

其中,反调试 DebugPort 清零就是用一个线程无限制清零自身的_EPROCESS.DebugPort时,调试进程与被调试进程无法建立链接是现实的。

采集与发送调试消息

调试进程被调试进程关联后,每当被调试进程产生一个调试事件时会被附加到调试对象(_DEBUG_OBJECT)中发送到调试事件的链表(_DEBUG_OBJECT.EventList)中,然后调试器通过读取调试对象中调试链表的值处理调试事件。

采集调试消息

调试事件会根据种类进行记录:

trpedef enum _DBGKM_APINUMBER  
{
	DbgKmExceptionApi = 0;       //异常   -- 断点也是
	DbgKmCreateThreadApi = 1;    //创建线程
	DbgKmCreateProcessApi = 2;   //创建进程
	DbgKmExitThreadApi = 3;      //线程退出 
	DbgKmExitProcessApi = 4;     //进程退出
	DbgKmLoadDllApi = 5;         //加载DLL
	DbgKmUnloadDllApi = 6;       //卸载DLL
	DbgKmErrorReportApi = 7;     //已废弃
	DbgKmMaxApiNumber = 8;       //最大值

} DEGKM_APINUMBER;

调试事件采集函数:

用 DbgK 开头的大部分是调试事件采集函数,采集函数在每个调用的必经之路上加一个调用,用来产生调试事件。这些采集函数调用后,都会先判断 DebugPort 是否为空,只有不为空才会调用 DbgKpSendApiMessage 函数发送消息。

<1> 创建进程、线程必经之路:
			PsUserThreadStartup
					DbgKCreateThread (采集函数) -> 判断当前线程是进程的第一个线程时,为创建进程,否则为创建线程
								DbgKpSendApiMessage(x, x)

<2> 退出线程、进程必经之路:
			PspExitThread
					DbgKExitThread/DbgKExitProcess (采集函数)
								DbgKpSendApiMessage(x, x)

<3> 加载模块的必经之路:
			NtMapViewOfSection: (LoadLib -> CraeteMapping -> NtMapViewOfSection)
					DbgkMapViewOfSection  (采集函数)
								DbgKpSendApiMessage(x, x)

<4> 卸载模块的必经之路:
			NtUnMapViewOfSection
					DbgKUnMapViewOfSection  (采集函数)
								DbgKpSendApiMessage(x, x)

<5> 异常的必经之路:
			KiDispatchException
					DbgKForwardException(x, x, x)  (采集函数)
								DbgKpSendApiMessage(x, x)

DbgKForwardException函数:

DbgKForwardException 函数既可以向进程的异常端口发送消息,也可以向调试端口发送消息,KiDispatchException 函数在调用它是会通过一个布尔类型的参数来指定。如果要向调试端口发送消息,那么 DbgKForwardException 函数会判断进程的 DebugPort 字段是否为空,如果不为空,便通过 DbgKpSendApiMessage 函数发送 DbgKmExceptionApi消息。

发送调试消息

调试子系统的内核函数使用一个结构体俩描述和传递调试消息(主要是向DbgKpSendApiMessage函数传递数据):

typedef struct _DEGKM_APIMSG
{
	PORT_MESSAGE h;                 //LPC 端口消息结构,Windows Xp 之前使用
	DBGKM_APINUMBER ApiNumber;      //消息类型
	NTSTATUS ReturnedStatus;        //调试器恢复状态
	union
	{
		DBGKM_EXCEPTION Exception;               //异常
		DBGKM_CREATE_THREAD CreateThread;        //创建线程
		DBGKM_CREATE_PROCESS CreateProcess;      //创建进程
		DBGKM_EXIT_THREAD ExitThread;            //线程退出
		DBGKM_EXIT_PROCESS ExitProcess;          //进程退出
		DBGKM_LOAD_DLL LoadDll;                  //映射DLL
		DBGKM_UNLOAD_DLL UnloadDll;              //反映射DLL -- 卸载
	} u;

} DBGKM_APIMSG, *PDBGKM_APIMSG;

DbgKpSendApiMessage(x, x)说明:

1)第一个参数:消息结构,每种消息都有自己的消息结构(7种类型) – _DEGKM_APIMSG

2)第二个参数:要不要把本进程内除了自己之外的其他线程挂起。有些消息需要把其他线程挂起,比如CC,有些消息不需要把其他线程挂起,比如模块加载。

3)在后续版本中还会有个参数为 端口。 即 DebugProt 或者 ExecpptionProt

DbgKpSendApiMessage是调试事件收集的总入口,如果在这里挂钩子,调试器将无法调试。

 ; __stdcall DbgkpSendApiMessage(x, x)
 _DbgkpSendApiMessage@8 proc near        ; CODE XREF: DbgkCreateThread(x)+461E8↑p
                                         ; DbgkCreateThread(x)+46302↑p ...

 arg_0           = dword ptr  8
 arg_4           = byte ptr  0Ch

                 mov     edi, edi
                 push    ebp
                 mov     ebp, esp
                 push    ebx
                 xor     ebx, ebx
                 cmp     [ebp+arg_4], bl
                 push    esi
                 jz      short loc_584496
                 call    _DbgkpSuspendProcess@0 ; DbgkpSuspendProcess() 
                 mov     [ebp+arg_4], al

 loc_584496:                             ; CODE XREF: DbgkpSendApiMessage(x,x)+C↑j
                 mov     edx, [ebp+arg_0]
                 mov     dword ptr [edx+1Ch], 103h
                 mov     eax, large fs:124h
                 mov     ecx, [eax+44h]
                 xor     eax, eax
                 inc     eax
                 lea     esi, [ecx+248h]
                 lock or [esi], eax
                 mov     eax, large fs:124h
                 push    ebx             ; FastMutex
                 push    ebx             ; Event
                 push    edx             ; int
                 push    eax             ; PVOID
                 push    ecx             ; Object
                 call    _DbgkpQueueMessage@20 ; DbgkpQueueMessage(x,x,x,x,x) //当前函数发送消息

                 push    ebx             ; NumberOfBytesToFlush
                 push    ebx             ; BaseAddress
                 push    0FFFFFFFFh      ; ProcessHandle
                 mov     esi, eax
                 call    _ZwFlushInstructionCache@12 ; ZwFlushInstructionCache(x,x,x)
                 cmp     [ebp+arg_4], bl
                 jz      short loc_5844DA
                 call    _KeThawAllThreads@0 ; KeThawAllThreads()

 loc_5844DA:                             ; CODE XREF: DbgkpSendApiMessage(x,x)+53↑j
                 mov     eax, esi
                 pop     esi
                 pop     ebx
                 pop     ebp
                 retn    8
 _DbgkpSendApiMessage@8 endp

DbgkpSuspendProcess 与 DbgkpResumeProcess(控制被调试进程):

调试子系统设计了两个内核函数来控制被调试进程。

调试事件处理

当被调试进程采集到对应的调试信息后,会发送一个消息到 _DEBUG_OBJECT结构体的EventList中,发送的消息是一个结构体:

typedef struct _DEBUG_EVENT { 
  DWORD dwDebugEventCode;                         //标识调试事件类型的调试事件代码。
  DWORD dwProcessId; 
  DWORD dwThreadId; 
  union { 
    EXCEPTION_DEBUG_INFO Exception;                //异常
    CREATE_THREAD_DEBUG_INFO CreateThread;         //创建线程
    CREATE_PROCESS_DEBUG_INFO CreateProcessInfo;   //创建进程
    EXIT_THREAD_DEBUG_INFO ExitThread;             //退出线程
    EXIT_PROCESS_DEBUG_INFO ExitProcess;           //退出进程
    LOAD_DLL_DEBUG_INFO LoadDll;                   //加载DLL
    UNLOAD_DLL_DEBUG_INFO UnloadDll;               //卸载DLL
    OUTPUT_DEBUG_STRING_INFO DebugString; 
    RIP_INFO RipInfo; 
  } u; 
} DEBUG_EVENT; 

DEBUG_EVENT

调试器的处理代码:

1)关联被调试进程

2)调试进程循环调试链表处理调试事件

#include <iostream>
#include <windows.h>
#include <winbase.h.>
#include <string>

using namespace std;

int main()
{
    BOOL bRet = FALSE;
    BOOL bIsContinue = TRUE;
    wstring wstrFilePath = L"C:\\Users\\WGH\\Desktop\\Dbgview.exe";
    
    DEBUG_EVENT debugEvent;
    memset(&debugEvent, 0, sizeof(debugEvent));

    //创建调试进程
    STARTUPINFO StartUpInfo;
    memset(&StartUpInfo, 0, sizeof(StartUpInfo));

    PROCESS_INFORMATION pInfo;
    memset(&pInfo, 0, sizeof(PROCESS_INFORMATION));

    GetStartupInfo(&StartUpInfo);  //取得进程在启动时被指定的 STARTUPINFO 结构。

    bRet = CreateProcessW(wstrFilePath.c_str(), NULL, NULL, NULL, TRUE, DEBUG_PROCESS || DEBUG_ONLY_THIS_PROCESS, NULL, NULL, &StartUpInfo, &pInfo);
    if (bRet == FALSE)
    {
        printf("CreateProcess Error: %d \n", GetLastError());
    }

	 //附加形式
	 /*
	 if (!DebugActiveProcess(21872))   //接受一个进程ID
	 {
	    return 0;
	 }
	 */
	

    //调试循环
    while (bIsContinue)
    {
        bRet = WaitForDebugEvent(&debugEvent, INFINITE); //等待异常消息
        if (bRet == FALSE)
        {
            printf("WaitForDebugEvent Error: %d \n", GetLastError());
            return 0;
        }
		
		// 可以在下面的代码处理调试信息
        switch (debugEvent.dwDebugEventCode)
        {
        case EXCEPTION_DEBUG_EVENT :         //异常
            printf("EXCEPTION_DEBUG_EVENT, %x, %x, %x \n", debugEvent.u.Exception.ExceptionRecord.ExceptionAddress, debugEvent.u.Exception.ExceptionRecord.ExceptionRecord, debugEvent.u.Exception.ExceptionRecord.ExceptionFlags);
            break;

		case CREATE_THREAD_DEBUG_EVENT:     //创建线程
			printf("CREATE_THREAD_DEBUG_EVENT \n");
			break;

		case CREATE_PROCESS_DEBUG_EVENT:     //创建进程
			printf("CREATE_PROCESS_DEBUG_EVENT \n");
			break;

		case EXIT_THREAD_DEBUG_EVENT:        //退出线程
			printf("EXIT_THREAD_DEBUG_EVENT \n");
			break;

		case EXIT_PROCESS_DEBUG_EVENT:       //退出进程
			printf("EXIT_PROCESS_DEBUG_EVENT \n");
			break;

		case LOAD_DLL_DEBUG_EVENT:           //加载DLL
			printf("LOAD_DLL_DEBUG_EVENT \n");
			break;

		case UNLOAD_DLL_DEBUG_EVENT:           //卸载DLL
			printf("UNLOAD_DLL_DEBUG_EVENT \n");
			break;
        }
        
        //DBG_CONTINUE, 表示编译器已经处理了改异常
        //DBG_EXCPTION_NOT_HANDLED, 即表示调试器没有处理该异常,转回到用户态中执行,寻找可以处理该异常的异常处理器
        bRet = ContinueDebugEvent(debugEvent.dwProcessId, debugEvent.dwThreadId, DBG_CONTINUE);  //调试事件处理完毕
        if (bRet == FALSE)
        {
            printf("ContinueDebugEvent Error: %d \n", GetLastError());
        }
    }

    return 0;
}

通过CreateProcess方式运行输出:

CREATE_PROCESS_DEBUG_EVENT  //开始调试时,先创建一个进程
LOAD_DLL_DEBUG_EVENT        //下面加载一堆DLL
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
EXCEPTION_DEBUG_EVENT, 774e1ba2, 0, 0  //这里出现了一个异常,在 774e1ba2 的位置
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
CREATE_THREAD_DEBUG_EVENT  //创建一个线程

上面代码出现的异常:

当通过 CreateProcess() 打开一个进程进行调试的时候,CreateProcess会调用 LdrInitializeThunk(ntdll.dll) 初始化进程函数,最后会调用LdrpInitializeProcess(ntdll.dll)函数(如果创建的线程是这个进程的第一个线程,就说明这是个创建进程的函数):

 loc_7C9410B2:                           ; CODE XREF: LdrpInitializeProcess(x,x,x,x,x)+BCC↓j
				 //ebx = PEB
                 cmp     byte ptr [ebx+2], 0  //判断 PEB.BeingDebugged 是否为0,不为零则开了调试模式
                 jnz     loc_7C95E60D

 loc_7C95E60D:                           ; CODE XREF: LdrpInitializeProcess(x,x,x,x,x)-4BB↑j
                 call    _DbgBreakPoint@0 ; DbgBreakPoint()  //调用 int 3 中断
                 mov     eax, [ebx+68h]
                 shr     eax, 1
                 and     al, 1
                 mov     _ShowSnaps, al
                 jmp     loc_7C9410BC

在 LdrpInitializeProcess 函数中调用了一个 _DbgBreakPoint函数,这个函数:

 ; _DWORD __stdcall DbgBreakPoint()
                 public _DbgBreakPoint@0
 _DbgBreakPoint@0 proc near              ; CODE XREF: LdrpRunInitializeRoutines(x):loc_7C95A534↓p
                                         ; LdrpInitializeProcess(x,x,x,x,x):loc_7C95E60D↓p ...
                 int     3               ; Trap to Debugger
                 retn
 _DbgBreakPoint@0 endp

可以看出这个函数就是一个int 3 中断。

当在调用CreateProcess函数的时候,会进行判断,如果当前进程以调试模式打开时函数产生一个 int 3 中断。当在OllyDbg中加载一个进程并设置第一次暂停于系统断点而不是断在WinMain函数的时候,他触发的中断就是这个中断。

774E1BA2    CC              int3
774E1BA3    EB 07           jmp     short 774E1BAC
774E1BA5    33C0            xor     eax, eax
774E1BA7    40              inc     eax
774E1BA8    C3              retn

由此可见OllyDbg会中断到 774E1BA2 的位置,和调试器代码收到的一样。

通过DebugActiveProcess方式运行输出:

CREATE_PROCESS_DEBUG_EVENT   
LOAD_DLL_DEBUG_EVENT
CREATE_THREAD_DEBUG_EVENT
CREATE_THREAD_DEBUG_EVENT
CREATE_THREAD_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
LOAD_DLL_DEBUG_EVENT
CREATE_THREAD_DEBUG_EVENT
EXCEPTION_DEBUG_EVENT, 774a4d30, 0, 0  //通过这个可以看出假消息依旧走了和上面打开进程一样的调试路线
EXIT_THREAD_DEBUG_EVENT
EXIT_THREAD_DEBUG_EVENT
EXIT_THREAD_DEBUG_EVENT

附加情况下应该不会出现 CREATE_PROCESS_DEBUG_EVENT 等消息,通过 NtDebugActiveProcess分析 可以看到:

call    _DbgkpPostFakeThreadCreateMessages@12 ; DbgkpPostFakeThreadCreateMessages(x,x,x)

call    _DbgkpPostFakeProcessCreateMessages@12 ; DbgkpPostFakeProcessCreateMessages(x,x,x) (在DbgkpPostFakeThreadCreateMessages函数中)

call    _DbgkpPostFakeModuleMessages@12 ; DbgkpPostFakeModuleMessages(x,x,x) (在DbgkpPostFakeThreadCreateMessages函数中)

在NtDebugActiveProcess里,通过这两个函数发送假的线程、进程创建信息等。之所以要发送假的消息,是因为如果附加的时候进程已经执行完加载DLL、启动进程、启动线程等操作了,但为了保证调试仍然有这些信息,所以通过发送假消息的形式将这些信息发送过去。但是这个模拟的流程并不可靠,如加载DLL的消息实际上是查找了PEB.Ldr(_PEB_LDR_DATA) 这个字段:

kd> dt _PEB_LDR_DATA
ntdll!_PEB_LDR_DATA
   +0x000 Length           : Uint4B         //长度
   +0x004 Initialized      : UChar          //初始化
   +0x008 SsHandle         : Ptr32 Void     //句柄
   +0x00c InLoadOrderModuleList : _LIST_ENTRY (_LDR_DATA_TABLE_ENTRY)    //模块加载顺序 
   +0x014 InMemoryOrderModuleList : _LIST_ENTRY (_LDR_DATA_TABLE_ENTRY)  //模块在内存中的顺序
   +0x01c InInitializationOrderModuleList : _LIST_ENTRY (_LDR_DATA_TABLE_ENTRY) //模块初始化装载顺序
   +0x024 EntryInProgress  : Ptr32 Void

即使函数以附加形式调用即用函数DebugActiveProcess,也会去修改 PEB.BeingDebugged 的这个值。

软件断点

当我们给一个指令添加一个软件点时,实际上就是在被调试进程加断点的位置将其修改为 int 3 中断(0xcc)。

软件断点本质是改变了被调试程序数据,CRC可以检测到软件断点。

int 3 指令流程:

int 3是一个异常指令所以他的流程前半段与异常相同,在之前异常流程中异常处理都会经过内核函数 KiDispatchException 进行分发,不论是内核异常还是用户异常在 KiDispatchException 中都会判断是否存在调试器,如果存在调试器则交给调试器处理,通过 DbgKForwardException 函数收集并发送调试信息.

被调试进程:

	1.CPU检测到INT 3指令
	
	2.查IDT表找到对应的中断处理函数
	
	3.CommonDispatchException
	
	4.KiDispatchException
	
	5.DbgKForwardException 收集并发送调试事件(只有在处理用户异常时才会调用),会阻塞在这等待结果
	
		DbgkpSengApiMessage(x, x) 发送消息

调试进程:

	1.循环判断
	
	2.取出调试事件
	
	3.列出信息
	
		   寄存器
		   内存
	
	4.用户处理

调试处理的流程:

1)判断是否为我们需要的 INT 3指令:前面说过当加载了调试器后,在线程启动的时候会有一个断点,可以通过判断是否需要启用系统断点来判断是否开启。

2)将INT 3修复为原来的数据:因为之前加断点要将加断点位置的代码修改为 0Xcc,当中断后要将代码修复为原来的数据进行显示和后续的执行。

3)显示断点的位置

4)获取线程上线文:显示寄存器的信息

5)修复EIP:当线程中断在我们断点的位置时,此时的EIP实际上已经指向下一条指令了。如果我们没有修复EIP的话,那我们上一条被我们修改为0xcc的指令就没有正常执行,即使我们前面将其修复了,CPU也会从中断的下条指令继续执行。所以要将EIP修改为断点指令的位置让其重新执行一下我们将0Xcc修复后的指令。(不等的断点修正的EIP方法不同)

6)将我们的指令以反汇编形式显示出来:可以拿到这条指令的地址(EIP),通过其硬编码转换为汇编代码

7)等待用户指令:下一步,继续执行等

注意:软件断点可以多个,所以真正写调试器的时候要用链表来保存多个软件断点的地址。

内存断点

内存断点的本质是修改内存的属性,PTE。所以只要保证所有用到的物理页都是可访问的,断点就不会被触发。

内存断点调用的函数:(VirtualProtectEx函数可以改变在特定进程中内存区域的保护属性。)

BOOL VirtualProtectEx(
			HANDLE hPeocess,
			LPVOID lpAddress,
			SIZE_T dwSize,
			DWORD flNewProtect,
			PDWORD lpflOldProtect
			);

当给某个内存(被调试进程)添加内存断点时(访问断点或写断点)实际上是修改内存的页属性:

PAGE_NOACCESS //访问断点:将页属性设置为不可访问 (PTE P位 = 0,即物理页无效会引发异常)

PAGE_EXECUTE_READ //读断点:将属性页设置为可读、可执行但不能写 (PTE R/W位 = 0 表示只读, p = 1)

即修改要加断点位置内存的物理页的属性PTE,修改PTE修改的是一个页的属性,也就是说当你给一个内存地址加上断点后,当前内存地址所在的那一个页都会产生异常并触发这个断点,不仅限于你要加断点的内存,所以在我们处理异常的函数中要判断是否是我们加断点的内存。

被调试进程:

	1.CPU访问错误内存地址,如地址不能访问、不能读等,触发页异常
	
	2.查IDT表找到对应的中断处理函数 (nt!_KiTrapOE)
	
	3.CommonDispatchException
	
	4.KiDispatchException
	
	5.DbgKForwardException 收集并发送调试事件(只有在处理用户异常时才会调用),会阻塞在这等待结果
	
		DbgkpSengApiMessage(x, x) 发送消息

调试进程:

	1.循环判断
	
	2.取出调试事件
	
	3.列出信息
	
		   寄存器
		   内存
	
	4.用户处理

调试处理的流程:

1)获取异常信息,异常原因,异常地址

<1> _EXCEPTION_RECORD.ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS]数组 描述异常的附加参数数组。 RaiseException函数可以指定这个参数数组。 对于大多数异常代码,数组元素是未定义的。 下表描述了定义数组元素的异常代码。

**\_EXCEPTION\_RECORD.ExceptionCode = EXCEPTION\_ACCESS\_VIOLATION:**
	
	数组的第一个元素包含一个读写标志,指示导致访问冲突的操作类型。 如果该值为0,则线程试图读取不可访问的数据。 如果该值为1,则表示线程试图写入不可访问的地址。  
	如果该值为8,则线程将导致用户模式数据执行预防(DEP)违规。  
 
	第二个数组元素指定不可访问数据的虚拟地址。 

_EXCEPTION_RECORD.ExceptionCode = EXCEPTION_IN_PAGE_ERROR:

	数组的第一个元素包含一个读写标志,指示导致访问冲突的操作类型。 如果该值为0,则线程试图读取不可访问的数据。 如果该值为1,则表示线程试图写入不可访问的地址。  
	如果该值为8,则线程将导致用户模式数据执行预防(DEP)违规。  
	 
	第二个数组元素指定不可访问数据的虚拟地址。  
	 
	第三个数组元素指定导致异常的底层NTSTATUS代码。  

2)判断断下的是否是我们下断点的地址,不是则放过,是则处理

3)修改内存页的属性为原来的属性

4)获取线程上线文:显示寄存器的信息

5)修复EIP:在内存异常中EIP不需要修复,因为在CPU读取指令的时候就已经发生异常了,所以不需要修复

6)将我们的指令以反汇编形式显示出来:可以拿到这条指令的地址(EIP),通过其硬编码转换为汇编代码

7)等待用户指令:下一步,继续执行等

注意:

1)内存断点和软件断点一样都可以添加多个,所以真正写调试器的时候要用链表来保存多个内断点的地址。

2)内存断点设置时可以设置内存大小各不相同,比如设置1个字节或者4个字节,当设置多个字节时,有可能会出现这多个字节不在同一个物理页中,所以可能修改了多个物理页PTE的标志位。

硬件断点

硬件断点不依赖被调试程序,即硬件断点不修改被调试进程的数据,但修改寄存器。

硬件断点产生的异常为单步异常。

设置硬件断点:

1)Dr0 ~ Dr3用于设置硬件断点,由于只有4个断点寄存器,所以最多只能设置4个硬件调试断点。

2)Dr7最重要的寄存器(32位):

<1> 0位 L0 和 1位 G0 用于Dr0是全局断点还是局部断点,G0位为1则是全局断点,L0位为1则是局部断点。从第2位到第8位为G1L1 ~ G3L3 用于控制 CR1 ~ CR0功能同上。每次异常后,Lx都会被清零,Gx不清零。

注: G3 L3 G2 L2 G1 L1 G0 L0(0位), 占0到8位

<2> LEN0 ~ LEN3从第16位开始,每个LENX占2位,用于控制 Dr0 ~ Dr3的断点长度:

00 1字节
01 2字节
10 保留
11 4字节

<3> RWE0 ~ RWE3从第18位开始,每个RWEX占2位,控制 Dr0 ~ Dr3 的断点是读、写、执行还是I/O 端口断点:

00 执行断点
01 写入数据断点
10 I/O端口断点
11 访问断点

注: LEN3 RWE3 LEN2 RWE2 LEN1 RWE1 LEN0(18位) RWE0 (16位) , 占16到31位

<4> GD位:用于保护DRx,如果GD位位1,则对DRx的任何访问都会导致进入1号调试陷阱。即IDT的对应入口,这样可以保证调试器在必要的时候完全控制DRx。

处理硬件断点:

1)硬件调试断点产生的异常是 STATUS_SINGLE_STEP(单步异常),其中 EFlage 的 TF位为1也会引发单步异常。

2)DR6 中 B0 ~ B3(0位 ~ 3位):哪个调试器触发的异常。

3)当 B0 ~ B3 中都为空时,触发的就是 EFlage 引起的单步异常。

被调试进程:

	1.CPU执行时检测到调试寄存器(DR0 ~ DR3)
	
	2.查IDT表找到对应的中断处理函数 (nt!_KiTrap01)
	
	3.CommonDispatchException
	
	4.KiDispatchException
	
	5.DbgKForwardException 收集并发送调试事件(只有在处理用户异常时才会调用),会阻塞在这等待结果
	
		DbgkpSengApiMessage(x, x) 发送消息

调试进程:

	1.循环判断
	
	2.取出调试事件
	
	3.列出信息
	
		   寄存器
		   内存
	
	4.用户处理

调试处理的流程:

不能在进程的入口设置断点,因为此时线程还没被创建,我们的硬件断点是基于线程的(context)。

所以测试时可以通过在软件断点的处理中设置硬件断点,确保硬件断点设在在当前线程。

1)获取线程上下文

2)通过DR6判断是否是硬件断点导致的异常

3)通过DR6判断哪个寄存器触发的异常

4)显示寄存器信息

5)显示反汇编

6)去掉断点

7)等待用户指令

注意:寄存器是一个CPU一份的当我们给线程寄存器如DR1中设置中断地址时,并不会影响其他线程的DR1,因为我们修改的DR1的值并不是直接修改DR1寄存器的值,修改的是线程CONTEXT中的值,再线程执行时才会将值写入到DR1中,其他寄存器同理。

单步异常(单步步入)

在EFLAGE中第8个字节的位置为单步异常标识位:

TF = 1 //设置单步运行 

处理异常:

STATUS\_SINGLE\_STEP(单步异常)

单步异常的处理流程与硬件中断的流程相同没有区别,只有在我们写的调试器中通过判断DR6来确定是硬件中断还是单步异常:

context.Dr6 & 0xF = TRUE 说明为硬件中断,否则单步异常

主要是判断Dr6中,B0 ~ B3 是否为空,不为空则是硬件中断,否则单步异常。

在用户操作事件设置TF = 1:

	CONTEXT context;
	memset(&context, 0, sizeof(CONTEXT));

	//获取线程上下文,显示寄存器信息
	context.ContextFlags = CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS;
	GetThreadContext(hThread, &context);

    context.EFlags |= 0x100;   //设置 TF = 1

	//设置线程上下文
	SetThreadContext(hThread, &context);

在单步中断后将TF位还原:

	//单步断点
    printf("SingleStep: Eip = 0x%p", context.Eip);

    //还原
    context.EFlags |= 0xfffffeff;   //还原TF位

之后再次等待用户操作。

当我们执行单步异常即单步不入时,碰到CALL指令他是会跟随CALL指令等跳转指令一起跳转的。但是单步步过不会。

单步步过

单步步过与单步步入不同,当碰到CALL等跳转指令时,单步步过会跳过。

实现方式(只有碰到跳转指令时单步步过和单步步入才会不同):

1)硬件断点

当我们判断当前指令为CALL时,在通过当前指令的长度计算出下一条指令的地址,然后在上面下一个硬件断点。若硬件断点的4个寄存器都被占用,则使用软件断点。

2)软件断点

当我们判断当前指令为CALL时,在通过当前指令的长度计算出下一条指令的地址,然后在上面下一个软件断点。

因为单步步过是通过当前指令的长度计算出下一条指令的地址后,在下个指令上加一个断点,当我们在一个CALL里面将返回地址修改掉,那通过单步步过的方式可能会找不到,无法继续即跟丢了无法继续往下跑。

调试器框架

调试器框架实现了上述的的基本功能和测试

硬件HOOK过检测

一些软件会通过一个线程来循环检测代码的正确性,当你HOOK去修改dll或exe中的代码时会触发这个检测导致被HOOK软件失败。

通过硬件HOOK过检测:

1)不修改检测线程

2)不修改挂钩子函数的代码

在要HOOK的函数地址(函数地址可以用LoadLiberal系列函数获取)处下一个硬件断点,通过我们上面的异常可以获取到这个异常和CONTEXT,可以修改Esp、Eip等寄存器,获取了Esp就是拿到了堆栈,即参数、返回地址等。Eip可以跳到我们想要执行的位置。

可以通过设置顶层异常处理过滤函数来捕获这个异常,最好也在dll注入后设置。至于硬件断点我们只需要在DLL注入时在我们想要HOOK的函数地址通过DR7与空闲的DR0~3中下上断点就好。

实现测试

蓝色妖姬 – 相守