もふもふ

くんかくんか

jsonwebtoken 3.2.2 ではnone attackは通らないよ

これは何

BCACTF 2.0でjsonwebtoken version 3.2.2にある脆弱性を使って解く問題が出題された。 jwtで認証が行われており、RS256をHS256に書き換えて公開鍵を対称暗号鍵にすり替えて認証を突破する問題であった。 のだが、なぜかnoneで通るんじゃねとか思ってしまい敗北したのでそれの反省記録。

L10N Poll

正しい解き方は他のWriteUpにも書かれているので保留。 とにかくいじったJWTがjwt.verifyを突破したい。

router.get("/localisation-file", async ctx => {
    const token = ctx.cookies.get("lion-token");
    /** @type {string} */
    let language;
    console.log(token)
    if (token) {
        const payload = await new Promise((resolve, reject) => {
            try {
                jwt.verify(token, publicKey, (err, result) => err ? reject(err) : resolve(result));
            } catch (e) {
                reject(e);
            }
        });
        console.log(payload)
        language = payload.language;
    } else {
        language = languages[Math.floor(Math.random() * languages.length)].id;
        ctx.cookies.set("lion-token", generateToken(language));
    }
    await send(ctx, language, {root: __dirname});
});

jsonwebtokenのコードがこちら

module.exports.verify = function(jwtString, secretOrPublicKey, options, callback) {
  if ((typeof options === 'function') && !callback) {
    callback = options;
    options = {};
  }

  if (!options) options = {};

  var done;

  if (callback) {
    done = function() {
      var args = Array.prototype.slice.call(arguments, 0);
      return process.nextTick(function() {
        callback.apply(null, args);
      });
    };
  } else {
    done = function(err, data) {
      if (err) throw err;
      return data;
    };
  }

  if (!jwtString){
    return done(new JsonWebTokenError('jwt must be provided'));
  }

  var parts = jwtString.split('.');

  if (parts.length !== 3){
    return done(new JsonWebTokenError('jwt malformed'));
  }

  // ポイント
  if (parts[2].trim() === '' && secretOrPublicKey){
    return done(new JsonWebTokenError('jwt signature is required'));
  }

  var valid;

 // ここが認証本体
  try {
    valid = jws.verify(jwtString, secretOrPublicKey);
  } catch (e) {
    return done(e);
  }

  if (!valid)
    return done(new JsonWebTokenError('invalid signature'));

  var payload;

  try {
   payload = this.decode(jwtString);
  } catch(err) {
    return done(err);
  }

  if (typeof payload.exp !== 'undefined') {
    if (typeof payload.exp !== 'number') {
      return done(new JsonWebTokenError('invalid exp value'));
    }
    if (Math.floor(Date.now() / 1000) >= payload.exp)
      return done(new TokenExpiredError('jwt expired', new Date(payload.exp * 1000)));
  }

  if (options.audience) {
    var audiences = Array.isArray(options.audience)? options.audience : [options.audience];
    var target = Array.isArray(payload.aud) ? payload.aud : [payload.aud];

    var match = target.some(function(aud) { return audiences.indexOf(aud) != -1; });

    if (!match)
      return done(new JsonWebTokenError('jwt audience invalid. expected: ' + payload.aud));
  }

  if (options.issuer) {
    if (payload.iss !== options.issuer)
      return done(new JsonWebTokenError('jwt issuer invalid. expected: ' + payload.iss));
  }

  return done(null, payload);
};

大事なのは次の部分。

  if (parts[2].trim() === '' && secretOrPublicKey){
    return done(new JsonWebTokenError('jwt signature is required'));
  }

実際に認証を行っているjws.verifyのコードがこちら。

function jwsVerify(jwsSig, secretOrKey) {
  jwsSig = toString(jwsSig);
  const signature = signatureFromJWS(jwsSig);
  const securedInput = securedInputFromJWS(jwsSig);
  const algo = jwa(algoFromJWS(jwsSig));
    console.log(algo)
  return algo.verify(securedInput, signature, secretOrKey);
}

jwaで今回走るコードはこちら。

module.exports = function jwa(algorithm) {
  const signerFactories = {
    hs: createHmacSigner,
    rs: createKeySigner,
    es: createECDSASigner,
    none: createNoneSigner,
  }
  const verifierFactories = {
    hs: createHmacVerifier,
    rs: createKeyVerifier,
    es: createECDSAVerifer,
    none: createNoneVerifier,
  }

見ての通りnoneは確かにサポートされておりここを見てnoneアタック行けるやんと思い込んでしまった。 もうちょっと読み進めたらうまく行かないことがわかるので猛省すべし。

algorithmがnoneであるときのVerifierコードはこちら。

function createNoneVerifier() {
  return function verify(thing, signature) {
      console.log(signature)
    return signature === '';
  }
}

signature===''ならOKということである。

ところが上でポイントと記したjsonwebtokenのコードを見てみると、verifyの引数のsecretOrPublicKeyに何か渡されているときsignatureが空なら例外を投げるようになっている。

  if (parts[2].trim() === '' && secretOrPublicKey){
    return done(new JsonWebTokenError('jwt signature is required'));
  }

したがってnoneに書き換えたtokenを投げたとき

  • signature === '' ならjsonwebtoken側のチェックで弾かれる。
  • signature !== '' ならjwaのチェックで弾かれる。

という流れになりnone attackは成立しない。ちゃんとコードは読もう。

また、jsonwebtokenでnoneを使いたいときは引数にnoneを渡すことで動作する。

まとめ

コミットログとかも読むべし。 ちゃんとコードは読もう。

github.com