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 で目指すべきはこのレポートです。

流れ図。出発点は 8 バイトのスタック配列への 21 文字の strcpy。ビルドで3つに分かれる。-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] へ)を3通りのビルドで実行(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

原則は 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 が長さを返すことの価値も同じ理由です。数を持っていれば読み直す必要がありません。

ミリ秒の棒グラフ。strcat で n=10,000:5.30、n=20,000:20.1、n=40,000:80.1。末尾ポインタで n=40,000:0.013。strcat は毎回バッファ全体を読み直すため、時間が n の2乗で増える。
5バイトの単語を n 個つなぐ時間:毎回 strcat と、末尾ポインタを覚える方法。Apple clang 21 -O2、Apple Silicon Mac (M1 Pro)、5回の最速値。n を2倍にすると strcat は4倍になる。

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²、バイト半分 ≈ 時間半分)で、絶対値は環境で変わります。

練習問題

  1. 実行せずに答えてください。64 ビットの Mac で 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 2 つが 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 バイト必要なので 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 行ほどの関数を載せています。

  • #C
  • #C言語
  • #文字列
  • #構造体
  • #バッファオーバーフロー
  • #AddressSanitizer
  • #clang
  • #中級

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑