【C言語】
メモリ破壊の原因と対策
~発生パターンと解決方法~

warning: iteration 1024 invokes undefined behavior

警告:ループカウンタ 1024 で未定義動作
[-Waggressive-loop-optimizations]

[gcc -fsanitize=addres]
[gcc -Wall -Wextra -O3]


■この記事の概要

メモリ破壊とは、簡単に言うと
主に配列からはみ出た領域に書き込みする事です。
この記事では、C言語におけるメモリ破壊の原因と影響、
strcpyやstrncpyなどの関数を使用する際の注意点を
サンプルコードを用いて詳しく解説しています。
GCCの警告とエラーを踏まえたプログラムの
安全性向上についても紹介します。


■文字列操作関数系のメモリ破壊

➡strcpy

#include <stdio.h>
#include <string.h>
//gcc -Wall -O
//warning: ‘__builtin___strcpy_chk’ writing 94 bytes 
//into a region of size 16 overflows the destination 
//[-Wstringop-overflow=]
int main(void) {
    char    buf[16];
    char    *jugemu = "寿限無寿限無五劫のすり切れ海砂利水魚の水行末雲来末風来末~~~";
    strcpy(buf, jugemu);
    puts(buf);
}

この問題は
gcc -Wall -Wextra -Oで検出できます。  
※ーOで最適化しないと検出できません。

参考:

汝が
“foo”とタイプするところで
“supercalifragilisticexpialidocious”とタイプする者が、
いつの日か必ずいるからである


➡strncpy

#include <stdio.h>
#include <string.h>
//clang -Wall 
//warning: 'strncpy' size argument is too large; 
//destination buffer has size 4, 
//but size argument is 1024 
//[-Wfortify-source]
int main(void) {
    char    buf[4];
    strncpy(buf,"abc",1024);
    puts(buf);
}

4byte領域に”abc”3byteと\0をコピーするので
問題なさそうですが、
第三引数の1024がくせ者です。

➡strcat

#include <stdio.h>
#include <string.h>
//gcc strcat.c -Wall  -O2
// warning: ‘__builtin___strcat_chk’ forming offset [4, 6] is 
//out of the bounds
//[0, 4] of object ‘buf1’ with type ‘char[4]’ 
//[-Warray-bounds]
int main(void){
    char    buf1[] = "abc";
    char    buf2[] = "def";
    strcat(buf1,buf2);
    puts(buf1);
}

第一引数に第二引数を加える領域がありません。

➡memcpy

#include <stdio.h>
#include <string.h>
//gcc -Wall -Wextra  -O -fsanitize=address
//warning: ‘__builtin_memcpy’ forming offset [4, 1023] is 
//out of the bounds [0, 4] 
//[-Warray-bounds]
int main(void){
    char buf[1024];
    //OK:"abc"の後ろは0で埋まる
    strncpy(buf,"abc",sizeof(buf));
    puts(buf);

    //NG:"def"の後ろを領域外参照する
    memcpy(buf,"def",sizeof(buf));
    puts(buf);
}

文字列定数”def”の後ろを参照してしまいます。

➡sprintf

#include <stdio.h>
//gcc -fsanitize=address
int main(void){
    
    char     input[4] = "abcd";//終端文字が無い
    char    output[5];
    
    sprintf(output,"%s",input);
    puts(output);
}

inputに終端文字\0が無いので
暴走して
\0を見つけるまでoutputに書き込みします。

■標準ライブラリ関数系のメモリ破壊

➡scanf

#include <stdio.h>
//gcc -fsanitize=address
int main(void){  
    printf("4桁の数字を入れてね");

    char    buf[4];//終端文字を考慮していない
    scanf("%s",buf);
    printf("%sが入力されました\n",buf);
}

4桁の数字文字列を収容するには終端文字\0を入れて5byte必要です。

➡fgets

#include <stdio.h>
//warning: 
//‘fgets’ writing 8192 bytes into a region of size 4 
//overflows the destination
//[-Wstringop-overflow=]
int main(void){  
    int     num;
    char    buf[4];

    printf("4桁の数字を入れてね");
    fgets(buf,BUFSIZ,stdin);
    sscanf(buf,"%d",&num);
    printf("%dが入力されました\n",num);
}

4桁の数字文字列を収容するには終端文字\0を入れて5byte必要です。

■ループ系のメモリ破壊

➡<と<=を間違える

static int  ary[1024];
int main(void){
    for(int i = 0 ; i <= 1024 ;i++){//★NG
        ary[i] = 0 ;
    }
}
static int  ary[1024];
int main(void){
    for(int i = 0 ; i < 1024 ;i++){
        ary[i] = 0 ;
    }
}

修正例は<に変更する。


➡回り切ったカウンタを添字に使う

//gcc   -Wall -Wextra -O2
//warning: array subscript 10 is above array bounds of ‘int[10]’
//[-Warray-bounds]
int main(void){
    int     ary[10]={0};
    int     i;
    for(i=0;i<10;i++){
        ary[i] = 0;
    }
    
    //ダメ:回り切ったiは10になってる
    return  ary[i];
}

forループを回り切ると添字の i は10になっているので
ary[10] の配列範囲外となります。
回り切った時のiを添字に使う時は注意してください。

■配列系のメモリ破壊

➡配列の添え字が一つ多い
(Off-by-one Error)

warning: array subscript 10 is outside array bounds of ‘char[10]’

警告:配列の添字 10 は ‘char[10]’ の配列境界外です。
[-Warray-bounds]

//gcc -Wall -Wextra  -O2
//warning: array subscript 10 is above array bounds of ‘int[10]’ 
//[-Warray-bounds]
int main(void){
    int     ary[10]={0};
    
    //ダメ:参照できるのはary[0]~ary[9]まで
    return  ary[10];
}

配列は ary[10]で宣言したら、
参照できるのはary[0]~ary[9]までです。
ary[10]は参照できません。
ary[10]を参照すると未定義動作となり何が起きるかわかりません。


➡要素数とサイズを間違える

#include <stdint.h>
static char aryC[1024];
static int  aryI[1024];
int main(void){
    //たまたま上手くいく例
    for(uint32_t i = 0 ; i < sizeof(aryC) ;i++){
        aryC[i] = 0 ;
    }
    //配列の要素数とサイズは違う
    for(uint32_t i = 0 ; i < sizeof(aryI) ;i++){//★NG
        aryI[i] = 0 ;
    }
}

要素数はサイズではありません。
sizeof(aryI) != 1024
sizeof(aryI) == sizeof(int)*1024 
です。

このコードではLP64モードの場合、4096回転するので
配列のメモリ破壊が発生します。

➡修正例

#include <stdint.h>
static char aryC[1024];
static int  aryI[1024];
int main(void){
    const uint32_t maxC = sizeof(aryC)/sizeof(aryC[0]);
    for(uint32_t i = 0 ; i < maxC ;i++){
        aryC[i] = 0 ;
    }
    
    const uint32_t maxI = sizeof(aryI)/sizeof(aryI[0]);
    for(uint32_t i = 0 ; i < maxI ;i++){
        aryI[i] = 0 ;
    }
}

配列の要素数を求める定石 sizeof(配列)/sizeof(配列[0])を使います。


➡配列[-1]を参照

warning: array subscript -1 is below array bounds of ‘char[10]’
警告:配列[-1]を参照した

[-fsanitize=address -O]
[-Warray-bounds]

//clang  -fsanitize=address -O
#include <stdio.h>
int main(void) {
    int     idx  = -1 ;
    char buf[10]="0123456789";
    printf("%c\n",buf[idx]);
}

buf[-1]を参照してしまいます。
プログラムが異常終了するかどうかは
運しだいです。

➡降順ループで配列[-1]を参照

//gcc -fsanitize=address
#include <stdio.h>
#include <string.h>
int main(void){
    char    str[] = "ABCDEFG";
    for(int i = strlen(str);i>=0;i--){
        printf("src[%d]=:%x:=%c:\n",
            i-1,
            str[i-1],
            str[i-1]
        );
    }
    printf("\n");
}

降順ループのこの例ではiが0の時
src[i-1]で
src[-1]を参照してしまいます。


■終端文字系のメモリ破壊

¥0の1byte考慮抜けてる

warning: ‘__builtin_memcpy’ writing 5 bytes into a region of size 4 overflows the destination

警告:サイズ4の領域へ5バイト書き込み
[-Wstringop-overflow=]

//warning: ‘__builtin_memcpy’ writing 5 bytes into a region of size 4 overflows the destination 
//[-Wstringop-overflow=]
#include    <stdio.h>
#include    <string.h>

int main(void) {
    char    buf[4];
    strcpy(buf,"1234");
    puts(buf);
}

終端文字¥0の考慮が抜けています。
コピー先が
4byteしかないのに
5byteコピーしました。

■エラーチェックの不良

➡添字のチェック間違い

//gcc -Wall -Wextra  -O2 
//warning: array subscript 4 is above array bounds of ‘int[4]’ 
//[-Warray-bounds]
#include <stdio.h>
int f(int idx){
    int buf[4] = {'A','B','C','D'} ;
    if(idx > 4) {//ダメ:idx == 4 を許容する
        return  'X' ;
    }
    return  buf[idx] ;
}
int main(void){
    printf("%c\n",f(4));
}

添字のチェック条件が誤っているので
idx が4の時も
制御が下へ行ってbuf[4]を参照してしまいます。
if(idx > 4) { ではなくて
if(idx >= 4) {でしょう。


➡チェックの順番

(style) Array index ‘idx’ is used before limits check.
警告:配列添字を範囲チェック前に使った[(style)arrayIndexThenCheck]

//gcc f60.c -fsanitize=address
#include <stdio.h>
char *f(int idx){
    char *buf[4] = {"赤","青","黄",NULL} ;
    if(buf[idx] != NULL && (idx < 4)) {
        return  buf[idx] ;
    }
    return  "無効" ;
}
int main(void){
    printf("%s\n",f(0));
    printf("%s\n",f(1));
    printf("%s\n",f(2));
    printf("%s\n",f(3));
    printf("%s\n",f(4));
}

if((ary[idx]!=NULL) && (idx<MAX)){は
第一条件(ary[idx]!=NULL) と
第二条件 (idx<MAX)を
同時に評価するのではなく
左から順番に評価します。

このため(ary[idx]を参照してしまった後で
(idx<MAX)のチェックをしても手遅れです。

配列領域外参照が発生する可能性があり、
これは未定義動作なので
プログラムが異常終了するかもしれません。

筆者の環境下では以下のようになりました。
gcc -fsanitize=address⇒異常終了する
gcc -fsanitize=address -O2⇒正常終了する

➡修正後:if(範囲チェック&&配列[添字])

//gcc f60.c -fsanitize=address
#include <stdio.h>
char *f(int idx){
    char *buf[4] = {"赤","青","黄",NULL} ;
    if((idx < 4) && (buf[idx] != NULL)) {
        return  buf[idx] ;
    }
    return  "無効" ;
}
int main(void){
    printf("%s\n",f(0));
    printf("%s\n",f(1));
    printf("%s\n",f(2));
    printf("%s\n",f(3));
    printf("%s\n",f(4));
}

if((idx<MAX) && (ary[idx]!=NULL))と
記述すると
第一条件(idx<MAX) が成立しなければ
第二条件(ary[idx]!=NULL)は評価されないので
配列領域外参照は発生しません。


参考:

EXP08-C. ポインタ演算は正しく使用する

ARR30-C. 境界外を指すポインタや配列添字を生成したり使用したりしない

CWE-125: Out-of-bounds Read

CWE-787: Out-of-bounds Write

CWE-193: Off-by-one Error