C の文字列・構造体・バッファオーバーフローを ASan で確かめる
C 講座 第4回。NUL 終端、strcpy・strncpy・snprintf の落とし穴、offsetof で測る構造体のパディング、スタックのオーバーフローを3通りのビルドで検出した実行記録。UTF-8 の切り詰めも。
シリーズ:C 講座 第4回/全6回(中級)。 関数とポインタは書けるようになり、実務で文字列やレコードを扱い始めた方向けの記事です。読み終えると、C の文字列がどこで終わるかを予測でき、strcpy・strncpy・snprintf を理由を持って選べ、構造体のメモリ配置を自分で確かめ、バッファオーバーフローの AddressSanitizer レポートを読めるようになります。
組込み機器のファームウェアや、固定長電文をやり取りする業務システムの C コードを保守していると、「名前は 8 バイトの領域に入れる」「足りない分は空白で埋める」といった仕様に必ず出会います。この記事は、そうした固定長の世界で strcpy や strncpy を使ったときに実際に何が起きるかを、手元で実行した結果だけで示します。例はあえて小さくしました(1 バイト足りない配列、長さを確認しないコピーが 1 つ)。目的は防御で、バグ・ツールの報告・修正までを扱います。
実行環境: Apple clang 21.0.0(clang -std=c17 -Wall -Wextra)、Apple Silicon Mac(M1 Pro)、macOS。以下の出力はすべて実際の実行からのコピーで、パスだけ短くしています。サニタイザーのビルドは -O0 -g -fsanitize=address です。学習の全体像はプログラミング学習ロードマップにあります。
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
3 行から 3 つのことが分かります。配列の sizeof は NUL まで数え、strlen は数えません。ポインタの sizeof は指す先と関係なくポインタの大きさ(64 ビットの Mac で 8)です。そして 5 文字の 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 バイト目へのアクセスはその 1 つ先だと示しています。READ of size 6 は 5 文字と 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 * にしておけば、書き込みをコンパイラが止めてくれます。
この実行にはもう 1 つ罠がありました。./mistakes > out.txt とファイルにリダイレクトすると、クラッシュ前に puts が 2 回実行されたのに out.txt は 0 バイトでした。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 も知りません。8 文字でも入らない点に注意してください。"abcdefgh" は NUL を含めて 9 バイト必要です。
strncpy(dst, src, n) は長さ制限付きの代替に見えますが、もともと古いファイル形式の固定長フィールド用の関数で、C 文字列用ではありません。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 はこの行に何の警告も出しませんでした。固定長電文の「空白埋め・終端なし」フィールドを作る目的なら strncpy は正しい道具ですが、その後で %s に渡してはいけません。
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 以降(Linux は未実行) |
長さを自分で持つ+memcpy |
はい | 自分で書く | はい | ループ内、文字列の組み立て(実測参照) |
日本語の罠:バイトで切ると文字が割れる
ここは英語圏の解説ではまず出てこない話です。C の文字列関数はすべてバイト単位で、UTF-8 の日本語は 1 文字 3 バイトです。上の copy_str で「山田太郎」を 8 バイトの name に入れてみました。
strlen("山田太郎") = 12 bytes
rc=-1 bytes=7 [山田�]
E5 B1 B1 E7 94 B0 E5
trimmed: bytes=6 [山田]
7 バイト目の E5 は「太」(E5 A4 AA)の先頭 1 バイトだけです。メモリ安全性の意味では正しく切り詰められていますが、画面には � が出て、この文字列をそのまま JSON や DB に送ると不正な UTF-8 として拒否されることがあります。末尾の不完全な文字を落とす関数を挟んだのが trimmed の行です。
/* 末尾の不完全な UTF-8 文字を削る(継続バイトは 10xxxxxx) */
static void trim_utf8(char *s) {
size_t len = strlen(s), i = len;
while (i > 0 && ((unsigned char)s[i - 1] & 0xC0) == 0x80) i--; /* 継続バイトを飛ばす */
if (i > 0) {
unsigned char lead = (unsigned char)s[i - 1];
size_t need = lead >= 0xF0 ? 4 : lead >= 0xE0 ? 3 : lead >= 0xC0 ? 2 : 1;
if (len - (i - 1) < need) s[i - 1] = '\0'; /* 不完全なら落とす */
}
}
Shift_JIS の固定長電文を扱う現場では、2 バイト文字の 1 バイト目だけが残る同じ問題が「泣き別れ」と呼ばれて昔から知られています。仕様書の「8 バイト」が文字数なのかバイト数なのかを最初に確認してください。
1 つのオーバーフローを 3 通りに捕まえる
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 で目指すべきはこのレポートです。
修正は、コピーする関数に容量を渡し、切り詰めたときの扱いを決めることです。
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
原則は 1 つです。バッファに書く関数は、バッファの大きさを引数で受け取る。 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 はほぼすべての構造体で鳴るので常用せず、ヘッダを 1 つ点検するときだけ使います。実務での注意は 2 つ。構造体を丸ごと fwrite してファイルやソケットに送り、別のコンパイラや CPU で読めると期待しないこと(パディングもバイト順も違う。固定長電文はフィールドごとに書き出す)。構造体を memcmp で比べないこと(パディングには以前のゴミが残っている)。
実測:ループ内の strcat、パディングの多い構造体と少ない構造体
レビューでは見えないコストを 2 つ。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 が 2 倍になると strcat は 4 倍になります。呼ぶたびにそれまでつないだ全体を strlen で読み直すので、仕事量が n² に比例します。40,000 個では長さを覚える方法の約 6,000 倍遅くなりました。snprintf が長さを返すことの価値も同じ理由です。数を持っていれば読み直す必要がありません。
1,000 万個の構造体で 1 つのフィールドを合計: struct Bad(32 バイト)は 5.41 ms、struct Good(16 バイト)は 2.71 ms(同じ qty 値で、チェックサム 494840983 も一致)。要素ごとに int を 1 つ読むのは同じですが、メモリはキャッシュライン単位(この M1 Pro では sysctl hw.cachelinesize で 128 バイト)で届きます。1 ラインに 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 の挙動です。Linux の 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 以降にあります。 - 時間は 1 台の空いている機械での 5 回の最速値です。大事なのは比率(n²、バイト半分 ≈ 時間半分)で、絶対値は環境で変わります。
練習問題
- 実行せずに答えてください。64 ビットの Mac で
struct A { char a; int b; char c; };とstruct B { int b; char a; char c; };のsizeofは? int append(char *dst, size_t cap, size_t *len, const char *src)を書いてください。srcを後ろに付け、*lenを更新し、入りきらないときは dst を変えずに -1 を返します。12 バイトのバッファに"GET "、"/index"、".html"を順に付けて試してください。
解答
A=12 B=8。Aはbがオフセット 4 から始まる必要があるのでaの後に 3 バイト、cが 8、4 の倍数に揃える末尾の 3 バイト。Bは char 2 つが 4 と 5、末尾 2 バイトで 8。-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 バイト必要なので 3 回目は断り、バッファは正しい文字列のまま残ります。
構造体とポインタを実際のデータ構造で練習するなら、ASan で検査した連結リストとスタックの記事もどうぞ。
前回: 第3回:ポインタ・メモリ・malloc · 次回: 第5回:未定義動作と最適化
よくある質問
strncpy を使えば strcpy より安全ですか?
いいえ。コピー元がバッファと同じ長さ以上だと、strncpy はバッファを埋め尽くし終端の NUL を書きません。実測では 8 バイトのバッファの最後のバイトが 0 ではなく 111('o')でした。続く printf や strlen は配列の外まで読みます。snprintf(dst, sizeof dst, "%s", src) を使い、戻り値で切り詰めを確認してください。
構造体の sizeof がメンバーの合計より大きいのはなぜですか?
各メンバーがアラインメントの倍数のアドレスから始まるよう、コンパイラが詰め物(パディング)を入れるからです。char・double・char・int32_t・bool の順の構造体はデータ 15 バイトなのに arm64 で 32 バイト、大きい順に並べ替えると 16 バイトでした。offsetof や -Wpadded で隙間を確認できます。
UTF-8 の日本語を snprintf で切り詰めると文字化けするのはなぜですか?
snprintf はバイト単位で切るからです。「山田太郎」は UTF-8 で 12 バイトあり、8 バイトのバッファには 7 バイト入るので「太」の 3 バイトのうち 1 バイトだけが残りました。末尾の不完全な文字を削る処理が必要です。本文に 10 行ほどの関数を載せています。