C 문자열·구조체·버퍼 오버플로: ASan으로 직접 잡아 보기

C 강좌 4강. NUL 종료 문자, strcpy·strncpy·snprintf 함정, offsetof로 잰 구조체 패딩, 스택 버퍼 오버플로를 세 가지 빌드로 잡아 본 실행 기록과 고친 코드.

시리즈: C 강좌 4/6 (중급). 함수와 포인터는 쓸 수 있고, 이제 실무 코드에서 문자열과 레코드를 다뤄야 하는 분을 위한 글입니다. 다 읽고 나면 C 문자열이 어디서 끝나는지 예측하고, strcpy·strncpy·snprintf 중 하나를 이유를 갖고 고르고, 구조체 메모리 배치를 직접 확인하고, 버퍼 오버플로에 대한 AddressSanitizer 보고서를 읽을 수 있습니다.

학교 C 과제를 Visual Studio로 해 본 분이라면 strcpy를 쓰자마자 "C4996: This function or variable may be unsafe" 경고가 뜨고, 검색해서 #define _CRT_SECURE_NO_WARNINGS를 붙여 경고를 끄는 습관이 들었을 겁니다. 이 글은 그 경고가 무엇을 막으려던 것이었는지, 경고를 끄면 실제로 어떤 일이 생기는지를 직접 실행해서 보여 드립니다. 예제는 일부러 작게 만들었고(1바이트 모자란 배열, 길이 확인 없는 복사 하나), 목적은 방어입니다. 버그, 도구의 보고, 고친 코드까지만 다룹니다.

실행 환경: Apple clang 21.0.0(clang -std=c17 -Wall -Wextra), Apple Silicon Mac(M1 Pro), macOS. 아래 출력은 전부 실제 실행에서 복사했고 경로만 줄였습니다. 새니타이저 빌드는 -O0 -g -fsanitize=address입니다. (Visual Studio의 C4996은 이 맥에서 돌려 보지 않았습니다.) 전체 학습 순서는 프로그래밍 학습 로드맵에 있습니다.

C 문자열은 "배열 + 약속"이다

C에는 문자열 타입이 없습니다. "문자열"은 내용이 0 바이트 '\0'(NUL, 널 문자)로 끝나는 char 배열일 뿐입니다. strlen, printf("%s"), strcpy처럼 char *를 받는 함수는 모두 0을 만날 때까지 앞으로 걸어갑니다. 길이도, 배열 크기도 어디에도 기록되지 않습니다. 이번 강의의 버그는 거의 다 이 설계에서 나옵니다.

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

int main(void) {
    char a[] = "hello";          /* 6바이트: h e l l o \0 */
    char b[5] = "hello";         /* 5바이트: \0 들어갈 자리 없음 (C에서는 합법!) */
    const char *p = "hello";
    printf("sizeof a = %zu, strlen a = %zu\n", sizeof a, strlen(a));
    printf("sizeof p = %zu, strlen p = %zu\n", sizeof p, strlen(p));
    printf("strlen b = %zu\n", strlen(b));   /* 끝을 넘어 읽는다 */
    return 0;
}

clang 21은 b 줄에 경고를 냅니다.

nul.c:6:17: warning: initializer-string for character array is too long, array size is 5 but
initializer has size 6 (including the null terminating character); did you mean to use the
'nonstring' attribute? [-Wunterminated-string-initialization]

에러가 아니라 경고인 이유는 표준이 "배열이 딱 1바이트 짧을 때는 종료 문자를 빼도 된다"고 허용하기 때문입니다. 실행 결과:

sizeof a = 6, strlen a = 5
sizeof p = 8, strlen p = 5
strlen b = 13

세 줄에서 세 가지를 배웁니다. 배열의 sizeof는 NUL까지 세고 strlen은 세지 않습니다. 포인터의 sizeof는 가리키는 대상과 상관없이 포인터 크기(64비트 맥에서 8)입니다. 그리고 다섯 글자짜리 b의 strlen이 13이 나왔습니다. 옆 스택 바이트를 읽다가 우연히 0을 만난 지점까지 센 것이라, 13이라는 숫자에는 아무 의미가 없고 배치가 바뀌면 다른 숫자나 크래시가 됩니다.

AddressSanitizer(ASan, 모든 객체 주변에 "독이 든" 바이트를 두고 접근할 때마다 검사하는 컴파일러 옵션)로 빌드하면 조용한 13이 보고서로 바뀝니다.

==73077==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x00016f6063c5 ...
READ of size 6 at 0x00016f6063c5 thread T0
    #0 0x0001012a6800 in strlen+0x22c (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x16800)
    #1 0x0001007f89e4 in main nul.c:10
  This frame has 2 object(s):
    [32, 38) 'a' (line 5)
    [64, 69) 'b' (line 6) <== Memory access at offset 69 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow nul.c:10 in main

아래에서 위로 읽으면 쉽습니다. SUMMARY가 종류와 줄 번호, 프레임 목록이 b가 64~68번 바이트를 차지한다는 것, 69번 접근이 바로 그 한 칸 뒤라는 것을 알려 줍니다. READ of size 6은 다섯 글자에 NUL 자리 1바이트까지 읽으려 했다는 뜻입니다.

흔한 실수: ==로 비교하기, 리터럴 고치기

실수 1: 문자열을 ==로 비교. 자바나 파이썬에서 넘어온 분이 가장 많이 하는 실수입니다.

char answer[8] = "yes";
if (answer == "yes") puts("== says equal");
else                 puts("== says NOT equal");
if (strcmp(answer, "yes") == 0) puts("strcmp says equal");
mistakes.c:6:16: warning: result of comparison against a string literal is unspecified
(use an explicit string comparison function instead) [-Wstring-compare]

실제로 일어나는 일: ==는 스택의 배열 주소와 읽기 전용 영역의 리터럴 주소를 비교합니다. 둘은 절대 같지 않아서 == says NOT equal, 이어서 strcmp says equal이 출력됐습니다. 해결: 항상 strcmp(a, b) == 0.

실수 2: 문자열 리터럴에 쓰기. char *lit = "hello"; lit[0] = 'J';는 C에서 -Wall -Wextra로도 경고 없이 컴파일됩니다(C++에서는 에러). macOS에서 리터럴은 읽기 전용 페이지에 있습니다.

bash: line 1: 74295 Bus error: 10           ./mistakes
exit=138

해결: 고쳐야 한다면 char s[] = "hello";처럼 배열로 선언해 스택에 복사본을 만듭니다. 리터럴을 가리키는 포인터는 const char *로 선언하면 컴파일러가 쓰기를 막아 줍니다.

이 실행에는 함정이 하나 더 있었습니다. ./mistakes > out.txt로 파일에 저장했더니 out.txt가 0바이트였습니다. 크래시 전에 puts 두 줄이 실행됐는데도요. stdout은 터미널에서는 줄 단위, 파일이나 파이프로 보낼 때는 블록 단위로 버퍼링되고, 크래시하면 버퍼가 그대로 버려집니다. "죽는 프로그램이 아무것도 안 찍는다"면 fflush(stdout)을 넣거나, 버퍼링하지 않는 stderr로 진단 메시지를 보내세요.

strcpy·strncpy·snprintf가 실제로 하는 일

코드 리뷰에 올라온 함수입니다. 사용자 이름을 8바이트 필드에 복사합니다.

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

/* 사용자 이름을 8바이트 필드에 복사. 버그: 길이 확인 없음 */
static void set_name(char *dst, const char *src) {
    strcpy(dst, src);
}

int main(int argc, char **argv) {
    char name[8];
    int  points = 100;
    const char *input = argc > 1 ? argv[1] : "alice";
    set_name(name, input);
    printf("name=%s points=%d\n", name, points);
    return 0;
}

set_name은 포인터만 받기 때문에 대상이 8바이트라는 사실을 함수도 strcpy도 모릅니다. 여덟 글자도 안 들어간다는 점에 주의하세요. "abcdefgh"는 NUL까지 9바이트가 필요합니다.

strncpy(dst, src, n)은 길이 제한이 있는 대체품처럼 보이지만, 원래 옛 파일 형식의 고정 폭 필드용으로 만들어진 함수입니다. src가 n글자 이상이면 정확히 n바이트만 복사하고 NUL을 붙이지 않습니다.

char b[8];
strncpy(b, "bartholomew", sizeof b);
printf("strncpy: last byte = %d (0 means terminated)\n", b[7]);
strncpy: last byte = 111 (0 means terminated)

111은 'o'입니다. 이제 b는 끝이 없는 문자열이고, 다음 printf("%s", b)가 첫 절의 범위 밖 읽기가 됩니다. clang 21은 이 줄에 아무 경고도 내지 않았습니다.

snprintf(dst, cap, "%s", src)는 cap > 0이면 항상 NUL로 끝내고, 쓰고 싶었던 길이를 돌려줍니다. 이 반환값이 핵심입니다. 용량과 비교하면 잘렸는지 알 수 있습니다.

/* 성공 0, 잘리면 -1 (dst에는 잘렸지만 NUL로 끝나는 복사본이 남는다) */
static int copy_str(char *dst, size_t cap, const char *src) {
    int n = snprintf(dst, cap, "%s", src);
    return (n < 0 || (size_t)n >= cap) ? -1 : 0;
}
snprintf: "barthol" rc=-1
snprintf: "alice" rc=0
snprintf(NULL, 0, ...) says it needs 23 bytes + 1 for NUL

마지막 줄은 또 다른 쓰임새입니다. snprintf(NULL, 0, ...)는 아무것도 쓰지 않고 필요한 길이만 알려 주므로, malloc 크기를 정할 때 씁니다.

함수 용량을 아나? 항상 NUL로 끝나나? 잘림을 알려 주나? 언제 쓰나
strcpy 모름 넘치지 않을 때만 아니오 외부 입력에는 절대 쓰지 않는다
strncpy 앎 아니오 (src ≥ n일 때) 아니오 문자열이 아닌 고정 폭 필드
strncat 남은 개수만 예 아니오 거의 안 씀. 인자를 틀리기 쉽다
snprintf(d, cap, "%s", s) 앎 예 (cap > 0) 예, 반환값으로 기본 선택. C99 표준이라 이식성 좋음
strlcpy 앎 예 예, strlen(src) 반환 macOS/BSD, glibc 2.38+ (리눅스는 실행 안 함)
길이를 직접 관리 + memcpy 앎 직접 씀 예 반복문 안, 문자열 빌더 (실측 참고)

그럼 Visual Studio가 권하던 strcpy_s는? C11 부록 K(경계 검사 함수)에 들어 있는 함수지만 선택 사항이라 구현하지 않은 라이브러리가 많습니다. 이 맥에서 #define __STDC_WANT_LIB_EXT1__ 1을 붙이고 컴파일해 봤습니다.

k.c:4:27: error: call to undeclared function 'strcpy_s'; ISO C99 and later do not support
implicit function declarations [-Wimplicit-function-declaration]
k.c:4:27: note: did you mean 'strcpy'?

macOS libc에는 선언 자체가 없습니다(__STDC_LIB_EXT1__ 매크로도 정의돼 있지 않음). 윈도우와 리눅스·맥을 함께 지원할 코드라면 위 표의 snprintf나 길이 직접 관리가 현실적인 선택입니다.

오버플로 하나를 세 가지 방법으로 잡다

strcpy 프로그램에 21글자 이름을 넣어 봅니다. 무엇이 보이는지는 어떻게 빌드했느냐에 달려 있고, 이 부분이 요약글에는 나오지 않습니다.

그냥 -O0: 출력까지 하고 나서 죽습니다.

name=bartholomew_the_third points=100
bash: line 1: 78502 Abort trap: 6           ./copy_O0 bartholomew_the_third
exit=134

(터미널에서 실행한 결과입니다. 파이프로 넘기면 앞 절처럼 name= 줄이 버퍼와 함께 사라졌습니다.) 덮어쓰기는 이미 일어났고, 망가진 데이터도 출력됐고, main을 빠져나가는 순간에야 스택 보호 장치가 알아챘습니다. 컴파일러가 반환 주소 옆에 임의의 값(카나리아)을 두고 return 직전에 확인하는 방식입니다. lldb로 보면 누가 abort를 불렀는지 나옵니다.

* thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGABRT
    frame #2: 0x0000000188cf5a1c libsystem_c.dylib`__abort + 152
    frame #3: 0x0000000188cf5af4 libsystem_c.dylib`__stack_chk_fail + 96
    frame #4: 0x0000000100000518 copy_O0g`main(argc=2, argv=0x000000016fdfe848) at copy.c:15:5

15번째 줄은 return 0;입니다. 오래 도는 서버였다면 망가진 상태로 한참 더 돌았을 수 있습니다.

그냥 -O2: 복사하기 전에 다른 시그널로 죽습니다.

bash: line 1: 78507 Trace/BPT trap: 5       ./copy_O2 bartholomew_the_third
exit=133

macOS 헤더는 strcpy를 __builtin___strcpy_chk(dst, src, 크기)로 바꿉니다(_FORTIFY_SOURCE). -O2에서는 set_name이 main에 인라인되어 컴파일러가 name이 8바이트라는 걸 알고 8을 넘기고, libc가 길이를 확인해 __chk_fail_overflow에서 멈춥니다. -O0에서는 같은 호출에 "크기 모름"이 넘어가서 검사가 소용이 없었습니다.

-fsanitize=address: 정확히 잘못 쓰는 순간에 멈춥니다.

==73154==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x00016dade388 ...
WRITE of size 22 at 0x00016dade388 thread T0
    #0 0x000102d82af0 in strcpy+0x458 (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x3aaf0)
    #1 0x000102320a84 in set_name copy.c:6
    #2 0x0001023209a4 in main copy.c:13
  This frame has 1 object(s):
    [32, 40) 'name' (line 10) <== Memory access at offset 40 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow copy.c:6 in set_name

WRITE of size 22는 21글자 + NUL을 8바이트에 썼다는 뜻이고, 위치는 13번째 줄에서 부른 copy.c:6입니다. 종료 코드는 134(macOS의 ASan은 abort로 끝냄). 개발 중과 CI에서 목표로 할 보고서가 바로 이것입니다.

흐름도. 출발점은 8바이트 스택 배열에 21글자를 strcpy. 빌드에 따라 세 갈래. -O0만: 복사가 실행되고 망가진 내용이 출력된 뒤 함수가 돌아갈 때 스택 보호가 abort(Abort trap 6, 종료 코드 134). -O2만: 강화된 __strcpy_chk가 크기 8을 알고 복사 전에 멈춤(Trace/BPT trap 5, 종료 코드 133). -fsanitize=address: AddressSanitizer가 stack-buffer-overflow, copy.c 6번째 줄에서 22바이트 WRITE라고 보고하고 멈춤(종료 코드 134).
같은 strcpy 오버플로(21글자를 char[8]에)를 세 가지 빌드로 실행(Apple clang 21, macOS). 잘못된 쓰기 순간에 멈추고 줄 번호까지 알려 주는 것은 ASan뿐이다.

고친 코드는 복사하는 함수에 용량을 넘기고, 잘렸을 때 어떻게 할지 정합니다.

static int set_name(char *dst, size_t cap, const char *src) {
    int n = snprintf(dst, cap, "%s", src);
    return (n < 0 || (size_t)n >= cap) ? -1 : 0;
}
/* main 안에서: */
if (set_name(name, sizeof name, input) != 0)
    fprintf(stderr, "warning: name truncated to \"%s\"\n", name);

-fsanitize=address,undefined로 빌드해 같은 입력으로 실행하면:

warning: name truncated to "barthol"
name=barthol points=100
exit=0

원칙은 하나입니다. 버퍼에 쓰는 함수는 버퍼 크기를 인자로 받아야 한다. sizeof name은 name이 아직 배열인 곳에서만 통하고, set_name 안에서는 포인터 크기 8이 됩니다(2강의 배열→포인터 변환).

구조체: 패딩을 직접 재 보기

struct는 멤버를 선언 순서대로 배치하고, 각 멤버가 자기 정렬(double은 8, int32_t는 4, char는 1)의 배수 주소에서 시작하도록 빈 바이트를 넣습니다. <stddef.h>의 offsetof로 결과를 볼 수 있습니다.

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

struct Bad  { char flag; double price; char kind; int32_t qty; bool active; };
struct Good { double price; int32_t qty; char flag; char kind; bool active; };

#define SHOW(T, f) printf("  %-7s offset %2zu size %zu\n", #f, offsetof(struct T, f), sizeof(((struct T*)0)->f))

int main(void) {
    printf("struct Bad: sizeof %zu, alignof %zu\n", sizeof(struct Bad), _Alignof(struct Bad));
    SHOW(Bad, flag); SHOW(Bad, price); SHOW(Bad, kind); SHOW(Bad, qty); SHOW(Bad, active);
    printf("struct Good: sizeof %zu, alignof %zu\n", sizeof(struct Good), _Alignof(struct Good));
    SHOW(Good, price); SHOW(Good, qty); SHOW(Good, flag); SHOW(Good, kind); SHOW(Good, active);
    return 0;
}
struct Bad: sizeof 32, alignof 8
  flag    offset  0 size 1
  price   offset  8 size 8
  kind    offset 16 size 1
  qty     offset 20 size 4
  active  offset 24 size 1
struct Good: sizeof 16, alignof 8
  price   offset  0 size 8
  qty     offset  8 size 4
  flag    offset 12 size 1
  kind    offset 13 size 1
  active  offset 14 size 1

데이터는 똑같이 15바이트인데 32바이트 대 16바이트입니다. Bad는 flag 뒤에 7바이트, kind 뒤에 3바이트, 끝에 7바이트(배열의 다음 원소도 정렬되도록)가 비어 있습니다. 손으로 계산할 필요 없이 -Wpadded가 빈 곳을 다 알려 줍니다.

layout.c:6:33: warning: padding struct 'struct Bad' with 7 bytes to align 'price' [-Wpadded]
layout.c:6:59: warning: padding struct 'struct Bad' with 3 bytes to align 'qty' [-Wpadded]
layout.c:6:8: warning: padding size of 'struct Bad' with 7 bytes to alignment boundary [-Wpadded]
layout.c:7:8: warning: padding size of 'struct Good' with 1 byte to alignment boundary [-Wpadded]

-Wpadded는 거의 모든 구조체에서 경고가 나니 상시 켜 두지 말고 헤더 하나를 점검할 때만 씁니다. 실무에서 기억할 것 두 가지: 구조체를 통째로 fwrite해서 파일이나 소켓으로 보내고 다른 컴파일러·CPU가 읽기를 기대하지 말 것(패딩과 바이트 순서가 다르다), 구조체를 memcmp로 비교하지 말 것(패딩 바이트에는 이전 쓰레기 값이 남아 있다).

실측: 반복문 속 strcat, 패딩 많은 구조체 대 적은 구조체

코드 리뷰에서는 안 보이는 비용 두 가지입니다. clock_gettime(CLOCK_MONOTONIC), 5회 중 최솟값, clang -O2, Apple Silicon Mac(M1 Pro). 측정 프로그램 전체(bench4.c)는 영어판에 있고 그대로 복사해 실행할 수 있습니다.

단어 n개를 strcat으로 잇기 대 끝 위치 기억하기:

단어 수 n 매번 strcat 끝 포인터 유지
10,000 5.30 ms 0.003 ms
20,000 20.1 ms 0.006 ms
40,000 80.1 ms 0.013 ms

n이 두 배가 되면 strcat 시간은 네 배가 됩니다. 호출할 때마다 지금까지 이어 붙인 전체를 strlen으로 다시 훑으니 총 작업량이 n²에 비례합니다. 40,000개에서는 길이를 기억하는 방식보다 약 6,000배 느렸습니다. snprintf가 길이를 돌려주는 게 왜 소중한지도 같은 이유입니다. 숫자를 들고 있으면 다시 훑을 일이 없습니다.

밀리초 막대그래프. strcat n=10,000: 5.30, n=20,000: 20.1, n=40,000: 80.1. 끝 포인터 n=40,000: 0.013. strcat은 호출마다 버퍼 전체를 다시 읽기 때문에 시간이 n의 제곱으로 늘어난다.
5바이트 단어 n개를 잇는 시간: 매번 strcat 대 끝 포인터 기억하기. Apple clang 21 -O2, Apple Silicon Mac (M1 Pro), 5회 중 최솟값. n을 두 배로 하면 strcat 시간은 네 배가 된다.

구조체 1,000만 개에서 필드 하나 합계: struct Bad(32바이트)는 5.41 ms, struct Good(16바이트)는 2.71 ms였습니다(같은 qty 값, 체크섬 494840983 동일). 원소마다 int 하나만 읽는 건 같지만 메모리는 캐시 라인 단위(이 M1 Pro에서는 sysctl hw.cachelinesize로 128바이트)로 옵니다. 한 줄에 Bad는 4개, Good은 8개가 들어갑니다. 옮기는 바이트가 절반이니 시간도 절반. 6강에서 이 생각을 더 밀고 갑니다.

/* bench4.c 핵심부 (전체는 영어판) */
static size_t join_strcat(char *buf, int n) {      /* 매번 buf 전체를 다시 훑는다 */
    buf[0] = '\0';
    for (int i = 0; i < n; i++) strcat(buf, "word,");
    return strlen(buf);
}
static size_t join_end(char *buf, int n) {         /* 끝 위치를 기억한다 */
    char *end = buf;
    for (int i = 0; i < n; i++) { memcpy(end, "word,", 5); end += 5; }
    *end = '\0';
    return (size_t)(end - buf);
}

한계와 버전 메모

  • 모두 arm64 macOS의 Apple clang 21.0.0에서 실행했습니다. 이 구조체의 패딩 값(32/16)은 흔한 64비트 ABI에서 같게 나오는 것이 일반적이지만, 표준은 "구현 정의"라고만 하고 우리는 이 기계에서만 쟀습니다.
  • -Wunterminated-string-initialization과 nonstring 힌트는 clang 21에서 -Wall -Wextra만으로 나오는 것을 확인했습니다. 이전 버전 중 어디부터 나오는지는 확인하지 않았습니다.
  • Abort trap: 6(스택 보호)과 Trace/BPT trap: 5(_FORTIFY_SOURCE 검사)는 macOS libc의 동작입니다. 리눅스 glibc는 *** stack smashing detected ***나 *** buffer overflow detected ***를 출력합니다(여기서는 실행 안 함). 둘 다 ASan을 대신하지 못합니다. 일부 오버플로만, 그것도 피해가 생긴 뒤나 직전에만 봅니다.
  • 표준: strncpy·strcat·offsetof는 C89, snprintf는 C99, _Alignof는 C11(C23에서 alignof가 키워드가 됨). memccpy·strdup은 C23에서 ISO C에 들어갔습니다. strlcpy는 ISO C가 아니고 POSIX.1-2024, macOS/BSD, glibc 2.38부터 있습니다.
  • 시간은 한가한 한 대에서 잰 5회 최솟값입니다. 중요한 건 비율(n², 바이트 절반 ≈ 시간 절반)이고 절대값은 환경마다 다릅니다.

연습 문제

  1. 실행하지 말고 답해 보세요. 64비트 맥에서 struct A { char a; int b; char c; };와 struct B { int b; char a; char c; };의 sizeof는?
  2. int append(char *dst, size_t cap, size_t *len, const char *src)를 작성하세요. src를 뒤에 붙이고 *len을 갱신하며, 넘칠 것 같으면 dst를 건드리지 않고 -1을 반환합니다. 12바이트 버퍼에 "GET ", "/index", ".html"을 차례로 붙여 테스트하세요.

정답

  1. A=12 B=8. A는 b가 오프셋 4에서 시작해야 하므로 a 뒤에 3바이트, c가 8, 그리고 4의 배수를 맞추는 꼬리 패딩 3바이트. B는 char 두 개가 4, 5에 있고 꼬리 패딩 2바이트로 8.
  2. -fsanitize=address,undefined로 확인한 예시입니다.
static int append(char *dst, size_t cap, size_t *len, const char *src) {
    size_t n = strlen(src);
    if (*len + n + 1 > cap) return -1;
    memcpy(dst + *len, src, n + 1);      /* NUL까지 복사 */
    *len += n;
    return 0;
}
append "GET " -> rc=0 buf="GET " len=4
append "/index" -> rc=0 buf="GET /index" len=10
append ".html" -> rc=-1 buf="GET /index" len=10

"GET /index.html"은 16바이트가 필요하므로 세 번째 호출은 거절하고, 버퍼는 올바른 문자열 그대로 남습니다.

구조체와 포인터를 실제 자료구조에서 더 연습하려면 ASan으로 검사한 연결 리스트와 스택 글을 보세요.


이전: 3강: 포인터·메모리·malloc · 다음: 5강: 정의되지 않은 동작과 최적화

자주 묻는 질문

strncpy를 쓰면 strcpy보다 안전한가요?

아닙니다. 원본이 버퍼 크기 이상이면 strncpy는 버퍼를 꽉 채우고 끝에 NUL을 쓰지 않습니다. 실제로 8바이트 버퍼의 마지막 바이트가 0이 아니라 111('o')이었고, 그 뒤의 printf나 strlen은 배열 끝을 넘어 읽습니다. snprintf(dst, sizeof dst, "%s", src)를 쓰고 반환값으로 잘림을 확인하세요.

구조체 sizeof가 멤버 크기 합보다 큰 이유는 무엇인가요?

각 멤버가 자기 정렬(alignment)의 배수 주소에서 시작하도록 컴파일러가 빈 바이트(패딩)를 넣기 때문입니다. char, double, char, int32_t, bool 순서의 구조체는 데이터가 15바이트인데 arm64에서 32바이트였고, 큰 것부터 다시 배치하니 16바이트가 됐습니다. offsetof나 -Wpadded로 빈 곳을 확인할 수 있습니다.

출력은 정상으로 나왔는데 마지막에 Abort trap: 6으로 죽는 이유는?

macOS에서 -O0으로 빌드한 프로그램의 스택 버퍼 오버플로는 함수가 return할 때 스택 보호 장치가 뒤늦게 발견하는 경우가 많습니다. 덮어쓰기와 출력은 이미 끝났고, 그다음 __stack_chk_fail이 abort를 부릅니다(종료 코드 134). -fsanitize=address로 빌드하면 잘못 쓰는 바로 그 줄에서 멈춥니다.

  • #C
  • #C언어
  • #문자열
  • #구조체
  • #버퍼 오버플로
  • #AddressSanitizer
  • #clang
  • #중급

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑