メモリ安全性
目次
- 概要
- 安全化の方向
- 代表的な脆弱性
- 未定義動作と安全性
- 防御技術
- GC
- RAII
- Rustのownership
- 所有権を図で見る
- FFIとunsafe
- 境界設計
- 言語選択の判断
- 実務で見るポイント
- セキュリティレビューで見るところ
- メモリ安全性のアーキテクチャパターン
- Unsafe 境界の実装パターン
- メモリ安全性の防御層まとめ
- ケーススタディ:実際のメモリ安全性バグ
- メモリ安全性と言語設計の進化
- リアルワールド:組み込みシステムでのメモリ管理
- API設計 におけるメモリ安全性
- 実装レベルのメモリ保護
- パフォーマンスと安全性のバランス
- チェックリストとベストプラクティス
- 参考資料
- CWE(共通脆弱性リスト)の体系
- Rust による メモリ安全性の保証
- LLVM と Clang による メモリ安全性検証
- CWE(Common Weakness Enumeration)による脆弱性の分類と対策
- AddressSanitizer (ASAN)による実行時検出
- Rust の所有権とメモリ安全性保証
- SEI CERT C Coding Standard の主要ルール
- 実務での言語選択のチェックリスト
- まとめ
- 参考文献
概要
正しい計算より前に、壊れない実行が必要になる
メモリ安全性は、プログラムが意図しない領域を読み書きしないこと、寿命の切れた領域を触らないこと、競合する書き込みを避けることに関わります。低レイヤでは性能と同じくらい重要な基礎です。
メモリ安全性の問題は、単なるバグではなく、クラッシュや情報漏えい、任意コード実行につながることがあります。
この章で重視すること
安全化の方向
これらは単純な優劣ではなく、どの責任を人間、言語処理系、実行時、型システムのどこへ置くかの違いです。Cは自由度を高く保つ代わりに、所有権や寿命の管理を開発者とレビューに寄せます。GCを持つ言語は寿命管理を実行時へ寄せます。Rustは所有権と借用を型検査へ寄せます。
| 方式 | 主に防げる問題 | 残る注意点 |
|---|---|---|
| 手動管理 | 低レベル制御が可能 | use-after-free、double free、leak |
| GC | use-after-freeを避けやすい | pause、native resource、finalizer |
| RAII | 解放漏れを減らす | 所有権の設計、move後の扱い |
| ownership / borrow | lifetimeとdata raceを抑えやすい | 学習コスト、unsafe境界 |
メモリ安全性を考えるときは、言語機能だけでなく、FFI、ライブラリ、ビルド設定、テスト、運用中の入力も含めて見ます。
代表的な脆弱性
Buffer overflow
確保した領域を超えて書き込む問題です。隣接するデータや制御情報を壊し、クラッシュや任意コード実行につながることがあります。
Use-after-free
解放済みのメモリを再び使う問題です。参照先が別用途に再利用されていると、予測しにくい挙動になります。
Double free
同じ領域を2回解放する問題です。メモリアロケータの内部状態を壊し、セキュリティ問題につながることがあります。
Data race
複数threadが同じメモリへ同期なしにアクセスし、少なくとも1つが書き込みを行う問題です。値の破壊だけでなく、未定義動作の原因になります。
未定義動作と安全性
CやC++ では、仕様上「何が起きてもよい」とされる未定義動作があります。未定義動作は単に実行結果が不安定になるだけではなく、コンパイラ最適化によって直感と違うコードへ変換される原因にもなります。
代表例:
- 範囲外配列アクセス
- 解放済みポインタの使用
- signed integer overflow
- data race
- 未初期化値の読み取り
安全な言語は、これらを実行時チェック、所有権、型、GCなどで防ぎます。どこで防ぐかは言語によって違います。
防御技術
メモリ安全性を完全に言語だけへ任せられない場合、ビルドと実行環境で防御します。
| 技術 | 目的 |
|---|---|
| ASLR | アドレス配置を予測しにくくする |
| stack canary | stack破壊を検出する |
| DEP / NX | データ領域の実行を防ぐ |
| AddressSanitizer | 範囲外アクセスやuse-after-freeを検出する |
| ThreadSanitizer | data raceを検出する |
| fuzzing | 想定外入力で壊れ方を探す |
これらはバグをなくすものではありませんが、発見を早め、悪用の難度を上げます。
防御技術は重ねて使います。ASLRやNXは攻撃の成功を難しくし、sanitizerやfuzzingは開発中にバグを見つけます。役割が違うため、どれか1つを入れれば十分というものではありません。
CIではすべてのテストを毎回重くする必要はありません。通常のPRでは軽いsanitizerや静的解析を走らせ、parserやcodecのような攻撃面が広い部分は定期的にfuzzingを回す、といった段階化が現実的です。
GC
GCは到達不能なオブジェクトを自動で回収します。Java、Go、C#、JavaScriptなどで使われます。
利点:
- use-after-freeを避けやすい
- 開発者が解放タイミングを細かく管理しなくてよい
注意点:
- pause
- memory overhead
- finalizerの扱い
- native resourceの解放
RAII
RAIIは、オブジェクトの寿命と資源の寿命を結びつける考え方です。C++ でよく使われ、ファイル、lock、メモリなどをスコープ終了時に解放します。
{
std::lock_guard<std::mutex> lock(m);
// critical section
}
スコープを抜けるとlockが解放されるため、例外時にも資源漏れを減らせます。
Rustのownership
Rustはownershipとborrow checkerにより、コンパイル時に多くのメモリ安全性を保証します。1つの値には基本的に1つのownerがあり、参照には借用の規則があります。
- 共有参照は複数持てる
- 可変参照は同時に1つ
- 参照は所有者より長生きできない
この制約により、use-after-freeやdata raceを型システムで防ぎやすくなります。
所有権を図で見る
メモリ安全性の多くは「誰がその値を所有しているか」で説明できます。
共有参照がある間は変更できない、変更参照がある間は他の参照を持てない、という制約は、data raceとuse-after-freeを防ぐための強いルールです。
FFIとunsafe
安全な言語でも、CライブラリやOS APIと接続するときはunsafeな境界が出ます。重要なのはunsafeを完全に消すことではなく、小さく閉じ込め、外側へ安全なAPIを出すことです。
FFIでは、呼び出し規約、メモリ確保、文字列の終端、例外やpanic、thread安全性が境界になります。安全な言語の型だけを見ていると、C側の前提を見落とします。
| 境界 | 確認すること |
|---|---|
| buffer | pointerとlengthが一致しているか |
| string | encoding、null終端、所有権 |
| callback | 呼び出し元より長く保持されないか |
| error | Cのerror codeを安全な型へ変換しているか |
| thread | 別threadから呼ばれても安全か |
| resource | allocateした側とfreeする側が一致しているか |
FFIの外側には、通常の利用者がunsafeな前提を知らなくても使えるAPIを置きます。安全なラッパーは、単なる薄い関数ではなく、不変条件を守る境界です。
境界設計
メモリ安全な言語で書いていても、FFI、native extension、OS API、GPU API、serialization、unsafe blockなどでは境界ができます。
境界では次を確認します。
- buffer lengthを明示しているか
- 所有権をどちらが持つか
- 解放責任はどちらか
- nullやinvalid pointerをどう扱うか
- threadをまたぐ共有が安全か
- panicや例外が境界を越えないか
安全なAPIを外側へ出し、unsafeな部分を小さく閉じ込めるのが基本です。
言語選択の判断
メモリ安全性は言語選択にも影響します。
| 選択 | 向いている場面 | 注意点 |
|---|---|---|
| C / C++ | 既存資産、低レイヤ、ABI互換 | 手動管理とレビューが重い |
| Rust | 低レイヤと安全性を両立したい | 学習コスト、borrow設計 |
| Go / Java / C# | サービス開発、GC許容 | pause、native resource管理 |
| Swift / Kotlin | アプリ開発、安全な高水準API | platformとの境界 |
性能だけでなく、チームが安全に保守できるかも含めて選びます。
unsafeを使う場合の考え方
Rust BookやRustonomiconは、unsafeを「危険なコードを書くための抜け道」ではなく、compilerが静的に保証できない境界を人間が引き受ける仕組みとして説明します。重要なのは、unsafeを使った内部実装を小さく閉じ込め、外側には安全なAPIを出すことです。
unsafe境界では次を文書化します。
| 観点 | 書くこと |
|---|---|
| 前提条件 | pointerの有効性、alignment、長さ |
| aliasing | 同時にmutable参照が存在しない根拠 |
| lifetime | 参照先がいつまで生きるか |
| thread安全性 | Send / Sync相当の前提 |
| 失敗時 | panic、例外、error pathで資源が壊れないか |
安全な抽象は、呼び出し側が前提を知らなくても未定義動作を起こせない形にします。unsafeブロックを小さくするだけでは不十分で、その周辺の型、関数名、test、commentが同じ不変条件を説明している必要があります。
実務で見るポイント
- sanitizerをCIで使えるか
- fuzzingを導入できるか
- unsafe境界が小さいか
- external inputの長さ検証があるか
- ownershipが曖昧なpointerがないか
- native resourceの解放責任が明確か
実務では、危険なコードをゼロにできない場合があります。その場合は、危険な範囲を見えるようにします。
確認したい単位:
- unsafe blockや生pointerを含むmodule
- 外部入力をparseする箇所
- C APIやOS APIを呼ぶ箇所
- manual memory managementを行う箇所
- lockやatomicを使う共有状態
- lifetimeをcommentだけで説明している箇所
危険な箇所を一覧化すると、レビューの優先順位が決めやすくなります。すべてを同じ深さで読むのではなく、外部入力、権限境界、長期間動くprocess、並行処理を優先します。
セキュリティレビューで見るところ
メモリ安全性のレビューでは、危険なAPIの有無だけでなく、入力境界を見ます。
- 外部入力の長さを検証しているか
- binary format parserにfuzzingがあるか
- pointer arithmeticが局所化されているか
- lifetimeがコメントではなく型や構造で表現されているか
- C APIから返る所有権規約を確認しているか
- error pathで解放漏れや二重解放がないか
特にparser、codec、圧縮、暗号、画像処理、ネットワークpacket処理は、攻撃面になりやすい領域です。
メモリ安全性のアーキテクチャパターン
実務では、単一の防御技術ではなく、複数の層を組み合わせてメモリ安全性を確保します。層化防御(Defense in Depth)のアプローチでは、1つの施策の破綻時も全体が保護される設計が重要です。
層化防御のステージ
ステージ1: 開発フェーズ(静的検査)
- 静的解析ツール(Clang Static Analyzer、Valgrind の memcheck)で潜在的バグを検出
- コンパイル警告を有効にし、すべてを error 扱いにする:
-Wall -Wextra -Werror - 型安全な言語(Rust、TypeScript)を選択肢として検討
ステージ2: ビルドフェーズ(インストルメンテーション)
- AddressSanitizer(-fsanitize=address)でメモリバグの痕跡を検出
- ThreadSanitizer(-fsanitize=thread)で並行処理のバグを検出
- UndefinedBehaviorSanitizer(-fsanitize=undefined)で未定義動作を検出
- CI パイプラインに統合し、毎コミットで検査を実行
ステージ3: テストフェーズ(動的テスト)
- 通常のユニットテスト
- 境界値テスト(0, 1, SIZE_MAX, SIZE_MAX-1 など)
- Fuzzing(libFuzzer、AFL)で予測外の入力パターンを試行
- メモリリークテスト(valgrind --leak-check=full)
ステージ4: 実行時防御(OS レベル)
- Address Space Layout Randomization(ASLR):バッファオーバーフロー攻撃を複雑化
- NX ビット / DEP:
mmap(..., PROT_EXEC)での不正実行を防止 - Control Flow Guard(CFG)、Shadow Stack:リターンアドレス改ざんを検出
ステージ5: 監視・対応(運用フェーズ)
- メモリプロファイリング(peak memory, fragmentation)
- Crash reporting と post-mortem analysis
- 定期的なセキュリティレビュー
型とメモリ安全性の関係
メモリ安全性は型設計と深く関わります。弱い型システムでは、低レベルメモリ操作をすべてキャッチできません。
C の void* の問題:
void* data = malloc(sizeof(int));
char* ptr = (char*)data; // 型情報は失われた
// ptr[1000] = 42; // コンパイル時に検出不可
Rust の型検査による回避:
let data: Vec<i32> = vec![1, 2, 3];
// data[1000] = 42; // コンパイルエラー: 範囲外アクセス
型システムがメモリ安全性を保証するほど、実行時チェックの負担は減ります。
ポインタの寿命管理パターン
メモリ安全性の実務的な課題は「ポインタの寿命」です。いつまでそのポインタが有効か、が不明確な場合、バグが潜みやすくなります。
パターン1: スタックスコープ(RAII)
{
std::vector<int> v = {1, 2, 3};
int* p = &v[0];
// ...
} // ここで v が破棄され、p は無効化
管理人物とスコープを一致させると、解放漏れが減ります。
パターン2: 参照カウント(RefCount)
use std::rc::Rc;
let data = Rc::new(vec![1, 2, 3]);
let data2 = data.clone(); // 参照カウント +1
// 両方が scope を抜けるまで破棄されない
パターン3: 所有権委譲
fn take_ownership(s: String) {
// s はこの関数内で所有権を得て、
// 関数を抜けるときに破棄される
}
各パターンはトレードオフがあります。スコープは予測可能ですが柔軟性が低く、RefCount はオーバーヘッドがあり、所有権委譲は呼び出し側で意識が必要です。
Unsafe 境界の実装パターン
Rust 利用時、unsafe ブロックを完全に避けられない場合がよくあります。FFI、low-level I/O、SIMD などでは必須です。重要なのは、unsafe を「小さく局所化」し、周囲を安全な型で包むことです。
Unsafe-safe ラッパーの設計
悪い例:unsafe を使いまくる
unsafe {
let p = ptr as *mut u32;
for i in 0..100 {
*p.offset(i) = i as u32; // bound check なし
}
}
このコードは使う側が安全性の前提を理解する必要があり、呼び出し側がバグを作りやすくなります。
良い例:unsafe を内部に封じ込める
fn safe_write_range(ptr: *mut u32, len: usize, values: &[u32]) {
assert!(len >= values.len(), "Buffer too small");
unsafe {
for (i, &val) in values.iter().enumerate() {
*ptr.offset(i as isize) = val;
}
}
}
呼び出し側は assert! で前提条件の検証が済んでいることを知り、unsafe な詳細を意識する必要がありません。
Unsafe 使用時の文書化チェックリスト
各 unsafe ブロックには以下が記載されるべき:
-
前提条件(Preconditions)
- ポインタが有効か?
- アライメント要件は?
- ライフタイムは?
-
副作用(Side Effects)
- スレッド安全か?
- メモリ破損や UB を起こさないか?
-
失敗時の動作
- panic するか?
- エラーを返すか?
- リソースリークがないか?
例:
/// # Safety
///
/// このポインタは以下の条件を満たす必要があります:
/// - non-null で読み取り可能なメモリを指している
/// - T の要件するアライメントに従っている
/// - その後少なくとも len * sizeof(T) バイトが有効である
unsafe fn read_array<T: Copy>(ptr: *const T, len: usize) -> Vec<T> {
std::slice::from_raw_parts(ptr, len).to_vec()
}
メモリ安全性の防御層まとめ
実務では、複数の防御層を組み合わせてメモリ安全性を確保します。
層化防御(Defense in Depth)
開発フェーズ: 静的解析ツール、コンパイル警告を -Werror で厳格に
ビルド: AddressSanitizer(-fsanitize=address)、ThreadSanitizer
テスト: Fuzzing、境界値テスト、メモリリークテスト
実行時: ASLR、NX ビット、Control Flow Guard
運用: メモリプロファイリング、クラッシュレポート
ケーススタディ:実際のメモリ安全性バグ
実務で遭遇した、または歴史的に重要なメモリ安全性バグを通じて、抽象的な脅威を具体化します。
Case 1: Heartbleed(CVE-2014-0160)
OpenSSL のバージョン 1.0.1~1.0.1g で存在した、深刻な Use-After-Free バグ。
// Heartbleed の簡略化された本質
typedef struct {
uint16_t type;
uint16_t length; // ← 攻撃者が任意に指定可能
unsigned char payload[MAX_PAYLOAD_SIZE];
} HeartbeatMessage;
void process_heartbeat(HeartbeatMessage *msg) {
unsigned char response[MAX_PAYLOAD_SIZE];
memcpy(response, msg->payload, msg->length); // ← length を検証しない!
// msg->length が MAX_PAYLOAD_SIZE を超えると、バッファオーバーフロー
send_response(response, msg->length);
}
攻撃:
クライアント → length: 65535, payload: "ping" (4バイト)
サーバー → response: メモリから65535バイト読み込み(秘密鍵含む)
機密情報がネットワーク経由で流出。
教訓:
- 外部入力のサイズを必ず検証
- 配列アクセスの境界チェック
- AddressSanitizer でテスト
Case 2: Stagefright(Android、2015)
メディアフレームワークの整数オーバーフロー。
// 簡略化
uint32_t size = read_from_file(); // 攻撃者が制御可能
uint8_t *buffer = malloc(size); // size が大きすぎると小さいバッファ確保
memcpy(buffer, source, size); // バッファオーバーフロー
攻撃シナリオ:
- 細工された MMS メッセージ受信
- メディアフレームワークが処理
- バッファオーバーフロー → 任意コード実行
教訓:
- 整数オーバーフロー検査が必須
safe_add()のような安全な演算ユーティリティを使用
// 安全な add
bool safe_add(size_t a, size_t b, size_t *result) {
if (a > SIZE_MAX - b) return false; // overflow
*result = a + b;
return true;
}
Case 3: String Format Bugs(Format String Vulnerability)
ユーザー入力を書式文字列として使う。
// 悪い例
char *user_input = get_user_input();
printf(user_input); // ← user_input が制御される!
// 攻撃例
user_input = "%x %x %x %x"; // スタックの内容を読み出し
正しい例:
printf("%s", user_input); // ← format 文字列を明示
教訓:
- ユーザー入力を format 文字列に使わない
- printf 系は常に format 文字列を明示的に指定
メモリ安全性と言語設計の進化
言語設計の歴史を通じて、メモリ安全性への対応がどう進化したかを見ます。
第1世代(1970-1990):手動管理
C, Assembly
- 完全な制御
- メモリ管理は開発者の責任
- セキュリティ上の脆弱性が多数
第2世代(1990-2000):GC の導入
Java, C#, Python
Object obj = new Object(); // 確保
// 使用
// 自動的に GC で解放
メリット:Use-After-Free が無い
デメリット:GC pause、制御困難
第3世代(2010-):所有権と型安全性
Rust, Swift
let s = String::from("hello");
let s2 = s; // 所有権が移動
// s は無効化される(コンパイルエラーになる)
let s3 = &s; // 参照で借用
コンパイル時に安全性を証明。
リアルワールド:組み込みシステムでのメモリ管理
組み込みシステムは、メモリが限定的で、GC が使えないことが多い。Rust がこの領域で注目されています。
メモリプロファイリング
// Rust での静的メモリ確保
struct EmbeddedDevice {
buffer: [u8; 1024], // スタックに固定
timer: Timer,
state: State,
}
impl EmbeddedDevice {
fn process(&mut self, input: &[u8]) {
// buffer を再利用、動的割り当てなし
}
}
メリット:
- メモリ使用量が確定的
- GC pause なし
- Rust の型安全性で buffer overflow 防止
リーク検出
組み込みシステムでは、数十年稼働することがあります。わずかなメモリリークも累積すると問題になります。
# Valgrind でリーク検出
valgrind --leak-check=full ./embedded_app
# 出力例
==1234== LEAK SUMMARY:
==1234== definitely lost: 1,024 bytes
==1234== indirectly lost: 512 bytes
API設計 におけるメモリ安全性
ライブラリ設計時、外部に公開する API がメモリ安全性を阻害しないよう配慮が必要。
悪い API:ポインタを直接返す
// 危険
int* get_values() {
int values[10] = {1, 2, 3, ...};
return values; // スタック上の配列へのポインタ → use-after-free!
}
良い API:所有権を明示
// Rust な設計
pub fn get_values() -> Vec<i32> {
vec![1, 2, 3, ...] // 所有権を呼び出し側に譲渡
}
// または C での設計
int* get_values(int *out, int *count) {
*count = 10;
for (int i = 0; i < 10; i++) {
out[i] = i + 1;
}
return 0; // error code
}
実装レベルのメモリ保護
組み込みシステムやOSカーネル開発では、メモリ保護が人命に関わる場合があります。例えば医療機器、航空機のファームウェアでは、バッファオーバーフロー1つが危険です。
実装時の12の重要チェック:
- すべての外部入力は検証 - サイズ、範囲、型
- 固定サイズバッファでなく動的割り当て を検討
- strncpy などの安全な関数を使用、strcpy は避ける
- memcpy の src/dst サイズが一致確認
- 配列アクセスは常に境界チェック
- ポインタの null チェック
- 整数オーバーフローはないか
- メモリリークはないか(Valgrind で確認)
- use-after-free はないか
- double-free はないか
- concurrent access での race condition
- signal handler での reentrancy
静的解析と動的テストの併用で多くのバグが防止できます。
パフォーマンスと安全性のバランス
メモリ安全性の強化にはコストがあります。ASLR、DEP、AddressSanitizer はすべてパフォーマンスオーバーヘッドを伴います。
実務では段階化戦略:
- 開発・テスト環境:全防御有効化
- Staging環境:本番と同じ設定
- 本番:パフォーマンス重視の設定選択
段階的にセキュリティと性能を最適化することが現実的です。
チェックリストとベストプラクティス
セキュアコーディングチェックリスト
実装前に確認すべき項目:
- 外部入力検証:サイズ、型、値の範囲チェック
- バッファ管理:固定サイズか動的か、bounds チェック
- ポインタ安全性:null, dangling pointer チェック
- 整数演算:overflow, underflow 検査
- メモリリーク:すべての malloc に対応する free
- concurrency:race condition, deadlock の回避
- エラー処理:失敗パス でのリソース解放
- log 出力:sensitive data の漏えい確認
- 依存関係:脆弱性がないか定期チェック(CVE)
- テスト:ユニット、integration, fuzzing
コードレビューのポイント
セキュリティレビューで見るべき箇所:
- 攻撃面:external input を受け入れる箇所
- メモリ操作:malloc/free, strcpy, memcpy
- 並行処理:lock, atomic, volatile
- 例外経路:エラー時のリソース解放
- 信頼境界:privilege escalation の可能性
レビュアーの checklist として使用すると、統一的なレビューが実現できます。
Tools と自動化
開発プロセスへの統合:
- Pre-commit hook:clang-tidy で自動チェック
- CI pipeline:AddressSanitizer, Valgrind
- Static analysis:Coverity, Clang Static Analyzer
- Fuzzing:libFuzzer で予測外入力テスト
- SAST tool:Fortify, Checkmarx
- Dependency check:OWASP Dependency-Check
複数の層で防御することで、本番環境でのバグを削減。
まとめ:メモリ安全性への包括的アプローチ
メモリ安全性は単一の技術ではなく、言語設計、ツール、プロセスの総合的なアプローチです。完全な安全性は性能のコストが高すぎるため、実務では適切なレベルを選択します。
開発段階では厳密に:AddressSanitizer、Valgrind 本番前には十分テスト:fuzzing、security audit 本番環境では監視:crash reporting、memory profiling
このような多層防御により、深刻なバグを最小化できます。
参考資料
本ノートでは、メモリ安全性の基礎から実務応用まで、包括的に解説してきました。セキュリティと性能のバランスを理解し、適切なツール・言語・プロセスを選択することが重要です。
CWE(共通脆弱性リスト)の体系
MITRE が管理する CWE(Common Weakness Enumeration)は、ソフトウェア脆弱性を分類した標準。メモリ安全性に関連するCWEを把握することは、セキュリティ設計の基本。
メモリ安全性に関連する主要 CWE
CWE-119: Improper Restriction of Operations within the Bounds of a Memory Buffer
バッファ境界外の読み書き。Buffer Overflow、Buffer Underflow の原因。
// Vulnerable
char buffer[10];
strcpy(buffer, user_input); // 入力がbuffer サイズを超えたらoverflow
CWE-134: Use of Externally-Controlled Format String
Format String 脆弱性。printf("%s", user_input) のような直接出力が危険。
CWE-190: Integer Overflow or Wraparound
整数オーバーフロー。特にサイズ計算時に危険。
int size = width * height; // width, heightが大きいと overflow
char* buf = malloc(size);
CWE-416: Use-After-Free
解放済みメモリへのアクセス。ダングリングポインタの典型的な脆弱性。
int* p = malloc(sizeof(int));
free(p);
*p = 42; // Use-After-Free
CWE-426: Untrusted Search Path
DLL Hijacking など、信頼できないパスからライブラリをロード。
CWE の Top 25(2023版)の約70%はメモリ安全性に関連
堂々たるランキング:
- CWE-79: Cross-site Scripting (XSS)
- CWE-89: SQL Injection
- CWE-116: Improper Encoding or Escaping of Output
- CWE-862: Missing Authorization
- CWE-78: OS Command Injection
- CWE-434: Unrestricted Upload
- CWE-94: Improper Control of Generation of Code
- CWE-22: Path Traversal
- CWE-352: Cross-Site Request Forgery (CSRF)
- CWE-434: Unrestricted Upload of File with Dangerous Type
Rust による メモリ安全性の保証
Rust は、メモリ安全性を言語レベルで保証する設計。
Ownership(所有権)と Borrow(借用)
Rust の最大の特徴は、メモリの所有権を明示的に追跡すること。
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1 の所有権が s2 に移動
// println!("{}", s1); // エラー: s1はもう無効
}
借用により、所有権を失わずに参照可能:
fn main() {
let s1 = String::from("hello");
let s2 = &s1; // s1 を借用(参照)
println!("{}", s1); // OK
println!("{}", s2); // OK
}
Lifetime(ライフタイム)
Rust は参照のスコープを型システムで追跡。
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() > s2.len() {
s1
} else {
s2
}
}
'a は「返される参照は、入力両者の短い方のスコープを超えて有効」を意味。
メモリ安全性 + パフォーマンス
Rust で Use-After-Free や Buffer Overflow はコンパイル時に検出され、実行時エラーにならない。
C/C++:パフォーマンスで安全性と引き替え Rust:安全性もパフォーマンスも両取り
Unsafe ブロック
低レイヤアクセスが必要な場合、unsafe で制限区間を明示:
unsafe {
let p = 0x12345 as *mut i32;
*p = 42; // この操作は危険
}
LLVM と Clang による メモリ安全性検証
LLVM/Clang は、メモリ安全性の静的解析と動的検証を提供。
Clang Static Analyzer
コンパイル前にメモリバグを検出。
clang --analyze program.c
Sanitizers(サニタイザ)
実行時にメモリエラーを検出する検査ツール。
AddressSanitizer (ASAN)
gcc -fsanitize=address program.c
./a.out # buffer overflow, use-after-free を実行時に検出
MemorySanitizer (MSAN)
未初期化メモリアクセスを検出。
ThreadSanitizer (TSAN)
データレース(並行アクセスの競合)を検出。
SEI CERT Coding Standards
Carnegie Mellon University の Software Engineering Institute (SEI) が公開。
C の安全な部分集合を定義:
- 配列の動的長を避ける
- 関数ポインタの用途を制限
- 整数オーバーフロー検査を必須化
- メモリ解放後の参照を禁止
CWE(Common Weakness Enumeration)による脆弱性の分類と対策
CWE は MITRE が管理する、ソフトウェアの共通脆弱性の分類リストです。メモリ安全関連の脆弱性は、CWE Top 25 に常に上位にランク。
CWE-119:バッファ内の操作範囲の不適切な制限
- 正式名: Improper Restriction of Operations within the Bounds of a Memory Buffer
- 影響: バッファオーバーフロー、バッファアンダーフロー
- 典型的な例:
char buffer[64]; strcpy(buffer, user_input); // ← user_input が 64 文字を超える場合に危険 - 対策:
strcpyの代わりにstrncpyまたはstrlcpyを使用- バッファサイズと入力長を常に確認
- フォーマット文字列攻撃の防止(
printf(user_input)は NG、printf("%s", user_input)が正)
CWE-125:境界外読み取り(Out-of-bounds Read)
- 正式名: Out-of-bounds Read
- 影響: 情報漏えい、予期しない動作
- 典型的な例:
int array[10]; printf("%d\n", array[15]); // ← 確保していない領域を読む - 対策:
- 配列アクセス前に境界チェック
- バッファサイズを記録し、ループ条件で常に確認
memcpy(dest, src, size)は size が目的地のサイズ以下であることを保証
CWE-129:配列インデックスの不適切な検証
- 正式名: Improper Validation of Array Index
- 影響: バッファオーバーフロー、use-after-free
- 典型的な例:
int arr[10]; int index = get_from_user(); arr[index] = 42; // index >= 10 や index < 0 の検証がない - 対策:
- インデックスの範囲チェック:
if (index < 0 || index >= size) { error; } - 符号付き/符号なし型の混在に注意
- 整数オーバーフロー(例:
size_t + 1が 0 になる)を避ける
- インデックスの範囲チェック:
CWE-416:Use-After-Free
- 正式名: Use After Free
- 影響: メモリ破損、任意コード実行
- 典型的な例:
char *ptr = malloc(100); free(ptr); strcpy(ptr, "data"); // ← free 後にアクセス - 対策:
- free 後に
ptr = NULL;と明示的に NULL をセット - ダブルフリーの防止:
free前に NULL チェック - RAII (C++ では std::unique_ptr) で自動解放
- メモリサニタイザの使用
- free 後に
CWE-415:二重解放(Double Free)
- 正式名: Double Free
- 影響: ヒープ破損、クラッシュ
- 典型的な例:
void *ptr = malloc(100); free(ptr); free(ptr); // ← 2 度目の free はクラッシュまたはセキュリティ脆弱性 - 対策:
- スコープ終了時に自動解放(RAII)
- 所有権を明確にする(C:コメント、C++:スマートポインタ)
- リソース管理の集約(初期化→利用→終了を 1 関数内に)
AddressSanitizer (ASAN)による実行時検出
AddressSanitizer は clang/gcc に統合されたメモリエラー検出ツール。以下を実行時に検出:
- ヒープバッファオーバーフロー
- スタックバッファオーバーフロー
- グローバルバッファオーバーフロー
- Use-After-Free
- 二重解放
- 初期化されていないメモリの読み取り(MSAN)
- データレース(TSAN)
使用方法
# コンパイル時に -fsanitize=address を指定
gcc -fsanitize=address -g program.c -o program
# 実行時にメモリエラーを検出
./program
# 詳細なレポート出力
ASAN_OPTIONS=verbosity=2:halt_on_error=1 ./program
ASAN の出力例
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on unknown address 0x60700001dff4
WRITE of size 5 at 0x60700001dff4 thread T0
#0 0x45a63f in strcpy (/path/program+0x45a63f)
#1 0x45a6a2 in main (/path/program+0x45a6a2)
...
Address 0x60700001dff4 is 4 bytes to the right of 16-byte region [0x60700001dfe0,0x60700001dff0)
allocated by thread T0 here:
#0 0x460123 in malloc (/path/program+0x460123)
#1 0x45a65a in main (/path/program+0x45a65a)
この出力から:
- どの操作(WRITE)でエラーが発生したか
- メモリがどこに確保されたか
- バッファから何バイト超過しているか を即座に把握できます。
Rust の所有権とメモリ安全性保証
Rust の所有権モデル(ownership model)は、コンパイル時に以下を保証:
- 各値は唯一の所有者を持つ
- スコープを抜けると所有者は自動的に DROP される
- 複数の可変参照は存在しない (mutable XOR alias)
- イミュータブル参照と可変参照は共存しない
所有権の移動(Move)
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1 は s2 に move、s1 は使用不可
// println!("{}", s1); // ← コンパイルエラー
println!("{}", s2); // OK
}
C で同じ動作をするなら:
char *s1 = malloc(strlen("hello") + 1);
strcpy(s1, "hello");
char *s2 = s1;
// s1 を使用してはいけない(本当)
free(s2);
ただし、C コンパイラは所有権移動をチェックしません。Rust はコンパイル時に強制します。
借用(Borrow)
fn print_string(s: &String) {
println!("{}", s); // s は借用、関数終了時に所有権は返される
}
fn main() {
let s = String::from("hello");
print_string(&s);
println!("{}", s); // s はまだ有効
}
イミュータブル借用(不変参照):複数同時
let s = String::from("data");
let r1 = &s;
let r2 = &s;
let r3 = &s;
// r1, r2, r3 はすべて読み取り可能
可変借用(可変参照):1 つだけ、同時に他の参照と共存不可
let mut s = String::from("data");
let r1 = &mut s;
r1.push_str("!");
// let r2 = &s; // ← コンパイルエラー:s は既に &mut s で借用中
このルールが、データレース(data race)を言語レベルで防ぎます。
Unsafe Rust:言語の安全保証を外す場面
unsafe {
// C のようなポインタ演算、メモリアクセスが可能
let ptr = 0x12345678 as *mut u32;
*ptr = 42; // dereferencing raw pointer
// 外部 C 関数呼び出し
libc::printf(b"Hello\n".as_ptr() as *const i8);
}
unsafe ブロックを使う場合でも、外側の Rust 型システム(所有権、借用)で保護されます。
SEI CERT C Coding Standard の主要ルール
Carnegie Mellon University の Software Engineering Institute (SEI) が公開する安全な C 部分集合:
重要なルール
CERT C / MEM (メモリ管理)
-
MEM01-C: malloc() のリターン値を確認
void *ptr = malloc(1024); if (ptr == NULL) { perror("malloc"); exit(1); } -
MEM02-C: malloc() で確保したメモリの寿命を追跡
- グローバル変数の一覧化
- 所有権を明示的にコメント化
-
MEM03-C: メモリのサイズをハードコードしない
// NG char buffer[64]; // OK #define BUFFER_SIZE 64 char buffer[BUFFER_SIZE]; -
MEM04-C: 動的配列の長さを先に確認
// NG for (int i = 0; i <= n; i++) arr[i] = ...; // i == n で越境 // OK for (int i = 0; i < n; i++) arr[i] = ...;
CERT C / ARR (配列境界)
- ARR01-C: 動的配列の長さを変数で管理
- ARR02-C: インデックスの範囲を常に確認
CERT C / INT (整数オーバーフロー)
- INT32-C: 整数演算結果のオーバーフロー/アンダーフロー防止
// NG int a = INT_MAX; int b = a + 1; // オーバーフロー // OK if (a > INT_MAX - 1) { error; } int b = a + 1;
CERT 準拠コーディング例
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
#define MAX_BUFFER_SIZE 256
// 入力バッファを安全にコピー
int safe_copy(char *dest, size_t dest_size, const char *src) {
if (dest == NULL || src == NULL) {
return -1; // エラー
}
if (dest_size == 0) {
return -1;
}
size_t src_len = strlen(src);
if (src_len >= dest_size) {
// バッファオーバーフローを防ぐ
strncpy(dest, src, dest_size - 1);
dest[dest_size - 1] = '\0';
return 1; // 警告:切り詰め
}
strcpy(dest, src);
return 0; // 成功
}
int main() {
char buffer[MAX_BUFFER_SIZE];
const char *input = "user input from network";
int result = safe_copy(buffer, sizeof(buffer), input);
if (result < 0) {
fprintf(stderr, "Error: safe_copy failed\n");
return 1;
}
printf("Buffer: %s\n", buffer);
return 0;
}
実務での言語選択のチェックリスト
メモリ安全関連の脆弱性を避けるため:
| 優先度 | チェック項目 | C | C++ | Go | Rust | Java | Python |
|---|---|---|---|---|---|---|---|
| 1 | メモリ安全(オフバイワン禁止) | × | ◎ | ◎ | ◎ | ◎ | ◎ |
| 2 | バッファオーバーフロー検出 | × | × | ◎ | ◎ | ◎ | ◎ |
| 3 | Use-After-Free 防止 | × | △ | ◎ | ◎ | ◎ | ◎ |
| 4 | データレース検出 | × | × | ◎ | ◎ | ◎ | ◎ |
| 5 | 型安全性 | × | △ | ◎ | ◎ | ◎ | ◎ |
| 6 | 性能(コンパイル言語) | ◎ | ◎ | ◎ | ◎ | × | × |
説明:
- ◎: 言語仕様で保証(ほぼ全ケース)
- △: 可能だがプログラマが注意必要
- ×: 言語が保証しない
判断基準:
- 超低レイヤー(カーネル、組み込み)→ C + 厳格なコード審査(SEI CERT)+ Sanitizer
- 高性能が必要 → C++ (smart pointers) または Rust
- クリティカルシステム → Rust が最優先
- 開発速度重視 → Python/Java、ただしメモリセーフティの監視強化
公式・標準
- CWE-119
- Rustonomicon
- The Rust Programming Language: Ownership
- The Rust Programming Language: Unsafe Rust
- SEI CERT C Coding Standard
解説・補助
まとめ
メモリ安全性はランタイム機能の話であると同時に、言語設計の話でもあります。安全性がどこで保証されるかを知ると、C、C++、Rust、Java、Goなどの設計差が見えやすくなります。