101
78

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

安全と言われるRustの落とし穴

Posted at

Rustは「安全な言語」として知られています。特にメモリ安全性の面では優れた言語設計が施されていますが、それだけですべての問題が解決するわけではありません。コンパイラは多くのバグを捕捉してくれますが検出できない落とし穴も存在します。

上記の記事にRustで安全なコードを書く際によく遭遇する落とし穴と、それらを避けるためのベストプラクティスが非常によくまとまっていました。いくつか紹介します。

Rustコンパイラが検出できない問題の種類

Rustのコンパイラは優秀ですが、以下のような問題は検出できません:

  • 数値型の変換ミスによるオーバーフロー
  • ロジックのバグ
  • unwrapexpectの使用による意図しないパニック
  • サードパーティクレートの問題のあるbuild.rsスクリプト
  • 依存ライブラリの不適切なunsafeコード
  • 競合状態

それでは、よくある落とし穴とその対策を見ていきましょう。

1. 整数オーバーフローに気をつける

整数のオーバーフローはよくある問題です。次のコードを見てみましょう:

// 良くない例:オーバーフローの可能性がある
fn calculate_total(price: u32, quantity: u32) -> u32 {
    price * quantity  // 大きな数値の場合、オーバーフローする可能性あり!
}

この例では、pricequantityが大きな値の場合、結果がオーバーフローする可能性があります。デバッグモードではパニックしますが、リリースモードではラップアラウンドします(値が最大値を超えると0から再度カウントを始める)。

対策:チェック付き算術演算を使う

// 良い例:チェック付き算術演算
fn calculate_total(price: u32, quantity: u32) -> Result<u32, ArithmeticError> {
    price.checked_mul(quantity)
        .ok_or(ArithmeticError::Overflow)
}

checked_mulなどのメソッドを使うと、オーバーフローが発生した場合にNoneを返してくれるので、適切にエラー処理ができます。

また、Cargo.tomlに以下を追加しておくとリリースビルドでもオーバーフローチェックが有効になるそうです。覚えておきたいですね:

[profile.release]
overflow-checks = true

2. 数値型の変換時はasを安易に使わない

数値型の変換にはよくasキーワードが使われますが、これは安全でない場合があります:

let x: i32 = 1000;
let y: i8 = x as i8;  // 値が範囲外なので切り捨てられる!

この例では、i32からi8への変換で値が切り捨てられ、予期しない結果になります。

対策:安全な変換方法を使う

Rustには3つの主要な数値変換方法があります:

  1. asキーワード:便利だが安全でない(値が切り捨てられる可能性あり)
  2. From::from():データ損失のない変換のみ許可(拡大変換に最適)
  3. TryFrom:変換が失敗する可能性がある場合にResultを返す
// 良い例:TryFromを使った安全な変換
use std::convert::TryFrom;

let x: i32 = 1000;
let y = i8::try_from(x).map_err(|_| "値が大きすぎます")?;

この方法なら、変換が失敗した場合にエラー処理ができます。

3. 数値に境界つき型を使う

数値が特定の条件を満たす必要がある場合、単純なプリミティブ型ではなく、境界つき型を使いましょう。

// 良くない例:無制限の数値型
struct Product {
    price: f64,   // マイナスの可能性あり!
    quantity: i32,   // マイナスの可能性あり!
}

対策:カスタム型で制限を設ける

// 良い例:境界つき型
#[derive(Debug, Clone, Copy)]
struct NonNegativePrice(f64);

impl NonNegativePrice {
    pub fn new(value: f64) -> Result<Self, PriceError> {
        if value < 0.0 || !value.is_finite() {
            return Err(PriceError::Invalid);
        }
        Ok(NonNegativePrice(value))
    }
}

struct Product {
    price: NonNegativePrice,
    quantity: std::num::NonZeroU32,  // 0以外の正の整数のみ
}

この方法では、不正な値を持つオブジェクトが作れなくなります。

4. 配列のインデックスアクセスはgetメソッドを使う

配列やベクトルへの直接のインデックスアクセスは危険です:

let arr = [1, 2, 3];
let elem = arr[3];  // 範囲外アクセスでパニック!

対策:getメソッドでオプション値を返す

let arr = [1, 2, 3];
if let Some(elem) = arr.get(3) {
    println!("4番目の要素: {}", elem);
} else {
    println!("4番目の要素はありません");
}

5. split_atよりsplit_at_checkedを使う

スライスを分割する際も同様に注意が必要です:

let arr = [1, 2, 3];
let (left, right) = arr.split_at(4);  // 範囲外でパニック!

対策:チェック付きの分割方法を使う

let arr = [1, 2, 3];
match arr.split_at_checked(3) {
    Some((left, right)) => println!("分割成功: {:?} と {:?}", left, right),
    None => println!("インデックスが範囲外です")
}

6. ビジネスロジックではプリミティブ型を避ける

文字列などのプリミティブ型をそのまま使うと、バリデーションが困難になります:

// 良くない例:制限のないString型
fn authenticate_user(username: String) {
    // ユーザー名が空かも?特殊文字が含まれているかも?
}

対策:バリデーション付きのカスタム型を作る

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Username(String);

impl Username {
    pub fn new(name: &str) -> Result<Self, UsernameError> {
        if name.is_empty() {
            return Err(UsernameError::Empty);
        }

        if name.len() > 30 {
            return Err(UsernameError::TooLong);
        }

        if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
            return Err(UsernameError::InvalidCharacters);
        }

        Ok(Username(name.to_string()))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

fn authenticate_user(username: Username) {
    // ユーザー名は常に有効!
}

この方法なら、関数に渡される時点で値が有効であることが保証されます。

7. 無効な状態を型システムで表現できないようにする

フィールドの組み合わせによっては無効な状態が生じることがあります:

// 良くない例:不正な組み合わせが可能
struct Configuration {
    port: u16,
    host: String,
    ssl_enabled: bool,
    ssl_cert: Option<String>, // ssl_enabled=trueなのに証明書がNoneという状態が可能
}

対策:列挙型で有効な状態のみを表現する

// 良い例:型で状態を強制
enum ConnectionSecurity {
    Insecure,
    Ssl { cert_path: String },  // 証明書なしのSSLは表現できない
}

struct Configuration {
    port: u16,
    host: String,
    security: ConnectionSecurity,
}

この方法では、「SSL有効だが証明書がない」という無効な状態を表現できなくなります。

8. Defaultの実装は慎重に

何も考えずにDefaultを実装すると問題が生じることがあります:

// 良くない例:不適切なデフォルト値
#[derive(Default)]
struct ServerConfig {
    port: u16,       // 0になる(有効なポートではない)
    max_connections: usize,  // 0になる
    timeout_seconds: u64, // 0になる
}

対策:意味のあるデフォルト値を提供するか、Defaultを実装しない

// 良い例:明示的な初期化
struct ServerConfig {
    port: Port,
    max_connections: std::num::NonZeroUsize,
    timeout: std::time::Duration,
}

impl ServerConfig {
    pub fn new(port: Port) -> Self {
        Self {
            port,
            max_connections: NonZeroUsize::new(100).unwrap(),
            timeout: Duration::from_secs(30),
        }
    }
}

9. Debug実装時は機密情報に注意

Debugトレイトを自動導出すると、機密情報が漏洩する恐れがあります:

// 良くない例:機密情報が表示される
#[derive(Debug)]
struct User {
    username: String,
    password: String,   // デバッグ出力で見えてしまう!
}

対策:機密情報を含む型はDebugを手動実装する

#[derive(Debug)]
struct User {
    username: String,
    password: Password,
}

struct Password(String);

impl std::fmt::Debug for Password {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("[REDACTED]")
    }
}

これにより、デバッグ出力時にパスワードが「[REDACTED]」と表示されるようになります。

また、次のような実装もあります:

impl std::fmt::Debug for DatabaseURI {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // 構造体を分解して、フィールドが追加された場合にコンパイルエラーになるようにする
        let DatabaseURI { scheme, user, password: _, host, database, } = self;
        write!(f, "{scheme}://{user}:[REDACTED]@{host}/{database}")?;
        Ok(())
    }
}

10. シリアライズ・デシリアライズは慎重に

serdeSerializeDeserializeを自動導出すると、思わぬ問題が生じることがあります:

// 良くない例:チェックなしのシリアライズ
#[derive(Serialize, Deserialize)]
struct UserCredentials {
    #[serde(default)]   // デシリアライズ時に空文字を受け入れる
    username: String,
    #[serde(default)]
    password: String,  // シリアライズ時に平文で出力される
}

対策:カスタム実装で検証を組み込む

#[derive(Deserialize)]
#[serde(try_from = "String")]
pub struct Password(String);

impl TryFrom<String> for Password {
    type Error = PasswordError;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        if value.len() < 8 {
            return Err(PasswordError::TooShort);
        }
        Ok(Password(value))
    }
}

この方法では、デシリアライズ時に自動的に検証が行われます。

11. 時間差攻撃から守る

機密データを比較する際は、実行時間の差から情報が漏れる可能性があります:

// 良くない例:単純な比較
fn verify_password(stored: &[u8], provided: &[u8]) -> bool {
    stored == provided   // タイミング攻撃に弱い
}

対策:定数時間比較を使用する

// 良い例:定数時間比較
use subtle::{ConstantTimeEq, Choice};

fn verify_password(stored: &[u8], provided: &[u8]) -> bool {
    stored.ct_eq(provided).unwrap_u8() == 1
}

12. 入力サイズを制限する

無制限の入力を受け付けると、DoS攻撃の原因になります:

// 良くない例:サイズ制限なし
fn process_request(data: &[u8]) -> Result<(), Error> {
    let decoded = decode_data(data)?;   // 巨大なデータでメモリを使い果たす可能性
    // 処理
    Ok(())
}

対策:明示的なサイズ制限を設ける

// 良い例:明示的なサイズ制限
const MAX_REQUEST_SIZE: usize = 1024 * 1024;  // 1MiB

fn process_request(data: &[u8]) -> Result<(), Error> {
    if data.len() > MAX_REQUEST_SIZE {
        return Err(Error::RequestTooLarge);
    }
    
    let decoded = decode_data(data)?;
    // 処理
    Ok(())
}

13. Path::joinの挙動に注意

パスを結合する際のPath::joinメソッドには注意が必要です:

use std::path::Path;

fn main() {
    let path = Path::new("/usr").join("/local/bin");
    println!("{path:?}");  // "/local/bin" と出力される
}

第2引数が絶対パスの場合、第1引数は無視されます。 これは意図しない動作につながる可能性があります。

14. 依存パッケージのunsafeコードをチェックする

自分のコードだけでなく、依存ライブラリのunsafeコードも脆弱性の原因になります。

対策:cargo-geigerで依存関係をチェック

cargo install cargo-geiger
cargo geiger

このツールを使うと、プロジェクトの依存関係に含まれるunsafe関数の数を確認できます。

15. Clippyでコンパイル時に問題を検出する

Rustのlintツール「Clippy」を使うと、前述の多くの問題をコンパイル時に検出できます:

// 数値演算関連
#![deny(arithmetic_overflow)]
#![deny(clippy::checked_conversions)]
#![deny(clippy::cast_possible_truncation)]

// unwrap関連
#![deny(clippy::unwrap_used)]
#![deny(clippy::expect_used)]

// 配列インデックス関連
#![deny(clippy::indexing_slicing)]

// その他
#![deny(clippy::join_absolute_paths)]
#![deny(clippy::serde_api_misuse)]

覚えておきたいのが:

  • cargo check はこれらの問題を報告しません
  • cargo run は実行時にパニックするか失敗します

一方

  • cargo clippyコンパイル時にすべての問題を検出します!

まとめ

Rustは安全な言語として設計されていますが、コンパイラだけでは検出できない問題も多くあります。実践的な対策も紹介されており大変参考になりました。

参考リンク

101
78
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
101
78

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?