* 2013-07-05 책으로 출판하는 문제 때문에 내용의 일부를 추가했습니다. 책에는 원래 있던 내용은 제거되고 추가된 내용만 나갈겁니다.
8. 비밀 정보는 쓰기 직전에 생성하고, 쓴 직후에 완전히 삭제하라
완전히 삭제한다는 말의 의미는, 그냥 free 하는 걸로 만족하지 말고, 먼저 0이나 다른 쓰레기 값을 채워넣은 다음에 함수에서 리턴 하거나, free 하라는 말이다. 그렇게 하지 않으면 엉뚱한 곳에서 중요한 데이터가 공격자에게 노출 될 수 도 있다. 이 문제는 심지어 인터넷 뱅킹 프로그램을 작성하는 사람 조차 모르거나 실수하는 것 중 하나로, 이 문제 때문에 인터넷 뱅킹의 보안 문제가 신문에 보도된 적도 있다. 예를 들어 다음과 같은 시나리오를 생각해 보자:
1. 사용자의 아이디와 패스워드를 암호화 해서 전송하기 위한 클래스의 인스턴스 X 를 생성한다.
2. 사용자의 아이디와 패스워드를 스크린 터치 방식을 이용해 입력 받는다.
(키보드로 부터 입력 받는게 아니니 키보드 후킹에 안전하다! 오 안전해!)
3. 로그인 인증을 위해 입력받은 아이디와 패스워드를 1. 에서 생성한 인스턴스 X 에게 넘긴다.
4. 인스턴스 X 는 구글신도 깰 수 없는 조낸 강력한 암호화 기법으로 암호화 해서 서버에 보낸다.
(현실세계에 존재하지 않는 조낸 안전한 암호화 방식이다. 안전하다! 오 안전해!)
5. 서버가 로그인 시도에 대한 결과를 넘겨 줬다. 결과는 4. 에서 사용했던 안전한 암호화 기법을 사용했기 때문에 역시 안전하다.
6. 공격자가 서버의 패킷이 클라이언트에게 가기 전에 가로챈 다음 클라이언트가 크래시 될만한 커다랗고 이상한 데이터를 대신 보냈다.
7. 서버가 보낸 정보 - 사실은 공격자가 보낸 데이터를 해석하지 못한 클라이언트는 크래시 됐다.
일단, 공격자는 클라이언트를 자신의 의도에 맞게 크래시 시켰다. 그러면, 공격자는 생성된 덤프 파일에서 무엇을 얻을 수 있을까? 먼저, 공격자는 스택 영역을 뒤져서 아이디와 패스워드 및 메모리의 어디쯤 사용자의 아이디와 패스워드가 저장되는지를 얻어 낼 수 도 있을 것 이다. 덤프 파일에는 프로그램이 크래시 되는 순간의 모든 정보가 저장되어 있으므로, 사용자의 아이디와 패스워드가 로컬 변수에 저장되어 있었다면 공격자는 필요한 모든 정보를 얻을 수 있을 것 이다. 나아가서 다른 사용자가 아이디와 패스워드를 입력했을 때 해당 정보가 저장될 메모리 위치도 얻었다. 어떻게?
보통 같은 과정을 거쳐 아이디와 패스워드가 입력되는 함수가 호출 되기 때문에 다른 사용자도 내가 입력한 아이디와 패스워드가 저장된 위치에 저장될 확률이 아주 높기 때문이다.
둘째로, 공격자는 아이디와 패스워드를 암호화 하여 전송하는 클래스의 인스턴스 역시 얻을 수 있다. 이쪽은 피해자의 아이디와 패스워드 뿐 아니라 통신에 사용되는 키도 얻어 낼 가능성이 있다. 공개키 방식의 통신 이라면 피해자의 개인 키 역시 알아낼 가능성도 있다. 아이디와 패스워드를 입력하고 확인하는 절차는 프로그램이 실행 된 뒤 시간이 많이 흐르지 않은 상태이기 때문에 암호통신 객체에 할당된 메모리는 항상 같은 위치일 확률이 올라간다. 그러므로 앞서 로컬 변수에 저장된 아이디와 패스워드를 얻어내듯 객체내에 저장된 데이터를 얻어낼 수 도 있게 된다.
위와 같은 이유들 때문에, 중요한 정보들은 사용하기 직전에 생성하고 사용한 직후에 메모리를 그냥 free하는 것이 아니라 0이나 다른 쓰레기 값으로 채워 넣어서 free 해줄 필요가 있는 것이다. 위의 시나리오를 예를 들자면 3과 4 사이에서, 2에서 입력받은 로그인 아이디와 패스워드가 저장되어 있는 로컬 변수에 0을 채워 넣는다. 그렇게 하면 스택에 저장되어 있는 정보가 제거된다. 또한, 4와 5 사이에서 3에서 넘겨받은 아이디와 패스워드를 저장하는 변수에 0을 채워넣어 정보를 제거해준다. 이렇게 데이터가 저장되어 있는 변수를 0이나 다른 쓰레기 값으로 채워 넣음으로서 공격자가 쉽게 피해자의 정보를 얻어내는 것을 차단할 수 있다. 1
임시 파일을 생성해서 작업을 하는 경우, 작업의 내용을 공격자가 보거나 수정해도 상관 없는 경우엔 임시 파일의 이름이 어떻든 상관 없겠지만, 그렇지 않은 경우에는 매번 다른 파일 이름을 선택해 주는 것이 공격자를 조금 더 귀찮게 만들 수 있다. 2
이를 위해서 UNIX* 계열에서 제공하는 함수로는 mkstemp() 라는 함수가 있다. 이 함수는 이전에 사용되던 tmpfile() 함수의 보안취약점을 제거한 버전으로 일단 생성에 성공했다면 유니크한 파일임을 보증해주고, 생성 프로세스 외에는 파일을 열 수 없도록 파일을 생성하자 마자 삭제해 버림 3으로서 레이스 컨디션을 이용한 공격법을 사용하지 못하도록 해준다. 당연히 생성되는 파일을 변조 하거나, 원하는 정보를 끼워넣는 등의 조작을 하기 위해서도 사용될 수 있다.
mkstemp() 함수가 아닌 다른 방법을 사용하는 경우, 공격자는 대충 다음과 같은 시나리오를 이용해 공격 할 수 있겠다. 시나리오에서 Victor는 피해자, Mallory는 악의를 가진 공격자가 되겠다.
1. Victor가 임시 파일을 만든다
2. Victor가 임시 파일에 내용을 써 넣는다.
3. 프로세스에게 할당된 시간이 다 지나가서 프로세스 전환이 이루어 진다.
4. Mallory가 미리 예측한 임시 파일의 이름을 open 해본다.
5. 오! Victor의 파일을 여는 것에 성공 했다!
6. Mallory는 유유히 Victor의 파일의 내용을 읽어 들이거나, 다른 값을 채워 넣는다. 쓰레기 값을 넣을 수 도 있다.
7. 할일 다 한 Mallory는 슬립해서 프로세스 전환을 유도한다.
8. Victor는 Mallory가 건드려 놓은 임시파일을 마음껏 사랑해 준다.
9. 파일을 닫는다.
10. 뭔가 사단이 난다. Mallory는 사단이 난 결과물을 가지고 즐거워 하며 뭔가 더 나쁜 짓을 시작한다.
물론, 2를 하기 전에 파일에 락을 거는 방법도 있다. 락을 걸어 버리면 조금 더 귀찮아 지긴 하겠지만 레이스 컨디션으로 공격하는 대상이 1. 과 2.의 사이나 8. 과 9. 의 사이가 될 수 있을 것이다. 레이스 컨디션을 이용한 공격 방법에 대한 자세한 설명은 말미에 있는 안랩에서 제공한 문서를 참조 하길 바란다.
MS 윈도우의 경우에는 mkstemp() 류의 함수는 존재하지 않는 것 으로 알고 있다. 최소한 구글님께서 2012년 2월 22일 글을 쓰고 있는 현재 알려주시지 않는 걸 보면 널리 알려져 있는 함수는 아닐 것으로 믿어마지 않는다. 대신에 ISO C++ 표준에서 제공하는 _mktemp_s() 함수를 사용하라고 권장하고 있다.
아래의 프로그램 코드는 윈도우에서 제공하는 함수들로 mkstemp() 와 유사한 동작을 하도록 만들어진 코드이다. FAT 이나 NTFS 자체가 POSIX 의 파일 시스템 과는 구조가 다르기 때문에 POSIX 에서 제공하는 안전성을 100% 보장한다고 볼 수 는 없다. 그러나 다른, 윈도우만의 방식으로 가장 안전한 임시 파일을 만들도록 작성된 코드 이므로 참고하거나 이용하는 것도 도움이 될 것 이다.
#include <windows.h>
static LPTSTR lpszFilenameCharacters = TEXT("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ");
static BOOL MakeTempFilename(LPTSTR lpszBuffer, DWORD dwBuffer) {
int i;
DWORD dwCharacterRange, dwTempPathLength;
TCHAR cCharacter;
dwTempPathLength = GetTempPath(dwBuffer, lpszBuffer);
if (!dwTempPathLength) return FALSE;
if (++dwTempPathLength > dwBuffer || dwBuffer - dwTempPathLength < 12) {
SetLastError(ERROR_INSUFFICIENT_BUFFER);
return FALSE;
}
dwCharacterRange = lstrlen(lpszFilenameCharacters) - 1;
for (i = 0; i < 8; i++) {
cCharacter = lpszFilenameCharacters[spc_rand_range(0, dwCharacterRange)];
lpszBuffer[dwTempPathLength++ - 1] = cCharacter;
}
lpszBuffer[dwTempPathLength++ - 1] = '.';
lpszBuffer[dwTempPathLength++ - 1] = 'T';
lpszBuffer[dwTempPathLength++ - 1] = 'M';
lpszBuffer[dwTempPathLength++ - 1] = 'P';
lpszBuffer[dwTempPathLength++ - 1] = 0;
return TRUE;
}
HANDLE SpcMakeTempFile(LPTSTR lpszBuffer, DWORD dwBuffer) {
HANDLE hFile;
do {
if (!MakeTempFilename(lpszBuffer, dwBuffer)) {
hFile = INVALID_HANDLE_VALUE;
break;
}
hFile = CreateFile(lpszBuffer, GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_DELETE | FILE_SHARE_READ | FILE_SHARE_WRITE,
0, CREATE_NEW,
FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE, 0);
if (hFile == INVALID_HANDLE_VALUE && GetLastError( ) != ERROR_ALREADY_EXISTS)
break;
} while (hFile == INVALID_HANDLE_VALUE);
return hFile;
}
맹글기 귀찮아서 'Secure Programming Cookbook for C and C++'에 있는 소스를 그냥 썼는데... 안될까요???
직접 맹글어야 하나요??? 우리만 보고 비밀로 해줘요
Note. FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE 플래그를 사용하면, 생성되는 파일은 메모리가 부족하지 않은 한 하드 디스크로 플러시 되지 않으며, 파일을 닫는 순간 삭제가 된다. 그러므로, 공격자는 파일의 내용을 볼 수 없게 된다.
위의 프로그램을 보면, 일반적인 rand() 함수가 아닌 spc_rand_range() 라는 함수를 이용해서 난수값을 만드는 것을 볼 수 있다. rand() 함수는 시드가 없는 경우엔 항상 동일한 숫자열을 생성해 내고, 시드를 이용해 초기화 해도 시간을 많이 사용하기 때문에 비교적 예측하기가 쉬운 편이다. 그래서 예측이 더 어려운 난수 생성 함수가 필요한 경우에 사용되는 함수가 spc_rand_range() 함수이다.
위의 프로그램을 보면, 일반적인 rand() 함수가 아닌 spc_rand_range() 라는 함수를 이용해서 난수값을 만드는 것을 볼 수 있다. rand() 함수는 시드가 없는 경우엔 항상 동일한 숫자열을 생성해 내고, 시드를 이용해 초기화 해도 시간을 많이 사용하기 때문에 비교적 예측하기가 쉬운 편이다. 그래서 예측이 더 어려운 난수 생성 함수가 필요한 경우에 사용되는 함수가 spc_rand_range() 함수이다.
혹여나 그래도 필자처럼 POSIX 표준이 아니면 죽음을 달라(응?)라는 고집을 가진 프로그래머가 있다면, 아래의 코드를 사용하면 될 것이다. 아래의 코드는 wcecompat( 4https://github.com/mauricek/wcecompat/)이라는 프로젝트의 코드 일부분을 발췌해 낸 것으로 mkstemp() 함수의 구현 코드이다.
/* mkstemp extracted from libc/sysdeps/posix/tempname.c. Copyright
(C) 1991-1999, 2000, 2001, 2006 Free Software Foundation, Inc.
The GNU C Library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version. */
static const char letters[] =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
/* Generate a temporary file name based on TMPL. TMPL must match the
rules for mk[s]temp (i.e. end in "XXXXXX"). The name constructed
does not exist at the time of the call to mkstemp. TMPL is
overwritten with the result. */
int
mkstemp (char *tmpl)
{
int len;
char *XXXXXX;
static unsigned long long value;
unsigned long long random_time_bits;
unsigned int count;
int fd = -1;
int save_errno = errno;
/* A lower bound on the number of temporary files to attempt to
generate. The maximum total number of temporary file names that
can exist for a given template is 62**6. It should never be
necessary to try all these combinations. Instead if a reasonable
number of names is tried (we define reasonable as 62**3) fail to
give the system administrator the chance to remove the problems. */
#define ATTEMPTS_MIN (62 * 62 * 62)
/* The number of times to attempt to generate a temporary file. To
conform to POSIX, this must be no smaller than TMP_MAX. */
#if ATTEMPTS_MIN < TMP_MAX
unsigned int attempts = TMP_MAX;
#else
unsigned int attempts = ATTEMPTS_MIN;
#endif
len = strlen (tmpl);
if (len < 6 || strcmp (&tmpl[len - 6], "XXXXXX"))
{
errno = EINVAL;
return -1;
}
/* This is where the Xs start. */
XXXXXX = &tmpl[len - 6];
/* Get some more or less random data. */
{
SYSTEMTIME stNow;
FILETIME ftNow;
// get system time
GetSystemTime(&stNow);
stNow.wMilliseconds = 500;
if (!SystemTimeToFileTime(&stNow, &ftNow))
{
errno = -1;
return -1;
}
random_time_bits = (((unsigned long long)ftNow.dwHighDateTime << 32)
| (unsigned long long)ftNow.dwLowDateTime);
}
value += random_time_bits ^ (unsigned long long)GetCurrentThreadId ();
for (count = 0; count < attempts; value += 7777, ++count)
{
unsigned long long v = value;
/* Fill in the random bits. */
XXXXXX[0] = letters[v % 62];
v /= 62;
XXXXXX[1] = letters[v % 62];
v /= 62;
XXXXXX[2] = letters[v % 62];
v /= 62;
XXXXXX[3] = letters[v % 62];
v /= 62;
XXXXXX[4] = letters[v % 62];
v /= 62;
XXXXXX[5] = letters[v % 62];
fd = open (tmpl, O_RDWR | O_CREAT | O_EXCL, _S_IREAD | _S_IWRITE);
if (fd >= 0)
{
errno = save_errno;
return fd;
}
else if (errno != EEXIST)
return -1;
}
/* We got out of the loop because we ran out of combinations to try. */
errno = EEXIST;
return -1;
}
10. try ... catch 를 사용 하지 않는 것은 '공격할 수 있으면 해봐' 라고 도발하는 것과 같다.
윈도우 프로그램이 문제가 발생되어 크래시 되면 닥터 왓슨께서 코어를 덤프해 주신다. 그리고 고마우시게도 프로그래머가 고생하지 말라고 5 덤프된 코어에는 프로그램에서 다루고 있는 정보들이 가득 들어있다. 한 바이트 한 바이트 정성스레 모은 메모리 내용이 한 비트도 안 빠지고 예쁘게 코어덤프 파일에 켜켜히 쌓여있다. 그렇기 때문에 코어덤프를 유발하는 것도 훌륭한 공격이 된다. 공격자가 필요한 순간에 크래시를 일으킬 방법만 찾는다면 메모리를 액세스 하지 못하도록 보호 하는 것 자체가 의미 없어질 수 도 있다.
공격자가 원하는 시점에서 프로그램을 크래시 시키는 방법은 꽤 여러가지가 있다. 어떤 방법을 사용하건 메모리를 살짝 건드려 주기만 하면 되기 때문에 크래시를 일으키는건 수퍼 유저의 권한을 얻거나 바이러스를 심는 것 보다 훨씬 쉽다. 어떤 방법을 쓰던 크래시만 일으켜 주면 닥터 왓슨께서 혹은 그 후계자 께서 해킹하기 편하라고 메모리의 모든 데이터를 모아서 파일로 만들어 주시니 얼마나 좋은가? 어떤 공격법을 사용할지, 어떻게 사용할지만 창의력을 발휘하면 된다.
이정도 창의력 이라면 충분하다!
신중하게 범위 체크 하고, 값을 밸리데이션 하고, 자원을 액세스 할 때 마다 검증을 하면 궂이 try ... catch 구문을 사용하지 않아도 될 수 있다. 그러나 인간은 실수를 저지를 것을 전제로 창조된 존재다, 실수 하지 않는다면 그건 창조주에 대한 배신이다. 자신이 절대로 실수 하지 않을 것 이라고 믿는 것은 죄악이다! 그러니 절대로 이룰 수 없는 무결점 프로그램을 만들기 위해 150%의 노력을 들이느니, try ... catch 구문에게 적당히 떠넘기고 85%의 노력만 기울이는 쪽을 추천한다.
11. 항상 메모리 범위 체크를 하라
스택 오버플로우, 힙 오버 플로우 합쳐서 버퍼 오버플로우! (응? 뭔소리냐?) 스택 오버플로우와 힙 오버 플로우는 공격의 대상이 다르고 그 효과가 다르긴 하지만 기본적으로 버퍼 오버플로우 공격에 해당된다. 버퍼 오버플로우 공격을 한마디로 줄인다면 '데이터 홍수' 되겠다. 준비한건 100바이트 뿐인데 150바이트가 들어왔다면 메모리 구조의 특성상 연달아 있는 다른 데이터 영역을 침범하게 되는데, 그 침범당하는 영역이 무엇이냐에 따라 공격의 내용이 바뀌게 된다.
준비된 메모리 영역보다 들어온 데이터가 더 많다면 요로케 되는 거시다! (수해를 입으신 분들껜 애도를)
힙 오버플로우 공격은, *alloc() 계열의 함수로 할당된 힙 영역에 존재하는 데이터 버퍼에 허용이상의 데이터를 밀어 넣음으로서 연달아서 할당받은 다른 데이터 영역의 데이터를 수정하는 방법이다. 이렇게 수정함으로서 포인터류 - 구조체의 인스턴스, 클래스의 인스턴스, 메소드, 링크드 리스트 등등등의 포인터를 수정하거나, 데이터 자체를 수정하는 것을 목적으로 한다. 그러나, 힙의 특성상 활용하기 위해서 더 많은 제약이 필요하기 때문에 스택 오버플로우에 비해 상대적으로 덜 사용된다. 6
스택 오버플로우 공격은 로컬 변수로 잡힌 배열을 대상으로 한다. 주로 char[] 배열이 그 공격 대상이 된다. 설명을 위해 함수가 호출되면 스택이 어떻게 동작하는지 잠시 먼저 설명하겠다.
한글버전의 일러스트가 아닌 점에 대해서는 심심합니다(응?)
그림의 연두색 부분이 DrawLine() 이라는 함수가 호출되었을 때 스택의 상태이다. 스택의 가장 아래쪽에는 DrawLine() 함수에게 넘겨지는 매개변수가 들어가고, 그 다음에는 리턴 주소가, 마지막으로 로컬 변수들이 할당된다.
그림에서 볼 수 있듯이, 로컬 변수가 돌아가야 되는 장소의 주소를 기록한 메모리의 윗쪽에 위치하기 때문에 지역변수가 배열인 경우 배열의 크기보다 더 많은 데이터를 입력해 주면 이 주소 영역까지 침범이 가능하고, 잘 이용하면 다른 코드를 실행할 수 도 있다.
이러한 공격에 저항하기 위해 i586 이후 부터는 코드 영역이 아닌 곳에서는 프로그램을 실행하지 못하도록 CPU에서 막아주는 기능을 제공하고는 있지만, 이를 위해서는 운영체제가 수정되어야 하는데, 아직 그 기능을 사용한 운영체제가 있다는 이야긴 듣지 못했다. 추가로, 인텔에서 생산하는 IA64의 경우에는 스택 오버플로우 공격을 막기위해 레이어드 스택이라는 개념을 도입하기도 했다. 7
우좌지당간에, 버퍼 오버플로우 공격은 상당히 강력한 공격법 이긴 하지만, 버퍼를 사용할 때 메모리 영역을 착실하게 확인만 해주면 아무런 문제가 발생하지 않는다. 그러므로, 어떤종류의 버퍼이든 관계없이 버퍼를 복사 할 때에는 항상 메모리 크기 확인하고, 배열을 로컬변수로 사용할 땐 항상 배열의 크기와 입력 데이터의 길이를 비교 해야한다. 좀 더 실제적인 경우를 예로 든다면, 입력된 데이터를 복사할 때 문자열 복사라고 해서 strcpy()를 사용하면 안된다는 말이다. 특히 C/C++ 언어를 배울때 문자열을 다루는 함수는 str* 계열의 함수를 배우게 되는데, 이 함수가 쓰는 입장에서 편리할 뿐 아니라 공격자 입장에서도 아주 편리한 함수이다. 이런 함수들의 문제점은 아주 오래전부터 알려져 있음에도 불구하고, 여전히 C/C++을 가르칠 때엔 strn*() 계열이 아닌 str*() 계열을 가르치고 있다. 이유는? 아무도 모른다.
사용자가 적당한 길이로 값을 입력해 주리라 예측하고 프로그램을 만들어서는 안된다. 사용자의 아이디를 입력받기 위한 버퍼의 크기가 1000 바이트면 적당하다고 생각하는가? 1001자를 입력해 보고 버퍼 오버플로우를 일으키는 사용자- 공격자가 아닌 일반 사용자는 반드시 있다. 사용자의 패스워드가 100자면 충분하고도 남는다고 생각 하는가? 한때 255자 패스워드가 유행한 적도 있었다. 그 당시 255자 패스워드 한번 안 써본 사람은 제대로된 서버 관리자가 아니라는 소리도 했었다. 그 당시 필자가 관리하던 서버의 패스워드는
dughdhksmsskdmlahrwktlslsorpqnwhrgkadldjqtdmflfhekrmrkskfmfvnfmschwkddpsndltlautnlfaksgksanfrkdmfhdlsehgktlsmsehek
였었다. 불행하게도150자도 안되어 다른 관리자들이 웃고 떠드는 와중에 끽소리 못했었다.
우좌지당간에, 사용자들은 전부 창의력 대장들이다. 프로그래머의 의도대로 프로그램을 사용해주는 사용자는 그다지 많지 않다. 아니, 프로그램을 만든 자신 조차도 만들고 나서 몇주 지나면 자신이 처음에 만든 의도와 다르게 프로그램을 사용하는건 당연하다. 그러므로 버퍼를 다루는 모든 코드에는 항상 버퍼의 크기와 입력된 - 키보드에서 입력 되었든, 네트워크를 통해서 전달 받은 데이터든 상관없이 - 데이터를 비교해서 데이터가 버퍼에 충분히 들어갈 수 있는지 확인하고, 그렇지 않다면 뱉어내는 코드를 잊지 말고 작성해 주어야 한다.
마찬가지로 memcpy 등을 이용해서 메모리 버퍼를 복사 할 때에도 복사해 주는 쪽의 버퍼 크기를 기준으로 복사하는 것이 아니라, 복사 받는 쪽의 버퍼 크기를 기준으로 복사 해야 한다. 데이터의 검증이 완료 되기 전에는 10초전에 같은 프로그램이 저장해 두었던 파일을 다시 열어서 처리하는 경우에도 데이터가 완전할 것 이라고 믿어선 안된다.
주저리주저리 설명을 했지만, 한마디로 줄여서 다시 말한다면 '데이터를 버퍼에 다 담을 수 있는지 항상 확인해라' 이다.
12. 값의 밸리데이션은 옵션이 아니다.
많은 프로그래머들이 입력된 값이 요구한 값과 같을 것 이라는 잘못된 전제를 가지고 프로그램 코드를 작성한다. 그러나, 사용자들은 절대 '알파벳만 입력해 주세요'라고 메시지를 띄웠다고 해서 알파벳만 입력해 주지는 않는다. '한글도 알파벳' 이라고 생각하는 사용자 부터, '다른 문자를 넣으면 어떻게 될까'라는 단순한 호기심을 가진 사용자, 그리고 공격할 틈을 찾는 크래커 등등 잘못된 입력은 항상 이루어지기 마련이다.
네트워크를 통해 전달된 데이터도 마찬가지 이다. 클라이언트에서 체크해서 보내는 데이터 이기 때문에 서버는 아무런 신경을 쓰지 않아도 된다고 생각한다면, 그건 경기도 오산에서나 있을 일이다. (어디서 쌍팔년도 개그를 날리고 XX이얏!) 마찬가지로, 서버에서 보내주는 데이터 이기 때문에 안전하다고 생각하면 안된다. 공격자들은 개구멍 찾는데 있어서는 하늘도 놀랄 창의력을 가지고 있다.
밸리데이션을 할 때에는 다음과 같은 전제 조건을 생각하라
ㄱ. 확인하기 전엔 모든 입력값 - 키보드로 부터의 입력이든 네트워크를 통해 전송된 것이든 상관없이 프로그램의 외부에서 전달된 데이터는 모두 공격자의 농간이 있을 것 이라고 생각하라. 게임이 릴리즈 되고 인기를 끌게 되면 어떻게든 정당하지 않은 방법으로 이득을 얻어보려는 찌질이 들이 등장하게 될 것이다. 일단 그런 찌질이 들이 등장하면, 축하한다. (응???) 그리고, 안전하다고 여겼던 바로 그 방법을 통해 클라이언트를 공격하려고 시도할 것 이다. 그러니 확인되지 않은 모든 입력값은 전부 해커가 공격하려고 시도한 값이다.
ㄴ. 밸리데션은 입력시와 인스턴스 내에서 사용하기 전 모두에서 확인하여야 한다. 입력되는 데이터가 올바른 것인지 여부는 그 형태가 올바른지를 확인하는 정도를 넘기가 쉽지 않다. 공격자가 입력 밸리데이션을 통과할 수 있도록 패턴을 맞추고, 실제 처리시에 문제를 일으킬 만한 데이터를 고안해 낼 가능성이 있다. 예를 들자면 이메일을 입력하는 경우에, 이메일의 패턴에는 맞지만 실제로는 이메일 정보가 아닌 데이터가 입력될 수 도 있다. 그렇기 때문에 그 데이터가 실제로 사용되기 직전에 다시한번 프로그램의 동작에 부합하는 데이터 인지를 다시 한 번 확인해야 한다.
ㄴ. 밸리데션은 입력시와 인스턴스 내에서 사용하기 전 모두에서 확인하여야 한다. 입력되는 데이터가 올바른 것인지 여부는 그 형태가 올바른지를 확인하는 정도를 넘기가 쉽지 않다. 공격자가 입력 밸리데이션을 통과할 수 있도록 패턴을 맞추고, 실제 처리시에 문제를 일으킬 만한 데이터를 고안해 낼 가능성이 있다. 예를 들자면 이메일을 입력하는 경우에, 이메일의 패턴에는 맞지만 실제로는 이메일 정보가 아닌 데이터가 입력될 수 도 있다. 그렇기 때문에 그 데이터가 실제로 사용되기 직전에 다시한번 프로그램의 동작에 부합하는 데이터 인지를 다시 한 번 확인해야 한다.
위의 두 베이스 가이드는 게임 클라이언트를 대상으로 했기에 일반적인 클라이언트나 서버 프로그램을 작성해야 하는 경우에 감안해야 하는 다른 내용들은 설명하지 않았다. 다음은 몇몇 실질적인 가이드 이다.
ㄱ. 액세스 하는 파일의 패스에 대한 밸리데이션을 반드시 하라
내가 입력한 패스 값을 쓰는데 뭐가 문제? 라고 생각하면 Orz. 상대 패스를 이용하여 파일을 액세스 하는 경우에는 '../' 몇개로 가능한 공격들도 있다. 물론 앞에서 말한대로 파일을 액세스 할 때 무조건 절대 패스를 사용할 수 있다면 문제가 되지 않는다. 그러나 모든 파일을 절대 패스를 이용하여 액세스 하는 데에는 제한이 있을 수 밖에 없고, 상대 패스를 이용하여 파일을 액세스 해야 하는 경우라면 액세스 하려는 파일이 내가 의도한 것이 맞는지를 확인해 줄 필요가 있다. 지정된 상대패스를 절대 패스로 바꾸는 함수들은 다음과 같은 것 들이 있다:
realpath() in C
getCanonicalPath() in Java
GetFullPath() in ASP.NET
GetFullPathName() in C++
getCanonicalPath() in Java
GetFullPath() in ASP.NET
GetFullPathName() in C++
위 언어 이외의 다른 언어라면 틀림없이 유사한 함수를 제공 할 터이니 확인해 보고, 파일을 액세스 할 때에는 반드시 의도한 파일이 맞는지 여부를 확인하는 것을 잊지 말라.
ㄴ. *printf() 계열의 포멧 스트링을 사용하는 경우엔 주의하라
특히 입력된 데이터를 전송하거나, 전송 받은 데이터를 포멧 스트링에 넣을 때에는 주의하라. 포멧을 지정하는 함수를 사용하는 경우, 공격자가 입력에 사용되는 문자열에 어떤 문자를 끼워 넣을지 알 수 없다. 특히 %s나 %n을 사용할땐 외부에서 입력되는 데이터를 검증없이 사용해서는 안된다. 입력받은 데이터를 포멧 스트링에 집어넣는 경우에는 아래에서 설명할 Neutralization을 시행하는 편이 더 안전 할 것 이다.
일반적으로 *sprintf() 계열의 함수를 사용할 텐데, 위에서 설명했던 것과 동일한 이유로 vsnprintf(), snprintf(), vasprintf() 그리고 asprintf() 함수를 사용하여 출력하는 데이터의 포멧을 지정하는 편이 유사한 동작을 하는 다른 함수에 비해 안전하다. 특히 %s를 사용할 때엔 가능하다면 그냥 %s로 사용하지 말고 %.10s와 같이 최대폭을 고정해 사용하는 편이 좋다.
마지막으로, CString 클래스의 Format() 메소드 역시 sprintf() 함수와 유사한 문제점이 있는 것으로 알려져 있다. 그러므로 CString 클래스를 사용하는 경우 MS에서 뭔가 해주었을 것 이라고 믿는 것 보다는 스스로 주의 하는 편을 권한다.
ㄷ. 입력받은 데이터를 서버로 보낼땐 항상 Neutralization을 하라
흔한 공격법 중에 쿼리 인젝션과 커멘드 인젝션이 있다. 이름은 다르지만, 성이 같은 걸 봐서는 같은 집안 녀석들 이라는 걸 알 수 있을 것 이다. 이 두놈은 성에서 알 수 있듯이, 입력에 무언가를 끼워 넣어 하는 공격이다. 특히 쿼리 인젝션은 에지간한 웹 프로그래머라는 다 알고 있을만큼 유명한 공격법이다. C/S 환경역시 웹 환경과 다르바가 없기 때문에 좀 말이 안되는 단순한 공격 예를 보이도록 하겠다.
"SELECT * FROM TBL_USERS WHERE USER_NAME='" + userName + "' AND PASSWORD='" + userPass + "'";
위와같이 전달된 문자열을 이용해서 쿼리를 생성, 실행하는 코드가 있다고 가정하자. 사용자가 아래와 같이 아이디를 입력하는 경우, Neutralization이 시행되지 않는다면, 공격자는 패스워드를 몰라도 인증을 통과할 수 있다.
bamboo' --
SELECT FROM TBL_USERS WHERE USER_NAME='bamboo' -- ' AND PASSWORD='something'
-- 가 주석을 의미하므로, 프로그램은 bamboo라는 사용자가 아이디와 패스워드를 제대로 입력했다고 인식할 확률이 높다.
물론 위와 같은 공격에 성공하려면 시간을 필요로 하고 쿼리를 정확하게 아는 사람이 아닌 다음에는 공격에 성공할 것 이라는 확신을 가질 수 도 없다. 게다가 쿼리가 조금만 더 복잡해진다면 위의 공격 방법은 확실하게 실패 할 것 이다. 그러나, 여전히 공격에 성공할 확률을 0%라고 안심 할 수 도 없다.
위와같은 공격 방법을 막는 길은 상당히 많다. 그러나 가장 널리 사용되는 방법은 Neutralization 이라는 방법으로, 간단하게 말하자면 위와 같은 상황에서 공격에 이용할 만한 가능성이 있는 문자를 다른 문자로 대체 하는 것이다. 가장 대표적인 Neutralization 함수는 PHP에서 사용되는 mysql_real_escape_string()함수가 있다. 물론 쿼리 인젝션을 막는 다른 방법들도 많다. Neutralization은 일반적인 경우에 쉽게 사용될 수 있는 간단한 방법이자 예상하지 못한 공격을 사전에 차단할 수 있는 방법이기도 하다.
* * *
다음번엔 포프님이 좋아할 지도 수도 있겠다는 생각을 할만한 내용이라고 판단할 근거가 있다고 볼만한 이야기를 해보겠다. 처음에 이야기 할 때 절대로 막을 수 없다던 사회 공학적인 해킹공격에 대처... 는 안되겠지만, 만약의 경우에 회사를 보호하고 안되면 자기 자신만 이라도 보호 하는 방법에 대해 다루어 보겠다.
* 참고 사이트
CWE: Common Weakness Enumeration:
Race condition을 이용한 공격: Ahn Lab.
* 참고자료
Secure Programming Cookbook for C and C++, O'Reilly, 2003
- 입력을 받는 것을 포함하여, 복호화 하거나 인증서에서 키 값을 뽑아내는 등 필요한 정보를 메모리에 로드하는 모든 동작을 말합니다. [본문으로]
- 사실, 임시파일의 내용을 봐도 상관 없는 경우라 해도 에러 처리를 제대로 해주지 않으면 아주 훌륭한 해킹의 대상이 될 수 있습니다. 이 내용에 대해서는 아래에서 설명 하도록 하겠습니다. [본문으로]
- UNIX* 에서 사용되는 대부분의 파일 시스템은, 파일이 rm 명령 등으로 삭제가 되어도 파일의 이름과 해당 파일의 데이터 시작 위치만 삭제될 뿐 실제로 데이터가 제거 되지는 않습니다. 또한, 해당 파일을 열고 있는 프로세스가 있는 동안에는 파일이 삭제되었다 해도 다른 프로세스가 해당 파일이 사용하고 있는 데이터 영역을 할당 받지 못하도록 되어있습니다. 그렇기 때문에 열려있는 파일을 삭제 하더라도, open() 함수 등으로 해당 파일을 열 수 없을 뿐, 현재 열려있는 파일 디스크립터로 파일을 액세스 하는 것은 보장이 됩니다. [본문으로]
- 이 내용은 책으로 내면서 위의 코드가 저작권과 관련되어 귀찮은 상황을 만들어 낼거 같아 새로 확인한 내용입니다. 책에는 'Secure Programming Cookbook for C and C++' 에서 발췌한 코드는 못 들어가겠지요. 의도하지 않았지만 책과 웹 페이지의 차별점이 되어 버렸네요. [본문으로]
- Windows 7과 2008의 경우에는 Dr. Watson은 은퇴 하셨지만, 대신에 *.dmp 파일을 만들어 주는 서비스가 움직이고 있습니다. 방어자의 입장에서 더더욱 불행한 것은, 작업관리자에서 특정 프로세스의 *.dmp 파일을 만들어 준다는 것 입니다. 메모리 액세스를 막아주는 프로그램이 이 기능도 막을 수 있는지는 확인해봐야 하겠지만, 운영체제에서 제공해주는 기능이니 아마 안되지 싶네요... [본문으로]
- Arm 계열이나 다른 마이크로 프로세서의 경우에는 메모리가 충분하지 않고, 힙과 스택의 충돌이 발생할 수 있기 때문에, 힙 오버 플로우 공격으로 스택영역의 데이터를 건드리는 공격을 할 수 도 있습니다. Arm을 사용하는 가장 대표적인 게임기가 바로 닌텐도 입니다. [본문으로]
- 인텔에서 발표한 64비트 RISC 칩 입니다. i586 계열과는 호환성이 없는 CPU로 이것 때문에 AMD한테 추월 당할 뻔 했었지요 - 실제로 추월 당했다고 생각하시는 분도 있는듯 합니다. 인텔에서 이거 만들고 있는 동안 우리가 쓰고 있는 64비트 CPU를 먼저 설계한건 AMD였습니다. 그래서 자료를 찾아보면 인텔측 것 보다는 AMD측의 것이 더 많고 쉽게 찾아지지요. [본문으로]
반응형
'기타' 카테고리의 다른 글
만남의 자리! (15) | 2012.03.08 |
---|---|
헉!하는 게임을 만들기 전에 (6) | 2012.03.08 |
Chicken Chicken Chicken한 게임 설계에 Chicken Chicken Methodology에 대한 고찰 (6) | 2012.03.02 |
헉! 하는 게임 하며 살아가기 (24) | 2012.02.23 |
해커의 클라이언트 공격에 저항하기 위한 꼼수들 #1 (15) | 2012.02.22 |