ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • eild(강신일)'s 간단한 어셈블리어 디컴파일
    Team $!9N 구성원의 글/Reversing 2019. 11. 27. 02:59

    환경 : Windows XP x84
    [공부하기에 직관적이고 쉬운 32bit 환경에서 작성하였습니다.]
    사전 지식 : stack, eax, ebx 등의 레지스터의 역할, 디버거 사용법, 기초 c언어 문법을 필요로 합니다.

     


     

    순서

     

    1. 인사말
    2. 어셈블리어 연산자
    3. 실습 환경 구성
    4. 사칙연산
    5. 포인터
    6. 맺음말

     

     


     

    1. 인사말

     

    리버싱을 입문하려 할 때 익숙하지 않은 어셈블리어에 어려움을 겪는 사람들이 많은 것 같습니다.
    그 이유는 자주 접하고 사용하지 않아서 인 것 같습니다.
    정적 분석을 위해 사용하는 툴들은 곧잘 우리에게 소스코드를 분석해서 보여줍니다.

     

    그래서 어셈블리어를 읽고 해석할 일들이 적어서 동적 분석 시에 어려움을 겪기도 합니다.
    동적 분석을 통해서 어떻게 프로그램이 동작하는지 확인하는 것에 친숙해질 수 있도록 어셈블리어로 되어있는 코드를 통해서 원래 개발자가 작성한 소스코드가 무엇이었을지 유추해 보는 것도 상당히 도움이 되는 방법인 것 같습니다.
    필자는 이 방법을 통해서 어셈블리어랑 조금이나마 더 가까워진 듯하여서 어떤 식으로 공부했는지 함께 나누려고 이렇게 포스팅을 작성하였습니다.

     

     


     

    2. 어셈블리어 연산자

     

    mov : 왼쪽에 오른쪽 값을 옮긴다.
    add : 왼쪽에 있는 값에 오른쪽 값을 더한다.
    sub : 왼쪽에 있는 값에 오른쪽 값을 뺀다.
    lea : 왼쪽 주소에 오른쪽 주솟 값을 저장
    and : and 연산
    or : or 연산
    xor : xor 연산
    imul : 왼쪽에 값에 오른쪽 값을 곱한다.
    idiv : 왼쪽 값에 오른쪽 값을 나눈다.
    inc : 1 증가
    dec : 1 감소

     

    이 부분은 워낙 기초적인 개념으로 빠르게 지나가도록 하겠습니다.

     

     


     

    3. 실습환경 구성

     

    준비물 : visual studio, ollydbg
    필자는 MS의 Visual C++ 6.0을 사용하였습니다.

     

    스크린샷 2019-11-12 오후 10 12 27스크린샷 2019-11-12 오후 10 17 39

     

    우리가 소스코드 빌드 했을 때 평소 올리디버거로 리버싱 연습 파일을 볼 때랑 비슷하게 나오게 하려면 몇 가지 설정이 필요합니다.

     

     

    File 탭에서 New를 눌러서 Win32 Console Application을 선택해서 프로젝트를 생성해줍니다.

     

     

     

    저와 같은 C++ 6.0을 사용하시는 경우 Class View가 아닌 File View를 누르시고
    Soucre Files 폴더를 클릭 후 다시 File 탭에서 new를 눌러줍니다.

     

    스크린샷 2019-11-12 오후 10 31 21

     

    c++ source file을 선택하시고 생성해줍니다.

     

     

     

     

    이제 리버싱 분석을 쉽게 하기 위한 설정을 합니다.
    build - setActiveproject configuration - win32 release 를 선택해줍니다.

     

     

    project - settings - c/c++ - optimization - disable[debug] 로 선택해줍니다.

     

     

     

    이로써 사전 준비해야 할 설정을 모두 완료했습니다.

     

     


     

    4. 사칙연산

     

    사실 처음부터 어셈블리어를 보고 해석해서 소스코드를 복원하기란 어렵습니다.
    그래서 우리가 먼저 소스코드를 빌드해서 어떻게 어셈블리어로 작성되는지 확인하는 것이 좀 더 직관적으로 다가올 겁니다.
    간단한 변수 정의만 작성한 코드를 빌드했습니다.

     

    #include "stdio.h"
    int main(){
        int n = 10;
        return 0;
    }

     

    F7을 눌러서 빌드를 해주고 생성 프로젝트에 Release 폴더에 들어가서 확인해줍시다.
    그러면 방금 소스코드로 빌드 한 실행파일이 생성된 것을 확인할 수가 있습니다.
    이제 ollydbg를 통해서 어떻게 컴파일되어 어셈블리어가 작성되었는지 확인해봄으로써 조금 더 어셈블리어에 친숙해질 수 있습니다.
    빌드한 파일을 ollydbg를 통해서 메인 함수에 접근해 보면

     

    MOV DWORD PTR SS:[EBP-4], 0A

     

    라는 어셈블리어 코드를 확인할 수가 있었습니다.
    DWORD 크기의 EBP-4에 0A를 저장한다는 뜻입니다.
    DWORD는 4bytes이고 A는 십진법으로 10입니다.
    즉 4bytes 자료형의 EBP-4공간에 10만큼의 데이터를 옮겨 담았습다.
    어떠한 공간에 데이터를 집어넣는 것, 이것은 '='과 비슷한 역할을 하고 있다는 것을 예상할 수 있고 실제로 int n = 10; 가 mov를 이용해서 처리된 것을 확인할 수 있었습다.
    mov와 '='연산자는 굉장히 친해 보이는 것 같습니다.

     

    이렇게 직접 빌드 해서 보면 어떻게 내 코드가 어셈블리어로 작성되는지 감이 대충 오게 됩니다.

     

    mov가 언제 주로 쓰이는지 알았으니 mov를 이용한 어셈블리어가 나오면 대충 소스코드가 예측되기 시작될 것입니다.

     

     

     

    MOV DWORD PTR SS:[EBP-8], 5
    MOV BYTE PTR SS:[EBP-4],54

     

    MOV가 두 번 쓰였는데 각각 EBP-8, EBP-4 두 가지 공간에 각각 따로 저장하는 것을 볼 수가 있습니다.
    그렇다면 5, 54 각각의 데이터가 따로 담아진 것이라고 볼 수 있겠네요.
    그리고 5는 4bytes크기에 담겨있고, 54는 1byte 크기 변수에 담겨있다.
    4bytes 자료형을 떠올리면 생각나는 게 int형이 대표적으로 있습니다.
    그래서 int형 변수에 5를 선언했다는 것을 예상할 수 있습니다.
    그리고 다른 하나의 1byte 짜리 변수에는 54가 들어가 있습니다.
    아스키코드표를 아는 사람이라면 어느 정도 낯이 익은 숫자일 것입니다.
    바로 'T'의 16진수 아스키코드 값입니다.
    'T'의 16진수 아스키코드 값이 저장된 1byte 자료형이라면 아마도 char 형일 것입니다.

     

    결론을 보면

     

    int n1 = 5;
    char n2 = 'T';

     

    라는 소스코드형태로 우리는 예측할 수가 있습니다.

     

     

     

    실제로 필자가 작성한 소스코드입니다.
    간단한 예시를 통해서 mov가 어떤 상황에서 사용되는지 한번 연습해보았습니다.

     

     

     

    다음은 +가 사용될 때를 살펴보겠습니다.

     

     

     

    #include "stdio.h"
    int main(){
        int n1 = 5;
        n1 += 10;
        return 0;
    } 

     

    굉장히 간단한 소스코드를 사용하였습니다.

     

     

     

    빌드 후 디버거를 통해 살펴보면 add 연산자가 사용된 것을 확인할 수가 있습니다.

     

     

    MOV DWORD PTR SS:[EBP-4],5 // n1 <= 5
    MOV EAX, DWORD PTR SS:[EBP-4] // eax <= n1
    ADD EAX, 0A // eax <= +10
    MOV DWORD PTR SS:[EBP-4], EAX // n1 <= eax

     

    여기서 주의해야 할 점은 EBP-4를 직접 연산하지 않고 EAX 레지스트리에서 연산 후 MOV를 통해서 다시 EBP-4에 데이터를 옮겨주었다는 것입니다.

     

    (ADD와 짝을 이루는 SUB은 원리가 같기에 생략하고 넘어가겠습니다.)

     

    MOV, ADD, SUB가 나온 어셈블리어를 보면 이제 어느 정도 간단한 코드는 유추가 가능할 것입니다.

     

    예를 통해 연습해봅시다.

     

     

     

    EBP-4, EBP-8, EBP-C 4bytes 변수 3개가 사용되었습니다.
    EBP-C에 5가 들어가는 것으로 보아 세 변수 모두 int형 변수인 것 같습니다.
    코드가 길다고 겁먹지 마시고 한 줄씩 차근차근 살펴보도록 하겠습니다.

     

    MOV DWORD PTR SS:[EBP-C],5
    // EBP-C에 5를 넣는다. (EBP-C = 5)
    
    MOV EAX,DWORD PTR SS:[EBP-C]
    // EAX 레지스터에 EBP-C에 있는 값을 담는다. ( EAX <= 5)
    
    ADD EAX, 5
    // EAX 레지스터에 있는 값에 5를 더한다. [EAX(5) + 5]
    // EAX = 10
    
    MOV DWORD PTR SS:[EBP-4],EAX
    // EBP-4에 EAX에 있는 값을 넣는다. [EBP-4 <= EAX(10)]
    // EBP-4 = 10
    
    MOV ECX,DWORD PTR SS:[EBP-4]
    // ECX 레지스터에 EBP-4에 있는 값을 담는다. [ECX <= EBP-4(10)]
    
    SUB ECX,DWORD PTR SS:[EBP-C]
    // ECX 레지스터에 있는 값에 EBP-C에 있는 값만큼 뺀다.
    // [ECX(10) - EBP-C(5)]
    // ECX = 5
    
    MOV DWORD PTR SS:[EBP-8], ECX
    // EBP-8에 ECX에 있는 값을 담는다. [EBP-8 <= ECX(5)]
    // EBP-8 = 5

     

    결과들만 다시 예쁘게 적어보면 이렇게 됩니다.

     

    EBP-C = 5
    EBP-4 = EAX(EBP-C + 5)
    EBP-8 = ECX(EBP-4 - EBP-C)

     

    소스코드로 어떻게 되어있을지 예상이 조금 되시나요?
    우선 EBP-4, EBP-8, EBP-C를 편의상 n1, n2, n3로 바꾸어 보겠습니다.
    사용된 순서대로 보기 쉽게 짜보면

     

    n1 = 5
    n2 = n1 + 5
    n3 = n2 - n1

     

    가 되겠습니다.

     

     

    실제 소스코드는 어떻게 되어 있는지 확인해 보겠습니다.

     

     

    예상한 대로 코드가 짜여있었습니다.

     

    이번엔 곱셈과 나눗셈을 한번 살펴보겠습니다.

     

     

    #include "stdio.h"
    int main(){
        int n1 = 10;
        int n2 = 3;
        int n3 = n1 * n2;
        return 0;
    }

     

    보기 쉽게 간단한 코드로 빌드 하였습니다.

     

     

    MOV DWORD PTR SS:[EBP-C], 0A
    // EBP-C = 10
    
    MOV DWORD PTR SS:[EBP-4], 3
    // EBP-4 = 3
    
    MOV EAX,DWORD PTR SS:[EBP-C]
    // EAX <= 10
    
    IMUL EAX,DWORD PTR SS:[EBP-4]
    // EAX <= EAX * 3
    MOV DWORD PTR SS:[EBP-8],EAX
    // EBP-8 = 30

     

    IMUL은 왼쪽에 오른쪽에 있는 값을 곱해서 저장합니다.
    그래서 EAX에 있던 기존 10에 3을 곱한 30이 들어가게 됩니다.

     

     

    EAX에 들어가 있는 값을 보면 '1E'로 10 * 3인 것을 확인할 수가 있습니다.

     

     

    곱셈을 알아봤으니 이제 나눗셈을 알아보겠습니다.
    나눗셈은 다른 연산과는 다르게 결괏값이 몫과 나머지로 두 개인 것을 유의해야 합니다.

     

     

    #include "stdio.h"
    int main(){
        int n1 = 10;
        int n2 = 3;
        int n3 = n1 / n2;
        int n4 = n1 % n2;
        return 0;
    }

     

    이 또한 간단한 소스코드를 빌드해서 살펴보겠습니다.

     

     

    MOV DWORD PTR SS:[EBP-10],0A
    // EBP-10 = 10
    
    MOV DWORD PTR SS:[EBP-4],3
    // EBP-4 = 3
    
    MOV EAX,DWORD PTR SS:[EBP-10]
    // EAX = EBP-10(10)
    
    CDQ
    // 32bit를 나눗셈하기 위하여 EDX:EAX로 만들어서 나머지 값을 EDX를 사용하기 위해 EDX를 0으로 초기화 시키는 과정
    
    IDIV DWORD PTR SS:[EBP-4]
    // EAX에 있는 값을 EBP-4(3)로 나눈다.
    
    MOV DWORD PTR SS:[EBP-8],EAX
    // EAX에 생긴 몫 값을 EBP-8에 넣는다.
    
    MOV EAX, DWORD PTR SS:[EBP-10]
    // 새로운 계산을 위해 EAX에 EBP-10(10)을 담는다.
    
    CDQ
    
    IDIV DWORD PTR SS:[EBP-4]
    // EAX에 있는 값을 EBP-4(3)로 나눈다.
    
    MOV DWORD PTR SS:[EBP-C],EDX
    // 나머지 값을 EBP-C에 닮는다.

     

     

    실제로 eax와 edx에 어떠한 값들이 들어가는지 확인해 보겠습니다.

     

     

    EAX에 10이 담겼습니다.
    현재 EDX에는 0x370000이라는 값이 담겨있습니다.

    하지만 CDQ를 진행하게 되면

     

     

    EDX가 0으로 초기화되었습니다.

     

     

     

    IDIV 연산 후 EAX는 3, EDX는 1이 되었습니다.
    몫은 EAX에, 나머지는 EDX에 담긴 것을 확인할 수가 있습니다.

     

     

     

    이제 기본적인 사칙연산에 쓰이는 어셈블리어를 다 알아봤으니 복합적으로 사용되었을 때를 천천히 분석해서 어떤 코드였을지 복원해 보겠습니다.

     

     

    전보다 많이 길어졌습니다.
    천천히 살펴보도록 하겠습니다.
    스택 프레임을 보니 esp가 0x14만큼 되어 있습니다.
    10진수로 20으로 DWORD만 사용된 지금 상황으로 보아 변수가 5개인 것 같습니다.
    한 번에 해석하기보다는 한 줄 한 줄 해석하는 것이 훨씬 쉬울 것 같습니다.
    EBP-14, EBP-4, EBP-C, EBP-10, EBP-8 순으로 사용되었네요.
    편의상 사용된 순서대로 n1, n2, n3, n4, n5로 임의로 지정해서 분석하는 게 좋을 것 같습니다.

     

    이제 상세한 과정은 생략해보겠습니다.

     

    MOV DWORD PTR SS:[EBP-14],5
    // n1 = 5
    
    MOV DWORD PTR SS:[EBP-4],7
    // n2 = 7
    
    MOV EAX,DWORD PTR SS:[EBP-4]
    // eax <= n2
    
    SUB EAX,DWORD PTR SS:[EBP-14]
    // eax = n2 - n1, eax = 2
    
    MOV DWORD PTR SS:[EBP-C],EAX
    // n3 = 2
    
    MOV ECX,DWORD PTR SS:[EBP-14]
    // ecx <= n1
    
    IMUL ECX,DWORD PTR SS:[EBP-C]
    // ecx = n1 * n3, ecx = 10
    
    MOV DWORD PTR SS:[EBP-10], ECX
    // n4 = 10
    
    MOV EAX,DWORD PTR SS:[EBP-10]
    // eax <= n4
    
    CDQ
    IDIV DWORD PTR SS:[EBP-4]
    // eax를 n2로 나누었습니다.
    // 밑에 명령어를 보시면 아시겠지만 EDX에 담긴 값을 사용하고 있으므로
    // 나머지 연산작업이 일어난 것임을 추축할 수 있습니다.
    // eax % n2
    
    MOV DWORD PTR SS:[EBP-14],EDX
    // n1 = edx(eax % n2)
    
    MOV EDX,DWORD PTR SS:[EBP-14]
    // edx <= n1
    
    ADD EDX,DWORD PTR SS:[EBP-C]
    // edx += n3
    
    MOV DWORD PTR SS:[EBP-8],EDX
    // n5 = edx(n1 + n3)

     

    보기 좋게 주석에 써져있는 결론만 다시 적어보면


    n1 = 5
    n2 = 7
    n3 = n2 - n1
    n4 = n1 * n3
    n1 = n4 % n2
    n5 = n1 + n3


    라는 결과를 확인할 수 있습니다.

     

    아마도 코드도 이런 식으로 짜여있지 않을까 예상해 볼 수가 있습니다.

     

    #include "stdio.h"
    int main(){
        int n1, n2, n3, n4, n5;
        n1 = 5;
        n2 = 7;
        n3 = n2 - n1;
        n4 = n1 * n3;
        n1 = n4 % n2;
        n5 = n1 + n3;
        return 0;
    }

     

    빌드한 소스코드랑 비슷한지 보겠습니다.

     

     

    예상한 대로 소스코드가 쓰여있었습니다!

    사칙연산에서 자주 쓰이는 어셈블리어에 대해서 알아봤으니 이제 포인터와 LEA를 보겠습니다.

     

     


     

    5. 포인터

     

    이번 챕터에서는 포인터와 LEA에 대해서 살펴보겠습니다.

     

     

    #include "stdio.h"
    int main(){
        int n = 10;
        int *p = &n;
        *p = 20;
        n += 30;
        return 0;
    }

     

    이러한 소스코드를 빌드했을 때 과연 n에 실제로 50이 들어있는지 디버거를 통해서 확인해 보면서 lea를 살펴보도록 하겠습니다.

     

     

    MOV DWORD PTR SS:[EBP-8],0A
    // EBP-8 <= 10
    
    LEA EAX,DWORD PTR SS:[EBP-8]
    // LEA는 오른쪽의 주소를 왼쪽에 넣는다.
    // EAX <= EBP-8의 주소
    
    MOV DWORD PTR SS:[EBP-4],EAX
    // EBP-4 <= EAX
    // 이로서 EBP-4에는 EBP-8의 주소가 담기게 된다.
    
    MOV ECX,DWORD PTR SS:[EBP-4]
    // ECX <= EBP-4
    
    MOV DWORD PTR DS:[ECX],14
    // [ECX]라 되어 있다. 즉, ECX에 있는 값이 아닌 ECX가 가리키고 있는 주소의 공간에 0x14를 넣겠다는 뜻이다.
    // [ECX] <= 20
    
    MOV EDX,DWORD PTR SS:[EBP-8]
    // EDX <= EBP-8(20)
    
    ADD EDX,1E
    // EDX += 30
    
    MOV DWORD PTR SS:[EBP-8],EDX
    // EBP-8 <= EDX

     

    LEA에 대해서 좀 더 자세히 보기 위해서 직접 동적분석으로 확인해 보겠습니다.

     

    MOV DWORD PTR SS:[EBP-8], 0A
    를 실행했습니다.

     

     

    스택 영역을 확인해보면

     

     

    0x12FF78에 0xA가 들어간 것을 확인할 수 있습니다.

     

    그다음 명령인
    LEA EAX, DWORD PTR SS:[EBP-8]
    를 실행해보면

     

     

    eax에 EBP-8의 주소인 0x12FF78가 담긴 것을 확인할 수가 있습니다.

     

    다음 명령어인
    MOV DWORD PTR SS:[EBP-4], EAX
    를 하면

     

     

    EPB-4인 0x12FF7C에 eax에 들어있던 0x12FF78이 들어간 것을 확인할 수가 있습니다.

     

    MOV ECX, DWORD PTR SS:[EBP-4]를 실행 후


    MOV DWORD PTR SS:[ECX], 14를 실행해보면

     

     

    ECX가 담고 있던 주소에 0x14를 집어넣은 것을 확인할 수가 있었습니다.

    포인터의 쓰임과 매우 비슷한 모습을 보여주고 있습니다.

     

     

    포인터 개념을 잘 알고 계신 분들이라면 EBP-8에 0x32가 들어간 것은 당연한 결과인 것을 아실 것입니다.

     

     

     

     

    포인터를 이용한 쓸모없지만 재미난 실험을 하나 해보겠습니다.

     

    이 부분은 읽기에 시간이 아까울 수 있는 부분으로 넘어가셔도 됩니다.

     

    우리는 변수를 선언하면 ebp 기준으로 스택이 형성된다는 것을 알고 있습니다.
    int형 변수를 생성하면 4bytes에 해당하는 스택이 생성되는 것을 참고하여 이 두 개의 소스코드를 비교해보겠습니다.

     

    #include "stdio.h"
    int main(){
        int n1 = 1;
        int n2 = 2;
        return 0;
    }
    #include "stdio.h"
    int main(){
        int n = 1;
        int * p = &n;
        *(p + 1) = 2;
        return 0;

     

     

     

     

     

     

     

    보기에도 다르고 실제로 작동하는 것도 다른 두 코드가 있습니다.


    둘 다 변수를 두 개 사용 중이며 빌드 후 디버깅을 해보면 ebp-4와 ebp-8을 사용할 것입니다.
    그런 상황에서 결국 함수가 끝날 때 ebp-4와 ebp-8에 담긴 값은 놀랍게도 차이가 없는 것을 확인할 수 있습니다.

     

     

     

     

    두 파일 각각의 실행 코드는 다르지만 결국 스택에 쌓인 값을 보면 일치하고 있는 것을 확인할 수가 있습니다.


    그 이유는 포인터를 사용하여 EBP-4를 참조했기 때문으로 변수를 스택에서 어떻게 처리하는지 알면 이러한 결괏값이 나오는 이유도 알 것입니다.
    마지막 사진에 나온 코드는 이 글을 읽으시며 공부하시는 분이시라면 혼자서도 충분히 해석하실 수 있는 코드이므로 직접 해석해보시는 것도 재밌을 것입니다!

     

    이상, 몰라도 되는 부분이었습니다.

     

     


     

    6. 맺음말

     

    어떠신가요?
    이 글을 읽고 따라 하면서 조금은 어셈블리어와 친해지셨다면 필자는 기쁠 것 같습니다.
    자신이 작성한 소스코드가 어떻게 어셈블리어로 번역되어 기계어로 돌아가는지 직접 보고 읽으면 남들이 만들어 둔 파일을 리버싱할 때도 어느 정도 도움이 될 것입니다.


    원래 함수 호출과 커맨드 라인 입력, aslr 등도 적어보려 했지만 글이 너무 길어지는 것 같아서 다음번 포스팅 때 이어서 적도록 하겠습니다.

    'Team $!9N 구성원의 글 > Reversing' 카테고리의 다른 글

    eild(강신일)'s 안티 디버깅의 종류  (2) 2020.01.20

    댓글

Designed by Tistory.