SONOTRI
  • Exercise: Helloworld
    2025년 03월 24일 20시 31분 27초에 업로드 된 글입니다.
    작성자: SONOTREE

    00. 서론

    이번 강의에서는 간단한 예제인 HelloWorld.exe를 분석해보는 실습을 진행할 것이다. 예제는 1초 대기하고 Hello, world!를 출력하는 프로그램이다. 소스코드를 제공하긴 하지만, 분석을 진행하는 동안은 프로그램에 대해 아는 것이 아무것도 없다고 가정하겠다.

    /*
        File: hello-world.cpp
        Build opts:
          - /MT -> Library Static Linking
          - /DYNAMICBASE:NO -> Disable ASLR
          - /od -> Disable Optimization
    */
    
    #include <Windows.h>
    #include <stdio.h>
    
    char* str;
    int main() {
      int delay = 1000;
      Sleep(delay);  // 1000ms(1초)를 대기합니다.
      str = (char*)"Hello, world!\n";
      printf(str);
      return 0;
    }

    01. 정적 분석

    01.1. main 함수 찾기

    정적 분석은 주로 main 함수를 찾고, 이를 분석하며 시작된다. 이 예제에서는 IDA가 main 함수를 자동으로 찾아주지만(아래와 같이), IDA와 익숙해질 겸 IDA의 도움을 받지 않고 main 함수를 찾아보도록 하겠다.

     

    바이너리(프로그램)에서 어떤 함수를 찾는 방법은 크게 두 가지가 있다. 전자는 바이너리의 규모가 조금만 커져도 분석에 소요되는 시간이 급증하므로 일반적으로는 적용하기 어렵다. 그래서 이 코스에서는 후자의 방식으로 main 함수를 탐색해 볼것이다.

    1. 프로그램의 시작 지점은 진입점(Entry Point, EP)부터 분석을 시작하여 원하는 함수를 찾을 때까지 탐색하는 것
    2. 대상 함수의 특성이나 프로그램의 여러 외적인 정보를 이용하여 탐색하는 방법

     

     

    01.2. 문자열 검색

    프로그램을 정적 분석할 때 많이 사용되는 정보 중 하나가 프로그램에 포함된 문자열이다.

     

    프로그래머는 디버깅 메시지를 출력하거나, 로그 파일을 생성하는 등의 목적으로 여러 문자열을 프로그램에 포함시키는데, 이 문자열들은 특성상 유용한 정보를 제공할 때가 많다. 예를 들어 프로그램의 로그와 관련된 문자열에는 로그를 생성하는 함수의 이름과 인자가 적혀있기도 한다. 이 문자열이 어느 함수에서 사용되는지 찾는다면, 원하는 함수를 쉽게 찾을 수 있고, 함수의 이름과 인자를 통해 기능을 유추할 수도 있을 것이다.

    IDA는 바이너리에 포함된 문자열을 쉽게 찾을 수 있도록 "문자열 탐색"기능을 제공한다. Shitf + F12

     

    shift+f12를 누르면 바이너리에 포함된 문자열이 열거된 String 창이 나타난다. 추가로 Ctrl+F 통해 helloworld를 쉽게 찾을 수 있다. 컴파일 과정에서 다양한 문자열이 바이너리에 추가되는데, 여기서 해당 문자열은 컴파일 과정에서 삽입된 것이 아니라 프로그래머가 추가했을 것이라고 추측할 수 있다(다른 문자열과 달리 완벽한 문장의 형태를 띄고 있으므로).

     

     

     

    01.3. 상호 참조

    이 문자열을 더블클릭하여 따라가보자. 그러면 IDA View-A 탭으로 이동하며, aHelloWorld를 확인할 수 있다.

     

    정적 분석을 하다가 어떤 수상한 값이나 함수를 찾았다면, 우리는 이를 참조하는 함수를 분석하고 싶을 것이다. 많은 정적 분석 도구들은 상호 참조(Cross Reference, XRdf)라는 기능을 통해 이를 지원한다. 앞에서 찾은 "Hello, world!\n"라는 수상한 문자열이 어디서 사용되는지 추적해보자.

     

    "aHelloWorld"클릭된 상태에서 상호 참조 단축키 X를 누르면 xrefs(cross reference) 창이 나타난다. 이 창에는 해당 변수를 참조하는 모든 주소가 출력된다.aHelloWorld의 상호 참조 결과 항목이 하나 뜨는데, 이걸 누르면 아까 IDA가 자동으로 찾아줬던 main 함수가 찾아진다.

     

     

     

    01.4. main 함수 분석

    이제 main 함수를 찾았으므로, F5를 눌러 이를 디컴파일한다. 디컴파일이 완료되면 Pseudocode-A 탭으로 이동하며, 아래와 같은 화면이 보일것이다. 이제 디컴파일된 코드를 통해 함수의 주요 정보를 살펴보도록 하겠다.

     

    <인자 분석>

    IDA는 argc, argv, envp로 3개의 인자를 받는다고 해석한다.

     

    <동작>

    Sleep(0x3E8u)

    : 0x3E8u에서 u는 부호 없는 정수(unsigned)를 의미한다. 즉 0x3E8을 10진수로 변환하면 1000이 된다. 따라서 이 명령줄은 Sleep 함수를 호출하여 1초(1000ms)동안 대기한다는 의미이다.

     

    qword_14001DBE0 = (__int64)"Hello, world!\n";

    : qword_14001DBE0에 Hello, world!\n 문자열의 주소를 넣는다.

     

    sub_140001060("Hello, world!\n");

    : sub_140001060에 Hello, world!\n을 인자로 전달하여 호출한다.

     

    return 0;

    : 0을 반환한다.

     

    <반환값>

    0을 반환한다.

     

     

     

    01.5. 생각해보기

    Q1) main()에서 사용되는 qword_14001DBE0 변수와 "Hello, world!\n" 문자열이 data, rodata, text 중 어떤 섹션에 존재하는지 생각해보자.

    Q2) 또한 sub_140001060는 어떤 함수일지, 소스코드를 보지 않고 예측해보자.

     

    나의 A1) qword_14001DBE0는 변수이므로 data 섹션에 존재할 것 같고, Hello, world!\n는 상수 문자열이므로 rodata 섹션에 존재할 것 같다.

    나의 A2) Hello, world!\n를 출력하는, 즉 print 함수 같은 출력 함수일 것 같다.

     

    공식 A1) qword_14001DBE0는 값이 변경될 수 있는 전역변수이기 때문에, data 섹션에 위치한다. 해당 변수가 전역변수라는 사실은 디컴파일 결과가 아닌 어셈블리를 보는 것으로 확인할 수 있고, 해당 변수를 더블클릭하는 것으로도 확인이 가능하다. Hello, world!\n 문자열은 실행 도중 값이 변경될 일이 없는 상수이기 때문에 rodata 섹션에 위치한다.

    공식 A2) sub_140001060 함수는 printf() 함수이다. 자세한 추론 과정은 다음 내용에서 다룬다.

     

     

     

    01.6. sub_140001060 함수 분석

    앞에서 sub_140001060가 어떤 함수인지 알아내기 위해, 이 함수의 디컴파일 결과를 살펴본 사람도 있을것이다. 아래는 sub_140001060 함수의 디컴파일 결과이다.

     

    먼저 va_start(va, a1) 함수를 통해 sub_140001060는 가변 인자를 처리하는 함수임을 알 수 있다. __acrt_iob_func(1u) 함수는 스트림을 가져올 때 사용되는 함수인데, 인자로 들어가는 1(u)는 stout을 의미한다. 따라서 문자열 인자를 받고 stdout 스트림을 내부적으로 사용하는 가변 함수임을 알 수 있다. 

    -> 이러한 모든 정황을 통해 sub_140001060 함수는 printf 함수로 추정할 수 있다.

    스트림(Stream)이란?
    데이터는 스트림의 형식으로 한 프로세스에서 다른 프로세스로, 또는 한 프로세스에서 다른 파일 등으로 이동한다.
    운영체제는 stdin, stdout, stderr와 같은 기본 스트림들을 프로세스마다 생성해준다. 이들은 일반적으로 사용자와 프로세스를 연결해주기 위해 사용된다. printf 함수는 stdout을 통해 출력 데이터를 우리가 볼 수 있게 해주며, scanf 함수는 우리의 키보드 입력을 stdin으로 받아서 프로세스에 전달해 준다.

    02. 동적 분석

    이번에는 앞에서 분석한 내용을 동적 분석으로 살펴보겠다. 정적 분석이 정확했는지 살펴보고, 앞에서 배운 함수의 호출 규약이 어떻게 구현되는지 자세히 분석해보겠다.

     

    02.1. main 함수 진입 - 중단점 설정(Break Point, F2) 및 실행(Run, F9)

    동적 분석은 프로그램을 실행하면서 분석하는 방법이다. 그런데 우리는 대개 전체 프로그램 중 아주 일부분의 동작에만 관심이 있다. 이 예제에서는 main 함수가 우리의 유일한 관심 대상이다. 만약 이럴 경우, 진입점부터 main 함수까지 코드를 한 줄씩 실행시켜서 main 함수에 도달해야 한다면, 디버깅은 그렇게 효율적인 분석 방법이 되지 않을 것이다.

     

    그래서 대부분의 디버거에는 우리가 원하는 지점까지 프로그램을 실행시킬 수 있는 중단점실행이라는 기능이 있다. 중단점을 특정 주소에 설정하고 실행 명령을 내리면, 프로그램은 중단점까지 멈추지 않고 실행된다. 우리는 main 함수의 동작이 궁금하므로, 이 기능을 이용하여 main 함수로 진입할 것이다. 아래 실습과 함께 이해해보자.

    중단점: 소프트웨어 개발에서 프로그램을 일시 중단하거나 멈추게 하는 지점

     

     

    1. main 함수에 중단점을 설정하자. 단축키는 F2이다.

     

    2. 디버깅을 시작하여 main함수까지 실행한다. 디버깅 시작 단축키는 F9이다. 디버거를 고르라는 창이 나타나면 Local Windows debugger을 선택한다. 그러면 이제 동적 분석을 위한 준비가 끝났다.

     

     

     

     

    02.2. 한 단계 실행(Step Over, F8)

    앞서 살펴본 중단점과 실행이 필요한 실행 과정을 생략하는 기능이라면, Step Over(F8)은 관심 있는 부분의 코드를 정밀하게 분석하기 위해 사용하는 기능이다. F8을 누르며 프로그램의 동작을 분석해보자.

     

    1. sub rsp, 38h

    : sub rsp, 38h를 통해 main 함수가 사용할 스택 영역을 확보한다. rsp는 stack pointer register로, 스택의 가장 끝 부분을 가리킨다. 38h의 h는 16진수를 나타내며 36h=0x36=56를 의미한다. 즉, 이 라인은 rsp를 0x38만큼 감소(sub)시켜 56바이트만큼의 스택 공간을 확보한다.

     

    2. mov [rsp+38h+dwMilliseconds], 3E8h

    : [rsp+38h+dwMilliseconds] 주소에 0x3e8 값을 저장한다. 강의에서는 [rsp+38h+dwMilliseconds] = [rsp+0x20]이라고 한다.

     

    3. mov ecx, [rsp+38h+dwMilliiseconds]

    : [rsp+38h+dwMilliiseconds] = [rsp+0x20]에 저장되어있는 값을 ecx에 저장한다.

     

    4. call cs:Sleep

    : Sleep 함수를 호출(call)한다. ecx=0x3e8=1000이므로, Sleep(1000)이 실행되어 1초간 실행이 멈춘다.

     

    5. lea rax, aHelloWorld; "Hello, world!\n"

    : "Hello, world!\n" 문자열의 주소를 rax에 저장한다.

     

    6. mov cs:qword_14001DBE0, rax

    : rax의 값을 data 세그먼트의 주소인 0x14001dbe0에 저장한다.

     

    7. mov rcx, cs:qword_14001DBE0

    : 0x14001dbe0에 저장된 값을 rcx에 저장한다.

     

    8. call sub_140001060

    : 0x140001060 함수를 호출한다. 우리는 위에서 이 함수가 printf 함수라고 추측했다.

     

    9. 프로그램을 확인하면 Hello, world!가 출력되어있다. 정적 분석을 할 때에는 함수의 기능을 추측하기 어려웠지만, 동적 분석으로는 문자열을 출력하는 함수라는 사실을 쉽게 알 수 있다.

     

    10. add rsp, 38h

    : 시작할 때 확장한 스택 영역을 add rsp, 38h를 통해 다시 축소시키고, ret으로 원래 실행 흐름으로 돌아간다.

     

     

     

     

    02.3. 함수 내부로 진입하기(Step Into, F7)

    앞의 Step Over을 자세히 관찰하였다면, Step Over가 함수 내부로 진입하지 않는다는 것을 발견했을 것이다(8번처럼 호출에서 그친다). 그런데 어떤 함수를 분석하다 보면 그 함수가 호출하는 다른 함수까지 정밀하게 분석해야 할 때가 있다. 많은 디버거는 그런 상황을 대비하여 Step Into(F7)라는 기능을 지원한다. 이번에는 printf()에 중단점을 설정하고, 해당 함수의 내부로 진입해 보겠다.

     

    1. 디버깅을 중단(Ctrl-F2)하고, printf를 호출하는 0x14000110b에 중단점을 설정한다.

    2. 디버깅을 다시 시작하고, Continue(F9)를 클릭하여, printf 함수에 도달한다.

     

    3. F7 단축키를 통해 printf 함수 내부로 들어간다. F7을 누루고 나면 함수 내부로 RIP가 이동한 것을 확인할 수 있다.

     

     

     

     

    02.4. Appendix, 실행 중인 프로세스 조작하기

    IDA를 이용하면 실행중인 프로세스의 메모리를 조작할 수 있다.

     

    기존 코드에서는 Sleep(delay=1000)을 호출하여 1000ms=1s 동안 프로세스를 정지시켰다. 이번에는 delay 값을 1000000으로 조작하여 1000초동안 프로세스를 정지시켜 볼 것이다.

     

    1. delay를 Sleep 함수의 인자로 전달하는 부분에 중단점을 설정하고, 프로세스를 재시작한다.

     

    2. 스택을 보면 rsp+0x20에 delay의 값인 0x3e8이 저장되어 있다

     

    3. 해당 값을 클릭하고 F2를 누른 뒤, 0xf4240 = 1000000을 입력한다. 그리고 다시 F2를 눌러 값을 저장한다. 그러면 delay의 값이 변경된다.

     

    4. 이제 F9를 눌러 Sleep 함수를 호출한다. 아까와 달리 한참 기다려도 프로세스가 재개되지 않는다. 1000초는 대략 20분이므로, 20분 정도를 대기해야 프로세스가 재개된다.


    03. 강의 요약

    • F2(Breakpoint): 중단점을 설정한다. 프로그램이 해당 지점에 도달하는 순간 정지한다.
    • Ctrl + F2(Restart): 디버깅을 중단한다
    • F9(Run): 프로그램을 계속 실행, 또는 디버깅을 시작한다
    • F7(Step Into): 어셈블리 코드를 한 줄 실행한다. 함수의 호출이라면, 함수 내부로 들어간다.
    • F8(Step Over): 어셈블리 코드를 한 줄 실행한다. 함수 내부로는 들어가지 않는다.

    'Dreamhack > Reverse Engineering' 카테고리의 다른 글

    Quiz: x86 Assembly 2  (0) 2025.03.23
    Quiz: x86 Assembly 1  (0) 2025.03.23
    x86 Assembly: Essential Part(2) [★]  (0) 2025.03.23
    x86 Assembly: Essential Part(1)  (0) 2025.03.22
    Background: Windows Memory Layout  (0) 2025.03.18
    댓글