【マイグレーションTips】Cのプログラム移植/移植後に見つかる潜在バグ例(RHEL8へのバージョンアップ)
はじめに
皆さん、こんにちは。株式会社SHIFT アプリケーションサービスGの村田です。主にお客様が使用するコンピュータシステムのマイグレーション作業支援を担当しています。
マイグレーションTipsと題して、マイグレーション作業を行う技術者に向けて役に立つと考えられる情報を選んで紹介しています。
※「マイグレーションって何?」と思われた方は、以下の記事で説明していますので先にお読み頂けると嬉しいです。
【現象】RedHat LinuxをRHEL8にバージョンアップしたら、Cプログラムの実行結果が変わってしまった!
今回紹介する事例は前回に引き続き、C言語のプログラムを移植する時に遭遇した問題への解決方法です。
システム:RedHat Linuxのバージョンアップ(RHEL6.5→RHEL8.2)
言語:C言語
コンパイラ:RedHat Linux/gcc
事象:RHEL6.5で稼働中のシステムをRHEL8.2に刷新。
RHEL6.5で稼働中のプログラムソースをリコンパイルして同じデータでテストしたら、RHEL8.2では実行結果が不正に変わってしまった。問題箇所の一次切り分けを実施したが、プログラムソースは変更しておらず、原因がわからない。
OSをバージョンアップしたことが契機なので、移植手順の問題?それともコンパイラのバグ?などいろいろと考えてしまいます。
しかし今回の事例はそうではありませんでした。
簡単なサンプルコードで説明しましょう。
-- sample.c --
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
main()
{
char workb[12];
char worka[8];
char cnum[9];
char cmoji[] = "abcdefghij";
int num = 20230808;
sprintf(cnum, "%d", num);
memcpy(workb, cmoji, sizeof(workb));
memcpy(worka, cnum, sizeof(cnum));
printf("worka : %s\n", worka);
printf("workb : %s\n", workb);
exit(0);
}
問題が起きたプログラムから、ポイントとなるコードを抽出しました。
int型変数 numとchar型変数 cmojiのデータを別変数に格納した後に標準出力へ表示する簡単なプログラムです。
RHEL 6.5、RHEL 8.2共にフラグ0でコンパイル終了したので、テストしてみたところ…
RHEL6.5では2つの変数の内容を表示できましたが、RHEL8.2では片方の変数workbの内容が表示されなくなってしまいました。
プログラムを見直したものの、一見どこも悪いようには見えません。
一体何が起きているのでしょうか。
【原因】memcpy関数のサイズ指定誤りによるメモリ破壊の潜在バグが顕在化!
本件はmemcpy関数のサイズ指定誤りによりメモリ破壊が発生する潜在バグの発現でした。
<バグの内容>
memcpy関数でメモリをコピーする際、コピー先となる第1引数に指定した変数のサイズより大きなサイズを第3引数のサイズに指定していました。
これによりバッファオーバーフローが発生し、第1引数に指定した変数の後ろに位置するメモリの内容を壊していました。
文章で書くと「コピー先より大きなデータをコピー先にコピーして壊す」という当たり前のような内容なのですが、C言語では注意していないとよく間違える事象の一つです。
サンプルコードにあるmemcpy関数の使用箇所をピックアップしました。
memcpy(workb, cmoji, sizeof(workb));
memcpy(worka, cnum, sizeof(cnum));
それぞれの変数の宣言は、以下のとおりです。
char workb[12];
char worka[8];
char cnum[9];
char cmoji[] = "abcdefghij";
変数 workbに対するmemcpy関数の第3引数には"sizeof(workb)"を指定しています。コピー先の変数workbのサイズ(=12)を指定しているため、12バイトより多くコピーすることはありません。
一方、変数 workaに対するmemcpy関数の第3引数は"sizeof(cnum)"を指定していました。変数 cnumのサイズは9バイトであり、8バイトの変数 workaに9バイトコピーすることで変数worka の後ろにある1バイトを壊してしまいました(潜在バグ)。
では、なぜこの潜在バグが顕在化したことで、プログラムの実行結果が変わってしまったのでしょうか?
サンプルコードにデバッグ用の命令を埋め込んで各変数のメモリ中のアドレスを確認し、メモリ内容を検証してみました。
/** デバッグ:変数のアドレスを表示 **/
printf("worka-addr : %08lx\n", worka);
printf("workb-addr : %08lx\n", workb);
printf("cnum-addr : %08lx\n", cnum);
printf("cmoji-addr : %08lx\n", cmoji);
printf("num-addr : %08lx\n", &num);
検証した結果、変数workaと変数workbはソースコードではworkb→workaの順に記載していますが、メモリ上ではworka→workbの順に連続で配置されていたことがわかりました。
この状態で潜在バグのあるmemcpy関数を実行すると、workaの後ろ1バイト=workbの先頭1バイトを壊します。
workbの先頭1バイトが0x00に書き換えられたため、workbをprintf文で表示すると何も表示されない状態になってしまいました。
【対策】memcpy関数の引数に指定する領域のサイズとコピーするサイズを見直す
この問題の対策としては、memcpy関数の引数を点検します。
コピー先領域(第1引数)のサイズ≧第3引数の数値 を満たすように指定しなければいけません。
[対策1] コピー先領域(第1引数)のサイズを拡張する
変数workaの宣言において、変数のサイズを変数cnumのサイズにあわせて、8バイトから9バイトに拡張します。
char workb[12];
/* char worka[8]; *//* 対策前 */
char worka[9]; /* 対策後 */
char cnum[9];
char cmoji[] = "abcdefghij";
:
memcpy(workb, cmoji, sizeof(workb));
memcpy(worka, cnum, sizeof(cnum));
[対策2] 第3引数の数値として、コピー先領域(第1引数)のサイズを超えないように指定する
変数workaに対するmemcpy関数の第3引数を見直し、バッファオーバーフローが発生しないようにします。
char workb[12];
char worka[8];
char cnum[9];
char cmoji[] = "abcdefghij";
:
memcpy(workb, cmoji, sizeof(workb));
/* memcpy(worka, cnum, sizeof(cnum)); *//* 対策前 */
memcpy(worka, cnum, sizeof(worka)); /* 対策後 */
コピー対象をコピー先領域のサイズまでに抑えるという対策です。
"sizeof(worka)"の代わりに"strlen(cnum)"も使えるのでは?とも思えますが、お勧めしません。
str系関数は引数に指定する文字列がNULL終端保証されていることが前提です。変数に格納する内容がすべて把握できるのであれば使うことに問題はありませんが、関数の引数など値が可変の場合はコピー先領域のサイズを超えた数値を返す可能性があり、バッファオーバーフロー対策としては不完全となります。
またmemcpy関数の代わりにstrncpy関数を使うことも可能です。
memcpy(workb, cmoji, sizeof(workb));
/* memcpy(worka, cnum, sizeof(cnum)); *//* 対策前 */
strncpy(worka, cnum, sizeof(worka)); /* 別対策 */
strncpy関数を使う場合でも、第3引数にはコピー先領域(第1引数)のサイズを超えないように指定する必要がありますので、注意しましょう。
前回の記事でも書きましたが、メモリ配置に関するバグはソースコードからは見つけずらいため、調査に時間がかかります。
今まで「たまたま」動いていた処理がマイグレーションによりバグが顕在化するケースもありますので、新たなバグを作りこまないよう注意したいところです。
【疑問】マイグレーション前のシステムで潜在バグが顕在化しなかったのはなぜ?
ここで一つの疑問が生じます。今回のマイグレーションはRedHat Linuxのバージョンアップですが、マイグレーション前のシステムでは潜在バグが顕在化していませんでした。
同じOSなのになぜ前のバージョンでは顕在化しなかったのでしょうか?
顕在化しなかった理由を説明する必要があるため、マイグレーション前のシステム(RHEL6.5)で今回の潜在バグ調査に使ったデバッグ用命令を組み込んだサンプルコードで検証しました。
/** デバッグ:変数のアドレスを表示 **/
printf("worka-addr : %08lx\n", worka);
printf("workb-addr : %08lx\n", workb);
printf("cnum-addr : %08lx\n", cnum);
printf("cmoji-addr : %08lx\n", cmoji);
printf("num-addr : %08lx\n", &num);
変数workaとworkbのメモリ配置を確認すると、潜在バグが発現したRHEL8.2ではworkaとworkbが隣り合っていましたが、RHEL6.5では離れたアドレスに配置されていました。
C言語では、関数内で宣言した自動変数は関数が動く時にメモリを割り当て、関数からリターンする時に割り当てたメモリを解放します。
メモリ割り当て処理の仕様はブラックボックスですが、検証結果から以下のように動いているものと推測されます。
RHEL6.5(gcc 4.4.6)
自動変数のメモリを割り当てる際、変数毎に16バイトバウンダリを確保して割り当てるRHEL8.2(gcc 8.3.1)
自動変数のメモリを割り当てる際、同じ型の変数であれば連続した領域を確保して割り当てる
今回取り上げた潜在バグは変数の直後にあるメモリ領域を壊すバグでした。
RHEL6.5
変数の後ろにあるメモリ領域がバウンダリ調整用の領域のため、別変数のメモリは壊していない⇒潜在バグは顕在化しないRHEL8.2 連続で配置された別変数のメモリを壊した⇒潜在バグが顕在化
マイグレーションを行う際、OSのバージョンアップが引き金となって今まで隠れていた潜在バグが顕在化する可能性があります。
作業スケジュールを計画する時はソースプログラムの潜在バグ点検作業を早い段階で組み込むことをお勧めします。
※ちなみに変数宣言は関数の外でも宣言できるので、サンプルコードの変数を関数内(自動変数)から関数の外(広域変数)に移動して検証したところ、RHEL6.5でも潜在バグが発現しました。
何はともあれ、今後は原因で示したようなコードを作りこまないことが重要となります。
セルフチェックやコードレビューにおいて、以下に示す観点を意識するようにして下さい。
memcpy関数及びmemset関数を使用する際、コピー及び設定先(第1引数)の領域サイズと取り扱うデータのサイズのバランスが取れていること
⇒コピー及び設定先(第1引数)の領域サイズよりも大きなサイズのデータをコピーまたは設定しようとしていないかの確認が必要mem系関数の第3引数(サイズ)には、値が変わる変数を極力指定しないこと
⇒特にstrlen関数は引数に指定した領域の文字列長により値が変化するため、使用は避けること。値が固定となるsizeof句や定数の指定が望ましい
最後に
マイグレーションTipsとして「Cのプログラム移植/移植後に見つかる潜在バグ例(RHEL8へのバージョンアップ)」をご紹介しましたが、いかがだったでしょうか。
Redhat Linuxは、古いバージョンのサービス提供終了(EOSL(End of Service Life))が迫ってきたこともあり、RHEL8やRHEL9へのバージョンアップに伴うマイグレーション案件が増えてきています。
RHEL6 延長ライフサポート(ELS) 2024年6月30日終了。以降サポートなし
RHEL7 メンテナンスサポート2 2024年6月30日終了
延長ライフサポート(ELS) 2028年5月31日終了。以降サポートなし
マイグレーションに携わる技術者の皆さんに、この記事が目に留まり少しでも役立てて頂ければ幸いです。
マイグレーションTipsの記事一覧
マイグレーション支援サービスのご紹介
株式会社SHIFTでは、コンピュータシステムのマイグレーションを円滑に進めるための支援サービスを提供しています。
マイグレーションに関する支援サービスの詳細は以下のとおりです。
システムのマイグレーションをご検討、または課題解決に困っている方がいらっしゃったら、以下のページよりお気軽に問い合わせいただければ幸いです。
《お問合せはお気軽に》
SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/
SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/
SHIFTの導入事例
https://service.shiftinc.jp/case/
お役立ち資料はこちら
https://service.shiftinc.jp/resources/
SHIFTの採用情報はこちら