결국 리버싱에서 제일 원하는 것은 main함수를 찾는 것이다. 가장 간단한 Hello World를 출력하는 바이너리를 생각해보자.
여기서 사용할 툴은 ollydbg라는 강력한 무료 툴이다. 이 툴을 이용해서 hello world.exe를 분석해보면 내가 작성한 소스코드에 비해 과하게 많은 코드들이 보이게 된다.
메인함수를 빠르게 찾기 위해선 stub code라고 하는, 프로그램의 실행에 직접적으로 영향을 주지 않는 코드들을 빠르게 건너뛰어야 한다. 지금 눈에 보이는 코드가 stub code인지 어떻게 아냐고? 리버싱을 많이 해보면 감이 온다. 지금은 신경쓰지 말자
EP(Entry point)에서 step into나 step over를 반복해서 한줄씩 명령어를 내려가면서 내가 원하는 동작의 API 호출코드가 나올 때까지 반복하는 거다. 예를 들어 출력 동작이면 MessageBoxW 같은 api가 나오겠지
이 방식이 가장 원초적이기에 비효율적일 순 있어도, 맨처음 해보는 입장에선 반드시 필요하다.
우선 BP를 설치해서 설치해서 매번 디버깅 할때마다 EP에서 시작하는 일에서 벗어나자.
일일히 Step를 사용해보면서 눈빠지게 분석하지 말고, 빠르게 코드를 한줄씩 실행만 시켜본다. 한줄씩 실행되다가 내가 원하는 동작(예: 출력)이 나오는 순간이 나올 것이다. 그 순간의 직전에서 호출된 함수가 내가 찾고자 하는 함수일 것이다.
보통 우리가 예제로 사용하는 함수는 너무 간단해서 위의 방법으로도 쉽게 main을 찾을 수 있다. 하지만 실제 바이너리는 이보다 훨씬 더 복잡해지는데, 이 경우에는 다른 방법을 활용해야 한다.
활용된 문자열을 검색한다. 프로그램을 실행해보니 나오는 메시지가 만약 “Hello, World”이라면, referenced text strings에서 이 문자열을 push하는 코드의 주소를 찾아갈 수 있다. 그곳이 사용자가 찾고자 하는 함수가 되는 것이다.
만약 프로그램의 기능을 보고 사용될법한 API를 예상해볼 수 있다면 intermodular 목록에서 코드에서 사용된 API 호출을 보고 원하는 함수로 찾아갈 수 있을 것이다.
소스코드를 직접 아는 것이 아닌데 Ollydbg가 프로그램에서 사용된 API 이름을 어떻게 정확히 알 수 있는지는 나중에 다룰 IAT 구조를 이해해야 한다.
만약 실행파일이 압축되어있거나 보호되어 있으면 API 호출 목록을 확인하기가 어려워진다. 이땐 DLL코드에 직접 BP를 걸어볼 수 있다. 사용자가 특정 작업을 수행하려면, 그 일과 연관된 API를 통해 커널(OS)에 직접 요청하고, 그 시스템이 구현된 DLL 파일을 메모리에 로드해야 한다.
이 DLL이 구현된 주소에 브레이크 포인트를 작성하고 프로그램을 실행하면 해당 주소에서 멈추게 된다. 이때의 ESP에 담겨있는 리턴 주소를 이용해서 원하는 함수로 찾아갈 수 있는 것이다.
파일이나 실행중인 메모리를 변경하는 작업이 패치이다. 출력되는 문자열을 패치하는 간단한 작업부터 프로그램을 대규모 수정하는 작업까지 범위가 다양할 것이다.
문자열을 패치하는 방법은 크게 두가지이다.
문자열이 담긴 버퍼에 접근해서 문자열을 직접 수정하는 것.
메모리의 다른 빈 영역에 새로운 문자열을 생성하고, 그 문자열을 출력하도록 하는 것.
예를 들어 Hello World의 출력에서 "Hello Irip"을 출력하고 싶다면 기존의 문자열이 담긴 버퍼를 수정하면 된다. 굉장히 직관적이지. 하지만 문자열 버퍼를 직접 수정하는 것은 굉장히 위험하다. 새로 작성한 문자열의 길이가 기존보다 더 긴다면, 뒤에 작성된 데이터가 덮어써지게 된다.
"Hello Irip"은 "Hello World"보다 길이가 짧거나 큰 차이가 없어서 문제가 되지 않겠지만, 만약 새로 작성한 문자열이 "Hello, This is Irip, I think I am finding you for a long time. It is hard to explain, but I can feel it" 처럼 긴 문장을 직접 삽입하려고 한다면 뒤에 "Hello Word" 뒤에 있는 어떤 데이터가 다 덮여지게 되는 것이다.
이때 이 데이터가 굉장히 중요하다면, 프로그램이 손상을 입거나 치명적인 보안상의 취약점으로 이어질 수 있다. 물론 아무 문제가 없을 수도 있지만, 이런 리스크들이 쌓이면 시스템의 안정성이 떨어진다.
때문에 두번째 방식이 더 권장된다.
물리 메모리가 가상메모리로 매핑 되는 과정에서 코드나 데이터가 작성되고 남은 부분은 그저 Null로 패딩된 영역이 존재한다. 물론 아무 영역만 되는 것은 아니고, 텍스트 데이터가 속해있는 영역이긴 해야한다. 나중에 PE구조에서 매핑 설명해줄 때 더 잘 얘기해줄게. 이 영역에 유저가 원하는 문자열을 작성해서 넣어준다. 유니코드로 작성된 문자열이 적절한 Hex Code로 대입될 것이다.
그 후, 출력 함수가 참조하는 문자열의 주소를 기존의 주소가 아닌 새로 작성한 문자열을 작성하게 하는 것이다. 주소 바꿔치기라고 생각하면 된다.
이렇게 패치한 버전을 디버거에서 실행하면 패치된 문자열을 출력할 수 있다. 하지만 이걸 파일로 저장하면 제대로 동작하지 않을 수 있다. 새로 생성한 문자열 버퍼가 담긴 실제 물리적인 파일 offset이 존재하지 않을 수 있는 것이다. 가상 메모리의 VA 범위가 파일의 offset 범위보다 크기 때문에 va에선 존재해도 offset에선 없을 수 있다. 이것도 나중에 설명해줄게.
데이터를 저장하는 방식을 바이트 오더링이라고 하는데 크게 두가지가 있다. 바이트 열에서 가장 큰 값을 먼저 저장하는 빅엔디언(BE)와 바이트열에서 가장 작은 값을 먼저 저장하는 리틀 엔디언(LE)이 있다.
내가 계속 헷갈려서 적는 파트. 16진수로 표현할때, 두자리 값이 1바이트이다.
0xABrk 1바이트. 왜냐, AB는 1010(2)로 표현되니까 4비트, B는 1011(2)로 표현되니까 4비트. 합쳐서 8비트니까 1바이트야 알겠지? 그만 헷갈려 네
어떤 엔디언이 적용된다고 해도 바이트 안에서의 순서는 달라지지 않는다. 이걸 니블 단위 숫자(0~F)는 순서가 유지된다고 한다.
예를 들어 0x1A5D를 저장하고 싶다고 하자.
이건 2바이트 WORD이다. 1A라는 바이트와 5D라는 바이트가 있다. 즉 2의 0제곱부터 2의 15제곱까지의 비트가 필요하게 된다. 가장 큰 바이트 값은 1A가 차지할 것이다. (2의 8제곱부터 2의 15제곱의 자리), 그리고 작은 바이트는 5D가 차지하겠지. (2의 0제곱부터 2의 7제곱 자리)
빅엔디언으로 저장하면 1A -> 5D 순서로 저장할 것이다.
장점: 당연히 사람이 보기에 직관적이지. 순서대로 말하는게 좋잖아.
"좋잖아, 말하는게, 순서대로"라고 말하는건 뭔가 이상한 힙합퍼 같고.
리틀 엔디언으로 저장하면 5D->A1이 될 것이다.
장점
눈으로 볼 땐 모르겠다. 그치만 Intel x86 CPU 아키텍처에서 LE를 사용한다. 그래서 윈도우 계열에서 리버싱 공부하려면 리틀 엔디언을 잘 알아야 한다.
그래서 우리가 hex code에서 56 34 12 이런 데이터를 본다면 0x123456이라고 읽을 줄 알아야 한다.
그리고 데이터를 역순으로 저장하면 산술 연산이나 데이터 타입이 확장/축소할 때 효율적이래.
프로그램이 실행될 때 cpu가 매번 RAM이란 메모리에 접근하기엔 시간이 오래 걸리니 cpu에 근접하게 위치한 레지스터에 접근해서 데이터를 빨리 처리해
리버싱 할 때 알아두어야 할 레지스터들이 몇 개 있다. esp, ebp, eip, eax, ecx 등등 있어. 스택할때 알려줄게.
레지스터 종류로는 범용 레지스터, 세그먼트 레지스터, 상태 레지스터, instruction pointer가 있다. 하지만 레지스터를 지금 다루기엔 우선 스택프레임의 구조, 그리고 코드가 실행되면서 스택 프레임과 메모리, 레지스터가 어떻게 달라지는지를 알고 얘기하는 것이 좋을 것이다.
지금은 그냥 레지스터란 프로그램이 실행되면서 필요한 값을 쉽게 쉽게 꺼내쓰는 용도거나 새로 값을 쓸 때 기존 값을 따로 보관하기 위한 저장소라고 생각하면 된다.
다음부턴, 올리디버거를 캡쳐한 화면과 함께 첨부하겠다.