Devin.KR

C 배열과 문자열 - 연속 메모리 널 종료와 안전한 복사 (C 기초 6장)

개발자 조회 1

이 장에서 배우는 것

5장에서 지역 변수가 스택 프레임의 칸이라는 것을 봤다. 배열은 그 칸을 같은 크기로 여러 개 붙여 놓은 것이고, C의 문자열은 그 배열 끝에 0 바이트 하나를 붙인 약속이다. 배열에는 길이 정보가 따로 저장되지 않는다. 이 한 가지 사실이 C에서 가장 흔한 보안 사고인 버퍼 오버플로의 뿌리다.

  • 배열 원소가 메모리에 빈틈없이 연속으로 놓이는 것을 오프셋으로 확인한다.
  • 배열을 함수에 넘기면 크기 정보가 사라지는 현상(포인터로의 변환)을 경고와 함께 본다.
  • 문자열의 널 종료, sizeofstrlen의 차이, UTF-8 한글의 바이트 수를 안다.
  • snprintf로 잘림을 감지하며 안전하게 복사하고, 배열 범위 밖 접근을 UBSan으로 잡는다.

문제 상황

사용자 ID를 8바이트 버퍼에 복사하는 코드가 있다. 테스트 ID는 모두 짧아서 문제가 없었는데, 긴 ID가 들어오자 옆 변수 값이 바뀌고 가끔 프로그램이 죽는다. 또 재고 합계를 구하는 반복문이 가끔 이상한 합계를 낸다. 배열은 자기 크기를 모르고, C는 범위를 검사하지 않는다. 그 대가를 이 장에서 눈으로 확인한다.

메모리 그림: 배열과 문자열

int stock[5] = {12, 7, 3};      (int 4바이트, 나머지는 0 으로 채워짐)

 오프셋  0     4     8     12    16    20
        ┌─────┬─────┬─────┬─────┬─────┐
 stock  │ 12  │  7  │  3  │  0  │  0  │ ?? ← stock[5] 는 배열 밖
        └─────┴─────┴─────┴─────┴─────┘
         [0]   [1]   [2]   [3]   [4]
 sizeof stock = 20, 원소 수 = 20 / 4 = 5

char animal[] = "cat";            문자열 = 문자 + 끝 표시 0
        ┌────┬────┬────┬────┐
 animal │ 63 │ 61 │ 74 │ 00 │     'c' 'a' 't' '\0'
        └────┴────┴────┴────┘
 sizeof = 4 (배열 크기),  strlen = 3 (0 앞까지 센 글자 수)

char hangul[] = "한글";           UTF-8: 한 글자 3바이트
        ┌────┬────┬────┬────┬────┬────┬────┐
        │ ed │ 95 │ 9c │ ea │ b8 │ 80 │ 00 │
        └────┴────┴────┴────┴────┴────┴────┘
          ──── 한 ────   ──── 글 ────

int stock[5] 는 4바이트 칸 다섯 개가 붙은 20바이트다. stock[5] 는 배열 바로 뒤의 남의 메모리이며 C 는 범위를 검사하지 않는다.

int stock[5] 는 4바이트 칸 다섯 개가 붙은 20바이트다. stock[5] 는 배열 바로 뒤의 남의 메모리이며 C 는 범위를 검사하지 않는다.

"cat" 은 글자 3개와 0 바이트로 4바이트다. "한글" 은 UTF-8 로 글자마다 3바이트라 strlen 이 6 이다.

"cat" 은 글자 3개와 0 바이트로 4바이트다. "한글" 은 UTF-8 로 글자마다 3바이트라 strlen 이 6 이다.

완성 코드

배열의 배치와 합계

#include <stdio.h>
#include <stddef.h>

static int sum(const int values[], size_t count) {
    int total = 0;
    for (size_t i = 0; i < count; i++) {
        total += values[i];
    }
    return total;
}

int main(void) {
    int stock[5] = {12, 7, 3};
    size_t count = sizeof stock / sizeof stock[0];

    printf("sizeof stock = %zu, 원소 수 = %zu\n", sizeof stock, count);
    for (size_t i = 0; i < count; i++) {
        ptrdiff_t offset = (char *)&stock[i] - (char *)&stock[0];
        printf("stock[%zu] = %2d  (시작에서 %td바이트)\n", i, stock[i], offset);
    }
    printf("합계 = %d\n", sum(stock, count));
    return 0;
}

함수로 넘긴 배열의 sizeof

#include <stdio.h>

static void show(int values[5]) {
    printf("함수 안 sizeof values = %zu\n", sizeof values);
}

int main(void) {
    int stock[5] = {12, 7, 3, 0, 9};
    printf("main 안 sizeof stock   = %zu\n", sizeof stock);
    show(stock);
    return 0;
}

문자열의 바이트

#include <stdio.h>
#include <string.h>

static void dump(const char *label, const char *s, size_t size) {
    printf("%-8s sizeof=%zu strlen=%zu 바이트:", label, size, strlen(s));
    for (size_t i = 0; i < size; i++) {
        printf(" %02x", (unsigned char)s[i]);
    }
    printf("\n");
}

int main(void) {
    char animal[] = "cat";
    char name[8] = "kim";
    char hangul[] = "한글";

    dump("animal", animal, sizeof animal);
    dump("name", name, sizeof name);
    dump("hangul", hangul, sizeof hangul);

    name[1] = '\0';
    printf("name[1] 에 0 을 넣은 뒤: \"%s\" (strlen=%zu)\n", name, strlen(name));
    return 0;
}

잘림을 감지하는 복사

#include <stdio.h>
#include <string.h>

int main(void) {
    const char *input = "embedded-engineer";
    char id[8];

    int needed = snprintf(id, sizeof id, "%s", input);
    printf("snprintf: \"%s\" (필요한 길이 %d, 버퍼 %zu)\n", id, needed, sizeof id);
    if (needed >= (int)sizeof id) {
        printf("잘렸다 -> 거절하거나 더 큰 버퍼를 쓴다\n");
    }

    char code[4];
    strncpy(code, "ABCD", sizeof code);
    printf("strncpy 뒤 code[3] = '%c', 널 종료 여부 = %s\n",
           code[3], memchr(code, '\0', sizeof code) ? "있음" : "없음");
    return 0;
}

한 칸 넘치는 반복문

#include <stdio.h>

int main(void) {
    int stock[5] = {12, 7, 3, 0, 9};
    int total = 0;
    for (int i = 0; i <= 5; i++) {
        total += stock[i];
    }
    printf("합계 = %d\n", total);
    return 0;
}

줄별 해설

  • int stock[5] = {12, 7, 3}; — 초기값을 일부만 주면 나머지 원소는 0이 된다. 초기값을 아예 주지 않은 지역 배열은 쓰레기 값을 가진다.
  • (char *)&stock[i] - (char *)&stock[0] — 두 원소 주소를 바이트 단위 포인터로 바꿔 빼면 오프셋이 나온다. 같은 배열 안의 주소끼리 빼는 것은 표준이 허용한다. 결과형은 ptrdiff_t이고 %td로 찍는다.
  • static int sum(const int values[], size_t count) — 매개변수 int values[]는 사실 const int *values와 같다. 배열은 함수로 넘어가는 순간 첫 원소의 주소로 바뀌므로, 원소 수를 따로 넘겨야 한다. C 표준 라이브러리 함수들이 거의 다 "포인터 + 길이" 쌍을 받는 이유다.
  • sizeof values(decay.c) — 매개변수에 [5]라고 적어도 포인터의 크기(이 기계에서 8)가 나온다. clang이 -Wsizeof-array-argument로 경고한다.
  • char name[8] = "kim"; — 8칸 중 앞 3칸에 글자, 넷째 칸에 0, 나머지도 0으로 채워진다. strlen은 첫 0 바이트까지만 세므로 3이다.
  • name[1] = '\0'; — 중간에 0을 넣으면 문자열은 거기서 끝난다. 뒤의 바이트는 그대로 남아 있지만 문자열 함수는 보지 않는다.
  • snprintf(id, sizeof id, "%s", input) — 버퍼 크기를 넘지 않게 쓰고 항상 0으로 끝낸다. 반환값은 "공간이 충분했다면 썼을 글자 수"이므로, 버퍼 크기 이상이면 잘렸다는 뜻이다. 잘린 ID를 그대로 쓰면 다른 사용자와 겹칠 수 있으니 잘림은 오류로 처리한다.
  • strncpy(code, "ABCD", sizeof code) — 원본이 버퍼보다 길거나 같으면 0을 붙이지 않는다. 이름과 달리 안전한 문자열 복사 함수가 아니다. memchr로 0이 없음을 확인했다.
  • for (int i = 0; i <= 5; i++)(bounds.c) — <= 때문에 stock[5], 즉 배열 바로 뒤 4바이트를 읽는다. 컴파일러는 이 반복문을 경고하지 않는다(출력 없음).

실제 실행 결과

$ ./arrays
sizeof stock = 20, 원소 수 = 5
stock[0] = 12  (시작에서 0바이트)
stock[1] =  7  (시작에서 4바이트)
stock[2] =  3  (시작에서 8바이트)
stock[3] =  0  (시작에서 12바이트)
stock[4] =  0  (시작에서 16바이트)
합계 = 22

원소가 정확히 4바이트 간격으로 붙어 있다. 초기값을 주지 않은 stock[3], stock[4]는 0이다.

$ clang -std=c17 -Wall -Wextra decay.c -o decay
decay.c:4:55: warning: sizeof on array function parameter will return size of 'int *' instead of 'int[5]' [-Wsizeof-array-argument]
    4 |     printf("함수 안 sizeof values = %zu\n", sizeof values);
      |                                                    ^
decay.c:3:22: note: declared here
    3 | static void show(int values[5]) {
      |                      ^
1 warning generated.
$ ./decay
main 안 sizeof stock   = 20
함수 안 sizeof values = 8
$ ./strings
animal   sizeof=4 strlen=3 바이트: 63 61 74 00
name     sizeof=8 strlen=3 바이트: 6b 69 6d 00 00 00 00 00
hangul   sizeof=7 strlen=6 바이트: ed 95 9c ea b8 80 00
name[1] 에 0 을 넣은 뒤: "k" (strlen=1)
strings.c 실행 결과: 배열 크기와 문자열 길이
배열sizeofstrlen바이트
animal4363 61 74 00
name836b 69 6d 00 00 00 00 00
hangul76ed 95 9c ea b8 80 00

"cat"은 3글자인데 4바이트를 차지한다. "한글"은 2글자지만 strlen은 6이다. C의 strlen은 글자가 아니라 바이트를 센다. 화면 글자 수를 제한하는 기능이라면 UTF-8 디코딩이 필요하다.

$ ./copy
snprintf: "embedde" (필요한 길이 17, 버퍼 8)
잘렸다 -> 거절하거나 더 큰 버퍼를 쓴다
strncpy 뒤 code[3] = 'D', 널 종료 여부 = 없음

범위 밖 접근을 UBSan으로 잡기

$ clang -std=c17 -Wall -Wextra bounds.c -o bounds
$ clang -std=c17 -Wall -Wextra -g -fsanitize=undefined -fno-sanitize-recover=bounds bounds.c -o bounds_ub && ./bounds_ub
bounds.c:7:18: runtime error: index 5 out of bounds for type 'int[5]'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior bounds.c:7:18 
/bin/sh: line 1: <pid> Abort trap: 6           ./bounds_ub

-fsanitize=undefined에 포함된 bounds 검사가 크기를 아는 배열(int[5])의 인덱스 5를 잡았다. 여기서는 -fno-sanitize-recover=bounds를 붙여 첫 오류에서 바로 멈추게 했다(셸이 "Abort trap"을 알리고, 번호 자리의 <pid>는 실행마다 바뀌는 프로세스 번호다). 멈추지 않으면 배열 뒤 메모리에 우연히 있던 값이 합계에 더해져 실행마다 다른 결과가 나올 수 있다. 이 검사는 컴파일 시점에 배열 크기를 아는 경우에만 동작한다. 함수로 넘어가 포인터가 된 배열이나 malloc한 메모리의 범위 초과는 AddressSanitizer가 필요하다(9장·12장).

한눈에 보기

문자열 복사 함수의 안전성
함수버퍼 크기를 받나널 종료 보장권장
strcpy아니오예(넘쳐도 씀)쓰지 않는다
strncpy아니오문자열 복사용으로 쓰지 않는다
snprintf기본 선택, 반환값으로 잘림 검사
memcpy길이를 직접해당 없음길이를 이미 알 때

실무에서 자주 틀리는 것

  • strcpy, strcat, sprintf, gets를 쓴다. 모두 대상 버퍼 크기를 모른다. gets는 C11에서 표준에서 아예 빠졌다. 크기를 받는 snprintf를 기본으로 쓰고 반환값으로 잘림을 확인한다.
  • 문자열 버퍼 크기를 글자 수만큼만 잡는다. "최대 8자"인 ID를 담으려면 널 문자를 위해 9바이트가 필요하다. UTF-8 한글이면 글자당 3바이트를 더 고려한다.
  • char t[3] = "cat"; — C에서는 오류도 경고도 없이 컴파일된다(널 문자를 넣을 자리가 없어 조용히 빠진다). 이 배열을 printf("%s")strlen에 넘기면 배열 밖을 읽는다. 크기를 생략하고 char t[] = "cat";으로 쓴다.
  • 함수 안에서 sizeof arr / sizeof arr[0]를 쓴다. 매개변수는 포인터이므로 엉뚱한 값이 나온다. 원소 수는 배열이 선언된 곳에서 계산해 넘긴다.
  • 문자열을 ==로 비교한다. 주소 비교가 된다. 내용 비교는 strcmp(a, b) == 0이다.

연습 문제

  1. char msg[] = "hi\n";sizeof msgstrlen(msg)는 각각 얼마인가?
  2. 최대 10바이트짜리 장치 이름을 입력받아 저장하려 한다. 버퍼 크기는 최소 얼마여야 하고, snprintf로 복사한 뒤 잘림을 어떻게 검사하는가?
  3. arrays.csum 함수 안에서 sizeof values를 찍으면 얼마가 나오는가? 왜 sumcount를 따로 받아야 하는가?

정답

  1. sizeof msg4(h, i, 줄바꿈, 널), strlen(msg)3이다. \n은 두 글자처럼 보이지만 바이트 하나(0x0a)다.
  2. 최소 11바이트(10 + 널 문자). char name[11]; int n = snprintf(name, sizeof name, "%s", input); if (n < 0 || n >= (int)sizeof name) { /* 잘림 또는 오류: 거절 */ }. snprintf는 인코딩 오류 등에서 음수를 돌려줄 수 있으므로 음수도 함께 검사한다.
  3. 포인터의 크기인 8(이 기계 기준)이 나온다. int values[] 매개변수는 int *values와 같아서 배열의 길이 정보를 전달받지 못하기 때문에, 원소 수를 호출하는 쪽이 계산해 넘겨야 한다. 크기를 적지 않은 values[] 형태에서도 clang은 같은 -Wsizeof-array-argument 경고를 낸다(검증 스크립트에서 확인). 하지만 매개변수를 처음부터 const int *values로 선언했다면 경고 없이 8이 나오므로, 함수 안에서는 원소 수를 sizeof로 구하려는 시도 자체를 하지 않는다.

다음 장에서는 이 장에서 계속 등장한 "주소"를 직접 변수에 담아 다루는 포인터를 배운다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.