해당 포스팅은 강병탁 교수님의 수업과 리버스 엔지니어링 바이블을 참고해 작성했습니다.


F-Secure level 1 문제 풀어보기


  • 이 프로그램을 리버싱해서 시리얼 넘버를 찾아보자.

Step 1. 파일 스캐너로 스캔

  • 파일 스캐너로 해당 파일의 패킹 여부를 확인하자.
  • IAT(Import Address Table)이 제대로 보이면 패킹이 안되었다는 것이다.

Step 2. API 확인

  • IAT를 통해 어떤 API가 사용되었는지 확인하자.
  • 해당 dll 파일에서 어떤 함수를 불러 왔는지 알 수 있고, 이를 통해 대략적인 행위를 예상하자.
  • MSVCR71.dll_stricmp (IAT: 0x2230) 을 확인할 수 있는데, 결정적으로 쓰이는 함수인지 의심해볼 필요가 있다.

Step 3. 파일의 이미지 베이스 확인

  • 실행 파일의 이미지 베이스를 확인해 디버깅 할 번지가 어디인지 확인하자.
  • PE의 Optional Header의 ImageBase를 보면 0x69000000 이라고 적혀있다. (대부분 400000 번지지만)

Step 4. 디버깅 해보기

  • OllyBDG로 디버깅을 해보면 아까 찾은 이미지 베이스부터 시작하는 걸 볼 수 있다.

  • 프로그램에서 쓰인 string을 메모리에서 찾아서 트레이스를 하거나,

  • _strcmp() 가 쓰인 부분을 찾아 함수가 시작하는 부분에 BP를 걸고 실행시켜보자.

  • 여기에서는 네 가지 방법으로 같은 결과를 보여줄 수 있다.


    1) Jmp 문 패치

    • jnz는 (레지스터의) zero flag(Z)가 켜져있지 않을 때 해당 주소로 점프하는 코드인데
    • test eax, eax에서 eax가 0일 때만 zero가 된다 (Z가 1로 바뀐다)
    • _stricmp 는 비교하는 문자가 같을 때 0을 리턴하므로 위와 같이 다른 문자를 입력했을 때는 Z=0이다.
    • 근데 여기서 jnzjz 로 변경하면 Z가 꺼져있을 때만 점프하게 된다.
    • 이는 코드를 변경하는 방식이므로 무결성 검사가 이루어졌을 때는 사용할 수 없다.

    2) 플래그 패치

    • 코드를 변경하지 말고 레지스터의 zero flag를 1로 변경시켜도 점프문을 통과할 수 있다.


    3) Nop 패치

    • 점프문을 nop 명령어로 대체하여 Sorry 부분으로 점프하지 못하도록 변경한다.
    • 여기에서 주의할 점은 nop 는 1바이트이므로 2바이트(jnz 는 2바이트)를 맞춰서 두 번 넣어줘야한다.

    !

    4) API 패치

    • 연결된 dll 부분으로 들어가 리턴값을 바꿔준다.
    • 즉, MSVCR71.dll_stricmp() 함수의 리턴값을 무조건 0을 리턴하도록 만들어준다.
    • _stricmp() 의 최종 리턴 부분인 RETN 부분을 XOR EAX, EAX 로 변경하고 RETN 을 추가하자.
    • 그럼 값에 상관 없이 EAX 에는 0이 들어가고 0을 리턴하게 될 것이다.



String 조작하기

  • 한편 출력하려는 string을 변경할 수도 있는데, PUSH 하는 부분에서 ascii 값의 주소를 알아내고 아래와 같이 드래그를 한 뒤 Binary edit(Ctrl + E)를 선택하면


  • 이와 같이 출력 문자를 변경할 수 있다.


  • 이 주제랑은 그렇게 상관있는 내용은 아닌데, 주소를 리틀 엔디안 방식으로 입력한다는 것을 주의해야한다.
  • 0x690030E8E8300069 로 표현



Call Stack

  • 리버스 엔지니어링을 위해 스택에 관해 알아야 할 지식은 다음과 같다.

    1. 함수 호출 시 파라미터가 들어가는 방향
    2. 리턴 주소
    3. 지역 변수 사용
  • 함수 안에서 스택을 사용하게 되면 보통 다음과 같은 코드가 함수의 시작 부분에 생성된다.(fastcall)

    push ebp      -> 1. ebp 레지스터를 스택에 넣는다.
    mov ebp, esp  -> 2. 현재 esp의 값을 ebp에 넣는다.
    sub esp, 0C   -> 3. esp에서 0C만큼 뺀다.
    
  • 여기서 esp는 현재 스택의 가장 에 있는 데이터를 가리키고 있는 포인터이고

  • ebp는 현재 스택의 가장 바닥을 가리키는 포인터이다.

  • 두 번째 줄까지 실행시키면 ebp와 esp 값이 같아지면서 이 함수에서 지역변수는 ebp 에서부터 얼마든지 계산할 수 있다.

    • ebp를 기준으로 오프셋을 더하고 빼는 작업으로 스택을 처리할 수 있게 된다는 뜻이다.
  • 스택은 LIFO 특성으로 아래로 자라는데, 특정 값만큼 빼면 그만큼 스택을 사용하겠다는 의미이다.

    • 따라서 세 번째 줄을 실행시키면 스택을 0C만큼 지역변수를 사용하겠다고 해석할 수 있다.
  • 그러면 ebp는 현재 함수에서 스택의 맨 위가 되었고, 첫 번째 번지가 되었다.

  • 또한 사이즈를 빼가면서 자리를 확보하고 있으므로, 결국 지역 변수는 “-“ 마이너스 형태로 계산이 가능하다.

    • 4바이트 단위로 움직이는 변수라고 생각했을 때 ebp-4는 첫 번째 지역 변수가 될 것이고, ebp-8은 두 번째 지역 변수가 될 것이다.
  • 예제로 다시 한 번 살펴보자.

  • 여기서 ebp: 0019FF28 / esp: 0019FF14이다.


  • 첫 번째 줄인 push ebp를 실행시키면 이전 스택의 base를 스택에 올린다.
  • 두 번째 줄인 mov ebp, esp를 실행시키면 현재 스택의 꼭대기 주소를 새로운 스택의 base를 설정한다.(새로운 스택이 시작됨)
  • 여기서 ebp: 0019FF10 / esp: 0019FF10이다.


  • 세 번째 줄인 sub esp, 0C를 실행시키면 스택을 12 바이트 내려서 지역변수로 그 만큼 사용할 수 있게 된다.
  • 여기서 ebp: 0019FF10 / esp: 0019FF04이다.


  • 함수의 마지막 부분에는 스택을 원상복구 시 마지막에 있는 ebp를 pop 시켜주고 ebp+4로 리턴하면 된다.
  • 또한 전역 변수는 ebp+8부터 사용할 수 있다.


함수의 호출 규약

int sum(int a, int b) {
	int c = a + b;
	return c;
}

int main(int argc, char* argv[]) {
	sum(1, 2);
	return 0;
}
  • 여기의 sum 함수를 어셈블리로 나타내면 다음과 같다.(호출 규약에 따라 변경 될 수도, 이건 cdecl)

    ⭐️ push ebp
    ⭐️ mov ebp, esp
    push ecx
    mov eax, [ebp+arg_0]
    add eax, [ebp+arg_4]
    mov [ebp+var_4], eax
    mov eax, [ebp+var_4]
    ⭐️ mov esp, ebp
    ⭐️ pop ebp
    ⭐️ retn
    
  • ⭐️ 부분은 모든 함수에서 공통적으로 볼 수 있다.

  • 이런 함수를 세 가지 방법으로 호출해보자.

1. cdecl (declaration)

  • 함수 선언 부분을 int __cdecl sum(int a, int b) 로 변경해주면 된다.

  • 그럼 sum과 main 함수는 다음과 같이 만들어진다.

    <sum 함수>
    push ebp
    mov ebp, esp
    push ecx
    mov eax, [ebp+arg_0]
    add eax, [ebp+arg_4]
    mov [ebp+var_4], eax
    mov eax, [ebp+var_4]
    mov esp, ebp
    pop ebp
    retn
      
    <main 함수>
    push 2
    push 1
    call calling.00401000
    ⭐️ add esp, 8
    
  • ⭐️ 부분은 스택을 사용한 만큼 다시 원래대로 당겨주는 의미

  • 즉 cdecl 은 함수를 호출한 쪽에서 스택을 보정해준다.

  • add에 들어간 operand 값으로 파라미터의 데이터 사이즈를 알 수 있다.

  • 여기에서는 eax에 숫자가 들어가는 것으로 보아 리턴 값은 주소 값이 아닌 숫자임을 확인 할 수 있다.

2. stdcall

  • 함수 선언 부분을 int __stdcall sum(int a, int b) 로 변경해주면 된다.

    <sum 함수>
    push ebp
    mov ebp, esp
    push ecx
    mov eax, [ebp+arg_0]
    add eax, [ebp+arg_4]
    mov [ebp+var_4], eax
    mov eax, [ebp+var_4]
    mov esp, ebp
    pop ebp
    ⭐️ retn 8
      
    <main 함수>
    push 2
    push 1
    call calling.00401000
    
  • cdecl과 다르게 함수 안에서 스택을 보정한다.

  • main의 add esp, 8 이 없어진 대신 retn에 8이 생겼다.

  • Win32 API는 __stdcall 방식을 이용한다.

3. fastcall

  • 함수 선언 부분을 int __fastcall sum(int a, int b) 로 변경해주면 된다.

    <sum 함수>
    push ebp
    mov ebp, esp
    ⭐️ sub esp, 0Ch
    mov [ebp+var_C], edx
    mov [ebp+var_8], ecx
    mov eax, [ebp+var_8]
    add eax, [ebp+var_C]
    mov [ebp+var_4], eax
    mov eax, [ebp+var_4]
    mov esp, ebp
    pop ebp
    retn
      
    <main 함수>
    push ebp
    mov ebp, esp
    ⭐️ mov edx, 2
    ⭐️ mov ecx, 1
    call sub_401000
    xor eax, eax
    pop ebp
    retn
    
  • 인자가 2개 이하일 때, stack에 값을 push하지 않고 ecx, edx 레지스터로 바로 계산하는 방식이다.

  • sub esp, 0Ch 로 스택 공간을 확보하고 edx 레지스터를 사용했다.