1 of 66

プログラム解析入門

もしくはC/C++を安全に書くのが難しすぎる話

Last updated: Jul 30, 2022

Kinuko Yasuda <@kinu>

2 of 66

前置き

  • プログラムってちゃんと書くの難しいですよね�(特に C/C++ みたいな性能に全振りで�安全性は自分で考えてねみたいな言語…)

  • あとプログラミング界隈でときどき聞く静的解析とか動的解析とかってやつ、裏で何をしているのか?どうすれば作れるのか?

気になりますね 👀

3 of 66

この資料で説明すること・しないこと

その難しさと戦うために日々改良が行われているプログラム解析� (動的解析、静的解析) とプログラミング言語の安全性について、�基礎的な工学・数学的背景も含めてざっくり説明します。

(明日からすぐに自分のプログラムに適用して安全にしたり…�はできるようになりません😊、他の資料をあたってください)

なお、内容は個人の意見・理解に基づくものです

4 of 66

問題点の共有:C / C++ などの難しさ

C++ (や類似の古めの言語) では安全なコードを書くのがとにかく難しい!

  • C は 50年近く前 (1972年)に、C++ は 40年近く前 (1983年) に作られた
  • 性能や低レイヤの記述性に重きをおき、メモリ安全機構を持たない。安全性に関わらずポインタを使ってほぼどこのメモリでもさわることができる

ptr

???

適当なアドレスを�セットすれば

その中を見られる

でも中を見ちゃダメなところだったら�クラッシュする 😨

5 of 66

例えば… (1)

void func(std::optional<int> opt) {

use(opt.value());

}

void func(char* p) {

*p = 'c';

}

p nullptr や適当な値 (0xff) が来たらクラッシュ�null-deref, invalid access

opt に値がセットされていなかったら例外�bad_optional_access

6 of 66

例えば… (2)

int func(std::vector<int>& vec) {

auto& v0 = vec[0];

vec.emplace_back(1);

return v0;

}

void func(const std::vector<int>& vec) {

return vi[idx];

}

idx vec.len() -1 が来たら不正アクセス (すぐクラッシュするとは限らない)�vector overflow / underflow

emplace_back によって元の vec[0]はもう free されているかも知れない use-after-free

7 of 66

疑問

どうすればより安全なコードを書ける?

8 of 66

安全なプログラムを書くための戦略

  • がんばって危険なバグ(プログラム上の間違い)がないプログラムを書く
    • 間違いを起こさなくなるまで鍛錬を積む
    • 寝不足のときにコードを書かない
    • 詳しい人にレビューしてもらう
    • 品質が高いコードを書いたら表彰する

9 of 66

安全なプログラムを書くための戦略

  • がんばって危険なバグ(プログラム上の間違い)がないプログラムを書く
    • 間違いを起こさなくなるまで鍛錬を積む
    • 寝不足のときにコードを書かない
    • 詳しい人にレビューしてもらう
    • 品質が高いコードを書いたら表彰する

規模が大きくなると、

間違いを「絶対に」�起こさないのはムリです…

10 of 66

安全なプログラムを書くための戦略

  • がんばってバグ(プログラム上の間違い)がないプログラムを書く
  • テストを書く

11 of 66

安全なプログラムを書くための戦略

  • がんばってバグ(プログラム上の間違い)がないプログラムを書く
  • テストを書く

テストは素晴らしい!でも限界がある:

  • テストも人が書くので間違いがある�
  • 取りうるすべての入力とその組み合わせをテストすることは普通はできない!

1. プログラムに入力を与える

2. プログラムの出力と正解を比べる

12 of 66

安全なプログラムを書くための戦略

  • がんばってバグ(プログラム上の間違い)がないプログラムを書く
  • テストを書く
  • 動的解析・Fuzzing

13 of 66

そこで動的解析

動的解析とは、プログラムを実行しながらプログラムの解析を行う手法の総称

ものすごくざっくり説明するならば、例えば�

このようなチェック文をコンパイラやライブラリ越しに挿入することで、プログラム実行中に起きてはいけないことが起こってないか常にチェックする方法。応用範囲が広く強力

if (起きてはいけない条件があるか?) {� 報告してクラッシュする();�}

14 of 66

動的解析の例

多くの手法やライブラリが発表されており、大量のバグを見つけてきた。例:

  • AddressSanitizer (ASan): メモリエラーを見つける動的解析 (後述)
  • ThreadSanitizer (TSan): スレッド並行制御エラーを見つける動的解析
  • Undefined Behavior Sanitizer (UBSan): 未定義動作エラーを見つける動的解析
  • Fuzzing: よく ASan などと組み合わせて使われるテスト手法 (後述)
  • Chromium の MiraclePtr も動的解析的手法ですね

15 of 66

動的解析の例:Address Sanitizer (ASan)

メモリアクセスをトラックする Shadow memory と呼ばれる特別な領域を使って、許されていないメモリへのアクセスがないか実行時に(= 動的に)チェックする

Process Memory

Shadow Memory

if (IsPoisoned(p))

Crash();

*p = 0xb00;

*p = 0xb00;

コンパイル時にチェックを埋め込む

p

0xb00

p >> 3 + offset

Poisoned

アクセスしては�いけない領域

8バイトのメモリを1バイトの shadow 領域で管理

16 of 66

動的解析の例:Address Sanitizer (ASan)

メモリアクセスをトラックする Shadow memory と呼ばれる特別な領域を使って、許されていないメモリへのアクセスがないか実行時に(= 動的に)チェックする

Process Memory

Shadow Memory

*p = 0xb00;

コンパイル時にチェックを埋め込む

p

0xb00

p >> 3 + offset

Poisoned

アクセスしては�いけない領域

* チェックを入れる箇所をサンプリングしてオーバヘッドを抑え、リリースバイナリに入れる方法もあります

if (IsPoisoned(p))

Crash();

*p = 0xb00;

2〜5倍遅くなる程度で動的チェックしながらプログラムを実行できる* �なにが嬉しい? あらゆるテストやリリース前バイナリで ASAN を有効化することで、メモリエラーを探せる範囲が格段に増える!

* チェックを入れる箇所をサンプリングすることでオーバヘッドを抑える方法 (GWP-Asan) もあります

17 of 66

動的解析の例:Fuzzing (ファジング)

プログラムの入力として、自動的に生成されるランダムなデータを(変異させながら)与え続けることで、プログラムのエラーを見つける方法。

単なるランダムな入力を与えるだけではなく、プログラムのいろいろな実行パスをなるべく効率よくテストできるように入力を変異させるアルゴリズムがいろいろ研究されている

テスト対象の

プログラム

新しい入力

フィードバック情報

18 of 66

動的解析の例:Fuzzing (ファジング)

プログラムの入力として、自動的に生成されるランダムなデータを(変異させながら)与え続けることで、プログラムのエラーを見つける方法。

単なるランダムな入力を与えるだけではなく、プログラムのいろいろな実行パスをなるべく効率よくテストできるように入力を変異させるアルゴリズムがいろいろ研究されている

テスト対象の

プログラム

新しい入力

フィードバック情報

  • 1990年の元の論文は、大量の入力を与えてプログラムがハングしたりしないか見るくらいだった�
  • その後多くの改良がなされ、たくさんのバグを発見�
  • 2014年にネット史上最悪の脆弱性とも言われた OpenSSL の Heartbleed を見つけたのでも有名

19 of 66

安全なプログラムを書くための戦略

  • がんばって危険なバグ(プログラム上の間違い)がないプログラムを書く
  • テストを書く
  • 動的解析・Fuzzing
  • 静的解析

20 of 66

動的解析と静的解析

動的解析 → プログラムを実行しながらエラーを見つける���

静的解析 → プログラムを実行せずに(実行する前に)エラーを見つける

if (check_error(...)) {� report_error_and_crash();�}

21 of 66

(補足) Shifting left: 早く問題を見つけるとコストも低い

動的解析

静的解析

なるべく早く(左側で)見つけたい

(ただし、右側のほうが一般的にはより多くの問題を正確に見つけられる)

22 of 66

プログラムを実行せずに解析するとは?

プログラムのソースコードや仕様を元に、パターンマッチや数学的な手法でプログラムの挙動や性質を検証する方法のこと。

プログラムの仕様を別の言語で書いてモデル検証する方法や、バイナリを解析して検証する方法もありますが、ここではソースコードをそのまま解析する手法を説明します

CPU上でプログラムの命令を実行する代わりに…

プログラムを解析して、

形をチェックしたり�モデル化した問題を解く

23 of 66

静的解析は古くて新しい問題

静的解析の考え方自体は 1950 年代頃からある古くて新しい問題

 コンパイラの進歩などとともに(地味に)発達してきたが、

 2005年頃からプログラム解析での応用が急速に発展した。

 理由はいくつかあるが…

  • 解析手法の研究が進み、需要も増えた
  • SAT / SMT ソルバの性能が飛躍的に上がった!

 奥深い分野ですが、こちらもものすごくざっくり説明します

24 of 66

解析の材料: Abstract Syntax Tree (AST, 抽象構文木)

AST とはプログラムを構文解析して、意味に関係ある部分を木構造で表したもの

コンパイラがプログラムの意味解析や最適化をするために作るものとして知られる�AST ベースのプログラム解析は IDE をはじめ多くのツールで使われています

x = a + b;

while (y > a) {

a = a + 1;

}

Program

=

while

>

Block

=

a

+

a

1

x

+

a

b

y

a

25 of 66

古典的な静的解析:Lint, よくあるエラーの指摘

プログラムの AST をパターンマッチして、危険なパターンを指摘・修正してくれる

void function() {

int x;

…� foo(&x);

}

void function() {

int x = 0;

…� foo(&x);

}

変数 x が初期化されていない

1) 初期値が与えられていないコードパターンを探す AST クエリを書く

2) クエリにマッチしたコードパターンを元に、警告を報告する

before

after

varDecl(� unless(hasInitializer(anything())),� unless(isInstantiated()),� isDefinition())

ASTクエリの例

26 of 66

古典的な静的解析:Lint, よくあるエラーの指摘

プログラムの AST をパターンマッチして、危険なパターンを指摘・修正してくれる

void function() {

int x;

…� foo(&x);

}

void function() {

int x = 0;

…� foo(&x);

}

変数 x が初期化されていない

before

after

1) 初期値が与えられていないコードパターンを探す AST クエリを書く

2) クエリにマッチしたコードパターンを元に、警告を報告する

varDecl(� unless(hasInitializer(anything())),� unless(isInstantiated()),� isDefinition())

ASTクエリの例

同じようにして、パターンをうまく書けばいろんなチェックを作れます!

でもこれだけだとパターンマッチ�による解析しか作れませんね…

27 of 66

一方、解析で見つけたいバグとは…

C++ や Java で断然危険なのはメモリエラー

  • 読んではいけないメモリ (Null など) を読む
  • 値があるかわからないメモリをチェックしないで読む
  • 配列の終端より先のメモリを読む

これらがあると:

  • プログラムが異常終了・異常動作する (信頼性の問題)
  • 読まれてはいけないデータを読まれてしまう (セキュリティ問題)

int x[4];

アクセスし�てはいけない

void func(std::optional<int> opt) {

if (opt.has_value()) {

...

}

use(*opt); // これは危険😣

}

28 of 66

一方、解析で見つけたいバグとは…

C++ や Java で断然危険なのはメモリエラー

  • 読んではいけないメモリ (Null など) を読む
  • 値があるかわからないメモリをチェックしないで読む
  • 配列の終端より先のメモリを読む

これらがあると:

  • プログラムが異常終了・異常動作する (信頼性の問題)
  • 読まれてはいけないデータを読まれてしまう (セキュリティ問題)

int x[4];

アクセスし�てはいけない

void func(std::optional<int> opt) {

if (opt.has_value()) {

...

}

use(*opt); // これは危険😣

}

危ないメモリアクセスをする�可能性のあるプログラムを�警告したい

29 of 66

一方、解析で見つけたいバグとは…

C++ や Java で断然危険なのはメモリエラー

  • 読んではいけないメモリ (Null など) を読む
  • 値があるかわからないメモリをチェックしないで読む
  • 配列の終端より先のメモリを読む

これらがあると:

  • プログラムが異常終了・異常動作する (信頼性の問題)
  • 読まれてはいけないデータを読まれてしまう (セキュリティ問題)

int x[4];

アクセスし�てはいけない

void func(std::optional<int> opt) {

if (opt.has_value()) {

...

}

use(*opt); // これは危険😣

}

危ないメモリアクセスをする�可能性のあるプログラムを�警告したい

どうやって??��各値の状態がフロー制御によってどう変わるか追っていかないとチェックできない

パターンマッチングでは限界がある

30 of 66

解析に使える便利な道具:CFG(制御フローグラフ)

AST をもとに CFG (制御フローグラフ) というグラフを構築することで、プログラムの流れに沿った解析や最適化が可能になる

void func(std::optional<int> opt) {

if (opt.has_value()) {

use1(*opt);

}

…� use2(*opt);

}

if (opt.has_value())

use1(*opt)

use2(*opt)

T

F

CFG の例

CFG: 分岐やループなどのフローの流れをつないでグラフにしたもの。ノードはプログラムの基本ブロック。

31 of 66

データフロー解析 : 単純な例

void Example(int n) {

int x = 0;

// x の取り得る値は {0}

if (n > 0) {

x = 5;

// x の取り得る値は {5}

} else {

x = 42;

// x の取り得る値は {42}

}

// x の取り得る値は {5, 42}

print(x);

}

“x” が各場所でどの値を取り得るかを解析する

→ x の取り得る値をセットで表すことにする�

  • x に定数を代入したあとは x は1つの値しか取り得ない
  • “then” と “else” が join するとき、x はthen か else どちらかの値を取る

このように CFG の沿ってモデルの値を更新することを繰り返し、すべての場所で値が変わらなくなったら (=fixpoint に到達したら) 解析終了とする

32 of 66

データフロー解析 : 単純な例

void Example(int n) {

int x = 0;

// x の取り得る値は {0}

if (n > 0) {

x = 5;

// x の取り得る値は {5}

} else {

x = 42;

// x の取り得る値は {42}

}

// x の取り得る値は {5, 42}

print(x);

}

“x” が各場所でどの値を取り得るかを解析する

→ x の取り得る値をセットで表すことにする�

  • x に定数を代入したあとは x は1つの値しか取り得ない
  • “then” と “else” が join するとき、x はthen か else どちらかの値を取る

値の更新をよく見ると…

  • x の取り得る値の情報量は増えるけど減らない
  • 2つのノードがあわさるとき、情報は join される

このように CFG の沿ってモデルの値を更新することを繰り返し、すべての場所で値が変わらなくなったら (=fixpoint に到達したら) 解析終了とする

33 of 66

データフロー解析 : 単純な例

void Example(int n) {

int x = 0;

// x の取り得る値は {0}

if (n > 0) {

x = 5;

// x の取り得る値は {5}

} else {

x = 42;

// x の取り得る値は {42}

}

// x の取り得る値は {5, 42}

print(x);

}

“x” が各場所でどの値を取り得るかを解析する

→ x の取り得る値をセットで表すことにする�

  • x に定数を代入したあとは x は1つの値しか取り得ない
  • “then” と “else” が join するとき、x はthen か else どちらかの値を取る

値の更新をよく見ると…

  • x の取り得る値の情報量は増えるけど減らない
  • 2つのノードがあわさるとき、情報は join される

このように CFG の沿ってモデルの値を更新することを繰り返し、すべての場所で値が変わらなくなったら (=fixpoint に到達したら) 解析終了とする

順序集合的にはこれは Lattice (束) � として見ることができる!

34 of 66

データフロー解析と束

このケースでは join(a, b) = a ∪ b であり

join(a, b) ⩾ a, join(a, b) ⩾ b

void Example(int n) {

int x = 0;

// x の取り得る値は {0}

if (n > 0) {

x = 5;

// x の取り得る値は {5}

} else {

x = 42;

// x の取り得る値は {42}

}

// x の取り得る値は {5, 42}

print(x);

}

何も情報がない状態 (初期化前) が ⊥ (bottom)

35 of 66

データフロー解析と束

void ExampleOfTopWithALoop() {

int x = 0; // x is {0}

while (condition()) {

x += 1; // x is {0, 1, 2...}

}

print(x); // x is {0, 1, 2...}

}

解析が終了しなそうな例

例えばこのようなループがあったら束の高さは無限になり、解析は終了しない😣

上界がない (Join-semilattice)

何も情報がない状態 (初期化前) が ⊥ (bottom)

36 of 66

データフロー解析と束

void ExampleOfTopWithALoop() {

int x = 0; // x is {0}

while (condition()) {

x += 1; // x is

}

print(x); // x is

}

情報が多すぎる、衝突がある場合を ⊤ (top) とする

→ 有限時間で fixpoint に到達する (= 解析終了)

現実的な落とし所:モデルの精度を落として上限を定め、そこを ⊤ (top) とする

例えば値の集合は 3 つまでしか追わないなど

解析が終了するには昇鎖条件が成り立つ必要がある = 上限が必要

何も情報がない状態 (初期化前) が ⊥ (bottom)

37 of 66

データフロー解析と束

void ExampleOfTopWithALoop() {

int x = 0; // x is {0}

while (condition()) {

x += 1; // x is

}

print(x); // x is

}

情報が多すぎる、衝突がある場合を ⊤ (top) とする

→ 有限時間で fixpoint に到達する (= 解析終了)

現実的な落とし所:モデルの精度を落として上限を定め、そこを ⊤ (top) とする

例えば値の集合は 3 つまでしか追わないなど

解析が終了するには昇鎖条件が成り立つ必要がある = 上限が必要

何も情報がない状態 (初期化前) が ⊥ (bottom)

Complete Lattice (完備束)として解析を� 作ることで、fixpoint (不動点) に到達 =� 最小解が求まると同意になる

38 of 66

データフロー解析:定式化

束の値 はプログラムの文 (statement) と制御フローによって変更される

  • 文による値の更新は “transfer” と呼ばれ、�前の値と文を入力に取り、次の値を出力する�transfer は単調増加関数でなければならない�x ⩾ y ⇒ transfer(x) ⩾ transfer(y)�
  • 制御フローが交わると値は “join” され、�前の値をすべて join した値を出力する�join は入力情報を減らしてはならないjoin(a, b) ⩾ a, join(a, b) ⩾ b

// 入力: x is {42, 44}

x = x + 100;

// 出力: x is {142, 144}

if (...) {

...

// 入力: x is {42}

} else {

...

// 入力: x is {44}

}

// 出力: x is {42; 44}

39 of 66

データフロー解析:定式化

束の値 はプログラムの文 (statement) と制御フローによって変更される

  • 文による値の更新は “transfer” と呼ばれ、�前の値と文を入力に取り、次の値を出力する�transfer は単調増加関数でなければならない�x ⩾ y ⇒ transfer(x) ⩾ transfer(y)�
  • 制御フローが交わると値は “join” され、�前の値をすべて join した値を出力する�join は入力情報を減らしてはならない�join(a, b) ⩾ a, join(a, b) ⩾ b

// 入力: x is {42, 44}

x = x + 100;

// 出力: x is {142, 144}

if (...) {

...

// 入力: x is {42}

} else {

...

// 入力: x is {44}

}

// 出力: x is {42; 44}

この条件を満たす

transfer, join と束の定義を

設計することで、様々な解析を�作ることができます

40 of 66

余談:データフロー解析の典型的な応用例

コンパイラの (少し古典的な) 最適化の話で良く出てきます

  • 定数畳み込み:ある変数に代入されるのが定数だけならそれで置き換え
  • 不要なコードの削除:定数畳み込みなどの結果、絶対通らないコードパスがわかったらそれを削除
  • 共通部分式をループ外に:どのフローでも必ず通る式があったらループや条件文の外に出す

41 of 66

とはいっても…

実際のプログラムでは、この例のように変数に 0 や 42 のような具体的な値 (concrete value) を代入することは少ない

具体的な値を取らない値を「プログラムを実行することなく」�どのように解析するのか?

x = 42; // {42}

x = x + y;

42 of 66

シンボリック実行

具体的な値がないときは「シンボリックな値」を使ってプログラムを実行する

実際のハードウェアでの実行と同様に、�シンボリック値を記録する仮想的な「ストレージ」の上で、モデルに従って束と値を更新しながら実行を進める

void func(int x, int y) {

int z = x + y;

if (z > 0) {

x = -x;

}

if (y > 0) {

if (x == y)

x = 5;

y = 3;

}

}

未知の値はシンボル値 x0, y0 として扱う

x のシンボリックな状態は x = -x0

43 of 66

パス条件 (Path Condition)

実行は分岐していくので、どの条件のときの値かの情報 (= パス条件) が必要

パス条件 == そこまでの分岐状態をすべてエンコードしたシンボリックな論理値

void func(int x, int y) {

int z = x + y;

if (z > 0) {

x = -x;

}

if (y > 0) {

if (x == y)

x = 5;

y = 3;

}

}

未知の値はシンボル値 x0, y0 として扱う

パス条件: x0 + y0 > 0�のとき、x のシンボリックな状態は x = -x0

x0 + y0 > 0

x = -x0;

T

F

44 of 66

パス条件 (Path Condition): すべて辿ると

すべての可能な分岐を見ると、実行は二分木状になる

void func(int x, int y) {

int z = x + y;

if (z > 0) {

x = -x;

}

if (y > 0) {

if (x == y)

x = 5;

y = 3;

}

}

z > 0

y > 0

y > 0

x == y

x == y

x = -x;

x = 5;

y = 3;

T

T

T

F

F

F

T

F

パス条件

(x0 + y0 > 0) ∧ (y0 > 0) ∧�¬(-x0 == y0)

x= -x0, y = 3

45 of 66

実際には通らないパスもある

どんな入力を与えても通らない分岐の組み合わせは解析から外してよい

z > 0

y > 0

y > 0

x == y

x == y

x = -x;

x = 5;

y = 3;

T

T

T

F

F

F

T

F

パス条件

(x0 + y0 > 0) ∧ (y0 > 0) ∧�(-x0 == y0)

x= 5, y = 3

void func(int x, int y) {

int z = x + y;

if (z > 0) {

x = -x;

}

if (y > 0) {

if (x == y)

x = 5;

y = 3;

}

}

これを満たす解は存在しない! Unsatisfiable

46 of 66

あり得ないパスはどう判定する?

あるパスがあり得るかは、SAT (充足可能性判定) 問題として解ける

  • 命題論理式 (真偽値) だけの充足可能性なら SAT ソルバで解ける
    • (a ∨ b) ∧ (¬c ∨ d) ∧ (¬b ∨ c)
  • SMT ソルバはさらに一階述語論理 (普通の数式) の充足可能性を解ける
    • (x + y < z) ∧ (x - y == 0)
    • (x^2 + y^2) = z^2

SAT問題は古典的な NP 完全問題だが、近年ソルバが著しく発展したこともあって幅広く応用されるようになった (== 大規模解析もかなり現実的になった)

47 of 66

寄り道: SAT / SMT ソルバの進化

 扱える変数規模の変遷

 1962年 DPLL 10変数� 1986年 BDDs 100変数� 1996年 GRASP 1K変数� 2001年 CDCL 10K変数� 2013年 1M変数� 2020年 100M変数〜

2020年のSATベンチマークで優勝したもの

速いSATとして応用を一気に広めた MiniSAT 2006 の性能

過去20年で著しく高速化

48 of 66

寄り道: Knuth先生の本を読んで自分で書いてみよう!

TeX、チューリング賞などで知られるクヌース先生のSAT本

  • コンピュータ・プログラムについての大著 TAOCP に、SAT ソルバと充足可能性問題だけを扱った別冊がある (2015)
  • 2000年以降の SAT ソルバの可能性に大興奮して自分でもたくさん SAT ソルバを書いて別冊にまとめた模様
  • この本に乗っている速めな方の実装をナイーブな実装と比べると、速度にして4〜6倍使用メモリ量では100倍以上の差があったります (実際のプログラム解析での実測値)

単純なもの(= 遅い)であれば比較的簡単に書けます

49 of 66

寄り道: SMT ソルバで身近な問題を解く

ソルバを自作して遊ぶわけではないなら、公開されている SMT ソルバ(cvc5 [1] やマイクロソフトのz3 [2]やなど)を使って問題を解いて見るのも楽しいですね�[1] https://cvc5.github.io/ �[2] https://github.com/Z3Prover/z3

機械学習の影に隠れていますが、実はソルバで解けてしまう�問題もかなりたくさんあります(問題設定さえうまくすれば)

50 of 66

そして… すべてを組み合わせる!

  • 行いたい解析を束としてモデル化して transfer, join を設計する
  • CFG を作ってデータフロー解析する (コンパイラ基盤を活用)
  • モデルにあわせてシンボリック値とストレージを更新しながら実行する
  • パス条件と SAT/SMT ソルバによって実行パスの充足可能性をチェックする
    • 危険な状態の値になるパスがあり得るなら(=すべてのパスで充足不可能でないなら)�安全性の問題が起こり得るコードとして警告する

LLVM/Clang でいろいろなデータフロー解析を開発できるフレームワークを作ってます

51 of 66

そして… すべてを組み合わせる!

  • 行いたい解析を束としてモデル化して transfer, join を設計する
  • CFG を作ってデータフロー解析する (コンパイラ基盤を活用)
  • モデルにあわせてシンボリック値とストレージを更新しながら実行する
  • パス条件と SAT/SMT ソルバによって実行パスの充足可能性をチェックする
    • 危険な状態の値になるパスがあり得るなら(=すべてのパスで充足不可能でないなら)�安全性の問題が起こり得るコードとして警告する

LLVM/Clang でいろいろなデータフロー解析を開発できるフレームワークを作ってます

「バグがあるコード」とは�意味合いが少し異なります

52 of 66

いろいろな静的解析の設計例

注:ある問題をモデル化する方法はいろいろ�あり得ます、ここであげてるのは全て一例です

53 of 66

解析の例:未初期化変数

void Init() {

int x; // x is uninitialized

if (cond()) {

x = 10; // x is initialized

} else {

x = 20; // x is initialized

}

print(x); // x is initialized

}

void Uninit() {

int x; // x is uninitialized

if (cond()) {

x = 10; // x is initialized

}

print(x); // x is maybe uninitialized

}

安全

ダメ

Initialized

Uninitialized

Maybe Uninitialized

例えば、各変数について、初期化状態を示す {Initialized, Uninitialized} を記録する�

join:

transfer:

decl: 新しく Uninitialized を作る� assign: Uninitialized → Initialized

54 of 66

解析の例:危険な std::optional の使い方を警告する

void Safe(std::optional<int> &x) {

if (x.has_value()) {

Use(x.value());

}

}

void Unsafe1(std::optional<int> &x) {

if (x.has_value()) {

...

}

Use(x.value());

}

安全

危険

例えば、シンボリックな論理値 has_value を子に持つ複合値として optional を定義

束の値: violation のあるソース箇所

join(a, b): a ∪ b�

transfer:

ctor(e), assign(x, e): eが値を持っていれば� x.has_value をパス条件に足す

unwrap(x): x.has_value がパス条件によっ� て Imply されているかを調べる� (パス条件 ∧ ¬has_value を満たす解が� 存在しない == Unsat であるかを調べる)

危険

void Unsafe2(std::optional<int> &x, � std::optional<int> &y) {

bool b = x.has_value();� if (b || y.has_value()) {

Use(x.value());

}

}

危険

55 of 66

解析の例:Null ポインタか参照される可能性を警告する

void foo(int *x, bool b) {

if (b) {

x = nullptr;

}

if (!b) {

*x; // 安全 { NonNull }

}

*x; // 危険 { Null? }

}

安全

危険

例えば、ポインタの状態は Null, NonNull, Unknown (状態がわからない) の3値論理値で決まるとする*

パス条件やシンボリック状態がより複雑になるため、どういう論理値モデルにするか、どれくらいの状態を保持するかなどが論点になる

* “Gradual Program Analysis for Null Pointers”, ECOOP 2021, Sam Estep et al より

56 of 66

そうはいっても…

本当にそれでちゃんと問題は見つかるの?�

→ 静的解析にももちろん限界があります

よく問題になるのが誤検知 (False Postivie)

57 of 66

静的解析の Soundness と Precision の話

静的解析を設計する上で必ず出てくる概念:

  • Sound: バグがあれば必ず検知される(けどバグではないものも検知される)
  • Precise: 検知されたものは全て本当のバグ(だけど見過ごされるバグもある)

ほとんどの解析は Precise でも Sound でもないが、多くの解析はなるべく Sound な(=見落としはない)解析を目指して、その中で誤検知をできるだけ低くする

問題

問題

問題

Sound, Imprecise

(見落としがない)

Precise, Unsound�(誤検知がない)

Unsound�Imprecise

実際の問題の形

解析のモデル化が�検知できる形

58 of 66

どう運用すると使われるのか?複数文献に見られるメタ知見

すぐに報告されなかったものは人間は重要だと思わない

やり終わったものへの報告は対処されにくい」(コンテキストスイッチが大変)

「同じ問題でもコンパイルエラーとして出るとびびって直す人が多い…」

  • コンパイル時(=プログラムを作ってるとき)に報告された問題は 74% が「本当の問題」と判断されたのに対し、作り終わったものへの報告は 21% しかそうとは取られなかった (Google, 2018)
  • 毎晩解析ツールを走らせてバグをまとめて報告していたときはほとんど修正されなかったが、コードレビュー時に報告するようにしたところ70%が対応された (Facebook, 2019)

59 of 66

ちなみに: 静的解析と動的解析の組み合わせ

質問:静的解析と動的解析の両要素を組みわせた方法はないの?

もちろんあります。例えば:似た要素技術(シンボリック実行とSMTソルバ)と動的テスト実行を組み合わせたテスト手法として、Concolic Testing などが有名

Symbolic Execution + Testing with Concrete Values = Concolic Testing

1. 適当な入力値を選んでテストを実行� 2. 実行パス中に通る条件の1つを反転 (Negate)し、それを満たす入力をSMTソルバを使って得る� 3. それを次の入力としてテストを実行 (繰り返し)

静的解析だけでは分からないフロー構造や実行環境を動的実行の結果から取り入れる…みたいな解析方法は他にもたくさんやられています

シンボリック実行

具体値を使ったテスト

コンコリックテスト

60 of 66

安全なプログラムを書くための戦略

  • がんばって危険なバグ(プログラム上の間違い)がないプログラムを書く
  • テストを書く
  • 動的解析・Fuzzing
  • 静的解析
  • 安全性の高いプログラミング言語を使う?

61 of 66

安全なメモリの世界:GC と Borrow Checker

GC (Garbage Collection)

  • メモリの自動管理機構。「使われている間は使えるようにしておく」
  • Use-after-free と呼ばれる、使用されなくなったと思われて解放されてしまったメモリをアクセスしてしまうエラーを根絶できる
  • Java, Python, Go, JavaScript など多くの言語で提供されている

Borrow Checker

  • 「共有されていてかつ読み書き可能な変数」を言語として許さない
  • Rust で提案・実装され、メモリ安全性の議論に大きな影響を与えた

62 of 66

安全なメモリの世界:GC と Borrow Checker

GC (Garbage Collection)

  • メモリの自動管理機構。「使われている間は使えるようにしておく」
  • Use-after-free と呼ばれる、使用されなくなったと思われて解放されてしまったメモリをアクセスしてしまうエラーを根絶できる
  • Java, Python, Go, JavaScript など多くの言語で提供されている

Borrow Checker

  • 「共有されていてかつ読み書き可能なメモリ」を言語として許さない
  • Rust で提案・実装され、メモリ安全性の議論に大きな影響を与えた

Garbage Collection は� 動的解析ベースの

アプローチ

Borrow Checker は

静的解析ベースの� アプローチ

63 of 66

C++ はライフタイムの夢を見るか

C++ に Rust のライフタイムという概念を持ち込む提案があります (してます)

ライフタイムとは

  • Rust では、すべての参照にライフタイム (生存期間) がついており、それを超えたスコープではその参照先を見ることはできない(=C++にありがちな「このポインタまだ生きているはず!」と中を見てみたらクラッシュ!…ということがない)

[RFC] Lifetime annotations for C++https://discourse.llvm.org/t/rfc-lifetime-annotations-for-c/61377

  • C++のポインタや参照に対し、それが指している先の生存期間を指定できるようにする提案。静的解析による推論とあわせることで「もう値が生存していないアドレス」をさわってしまう問題を検知可能にする(先に説明したデータフロー解析を使います) 少しコードを書きました😃

ptr

😈?

ptr

invalid

64 of 66

安全なプログラムを書くための戦略

  • がんばって危険なバグ(プログラム上の間違い)がないプログラムを書く
  • テストを書く
  • 動的解析・Fuzzing
  • 静的解析
  • 安全性の高いプログラミング言語を使う?
  • (これらに加えて)コード自動修正・生成??

65 of 66

自動化アプローチ例

自動コード提案:CodeX / Github Copilot (OpenAI / MS), AlphaCode (Google)

  • コメントなどの自然言語を元に、使えそうなコード片を生成・提案する

プログラムを自動で修正する:DeepDelta (Google)

  • 良くあるコンパイルエラーに対し、必要なコード変更の情報とコンパイルエラーの組を学習データとして訓練したモデルを使い、自動で修正を行う
  • 同様に、自動でテストをつくる技術もいろいろ研究されています

ML と動的解析・静的解析を組み合わせるアイディアも多く研究されています

66 of 66

おしまい

  • プログラム解析とメモリ安全性について駆け足で説明しました
  • どの方法も長所も限界もあり、目的によって効きそうなモデル化や手法の組み合わせは変わります (説明しなかった方法もたくさんあります)
  • 人間が書くプログラムの種類や頭を使う部分というのも、この先もまだまだ変わっていきそうですね… (個人の感想です)