CERT.EXPR.MOVED_FROM.RANGE
erase-remove アルゴリズムによって生成されたムーブ元範囲内の要素にはアクセスしない
CERT.EXPR.MOVED_FROM.RANGE チェッカーは CERT C++ 規則 EXP63-CPP を実装しています。std::remove、std::remove_if、std::unique などのアルゴリズムによって返された範囲内のコンテナー要素の使用を検出します。これらのアルゴリズムは、コンテナーから要素を削除するのではなく、保持された要素を先頭にムーブさせ、残りの要素を有効ではあるものの未指定の状態のまま残します。この末尾の範囲内の要素にアクセスすると、未指定の値が使用される可能性があります。
CERT.EXPR.MOVED_FROM.RANGE は、明示的なムーブ操作後のオブジェクトの使用を検出する CERT.EXPR.MOVED_FROM.USE を補完するチェッカーで、std::remove() や std::unique() などのアルゴリズムによって生成される未指定の値範囲へのアクセスに焦点を当てています。
脆弱性とリスク
std::remove(first, last, val) が new_last を返した後、[new_last, last) 内の要素はムーブ元の状態になります。new_last を越えて反復処理を行うと、実装によってたまたま残された値を呼び出し元が読み取ることになります。通常は前にある要素の重複ですが、規格でこれが保証されているわけではありません。コンテナーのサイズと終了反復子は変更されないため、範囲ベースの for ループや size() を上限とするインデックスループでは、未指定の末尾部分が暗黙に読み取られます。remove/unique の後に出力、ハッシュ化、シリアル化などの処理を連結するパイプラインでは、破損した値がソース側での警告もなく下流へと伝播します。
軽減と防止
アルゴリズムによって返された反復子は必ず消費します。その後の反復処理をその反復子にバインドするか (for (auto i = c.begin(); i != new_last; ++i))、あるいは erase-remove イディオム (c.erase(std::remove(c.begin(), c.end(), v), c.end())) を適用して、それ以上のアクセスが行われる前に、ムーブ元の末尾部分が物理的に削除されるようにします。ヘルパー関数がアルゴリズムを実行する場合は、ヘルパー関数内で消去を実行するか、境界反復子を返すことで、呼び出し側が erase-remove パターンを完了できるようにします。
脆弱コード例 1: 戻り値を無視した全範囲の反復処理
#include <algorithm>
#include <iostream>
#include <vector>
void case1_remove_full_iteration(std::vector<int> &c) {
std::remove(c.begin(), c.end(), 42); // source: return value ignored
for (auto v : c) { // CERT.EXPR.MOVED_FROM.RANGE
std::cout << v << std::endl; // reads into [new_last, last)
}
}
std::remove は、条件に一致しない要素を前方へムーブさせ、新しい論理的な末尾を返しますが、コンテナーのサイズは変更されません。for (auto v : c) を使って範囲全体を反復処理すると、ムーブ元の末尾部分を含め、c.end() までのすべての要素が読み取られます。
脆弱コード例 2: c.end() を上限とするインデックスループ
#include <algorithm>
#include <iostream>
#include <vector>
void case4_walk_to_end(std::vector<int> &c) {
auto e = std::remove(c.begin(), c.end(), 0);
(void)e; // boundary discarded
for (auto it = c.begin(); it != c.end(); ++it) { // CERT.EXPR.MOVED_FROM.RANGE
std::cout << *it << std::endl; // deref into [e, end())
}
}
返された反復子を e に格納するだけでは不十分です。もしループが c.end() まで反復を続け、e が一度も使用されない場合、ループはムーブ元の末尾部分を逆参照します。
脆弱コード例 3: プロシージャ間ラッパー
#include <algorithm>
#include <iostream>
#include <vector>
static void applyRemove(std::vector<int> &c, int val) {
std::remove(c.begin(), c.end(), val); // moved-from state escapes via &c
}
void fn_a1_source_in_callee(std::vector<int> &c) {
applyRemove(c, 7);
std::cout << c.front() << '\n'; // CERT.EXPR.MOVED_FROM.RANGE
}
applyRemove は、参照パラメーターをムーブ元範囲になっている状態のままにします。チェッカーは、呼び出し箇所で呼び出し元のコンテナーを再度汚染するため、その後の c.front() へのアクセスは、呼び出し先の std::remove の行を指すネストされたトレースとともに報告されます。
修正コード例 1: 返された反復子まで反復処理する
#include <algorithm>
#include <iostream>
#include <vector>
void case1_respect_boundary(std::vector<int> &c) {
auto e = std::remove(c.begin(), c.end(), 42);
for (auto i = c.begin(); i != e; ++i) { // OK: stops at the valid end
std::cout << *i << std::endl;
}
}
修正コード例 2: erase-remove イディオム
#include <algorithm>
#include <iostream>
#include <vector>
void case2_erase_remove(std::vector<int> &c) {
c.erase(std::remove(c.begin(), c.end(), 42), c.end()); // OK: tail erased
for (auto v : c) {
std::cout << v << std::endl;
}
}
c.erase(...) の呼び出しにより、ムーブ元の末尾部分が物理的に削除されます。EraseKillVisitor は、このノードのコンテナー汚染を解除するため、その後の反復処理はクリーンな状態で行われます。
外部参考資料
拡張機能
このチェッカーは、プロシージャ間バイナリ KB レコード (EMFR.ITER.SRC) を生成および消費するため、消去を実行せずに erase-remove アルゴリズムを実行するラッパーでは、ムーブ元の状態が呼び出し元に伝播されます。参照パラメーターとポインタパラメーターの両方が伝播されます。マルチホップチェーンもサポートしています。