C언어 문자열을 가능한한 피해야하는 이유

C++에서  C 언어 스타일로 — 메모리 할당만 빼고 — 문자열을 복사하는 코드다. 여기에는 버그가 숨어있다.

void some_function( const char* str )
{
    int len = ::strlen( str ); // str은 안전한 문자열임
    char* pBuffer = new char[ len ];
    ::memset( pBuffer, 0, len );
    ::strcpy_s( pBuffer, len, str ); // 원본 코드는 ::memcpy_s 였음
    // blah blah blah. uses pBuffer

어제 코드베이스에 같이 일하는 팀과 연동하기 위해 받아온 리소스 로더 코드를 넣었다. 위의 코드는 거기에 있던 유틸리티 함수 격인 코드에 있던 일부분 — 을 버그있는 부분만 축약한 — 이다. (앞 뒤로 훨씬 많은 코드가 있다)

코드 베이스에 통합하고나서 리소스를 읽어오는 코드를 부르는 유닛 테스트를 넣었다[1] . 그러고나니 유닛 테스트를 하다가 랜덤하게 프로세스가 죽는다. 처음에는 읽어들인 데이터를 잘못 처리하고 있나 의심하고, 죽는 테스트만 남겨봤는데 또 문제가 없다(…).

반복해본 결과 fixture[2] 부분의 오류라는 것을 알게 되었다 — 거기서 리소스를 읽고/지우고를 수행했다.

일단 리소스 로더 없이 커밋하고, 오늘 오후에 저 부분을 테스트하기 시작했는데, 디버거를 붙이면 정상 동작하고[3] 없으면 죽어서 일단 재연에 중점을 두고 재연에는 성공. 리소스를 읽고/지우고를 수십~수천번 반복하다보면 죽더라.

그래서 버그가 있다고 의심되는 부분에서 빼면 버그가 안나는 코드를 차근차근 줄여서 — 소위 델타 디버깅;delta debugging — 결국 위 코드가 포함된 리소스 길이 파서의 유틸리티 함수에서 오류가 나는 것을 확인.

실제로 저 코드는 “\0″를 복사하지 않는 버그가 있다. strcpy_s는 주어진 버퍼 길이 이상으로는 복사하지 않는 MS 확장함수인데[4] \0를 복사 안하는 만행(?)을 저지르게 된다…  그리고 \0 이 없는 C 문자열을 각종 C 문자열 함수를 써서 조작해댔으니 힙 공간이 어떻게 오염될지는 Orz 게다가 이 동작은 이전 힙 공간의 메모리 상태에 완전히 의존적이기 때문에 거의 종잡을 수 없는 동작을(…).

여담으로, 위 코드가 strcpy를 썼다면,  흔히 “끝을 지나서 쓰는 문제” 인데, native code 언어라고 불리우는 언어에서 가장 악명높은 문제를 일으켰을 것이다[5]. C 언어 문자열의 가장 큰 문제 중 하나가 “문자열이 NULL로 끝난다”라는 전제 조건이 있기 때문에 길이단위 k인 문자열을 저장하기 위해서는 실제로 k+1 단위길이의 공간이 필요하다. 저 위에서는 k 만큼만 할당하게 되고, 마지막 문자(=널문자)가 할당되지 않아야할 힙공간을 덮어 쓰게된다.

여튼, 힙 공간은 보통 약간의 패딩/태깅이 있고, 그 덕분에 위 버그가 잘 안드러나다가(……..), 결국엔 펑하고 터져서 “왜 여기서 죽냐” 싶은 버그가 발생; 사실 DevPartner나 valgrind가 있으면 쉽게 잡힐 문제였는데 64bit OS에서 DevPartner는 동작하지 않고 / Valgrind는 linux 용 소프트웨어라서 Orz

위에서 생긴 버그처럼 “직관적으로 인식되지 않는 버그”를 쉽게 만들 수 있기 때문에, 정말 번개 같은 속도가 필요한게 아니면 C 문자열은 제발 안 써줬으면 좋겠다. std::string / std::wstring 같이 귀여운(?) 녀석들을 놔두고 이게 무슨 삽질이야 ㅠㅠ[6]

최종적으론 len = strlen(str) 대신에 len = strlen(str) + 1로 고쳐놓고 퇴근했다 orz

결론: 문자열 처리 성능이 말도 안되게 중요한게 아니면 C++의 std::string / std::wstring을 쓰자.

+ 성능이 중요하다면 정말 중요한지 다시 생각하고, 가능하면 C++의 문자열을 쓰자(…).

  1. 이 간단한 추가(?)로 버그를 찾을 수 있게 된건 기쁘긴하다 []
  2. 테스트 묶음(suite)을 실행될 때 개별 테스트 시작 전에 초기화 되고 / 개별 테스트 완료 후에 폐기되는 데이터들 []
  3. MS VisualStudio는 디버거가 있을 때와 없을 대 heap 할당 방식이 다르다 []
  4. 개인적으로는 그냥 strncpy를 쓰던가 std::string을 쓰는걸 추천 []
  5. 그래서 C#, Java, Python 등의 언어가 각광을 받는 것이기도 하고 []
  6. 심지어 이 코드 내가 입사하기 전에 나온 코드던데 Orz []

Author: rein

나는 ...

15 thoughts on “C언어 문자열을 가능한한 피해야하는 이유”

  1. 아 저도 예전에 일할때 비슷한 상황으로 고생한적 있었어요.
    무슨 검색엔진 연동소스를 작성했었는데, 검색몇번하면 검색서버가 죽어버리는 쓰라린 체험을 했었죠.
    나중에 알고보니 strncpy 에서 길이 잘못줘서 그랬던거였음 ㅜㅜ

  2. 전 그런 일 몇 번 겪고나니 아예 C에서 문자열 다루는걸 피하게 되더라고요; C문자열은 강제성이 너무 없어서 혼자 집중한다고 되는게 아님;;;

    요즘은 C++로 코어짜고, 한 두번 실행될 부분 — 데이터 파싱 같은 것 — 은 아예 lua나 python 임베딩 시켜서 처리하고 있어요(…)

  3. valgrind 좋죠. Win32/64 개발하면서 정적/제어된 실행 환경이 필요했는데, DevPartner는 64bit에서 안 돌아서 쓰질 못하고 있어요(…)

    근데 항상 좋은게 아닌게 debian 개발자들이 openssh에 valgrind 돌리고는 어째서인지(…) 임의성;randomness를 부여해야할 배열 하나를 통채로 초기화(?)해버려서, 보안 문제를 일으킨 일도 있음….(…)

  4. 흠… 솔직히 C 문자열 길이 + 1 만큼 할당해야하는 건 기본인데;; 아쉽군요.

    MSDN을 보니

    The strcpy_s function copies the contents in the address of strSource, including the terminating null character, to the location specified by strDestination.

    널을 포함해서 카피한다고 나와있는데요. 그리고 이상하게 위의 코드를 그대로 돌려보면 디버그 모드에서 strcpy_s를 실행하면 버퍼가 부족하다고 assertion이 뜨네요. *_s 함수군들이 어설션이 풍부해서 사실 버퍼 부족 문제는 디버그 시간에 잡힐 수도 있었을 것 같은데 좀 의아하네요. new/malloc이 non-debug 버전으로 링크되었나 싶기도 하네요.

    그리고 힙 영역이 망가지는 건 밸그린드, 데브파트너가 특별히 똑똑하게 하는 건 없구요. (설마 메모리 억세스를 다 디텍트해서 바운더리 넘는가를 체크할 수는 없겠죠. 현재의 하드웨어로는 거의 불가능합니다.) 디버그 모드의 힙 라이브러리처럼 그냥 canary value가 망가진 것 보고 디텍트 하는 정도입니다. 그래서 힙 관련 에러는 디버그 힙 라이브러리로도 보통 충분히 검출은 되죠.

  5. 제가 코딩할 때는 습관적으로 +1을 하는데, 남의 코드 봐서는 어디가 문제인지 모르겠군요 허허 …

  6. object / 그렇죠;; 저라면 C++에선 저거 안 쓰고 C라면 맘편히(?) strdup을 -_-;;

    네 저도 댓글 보고 이상해서 고민하면서 출근했는데, 고치기 전 코드를 보니 strcpy_s가 아니라 memcpy_s네요. NULL만 빼놓고 복사한건 마찬가지 -_-;;;

    데브파트너는 안써봐서 — Valgrind 비슷한거 없냐고 했더니 그거 라이센스를 줘서; 근데 Windows Server 2003/x64 라고 설치가 안되는 문제가 — 모르겠고, Valgrind는 MemCheck로 돌리면 아예 VM용 컴파일을 하고 VM 위에서 메모리 접근을 계산해서 스택 영역은 몰라도(이건 아마도 canary 값 검사) 힙 영역은 상당히 잘 잡아내던데요;;;

    피앙 / 난 습관이고 자시고 문자열 길이를 내가 계산 안한다. std::string/wstring 쓰던가 strdup을 쓴단다[…]

  7. 인간이 바로 이해하기 힘든 식으로 (또는 두번 생각해야 하는 식으로) 되어있다는게 문제겠죠.. 자연어로 프로그래밍이 되는 그날까지[?]

  8. VS에서 디버거 붙일 때랑 안붙일 때랑 heap 할당 방식이 다르다는 걸 몰랐네요. 앞으로 디버거 붙이면 안생기는 버그를 해결할 때 참고할 것이 늘었군요. ㅎ

  9. 홍민희 / 저도(…)

    ipkn / 자연어의 복잡도가 너무 커서; 근데 난 아직은(?) python 정도만되어도 편하게 써서 ㅎㅎ;

  10. memcpy_s…. 네 역시나 그랬군요. 저 코드 짠 친구를 잠시 불러서 약간의 담화를 나누시는 것이 좋을 듯 합니다.

    그리고 제가 잘못 알았네요. Valgrind의 방식도 역시나 인스트루멘테이션해서 메모리 주소 값을 계속 측정하는 거네요. 하기야 Valgrind의 slowdown이 x30 이상이니 그런 식으로 해야죠.

    이건 사실 만들기가 어렵지 않습니다. 아주 심심하시다면 PIN이라는 dynamic binary instrumentation 툴로 뚝딱(!) 만들어 낼 수 있습니다. PIN을 이용하면 메모리에 접근하는 명령어를 가로채서 그 주소를 가져올 수 있죠. 스택 영역은 이제 VC++ 컴파일러가 넣어주는 코드만으로도 충분히 버그를 잡고 힙은 정확히 하려면 말씀대로 Valgrind 방식을 써야 합니다. 대신 무진장 느립니다. 보통 x5 이상 늦어지면 고객들로부터 욕이 튀어나오죠. 그래서 multithread 버그 검출 (재현이 힘든 버그들) 프로그램 등이 실용화되기 매우 힘듭니다. 보통 50배 이상 느려지거든요. (모든 힙 영역 메모리 엑세스를 검출하면 한 30~60

    VC++에서 디버그 모드로 컴파일 할 때는 new/malloc이 다른 버전으로 치환이 되어 메모리릭 추가 코드도 넣어주고 0xbaadf00d와 같은 값을 넣어서 체크하기도 하죠. HeapAlloc Win32 API 자체는 변함이 없습니다. 한 때 제 전공 분야라 답변이 길어졌습니다. -_-

  11. object / PIN API를 보니 “뚝딱”은 안될것같지만 “조물조물” 만들어 낼 수는 있을 것 같네요 ~_~

    baadf00d라거나 deadbeef라거나 0xcdcdcdcd라거나(…) 메모리 디버깅하다보면 가끔 보게되는 녀석들이네요;

    근데 x5보다 느리면 문제가되긴하나보네요; 학부다닐때는 한 30배 느려져도 자고 일어나면 끝나겠지를 했는데 /먼산

Leave a Reply