形式検証アドベントカレンダー10日目の記事です。(2本しかありませんが)

OSカーネルの実装や組み込みソフトウェアに利用できるような、C言語とアセンブリ言語を対象とする形式検証ツールの紹介です。 出来ることと基本的な使い方くらいの説明になります。(例もショボい) 今回紹介するツールは、軽量形式手法で使われるようなソースコードと直接的な繋がりが少ない設計を吟味するためのものではなく、ソースコードとの乖離が少ないものを選びました。 また、以下に紹介するツールは研究プロジェクトであったり、メンテナンスが放棄されたものも含みます。

環境

  • OS: ArchLinux

  • opam version: 2.1.3

  • Nix version: 2.12.0

RefinedC

並行分離論理を Coq で形式化した iris を利用した検証ツール。[refinedc][refinedc_source] [1]

Installation

opamにパッケージがある。coqとirisのリポジトリを使っているので追加してからインストールする。[refinedc_installation] [2]

opam repo add coq-released "https://coq.inria.fr/opam/released"
opam repo add iris-dev "https://gitlab.mpi-sws.org/iris/opam.git"
opam update
opam pin add -n -y cerberus "git+https://github.com/rems-project/cerberus.git#b60ea9a7d30dfa7f048c2b312dd86547939a035a"
opam pin add refinedc "git+https://gitlab.mpi-sws.org/iris/refinedc.git"

使い方

Cのソースコードに注釈をつけたものがCoqに変換され,ある程度自動的に証明されたあと残ったものを手動で補題を立てて自動証明器に指示しつつ証明する。

プロジェクトの生成

refinedc init

swap関数がちゃんと値を入れ替えているか検証する例

#include <refinedc.h>
[[rc::parameters("a : loc", "b : loc", "n : Z", "m : Z")]]
[[rc::args("a @ &own<n @ int<i32>>", "b @ &own<m @ int<i32>>")]]
[[rc::ensures("own a : m @ int<i32>", "own b : n @ int<i32>")]]
void swap(int *a, int *b)
{
	int tmp = *a;
	*a = *b;
	*b = tmp;
}

refinedc init したディレクトリで以下のようにする

refinedc check swap.c

proofs/swap以下にCoqに変換されたコードや生成された証明がある。

この程度ならば自動で証明してくれるが、Coqに変換されるのでより複雑な対象には手動で証明することで対応できる。

CBMC

モデル検査によるCとC++ の検査ツール。[cbmc][cbmc_source][cbmc_hp] CProverというプロジェクトの成果物で、他の成果物は[cprover_overview]に説明がある。 CBMCの他にはgoto文を解析してくれるようなものもある。

C言語の場合C99,C89,C11とGCCやVisualStudioのほとんどの拡張に対応していて、未定義動作などのバグの種となるミスを指摘してくれる他、ユーザーが定義した性質が実行時に満たされるかどうかを静的に解析してくれる。 (もちろん、検査器に指摘されたからと言ってバグであるとは限らない)

Installation

Win/Mac/Ubuntu に対応していますが、Docker Image が公開されているので使う。

docker run -it diffblue/cbmc:5.71.0

使いかた

以下のようなフラグで検査する性質を指定できる。

cbmc help

以下のようなプログラムに対して検査してみる。

int main() {
	char buf[3] = {'a', 'b', 'c'};
	buf[3] = 'd';
}

指定したフラグでチェックされる性質(assertion)を見るには --show-properties をつける。 すると以下のように配列の境界チェックの条件が生成されていることがわかる。

cbmc --bounds-check --pointer-check cbmc_sample.c --show-properties
cbmc property output

実際に解析を実行するには先のフラグ`--show-properties`を外す

cbmc check sample

期待通りに検証が失敗している。

ユーザー定義の条件の例

CBMC には、定義したアサーションに対して満たされるかどうか静的に検査する機能がある。 例えば wrap around しない減算をする以下のような関数にアサーションを加えて検査すると

cbmc  cbmc_sample1.c --function uint_sub_not_wa --trace
#include <assert.h>

unsigned int uint_sub_not_wa(unsigned int a, unsigned int b)
{
	assert(a >= b);
	unsigned int res = a - b;
	assert(res + b == a);
	return res;
}
cbmc not wa sub1

1 つ目のアサーションに対していくつか反例が上がってくる。(`--trace`で反例を表示できる) 以下のようにバリデーションを加えると検証が通る。

unsigned int uint_sub_not_wa(unsigned int a, unsigned int b)
{
	if (a < b) {
		return 0;
	}
	assert(a >= b);
	unsigned int res = a - b;
	assert(res + b == a);
	return res;
}
cbmc not wa sub2

ループが絡む検証の例

CBMC はループに関する性質を検証するために以下のようなコードを

while (flag) {
    YOURCODE
}
if (flag) {
    YOURCODE[1/flag]
    if (flag) {
        YOURCODE[2/flag]
        if (flag) {
            YOURCODE[3/flag]
        }
    }
}

のように解釈して(loop unrolling/unwinding)検査するため,どこまで検査するか上限を決定する必要がある。 [cbmc_loop_unwinding] プログラム全体の各ループに対して統一的に設定するには --unwind N のように指定する。

例:

#include <assert.h>
int main() {
	for (int i = 0; i < 100; ++i) {
		assert(i < 100);
	}
}
cbmc --unwind 100 cbmc_loop.c

しかし、今回の場合のようにループ回数が固定だったりすると CBMC が自動的に N を推測してくれるので、一旦`--unwind`無しで実行してから手動で推測すればよい。

VST/CompCert

言わずとしれた(?)形式検証済みCコンパイラ[compcert_hp]だが、普通のプログラム検証にも使える。 派生物も研究・開発されており、アセンブリとの相互運用も形式検証出来るものもある。[compcertm] VSTはCompCertの成果を使って一般のプログラム検証が出来るようにしている。 [3]

Installation

opam で coq-released リポジトリを追加後に [4]

opam install coq-vst coq-compcert

使いかた

Coqで設計の形式モデルを作って、その実装であるCのソースコードを clightgen で Coqに変換し設計のモデルの要求を満たしていることを証明する。

VST を使ったワークフローは

  1. clightgen your_code.c (出力: your_code.v)

  2. API spec を書く

  3. 実装が API spec を満たしていることを証明

// swap.c
void swap(int *a, int *b)
{
	int tmp = *a;
	*a = *b;
	*b = tmp;
}
int main()
{
	int x = 1, y = 2;
	swap(&x, &y);
	return x;
}
clightgen swap.c

API spec は、以下のようになる

Definition swap_spec : ident * funspec :=
DECLARE _swap
WITH sh: share, a: val, b: val, a_val: Z, b_val: Z
  PRE [ tptr tint, tptr tint ]
    PROP (writable_share sh)
    PARAMS (a; b)
    SEP (data_at sh tint (Vint (Int.repr a_val)) a; data_at sh tint (Vint (Int.repr b_val)) b)
  POST [ tvoid ] (
    PROP()
    RETURN()
    SEP (data_at sh tint (Vint (Int.repr b_val)) a; data_at sh tint (Vint (Int.repr a_val)) b)
  ).

Definition main_spec :=
 DECLARE _main
  WITH gv : globals
  PRE [] main_pre prog tt gv
  POST [ tint ]
     PROP()
     RETURN (Vint (Int.repr 2))
     SEP(TT).
  • PRE/POST で事前・事後条件を指定している

  • PARAMS で引数を指定

  • SEP は、メモリの状態(e.g. ここではポインタ a,b が指す値)を規定

  • PROP で追加の条件を書ける

Lemma body_swap: semax_body Vprog Gprog f_swap swap_spec.
Proof.
  start_function.
  try repeat forward.
  entailer!.
Qed.
  • 実装が API spec を満たしていることを示している

  • start_function はゴールを Hoare triple に変換してくれる。この時点で API spec と実装のミスマッチ(e.g. spec の返り値の型と実装の返り値の型が食い違っている)も検出してくれる

  • forward は、sequence rule の適用(gdb で si 連打するみたいな…​)

  • entailer! は、分離論理のソルバ

全体はこのようになる

(* swap_verify.v *)
Require Import VST.floyd.proofauto.
Require Import swap.
Instance CompSpecs : compspecs. make_compspecs prog. Defined.
Definition Vprog : varspecs.  mk_varspecs prog. Defined.

Definition swap_spec : ident * funspec :=
DECLARE _swap
WITH sh: share, a: val, b: val, a_val: Z, b_val: Z
  PRE [ tptr tint, tptr tint ]
    PROP (writable_share sh)
    PARAMS (a; b)
    SEP (data_at sh tint (Vint (Int.repr a_val)) a; data_at sh tint (Vint (Int.repr b_val)) b)
  POST [ tvoid ] (
    PROP()
    RETURN()
    SEP (data_at sh tint (Vint (Int.repr b_val)) a; data_at sh tint (Vint (Int.repr a_val)) b)
  ).

Definition main_spec :=
 DECLARE _main
  WITH gv : globals
  PRE [] main_pre prog tt gv
  POST [ tint ]
     PROP()
     RETURN (Vint (Int.repr 2))
     SEP(TT).

Definition Gprog := [swap_spec; main_spec].

Lemma body_swap: semax_body Vprog Gprog f_swap swap_spec.
Proof.
  start_function.
  try repeat forward.
  entailer!.
Qed.

わりと最近にsoftwarefoundations の vol. 5 としてハンズオンが作られた。[sf_vc]

c-parser(StrictC)(seL4)

  1. seL4の検証プロジェクトの一部として配布されているのでリポジトリを持ってきて一部を使う。 [source,shell] --- mkdir $workdir cd $workdir repo init -u ssh://[email protected]/seL4/verification-manifest.git repo sync ---

  2. https://github.com/seL4/l4v/blob/master/docs/setup.md に従って依存を揃える[sel4_nix]

  3. $workdir/l4v/tools/c-parser`に移動して`make CParser

  4. $workdir に戻って ./isabelle/bin/isabelle jedit -d ./l4v -l CParser とすれば jedit が起動して開発の準備が完了

使い方

C言語のコードをIsabelle上の形式言語Simpl[simpl]に変換してIsabelleで形式 化された要求を満たしていることを検証する。

jeditを立ち上げて

./isabelle/bin/isabelle jedit -d ./l4v -l CParser CParserExample.thy

以下のようにすれば、 ファイル名_global_addresses という context[isabelle_context]に関数定義などが変換されたものが入っている。

theory CParserExample
  imports CParser.CTranslation
begin
install_C_file "path/to/c_src.c"

関数は f_関数名_body_def のような名前で変換されるので,上に付け加えて

context c_src_global_addresses
begin
    thm f_your_function_body
end

などとすればOutputタブにSimplの式が表示される

かなり自明な例

// a.c
void f(void) {}
(* CParserVerySimpleExample.thy *)
theory CParserVerySimpleExample
  imports CParser.CTranslation
begin
install_C_file "a.c"
theorem (in a_global_addresses) shows "\Gamma \turnstile {| True |} PROC f() {| True |}"
    apply vcg
    done
c parser very simple example

Simplの式中では元の名前で参照できる。

Frama-C/WP

Frama-C[framac][framac_source]は、C言語の静的解析フレームワークで、そのプ ラグインであるWP[framac_wp] がVerifastやCBMCのようにC言語に注釈をつけて条件を満たすか検証する機能を持っている。

Installation

opam install frama-c

Why3に証明器が登録されていないと言われたら、opam でインストールした場合証明器自体は入ってるはずなので why3 config detect で why3 の設定を生成すれば動くと思われる。

使い方

C言語のコードにACSL(ANSI/ISO C Specification Langage)という言語で注釈をつけて検査器にかける。

swap の例:

/*@
   requires \valid(a);
   requires \valid(b);
   assigns *a;
   assigns *b;
   ensures *a == \old(*b);
   ensures *b == \old(*a);
*/
void swap(int *a, int* b);

void swap(int *a, int *b)
{
	int tmp = *a;
	*a = *b;
	*b = tmp;
}
  • \valid(x) はポインタ x はデリファレンスされても安全であることを表すACSLのビルトインの述語

  • requres/ensures は事前・事後条件を表す

  • assigns は呼び手側にどの変数が変更されるかを教える

  • \old(x) は関数呼び出し前の x の値を表す

frama-c -wp swap.c

で検査できる。

かなり充実したチュートリアルとしてACSL by Exampleがある。[acsl_by_example] footenote:[あまりメンテナンスされていない。著者の所属が分野を転換したらしい] また、プログラム検証の基本事項の解説を含むチュートリアルもあるらしい。[intro_cprog_framac]

CheckedC

C言語を拡張して、安全にシステムプログラミングに使えることを目指しているプロジェクト。 [checkedc] [checkedc_source]

  • Cの効率性や低レイヤの操作における自由度を下げない

  • 最小限の拡張

  • 読みやすいコード・分かりやすい拡張・最小限の型付け

  • 既存のコードベースに対してインクリメンタルな使い方を可能にする

といった設計思想で作られている。

Installation

Windows 向けにはバイナリ配布があるが、Linux にはないのでビルドする。 処理系は Clang をベースに作られていて、同じようにビルドできる。[checkedc_compiler_build]

mkdir $workdir && cd $workdir
git clone https://github.com/Microsoft/checkedc-clang src
cd src/llvm/projects/checkedc-wrapper/
git clone https://github.com/Microsoft/checkedc
cd $workdir
cmake -G Ninja -DLLVM_ENABLE_PROJECTS=clang \
          -DCMAKE_INSTALL_PREFIX=./install \
          -DCMAKE_BUILD_TYPE=Release \
          -DLLVM_ENABLE_ASSERTIONS=ON \
          -DLLVM_CCACHE_BUILD=ON \
          -DLLVM_INSTALL_TOOLCHAIN_ONLY=ON \
          -DLLVM_TARGETS_TO_BUILD="X86" \
          -DLLVM_LIT_ARGS=-v \
          ./src/llvm
ninja

使い方

C 言語に対して主に以下のように拡張された言語でプログラムを記述する。

  • 境界チェック付配列型・ポインタ型

  • generic struct

  • generic function

  • 後方互換性(i.e. 既存の C 言語のコードベースがそのまま動く)

  • 動的検査

境界チェック付配列型・ポインタ型

checked キーワードで境界検査付の配列が作れる e.g.

#include <stdchecked.h>
int out_of_bound(int a checked[2]) {
    return a[3];
}

これを先程ビルドしたコンパイラに通すと以下のようにエラーになる

checkedc bounds check1

例は一次元だが、多次元配列も checked[n][m] のようにして定義できる。他に null-terminated array のための型もある。[checkedc_spec]

generic struct/function

しょうもない例だが、このように書ける

#include <stdio.h>
#include <stdchecked.h>

struct BinTree _For_any(T) {
    T *elm;
    struct BinTree<T> *left;
    struct BinTree<T> *right;
};

_For_any(T) struct BinTree<T> new_bintree(T *elm) {
    struct BinTree<T> t =  {
        .elm = elm,
        .left = NULL,
        .right = NULL,
    };
    return t;
}

int main() {
    int e = 1;
    struct BinTree<int> t = new_bintree<int>(&e);
    printf("%i\n", *t.elm);
}

spec[checkedc_spec]では、 for_any とされているが、GitHub wiki の記述では _For_any となっていて前者はコンパイルが通らなかった。

動的検査

dynamic_checkassert のように与えられた条件が実行時に満たされるかどうか検査できる。 dynamic_check で与えられた条件はコンパイラが仮定として受け取るため境界検査などを省略できる場合がある。([checkedc_spec], 2.11)

後方互換性

既存のライブラリを問題なくリンクできる他 #pragma CHECKED_SCOPE ON で検査の適用範囲を管理できる。 また、CheckedC で既存のコードベースをコンパイルしたときも、 ソースコードを書き換える必要も、勝手に意味論が変わることもないため、既存のコードベースに CheckedC を混ぜる場合も問題なく相互運用性がある。

VeriFast

C言語に分離論理で事前・事後条件を書いて検証するツール。[verifast] [verifast_source] マルチスレッドなプログラムにも対応している。

また、しっかりしたチュートリアル[verifast_tut]がある。

Installation

Win/Mac/Linux に向けたリリースがある。

wget https://github.com/verifast/verifast/releases/download/nightly/verifast-21.04-118-gc2cdad9a-linux.tar.gz
tar xf verifast-21.04-118-gc2cdad9a-linux.tar.gz
./verifast-21.04-118-gc2cdad9a-linux/bin/verifast --help

使い方

独自の仕様記述言語で C のソースコード上にコメントとして事前・事後条件を書くと、自動的に証明してくれる。 以下のような単純な swap 関数を例に説明する。

void swap(int *a, int *b)
//@requires *a |-> ?x &*& *b |-> ?y;
//@ensures *a |-> y &*& *b |-> x;
{
	int tmp = *a;
	*a = *b;
	*b = tmp;
}
  • @requires …​;で事前条件を書く

  • @ensures …​;で事後条件を書く

  • 事前・事後条件で引数に言及できる

  • ポインタ変数であれば"デリファレンス |→ 値"で指している値について主張出来る

    • ?x とあるのは ∃x. ref ↦ x という意味でポインタ変数 ref が指している値をその名前で束縛できる

  • &*& は分離論理における separating conjunction で両隣の式が成り立ち、且つ言及しているメモリ領域が被らないことを主張する

これを踏まえれば上の記述は, "エイリアスでないポインタ a と b にそれぞれある値 x と y を格納して swap(a, b)を呼び出せば、互いの指す値が入れ替わっている"ということを主張している。 実際に verifast を使って検証するには

path/to/verifast -shared verifast_swap.c

-shared で main 関数を要求しないようにしている。 出力:

verisoft swap

おわりに

いくつか取り上げようと思って[.line-through]#面倒くさくなって#やめたやつ

  • Armada[armada] [armada_source] [armada_install]: パフォーマンスを落とさずに安全な並列プログラミングが出来ることを目指したツール

    • dotnet runtime 5.0.17 必要だが動かすことには成功

  • Bedrock[bedrock][bedrock_hp][bedrock_source]: 低レイヤの自由度とパフォーマンスを犠牲にせず、Coq から C やアセンブリを Extraction する

    • 証明が通らなかった部分を直したりしたが [5] Coq コードのコンパイルが無限に終わらず断念

  • csolve[csolve][csolve_source][csolve_hp]: C 向けの Liquid type ベースな検証器 footenote:[LiquidHaskell やってるところが作ったっぽい]

    • ビルドしてたらなんか内部的なところが型エラー起こしてて諦めた

  • vcc[vcc][vcc_source]: C 言語の並行処理を検証できる

  • KaRaMeL: F* のやつ kremlin から名前変わったやつ[karamel] [karamel_source]

References


1. 個人的最推
2. 主に iris のほうで割と活発に更新があるので switch を切っておくが吉
3. 後に言及するタクティクの開発など
4. 他に 32bit 版(coq-compcert-32)とセットアップに coq-platform を使うパッケージがある(coq-compcert-64)
5. 仮定の名前を指定せずに intros.してたせいだった