65 문자열과 기억 — <string.h>
먼저 알아야 할 것
돌아보기
42장에서 C 문자열은 “NUL을 만날 때까지”라 길이를 매번 세어야 하고, 62장에서는 크기를 받지 않는 함수가 첫 번째 고질병이라 했다. 그러면 strcpy 대신 strncpy를 쓰면 그 병이 낫는가?
답. 낫지 않는다. 이름이 주는 인상과 달리 strncpy는 안전한 복사 함수로 설계된 것이 아니다. 원래 목적은 옛 유닉스의 고정 길이 필드(예: 14바이트 디렉터리 항목 이름)를 채우는 것이었다 — 그래서 남는 자리를 전부 0으로 채우고, 자리가 모자라면 NUL을 붙이지 않는다. 두 성질 모두 “문자열 복사”의 기대와 어긋난다. 이 장의 첫 예제가 그것을 눈으로 보여 준다.
이 장의 필요성과 맥락
strncpy 가 왜 위험한가」는 계약이라는 낱말 없이는 「조심해서 쓰라」로 끝나 버린다.이 장이 끝나면
strncpy, 겹친 영역을 건드리는 복사, 원본을 부수며 상태를 숨기는 strtok까지 — 42장에서 배운 문자열의 위험이 여기서 함수 단위로 구체화된다. 표준 밖의 대안(strlcpy 계열)의 실제 이식성도 함께 본다.이 장에서 답할 질문
memcmp는 암호 비교에도 위험하다고 들었는데?
65.1 strncpy의 진실
examples/ch65/strncpy.c
#include <stdio.h>
#include <string.h>
static void dump(const char *label, const char *buf, size_t n)
{
printf("%-14s", label);
for (size_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)buf[i];
if (c == 0) printf(" \\0");
else if (c >= 32) printf(" %c", c);
else printf(" %02x", c);
}
printf("\n");
}
/* 컴파일러가 원본 길이를 보지 못하도록 함수 경계 뒤에 둔다.
(그러지 않으면 gcc 가 -Wstringop-truncation 으로 잡아 준다) */
static void copy_into(char *dst, size_t cap, const char *src)
{
strncpy(dst, src, cap);
}
int main(void)
{
/* ① 남는 자리를 전부 0 으로 채운다 — 큰 버퍼에서는 비싸다 */
char pad[10];
memset(pad, 'X', sizeof pad);
copy_into(pad, sizeof pad, "abc");
dump("short source:", pad, sizeof pad);
/* ② 딱 맞거나 넘치면 NUL 을 붙이지 않는다 — 문자열이 아니게 된다 */
char tight[4];
memset(tight, 'X', sizeof tight);
copy_into(tight, sizeof tight, "abcd");
dump("an exact fit:", tight, sizeof tight);
printf(" there is no NUL - printing this with %%s is outside the contract\n");
/* ③ 안전하게 쓰려면 마지막 칸을 손으로 닫는다 */
char safe[4];
copy_into(safe, sizeof safe - 1, "abcd");
safe[sizeof safe - 1] = '\0';
printf("terminated by hand: [%s]\n", safe);
/* ④ 잘렸는지 알려면 결국 길이를 따로 재야 한다 */
const char *src = "abcd";
printf("truncated? : %s\n", strlen(src) >= sizeof safe ? "yes, it was cut" : "no, it is whole");
return 0;
}
실행 결과
short source: a b c \0 \0 \0 \0 \0 \0 \0
an exact fit: a b c d
there is no NUL - printing this with %s is outside the contract
terminated by hand: [abc]
truncated? : yes, it was cut
두 가지가 드러난다.
첫째, 남는 자리를 전부 0으로 채운다. 10바이트 버퍼에 3글자를 넣으면 나머지 일곱 칸을 모두 0으로 쓴다. 버퍼가 클수록 낭비가 커진다.
둘째, 딱 맞거나 넘치면 NUL을 붙이지 않는다. 4바이트 버퍼에 abcd를 넣으면 NUL 자리가 없어 문자열이 아닌 바이트 배열이 남는다. 이것을 %s로 찍거나 strlen에 넘기면 버퍼 밖을 읽는다 — “안전한 함수를 썼는데 터졌다”의 전형적 경로다.
gcc는 이 실수를 실제로 잡아 준다. 예제를 처음 쓸 때 받은 진단이다.
error: ‘strncpy’ output truncated before terminating nul copying 4 bytes
from a string of the same length [-Werror=stringop-truncation]다만 62장에서 본 대로 컴파일러는 자기가 볼 수 있는 것만 잡는다. 원본 길이가 실행 중에 정해지면 이 경고는 나오지 않는다.
반례. strncpy로 “안전하게” 복사하기
char dst[32];
strncpy(dst, src, sizeof dst); /* NUL 이 없을 수 있다 */
printf("%s\n", dst); /* 버퍼 밖을 읽는다 */최소한 이렇게 고쳐야 한다.
strncpy(dst, src, sizeof dst - 1);
dst[sizeof dst - 1] = '\0'; /* 손으로 닫는다 */
if (strlen(src) >= sizeof dst) { /* 잘렸다 — 처리 */ }세 줄이 필요하고, 그중 하나라도 빠뜨리면 사고다. 이 함수가 “쓰기 어려운 안전 장치”라는 평가를 받는 이유다.
65.2 그러면 무엇을 쓰는가
표준 안에서 문자열을 안전하게 이어 붙이는 가장 실용적인 도구는 사실 <stdio.h>에 있다.
int need = snprintf(dst, sizeof dst, "%s", src);
if (need < 0 || (size_t)need >= sizeof dst) { /* 잘렸다 */ }느리다는 비판이 있지만(서식 해석 비용), 경계를 지키면서 잘림을 알 수 있는 표준 함수는 이것뿐이다.
| 함수 | 지위 | 경계 | 잘림을 알 수 있나 |
|---|---|---|---|
strcpy·strcat | 표준 | 없음 | — |
strncpy | 표준 | 있음 | 아니오(직접 재야 함) |
strncat | 표준 | 있음(하지만 인자가 남은 자리) | 아니오 |
snprintf | 표준 | 있음 | 예(반환값) |
strlcpy·strlcat | BSD 계열, C23 부속서 | 있음 | 예(반환값) |
strcpy_s·strcat_s | C11 부속서 K(선택) | 있음 | 예(오류 반환) |
표 65.1
strncat의 인자는 특히 함정이다 — 두 번째 인자가 목적지의 크기가 아니라 추가로 쓸 수 있는 바이트 수다. strncat(dst, src, sizeof dst)는 거의 언제나 틀렸고, sizeof dst - strlen(dst) - 1이 맞다.
실제 사례. strlcpy는 왜 표준이 아니었나
OpenBSD가 1998년에 strlcpy·strlcat을 내놓았다. 목적지 크기를 받고, 언제나 NUL로 닫고, 원본의 길이를 돌려주어 잘림을 알 수 있게 한 함수다. BSD 계열과 여러 라이브러리로 퍼졌지만 glibc는 오랫동안 채택을 거부했다 — “잘림을 조용히 허용하는 API는 문제를 옮길 뿐”이라는 반론이었다.
그래서 리눅스에서 strlcpy를 쓴 코드는 오랫동안 이식되지 않았고, 프로젝트 마다 자기 판을 하나씩 갖게 됐다. 2023년 glibc 2.38이 마침내 이를 추가했고 C23도 비슷한 취지의 함수를 부속서로 들였지만, “쓸 수 있다”고 말하려면 대상 플랫폼의 판을 확인해야 하는 상태는 여전하다. 표준과 현실 사이의 간극을 보여 주는 대표 사례다.
65.3 겹치는 영역 — memcpy와 memmove
examples/ch65/overlap.c
#include <stdio.h>
#include <string.h>
/* memcpy 는 겹치는 영역을 허용하지 않는다. memmove 는 허용한다. */
static void show(const char *label, const char *s)
{
printf("%-22s [%s]\n", label, s);
}
int main(void)
{
char buf[32];
/* 겹치지 않는 복사 — memcpy 가 맞는 자리 */
strcpy(buf, "abcdefgh");
char other[32];
memcpy(other, buf, strlen(buf) + 1);
show("no overlap (memcpy):", other);
/* 한 칸 밀기 — 원본과 목적지가 겹친다. memmove 를 쓴다 */
strcpy(buf, "abcdefgh");
memmove(buf + 1, buf, 7); /* 뒤로 한 칸 밀기 */
buf[8] = '\0';
show("shifting one slot up (memmove):", buf);
/* 앞으로 당기기도 겹친다 */
strcpy(buf, "abcdefgh");
memmove(buf, buf + 2, 6 + 1); /* 두 칸 당기기 */
show("pulling two slots down (memmove):", buf);
/* strtok 은 원본을 부순다 — 그리고 상태를 함수 안에 숨긴다 */
char line[] = "name,age,city";
printf("before strtok : [%s]\n", line);
for (char *t = strtok(line, ","); t; t = strtok(NULL, ","))
printf(" token: [%s]\n", t);
printf("after strtok : [%s] <- the original was cut apart\n", line);
return 0;
}
실행 결과
no overlap (memcpy): [abcdefgh]
shifting one slot up (memmove): [aabcdefg]
pulling two slots down (memmove): [cdefgh]
before strtok : [name,age,city]
token: [name]
token: [age]
token: [city]
after strtok : [name] <- the original was cut apart
memcpy의 계약에는 “두 영역이 겹치면 안 된다”가 들어 있다. 겹친 채로 부르면 정의되지 않은 동작이고, 실제로 최적화된 구현에서 값이 뒤섞인다 — 바이트를 순서대로 옮긴다는 보장이 없기 때문이다(SIMD로 한 번에 여러 바이트를 옮기거나 뒤에서부터 옮길 수 있다).
겹칠 수 있으면 memmove다. 이름이 주는 인상과 달리 “옮기기”가 아니라 겹쳐도 안전한 복사라는 뜻이다.
흔한 오해. “memcpy가 memmove보다 항상 빠르다”
memcpy가 더 빠를 수 있는 이유는 “겹치지 않는다”는 전제를 최적화에 쓸 수 있어서인데, 그 전제를 잘못 세우면 얻는 것(몇 나노초) 보다 잃는 것(찾기 어려운 버그)이 훨씬 크다. 겹칠 가능성이 조금이라도 있으면 memmove — 이것이 현대의 기본값이다.65.4 strtok — 원본을 부수고 상태를 숨긴다
예제의 마지막 부분이다. strtok은 두 가지 죄가 있다.
첫째, 원본을 파괴한다. 구분자를 NUL로 덮어써서 토큰을 만든다. 그래서 읽기 전용 문자열(문자열 리터럴)에는 쓸 수 없고 — 쓰면 계약 밖이다 — 원본이 필요하면 복사해 두어야 한다.
둘째, 상태를 함수 안에 숨긴다. 두 번째 호출부터 NULL을 넘기는 이유가 그것이다. 그 상태는 하나뿐이라, 토큰을 자르는 도중에 다른 함수가 strtok을 부르면 서로의 순회를 망가뜨린다. 여러 갈래로 도는 프로그램에서는 더 나빠진다.
대안은 셋이다. strtok_r(POSIX)·strtok_s(부속서 K)처럼 상태를 호출자가 들고 있는 판을 쓰거나, strcspn·strchr로 직접 자르거나, 제12부의 뷰 기반 분할처럼 원본을 건드리지 않는 도구를 쓰는 것이다.
65.5 나머지 함수들의 함정
| 함수 | 하는 일 | 조심할 것 |
|---|---|---|
strlen | 길이 | NUL이 없으면 폭주. 매번 |
strcmp | 사전순 비교 | 반환값은 부호만 의미. 0이 “같다” |
strncmp | 앞 n바이트 비교 | n이 길이보다 크면 NUL에서 멈춘다 |
strchr·strrchr | 문자 찾기 | 찾는 문자가 '\0'이면 끝을 가리킨다 |
strstr | 부분 문자열 | 최악 성능은 구현마다 다르다 |
strspn·strcspn | 집합 기준 길이 | 자르기 관용구의 핵심 |
memset | 바이트 채우기 | ★ 비밀 지우기에는 최적화로 사라질 수 있다 |
memcmp | 바이트 비교 | ★ 패딩까지 비교한다. 구조체 비교에 쓰면 안 된다 |
표 65.2
두 별표가 실무에서 특히 위험하다.
memset으로 비밀을 지우는 것 — 다 쓴 뒤 지우는 memset(key, 0, len)은, 그 뒤로 key를 읽지 않으면 컴파일러가 “쓸모없는 쓰기”로 보고 지워 버릴 수 있다(13장의 최적화 이야기다). C11은 이를 위해 memset_s를 부속서 K에 넣었고, 플랫폼마다 explicit_bzero·SecureZeroMemory 같은 함수를 둔다.
memcmp로 구조체를 비교하는 것 — 46장에서 본 패딩 때문이다. 같은 값을 담은 두 구조체라도 패딩 바이트가 다르면 memcmp는 “다르다”고 답한다. 멤버를 하나씩 비교해야 한다.
문. memcmp는 암호 비교에도 위험하다고 들었는데?
답. 맞다. 다른 이유에서다. memcmp는 다른 바이트를 만나는 즉시 돌아오므로 비교에 걸린 시간이 앞부분이 얼마나 일치했는지를 누설한다. 공격자가 시간을 재며 한 바이트씩 맞춰 갈 수 있다는 뜻이다(timing attack). 비밀을 비교할 때는 길이에 관계없이 언제나 같은 시간이 걸리는 상수 시간 비교 함수를 쓴다 — 표준에는 없고, 암호 라이브러리가 제공한다.
복습 정리
<string.h> 요약.
| 하고 싶은 일 | 쓸 것 | 쓰지 말 것 |
|---|---|---|
| 문자열 복사 | snprintf (또는 플랫폼의 strlcpy) | strcpy, 무심한 strncpy |
| 이어 붙이기 | snprintf로 한 번에 | strcat, 인자 헷갈리는 strncat |
| 겹칠 수 있는 복사 | memmove | memcpy |
| 토큰 자르기 | strcspn/strchr 또는 strtok_r | strtok |
| 구조체 비교 | 멤버별 비교 | memcmp(패딩) |
| 비밀 비교 | 상수 시간 비교 | memcmp(시간 누설) |
| 비밀 지우기 | 플랫폼의 명시적 함수 | memset(최적화로 소멸) |
표 65.3
문자열을 지났다. 다음 장은 잡동사니 서랍이자 사고의 보고 — <stdlib.h>다.