エンコード、ハッシュ、暗号化は別物である
ぐちゃぐちゃに見える文字列は、保護されている文字列ではありません。読めない出力を生む操作は 3 種類あり、これらを混同することが、実際のセキュリティ上の欠陥の温床になっています。
| 元に戻せるか | 鍵が必要か | 目的 | |
|---|---|---|---|
| エンコード | 戻せる。誰にでも | 不要 | 生のバイト列を運べない経路にバイト列を通すため |
| ハッシュ | 戻せない | 不要 | 指紋、完全性の検証、検索キー |
| 暗号化 | 鍵があれば戻せる | 必要 | 秘匿 |
エンコードとは文字集合の変更です。Base64、パーセントエンコーディング、16 進表記、HTML の実体参照は、同じ情報を別の書き方で表すことで、下流のパーサーが詰まらないようにします。秘密は一切関与しないので、守るべきものもありません。シーザー暗号もここに属します。Caesar Cipher ツールは 25 通りのずらし方を一度に総当たりしますが、これは固定の置換にどれほどの防御力があるかを端的に示しています。ハッシュは任意の入力を固定長のダイジェストに写像するもので、逆向きには実行できません。3 つのうち機密性をもたらすのは暗号化だけであり、そこには必ず鍵があります。鍵を指し示せないなら、何も暗号化されていません。
Base64 とその代償
Base64 は 3 バイト (24 ビット) を 6 ビットずつ 4 つに分け、各グループを 64 文字の集合から 1 文字ずつ選んで書き表します。3 バイトあたり 4 文字なので、出力は 3 分の 1 大きくなります。これが最も響くのはファイルを埋め込むときです。File to Base64 ツールはデータ URL を生成するため、300 KB の画像は HTML の中でおよそ 400 KB になり、ページを表示するたびに再ダウンロードされます。
入力が 3 バイトの倍数でない場合、最後のグループは = で埋められます。
"abc" -> YWJj (3 bytes, no padding)
"ab" -> YWI= (2 bytes, one =)
"a" -> YQ== (1 byte, two =)
標準の文字集合は + と / で終わりますが、これは URL では問題になります。+ はフォームデータでは空白としてデコードされますし、/ はパスの区切りだからです。そこで第 2 の文字集合があります。Base64url は + を - に、/ を _ に置き換え、通常はパディングを省きます。URL からコピーしたトークンが、標準の文字集合に設定されたデコーダーで失敗することがあるのは、このためです。Base64 Encoder は両方に対応し、Unicode も正しく扱います。素朴な実装がよく間違えるのがこの点で、テキストは Base64 の処理に入る前に UTF-8 でエンコードされていなければなりません。
これらはいずれもセキュリティ対策ではありません。URL の中で製品が「暗号化された」識別子と呼んでいる値は、実際にはただの整数を Base64 にしただけということが非常によくあります。誰でも数秒で読み、書き換え、再エンコードできます。JSON Web Token のペイロードも同じです。JWT Decoder が鍵なしで読めるのは、解錠すべきものが何もないからです。
パーセントエンコーディングと、どの関数を使うか
パーセントエンコーディングは、1 バイトを % と 16 進 2 桁に置き換えます。文字ではなくバイトに対して働くため、非 ASCII のテキストは先に UTF-8 でエンコードされます。é は %C3%A9 になります。
非予約文字 (A-Z、a-z、0-9、-、.、_、~) はエンコードが必要になることはありません。予約文字 (: / ? # [ ] @ ! $ & ' ( ) * + , ; =) は URL に構造を与える区切り文字であり、エンコードが必要かどうかは、それが区切りとして使われているのか、データとして使われているのかで決まります。JavaScript の 2 つの関数の違いは、まさにここにあります。
encodeURI("https://ex.com/s?q=cats & dogs")
// https://ex.com/s?q=cats%20&%20dogs the & still splits the query
encodeURIComponent("cats & dogs")
// cats%20%26%20dogs safe as a single value
encodeURI は URL 全体を妥当な形にするための関数なので、区切り文字はそのまま残します。encodeURIComponent はパスセグメントやクエリパラメーターに入れる 1 つの値のための関数なので、区切り文字もエンコードします。何かを埋め込むときは後者を使ってください。ただし後者でも !、'、(、)、* はそのまま残ることに注意してください。
次にプラス記号です。application/x-www-form-urlencoded として送られるフォームのボディでは空白が + にエンコードされ、この慣習がクエリ文字列にも流れ込みました。そのため、ほとんどのサーバーはクエリ内の + を空白としてデコードします。リテラルのプラス記号は %2B として送らなければなりません。alex+billing@example.com がタグを失って alex billing@example.com として届くことが多いのは、これが理由です。対照的にパスセグメントでは + はただのプラス記号であり、空白は %20 です。ある値が 1 回の往復では生き残り、次の往復で壊れるとき、URL Encoder を使えばその様子が目に見えます。
エスケープは値の行き先で決まる
「Web 向けにエスケープした形」という単一の形は存在しません。決めるのは文脈です。
| 文脈 | 正しい処理 |
|---|---|
| HTML のテキスト | &、<、> を実体参照にする |
| クォートで囲まれた属性 | & と、囲みに使っているほうのクォートを実体参照にする。属性は必ずクォートで囲む |
<script> の内側 | 実体参照ではなく JavaScript の文字列エスケープ |
href や src に入れる URL | 値をパーセントエンコードし、属性としてエスケープし、その上でスキームを確認する |
| CSS の値 | CSS のエスケープ。url() をユーザー入力から組み立ててはならない |
script の場合が引っかかりどころです。script の内容は実体参照のデコードを受けないため、そこに書いた実体参照はそのままの文字列として残ります。さらに HTML パーサーは、</script という並びが現れた時点でブロックを閉じます。文字列リテラルの中であっても関係ありません。
<script>var s = "</script>";</script> <!-- block ends early -->
<script>var s = "<\/script>";</script> <!-- correct -->
URL についても、エスケープだけでは不十分です。javascript: で始まる値は、どれほど丁寧にエンコードされていてもクリックされれば実行されるので、スキームを許可リストと照合してください。HTML Entities ツールはテキストと属性の場合をカバーし、実体参照を元に戻すこともできます。API が二重にエスケープして &amp; になってしまったときに必要になるのが、この逆変換です。
ハッシュ、どのアルゴリズムを何に使うか
| アルゴリズム | ダイジェスト | 状況 |
|---|---|---|
| MD5 | 128 ビット | 破られている。衝突は容易 |
| SHA-1 | 160 ビット | 破られている。選択接頭辞衝突が実用的かつ安価 |
| SHA-256、SHA-512 | 256、512 ビット | 完全性の検証と署名には問題なし |
| bcrypt、scrypt、Argon2 | 可変 | パスワード専用 |
MD5 と SHA-1 は衝突耐性を失っています。つまり攻撃者は、同じダイジェストを持つ異なる 2 つの入力を作り出せます。ハッシュが文書の代役を務める場面ではどこでも使えません。署名、証明書、コンテンツアドレッシング、攻撃者が提供しうるものの重複排除などです。原像に対しては破られていないので、古い MD5 のチェックサムは偶発的な破損を検出する役に今も立ちますが、どちらも新しい仕事に使うものではありません。SHA-256 を使ってください。Hash Generator はテキストだけでなくファイルについても計算できます。
パスワードは別種の問題であり、高速なハッシュが不適切なのは、まさにそれが高速だからです。市販のハードウェアは毎秒数十億回の SHA-256 演算を実行できるので、SHA-256 のパスワードハッシュの表が流出することは、パスワードの表が流出することと同じです。bcrypt、scrypt、Argon2id は意図的に低速で、調整可能に作られています。ハードウェアの進歩に応じて引き上げられるワークファクター (後ろの 2 つではメモリコストも) を持ち、それぞれ出力の中に一意なランダムソルトを保存します。
ハッシュは署名でもありません。メッセージが鍵の保持者から来たことを証明するには、秘密値とメッセージを連結してハッシュするのではなく、HMAC-SHA-256 を使ってください。前者は長さ拡張攻撃に対して脆弱です。
ハッシュにできないこと
ハッシュは計算では元に戻せません。しかし、ありうる入力の集合が列挙できるほど小さければ、探索によって元に戻せます。4 桁の PIN をすべてハッシュすれば 1 万個のダイジェストが一瞬で手に入りますし、流出したリストにある郵便番号、電話番号、住所についても同じです。識別子をハッシュしても、匿名化にはなりません。
ソルト、つまりレコードごとに保存され入力に混ぜ込まれる一意なランダム値は、単一の対象を推測しにくくするわけではありません。しかし攻撃者にレコードを個別に攻撃することを強い、事前計算した表を無価値にします。エントロピーの低い値に対して本当に効くのは、ペッパー (データベースの外に保管される秘密鍵) のほうです。
正体不明の文字列を見分ける
| 形 | 見分け方 | 例 |
|---|---|---|
| 16 進 | 0-9a-f のみで長さが偶数。32 文字なら MD5、40 文字なら SHA-1、64 文字なら SHA-256 | 5d41402abc4b2a76b9719d911017c592 |
| Base64 | 大文字と小文字が混在し + と / を含む。長さは 4 の倍数で、末尾が = や == になることがある | SGVsbG8sIHdvcmxkIQ== |
| Base64url | 同じだが - と _ を使う。通常はパディングなし | eyJzdWIiOiIxMjM0NSJ9 |
| パーセントエンコード | % に 16 進 2 桁が続く | a%20b%26c |
| JWT | ピリオドで区切られた 3 つの Base64url の塊。eyJ で始まる | eyJhbGciOi... |
| bcrypt | ちょうど 60 文字。$2a$、$2b$、$2y$ のいずれかとコストで始まる | $2b$12$... |
| Argon2 | $argon2id$v=19$m=...,t=...,p=...$salt$hash |
eyJ は覚えておく価値があります。これは {" を Base64 にしたものなので、こう始まる塊はトークンかどうかにかかわらず、エンコードされた JSON オブジェクトです。
デコードに成功したからといって、推測が正しかったことにはなりません。Base64 はどんな入力でもバイト列に変えてしまうからです。結果がもっともらしいテキストになっているか、既知のファイルシグネチャで始まっているかを確認してください。iVBORw0KGgo で始まる Base64 は PNG、/9j/ は JPEG、JVBERi0 は PDF、UEsDB は zip です。バイト列が何にも見えない場合は、出力を Text to Binary ツールに通してコードを直接読むか、その値が Base64 にされる前にパーセントエンコードされていなかったかを確認してください。この二重の包み方はよくあります。