サムネがコーヒーの記事は書きかけです。

2段階認証のワンタイムパスワードをクラックする方法

pythonでバックエンドの認証周り、特にOTP認証フロー周りについて書いているときに何の迷いもなくrandomモジュールを使って実装していたため危うくセキュリティインシデントが発生するところだったのでメモを書いておく。

pythonのrandomモジュールは内部的にメルセンヌツイスタを利用しているらしく、これは数学的に内部状態を一意に決めることができるという特性を持つ。

調べると、高次元(623次元)に均等分布するような乱数を生成する擬似乱数生成器のようで、このことから一周分(624個)のアウトプットを連続で取得すると内部状態を決定することができる。

https://ja.wikipedia.org/wiki/%E3%83%A1%E3%83%AB%E3%82%BB%E3%83%B3%E3%83%8C%E3%83%BB%E3%83%84%E3%82%A4%E3%82%B9%E3%82%BF

OTPクラックシミュレーション

上記を利用して、ワンタイムパスワードクラックのシミュレーションを行った。

from __future__ import annotations

import random
from typing import ClassVar, List, Tuple


class MT19937Cracker:
    MASK_32: ClassVar[int] = 0xFFFFFFFF
    N_WORDS: ClassVar[int] = 624 

    @classmethod
    def _invert_right_xor(cls, y: int, shift: int) -> int:
        """x ^ (x >> shift) = y を解く"""
        x = y
        for _ in range(32 // shift + 1):
            x = y ^ (x >> shift)
        return x & cls.MASK_32

    @classmethod
    def _invert_left_xor_mask(cls, y: int, shift: int, mask: int) -> int:
        """x ^ ((x << shift) & mask) = y を解く"""
        x = y
        for _ in range(32 // shift + 1):
            x = y ^ ((x << shift) & mask)
        return x & cls.MASK_32

    @classmethod
    def untemper(cls, y: int) -> int:
        """MT19937 出力 32bit を内部ワードに戻す(逆テンパリング)"""
        y = cls._invert_right_xor(y, 18)
        y = cls._invert_left_xor_mask(y, 15, 0xEFC60000)
        y = cls._invert_left_xor_mask(y, 7, 0x9D2C5680)
        y = cls._invert_right_xor(y, 11)
        return y 

    @classmethod
    def recover_state(cls, outputs: List[int]) -> Tuple:
        """
        MT19937 から得た 32bit 整数 624 個を untemper して
        random.Random().setstate に渡せる状態タプルを返す。
        """
        if len(outputs) < cls.N_WORDS:
            raise ValueError(f"{cls.N_WORDS} 個以上の 32bit 出力が必要です。")

        mt_words = tuple(cls.untemper(o) for o in outputs[:cls.N_WORDS])
        return (3, mt_words + (cls.N_WORDS,), None)

    @classmethod
    def clone_from_outputs(cls, outputs: List[int]) -> random.Random:
        """
        収集した出力列から内部状態を復元し、
        同期した random.Random インスタンスを返す。
        """
        rng = random.Random()
        rng.setstate(cls.recover_state(outputs))
        return rng

    @classmethod
    def demo(cls) -> None:
        # 被害者の乱数生成器(seed は未知とする)
        victim = random.Random(123456789)
        otps: List[int] = [victim.getrandbits(32) for _ in range(cls.N_WORDS + 10)]

        # 攻撃者が盗聴した 624 個の出力
        captured = otps[:cls.N_WORDS]

        # 内部状態を復元してクローンを生成
        attacker = cls.clone_from_outputs(captured)

        # 625 個目以降を予測
        predicted = [attacker.getrandbits(32) for _ in range(10)]
        ground_truth = otps[cls.N_WORDS : cls.N_WORDS + 10]

if __name__ == "__main__":
    MT19937Cracker.demo()

>>>
▷ 予測値  :  [483830225, 3158136471, 4148883372, 2750127010, 3482603909, 123111939, 2893667825, 4068793399, 4276382171, 1401715985]
▷ 真の値  :  [483830225, 3158136471, 4148883372, 2750127010, 3482603909, 123111939, 2893667825, 4068793399, 4276382171, 1401715985]
▷ 一致確認:  True

上記から、OTPの擬似乱数生成器にrandomモジュールを用いているシステムは624回だけOTPを連続で発行させることができれば、2段階認証を突破できることになる。

どうするべきか

暗号論的擬似乱数生成器(SCPRNG)と呼ばれるものを使用する。

例えばsecretsモジュールがある。

https://docs.python.org/3/library/secrets.html#module-secrets

import secrets
secure_str : str = ''.join((secrets.choice(string.ascii_letters) for i in range(length)))

メルセンヌツイスタを用いた生成器では19 968 bit がメモリに保持されて外部から読み放題な状態になるのに対して、secretsでの内部状態はカーネル空間にあるため、実質読むことができない。

また、secretsはシステム起動時に加え適宜再シードを行うため、エントロピーが継続的に注入されることとなる。

なぜ乱数生成機構が公開されていて良いのか

生成機構が公開されているということは、攻撃者がシステムを再現できる状態ではないか、と最初は自分も疑問に思っていたが、暗号の世界では機構が公開されている暗号の方がより安全だという。

なぜなら、ベッドの下のアタッシュケースに大事に独自の暗号化アルゴリズムを保管していた場合、ある日突然容易に逆トレースされてしまうということがありうるからだ。

逆に堂々と全世界にアルゴリズムを公開していれば、それを元に攻撃者たちがクラックを試みる。それでもなお生き残っているアルゴリズムというのが最も強い暗号化アルゴリズムと呼べるのである。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です