Skip to content

VibeGuard Compiler

A research project on security properties that survive in source but not in the compiled binary, tracked at LLVM pass granularity.

The question

A security property you wrote can disappear when the compiler optimises your code. A memset that scrubs a key can be removed as a dead store, because nothing reads the buffer afterwards and the standard lets the compiler say so. Two different permission checks can be folded into the same bytes, because they compute the same thing from the compiler's point of view and differ only in what a human meant by them. The source still says what you meant. The binary no longer does, and nothing in the usual toolchain tells you which of your properties survived.

The method

The work tracks where that happens, one LLVM pass at a time, using the compiler's own public flags rather than a modified compiler: -print-after-all to see the module as each pass leaves it, -opt-bisect-limit to stop the run at the pass that changed the answer, and __attribute__((annotate)) to mark the property being followed so it can be located in the output after inlining has rearranged everything around it.

Working with stock flags is a constraint that pays: a result that needs a patched compiler is a result about that patched compiler, and the question here is what happens to ordinary code under an ordinary toolchain.

The same question is put to gcc, and answered at a coarser grain: in the corpus lane its verdict is taken on the compiled function rather than on a named pass. A separate probe does read gcc's own-fdump-tree-all output to name the pass at which a tracked effect first goes absent, so the difference is between two instruments in this repository, not a limit of gcc.

The pass-level attribution comes from an out-of-tree pass plugin loaded through the documented-fpass-plugin interface, not from the flags alone. That is still not a patched compiler — the plugin builds against released LLVM headers and the compiler binary is the distribution's — but it is more than a flag, and the distinction is the first thing anyone reproducing this will hit.

Link-time optimisation moves part of the problem out of reach of all of that. The optimiser runs a second time at link time, over a module merged from every translation unit, and a disappearance that needs two units to happen can only happen there. That window was opened separately, and what it has caught so far is narrow: of fifteen cells at each optimisation level, exactly one carries a link-time attribution — at -O2, -O3 and -Os, none at -O1, on one fixture under clang, where a loss the compile-time view cannot see was attributed to dead-store elimination on the merged function. Three cells at every level are gcc's, and they are recorded as unsupported rather than as clean, because no observer of this kind can be loaded into that link step and "we could not look" is not "there was nothing there".

What has been measured

Three functions that mean entirely different things to a reader — an authorization check, a feature-flag test, and one written to mean nothing at all — compile to the same bytes. Each takes a pointer to a different struct, reads the first field and returns whether it is set; by the time a compiler decides what to emit, the type names that made them different are gone.

The length belongs to the toolchain rather than to the finding. At -O2 on x86-64 it is fourteen bytes under gcc 13.3.0 and nine under clang 18.1.3, the difference being the four-byte branch-protection prologue that Ubuntu's gcc emits by default and its clang does not.

Optimisation is not what does it. The three are identical at -O0 as well, on both compilers, at 28 and 25 bytes: they are the same computation as written, and -O2 only shortens the code they already share. What no diagnostic anywhere reports is that the distinction the programmer wrote has stopped existing. A control of the same shape, differing only in which field it reads, stays distinct at every level — which is what makes this a measurement and not a demonstration.

Secret-erasing writes disappear under optimisation too, and that has now been counted on C a language model wrote rather than on fixtures written to make the point. Of the 133 files the lane classes as erasing with a removable plain memset, compiling at -O2 for x86-64 removed that memset from 113 under clang 18.1.3 and from 108 under gcc 13.3.0. By the same cell-level criterion, none of the 160 scorable files that erased with an idiom a compiler is not allowed to drop lost it, at any optimisation level.

Three of those 133 are mislabelled, and the lane says so itself rather than leaving it to be found: two zero-fill a buffer before it is filled, which erases nothing, and one also erases by a route the compiler may not touch, so the memset it loses is not the only thing wiping that secret. With the three taken out the same cells read 112 and 107 of 130. Both pairs are in the lane's notes; the larger is its standing criterion and the smaller is what survives reading the three files.

How a loss is decided is what makes those columns comparable. Each file is compiled twice with identical flags, once as written and once with the erasing statement taken out, and the assembly of the target function is compared once the assembler's own bookkeeping has been stripped from it. The verdict on the erasure is never a search for the word memset in the output — the oracle an earlier draft of this page quoted a number from, and the one the protocol here forbids, because a deleted call and a surviving declaration look alike to a grep. A control erasure that cannot legally be removed is compiled into every file, and a configuration in which that control is not visible is dropped as unverifiable rather than counted as no change.

The corpus is narrow in ways worth stating. The tasks were synthetic, the code came from four models at one vendor, each compiler was a single version, and the code was generated by an agent rather than by a bare model call. The numbers describe that corpus and no wider one.

A removal found this way can also be undone by the same route, though the two compilers need different routes. Under clang a pass marks the erasing store volatile so that it has to be emitted; under gcc a pass inserts a barrier naming the buffer after it, which is added code rather than a mark on existing code. Both load through the compiler's documented plugin interface and neither patches a compiler, and repeating the comparison at -O2 brings the erasure back in all 113 of the clang cells and all 108 of the gcc ones. Those are in-sample figures for a plugin that is not packaged: no released VibeGuard channel carries any of this.

Where the code is

The toolchain's source is in the repository, under compiler/ (https://github.com/YUTAKONDO1205/VibeGuard/tree/main/compiler), together with a README explaining how to build it against a local LLVM. Nothing there is packaged, and none of the four VibeGuard channels carry any of it: the extensions, the CLI and the GitHub Action are built from the TypeScript workspaces and behave identically on a machine where this directory was never built. That boundary is checked by the repository's own packaging invariants, not maintained by hand.

That directory is licensed Apache-2.0 with the LLVM exception rather than the repository's MIT, because it builds against the Clang and LLVM libraries.

Status

A paper about this work is in preparation. It will be linked here once it is published.

概要(日本語)

コンパイラの最適化は、ソースコードが表明したセキュリティ上の性質を取り除くことがあります。鍵を ゼロ埋めする memset は、その後に誰もそのバッファを読まないという理由で、不要な書き込み として削除されることがあります。

意味の異なる関数どうしが、計算内容が同じであるという理由で同一のコードに畳み込まれることもあります。 ソースコードは書いたままですが、削除も畳み込みも、通常のツールチェーンは報告しません。

本プロジェクトでは、性質がどのパスで消えるかを LLVM のパス単位で追跡しています。用いるのは Clang と LLVM が公開しているフラグです。

-print-after-all で各パス通過後のモジュールを観測し、-opt-bisect-limit で 結果が変わったパスまでで実行を止めます。追跡したい性質には __attribute__((annotate)) で 印を付けます。

コンパイラ本体には手を入れません。改造したコンパイラで得た結果は、その改造したコンパイラについての 結果にしかならないからです。

ただし、パス単位の帰属を与えているのはフラグではありません。公開されている -fpass-pluginのインタフェースで読み込む外部のパスプラグインです。

これも改造したコンパイラではなく、プラグインは公開された LLVM のヘッダに対してビルドし、コンパイラ 本体は配布物そのままです。とはいえフラグだけで済む話ではないので、追試する際はここが最初の関門に なります。

gcc に対しても同じ問いを立てていますが、答えの粒度は粗くなります。コーパスのレーンでは、名前の付いた パスではなく生成された関数に対して判定しています。

もっとも、gcc 自身の -fdump-tree-all の出力を読み、効果が最初に消えるパスの名前を得る プローブは別に用意しています。これは本リポジトリが持つ2つの観測器の違いであり、gcc の限界では ありません。

リンク時最適化(LTO)では、ここまでの追跡が届かない領域が生じます。最適化が、全翻訳単位を結合した モジュールに対してリンク時にもう一度動くためです。

翻訳単位が2つそろわなければ起きない消失は、そこでしか起きません。この窓は別に開きましたが、捕まえ られたものは狭い範囲にとどまります。

各最適化水準15セルのうち、リンク時の帰属を持つのは1セルだけです。-O2 と-O3 と -Os に1件ずつで、-O1 にはありません。

そのセルでは、clang の1つの検体について、コンパイル時には見えない消失を、結合後の関数に対する デッドストア除去として帰属できました。各水準3セルは gcc のもので、異常なしではなく未対応として 記録しています。この種の観測器を gcc のリンク段に読み込めないためで、見られなかったことは、無かった ことではありません。

読み手にとって意味の異なる3つの関数、すなわち認可チェックと、フィーチャーフラグの判定と、まったく 意味を持たないように書かれた関数が、同一のバイト列にコンパイルされます。どれも別の構造体への ポインタを受け取り、最初のフィールドを読んで、それが立っているかを返すだけです。3つを違うものに していた型名は、コンパイラが出力を決める時点では残っていません。

長さは、所見ではなくツールチェーンに属します。x86-64 向けの -O2 では、gcc 13.3.0 で 14バイト、clang 18.1.3 で9バイトであり、差は Ubuntu の gcc が既定で付ける4バイトの分岐保護の 前置です。

最適化がこれを起こしているのではありません。3つは -O0 でも同一で、両方のコンパイラで それぞれ28バイトと25バイトになります。3つは書かれた時点から同じ計算であり、-O2 は すでに共有しているコードを短くしているだけです。

プログラマが書き分けた区別が失われたことを、どの診断も報告しません。なお、同じ形で読むフィールドだけ が違う対照は、どの水準でも別のバイト列のままです。これがこの比較を、実演ではなく計測にしています。

秘密を消去する書き込みも、最適化によって削除されます。その頻度は、この主張のために書いた検体では なく、言語モデルが生成した C コードで計測しました。

除去可能な素朴な memset で消去していると、このレーンが分類したファイルが133あります。 x86-64 向けに -O2 でコンパイルすると、その memset が削除されたのは clang 18.1.3 で113ファイル、gcc 13.3.0 で108ファイルでした。

一方、コンパイラが削除できない書き方で消去していたファイルでは、同じセル単位の基準で、採点できた 160ファイルのいずれも、どの最適化水準でも消去を失いませんでした。

この133ファイルのうち3件はラベルを誤っており、そのことはレーン自身が記録しています。2件は、埋める 前のバッファをゼロ埋めしているだけで、何も消去していません。

残る1件は、コンパイラが手を付けられない経路でも消去しており、失われた memset はその 秘密を消している唯一のものではありません。この3件を外すと、同じセルは130ファイル中112と107に なります。どちらの組もレーンの記録にあり、大きいほうが定常の基準、小さいほうが3ファイルを読んだ 結果です。

これらの列を比べられるのは、消去が削除されたかどうかの判定方法によります。各ファイルを同一のフラグ で2回コンパイルし、書かれたままの版と、消去文を取り除いた版とで、対象関数のアセンブリからアセンブラ 自身の管理情報を落としたうえで比較しました。

消去についての判定に、出力から memset という語を探す方法は使っていません。それは、この ページの以前の版が数値の出所にしていた方法であり、ここでの規約が禁じている方法です。削除された 呼び出しと、残った宣言とが、grep には同じに見えるためです。

また、削除できない消去を対照として全ファイルに同居させました。その対照が見えない構成は、変化 なしと数えるのではなく、検証不能として落としています。

ただし、検体の狭さは書いておく必要があります。課題は合成したもので、コードは1社の4種類のモデルから 得ました。

コンパイラは2種類で、それぞれ1つの版しか用いていません。さらに、コードを生成させたのは素のモデル 呼び出しではなくエージェントです。数値はこの検体の範囲に限定されます。

さらに、この方法で見つけた削除は同じ経路で元に戻せますが、2つのコンパイラでは経路が異なります。clang では、 パスが消去のストアを volatile として印を付け、出力を必須にします。

gcc では、パスがその直後にバッファを名指しするバリアを挿入します。これは既存のコードへの印ではなく、 追加したコードです。

どちらも公開されているプラグインのインタフェースで読み込み、コンパイラ本体は改造しません。-O2 で比較をやり直すと、clang の113セルと gcc の108セルのすべてで消去が戻りました。

ただしこれは同じ検体で測った数値で、このプラグインはパッケージ化していません。リリース済みの VibeGuard のチャネルは、いずれもこれを含みません。

なお、ツールチェーンのソースはリポジトリの compiler/ にあり、ローカルの LLVM に対してビルド する手順を README に記しています。ただし、パッケージ化された成果物はなく、VibeGuard の4つの配信 チャネルはいずれもこれを含みません。

拡張と CLI と GitHub Action は TypeScript のワークスペースから作られており、このディレクトリを一度も ビルドしていない環境でも同一に動作します。この境界はリポジトリ側の検査で機械的に保っています。

この研究についての論文を執筆しています。公表され次第、ここからリンクします。