C言語基礎|文字列の不正なコピー

「コピーできた」に油断すると危険!―文字列リテラルと領域不足が招く“静かな破壊”を防ごう。

文字列コピーは、C言語では日常茶飯事の処理ですよね。ところが、コピー先を1つ間違えるだけで、動いたり落ちたり、環境で挙動が変わったりします。
その原因はたいてい、次のどちらか(あるいは両方)です。

  • 書き換えてはいけない場所に書き込んだ
  • サイズが足りない場所に書き込んだ

この記事では「なぜ危ないのか」を、ポインタと記憶域の観点からしっかり押さえていきます。怖いのは、失敗がすぐ分からないこと。壊れた瞬間ではなく、少し後で落ちることも多いんです。

サンプルプログラム

短い固定メッセージを指すポインタへ、入力文字列をコピーするプログラム例です。

プロジェクト名:chap11-7-1 ソースファイル名:chap11-7-1.c

Visual Studio でこのプログラムを実行するには、SDLチェック設定を変更しておく必要があります。
1.プロジェクト名を右クリックして、「プロパティ」をクリックします。
2.「C/C++」→「全般」→「SDLチェック」を「いいえ」に切り替えて「OK」をクリックします。

#include <stdio.h>

// 文字列srcをdstへコピー(サイズ検査なし)
char *str_copy(char *dst, const char *src)
{
    char *top = dst;
    while (*dst++ = *src++) {
    }
    return top;
}

int main(void)
{
    char *msg = "OK";     // 文字列リテラルを指す
    char input[128];

    printf("現在の表示:%s\n", msg);

    printf("新しい表示文字を入力:");
    scanf("%127s", input);

    // 不正:msgが指す先(文字列リテラル)へコピーしようとする
    str_copy(msg, input);

    printf("更新後の表示:%s\n", msg);

    return 0;
}

このプログラムは、環境によって

  • すぐ落ちる
  • たまたま動いたように見える
  • 後から別の場所の破壊で落ちる
    など、正しい動作が保証されません

なぜ正しく実行できないのか:2重の誤り

このプログラムが踏んでいる2つの地雷

誤りやっていること何が問題?よくある症状
1. 文字列リテラルを書き換えmsg が指す "OK" の領域へ書き込み書き換え可能かは処理系依存(不可が多い)クラッシュ、保護違反
2. 空き領域でない場所まで書く可能性短い領域に長い入力をコピー後ろが空いている保証がない他の変数破壊、挙動が不安定

誤り1:文字列リテラルを書きかえている

ポイントはここです。

  • char *msg = "OK"; は、配列を用意しているわけではなく
    「文字列リテラルの先頭を指すポインタ」を作っています。
  • str_copy(msg, input); は、msg が指す先へ1文字ずつ代入します。

図:msg は「文字列の実体」ではなく「指しているだけ」

msg  ----->  "OK\0"  (文字列リテラル)
                 ↑ここへ書き込もうとしてしまう

文字列リテラルの領域が書き換え可能かどうかは処理系依存です。
書き換え不可の環境では、ここで正しく動く保証はありません。

誤り2:領域外に書き込みうる(サイズ不足)

"OK" はナル文字を含めて 3バイトです。
でも input に "HELLOWORLD" のような長い文字列が入ったら、コピーは最後まで止まりません('\0' が来るまで進むからです)。

図:短い領域に長い文字列を突っ込むと、はみ出す

コピー先(短い):  [ 'O' ][ 'K' ][ '\0' ]
コピー元(長い):  [ 'H' ][ 'E' ][ 'L'  ][ 'L' ][ 'O' ][ ... ][ '\0' ]

コピーが進むと…
[ 'H' ][ 'E' ][ 'L' ] 
                 ↑ ここで既に領域外

「後ろが空いている保証はない」ので、運が悪いと

  • 近くの変数が壊れる
  • 管理情報が壊れて後で落ちる
  • たまたま動いたように見える(これが一番危険)
    という状態になります。

今回の核心:str_copy は「コピー先の容量」を知らない

str_copy の while は、次の意味です。

  • *dst = *src を実行する(1文字コピー)
  • その後 dst と src を1つ進める
  • コピーした文字が '\0' なら終了、そうでなければ続行

つまり '\0' が来るまで無条件で書き続ける関数です。
だからこそ、呼び出し側は必ず、

  • dst が書き込み可能な領域であること
  • dst に十分なサイズがあること

を満たしている必要があります。今回の例は両方満たしていません。

登場する命令の書式と役割

scanf の書式

  • 書式:scanf(書式文字列, 格納先, ...);
  • 何をする命令?:標準入力から読み取って、指定されたメモリへ書き込む

今回の例:

  • scanf("%127s", input);
    ・input へ最大127文字まで読み込む(最後の '\0' のための余裕を残す)
    ・input は配列なので input 自体が先頭要素へのポインタとして渡される(& は不要)

※scanf("%s", input) のように上限なしにすると、input 側が溢れる危険もあります。ここではそれも避けています。

printf の書式

  • 書式:printf(書式文字列, 引数, ...);
  • 何をする命令?:値を整形して表示する
  • %s:ポインタが指す先を '\0' まで文字列として表示する

どう直すのが安全?(方向だけ押さえよう)

安全な形への置き換え

やりたいこと危険なやり方安全なやり方
書き換える文字列を持ちたいchar *msg = "OK"; をコピー先にするchar msg[128] = "OK"; のように配列で領域確保
長さが分からない入力をコピーサイズ無視のコピー関数を使うコピー先サイズを前提に扱う(長さ制限つき)

ここで大事なのは「ポインタ変数にコピーしても安全になるわけではない」という点です。
安全なのは“十分な書き込み可能領域”を確保したときだけです。