<< セキュリティキャンプ2024 3日目のCコンパイラゼミ | Cコンパイラゼミ2024

ドーナツのupdate2でセグフォが起きる

Program received signal SIGSEGV, Segmentation fault.
update2 () at tmp.s:524
524      push rax
(gdb) backtrace
#0  update2 () at tmp.s:524
#1  0x0000000000402311 in main () at tmp.s:2428

push命令でセグフォが起きる。謎い。(後から思うと、pushでセグフォって時点で実は結構絞られて、経験があればここからすぐに原因が分かったのかもしれない。)

コードの一部をコメントアウトした所、今度はupdate2の中で呼び出しているmov命令でセグフォが出るようになった。

コードの一部をコメントアウトしたupdate2の様子

void update2(FloatTimes10000 *cos, FloatTimes10000 *sin, FloatTimes10000 cos_delta, FloatTimes10000 sin_delta) {
  // FloatTimes10000 new_cos = mul(*cos, cos_delta) - mul(*sin, sin_delta);
  FloatTimes10000 new_sin = mul(*sin, cos_delta) + mul(*cos, sin_delta);
  // *cos = new_cos;
  *sin = new_sin;
}

そしてそのセグフォ

Program received signal SIGSEGV, Segmentation fault.
mul () at tmp.s:417
417      mov [rbp-12], edi
(gdb) backtrace
#0  mul () at tmp.s:417
#1  0x00000000004018b1 in update2 () at tmp.s:533
#2  0x000000000040227b in main () at tmp.s:2298

アセンブリは以下のようになっていうr

mul:
  push rbp
  mov rbp, rsp
  sub rsp, 16
  mov [rbp-12], edi # <- ここでセグフォ

この関数は何度も呼ばれているので、まずは変な入力が来ている可能性を疑った。printfで引数を出力してみた。しかし、ぱっと見問題の有りそうな入力は来ていなかった。セグフォする回の引数をコピーしてminimizeしてみたが、動いてしまった。不思議。

引数を全部表示するようにしたが、gdbで動かしたときと、普通に動かしたときでセグフォする際の入力が異なる。(後から思うと、これは伏線だった)。このとき考えた可能性は次の2つ。

  • 標準出力フラッシュされていない
    • fflashをしてフラッシュしようと考えた。しかし、stdoutを実装していないので厳しそうという結論になった
  • 何らかのランダム要素(例えばメモリアドレス)で落ちている

printfを追加したコードをgdbで動かしてみると、今度はprintfで落ちるようになった。もはや何でもあり。当時は悪いポインタを使っている(つまりprintfの引数あたりが悪そう?)を疑った。

2日目は16バイトアライメントで似たような地点で散々落ちていたが、16バイトアライメントは根本治療したので問題ないはず。問題ないよな?と疑心暗鬼になっていた。

セグフォ時のレジスタを見て、ランダム要素を探ることにした。rspは0x7fffff7ff000(実はこれも伏線だった)。レジスタのランダム要素はrbpとrspと、たまにr15とeflags。

次にupdate2がセグフォをする回数を探ってみることにした。普通の実行では標準出力がフラッシュされていない可能性もあるので、「if文の条件を変える」・「コンパイル」・「gdbで実行」を繰り返して二分探索をすることにした。辛い。

15分くらいかけて、77452回目のupdate2の呼び出しでセグフォが起きることが分かった。なんだよ77452って。しかも、少しコードを変えるとこの77452回も少しずつ変化することが分かった。

ここで講師が、ループが回るたびにスタックが浪費されて、スタックサイズの上限に達してエラーが出ているのではないかという案を出した。文を実行するたびにスタックにゴミが積まれているのは覚えていたので、完全にコレじゃんという気持ちになった。意外とスタックって小さいんだなぁと。そりゃ関数をちょっといじるだけでセグフォの位置が変わったりするわけだと。

このバグはもっと早く気づけそうな点があった。

  • そもそも文を実行するたびにスタックにゴミが残ることは認識していたけれど、このバグと関係があると気づかなかった
  • push命令は普通セグフォが出るような命令ではないので、pushで出そうなセグフォの原因という点から追うことができた
  • gdbで動かしたときと普通に動かしたときでエラー回数が違ったのは、gdb関連の関数の呼び出しでその分スタックの使用量が違ったからではないか
  • セグフォ時点でのrspは0x7fffff7ff000だったが、この値はやけに綺麗だった。スタックの最大値とかはキリの良い値にしがちなので、これからエスパーすることもできそう
  • 77452回目のupdate2の呼び出しで落ちていた。沢山動かすと落ちるという点から、どこかにゴミが溜まってるということを想像できた

約1時間で謎エラーは解決できた。

SIGFPE arithmetic exception

どうせどこかでゼロ割りしてそう。と思ったけれど違った。

printf("%d\n", o);
b[o] = bo;

こんな感じでデバッグ出力を入れた状態で実行すると、次のようなデバッグ出力が出た。

1018
-2013265920

ゼロ割りじゃなかった。

アセンブリを読んだ所、さっきの修正が悪さをしていた。適当にpopをしてたらpopしすぎてスタックを破壊していた。文やブロックは必ずpushするようにして、ブロックでpopする形に統一して解決した。

これを実装したところで、見事ドーナツのプログラムを動かすことに成功した。

#includeを実装する

11時くらいから#includeをどうやって実装するか悩んだ。特に__FILE____LINE__との相性が難しい。#includeを簡単に実装しようと思うと、#includeのあった行にインクルード先のファイルの文字列を入れて、1つの大きな文字列を作る形にすると良さそう。しかし、そうすると__FILE____LINE__を解析するための情報が抜けてしまうので、なんとかしないといけない。

__FILE____LINE__を削除することも考えたけれど、これらはエラー表示部分で大量に使っているし、使わない形で置き換えるのも大変。

__FILE____LINE__をプリプロセッサで置き換える方式は、実装が大変。C言語の本来の動作的には、__FILE____LINE__はプリプロセッサでリテラルに変換される。しかし、識別子をきちんと考慮しようとすると構文解析をする必要がある。また構文解析を実装するのは面倒くさいので、どうにかして楽に実装する道を探すことにした。ついでに、エラー表示を考えると構文解析以降にもファイルと行数の情報は必要なので、この方式は微妙。

__FILE____LINE__を構文解析以降で置き換える方式について考えた。#includeを使った際に、プリプロセッサからトークナイザにどうにかしてファイルと行数の情報を渡す必要がある。ここで2つの実装方針を比較した。

  1. 各行にファイルと行の情報を載せて、トークナイザで解析する
    • トークナイザは各行で情報が得られるので楽そう
  2. #includeをして、展開した前後にファイルと行数の情報をのせる
    • gccとかはこの方式を使っている 結局、実装の容易さを考えて2の方式を使うことにした。

とりあえずファイルと行数については置いておいて、簡単な#includeの実装をすることにした。バッファの扱いでしばらくバグって詰まったりしたものの、とりあえず実装できた。

愚かなコード

free(file_and_line);
free(after_buf);
i += strlen(file_and_line);

<ごはんタイム>

プリプロセッサからトークナイザにデータを送る部分について色々考えたものの、結局行末に// file:lineの形式で挿入することにした。とりあえず行コメントを使うことで、プリプロセッサ後のコードも実行できるようにした。

そんなこんなで__FILE____LINE__に対応した#includeも実装でき、一通り実装したい昨日は作り上げることができた。そして成果報告書の作成に移った。