ITエンジニアによるITエンジニアのためのブログ

Rubyのsecure_compareでタイミングアタックを防ぐ方法

RubyでHMACなどの認証トークンを検証する際に、安易に==演算子を使うと、タイミングアタックと呼ばれる攻撃に対して脆弱になる可能性があります。

この記事では、タイミングアタックの仕組み、==演算子の危険性、そして安全な代替手段であるsecure_compareメソッドについて解説します。

目次

  • タイミングアタックとは? なぜ String#== は危険なのか?
  • 安全な比較のためのsecure_compareメソッド
  • Rack::Utils.secure_compareのコード解説
  • ActiveSupport::SecurityUtils.secure_compare の実装 (Rails)
  • OpenSSL.fixed_length_secure_compare (コアとなる比較関数)
  • 【注意】OpenSSL.secure_compare は安全ではない?
  • まとめ:推奨される比較方法

タイミングアタックとは?なぜString#==は危険なのか?

タイミングアタックとは、処理時間のわずかな違いを利用して、秘密の情報(パスワードやトークンなど)を推測する攻撃手法です。

例えば、以下のようにURLパラメータで渡されたトークンを検証する処理を考えてみましょう。

# URL例
/example?token=abcde

# 不安全な認証メソッドの例
def authenticate(correct_token, provided_token)
  # == 演算子で比較している
  if correct_token == provided_token
    puts "認証成功"
    # ... 認証後の処理 ...
  else
    puts "認証失敗"
    # ... エラー処理 ...
  end
end

correct_token = 'bcdefgh' # 正しいトークン
provided_token = params['token'] # ユーザーが送信したトークン

authenticate(correct_token, provided_token)

このコードで使用されている String#== メソッドは、文字列を先頭から一文字ずつ比較し、異なる文字が見つかった時点で false を返して比較を終了します。

  • 'aaaaaaa' == 'bcdefgh' の場合: 最初の文字 ab が違うため、すぐに比較が終了し false が返る。
  • 'baaaaaa' == 'bcdefgh' の場合: 最初の文字 b は一致。次の ac が違うため、ここで比較が終了し false が返る。

攻撃者は、この比較終了までの時間の差を悪用します。

  1. 考えられる文字で始まるトークン ('aaaaaaa', 'bbbbbbb', 'ccccccc', …) を順番に送信し、レスポンス時間を計測します。
  2. もし 'bbbbbbb' を送信した際のレスポンス時間が、他の文字で始まるトークンよりもわずかに長ければ、「最初の文字は b である可能性が高い」と推測できます。
  3. 次に、'baaaaaa', 'bbbbbbb', 'bcccccc', … と2文字目を変化させて送信し、同様にレスポンス時間を計測します。
  4. これを繰り返すことで、最終的に正しいトークン bcdefgh を特定できてしまう可能性があります。これがタイミングアタックの原理です。

安全な比較のための secure_compare メソッド

String#== のような比較方法の脆弱性を防ぐために、Rubyには比較時間に差が出ないように設計された secure_compare という名前のメソッドがいくつかのライブラリで提供されています。

代表的なもの:

これらのメソッドは、基本的に文字列全体を比較してから結果を返すため、途中で処理時間が変わることがなく、タイミングアタックを防ぐことができます。

今回は、Rubyのみで実装されている Rack::Utils.secure_compare (OpenSSL拡張がない場合のフォールバック実装) を例に、その仕組みを見てみましょう。

Rack::Utils.secure_compare のコード解説

# Rack::Utils.secure_compare の OpenSSL が使えない場合の Ruby 実装
def secure_compare(a, b)
  # 1. バイトサイズが違う場合は即座に false を返す
  return false unless a.bytesize == b.bytesize

  # 2. 文字列 a をバイト値 (8ビット符号なし整数) の配列に変換
  l = a.unpack("C*") # 例: 'abc' -> [97, 98, 99]

  r = 0 # 結果を格納する変数
  i = -1 # 配列インデックス

  # 3. 文字列 b を1バイトずつ処理
  b.each_byte { |byte_b|
    i += 1
    byte_a = l[i] # 対応する a のバイト値を取得

    # 4. a と b の対応するバイトを XOR (排他的論理和) し、結果を r に OR 演算で累積
    r |= byte_a ^ byte_b
  }

  # 5. すべてのバイトが一致していれば r は 0 のまま。一致していなければ 0 以外になる。
  r == 0
end

ポイント解説:

  1. バイトサイズ比較: a.bytesize == b.bytesize
    • まず、2つの文字列のバイト長が同じかを確認します。長さが異なれば、明らかに違う文字列なので即座に false を返します。
    • 注意点: この最初のチェックにより、文字列の長さに関する情報はタイミング攻撃から保護されません(長さが違う場合は処理時間が短くなる)。そのため、secure_compare は基本的にHMAC署名やハッシュ値など、常に長さが決まっている(固定長の)文字列の比較に使用する必要があります。可変長の秘密情報(パスワードそのものなど)の比較には適していません。
  2. バイト配列変換: a.unpack("C*")
    • 文字列 a を、各バイトに対応する8ビット符号なし整数(0〜255)の配列に変換します。
  3. 全バイト比較ループ: b.each_byte { ... }
    • 文字列 b の各バイト (byte_b) についてループ処理を行います。String#== と異なり、途中で違いが見つかってもループは中断されません
  4. XORとOR演算: r |= byte_a ^ byte_b
    • ここがタイミング攻撃を防ぐ核となる部分です。
    • ^ はビット演算子のXOR (排他的論理和) です。2つのビットを比較し、同じなら0、違えば1を返します。つまり、byte_abyte_b完全に同じであれば byte_a ^ byte_b は 0 になり、1ビットでも異なれば 0 以外の値になります。
    • 例: 97 ^ 97 -> 0b01100001 ^ 0b01100001 -> 0b00000000 -> 0
    • 例: 97 ^ 98 -> 0b01100001 ^ 0b01100010 -> 0b00000011 -> 3
    • |= はビット演算子のOR代入です (r = r | (byte_a ^ byte_b))。XORの結果 (byte_a ^ byte_b) と現在の r の値でビットごとのOR演算を行います。
    • r は最初に 0 で初期化されています。ループ中で byte_abyte_b が一度でも異なると、byte_a ^ byte_b は 0 以外の値になります。その値と r をOR演算すると、r のいずれかのビットが 1 になり、r は 0 ではなくなります。一度 r が 0 以外になると、以降のループで byte_a ^ byte_b0 になったとしても、OR演算の特性上、r が再び 0 に戻ることはありません。
  5. 最終結果: r == 0
    • ループが最後まで実行された後、変数 r の値を確認します。
    • もし r0 のままなら、比較したすべてのバイトで byte_a ^ byte_b0 だった、つまり文字列 ab が完全に一致していたことを意味し、true を返します。
    • もし r0 以外なら、どこかのバイトで違いがあったことを意味し、false を返します。

このように、Rack::Utils.secure_compare (のRuby実装部分) は、文字列の長さが同じであれば、内容が一致していてもしていなくても、必ず最後までループ処理を行うため、処理時間に差が出にくく、タイミングアタックを防ぐことができます。

ActiveSupport::SecurityUtils.secure_compareの実装(Rails)

Ruby on Rails で使われる ActiveSupport::SecurityUtils.secure_compare は、内部で OpenSSL.fixed_length_secure_compare を利用しています。

# ActiveSupport::SecurityUtils の実装 (抜粋)

    def secure_compare(a, b)
      # 文字列のバイト長が一致しているかチェック (必須)
      a.bytesize == b.bytesize && fixed_length_secure_compare(a, b)
    end

    if defined?(OpenSSL.fixed_length_secure_compare)
      def fixed_length_secure_compare(a, b)
        # OpenSSL の時間一定比較関数を呼び出す
        OpenSSL.fixed_length_secure_compare(a, b)
      end
    else
      def fixed_length_secure_compare(a, b)
        # Rack の実装と同様のフォールバック処理 (長さチェック + Rubyによる比較)
        # ... (省略) ...
      end
    end

基本的には Rack::Utils.secure_compare と同様に、まずバイト長を比較し、同じであればコアとなる時間一定の比較関数 OpenSSL.fixed_length_secure_compare を呼び出します。

OpenSSL.fixed_length_secure_compare (コアとなる比較関数)

Rack::Utils.secure_compare (OpenSSL拡張が利用可能な場合) や ActiveSupport::SecurityUtils.secure_compare が内部で利用しているのが、openssl gem が提供する OpenSSL.fixed_length_secure_compare です。

このメソッドはC言語で実装されており、非常に高速かつ時間一定で文字列を比較します。

static VALUE
ossl_crypto_fixed_length_secure_compare(VALUE dummy, VALUE str1, VALUE str2)
{
    // ... 文字列ポインタと長さを取得 ...
    const unsigned char *p1 = (const unsigned char *)StringValuePtr(str1);
    const unsigned char *p2 = (const unsigned char *)StringValuePtr(str2);
    long len1 = RSTRING_LEN(str1);
    long len2 = RSTRING_LEN(str2);

    // 長さが違う場合はエラー
    if (len1 != len2) {
        ossl_raise(rb_eArgError, "inputs must be of equal length");
    }

    // OpenSSL ライブラリの CRYPTO_memcmp を呼び出して比較
    // CRYPTO_memcmp は時間一定でメモリ内容を比較する関数
    switch (CRYPTO_memcmp(p1, p2, len1)) {
        case 0:	return Qtrue; // 一致
        default: return Qfalse; // 不一致
    }
}

// CRYPTO_memcmp の実装 (OpenSSLライブラリ内)
int CRYPTO_memcmp(const void *in_a, const void *in_b, size_t len)
{
    size_t i;
    const volatile unsigned char *a = in_a;
    const volatile unsigned char *b = in_b;
    unsigned char x = 0;

    // .ループとビット演算を使い、時間一定で比較
    for (i = 0; i < len; i++)
        x |= a[i] ^ b[i];

    return x;
}

重要なのは、このメソッドも固定長 (fixed length) の文字列比較を前提としている点です。引数の文字列長が異なるとエラーが発生します。

【注意】OpenSSL.secure_compare は安全ではない?

openssl gem には、OpenSSL.fixed_length_secure_compare とは別に OpenSSL.secure_compare というメソッドも存在します。

# OpenSSL.secure_compare の実装
def self.secure_compare(a, b)
  # 両方の文字列を SHA256 でハッシュ化 (これにより長さが固定になる)
  hashed_a = OpenSSL::Digest.digest('SHA256', a)
  hashed_b = OpenSSL::Digest.digest('SHA256', b)
  # ハッシュ値を時間一定で比較し、かつ、元の文字列も一致するか確認 (ハッシュ衝突の可能性を考慮)
  OpenSSL.fixed_length_secure_compare(hashed_a, hashed_b) && a == b
end

このメソッドは、入力文字列 ab をまず SHA256 でハッシュ化し、そのハッシュ値を fixed_length_secure_compare で比較します。これにより、元の文字列の長さが可変長であっても、比較対象は固定長のハッシュ値になるため、一見安全に見えます。

しかし、最後の a == b という比較が問題です。 ハッシュ値の比較 (fixed_length_secure_compare) は時間一定で行われますが、その後の元の文字列同士の比較 (a == b) は、通常の String#== と同じく、時間一定ではありません。

たしかに、SHA-256 は暗号学的に強力なハッシュ関数であり、現在の技術レベルでは、意図的にハッシュ衝突を引き起こすこと(a != b かつ SHA256(a) == SHA256(b) となる a, b のペアを見つけること)は現実的に不可能でしょう。ましてや、未知の a に対して、それと衝突する b を見つけることはさらに困難です

ただし、現在は SHA-256 が安全でも、将来的に脆弱性が発見されたり、計算能力の向上によって衝突が見つけやすくなる可能性はゼロではありません。

HMAC署名などの固定長の値の比較に、あえて将来的に脆弱になる可能性のある方法を使う意味はないので、よりシンプルで目的に合致した OpenSSL.fixed_length_secure_compare や、それをラップした Rack::Utils.secure_compare / ActiveSupport::SecurityUtils.secure_compare を使用することが強く推奨されます。

まとめ

まとめ:推奨される比較方法

  • HMAC署名やハッシュ値など、固定長の認証情報を比較する場合は、String#== の代わりに以下のいずれかのメソッドを使用してください。
    • Rack::Utils.secure_compare(expected, actual)
    • ActiveSupport::SecurityUtils.secure_compare(expected, actual) (Rails アプリケーションの場合)
    • OpenSSL.fixed_length_secure_compare(expected, actual) (openssl gem が利用可能な場合)
  • これらのメソッドは、文字列のバイト長が異なる場合は即座に false を返しますが、長さが同じであれば、内容が一致するかどうかにかかわらず比較処理にかかる時間がほぼ一定になるように設計されています。
  • 注意: これらのメソッドは固定長の文字列比較を前提としています。可変長の文字列比較には使用しないでください(長さの情報が漏洩する可能性があります)。
  • OpenSSL.secure_compare は、内部で時間一定でない比較 (a == b) を行っているため、使用を避けてください。

認証情報の比較はセキュリティ上非常に重要です。String#== を安易に使用せず、状況に応じて適切な secure_compare 系メソッドを選択し、安全なコーディングを心がけましょう。