Devin.KR

안전한 문자열 처리 - 길이를 함께 다루는 습관

개발자KR 조회 10

이 장에서 배우는 것

문자열은 배열과 포인터의 조합으로 이루어진다. 포인터 기초를 다룬 기본서에서 문자열의 끝을 나타내는 널 종료 문자(\0)의 역할을 배웠다. 이 널 문자에 의존하는 C 언어의 전통적인 방식은 메모리 경계를 스스로 검사하지 않는다. 앞 장에서 다룬 정의되지 않은 동작 중 상당수가 배열의 크기를 무시한 문자열 처리에서 발생한다. 이 장에서는 배열의 길이 정보를 문자열 조작 함수와 함께 다루는 방법을 알아본다.

  • strcpy, sprintf 등 경계 검사가 없는 함수의 문제점을 이해한다.
  • snprintf의 반환값을 활용해 문자열 잘림을 감지한다.
  • strncpy가 가진 널 종료 문자의 함정을 피한다.
  • 길이와 버퍼를 묶어 관리하는 구조체 패턴을 작성한다.
  • fgets를 사용하여 안전하게 한 줄을 입력받고 처리한다.

문제 상황

작은 도서관 대출 관리 프로그램에서 회원의 이름과 대출할 책의 제목을 합쳐 로그 메시지를 만든다고 가정한다. 이름과 책 제목의 길이는 프로그램이 실행되어 사용자가 입력하기 전까지 알 수 없다. 만약 64바이트로 고정된 로그 버퍼에 두 문자열을 아무런 검사 없이 복사해 넣으면, 입력된 이름과 제목의 합이 64바이트를 넘을 때 버퍼 오버플로(buffer overflow)가 발생한다. 이는 인접한 메모리 영역을 덮어써서 프로그램을 예측 불가능한 상태로 만들고, 보안 취약점의 원인이 된다.

전통적인 문자열 함수의 위험성

strcpy, strcat, sprintf 같은 함수는 목적지 버퍼의 크기를 인자로 받지 않는다. 오직 소스 문자열에서 널 종료 문자를 만날 때까지 메모리 복사를 계속한다.

작은 버퍼에 긴 문자열을 복사할 때 인접 메모리를 덮어쓰는 버퍼 오버플로 과정

버퍼를 넘어선 기록은 인접한 변수의 값을 바꾸거나 함수 호출 흐름을 제어하는 스택 메모리를 망가뜨릴 수 있다. 이러한 함수들은 목적지 버퍼가 충분히 크다고 컴파일 시점에 확신할 수 있을 때만 제한적으로 사용해야 한다. 실무에서는 외부 입력이나 변경되는 요구사항 때문에 이를 항상 보장하기 어렵다. 따라서 문자열을 복사하거나 조립할 때는 버퍼의 최대 크기를 함께 전달하는 함수로 대체하는 것이 원칙이다.

잘림을 감지하는 복사

sprintf의 대안인 snprintf는 목적지 버퍼의 최대 크기를 인자로 받아 그 크기를 넘는 기록을 방지한다. 명시한 크기 안에서 마지막 바이트에는 항상 널 문자를 기록하므로 버퍼 오버플로를 막으면서 문자열을 다룰 수 있다. 하지만 문자열이 버퍼 크기보다 길어서 끝부분이 잘려나가는 문제(truncation)는 여전히 남는다.

다행히 snprintf는 버퍼 크기가 충분했다면 기록했을 총 문자열의 길이(널 문자 제외)를 반환한다. 즉, 반환값이 전달한 버퍼 크기보다 크거나 같다면 버퍼가 모자라 문자열이 잘렸다는 뜻이다. 이를 통해 문맥에 맞춰 오류를 반환하거나 동적으로 더 큰 버퍼를 할당하는 후속 처리가 가능하다.

strncpy는 널 문자를 보장하지 않지만 snprintf는 널 문자를 보장하면서 잘림을 처리한다

strncpy는 이름 때문에 strcpy의 안전한 대안으로 오해받곤 한다. 이 함수는 원래 널 문자가 필요 없는 고정 크기 레코드를 다루기 위해 만들어졌다. 소스 문자열의 길이가 지정한 크기와 같거나 길면 목적지 버퍼에 널 문자를 붙이지 않는다. 널 문자가 없는 배열을 printf나 strlen에 전달하면 배열 밖의 메모리를 계속 읽게 되어 프로그램이 비정상 종료될 수 있다.

길이를 포함하는 문자열 구조체

C 언어 문자열의 근본적인 단점은 메모리 주소(포인터)만 전달할 뿐 크기 정보를 언어 차원에서 유지하지 않는다는 점이다. 이를 해결하기 위해 문자열 배열과 해당 문자열의 현재 길이를 하나의 구조체로 묶어 관리하는 방식을 자주 쓴다.

문자열 길이 변수와 데이터 배열이 연속으로 배치된 구조체 메모리 레이아웃

이 패턴을 사용하면 strlen을 매번 호출할 필요 없이 구조체의 길이 변수를 확인하면 되므로 불필요한 연산이 줄어든다. 또한 버퍼의 최대 크기를 sizeof 연산자로 명확히 알 수 있어 다른 함수로 문자열을 전달할 때 더 안전하게 다룰 수 있다.

입력 버퍼 처리

사용자 입력을 받을 때 scanf("%s", buf)를 사용하는 것은 strcpy만큼 위험하다. 공백을 기준으로 문자열을 끊을 뿐 목적지 버퍼 크기를 확인하지 않기 때문이다. 입력 문자열 서식에 %63s처럼 길이를 지정할 수 있지만, 매크로나 변수로 길이를 동적으로 지정하기 까다롭다.

표준 입력에서 한 줄을 통째로 읽을 때는 fgets를 사용하는 것이 안전하다. fgets는 인자로 전달된 버퍼 크기보다 하나 적은 문자까지만 읽고 널 문자를 붙인다. 주의할 점은 버퍼 공간이 충분하면 사용자가 입력한 줄바꿈 문자(\n)까지 버퍼에 저장한다는 것이다. 따라서 문자열을 출력하거나 비교하기 전에 줄바꿈 문자를 찾아 제거하는 과정이 필요하다.

완성 코드

library_loan.c

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

typedef struct {
    size_t length;
    char message[128];
} LoanLog;

void strip_newline(char *str) {
    size_t len = strlen(str);
    if (len > 0 && str[len - 1] == '\n') {
        str[len - 1] = '\0';
    }
}

int create_loan_log(LoanLog *log, const char *name, const char *book) {
    int written = snprintf(log->message, sizeof(log->message), 
                           "[대출] 회원: %s, 도서: %s", name, book);

    if (written < 0) {
        return -1;
    }

    if ((size_t)written >= sizeof(log->message)) {
        log->length = sizeof(log->message) - 1;
        return 0; 
    }

    log->length = (size_t)written;
    return 1;
}

int main(void) {
    char name[32];
    char book[64];
    LoanLog log;

    printf("회원 이름: ");
    if (fgets(name, sizeof(name), stdin) == NULL) {
        return 1;
    }
    strip_newline(name);

    printf("도서 제목: ");
    if (fgets(book, sizeof(book), stdin) == NULL) {
        return 1;
    }
    strip_newline(book);

    int status = create_loan_log(&log, name, book);
    if (status == 0) {
        printf("경고: 로그 메시지가 버퍼 크기를 초과하여 잘렸습니다.\n");
    } else if (status < 0) {
        printf("오류: 로그 메시지 포맷팅에 실패했습니다.\n");
        return 1;
    }

    printf("생성된 로그: %s\n", log.message);
    printf("기록된 길이: %zu 바이트\n", log.length);

    return 0;
}

줄별 해설

strip_newline 함수는 문자열 끝을 확인하여 \n을 \0으로 덮어쓴다. fgets로 입력받은 문자열을 정제할 때 항상 쓰이는 패턴이다. create_loan_log 함수는 snprintf를 사용해 log->message 버퍼에 문자열을 조합한다. snprintf의 반환값 written이 버퍼 크기인 sizeof(log->message)와 같거나 크다면 잘림이 발생한 것이다. 이때는 안전하게 구조체의 length를 최대 길이로 맞추고 0을 반환하여 호출자에게 잘림을 알린다. 잘림이 없었다면 반환값을 그대로 구조체의 length에 저장한다.

main 함수에서는 고정 크기 배열 name과 book을 선언하고 fgets로 입력받는다. fgets의 두 번째 인자로 sizeof를 사용해 버퍼 크기를 정확히 전달하므로 입력이 길어도 오버플로가 발생하지 않는다. 반환값을 검사해 잘렸다는 경고를 출력함으로써 문제를 조기에 발견할 수 있다.

실행 결과

회원 이름: 홍길동
도서 제목: C 언어 시스템 프로그래밍 가이드
생성된 로그: [대출] 회원: 홍길동, 도서: C 언어 시스템 프로그래밍 가이드
기록된 길이: 67 바이트

실무에서 자주 틀리는 것

포인터 변수에 sizeof 사용

배열을 선언한 스코프에서는 sizeof(arr)가 전체 배열의 크기를 반환하지만, 포인터로 배열을 넘겨받은 함수 안에서는 포인터 변수 자체의 크기(주로 8바이트)를 반환한다. 버퍼 크기를 넘길 때 흔히 하는 실수다.

// 틀린 코드
void process_string(char *buf) {
    snprintf(buf, sizeof(buf), "결과: %d", 42); 
    // 버퍼 크기가 아니라 포인터 크기(8)가 전달됨
}

// 고친 코드
void process_string(char *buf, size_t buf_size) {
    snprintf(buf, buf_size, "결과: %d", 42);
}

strncpy를 안전한 문자열 복사로 간주

문자열 복사 시 strncpy를 쓰면 소스 길이가 지정한 길이보다 클 때 널 문자가 붙지 않아 출력 시 쓰레기 값이 나오거나 프로그램이 죽을 수 있다.

// 틀린 코드
char dest[5];
strncpy(dest, "123456", sizeof(dest));
printf("%s\n", dest); // 널 문자가 없어 범위를 벗어난 읽기 발생

// 고친 코드
char dest[5];
snprintf(dest, sizeof(dest), "%s", "123456");
printf("%s\n", dest); // "1234" 출력 후 정상 종료

snprintf 반환값 검사 생략

snprintf는 버퍼를 보호하지만 내용이 잘리는 것 자체를 막아주지는 않는다. 잘림이 발생했는지 검사하지 않고 후속 작업을 진행하면 데이터 유실을 모른 채 지나치게 된다.

// 틀린 코드
char path[64];
snprintf(path, sizeof(path), "/usr/local/lib/%s", filename);
FILE *f = fopen(path, "r"); // 경로가 잘렸어도 그대로 파일 열기 시도

// 고친 코드
char path[64];
int ret = snprintf(path, sizeof(path), "/usr/local/lib/%s", filename);
if (ret < 0 || (size_t)ret >= sizeof(path)) {
    // 오류 처리 로직
    return -1;
}
FILE *f = fopen(path, "r");

한눈에 보기

문자열 포맷팅 및 복사 함수 비교
함수 버퍼 크기 검사 널 종료 보장 반환값의 의미
sprintf 없음 (위험) 보장함 기록된 문자 수
snprintf 있음 보장함 버퍼가 충분했다면 기록했을 총 문자 수
strcpy 없음 (위험) 보장함 목적지 버퍼 포인터
strncpy 있음 보장 안 함 목적지 버퍼 포인터

입력 함수 비교
함수 크기 검사 공백 처리 줄바꿈 문자(\n) 처리
gets 없음 (사용 불가) 포함하여 읽음 읽어서 버림
scanf("%s") 기본적으로 없음 공백에서 읽기 중단 입력 버퍼에 남김
fgets 인자로 전달 포함하여 읽음 공간이 남으면 포함함

연습 문제

  1. 다음 중 목적지 버퍼에 널 문자가 들어가는 것을 항상 보장하는 함수만을 고르시오.
    (A) strcpy (B) strncpy (C) snprintf
  2. 10바이트 크기의 문자 배열 buf에 snprintf(buf, sizeof(buf), "%s", "Hello World");를 실행했다. 함수가 반환하는 정수 값은 얼마인가?
  3. fgets로 문자열을 입력받은 직후, 버퍼에 담긴 문자열의 길이가 0보다 클 때 마지막 문자가 \n인지 검사하여 \0으로 지우는 이유를 서술하시오.

정답과 해설

1. 정답: (A), (C). strcpy는 목적지 버퍼 크기를 무시하고 오버플로를 일으킬 수 있으나 소스의 널 문자는 복사한다. strncpy는 버퍼 크기를 넘어서면 널 문자를 추가하지 않는다. snprintf는 지정한 크기 안에서 반드시 널 문자를 추가한다.

2. 정답: 11. "Hello World"는 11글자이며 snprintf는 버퍼의 크기에 상관없이 널 문자를 제외하고 원래 기록했어야 할 온전한 문자열의 길이를 반환한다. 반환값이 10(버퍼 크기) 이상이므로 잘림이 발생했음을 판단할 수 있다.

3. 해설: fgets는 인자로 주어진 버퍼 크기에 여유가 있으면 사용자가 엔터 키를 누를 때 발생한 줄바꿈 문자(\n)를 버퍼에 함께 저장하기 때문이다. 문자열 비교나 파일 이름 등 외부 리소스 참조에 사용하려면 의도하지 않은 줄바꿈 문자를 제거해야 논리적 오류를 막을 수 있다.

댓글 0

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

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