정보를 가로채어 실행 흐름을 변경하고 기존과 다른 기능을 하게 만드는 것이다.
디스어셈블러/디버거로 프로그램 구조랑 동작 운리 파악
내가 원하는 기능이 담긴 훅 코드 개발
실행 파일과 프로세스 메모리를 자유롭게 조작하여 훅 코드 설치
유저 모드 중에서 메시지 후킹과 api 후킹이 가장 널리 쓰인다. win32API를 후킹하는게 api 후킹이다.
계속 나왔던 API는 대체 뭘까? 하는 짓이나 꼴을 보면 함수 같은데 왜 함수라고 안하고 API라고 할까?
Application Programming Interface다.
유저모드와 커널모드 사이에서 동작하는 함수라고 생각하면 된다.
사용자 앱이 시스템 자원에 직접 접근하는 것은 운영체제 하에서 막혀있다. 앱이 직접 메모리, 파일, 네트워크, 비디오, 사운드 같은 것에 접근했다가 안정성이나 보안이나 효율을 해칠 수 있으니까.
그래서 앱은 시스템 커널에게 시스템 자원을 갖다달라고 요청한다. 그 요청하는 방법이 api라는 것이다.
모든 프로세스는 기본적으로 kernel32.dll이 로드되고 kernel32.dll은 ntdll.dll을 로드하지.
얘가 바로 유저 모드 애플리케이션의 코드에서 발생하는 모든 시스템 자원에 대한 접근을 커널한테 요청하는 것이다. 메모장에서 어떤 텍스트 파일 sample.txt를 열어보려고 한다.
노트패드 코드가 msvcrt!fopen() api 호출
-> kerne!32!CreateFileW() 호출
-> ntdll!ZwCreateFile() 호출
-> ntdll!KiFastSystemCall() 호출
-> IA-32 명령어 SYSENTER
-> 커널 모드 진입
결국 웬만한 시스템 자원 활용은 SYSENTER 명령어로 커널 모드로 진입하게 되더라 이거다.
API 후킹은 이런 원리다.
- 타깃 프로세스가 어떤 kernel32.~~~( ) api호출
- 그때마다 저 api가 아니라 조작된 다른 api가 먼저 호출
- 공격자가 원하는 작업이 시됨

이 그림은 api 후킹의 모든 기술적 범주를 다룬 테크뱁이다.
후킹하는 방식에 대한 분류다. 동적으로 할래 정적으로 할래를 말한다.
파일에 작업하는게 정적, 프로세스 메모리에 작업하는게 동적일 것이다.
| static | dynamic |
|---|---|
| 파일을 대상으로 | 메모리를 대상으로 |
| 프로그램 실행 전에 후킹 | 실행 후에 후킹 |
| 최초 한번만 후킹 | 실행할 때마다 후킹 |
| 특수한 경우에 쓰임 | 보편적 |
| unhook 불가 | 프로그램 실행 중에 유연하게 unhook 가능 |
타깃의 어느 부분을 공략할 지에 대한 분류다. 세군데가 있다.
가장 단순하고 쉬운 방법이다. IAT에 있는 API 주소를 내가 심어놓고 싶은 함수(후킹 함수) 주소로 변경하는 것이다.
다 좋은데 만약 IAT에 없지만 프로그램에 사용하는 API들은 후킹할 수 없다. 동적 DLL 로딩같은거 있자나
프로세스 메모리에 매핑된 dll (시스템 라이브러리)에서 api 실제 주소를 찾아내가지고 코드를 직접 바꾸는 것이다. 가장 널리 사용된다.
시작코드를 jmp로 패치하기
함수를 일부 덮어쓰기
필요한 부분 일부 변경하기
같은 조작으로 구현한다.
잘 안 쓴다. DLL의 EAT에 있는 API 시작 주소를 후킹해서 내가 원하는 함수 주소로 변경하는 것인데 앞에 두개가 더 나아서 굳이 안 쓴다.
how에 해당한다. 구체적으로 어떻게 프로세스 메모리에 침투해서 후킹함수를 설치할지를 다룬다.
인젝션과 디버그로 나누는데 인젝션은 code injection, dll injection이 있었지. 우리 전에 했던 것들이다.
타깃을 디버깅하면서 api 후킹하는 건데 그게 그냥 디버깅이랑 뭐가 다르냐고 생각들 수 있다. 일반적인 ollydbg, x96dbg 말고 공격자가 직접 제작한 디버거로 하는게 디버그를 이용한 후킹이다. 디버거는 디버기의 모든 실행제어, 메모리 액세스, 기타 권한을 가지기 때문에 후킹 함수를 자유롭게 설치할 수 있거든.
디버거 프로그램을 돌려서 타깃을 열고 실행을 잠깐 멈춰세워서 후킹함수 설치하고 다시 실행시키면 완벽하다.
이 방식의 가장 큰 특징은 중간중간마다 내가 실행흐름을 멈춰세울 수 있어서 유동적으로 api 후킹을 추가/수정/제어할 수 있다는 것이다.
단점은 하나. 어렵고 빡세다는 것이다. 지식도 필요하고 테스트도 여러번 거쳐야하는 것이다.
그래서 막 널리널리 쓰이진 않는다.
많은 API 후킹 중에서 이번에 다룰 건 디버그 테크닉이다.
테크맵에서 HOW에 해당하는 디버그 테크닉이다. 후킹을 위해서 디버깅을 사용하는 것의 장점은 유저와 상대적으로 조금 더 Interactive한 후킹을 수행할 수 있단 것이다. 무슨 말이냐면 내가 간단한 인터페이스를 제공받아서 내가 후킹하고 싶은 프로그램의 실행을 제어하고 메모리를 자유롭게 사용할 수 있는거지
디버깅을 하는 프로그램을 디버거, 디버깅을 당하는 타깃 프로그램을 디버기라고 한다.
비주얼 스튜디오 등으로 작업을 하다보면 이미 Debug라는 용어를 쉽게 찾아볼 수 있다. 이 디버거의 역할을 그냥 소스코드를 한줄씩 실행해보면서 메모리, 레지스터, 변수에 담긴 값들을 확인하는 것 정도로 이해했을 것이다. 적어도 나는 코딩 배울 때 그랬다.
정확히 설명해보자. 운영체제로부터 디버거 프로세스로 등록되는 게 시작이다. 그러고나면 디버기에서 발생하는 디버그 이벤트마다 OS가 디버거에게 그 이벤트를 통보해준다. 디버거는 그 이벤트를 적절히 처리하고 나서 다시 디버기 실행을 재개할 수 있는 제어를 가지는 것이다.
일반적인 exception도 이버그 이벤트다.
그리고 내가 원하는 타깃 프로세스가 애초에 디버깅 중이 아니었다면 디버그 이벤트는 그냥 프로세스에서 자체 예외처리 되거나 os의 예외 처리 루틴에서 처리되고 만다.
디버그 이벤트 중에서도 디버거가 처리할 수 없는 것들은 운영체제가 그냥 처리하도록 한다.
디버그 이벤트의 종류는 9가지가 있다.
• EXCEPTION_DEBUG_EVENT
• CREATE_THREAD_DEBUG_EVENT
• CREATE_PROCESS_DEBUG_EVENT
• EXIT_THREAD_DEBUG_EVENT
• EXIT_PROCESS_DEBUG_EVENT
• LOAD_DLL_DEBUG_EVENT
• UNLOAD_DLL_DEBUG_EVENT
• OUTPUT_DEBUG_STRING_EVENT
• RIP_EVENT
굉장히 많지만 꼭 알아둬야 할 건 EXCEPTION_DEBUG_EVENT이다. 예외라는 뜻이지. 그 예외 목록들은 또 이런 것들이 있어.
• EXCEPTION_Access_VIOLATION
• EXCEPTION_ARRAY_BOUNDS_EXCEEDED
• EXCEPTION_BREAKPOINT
• EXCEPTION_DATATYPE_MISALIGNMENT
• EXCEPTION_FLT_DENORMAL_OPERAND
• EXCEPTION_FLT_DIVIDE_BY_ZERO
• EXCEPTION_FLT_INEXACT_RESULT
• EXCEPTION_FLT_INVALID_OPERATION
• EXCEPTION_FLT_OVERFLOW
• EXCEPTION_FLT_STACK_CHECK
• EXCEPTION_FLT_UNDERFLOW
• EXCEPTIONJLLEGALJNSTRUCTION
. EXCEPTION_IN_PAGE_ERROR
• EXCEPTIONJNT_DIVIDE_BY_ZERO
• EXCEPTION_INT_OVERFLOW
• EXCEPTIONJNVAnD_DISPOSITION
• EXCEPTION_NONCONTINUABLE_EXCEPnON
• EXCEPTION_PRIVJNSTRUCnON
• EXCEPTION_SINGLE_STEP
• EXCEPTION_STACK_OVERFLOW
많고 많지만 디버거가 꼭 처리해야 하는 예외는 저 EXCEPTION_BREAKPOINT다.
BP는 우리가 여태 리버싱하면서 많이 만났던 것이다.
| 항목 | 명령어 |
|---|---|
| 어셈블리 | INT3 |
| IA-32 | OxCC |
코드 디버깅 중에 int3를 만나면 실행이 중지되고 디버거에게 EXCEPTION_Breakpoint 예외 이베트가 날아간다. 그래서 우리가 브레이크 포인트를 설치하고 실행하면 늘 bp에서 멈췄던 것이다.
디버거 차원에서 BP를 구현하는 방법은 복잡하지 않다. BP 가 설치되기 원하는 코드 부분의 메모리 주소가 있겠지. 그 시작 주소에 1바이트만 CC로 바꿔치면 된다. 계속 진행하고 싶을 땐 CC 지우고 다시 원래대로 바꿔주면 된다. 이렇게 디버거가 예외 이벤트르 받으면 디버거는 제어 및 접근 권한을 얻기 때문에 다양한 작업을 할 수 있다.
디버거로 API 후킹을 하는 순서를 다시 한번 따져봅세.
후킹하고 싶은 타깃 프로세스에 attatch 하여 디버기로 만든다
Hook: 타깃의 API의 시작 주소의 첫 바이트를 0Xcc로 바꾼다.
해당 api가 호출되면 타깃 프로세스의 제어가 디버거에게 넘어온다.
사용자(혹은 공격자)가 원하는 작업을 수행한다. (파라미터, 리턴 값 조작 등)
UnHook: 0Xcc를 원래대로 복원시켜서 api가 정상 실행되게 한다.
0xCC가 빠진 api를 실행해준다.
Hook: 지속적인 후킹을 위해 다시 0xCC를 넣어준다.
제어를 디버기에게 되돌려준다.
물론 이건 정석 같은 예시고 여러 변주가 가능하다. Original API를 호출하지 않을 수도 있고, 사용자가 제공한 customized API를 호출할 수 있다. 한번만 후킹할 수도 있고 여러 번 후킹할 수도 있겠지.
원하는 건 notepad.exe의 WriteFile( ) API를 후킹하는 것이다. 파일이 저장되는 순간 입력된 파라미터를 조작해서 소문자로 입력된 것들도 모두 대문자로 바꿔져서 저장되길 원한다.
먼저 Process Explorer로 notepad의 pid를 따온다.
20816이네
그리고 후킹프로그램에 이 pid를 대상으로 실행해줘.
이거 한다고 커맨드창에 뭐가 나타나진 않아. 대신 노트패드에 뭘 적어봐봐
그냥 내가 지금 요요기 파크에 있고 24년 11월 17에 여기 공원엔 스페인 축제가 열리고 있거든.
저장한다고 노트패드 화면에 뭔가 변화가 일어나는 것은 아니다. 왜냐면 우리는 WriteFile( )api만 후킹한거니까..
그럼 이렇게 글자가 따와진다. 오리지널 글자와 대문자로 변환되어 저장된 글자들이 보인다. 실제로 저장한 test.txt를 열어보자. 붐! 노트패드가 전부 대문자로 작성된게 보인다.이제보니까 espaga가 아니라 espana라고 적었어야 했는데 잘못 적었네.
노트패드는 어떻게 파일을 저장할까? 일단 kernel32!WriteFile( )을 쓴다고 가정해본다. (교재에선 이렇게 가정하라는데 사실 난 이런 api 있는지도 몰랐네. 몰랐다면 어떻게 가정했어야 하지)
WriteFile( )의 정의는 이래
BOOL WriteFile(
HANDLE hFile,
LPCVOID IpBuffer,
DWORD nNumbe rOfBytesToWrite,
LPDWORD IpNumberOfBytesWritten,
LPOVERLAPPED IpOverlapped
);
두번째 파라미터는 쓰기 버퍼고 세번째 파라미터는 써야할 크기라고 한다. 스택프레임에 파라미터가 쌓이는 순서는 역순이니까 number of bytes가 먼저 쌓일거야.

x96dbg에서 Alt+E로 모듈간 호출에 들어가서 Kernel32.WriteFile( )을 찾아봤다. 우클릭해서 bp 걸어주고 노트패드에 아무거나 쓰고 저장해보자.

실행(F9)를 계속 하면 저장할 수 있는 단계까지 가게 된다. 내가 직접 노트패드 클릭해가면서 저장해가는게 아니라 디버거가 하게 하는 것이다.
글면 이런 화면을 볼 수 있엉. 스택을 보면 esp에는 리턴주소가 들어가있다.
ESP보다 두칸 밑, 즉 [ESP+8]를 보면 어떤 주소가 적혀있다. 이게 바로 두번째 파라미터인 '쓰기버퍼(IpBuffer)가 저장되어 있는 주소다. 우클릭해서 덤프에서 DWORD따라가기를 해보자 
왼쪽 아래에 내가 메모장에 적었던 'so tired'가 저장되어 있다. 이게 바로 notepad에서 저장하고자 하는 원래의 문자열이다.
이제 공격 방식을 깨달을 수 있다. WriteFile( ) API를 후킹하고 저 쓰기버퍼 주소에 원하는 문자열로 덮어쓰면 되는 것이다.
WriteFile( ) api가 시작되는 주소에 INT3 명령어를 설치하면, 파일이 저장될 때 디버거에게 EXCEPTION_BREAKPOINT 이벤트가 들어오게 된다.
이때 디버기, 즉 Notepad의 EIP를 좀 알아두면 좋다고 한다.
WriteFile( ) api의 시작 주소가 아니라 시작 주소에서 1바이트를 더한 값이 eip가 된다.
왜냐면
WriteFile( ) api 시작주소에서 0xCC를 읽고나서야 브레이크포인트가 실행되는거니까 시작주소에서 저 1바이트(0xCC)만큼 커진값이 EIP가 된다.
그래서 후킹할 땐, 우선 쓰기버퍼에 있는 값을 공격자가 원하는 문자열로 덮어써버리고 디버기의 EIP를 WriteFile( )의 시작주소로 되돌려야 한다.
근데 이렇게 api의 시작주소로 돌아가면 바로 그 다음에 또 다시 0x33을 만나게 되니까 결국 도돌이표에 빠진다. 이 무한루프를 방지하기 위해 쓰기 버퍼를 덮어쓰고 나면 0xCC를 지워줘야 한다. api 후킹하기 전에 미리 어딘가에 세이브해둔 오리지널 바이트로 복원해줘야 한다.
그리고 나서 API 시작주소로 돌아가야 이제야 비로소 변경된 문자열이 파일에 저장되고, 무한루프 문제도 발생하지 않는 것이다.
후커 소스코드를 살펴보자
결국 main만 봤을 간단한 편이다.
파라미터로 들어오는 타깃의 PID를 받는다. 그리고 pid로 타깃 프로세스에 attatch한다. 이건 DebugActiveProcess( )api가 해줄 것이다.
중요한 건 attatch가 되면 디버깅이 시작되고 DebugLoop( )함수로 들어가서 타깃에서 오는 디버그 이벤트를 처리한다는 것이지.
이 디버깅루프를 살펴보자
주석을 보면 여러 동작들이있다. 디버기에서 이벤트를 받아서 처리하고 다시 타깃 디버기가 실행될 수 있도록 하는 것이다. 몇가지 API를 알아보자.
디버기로부터 디버그 이벤트가 발생할 때까지 기다린다.
BOOL WINAPI WaitForDebugEvent(
LPDEBUG_EVENT IpDebugEvent,
DWORD dwMilliseconds
);
이렇게 생긴 친구다. 디버그 이벤트가 발생하면 이 첫번째 파라미터인 lpDebugEvent, 즉 DEBUG_EVENT 구조체객체에 해당 이벤트에 대한 정보를 설정하고 곧장 리턴한다.
DEBUG_EVENT는 이렇게 정의되어 있다.

아까 디버그 이벤트가 9개가 있다고 했지. 암튼 디버기에서 이벤트가 발생하면 저 구조체에 첫번째 멤버인 dwDebugEventCode가 적절하게 세팅된다. 그리고 밑에 있는 유니온 멤버도 해당 이벤트로 세팅된다.
Debug loop 가장 마지막에 있는 이 api는 다시 디버기의 실행을 재개하는 함수다.
BOOL WINAPI ContinueDebugEvent(
DWORD dwProcessId, DWORD dwThreadld,
DWORD dwContinueStatus
);
마지막 파라미터인 dwContinueStatus를 보자. 상태값을 말하는 얘는 정상처리되면 DBG_Continue를, 처리가 안됐거나 또는 애플리케이션 SEH에서 처리하고자 할 때는 DBG_EXCEPTION_NOT_HANDLED 중에서 하나를 가진다.
SEH(Structured Exception Handler) 뭔지 나중에 배우니까 걱정 ㄴㄴ
지금 이 DebugLoop가 처리하는 이벤트는 세가지다.
프로세스 생성, 예외, 프로세스 종류
하나씩 보자.
조건문을 보면 CREATE_PROCESS_DEBUG_EVENT의 이벤트 핸들러는 OnExceptionDebugEvent(&de)다. 이 함수를 보자.
이 함수는 디버기 프로세스가 시작될 때나 혹은 Attatch 될 때 호출된다. attach 된다는게 정확히 뭔지는 모르겠어.
어디에 attach한다는 거지? 메모리? 디버거?
우선 WriteFile( ) api의 시작 주소를 구하는데 타깃인 디버기의 메모리 주소가 아니라 디버거 자체의 프로세스 메모리 주소를 얻어낸다.
이거 전에 DLL 인젝션 같은거 할 때도 익숙했는데 윈도우os에선 system dll이 모든 프로세스마다 동일한 가상 메모리 주소에 로딩되니까 이렇게 구해도 된다고 했지.
그 다음 memcpy 할 때 g_cpdi가 나온다. 이거는 구조체 변수라고 한다. CREATE_PROCESS_DEBUG_INFO 구조체의 변수다.
저기 저 두번째 hProcess, 디버기 프로세스 핸들로 WriteFile( ) api를 후킹할 수 있다.
이 디버기 프로세스의 핸들을 얻어낸 다는 것은 타깃 프로세스의 디버그 권한을 가진다는 것이다. 그래서 타깃의 프로세스 메모리 공간에 자유롭게 읽기/쓰기 작업을 할 수 있다. ReadProcessMemory( ), WriteProcessMemory( ) 이런 api 쓴다.
ReadProcessMemory( )로 WriteFile( ) 첫 바이트를 읽어내서 g_chOrgByte 변수에 보존해둔다.
WriteProcessMemory( )로 이 값을 0xCC로 바꾼다.
요렇게 타깃 디버기에 WriteFile( ) 시작 주소에 0x33을 심어두는 것이다. 이제 디버기 프로세스가 WriteFile을 호출하면 디버거에게 제어권이 넘어가버리는 것이다.
예외일 땐 어떤가.
조건문을 보면 예외일 때의 이벤트는 OnExceptionDebugEvent가 핸들링한다. 이게 디버기의 INT3 명령어를 처리할 것이다. 가장 핵심적인 부분이다.


엄청 길다. 하나씩 뜯어봐야 한다.
언훅을 먼저 한다. 왜냐면 소문자->대문자 저장 작업 이후에 WriteFile( )을 정상적으로 호출하기 위해서다. 내용은 그냥 원래 바이트 복원해뒀던 g_chOrgByte를 메모리에 다시 써주면 된다.
변형된 경우에는 그냥 이 언훅을 안하고 넘어가는 배리에이션도 있다.
스레드 컨텍스트가 뭐냐면 멀티스레드 환경에서 하나의 프로세스 안에는 여러개의 스레드가 동시 실행된다.
기존 스레드를 실행할 때, 다음 실행에 필요한 정보는 CPU 레지스터에 담겨있다. 이 레지스터의 값을 정확히 유지하기 위해서 CPU 레지스터 정보를 저장하는 구조체가 필요했는데 그게 CONTEXT 구조체다. 스레드마다 CONTEXT 구조체를 하나씩 갖게 된다.
typedef struct _C0NTEXT {
DWORD ContextFlags;
DWORD DrO;
DWORD Drl;
DWORD Dr2;
DWORD Dr3;
DWORD Dr6;
DWORD Dr7;
FLOATING_SAVE_AREA FloatSave;
DWORD SegGs;
DWORD SegFs;
DWORD SegEs;
DWORD SegDs;
DWORD Edi;
DWORD Esi;
DWORD Ebx;
DWORD Edx;
DWORD Ecx;
DWORD Eax;
DWORD Ebp;
DWORD Eip;
DWORD SegCs;
DWORD EFlags;
DWORD Esp;
DWORD SegSs;
byte ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
} CONTEXT;
자 이 긴 구조체에서 스레드는 GetThreadContext( )라는 api를 이용해서 스레드 컨텍스트를 구한다.
// Thread Context 구하기
ctx.ContextFlags = C0NTEXT_CONTROL;
GetThreadContext(g_cpdi.hThread, &ctx)
구한 정보는 ctx라는 구조체 변수에 저장된다.
g_cpdi.hThread는 디버기의 메인 스레드 핸들이라는데 뭔지 모르겠다. GetThreadContext( )는 이렇게 생겼다.
BOOL WINAPI GetThreadContext(
HANDLE hThread, LPCONTEXT IpContext
)
WriteFile( )을 호출할 때 쓰기 버퍼 주소(디버기의)랑 버퍼 크기를 알아내야 한다고 했다. 그게 각각 두번째, 세번째 파라미터다.
스택프레임에 쌓여있는 파라미터를 구해내자. esp를 이용해서 파라미터에 접근해야하는데 esp 정보는 아까 구한 CONTEXT 구조체에 있었지. CONTEXT.Esp 멤버를 이용하면 된다.
// param 2 : ESP + 0x8
// param 3 : ESP + 0xC
ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0x8),
&dwAddrOfBuffer, sizeof(DWORD), NULL); ReadProcessMemory(g_cpdi.hProcess, (LPVOID)(ctx.Esp + 0xC),
&dwNumOfBytesToWrite, sizeof(DWORD), NULL);
걍 esp보다 8바이트 밑에 내려가서 두번째 파라미터 가져오고 12바이트 밑에 내려가서 세번째 파라미터 가져오란 것이다.
쓰기버퍼 주소부터 버퍼의 크기만큼의 공간을 디버거를 위한 메모리 공간으로 할당해준다. 그리고 소문자를 모두 대문자로 변환해준다.
아마 아스키코드로 0x20씩 빼주면 될 걸.
지금 eip가 0xCC를 읽어버리느라 WriteFile( )시작주소 + 1로 되어있는데 이거를 WriteFile( )의 시작위치로 옮겨준다.
eip 값 역시 컨텍스트 구조체에 있지. CONTEXT.Eip 멤버를 변경한다.
나는 걍 EIP 값을 1 깎을 줄 알았는데 정확하게 하려는 건지 WriteFile( ) 시작 주소를 가져오더라.
SetThreadContext api를 호출하고 끝난다.
BOOL WINAPI SetThreadContext(
HANDLE hThread,
const CONTEXT HpContext
);
다시 정상적인 WriteFile( )d을 호출해야 한다. ###2에 있었던 ContinueDebugEvent( )를 호출해서 디버기 프로세스 실행을 재개한다.
sleep(0)은 지금 스레드한테 있는 남은 cpu 작업 시간을 포기하는 것이다. 곧장 다른 스레드를 실행할 수 있도록 하는 것이다. 다른 스레드라 함은 디버기의 main 스레드겠지. 즉 곧바로 WriteFile을 호출할 수 있게 해주는 것이다. 그리고 나서 또 다음 스레드가 이어지겠지. 그건 4-7의 다시 훅을 설치하는 과정이다.
sleep이 없으면 노트패드가 writefile을 호출하는 도중에 후커는 다시 후킹을 시작할거라 writefile 첫 바이트가 0xCC로 또 바뀌는 memery access 에러가 생긴다.
다음번에 후킹할 때를 위해 다시 api 훅을 설치한다. 이걸 안하면 이미 아까 4-1에서 언훅한게 있어서 후킹이 완전히 풀리게 된다. 이건 아까 ###3이랑 똑같아.
디버기가 끝날 때 발생한다. 이 이벤트가 발생하면 조건문에서 break를 통해 디버거도 종료되게 만들었다.