C言語でオセロを作ったらscanf()の脆弱性をつつかれたので対策した話

# はじめに
筆者はプログラミングを初めて半年も経ってない大学生です。
C言語を用いてCUI上で縦横の座標を入力して対戦するオセロを作ったら先輩にscanfの脆弱性をつつかれ修正に非常に手間取ったので頑張って修正した流れを記したいと思います

## 事の始まり
Githubの勉強も兼ねてオセロを制作していて、実際に完成したコードをGithubを通して大学の先輩方にレビューしてもらった所、とあるCTFが好きな先輩が3つのissueを立てていきました。そのissue3つは以下の通りです
1. ルール上置けてはいけない場所に石を置けてしまう
1. 座標に負の数を入力できてしまう
1. 座標に整数以外も入力できてしまう

今回問題になったのは3の座標に整数以外も入力できてしまう問題です。入力にはscanf()、座標用の変数にはint型の変数を使っているので小数やその他の文字を入力してしまうと意図しない挙動の原因となってしまいます。私の場合while文内でscanf()を使っていたのですが一度整数以外を入力するとそのプログラムを終えるまで何も入力できなくなっていました。

# 実際に修正していこう
今回座標を入力するための関数inputをfileio.cファイル内で定義し、その関数をmain関数内で呼び出す形で作っていきます。まずは整数ではなく文字や小数を入力されても大丈夫な形に持っていきます。
~~~C:fileio.c
#include <stdio.h>

int input()
{
    int num;
    scanf("%d", &num);

    return num;
}
~~~
これが元のコードです。方針としては文字列として入力させint型に変換して変数に代入する形で対策していきます。入力にはfgets()、変換にはstrtol()を使います。atoi()ではなくstrtol()な理由は意図せぬ入力で正常に変換できなかった場合に対応する為です

:::note info
他の関数と違いfgets()は用意していた配列よりも長い文字列を入力した際にはオーバーした部分を無視して自動で配列の最後にヌル文字を代入してくれます。無視した部分は入力ストリームのバッファに溜め込まれ、それ以降の入力に使われていきます。
:::

~~~C:fileio.c
#include <stdio.h>
#include <stdlib.h> //strtol()を使うのに必要

int input()
{
    char buf[3];  //小数等に対応するため3にしています
    int num;
    char *s;
    long longnum;

    fgets(buf, sizeof(buf), stdin);
    longnum = strtol(buf, &s, 10);
    num = (int)longnum;

    return num;
}
~~~
これで整数以外を入力されても大丈夫な関数inputが定義できました。

# またしても現れる意図しない挙動
よしこれで整数以外を入力できてしまうissueも解決できそうだぞ～と思っていた矢先また意図いない挙動が発生しました。今回の場合はinput内で2文字以上入力した次に入力を行うための関数を呼び出した際に起こります。例えば先程定義したinput()を使って数字を入力すると入力した数字を出力するという作業を二回行うプログラムを作り、一回目の入力に"123"と入力したとすると以下のような挙動をすると思います。
~~~
123　//入力した数字
12   //一度目の出力
3    //二度目の出力
~~~
入力したのは一度のはずなのに勝手に二回目の入力があったかのような挙動をしています。理由は先程も説明した通りfgets()は入りきらなかった分がある場合バッファに溜め込み次入力するタイミングでバッファから吐き出してその入力を行うという仕様によるものです。この例の場合は"3\n"をバッファに溜め込み二回目のinput()を呼び出した際バッファから"3\n"を入力して入力を終えています。
scanf()を使っていた時はこのような挙動は見なかったのですがこれはscanf()が空白や改行までを一度の入力として受け取り空白や改行を無視するからです。
たくさんの文字を入力したら勝手に何回も行動した判定になるようでは困るのでバッファに入る分の入力した文字は不要です。なので入力後バッファに何も残らないよう変更します。
入出力ストリームのバッファの挙動を設定するsetvbuf関数やsetbuf関数がありますが、筆者には使いこなせずお望みの挙動にはなりませんでした。なので入力ストリームのバッファにある分を空読みして一回の入力毎にバッファを空にする作業を挟む形で対応します。
バッファの仕様を使い無限ループ内で1文字の入力を受け取るgetchar()を呼び出し続け空読みを行います。getchar()で読み込んだ文字を返り値に持つので、読み込んだ文字が入力を終えるための'\n'である、もしくは標準入力のEOFが来た際に無限ループを抜け出す事でバッファを空にすることができます。

~~~C:fileio.c
#include <stdio.h>
#include <stdlib.h> //strtol()を使うのに必要
#include <string.h>

int input()
{
    char buf[3];  //小数等に対応するため3にしています
    int i, num;
    char *s, *p;
    long longnum;

    fgets(buf, sizeof(buf), stdin);
    longnum = strtol(buf, &s, 10);
    num = (int)longnum;
    p = strchr(buf, '\n');
    if(p != NULL){  //strchr()は見つかれば場合最初に見つけた文字へのポインタを返す
        *p = '\0';  //1桁の整数を入力した場合改行が邪魔なので改行をヌル文字にする
    } else {
        for(i = 0;;i++){
            if(getchar() == '\n'){  //多く入力した場合は空読みをする
                break;
            }
            if(feof(stdin)){ //feof()はEOF以外なら0,EOFなら0以外を返す
                break;
            }
        }
    }

    return num;
}
~~~
これでどのような入力を行っても複数回呼び出しても正常な動作をする入力関数input()を定義できました。

:::note alert
バッファに蓄えられたデータを全て放出する関数fflush()がありますがこの関数は入力ストリームのバッファに対しては未定義の動作をします。今回の場合標準入力を使っているので絶対に使わないでください
:::

# 整数以外を入力したら再入力をさせたい
そもそものissueは「座標に整数以外も入力できてしまう」でした。ここまでは入力されても大丈夫な形にしただけで整数以外を入力できることには変わりません。なのでここからは入力したものが整数以外だった場合再度入力させるように変更していきます。オセロの座標の範囲外である大きい数や負の数はmain関数内で判定するとして、文字や小数を入力した場合、本来入力できない大きさの整数を返り値として持たせることで、返り値で整数なのか整数以外だったのかを判別します。またエンターのみの入力をされるとうまく判定できなかったのでstrcmp()で別で判定しています。
~~~C:fileio.c
#include <stdio.h>
#include <stdlib.h> //strtol()を使うのに必要
#include <string.h>

int input()
{
    char buf[3];  //小数等に対応するため3にしています
    char entercheck[2] = "\n";
    int i, num;
    char *s, *p;
    long longnum;

    fgets(buf, sizeof(buf), stdin);
    longnum = strtol(buf, &s, 10); //変換できなかった最初の文字へのポインタをsに格納できる
    num = (int)longnum;
    if(strcmp(entercheck, buf) == 0){ //入力がエンターのみの場合0になってしまうので別で判定
    return 400;
    }
    p = strchr(buf, '\n');
    if(p != NULL){  //strchr()は見つかれば場合最初に見つけた文字へのポインタを返す
        *p = '\0';  //1桁の整数を入力した場合改行が邪魔なので改行をヌル文字にする
    } else {
        for(i = 0;;i++){
            if(getchar() == '\n'){  //多く入力した場合は空読みをする
                break;
            }
            if(feof(stdin)){ //feof()はEOF以外なら0,EOFなら0以外を返す
                break;
            }
        }
    }
    //1,2文字目にヌル文字以外の変換できない文字がある場合400を返す
    if(*s != '\0'){
        return 400;
    }

    return num;
}
~~~
入力に使う配列の大きさは3なので2桁より大きい整数は入力できません。なので返り値に本来入力できない400を持たせます。後はmain関数内で返り値が400だった場合再度input()を呼び出すようにしたら整数以外入力できない関数input()の完成です。
これでissueの「座標に整数以外も入力できてしまう」を解決することができました。

:::note info
strtol関数は文字列をlong型に変換する関数ですが変換できないもの(文字等)があった場合最初の変換できなかったものへのポインタを返します。今回は変換を正常に行えたかどうかを確認する必要があったので使っています。
:::

# (追記)より目的に沿った実装を求めて
@fujitanozomu さんよりコメントで今回の「1~8の数字の入力だけ受け付けるようにしたい」という目的により適したinput関数の実装案を頂いたので追記します。
~~~C:fileio.c
#include <stdio.h>

#define InputErr 400

int input(void)
{
    char n[2], e[2];
    if (scanf("%1[12345678]%1[\n]", n, e) < 2) { //1~8以外の入力の場合は受け付けず、バッファの分を空読みする
        scanf("%*[^\n]"); //改行がくるまで空読み
        scanf("%*1[\n]"); //改行を空読み
        return InputErr;
    }
    return n[0] - '0';
} 
~~~
今回頂いた実装案です。if文内でscanf()を使い入力をさせていますが書式設定で1文字目は1~8の文字だけを、2文字目には改行のみを受け付ける書式設定となっています。。またscanf()は書式設定に沿って入力できた書式設定の数を返り値に持ちます。今回書式設定は2つあるので1文字目は1~8の文字だけを、2文字目には改行を入力した場合は返り値は2になり、それ以外の入力を行うと1つ目か2つ目、もしくは両方の書式設定に沿わない入力となるので返り値は0か1になります。もし返り値が2より小さい場合は空読みをし、その後入力に失敗したことを表すための返り値400を返しています。また正常に入力した場合の返り値である"n[0] - '0'"について、C言語では文字もASCIIコードによって割り振られた整数で扱われており、ASCIIコードにおいて0~9は数字の順にならんでいるのでn[0]に格納されている入力した文字1~8と文字0の割り振られた番号の差が入力した数字となるのでその数字をint型として返しています。返り値がint型かつ一桁だという前提があるからこそできることですね。
筆者の考えたコードとは方法がかなり異なりますしscanf()の書式設定について知らない部分があったので思いつきませんでしたがその部分の知識さえあれば筆者の考えたコードよりよっぽどシンプルで読みやすいと思います。

# 終わりに
今回この問題を解決するのにとても時間を使ったこと、そして私と同じようにプログラミングの勉強をしている友達も似た問題で躓いていたこと、そして記事を書こうという先輩からの提案で今回この題材で記事を書いてみました。入出力に関してまだまだ知らないことがたくさんありそうなので今後も勉強していきたいと思います。