ハッシュ・暗号化・エンコードの違いを実行して確かめる
セキュリティ基礎講座1。Base64 を1行で復元し、「パスワード」の SHA-256 を計算し、1ビット反転で出力の約半分が変わるのを確認。MD5/SHA-1 がパスワードに向かない理由を実測。
シリーズ:セキュリティ基礎講座 1/6(初級)。 「ハッシュ化しておいて」「暗号化してあります」「Base64 で隠してある」が同じ意味のように使われる現場で戸惑っている新人エンジニア向けです。読み終えると、4つの道具を区別して1分以内にそれぞれ自分で動かせるようになり、速いハッシュがパスワードの保存に向かない理由を数字で説明できます。
日本でも「パスワードを平文で保存していた」「簡単に元に戻せる形式で保存していた」ことが漏えい後に判明し、謝罪とパスワード再設定のお願いに追い込まれる事例が繰り返し報道されてきました。個人情報保護法のもとでは、一定の漏えいが起きると個人情報保護委員会への報告や本人への通知が求められます。原因の多くは高度な攻撃ではなく、エンコード・ハッシュ・MAC・暗号化を取り違えた設計です。この講座はその取り違えをなくすところから始めます(法律の細かな要件は専門家に確認してください。ここでは一般的な話にとどめます)。
以下はすべて Apple Silicon Mac(M1 Pro)で、Python 3.9.6、PHP 8.5.6、OpenSSL 4.1.0、macOS 付属の shasum と base64 を使って実行しました。ライブラリは何もインストールしていません。パスは ~/demo/ に置き換えています。
似ているけれど別物の4つ
まず全体像です。この後、1つずつ実際に動かします。
| 道具 | 例 | 鍵は要る? | 元に戻せる? | 何を防ぐか |
|---|---|---|---|---|
| エンコード | Base64、16進、UTF-8 | 不要 | 誰でも戻せる | 何も防がない。形式を変えるだけ |
| ハッシュ | SHA-256、BLAKE2 | 不要 | 戻せない(一方向) | 偶然の破損、出力から入力を求めること |
| MAC | HMAC-SHA256 | 共有の秘密鍵 | 戻せない | 鍵を持たない人による改ざん(なりすまし) |
| 暗号化 | AES-GCM | 必要 | 鍵があれば戻せる | 鍵を持たない人に読まれること |
用語を2つだけ先に説明します。ハッシュ関数は、どんな入力も固定長の「指紋」(SHA-256 なら32バイト)に変える関数で、逆向きには計算できません。MAC(メッセージ認証コード)は秘密鍵も入力に取るハッシュで、鍵を知っている人しか検証に通る指紋を作れません。
Base64 は暗号化ではない:1行で元に戻る
Base64 は3バイトごとに A–Z a–z 0–9 + / の4文字へ置き換える方式です。メール、JSON、URL、HTTP ヘッダーのように「文字しか通らない場所」にバイナリを通すためにあります。新人が設定ファイルに「読めないから安全」と書きがちな文字列を作って、すぐ戻してみます。
import base64
token = base64.b64encode(b"tanaka:Sakura2026!").decode()
print("encoded :", token)
print("decoded :", base64.b64decode(token))
encoded : dGFuYWthOlNha3VyYTIwMjYh
decoded : b'tanaka:Sakura2026!'
Python がなくても、文字列を見た人は誰でもシェルで戻せます。
$ echo 'dGFuYWthOlNha3VyYTIwMjYh' | base64 -d
tanaka:Sakura2026!
この文字列は HTTP の Basic 認証がそのまま送っているものです。受け取ったヘッダーを表示するだけの3行スクリプトを php -S 127.0.0.1:18157 で動かし、curl で呼びました。
$ curl -sv -u 'tanaka:Sakura2026!' http://127.0.0.1:18157/ 2>&1 | grep -E '^> Auth|^decoded'
> Authorization: Basic dGFuYWthOlNha3VyYTIwMjYh
decoded: tanaka:Sakura2026!
つまり平文の HTTP で Basic 認証を使うと、パスワードは読める形で流れます。守っているのは TLS だけです(HTTPS・TLS・証明書)。覚えるルールは1つ、鍵がなければ秘密もない。
5分で最初のハッシュ:SHA-256
ハッシュが扱うのは「文字」ではなくバイト列です。日本語はエンコーディングによってバイト列が変わるので、まず UTF-8 にします(バイトと文字の違いがあいまいなら文字コード・バイト・UTF-8・コードポイントを先にどうぞ)。
import hashlib
for s in ["パスワード", "パスワード!"]:
b = s.encode("utf-8")
print(f"{s!r:14} {len(b):2d} bytes {hashlib.sha256(b).hexdigest()}")
'パスワード' 15 bytes d8b076148c939d9d2d6eb60458969c486794a4c0fcf0632be58fa5bf6d15aafa
'パスワード!' 18 bytes 1319e32489b0249c01145c96ed50e99d10231e6b0f7a44b7c89c367ab11aef54
カタカナ5文字で15バイト、全角の「!」で3バイト増えています。SHA-256 は標準なので、別の道具でも同じ値になります。
$ printf 'パスワード' | shasum -a 256
d8b076148c939d9d2d6eb60458969c486794a4c0fcf0632be58fa5bf6d15aafa -
$ printf 'Hello, OWASP' | openssl dgst -sha256
SHA2-256(stdin)= 7c15623eb2e7f0d3f153268d5a37f0f3c1fae53f255e421c3e34a743e7ea2d19
$ php -r 'echo hash("sha256", "Hello, OWASP"), "\n";'
7c15623eb2e7f0d3f153268d5a37f0f3c1fae53f255e421c3e34a743e7ea2d19
ここで最初の落とし穴です。printf の代わりに echo を使うと、値がまったく変わります。
$ echo 'パスワード' | shasum -a 256
1d6beaa9e5c6bb620bfc5975d6c8592949f9058022406a8e9ad2845821926042 -
echo は末尾に改行を付けるので、16バイトをハッシュしたことになります。日本語ではもう1つ、Unicode 正規化の罠があります。見た目は同じ「パスワード」でも、濁点を合成した NFC 形式は15バイト、「ハ+゜」のように分解した NFD 形式は21バイトで、ハッシュは d8b07614… と 47eb2a5e… に分かれます。Mac のファイル名は NFD で来ることがあるので、「同じ文字列なのにハッシュが合わない」ときはまずバイト列を疑ってください。
アバランシェ効果:1ビットだけ変える
良いハッシュにはアバランシェ効果(雪崩効果)があります。入力を1ビット変えると、出力のおよそ半分のビットが規則性なく変わる性質です。H(0x48)を I(0x49)に変えると、ちょうど1ビットの違いです。
b'Hello, OWASP' 7c15623eb2e7f0d3f153268d5a37f0f3c1fae53f255e421c3e34a743e7ea2d19
b'Iello, OWASP' ab4d7b3d2213c9bc8a3286cb80924bcfc49d4c3a6eff5441f3a742727fae1c66
input bits changed : 1
output bits changed: 124 of 256
1回だけでは偶然かもしれないので、シードを固定したランダムな32バイト入力で10,000回繰り返しました。
import hashlib, random, statistics
rng = random.Random(2026)
bits = lambda b: int.from_bytes(b, "big")
counts = []
for _ in range(10_000):
m = bytearray(rng.getrandbits(8) for _ in range(32))
a = hashlib.sha256(m).digest()
i = rng.randrange(256)
m[i // 8] ^= 1 << (i % 8) # ランダムな1ビットを反転
counts.append(bin(bits(a) ^ bits(hashlib.sha256(m).digest())).count("1"))
print(f"N={len(counts)} mean={statistics.mean(counts):.2f} "
f"stdev={statistics.pstdev(counts):.2f} min={min(counts)} max={max(counts)}")
N=10000 mean=127.96 stdev=8.00 min=92 max=160
公平なコインを256枚投げたときと同じ形(平均128、標準偏差8)です。だからハッシュを狙った値へ少しずつ「寄せる」ことはできず、2つのハッシュを見比べて入力が似ていたかどうかも分かりません。
ハッシュ・MAC・暗号化を並べて動かす
ただのハッシュには秘密がありません。サーバーが user_id=42&role=member とその SHA-256 をクライアントに渡しても、クライアントは role を書き換えて SHA-256 を計算し直せます。MAC ならタグの計算にサーバーの鍵が要るので、これを防げます。
import hmac, hashlib
key = b"server-side-secret-key-32-bytes!"
msg = b"user_id=42&role=member"
tag = hmac.new(key, msg, hashlib.sha256).hexdigest()
forged = b"user_id=42&role=admin"
print("tag :", tag)
print("forged message accepted?",
hmac.compare_digest(tag, hmac.new(key, forged, hashlib.sha256).hexdigest()))
tag : 099ccdda84f0a9886626a2edb75c8355bef871583e16a20d72a2855d5dbbe9f6
forged message accepted? False
OpenSSL でも同じタグになります。2つの実装を突き合わせるときに便利です。
$ printf 'user_id=42&role=member' | openssl dgst -sha256 -hmac 'server-side-secret-key-32-bytes!'
SHA2-256(stdin)= 099ccdda84f0a9886626a2edb75c8355bef871583e16a20d72a2855d5dbbe9f6
ただし MAC は何も隠しません。role=member はそのまま読めます。隠すには暗号化が必要で、鍵があれば戻せ、鍵が違えば失敗します。
$ printf 'card ending 4242' > plain.txt
$ openssl enc -aes-256-cbc -pbkdf2 -salt -in plain.txt -out c.bin -pass pass:correct-horse
$ openssl enc -d -aes-256-cbc -pbkdf2 -in c.bin -pass pass:correct-horse
card ending 4242
$ openssl enc -d -aes-256-cbc -pbkdf2 -in c.bin -pass pass:wrong
bad decrypt
80E182F501000000:error:1C800064:Provider routines:ossl_cipher_unpadblock:bad decrypt:...
$ echo $?
1
c.bin の先頭8バイトは文字どおり Salted__ で、その後にランダムなソルトが続きます。-pbkdf2 を付け忘れると、OpenSSL 4.1 でも *** WARNING : deprecated key derivation used. と出ます。この落とし穴と、CBC ではなく AES-GCM を使うべき理由は講座5で扱います。
よくある間違い:何が起きて、どう直すか
1. 「アプリの中で API キーを Base64 にしてあるから見えない」
起きること:アプリを展開した人が base64 -d を打てば数秒でキーが手に入ります。直し方:クライアントアプリに同梱した秘密は秘密ではありません。キーはサーバーに置き、アプリには短命のトークンを渡します(講座3)。
2. 自作署名 sha256(秘密 + メッセージ)
起きること:SHA-256 の構造上、sha256(秘密 + メッセージ) の値だけから 秘密 + メッセージ + パディング + 追加データ の正しいハッシュを計算できてしまいます(長さ拡張攻撃。ここでは説明のみで実行していません)。直し方:Python の hmac.new(key, msg, sha256) か PHP の hash_hmac() を使います。
3. タグを == で比較する
起きること:PHP では "0e462097431906509019562988736854" == "0e830400451993494058024219903391" が true になります。どちらも数値の0に見えるからです(講座2で実行します)。どの言語でも == は最初に違うバイトで止まるので、時間差が漏れます。直し方:Python は hmac.compare_digest()、PHP は hash_equals()(講座6)。
4. ハッシュを「復号」する 起きること:「MD5 を復号できる」というサイトは、既知の文字列のハッシュを集めた巨大な表を引いているだけです。直し方:推測しやすい入力のハッシュは推測できる、と理解することです。パスワードにソルトと遅いハッシュが要る理由はまさにこれです。
5. 配布ファイルの確認に MD5 を使う 起きること:MD5 と SHA-1 には実用的な衝突攻撃があり、同じハッシュを持つ別々のファイルを作れます。攻撃者がファイルを選べる場面では、署名やダウンロード確認が成り立ちません。直し方:完全性の確認には SHA-256 か BLAKE2、公開しているハッシュ自体を書き換えられる恐れがあるなら MAC かデジタル署名を使います。
実測:MD5・SHA-1・SHA-256 がパスワードに向かない理由
「MD5 は破られているからパスワードに使ってはいけない」とよく言われます。でも本当の理由は衝突ではなく速さです。パスワード用のハッシュは1回の推測を高くつかせる必要があるのに、これらは安く計算できるように設計されています。OpenSSL 自身のベンチマークで、CPU 1コアを測りました(入力16バイト、各2秒、3回)。
$ openssl speed -seconds 2 -bytes 16 -evp sha256
Doing sha256 ops for 2s on 16 size blocks: 15614250 sha256 ops in 1.98s
SHA-256 は 1コアで毎秒約789万回、MD5 は毎秒約575万回でした。Apple Silicon には SHA-256 専用命令があるので、この環境では SHA-256 は MD5 より遅くすらありません。MD5 を SHA-256 に替えても、パスワードの守りはまったく強くならないということです。
時間に直してみます。英小文字8文字のパスワードは 26⁸ ≈ 2.09 × 10¹¹ 通りです。2.09 × 10¹¹ ÷ 7.89 × 10⁶ ≈ 26,500秒、ノートPC 1コアで約7.4時間。よく使われるパスワード100万件のリストなら約0.13秒です。攻撃者は多数のコアや GPU を使うので、実際はさらにずっと速くなります(ここでは測っていません)。
Python から呼ぶと遅くなります(8バイト入力で毎秒325万回、N = 200,000、5回の中央値)。インタープリターのループのほうがハッシュより重いからです。攻撃者の速さを見積もるときは、自分のコードではなく最速の実装を基準にしてください。
用途別の早見表
| やりたいこと | 使うもの | 使わないもの |
|---|---|---|
| バイト列を JSON・URL・メールに載せる | Base64 / Base64url | 「隠せた」と思うこと |
| ダウンロードの確認、偶然の破損の検出 | SHA-256、BLAKE2 | MD5、SHA-1 |
| Cookie や Webhook が自分から来たと証明する | 秘密鍵付きの HMAC-SHA256 | sha256(秘密 + msg) |
| データを秘密にして後で取り出す | AES-GCM か libsodium(講座5) | Base64、XOR、ECB モード |
| パスワードを保存する | bcrypt、Argon2id、scrypt、PBKDF2(講座2) | MD5、SHA-1、SHA-256、暗号化 |
| トークンや鍵を作る | secrets / random_bytes(講座3) |
random、時刻、ユーザーIDのハッシュ |
「パスワードを暗号化して保存」も表の右側に入っている点に注意してください。暗号化は鍵があれば戻せるので、鍵ごと漏れれば平文保存と同じです。照合だけできればよいパスワードは、戻せない形(講座2のパスワードハッシュ)で持ちます。
正直な限界とバージョンの注記
- Python 3.9.6(この Mac では
hashlibが LibreSSL 2.8.3 にリンク)、PHP 8.5.6、OpenSSL 4.1.0 で実行しました。ハッシュ値は標準なのでどの環境でも一致しますが、速度はこの M1 Pro の1コア固有の数字です。 openssl speedはハードウェア SHA 命令を使う C の速度です。CPU 1コアでの攻撃者の目安であり、GPU の数字ではありません。- アバランシェの統計はシード 2026、N = 10,000 の1回分です。シードを変えると件数は少し変わりますが、形は同じです。
- 長さ拡張攻撃と MD5 の衝突は説明のみで、実行していません。SHA-256 は衝突には強いものの長さ拡張の影響を受け、HMAC と SHA-3 は受けません。
- OWASP Top 10 の番号は版によって変わります。ここでは2021年版の分類名(Cryptographic Failures)だけを挙げました。
練習問題
1. 実行せずに答えてください。echo -n 'パスワード' | shasum -a 256 の結果は、printf 版と echo 版のどちらと同じになりますか。理由も。
2. 同僚が「ログイン状態を覚えるため」に base64(user_id + ":" + 有効期限) を Cookie に入れています。問題点と、早見表のどの道具で直すかを答えてください。
3. 毎秒789万回のとき、ソルトなし SHA-256 で保存した6桁の暗証番号(000000〜999999)を1コアで全部試すと何秒かかりますか。
解答
1. printf 版と同じ d8b07614…aafa です。-n が改行を付けないからです。(zsh 組み込みの echo は -n を受け付けますが、sh の種類によっては受け付けないので、printf を使うほうが確実です。)
2. Base64 は鍵なしで戻せるので、利用者が誰でもデコードして user_id を書き換え、再エンコードできます。サーバー側の鍵で HMAC-SHA256 のタグを付け、定数時間比較で検証します(またはランダムなセッションIDをサーバー側で管理します)。
3. 10⁶ ÷ 7.89 × 10⁶ ≈ 0.13秒です。オフラインの攻撃者に対しては、どんなハッシュを使っても6桁の暗証番号は守れません。次の講座で、試行回数の制限とパスワードの長さの話も扱うのはそのためです。
次へ
よくある質問
Base64 は暗号化の一種ですか?
いいえ。Base64 はバイト列を印字可能な64種類の文字に書き換えるだけで、鍵がありません。base64 -d か Python 1行で dGFuYWthOlNha3VyYTIwMjYh は tanaka:Sakura2026! に戻ります。HTTP の Basic 認証ヘッダーも Base64 なので、HTTPS 以外で送ってはいけません。
ハッシュ、MAC、暗号化は何が違いますか?
SHA-256 などのハッシュは鍵のない一方向の指紋で、誰でも計算できます。HMAC-SHA256 などの MAC は秘密鍵を混ぜるので、鍵を持つ人しか正しいタグを作れず、改ざんされたメッセージは拒否されます。暗号化は双方向で、鍵があれば元に戻せます。中身を隠せるのは暗号化だけ、誰が書いたかを証明できるのは MAC だけです。
パスワードを MD5、SHA-1、SHA-256 で保存してはいけないのはなぜですか?
速すぎるからです。openssl speed の実測で、Apple M1 Pro の1コアは SHA-256 を毎秒約789万回、MD5 を毎秒約575万回計算しました。英小文字8文字のパスワードは全部試しても約7.4時間です。パスワードには bcrypt や Argon2id のような、ソルト付きでわざと遅いハッシュを使います。