🔑

タイミング攻撃のシミュレーション

に公開

前提条件

パスワードには aj の文字しか使えないものとする。

パスワードを決める

PASSWORD = ("a".."j").to_a.sample(8).join  # => "faeghcjd"

これを攻撃者は知りたい。

ログイン処理

サーバー管理者が書いたコード。

def login(password)
  if password.length != PASSWORD.length
    return false
  end

  password.each_char.with_index do |e, i|
    sleep(0.01)
    if e != PASSWORD[i]
      return false
    end
  end

  true
end

動作確認すると、

login(PASSWORD)  # => true
login("x")       # => false

となるので問題なさそうだ。

パスワードの長さを特定する

攻撃者は試しに 4〜12 文字の文字列を順番に渡し、処理時間を計測してみた。

(4..12).each do |e|
  t = Time.now
  login("x" * e)
  p "%2d: %f" % [e, Time.now - t]
end
# > " 4: 0.000002"
# > " 5: 0.000000"
# > " 6: 0.000000"
# > " 7: 0.000001"
# > " 8: 0.012518"
# > " 9: 0.000000"
# > "10: 0.000001"
# > "11: 0.000001"
# > "12: 0.000000"

すると一つだけ処理時間が長くなっている。

これによりパスワードの長さが8であると推測できる。

書き直すとこれで、

length = (4..12).max_by do |e|
  t = Time.now
  login("x" * e)
  Time.now - t
end
length  # => 8

8 が返ってくる。

パスワードの最初の文字を特定する

******** の先頭の文字を aj に変えつつログインを試み、最も時間がかかったものを探す。

password = "*" * length  # => "********"
("a".."j").each do |e|
  password[0] = e
  t = Time.now
  login(password)
  p "%s: %f" % [password, Time.now - t]
end
# > "a*******: 0.010337"
# > "b*******: 0.011287"
# > "c*******: 0.012513"
# > "d*******: 0.012528"
# > "e*******: 0.012558"
# > "f*******: 0.024530"
# > "g*******: 0.012537"
# > "h*******: 0.012559"
# > "i*******: 0.012535"
# > "j*******: 0.012544"

これで最初の文字は f だとわかる。

全文字を1つずつ特定する

同様にして8文字すべてを特定する。

password = "*" * length
length.times do |i|
  char = ("a".."j").max_by do |e|
    password[i] = e
    t = Time.now
    login(password)
    Time.now - t
  end
  password[i] = char  # => "f", "a", "e", "g", "h", "c", "j", "b"
end

これでパスワードが特定できた。

password  # => "faeghcjb"

しかしログインすると、

login(password)  # => false

失敗してしまった。

原因は最後の1文字が時間の差で判断できなかったためである。

最後の1文字は総当たり

最後の文字だけは実際にログインの可否で直接判断する。

("a".."j").each do |e|
  password[-1] = e
  if login(password)
    break
  end
end
password         # => "faeghcjd"
login(password)  # => true

サーバー側の改善例

def login(password)
  [*password.chars, nil].each.with_index.inject(true) do |a, (e, i)|
    a & (e == PASSWORD[i])
  end
end

パスワードが誤っていても最後まで比較を行うことで計算量が一定になる。

ActiveSupport の専用メソッド

require "active_support/security_utils"
ActiveSupport::SecurityUtils.secure_compare("foo", "foo")  # => true
ActiveSupport::SecurityUtils.secure_compare("foo", "bar")  # => false

必要なときは自作せずにこちらを使うのが望ましい。

タイミング攻撃の実用性は低い

理論上有効であっても現実では難しい。処理時間の差から秘密情報を推測することは理屈の上では可能だが、実際のネットワーク環境では遅延や揺らぎ、CDNやSSLの影響により微細な時間差がノイズに埋もれてしまう。

たとえローカル環境であっても、パスワードの比較程度のごくわずかな処理時間の差を安定して観測するのは困難であり、攻撃が成立するのは極めて限られた条件下においてのみである。

なのでこのシミュレーションでは sleep を入れてインチキしている。

Discussion