티스토리 뷰

최종 수정일: 2014-02-06


안녕하세요. Hackability 입니다.

이번 포스팅 내용은 우리의 영원한 Exploit PoC 타겟으로 Notepad.exe와 함께 어울리는 Calc.exe (계산기)의 스택 오버플로우 버그에 대한 내용입니다. 그 동안 Exploit PoC의 보조 역할로 등장하다가 이렇게 주연으로도 등장하네요. 마이크로소프트 계산기는 역사적으로도 굉장히 오래된 프로그램이고 구조적으로도 복잡한 모듈들이 많이 들어가지 않는 프로그램임에도 불구하고 (물론 자체로도 기능도 많고 복잡하긴 합니다만) 이런 버그가 생기는 것에 대해 프로그래머로써 다시 한 번 버그님 앞에 숙연해 집니다.

이 버그에 대해 간단히 요약하면, 현재 Win7 계산기에서 연산 과정에 의해 발생되는 스택 오버플로우 인데요. 스택 버퍼 오버플로우랑 스택 오버플로우는 다릅니다. 구글링 하면 둘다 비슷하게 쓰이는데요. 스택 버퍼 오버플로우는 우리가 일반적으로 문자열 버퍼를 이용하여 스택 구조를 망가뜨리는 경우 스택 버퍼 오버플로우라고 하고 스택 오버 플로우는 메모리 구조에서 스택 한계치를 넘어선 경우 스택 오버플로우라고 합니다. 

버그에 대해 설명하기 전에 버그가 어떻게 발생되는지 보시면 다음과 같습니다.

[1-1] 기본적인 계산기 실행 결과


[1-2] 1/999 연산을 한 결과


[1-3] [F-E] 버튼 클릭



[1-4] Crash !! :)

아름다운 메시지가 발생했습니다. 왜 프로그램 충돌이 발생하는 걸까요?


충돌 시점의 명령은 다음과 같습니다.

  : PUSH EDI



스택에 EDI를 PUSH 하는 순간 충돌이 발생된 것 같습니다. 그러면 스택내용을 한 번 확인해보도록 하겠습니다. 먼저 충돌 발생 시 스택은 다음과 같습니다. (ESP = 0x00141000)



메모리 구조를 확인하면 다음과 같습니다.



스택 주소와 커밋 사이즈를 보시면 Main 스레드의 스택 최 상위주소는 0x00141000 이며 커밋 사이즈는 0x0003F000 임을 알 수 있습니다. 스택은 높은 주소에서 낮은 주소로 증가하기 때문에 0x0018000부터 스택이 쌓이기 시작하여 0x00141000 까지 쌓이고, 이 상태에서 PUSH EDI를 하면서 문제가 발생됨을 알 수 있습니다.


결과적으로 메인 스레드 스택이 사용할 수 있는 크기를 넘어서 스택을 사용하려고 했기 때문에 스택 오버플로우가 발생되고 우리는 스택 오버플로우라는 결론을 내릴 수 있습니다.


스택 오버플로우가 발생되는 경우는 많은 재귀 호출 또는 큰 지역 변수를 선언 시에 발생될 수 있습니다. 문제가 어떤지 스택을 한 번 확인해보도록 하겠습니다.



스택 그림은 아래서 부터 위로 호출이 지속적으로 이루어지고 있는 순서 입니다. 빨간 네모를 친 부분이 함수 호출이 끝나고 돌아갈 Code 영역의 주소 입니다. 0x00C970E3의 코드 영역에서 이 전 명령이 실행되었다는 의미인데 0x00C970E3의 이전 명령어는 다음과 같습니다.


문제의 재귀 함수 호출은 0x00c95805 입니다. 뒤에 다시 설명하겠지만 이 함수는 putnum 이라는 함수 입니다.


번외로, 스택을 통해 저 함수가 몇 번이 재귀적으로 호출되었는지는 간단히 아래와 같은 식을 통해 구할 수 있습니다.



저 스택 프레임을 갖는 제일 높은 주소는 0x0017DEE8이고 낮은 주소는 0x0014142C이며, 저 스택 프레임 사이즈는 52 Byte 이기 때문에,


(0x17DEE8 - 0x14142C) / 52 + 1 = 4780


총 4780 번의 재귀 호출이 일어났음을 알 수 있습니다.


생각해보면 일반적인 프로그램도 저것보다 더 많은 함수를 호출해도 충돌이 안나는데 왜 여기서는 충돌이 날까요. 그것은 기본적으로 함수 호출과 재귀 함수 호출의 스택 사용에 차이가 있기 때문입니다.


기본적인 함수 호출은 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
int test(int i, int j)
  return (i + j);
 
void main()
{
  int i, j;
  int sum = 0;
  for (i = 0 ; i < 10 ; i++)
    for (j = 0 ; j < 10 ; j++)
         sum = sum + test(i, j)
}


재귀 함수 호출은 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int test(int i, int j)
{
  if (i == 0 && j == 0)
    return 0;
 
  if (i != 0)
    return i + j + test(i-1, j);
  else
    return i + j + test(i, j-1);
}
 
void main()
{
  int sum = test(10, 10);
}


두 함수의 차이점은, 기본적인 함수 호출에서는 함수 호출시 파라미터 값들과 리턴 주소, EBP 등을 스택에 넣고 함수 종료시 스택에 넣었던 내용들을 모두 제거하게 됩니다. (자세한 내용은 Calling Convention 을 참고)


하지만 재귀 함수 호출의 경우 함수 호출시 기본적인 함수 호출과 동일하게 파라미터 값, 리턴 주소, EBP 등을 스택에 쓰지만 함수가 종료되기 전에 다시 함수를 호출하기 때문에 지속적으로 위 값들을 스택에 쌓게 됩니다. 


결과 적으로 기본적인 함수 호출에서는 스택을 쌓고 빼고 하는 행위를 하지만 재귀 함수에서는 모든 재귀 행동이 끝나기 전까지는 쌓았던 스택을 빼지 못하기 때문에 이와 같은 스택 오버플로우가 발생 될 수 있습니다.


본론으로 들어가서 스택에 쌓인 내용을 통해 5805 (putnum) 함수가 재귀적으로 호출됨을 알 수 있었고, 재귀 함수를 호출하는 함수는 6FFD (lessnum) 함수임을 알 수 있습니다.


그림으로 표현하면 다음과 같습니다.


동작 방식은 lessnum 함수 (calc.xxxx6FFD)에서 putnum 함수 (calc.xxxx0x5805)를 호출 합니다. 호출된 putnum 함수에서 lessnum 함수 중간으로 점프하고 (호출이 아니라서 이 부분은 스택에 쌓이지 않음) 다시 lessnum 에서 putnum 함수를 호출하게 되어 무한 재귀가 발생합니다.


좀더 정확하게 표현하면 다음과 같습니다.



여기까지, 계산기가 어떤 이유에서 충돌이 발생되었고, 어떤 함수에서 충돌이 발생되었는지 이유를 알 수 있었습니다.

그러면 이제부터 어떤 조건(!) 에서 이런 행위를 하는지 분석해보도록 하겠습니다. 사실 이 부분이 제일 재미있는 부분 입니다. :)

다시 처음으로 돌아가 충돌이 났던 연산을 살펴보도록 하겠습니다. 

먼저 계산기에서 [F-E] 연산은 10진수를 10의 지수승으로 표현하기 위한 연산입니다. 예를들어 12345 라는 숫자가 있다고 가정하면 [F-E] 연산을 하면 1.2345e+4 (=1.2345 * 10^4)을 의미합니다. 이 연산이 필요한 이유는 계산기에서는 기본적으로 32 글자, 즉 10^32 의 숫자 까지만을 지원하기 때문에 더 큰 숫자를 지원하기 위해서는 지수승 표현이 필요합니다. (예, 10^100 = 1.e+100)

한 번 테스트 해보시면 아시겠지만 대부분의 경우에서는 충돌이 발생하지 않고 정상적으로 동작하는 것을 확인하실수 있습니다. 심지어 충돌이 발생했던 값인 0.001001001001001001001001001001도 정상 동작하시는 것을 확인할 수 있습니다. 다급히 스크롤을 올리시면서 처음에 연산했던 과정을 보시는 분들이 없게 하기 위해 결론을 말씀드리면 다음과 같습니다.

1/999 != 0.001001001001001001001001001001

이쯤 되면 생각이 드는게, 무한 소수에 의한 자릿수 버그겠구나 하시겠지만 무한 소수가 되는 몇 가지 경우를 두고 테스트 해보시면 왠만한 숫자는 충돌이 발생이 안되고 정상 동작 함을 알 수 있습니다. 결론적으로, 무한 소수를 만드는 특수한 값만이 계산기에 스택 오버플로우를 발생시킴을 알 수 있습니다. 

다시 돌아가 문제가 발생하는 재귀 함수를 보면 다음과 같습니다.


문제가 발생되는 지점이 putnum 함수에서 lessnum 함수를 호출하고 이 함수 안에서 stripzeroesnum 를 호출하게 되는데 이 함수에서 반환값이 0이면 정상 종료가 되고 1이면 다시 putnum 함수를 호출하게 됩니다.


stripzeroesnum 함수를 수동적으로 디컴파일 하면 아래와 같습니다.


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
32
33
34
35
36
bool stripzeroesnum(struct unknown *p_info, int n_max)
{
      struct unknown *p = p_info;
      int n_count = p_info[1];
      int *p_current_num = &p_info[3];
      int ret = false;
      int index = 0;
 
   if (n_count > n_max)
  {
        index = n_count - n_max
     n_count = n_max;
  }
 
  while (n_count > 0)
  {
        if (p_current_num[index] == 0)
       {
          index++;
          n_count--;
          ret = true;
          continue;
        }
 
    if (ret == true)
    {
        memmove(p_info[3], &p_current_num[index], n_count * 4);
        p_info[2] = p_info[1] - n_count;
        p_info[1] = n_count;
    }
        
    break;
  }
 
  return ret;    
}


stripzeroesnum 함수의 역할은 소수점 뒤에 있는 0을 제거하는 역할을 합니다.

예를들어, 0.000100 이라는 숫자가 있을 경우 뒤에 있는 0 2개는 필요가 없으므로 stripzeroesnum을 통해 0.0001 이라는 숫자로 변경되게 됩니다.


이 함수에서 주의깊게 봐야할 부분은 unknown 구조체 인데요. 이 구조체는 현재 쓰여질 데이터의 정보를 갖고 있습니다. 이 구조체를 표현하면 다음과 같습니다.


struct unknown{

  int unknown_value;

  int n_count;

  int unknown_index;

  int number[35~36];

};


n_count는 숫자 길이를 나타냅니다. 중요한건 처음이 소수점 이후 0을 제외한 숫자의 개 수를 나타냅니다. 예를들어 0.000222 의 경우 소수점 이하로 수가 6개 이지만 실제 사용되는건 4번째부터 6번째 이므로, n_count 는 3 이되게 됩니다.


저 구조체에서 흥미로운점은 4번째에 있는 int number 배열 변수인데요. 계산기 구조상 표현될 수 있는 숫자는 32개 그리고 "0." 을 표현하기 위한 2개를 추가하여 총 34개의 배열이 필요합니다. 그림으로 표현하면 아래와 같습니다. 편의상 1/255 의 결과에 대한 배열로 설명드리도록 하겠습니다.

-> 계산값: 1/255 = 0.00392156862745098039215686274509803

-> Win 7 계산기에서 보여주는 값: 1/255 = 0.0039215686274509803921568627451


int number[] => [3, 0, 8, 9, 0, 5, 4, ..., 1, 2, 9, 3, 0, 0, 0]  (36 개가 역순으로 저장됩니다.)


여기서 중요한 것은 메모리에 계산 결과가 우리가 필요한 결과보다 더 많은 값을 저장하고 있다는 것을 알 수 있습니다. (아마 이부분은 소수점 마지막 결과를 반올림하기 위해 있는것을 추측해볼 수 있습니다.) 


기본적으로 stripzeroesnum에서는 number[0] 부터 시작하여 숫자 0인지 판단하고 0이 있을경우 0과 만나지 않을때가지 반복한 다음 memmove를 하게 됩니다. 예를들어,


int number[] => [0, 0, 1, 2, 3, 0, 0, 0] (0.0032100)


이 있다고 가정하면 이 숫자는 stripzeroesnum이 끝나면 아래와 같이 memmove를 통해 변경되게 됩니다. (물론 총 카운트도 변하게 됩니다.)


int number[] => [1, 2, 3, 0, 0, 0, 0, 0]


지금 문제가 발생되는 부분은 stripzeroesnum 함수에서 계속 리턴 값이 1이 발생되는데, 이는 숫자가 strip 되었으니 다시 putnum을 호출하라는 의미가 되고 putnum에서는 다시 stripzerosnum을 호출하고, 다시 stripzeoresnum에서 1을 리턴하여 putnum을 호출하게 됩니다.


문제가 발생하지 않는 숫자는 stripzerosnum에서 더이상 strip 할 숫자가 없으므로 0을 리턴하여 종료가 되게 됩니다.


다시 위로 올라가 1/255가 계산된 메모리 결과를 보면 number[0] = 3 이기 때문에 0과 같지 않아 stripzeroesnum 에서 반환값이 0 으로 반환되어 정상적으로 끝나야 되는것처럼 보입니다. 하지만 여기에서 문제가 발생하는데요. stripzeroesnum의 처음 if 문을 보시면 n_count가 MAX보다 크게되면 index = n_count - MAX를 하게 됩니다. n_count = 0x21 (33)이고 MAX = 0x20 (32) 이기 때문에 index = 1이 되고 number[1] = 0 이기 때문에 stripzeroesnum을 하게 되면 배열이 아래와 같이 변경되고 리턴이 1이 되게 됩니다.


int number[] => [8, 9, 0, 4, 5, ..., 1, 2, 9, 3, 0, 0, 0, 0, 0] (이때, n_count = 0x1F)


stripzeroesnum의 리턴값이 1이기때문에 다시 putnum 으로 돌아가 stripzeroesnum을 호출하는데 이때는 여기서는 문제가 없어 보이지만 addnum을 거치고 다시 stripzeroesnum을 만났을 때는 저 배열이 초기화가 되어 있습니다. 


결론적으로 이 버그를 발생시키는 조건은 다음과 같습니다.


1. 그냥 입력이 아닌 연산결과를 통해 메모리에 33개 이상의 값이 저장되게 해야 합니다. 그냥 입력으로는 소수점 이하 32개까지 밖에 입력을 할 수 없습니다.


2. 메모리에 저장된 배열 (숫자가 역순으로 되어 있는 배열)의 인덱스가 [연산 결과의 수 - 최대 입력 가능한 수(0x20)]가 되는데 이 값이 0이 되야 합니다.


처음의 예로 설명을 드리면 다음과 같습니다.


1. 먼저, 1/999를 하면 메모리에 저장되는 결과는 다음과 같습니다.


number[] => [0, 0, 1, 0, 0, 1, 0, 0, 1 ... 0, 0, 1, 0, 0, 1, 0, 0, 0]

n_count => 0x21


2. putnum 함수의 stripzeroesnum을 만났을 때 n_count가 n_max인 0x20보다 크기 때문에 검사할 인덱스 값이 0x21 - 0x20 = 1 이 되게 되고, number[1] => 0 이기 때문에 stripzeroesnum은 1을 리턴하고 다시 putnum 함수를 호출하게 됩니다.


3. putnum 함수는 다시 stripzeroesnum을 호출하는 데, 이 때 배열은 addnum에 의해 다시 처음상태로 초기화된 상태에서 stripzeroesnum을 만나기 때문에 (2)번 과정을 다시 거치게 됩니다.


여기까지가 Win7 32bit, 64bit에서 발생되는 스택 오버플로우 버그 입니다. 


그러면 Win XP는 어떨까요? Win XP는 위 버그가 발생되지 않습니다. 그 이유는 Win XP에서는 기본적으로 34 글자 까지 지원이 되기 때문에 stripzeroesnum에서 저 루틴을 타지 않습니다.


(정상적으로 1/999 이후 [F-E] 연산을 수행한 Win XP 화면)


단순히 제 추측입니다만, Win XP에서의 계산기가 Win 7 으로 오면서 수정이 많이 되었는데 이 때, Win XP에서 쓰던 배열 구조를 그대로 가져오면서,  XP에서는 소수점 밑 최대 글자수가 33개 였는데 이를 32개로 고정하면서 발생되는 버그인것 같습니다. (확실한 내용은 아닙니다.)


이를 수정하기 위해서는 MAX_INT 값을 0x21 또는 0x22로 설정이 되어야 할 것 같습니다. (기존 XP와 동일하게)


끝으로


이 버그 자체에는 익스플로잇을 하거나 서비스 거부 공격등을 하기는 힘들지만 이 내용을 통해 업그레이트된 소프트웨어의 구조적 변화의 취약점이라던지 소수점 연산 버그등에 대해 많은 공부를 할 수 있었던 좋은 시간이였습니다.


이 분석에 도움을 주신 WhatTeam 멤버분들과 CodeRed의 ReverseHW 님께 감사드립니다. 


댓글