본문 바로가기

리버싱/Lena's Reversing for Newbies

Reversing With Lena Tutorial 15 - Call Stack, Code Caving

리버싱 튜토리얼 중에서도 유명한 레나(Lena)의 튜토리얼 15번째다. 첫번째부터 진행하지 않고 중간중간 하는 이유는 지금 읽고 있는 책을 따라서 실습해보는 중이기 때문이다. 추후 첫번째 튜토리얼부터 따라해볼 예정이다.

 

Abex의 크랙미때와는 달리 특별한 설명 파일은 없고 애초에 HTML과 플래시 파일로 튜토리얼이 제공되기 때문에 직접 따라하면서 공부할 수 있다. 여기서는 해당 도서를 따라하면서 했기 때문에 튜토리얼 내용과 차이가 좀 있을 수 있다.

 

먼저 이번 리버스미(ReverseMe)에서는 Nag라는, 프로그램 실행 전후로 나타나는 특정 창을 띄우지 않도록 리버스 엔지니어링하는것을 목표로 하고 있다. 프로그램을 별다른 조작 없이 곧바로 실행시킨다면 아래와 같은 Nag 창이 몇초간 나타난다.

해상도 차이로 인해 글자가 잘리는듯하다.

이후 메인 프로그램이 실행되어 사용자 입력을 기다리는데 몇가지 메뉴와 버튼이 있다. 

File 메뉴에서는 프로그램 종료, Help 메뉴에서는 프로그램의 정보를 출력하는 메시지 박스를 호출하고 있다. 설명을 읽어보면 아래의 Register 버튼을 누르면 closing nag, 즉 종료 시에 나타나는 Nag를 출력하지 않는다는 것 같다. 일단 확인을 위해 아무런 조작을 하지 않고 Exit 버튼을 눌러보면 다음과 같은 Nag 창이 또다시 몇초간 나타난다.

이번에는 확인을 위해 다시 프로그램을 실행해서 Register 버튼을 누르고 Exit 버튼으로 종료했더니 Nag가 나타나지 않았다. 언뜻 들어보면 Register 버튼을 눌러서 종료 Nag를 없애고 디버깅하여 시작 Nag를 없애면 될 것 같지만 Help의 About 메뉴에서는 다음과 같은 내용을 알려주고 있다.

즉, begin nag와 closing nag 모두를 Register 버튼을 사용하지 않고 디버깅을 통해 코드상에서 나타나지 않도록 수정하라는 것이 이 리버스미의 최종 목표가 될 것이다. 일단 x32dbg를 켜서 디버깅을 시작해보도록 하자.

엔트리 포인트에서 한 구문씩 실행시켜볼 수도 있지만 초기 작업에 너무 많은 시간을 소모하기 때문에 일단 프로그램을 실행시켜본 후 이전처럼 문자열을 탐색해보았다. Help 메뉴의 About 탭에서 위와 같이 "Clicking on Register button..." 문자열을 메시지 박스로 출력하고 있기 때문에 시도했던 방법이지만 아쉽게도 별다른 값을 찾을 수 없었다.

Click 이란 단어로 필터링을 수행했을 때 하나도 걸리지 않는 것을 볼 수 있다.

그렇다면 어떻게 해볼 수 있을까? 여기서 사용할 수 있는 기능이 바로 Call Stack(콜 스택)이다. 콜 스택이란 프로그램에서 현재 실행중인 서브루틴(함수 등)에 대한 정보를 저장하는 자료구조로 서브루틴 간 호출 순서를 추적할 수 있다는 장점이 있다. Head에 대해 Push, Pop만 순서대로 가능한 스택 자료구조의 특성을 활용하여 함수호출마다 스택 프레임이 스택이 쌓이는 방향(낮은 주소)으로 저장된다. 스택 프레임에는 이전 EBP 값, 지역 변수, 매개변수, 복귀 주소함수 등 함수 호출 및 종료 시 필요한 정보들이 저장된다. 만약 이 Nag가 메인 다이얼로그가 시작될 때 호출되는 그런 함수라면 어딘가에서는 이 Nag를 부를테니 그 함수 호출 기록을 스택을 통해 확인해보는 것이다.

 

그렇다면 어떻게 콜 스택을 추적할 수 있을까? x32dbg 기준 다음과 같은 "호출 스택" 메뉴를 활용할 수 있다.

프로그램 실행 및 종료 시 몇초간 Nag가 실행되는데 그때 디버깅을 일시 중지(왼쪽 위에 버튼이 있다)하고 호출 스택을 확인하면 다음과 같은 스택이 있는 것을 알 수 있다.

win32u나 mfc42같은 모듈은 프로그램 실행 기반 프레임워크/라이브러리일 것이다. 그렇다면 맨 아래쪽의 reverseme. nags.0042039에서 현재 실행되고 있는 Nag를 호출한 것일 텐데 이를 확인하기 위해 해당 주소(0x0042039F) 근처의 함수 호출 명령어에 중단점을 걸고 확인해보았다.

정확히 0x0042039F는 아니지만 근처의 함수 호출을 확인해보기로 했다.
중단점에 잘 걸리는 것을 알 수 있다.

몇 번의 실행 결과 0x0042039A의 Ordinal#2514 함수를 총 3번 호출한다는 것을 확인할 수 있었는데 바로 begin nag, 다이얼로그, closing nag가 나타날 때였다.

즉 Ordinal#2514 함수로 begin nag를 출력한 후 잠시 기다렸다가 다시 해당 함수에서 메인 다이얼로그를 호출하고 Exit 버튼으로 종료되면 마지막으로 해당 함수에서 closing nag를 출력하는 방식이다. 그렇다면 해당 함수 호출을 없애버리면 nag도 사라지지 않을까? 생각할 수 있지만 그렇게 되면 nag 뿐 아니라 메인 다이얼로그 자체도 출력되지 않기 때문에 다른 방법이 필요해진다.

이때 해당 명령어 주변을 살펴보면 윗부분에 test eax, eax로 점프를 결정하는 구문이 있다. 현재는 0x00420372의 함수 호출이 1을 반환했기 때문에 피연산자를 AND하여 ZF를 설정하는 test 특성상 플래그가 세워지지 않아 점프가 일어나지 않는다. 그렇다면 만약 jne로 어셈블해서 항상 점프가 일어나도록 하면 어떨까?

순식간에 프로그램이 종료된 것을 볼 수 있었다. 즉 0x004203BA로 점프하게 된다면 nag나 다이얼로그가 나타나지 않고 그렇지 않다면 나타나는 분기를 찾을 수 있었는데 그렇다면 이를 어떻게 조작해야 첫번째, 세번째 호출(nag)에서는 점프하고 두번째 호출(다이얼로그)에서는 점프하지 않도록 지정할 수 있을까? 여기서 사용할 수 있는 기술이 "코드 케이빙(Code Caving)"이다.

add byte ptr ds:[eax], al로 표시된 사용되지 않는 영역

프로그램을 디버깅해보면 모든 영역에 코드가 꽉 차있지 않고 위처럼 빈 공간이 존재하는 것을 볼 수 있다. 이 부분에 동굴(cave)을 파서 코드를 집어넣고 사용하는 기술이 코드 케이빙인데 흐름을 보이면 다음과 같다.

정상 흐름인 검은 화살표 / 조작된 흐름인 빨간 화살표

기본적으로 코드는 위에서 아래로 순차적으로 실행된다. 하지만 중간중간 다른 함수를 호출하면 스택에 매개변수나 반환주소 등을 넣고 해당 함수로 넘어갔다가 다시 복귀하게 되는데 이와 비슷하게 함수 호출 대신 특정 코드 블록(여기서는 빈 공간에 파낸 코드 동굴)으로 점프했다가 다시 원래 위치로 점프하는 방식으로 이를 활용하는 것이다.

그러면 아무 곳에나 동굴을 파서 코드를 적어넣으면 될까? 그건 또 아니다.

프로세스가 사용하는 메모리 영역에는 프로그램의 코드(명령어)가 들어있는 ".text" 영역, 코드에서 사용하는 데이터가 들어있는 ".data" 영역, 프로그램에서 사용하는 리소스가 들어있는 ".rsrc" 영역이 있다. 각 영역마다 주어진 권한이 다른데 가장 큰 차이점은 ".text" 영역에는 Execute, Read 권한이, ".data" 영역에는 Read, Write 권한이 주어져 있는 것을 볼 수 있다.

https://ko.wikipedia.org/wiki/%EB%A9%94%EB%AA%A8%EB%A6%AC_%EB%A7%B5

흔히 메모리 구조를 공부하다보면 프로그램 코드가 들어있는 영역이라던지, 초기화된 변수나 지역 변수 등이 저장된 영역이 구분되어 있던 것을 볼 수 있었을 것이다. 이렇게 프로그램의 구성 성분(변수, 상수, 코드 등)을 구역마다 분할시켜놓고 보안을 위해 각 구역마다 권한을 달리 지정했기 때문에 프로그램 코드(기계어)는 실행(Execute) 권한이 있는 구역에서만 실행될 수 있다. 마찬가지로 코드는 읽기(Read) 권한이 있는 영역의 데이터만 읽을 수 있다.

 

그렇다면 이 문제를 어떻게 해결할 수 있을지에 대해서 생각해보자. 이 리버스미의 코드는 복잡해보이지만 프로그램의 흐름은 (실행) -> Nag 호출 -> 다이얼로그 호출 -> Nag 호출 -> (종료)로 매우 간단하다. Nag, 다이얼로그, Nag가 나타날 때마다 함수(Ordinal#2514)가 호출되는데 첫 번째(begin nag), 세 번째(closing nag)로 호출될 때는 함수가 호출되지 않는 분기를, 두 번째로 호출될 때는 함수가 호출되는 분기를 타도록 조정한다면 Nag를 지우고 다이얼로그만 출력시킬 수 있을 것이다.

 

그렇다면 해당 함수가 몇번째로 불렸는지 파악하려면 어떻게 할 수 있을까? 가장 간단한 방법은 카운터로 측정하는 것이다. 하지만 프로그래밍이 아닌 리버스 엔지니어링에서 어떻게 카운터를 구현할 수 있을까? 이는 ".data" 영역의 빈 곳(0x00)에 ".text" 영역의 코드(동굴 안에 있는)가 값을 증가시키면서 구현할 수 있다.

  1. 먼저 실행, 읽기가 가능한 ".text" 영역의 빈 곳(동굴)을 찾는다.

  2. 동굴의 코드에서 사용할 만한 ".data" 영역의 빈 곳을 찾는다.

  3. Nag, 다이얼로그를 표시하던 Ordinal#2514 함수의 분기문을 지우고 동굴로 점프하는 명령어로 어셈블한다.

  4. 동굴 내에 카운터 및 비교를 수행하는 코드를 작성한다.

    1. 미리 찾아둔 데이터 영역의 빈 곳의 값을 1 증가시킨다.

    2. 데이터 영역의 값이 2인지 비교한다.

      1. 2가 아니라면 첫 번째, 혹은 세 번째로 Ordinal#2514 함수가 호출되어 Nag를 호출하는 것이다. 그러므로 함수를 실행하지 않는 분기로 점프한다.

      2. 2라면 두 번째로 Ordinal#2514 함수가 호출되어 다이얼로그를 호출하는 것이다. 그러므로 함수를 실행하는 분기로 점프한다.

그렇다면 이 계획을 바탕으로 실제로 코드 케이빙을 수행해보자.

먼저 ".text" 영역에서 동굴로 쓸만한 적당한 위치를 찾아야 한다. 올리디버거에서는 NOP으로 표시되던것 같은데 x32dbg 기준으로는 add 명령어로 표시되고 있다. 중요한 것이 x96dbg 기준으로 저렇게 나타나는 모든 구역에 코드 케이빙이 가능한 것은 아니다.

아래쪽 상자에 [파일명]:$37E20 #0 처럼 파일 섹션의 끝 이후나 SizeOfRawData가 0인 경우 파일 오프셋이 #0으로 출력되는 곳에는 코드 케이빙을 해도 패치로 내보낼수가 없다. 이는 x64dbg FAQ에서도 확인할 수 있다. 그래서 너무 아래쪽이 아닌 원래 코드에서 멀지 않은 곳(0x00437D70)으로 선택했다.

코드에서 사용할 ".data" 영역으로 0x00445EA0을 선택했다. 이곳을 카운터 값을 저장하는 곳으로 사용하는 것이다.

함수 실행의 분기는 Ordinal#3957 함수를 호출한 후 결과값을 test로 비교하여 점프 여부를 결정한다. 0x00420379에서 점프를 수행하면 Nag나 다이얼로그를 띄우지 않고 수행하지 않으면 띄우게 된다. 그렇다면 일단 이 부분을 카운터로 조작하기 위해 test eax, eax를 아까 동굴 위치로 점찍어둔 0x00437D70로 점프하도록 어셈블한다.

그런데 어셈블했더니 명령어 일부분이 nop으로 바뀌었다. 이는 명령어가 달라지면서 해당 명령어가 차지하던 공간(85C0 / 2바이트)에 새로운 점프 명령어(E9 F4790100 / 5바이트)가 다 들어가지 못해 남는 3바이트가 뒤에 있던 명령어를 침범하게 된 것이다. 그렇기 때문에 명령어를 어셈블하기전에 어셈블 메뉴에서 기존 명령어 공간을 유지하도록 하는 '크기 유지' 옵션을 사용하거나 지워질 명령어를 다른곳에 메모해두는 것이 좋다.

이를 기반으로 작성된 동굴 속 코드는 위와 같다.

  1. add byte ptr ds:[0x00445EA0], 1: 0x00445EA0에 위치한 값을 1 증가시킨다. 카운터 역할을 수행한다.

  2. cmp byte ptr ds:[0x00445EA0], 2: 0x00445EA0에 위치한 값이 2인지 확인한다. 다이얼로그를 호출하는 지 확인한다.

  3. jne reverseme. nags. 4203BA: 이는 0x00420377의 test eax, eax를 어셈블하기 전 아래에 있던 명령어로 je 대신 jne로 어셈블하였다.

    1. 기존에는 test eax, eax가 항상 ZF를 세우지 않아 je가 수행되지 않아 Nag, 다이얼로그 창이 호출되었다. 즉 점프를 한다면 다이얼로그 창이 뜨지 않는다.

    2. 이번에는 ZF를 세우지 않으면 점프하여 다이얼로그 창을 호출하지 않도록 반대(jne)로 수정하였다.

    3. cmp 명령어는 피연산자가 동일해야 ZF를 세우며 0x00445EA0에 저장된 카운터 값이 2라면 ZF가 설정된다.

    4. 이는 add 명령어가 두번 실행된 것이며 두 번째로 함수가 호출되어 다이얼로그를 부르는 상황임을 의미한다.

    5. 즉 두 번째 함수 호출을 제외하고는 함수를 호출하지 않도록 하여 Nag를 띄우지 않는 것이다.

  4. lea ecx, dword ptr ss:[esp+4C]: 이것 역시 test eax, eax가 jmp로 어셈블되면서 사라진 명령어로 복구해두었다.

  5. jmp reverseme. nags. 42037F: 동굴에서 원본 코드로 돌아가는 명령어다.

    1. 현재 0x00420377에서 0x0042037E까지 명령어가 수정되어 동굴 코드로 점프하게 되어있다.

    2. 이는 함수 호출이 아닌 단순 점프기 때문에 동굴 코드에서 모든 명령어를 수행한 후에는 자동으로 원래 코드로 돌아가지 않는다.

    3. 그래서 동굴 속의 모든 코드가 끝난 이후에는 원본 명령어의 흐름처럼 돌아갈 수 있도록 수정된 명령어 구간 바로 다음의 주소로 점프해야 한다.

제대로 작동하는지 확인해보기 전에 x96dbg의 경우 파일 패치에서 내보내기를 통해 패치 내역을 파일이 아닌 일종의 리스트로 저장할 수 있다.

실제 실행 파일이 아닌 패치 내역 목록을 저장하기 때문에 디버거를 재시작하더라도 패치 내역을 불러올 수 있다. 코드 케이빙에 몇번 실패하여 재시작하면서 계속 파내고 작성하다보면 번거로울 수 있기 때문에 이처럼 목록으로 저장하는 편이 편할 수 있다.

 

실행시켜보면 아까 데이터 영역에서 지정한 곳이 0x02 값을 가질 때 해당 함수 호출에 중단점이 걸리는 것을 확인할 수 있다. 즉 begin nag가 호출되지 않았다는 것을 의미한다. 계속 진행하면 다이얼로그가 나타나며 Exit 버튼을 눌러도 closing nag가 호출되지 않는 것을 알 수 있다.

해결방법은 단순하지만 처음에 실습해볼 때는 코드 영역 밖에 패치를 해서 계속 패치 내역이 실제 파일로 반영되지 않았던 문제때문에 많이 헤맸다. 다행히 FAQ에서 원인을 찾아서 영역 내에 패치를 하니 정상적으로 반영되어 다행이었다. 레나의 리버싱 튜토리얼도 매우 괜찮은 강의라 생각하는데 얼른 시간을 내서 처음부터 들어봐야 할 것이다.

'리버싱 > Lena's Reversing for Newbies' 카테고리의 다른 글

Reversing With Lena Tutorial 17  (0) 2020.11.12