Recent Posts
Recent Comments
Link
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

CIDY

[Reverse_Engineering] stage2_Binary&Analysis 본문

Hack/DreamHack(로드맵)

[Reverse_Engineering] stage2_Binary&Analysis

CIDY 2022. 7. 16. 05:02

*프로그램 == 바이너리

연산 장치가 수행해야 하는 동작을 정의한 문서이다. 연산 장치에 프로그램 전달 -> CPU가 적혀있는 명령어들을 처리해 프로그래머가 의도한 동작을 수행함

 

사용자가 정의한 프로그램을 해석해 명령어를 처리할 수 있는 연산 장치를 programmable하다고 함. (컴퓨터 등)

 

프로그램이 저장 장치에서 이진 형태로 저장되기에 바이너리(Binary)라고도 한다.

 

 

 

*컴파일러&인터프리터

CPU가 수행할 명령들을 프로그래밍 언어로 작성한 것을 소스코드라고 한다.

 

소스코드 -> (컴파일 by.compiler) -> 기계어 // 이렇게 컴퓨터가 이해할 수 있도록 기계어로 번역하는 소프트웨어를 컴파일러라고 한다. (gcc, clang, MSVC등)

 

컴파일러로 한 번 컴파일해두면 프로그램으로 계속 쓸 수 있는데, python이나 javascript같은 언어는 컴파일이 필요없다. 작성된 스크립트를 그때그때 번역해 cpu에 전달하기 때문이다. -> 이 번역 과정은 컴파일이 아닌 인터프리팅(interpreting)이라고 함. (by.interpreter)

 

컴파일러의 경우 한 번 컴파일해두면 프로그램으로 계속 쓸 수 있지만 한 번 번역하는데 시간이 많이 필요한 반면, 인터프리터의 경우 각 코드를 빠르게 번역할 수 있지만 실행할 때 마다 번역해줘야 한다는 단점이 있다.

 

 

*컴파일 과정

C언어로 작성된 코드는 전처리 -> 컴파일 -> 어셈블 -> 링크 과정을 거쳐 바이너리로 번역된다. 

 

(컴파일의 의미는 어떠한 언어로 작성된 것을 다른 언어로 번역하는 것이기 때문에 번역의 각 과정을 모두 컴파일이라고 볼 수 있다. 물론 전체 과정도 컴파일이다.)

 

// Name: add.c
#include "add.h"
#define HI 3
int add(int a, int b) { return a + b + HI; }  // return a+b
// Name: add.h
int add(int a, int b);

 

-전처리(.c -> .i): 컴파일 전에 필요한 형식으로 가공하는 과정이다. 주석 제거 -> 매크로 치환(#define) -> 파일 병합(여러개의 소스와 헤더파일들을 병합 후 컴파일, 혹은 따로 컴파일해 병합하기도 한다.)

 

# 1 "add.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "add.c"
# 1 "add.h" 1
int add(int a, int b);
# 2 "add.c" 2
int add(int a, int b) { return a + b + 3; }

위의 C코드를 전처리하면 이러한 결과가 나온다.

 

(gcc -E add.c > add.i) -> gcc에서 -E옵션을 사용해 만들어진 파일을 cat하면 전처리 결과를 확인할 수 있다.

 

우선 주석은 다 사라졌고(주석 제거), HI가 3으로 치환되었다.(매크로 치환) 그리고 add.h헤더파일의 내용이 #include에 의해 병합되었다.(파일 병함)

 

-컴파일(.i -> .S): C로 작성된 소스코드를 어셈블리어로 번역하는 과정이다. 여기서 소스코드의 문법 검사가 이루어진다. (문법에 문제있으면 컴파일 중단 + 에러 출력) 

 

gcc에서는 -0 -00 -01 -02 -03 -0s -0fast -0g등 옵션을 사용해 최적화를 적용해서 효율적인 어셈블리 코드를 생성할 수 있다.

// Name: opt.c
// Compile: gcc -o opt opt.c -O2
#include <stdio.h>
int main() {
  int x = 0;
  for (int i = 0; i < 100; i++) x += i; // x에 0부터 99까지의 값 더하기
  printf("%d", x);
}

 

위와 같은 코드를 -02옵션으로 최적화하여 컴파일하면 -> 컴파일러는 반복문을 어셈블리어로 옮기는 것이 아니고, 반복문의 결과로 x가 가질 값을 직접 계산해 이를 대입하는 코드를 생성한다. -> 최적화하지 않았을 때와 기능은 같지만 더 짧고 효율적인 어셈블리 코드가 나온다.

 

0x0000000000000560 <+0>:     lea    rsi,[rip+0x1bd]        ; 0x724
0x0000000000000567 <+7>:     sub    rsp,0x8
0x000000000000056b <+11>:    mov    edx,0x1356  ; hex((0+99)*50) = '0x1356' = sum(0,1,...,99) 
0x0000000000000570 <+16>:    mov    edi,0x1
0x0000000000000575 <+21>:    xor    eax,eax
0x0000000000000577 <+23>:    call   0x540 <__printf_chk@plt>
0x000000000000057c <+28>:    xor    eax,eax
0x000000000000057e <+30>:    add    rsp,0x8
0x0000000000000582 <+34>:    ret

 

처음 예시로 들었던 C코드 -> i파일 해줬던걸 gcc -S add.i -o add.S해주면 어셈블리 코드로 컴파일해줄 수 있다.

 

해당 파일을 읽어보면 다음과 같다.

 

        .file   "add.c"
        .intel_syntax noprefix
        .text
        .globl  add
        .type   add, @function
add:
.LFB0:
        .cfi_startproc
        push    rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        mov     rbp, rsp
        .cfi_def_cfa_register 6
        mov     DWORD PTR -4[rbp], edi
        mov     DWORD PTR -8[rbp], esi
        mov     edx, DWORD PTR -4[rbp]
        mov     eax, DWORD PTR -8[rbp]
        add     eax, edx
        add     eax, 3
        pop     rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
.LFE0:
        .size   add, .-add
        .ident  "GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0"
        .section        .note.GNU-stack,"",@progbits

 

 

-어셈블(.S -> .o): 컴파일로 생성된 어셈블리 코드를 ELF형식의 오브젝트 파일로 변환하는 과정이다. (ELF는 리눅스, 윈도우에서 어셈블할 경우 PE형식) -> 기계어로 번역되는 것. 

 

$ gcc -c add.S -o add.o
$ file add.o
add.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
$ hexdump -C add.o
00000000  7f 45 4c 46 02 01 01 00  00 00 00 00 00 00 00 00  |.ELF............|
00000010  01 00 3e 00 01 00 00 00  00 00 00 00 00 00 00 00  |..>.............|
00000020  00 00 00 00 00 00 00 00  10 02 00 00 00 00 00 00  |................|
00000030  00 00 00 00 40 00 00 00  00 00 40 00 0b 00 0a 00  |....@.....@.....|
00000040  55 48 89 e5 89 7d fc 89  75 f8 8b 55 fc 8b 45 f8  |UH...}..u..U..E.|
00000050  01 d0 5d c3 00 47 43 43  3a 20 28 55 62 75 6e 74  |..]..GCC: (Ubunt|
00000060  75 20 37 2e 35 2e 30 2d  33 75 62 75 6e 74 75 31  |u 7.5.0-3ubuntu1|
00000070  7e 31 38 2e 30 34 29 20  37 2e 35 2e 30 00 00 00  |~18.04) 7.5.0...|
00000080  14 00 00 00 00 00 00 00  01 7a 52 00 01 78 10 01  |.........zR..x..|
00000090  1b 0c 07 08 90 01 00 00  1c 00 00 00 1c 00 00 00  |................|
000000a0  00 00 00 00 14 00 00 00  00 41 0e 10 86 02 43 0d  |.........A....C.|
000000b0  06 4f 0c 07 08 00 00 00  00 00 00 00 00 00 00 00  |.O..............|
...

 

이건 아까 그 .S파일을 -c 옵션으로 오브젝트 파일로 변환한 것을 16진수로 출력한 것이다.

 

-링크: 여러 오브젝트 파일들을 연결해 실행 가능한 바이너리로 만드는 과정이다.

 

// Name: hello-world.c
// Compile: gcc -o hello-world hello-world.c
#include <stdio.h>
int main() { printf("Hello, world!"); }

 

위 코드에서 printf함수를 호출하는데, 이 정의는 립시에 있다. -> 립시는 gcc의 기본 라이브러리 경로에 있는데, 링커는 바이너리가 printf를 호출하면 립시의 함수가 실행될 수 있도록 연결해준다.

 

링크를 거치면 실행 가능한 상태의 프로그램이 되는 것이다.

 

gcc add.o -o add -Xlinker --unresolved-symbols=ignore-in-object-files

 

이렇게 add.o를 링크할 수 있다. 

 

링크 과정에서 링커는 main함수를 찾는데, add의 소스코드에는 main의 정의가 없기 때문에 에러가 발생할 수 있음 -> --unresolved-symbols 을 컴파일 옵션에 추가해 해결 가능하다.

 

 

*디스어셈블&디컴파일

바이너리 자체는 기계어라 이해하기 힘들다 -> 디스어셈블(어셈블 역과정)해서 어셈블리어로 볼 수 있다.

 

objdump -d ./add -M intel

 

위는 디스어셈블 명령어이다. gdb로 보는 게 편할 것 같다.

 

어셈블리어도 기계어보단 낫지만 여전히 저수준 언어라 이해가 힘들다. -> 디컴파일해서 고급 언어로 볼 수 있음.

 

근데 어셈블리어는 사실 저수준 언어라 기계어랑 거의 1대1 대응돼서 거의 정확하게 디스어셈블 해올 수 있는데 높은 수준의 언어는 이러한 대응관계가 없기에 + 코드 작성 시 사용한 변수/함수명 등은 컴파일 과정에서 모두 사라지고, 코드 일부는 위에서 봤던 최적화와 같은 사유로 컴파일 과정에서 완전 변형되기도 하므로 디스어셈블 과정만큼 깔끔한 복구가 어렵다. -> 완벽한 디컴파일은 불가능

 

그래도 디스어셈블해서 보는 것보다 분석 효율이 훨씬 좋으므로, 디컴파일러는 쓸 수 있으면 써야함. Hex Rays나 Ghidra같은 좋은 디컴파일러 많음. 물론 IDA 프리웨어도..

 

 

 

*정적분석

프로그램을 실행시키지 않고 분석하는 방법이다. -> 전체 구조를 파악하기 쉬움 + 분석 환경의 제약에서 비교적 자유로움 + 악성 프로그램 위협으로부터 안전함(실행하지 않으니까) 

 

but프로그램에 난독화가 적용되면 분석이 매우 어려워진다.

 

-> IDA로 까보기

 

 

*동적분석

프로그램을 실행시켜가며 분석하는 방법이다. -> 상세한 분석 없이 프로그램 동작 파악 가능 + 입/출력을 직접 보면서 동작 추론 가능

 

but 분석 환경을 구축하기 어려울 수 있다. (프로그램을 실행할 환경이 갖춰지지 못했다거나, 가상머신 혹은 프로그램 실행 장치가 없다거나) + 그리고 정적분석의 난독화처럼 동적 분석을 방해하는 안티디버깅이 있음(ex: 자신이 디버깅당하는 중인지 검사하고, 디버깅 중이라면 프로그램 종료시키기 등)

 

if (is_debugging()) // 디버깅인지 확인
  exit(-1); // 프로그램 종료
Func();

 

-> x64dbg 돌려보기