| 메모리 브레이크포인트


메모리 브레이크포인트(Memory Breakpoint)는 사실 브레이크포인트가 아니다. 디버거가 메모리 브레이크포인트를 설정하면 해당 메모리 영역이나 페이지에 대한 접근 권한이 변경된다. 메모리 페이지는 운영체제가 처리하는 가장 작은 단위의 메모리 크기다. 메모리 페이지가 할당되면 그곳에 대한 접근 권한이 부여된다. 메모리 페이지에 부여되는 접근 권한은 다음과 같다.



- 페이지 실행(Page Execution) 이 권한이 할당된 메모리 페이지는 실행시킬 수 있다. 하지만 이 메모리 페이지에서 데이터를 읽거나 쓰려고 하면 접근 위반 예외가 발생한다.


- 페이지 읽기(Page Read) 프로세스는 이 권한이 할당된 메모리의 내용을 읽을 수 있따. 하지만 데이터를 쓰거나 실행시키려고 하면 접근 위반 예외가 발생한다.


- 페이지 쓰기(Page Write) 이 접근 권한은 프로세스가 해당 메모리 페이지에 데이터를 쓰는 것만 허용한다.


- 보호 페이지(Guard Page) 이 권한이 할당된 페이지에 대해 어떤 종류의 접근이라도 발생하면 예외를 발생시킨다. 예외를 발생시킨 이후에는 페이지의 원래 상태로 복귀된다.



대부분의 운영체제는 이와 같은 접근 권한들을 지원한다. 예를 들어 메모리상에는 읽고 쓰는 것이 가능한 메모리 페이지가 있을 수 있고 읽고 실행하는 것이 가능한 또 따른 메모리 페이지가 있을 수 있다. 또한 각 운영체제는 특정 메모리 페이지의 접근 권한을 질의하거나 접근 권한을 원하는 대로 변경시킬 수 있는 내장 함수를 제공한다. 



메모리의 접근 권한 중에서 우리의 관심사는 바로 보호 페이지(Guard Page) 권한이다. 이 접근 권한은 스택에서 힙을 분리해내거나 특정 메모리 영역이 어떤 범위 이상으로 커지는지 확인하는데 유용하다. 또한 특정 메모리 영역에 대한 접근이 발생할 때 프로세스를 중지시키고자 할 때 매우 유용하게 사용된다. 


예를 들어 네트워크 서버 어플리케이션을 리버스 엔지니어링할 때 어플리케이션에 전달된 패킷의 페이로드가 저장되는 메모리 영역에 메모리 브레이크포인트를 설정할 수 있다. 이렇게 하면 브레이크포인트를 설정한 메모리에 대한 접근이 발생했을 때 CPU가 보호 페이지 디버그 예외를 발생시키기 때문에 어플리케이션이 전달된 패킷의 내용을 언제, 어떻게 사용하는지 판단할 수 있게 된다. 그리고 해당 메모리 페이지에 접근하는 명령을 조사해 어플리케이션이 패킷의 내용으로 어떤 작업을 수행하는 것인지 알아낼 수 있다. 메모리 브레이크포인트는 실행되는 어떤 코드도 변경하지 않기 때문에 소프트 브레이크포인트가 갖고 있는 코드 변경으로 인한 제약을 극복할 수 있다.









| 하드웨어 브레이크포인트


하드웨어 브레이크포인트(Hardware Breakpoint)는 설정할 브레이크포인트의 개수가 적을 떄나 디버깅할 소프트웨어의 코드가 변경되면 안될 때 유용하게 사용할 수 있다. 이런 형태의 브레이크포인트는 CPU 레벨에서 브레이크포인트를 설정하는 것이다. 즉, 디버그 레지스터라고 불리는 특별한 레지스터를 이용한다. CPU에는 일반적으로 하드웨어 브레이크포인트를 설정하는 데 사용되는 디버그 레지스터가 8개(DR0~DR7) 있다. DR0에서 DR3까지의 디버그 레지스터는 브레이크포인트의 주소를 저장하기 위해 사용된다. 이는 단지 한 번에 최대 4개까지의 하드웨어 브레이크포인트만을 설정할 수 있다는 뜻이다. 


디버거 레지스터 DR4와 DR5는 예약된 레지스터이고, DR6는 브레이크포인트에 의해 발생되는 디버깅 이벤트의 종류를 판단하기 위해 사용되는 상태 레지스터다. 디버그 레지스터 DR7은 하드웨어 브레이크포인트의 ON/OFF 스위치로 사용되며, 서로 다른 브레이크포인트의 조건도 저장한다. DR7의 특정 플래그값을 설정하면 다음과 같은 조건의 브레이크포인트를 만들어낼 수 있다.


- 지정된 주소의 명령이 실행될 때

- 데이터가 어느 주소에 써질 때

- 어느 주소에 대한 읽기/쓰기 작업이 수행될 때


이처럼 하드웨어 브레이크포인트는 실행 중인 프로세스의 코드를 변경하지 않고 매우 구체적인 조건의 브레이크포인트를 최대 4개까지 설정할 수 있다. 




소프트 브레이크포인트에서는 INT 3 이벤트를 사용하지만 하드웨어 브레이크포인트에서는 INT 1 이벤트를 사용한다. INT 1은 하드웨어 브레이크포인트를 위한 이벤트이며 단일 스텝 이벤트다. 단일 스텝은 각 명령을 하나씩 수행할 수 있다는 의미다. 이는 중요한 부분의 코드와 데이터의 변경 내용을 매우 세밀히 살펴볼 수 있게 한다.


하드웨어 브레이크포인트는 소프트 브레이크포인트와 동일한 방법으로 처리되지만 로우레벨에서 수행된다. CPU는 명령을 실행하기 전에 해당 주소가 하드웨어 브레이크포인트로 설정되어 있는지 먼저 확인한다. 또한 수행할 명령이 하드웨어 브레이크포인트가 설정된 주소에 접근하는지 여부를 확인한다. 해당 주소가 DR0~DR3 레지스터에 저장되어 있고 읽기/쓰기나 실행 조건이 설정되어 있다면 CPU는 명령에 대한 실행을 중지시키고 INT 1 이벤트를 발생시킨다. 해당 주소가 디버그 레지스터에 저장되어 있지 않다면 CPU는 해당 명령을 실행하고 다음 명령으로 이동해 하드웨어 브레이크 포인터 설정 내용을 다시 확인한다.


하드웨어 브레이크포인트는 매우 유용하지만 몇 가지 제약이 있따. 단지 4개의 개별적인 브레이크포인트를 설정할 수 있다는 것과 별개로 브레이크포인트를 설정할 수 있는 데이터의 최대 크기가 4바이트라는 점이다. 이는 큰 메모리 영역에 대한 버근을 추적하고자 하는 경우에는 맞지 않는다. 이런 한계를 극복하려면 메모리 브레이크포인트를 사용해야 한다.









| 소프트 브레이크포인트

소프트 브레이크포인트(Soft Breakpoint)는 명령을 실행하는 CPU를 일시 중지시키는데 사용되며 애플리케이션을 디버깅할 때 가장 흔하게 사용되는 형태의 브레이크포인트다. 소프트 브레이크포인트는 한 바이트 명령을 사용해 디버깅 대상 프로세스의 실행을 중지시킨다. 프로세스의 실행이 중지되면  디버거의 브레이크포인트 예외 핸들러가 제어권을 전달받는다. 이 작업이 어떻게 수행되는지 이해하려면 x86 어셈블리 언어에서의 명령과 opcode의 차이점을 이해하고 있어야 한다.


어셈블리 명령은 CPU를 실행시키기 위한 명령을 하이레벨 수준으로 표현한 것이다. 예를 들면 다음과 같다.


MOV EAX, EBX


이 명령은 CPU가 EBX 레지스터에 있는 값을 EAX 레지스터에 저장하라는 의미로, 상당히 간단하다. 하지만 CPU는 사실 이 명령을 어떻게 해석해야 하는지 모른다. 따라서 어셈블리 명령은 opcode라는 형태로 변환되어야 한다. opcode는 바로 CPU가 실행하는 기계어 명령이다. 위의 어셈블리 명령을 opcode로 변환하면 다음과 같다.


8BC3


보다시피 어떤 의미인지 파악하기 힘든 매우 난해한 형태로 변환되었다. 하지만 이런 형태로 CPU와의 대화가 이루어진다. 어셈블리 언어를 DNS 주소에 비유해 생각하면 이해가 쉬울 것이다. CPU가 실행하는 opcode(IP 주소)를 모두 기억하는 것은 어렵다. 따라서 기억하기 쉽게 변환한 것이 어셈블리 명령(DNS 주소)이다. 디버깅 시에 opcode를 사용해야 하는 경우는 거의 없다. 하지만 소프트 브레이크포인트를 이해하려면 중요하다. 예를 들어 0x44332211 위치의 opcode와 어셈블리 명령이 다음과 같다고 생각해보자.


0x44332211:    8BC3    MOV EAX, EBX


이는 주소의 opcode, 어셈블리 명령을 보여준다. 이 주소의 위치에 소프트 브레이크포인트를 설정해 CPU를 일시중지시키려면 2바이트 opcode인 8BC3 중에서 1바이트를 교체해야 한다. CPU를 일시 정지시키기 위해 새롭게 교체되는 1바이트는 인터럽트 3(INT 3) 명령의 opcode다. opcode 0xCC로 교체되는 것이다. 다음은 소프트 브레이크포인트 설정 전과 설정 후의 상태를 보여준다.


브레이크포인트를 설정하기 전의 opcode

0x44332211:    8BC3    MOV EAX, EBX


브레이크포인트를 설정한 이후의 opcode

0x44332211:    CCC3    MOV EAX, EBX



브레이크포인트를 설정함에 따라 8B가 CC로 교체된 것을 확인할 수 있다. CPU가 이 바이트를 만나게 되면 INT 3 이벤트를 발생시킨다. 디버거는 자체적으로 이 이벤트를 처리할 수 있다. 따라서 디버거를 자체적으로 제작하려면 디버거가 이 이벤트를 어떻게 처리하는지 이해해야 한다. 디버거는 특정 주소에 브레이크 포인터를 설정하라는 명령을 받으면 해당 주소의 첫 번쨰 opcode바이트를 읽어 그것을 저장하고 그 위치에 CC 바이트를 써 넣는다. CC opcode로 인해 CPU가 브레이크포인트나 INT 3 이벤트를 발생시키면 디버거는 그 이벤트를 전달받는다. 그러면 디버거는 EIP 레지스터(instruction register)가 자신이 이전에 설정한 브레이크포인트 주소를 가리키고 있는지 확인한다. EIP 레지스터가 가리키는 주소가 디버거 내부의 브레이크포인트 리스트에 존재하면 디버거는 실행이 다시 재개될 때 올바로 실행되게 하기 위해 이전에 저장해 두었던 원래의 opcode 바이트를 해당 주소 위치에 써 넣는다.


디버거는 소프트 브레이크포인트를 처리하기 위해 많은 일을 수행해야 한다. 소프트 브레이크포인트는 두 가지 종류가 있다. 하나는 일회성 브레이크 포인트(One-shot breakpoint)이고, 다른 하나는 지속적인 브레이크포인트(persistent breakpoint)다. 일회성 브레이크포인트는 한 번 브레이크포인트 이벤트가 발생하면 디버거의 내부 브레이크포인트 리스트에서 해당 브레이크포인트 정보가 제거되는 것이다. 이는 단 한 번만 브레이크포인트를 발생시키고자 할 때 안성맞춤이다. 지속적인 브레이크포인트는 브레이크포인트가 발생하고 CPU가 원래의 opcode를 실행한 다음에 다시 브레이크포인트 설정을 수행하는 것이다. 따라서 브레이크포인트 리스트의 정보는 계속 유지된다.


소프트 브레이크포인트는 한 가지 단점이 있다. 그것은 메모리 상의 실행 바이너리의 바이트를 변경하기 때문에 CRC(Cyclic Redundancy Check) 체크섬 값이 변경된다는 것이다. CRC는 데이터가 변경되었는지 확인하기 위해 사용되는 방법이며, 파일, 메모리, 텍스트, 네트워크 패킨, 기타 모든 종류의 데이터에 적용가능하다. CRC는 특정 범위의 데이터 영역으로부터(이 경우에는 프로세스의 메모리 영역) 그것의 해쉬 값을 산출해 낸다. 그리고 데이터가 변경되었는지 확인하기 위해서 해당 데이터 영역의 원래 CRC 체크섬 값과 비교한다. 체크섬값이 서로 틀리면 데이터가 변경된 것이다. 악성 코드는 흔히 실행되고 있는 자신의 메모리상 코드에 대한 CRC 체크섬 값을 확인한다. 그리고 자신의 코드가 변경됐다는 것이 확인되면 스스로 종료해버린다. 이렇게 하면 악성 코드는 자신을 리버스 엔지니어링하는 것이나 소프트 브레이크포인트를 설정해 분석하는 것을 효과적으로 차단할 수 있다. 따라서 결국엔 악성 코드의 행위만을 동적 분석할 수박에 없다. 이런 한계점을 극복하려면 하드웨어 브레이크 포인트를 사용해야 한다.







+ Recent posts