前言
本章我们来分析 YSoSerial BeanShell1 Payload .
BeanShell1 Payload
BeanShell 简介
在分析 BeanShell1 POP Gadgets 之前 , 我们需要先了解什么是 BeanShell , 以及 BeanShell 的基本用法 .
Google 上关于 BeanShell 的文档比较少 , 本文主要参考了以下四个官方文档.
-
BeanShell User's Manual : BeanShell 用户文档 , 包含 BeanShell 的简介与基本用法.
-
BeanShell JavaDoc : BeanShell 类定义文档 , 包含 BeanShell 中绝大部分类定义与说明.
-
BeanShell Commands Documentation : BeanShell 命令集文档 , 定义了 BeanShell 中的命令.
-
CODE>org.beanshell>bsh>2.0b5 : BeanShell 源代码 , 导入 bsh-2.0b5.jar 通过 IDEA 反编译后没有注释 , 在这里可以找到对应代码的注释信息
BeanShell 是一个小型的 , 免费的 , 可嵌入的Java源代码解释器 , 具有使用Java编写的对象脚本语言功能 . BeanShell 能够执行标准的 Java 语句和表达式 , 也可以使用通用的脚本语言约定和语法将 Java 扩展到脚本域.
如何理解 "脚本域"
编程语言分为 "编译型语言" 和 "解释型语言" , 我们常用的编译型语言有 Java , C , C++ , 解释型语言有 Python , Shell , JavaScript 等.
编译型语言:用来定义计算机程序的形式语言 , 是一种将程序员定义的代码 , 编译成计算机所认识的二进制代码的工具 , 所以编程语言需要编译器( Compiler ).
解释型语言 : 为了缩短传统的 源代码 → 预处理器 → 编译器 → 目标代码 → 链接器 → 可执行程序 的过程而创建的计算机编程语言 . 解释型语言需要解释器( Interpreter ) , 而不需要编译器.
解释型语言就是我们常说的脚本语言 , 编译型语言和脚本语言在很多地方均存在区别 , Jonny的ICU 前辈的 脚本语言和编译语言的区别 中将这些区别分成四个方面 :
-
抽象级别不同
脚本语言更抽象 , 其中存在更高级的数据结构 . 举个例子 : Python 中内置了元组 , 列表 , 字典等结构以及这些结构的嵌套和操作函数 . 而编译型语言的数据结构有比较明确的定义 . -
类型定义不同
脚本语言对类型的定义比较松散 , 不需要类型声明 , 而且在运行时自动进行动态类型检查 . 而编译型语言通常是强类型定义或静态定义 , 变量的类型在程序代码中就已经指定了. -
执行方式不同
脚本语言会被解释器解释成指令后立即执行 , 而编译型语言的程序需要被编译成可执行的二进制文件后再执行. -
运行速度不同
脚本语言是解释执行的 , 解释器在运行时会解释每一条语句然后执行 , 这样会比编译执行的语言慢 . 而编译型语言会被编译成机器码 , 然后直接运行 , 因此在运行速度上比较快.
如何将 Java 代码转换到 "脚本域"
这个问题就要回到我们本章的核心知识点 : BeanShell
BeanShell 是一种脚本语言 , 开发人员可以通过 "命令行" 和 "图形化" 两种方式来调用 BeanShell.
- 命令行方式 : 调用
java bsh.Interpreter.Class
接口 - 图形化方式 : 调用
java bsh.Console.Class
接口
BeanShell 可以将标准Java语言以自然的方式桥接到 "脚本域" 中 , 开发人员可以在适当的地方定义宽松类型的变量 . 例子如下 :
上图中以命令行方式运行 BeanShell . 在定义 "foo" 和 "bar" 两个变量时 , 没有指定变量的类型 . 代码被执行时会自动进行动态类型检查 .
BeanShell 还封装了一些命令集 , 开发人员可以通过其中的命令快速实现某些功能 , 完整的命令集可以参考 BeanShell Commands Documentation
- source() : 将文件名读入解释器中 , 并在当前命名空间中执行文件内容
- run() : 在私有命名空间中使用自己的类管理器和解释器器上下文执行命令
- frame() : 在Frame或JFrame中显示GUI组件
- save() : 将可序列化的 Java 对象保存到文件中
- load() : 从文件中加载序列化后的Java字节流 , 返回反序列化后的 Java 对象
- cd(), cat(), dir(), pwd() : 对应 Unix 中的 "cd" , "cat" , "ls" , "pwd" 命令
- exec() : 通过
java.lang.Runtime.getRuntime().exec()
方法执行系统命令 - javap() : 打印 Java 对象的方法与字段 , 类似 javap 命令
- setAccessibility() : 开启对私有组件和受保护组件的访问权限
下面是一个例子:
上图中以图形化方式运行 BeanShell . 代码中使用了 BeanShell 命令集.
如何在 "脚本域" 中执行系统命令
上文简单的介绍了什么是 BeanShell 以及 BeanShell 的基本使用 , 现在我们来看一看如何利用 BeanShell 执行系统命令.
-
如何执行系统命令
我们通常使用
java.lang.Runtime.getRuntime().exec()
这组反射调用来执行系统命令. -
如何执行反射调用代码
在 BeanShell 中 , 可以使用
eval()
命令来执行当前解释器中的 Java 代码.根据官方文档的定义 , 我们可以编写如下测试代码.
首先生成
bsh.Interpreter
实例对象来以命令行的方式调用 BeanShell , 然后定义要执行的 Java 代码作为 Payload , 最后通过 BeanShell 的eval()
命令来在解释器中执行 Java 代码.
成功通过 BeanShell 执行系统命令 ! 但是这还不够 , 下面我们来详细分析一下 eval()
命令的执行路程.
bsh.Interpreter.eval( String statements ) 流程分析
我们知道 Runtime.getRuntime().exec()
方法最终回去调用 ProcessBuilder().start()
方法 , 因此这里直接在 start()
方法处下断点 , 查看完整的函数调用栈 .
bsh.Interpreter 中的三步 eval() 方法调用
通过函数调用栈可以得知 , 在 bsh.Interpreter.Class
接口中一共调用了三个重载的 eval()
方法
-
第一次调用
bsh.Interpreter.eval()
方法先判断是程序是否开启了
DEBUG
, 如果开启则调用debug()
方法 , 输出相关信息然后在指定命名空间中调用
eval()
重载方法 , 需要注意的是 , 这里的命名空间为全局命名空间. -
第二次调用
bsh.Interpreter.eval()
方法第二个
eval()
方法主要用于生成注释 , 注释内容为 BeanShell 即将执行的代码 . 需要注意的是 , 该方法还会对语法规范进行检测 , 如果发现 Java 代码末尾没有添加分号 , 则会自动添加 -
第三次调用
bsh.Interpreter.eval()
方法该方法会先实例化一个 Interpreter 解释器对象 , 一个 CallStack 调用栈对象 , 以及一个变量 eof , 用于判断是否执行到代码末尾( 前文有提到脚本语言代码会被逐句解析执行 )
然后进入 Java 语义分析 , 这部分涉及到底层数据结构, 我并没有深入分析 , 但是根据调试流程可以得知 , 这部分的核心是通过
node.eval()
方法在生成的非交互式本地解释器( localInterpreter )中执行当前 Java 代码 , 并将执行结果返回给 retVal 变量.需要注意的是 , 只有当前 node 节点的类型为
bsh.BSHPrimaryExpression
, 才会进入到命令执行的流程中 , 这点非常重要 , 后面会用到 .
bsh.BSHPrimaryExpression 中的二步 eval() 方法调用
程序随后进入到 BSHPrimaryExpression
类中 , 该类中也调用了两个重载的 eval()
方法.
-
第一次调用
bsh.BSHPrimaryExpression.eval()
方法这步仅是将一个
false
作为参数传入eval()
重载方法中 .这个 false 用于赋值 toLHS 变量 , LHS( Left-hand Side ) 和 RHS( Right-hand Side ) 常用于 JavaScript 中 , 但是根据官方文档的定义 , 此处含义相同
简单的说 : 在脚本语言中 , 变量赋值时解析引擎会在引用域中查找该变量,如果能够找到就会对它赋值 . 而 LHS 和 RHS 就是上述查找过程的两种动作 :
LHS查找 : 查找的目的是对变量进行赋值 , 例如
a = 2
.
RHS查找 : 查找的目的是获取变量的值,例如return b
.由于篇幅原因 , 这个知识点我们不深究 , 有兴趣的师傅可以参考 鹿丶羽 前辈的 JavaScript中的LHS和RHS查询
-
第二次调用
bsh.BSHPrimaryExpression.eval()
方法这里必须要结合源码来看了 , 源码中对该重载方法的解释如下 :
每一组 Children 节点由一个前缀表达式和若干个后缀表达式组成 .
后缀表达式会决定如何解释某些会引起歧义的名称 , 只有当所有的 Children 节点都已经被后缀表达式解析后 , bsh 才会去调用 eval() 方法来执行它们.
上述的 前缀表达式 / 后缀表达式 是指 "波兰式" 与 "逆波兰式" , 详细内容可以参考如下 CSDN链接.
上述的歧义应该是指 Java语法中的歧义 , 举个例子 : 假设不同 Java 程序包中存在名称相同的类 , 如果在某个类中导入( Import )这两个相同名称的类 , 而不指定完全限定类名( Fully Qualified Name ) , 就会引发歧义.
综上所述 , bsh 会通过计算节点个数的方式来判断当前 Children 节点中是否存在后缀表达式 , 如果Children节点的个数超过1个 , 则说明存在后缀表达式 , 即调用后缀表达式来处理 Children 节点.
此处通过
((BSHPrimarySuffix)this.jjtGetChild(i)).doSuffix(obj, toLHS, callstack, interpreter)
这组反射调用链来调用后缀表达式处理 Children 节点.
bsh.BSHPrimarySuffix.doSuffix()
根据官方文档定义 , 该方法会对传入的对象执行后缀表达式操作 , 并返回新的对象.
需要注意的是 : 在执行后缀表达式时 , 传入的对象类型会被转换成 SimpleNode 类型 . 以便程序根据上下文进行解释.
后缀表达式的执行过程我们不深究 , 根据调试结果可以得知 , 返回的新对象为 java.lang.Runtime
类型.
最后 , 该方法会根据 this.operation
的值来对新的 obj 对象执行不同的操作. 通过调试可以确定此处调用的是 doName()
方法 .
bsh.BSHPrimarySuffix.doName()
根据官方文档定义 , 该方法中会以 LHS 的方式来查询可访问的字段 , 数组长度以及可调用的方法 , 然后通过 Java 反射机制来调用获取的方法.
此处传入的参数分别如下所示 :
bsh.Reflect.invokeObjectMethod()
该方法会获取 被调用方法的类
, 被调用方法的类管理器
, 被调用的方法
, 并将这三个值作为参数并调用 bsh.Reflect.invokeMethod()
方法.
下面让我们跟进 invokeMethod() 方法 ,
bsh.Reflect.invokeMethod()
该方法首先会获取被调用方法的参数以及参数类型
然后会通过反射调用 java.lang.Runtime.exec()
方法
根据函数调用栈可知 , bsh 最终会调用 JDK 原生的 java.lang.reflect.Method.invoke()
方法来处理该次方法调用 , 从而实现命令执行.
至此 , 整个命令执行的过程就已经走完了.
BeanShell1 Payload 复现
复现环境为 : JDK1.7_67
+ Tomcat7
+ bsh-2.0b5
-
首先添加参数并生成可用的 BeanShell1 Payload
-
加载 bsh-2.0b5 组件并启动 Tomcat7 , 将本地生成的 Payload 通过 Curl 工具发出
bsh-2.0b5.jar 组件可以直接在 MVNRepository 上下载
curl "http://localhost:8080/webdemotest_war_exploded/demotest" --data-binary @/tmp/payload.ser
Payload 被成功执行 , 漏洞复现成功.
BeanShell1 Payload 分析
构造恶意方法 , 并通过 bsh.Interpreter.eval()
方法解析
getObject()
方法的返回类型为 PriorityQueue 类型 , 定义的恶意方法又是 compare()
方法 , 很明显 BeanShell1 Payload 的利用点是 PriorityQueue.compare()
方法 , 类似 Apache CommonsCollections2 Payload .
回顾 ACC2 Payload , 其利用点位于
PriorityQueue.siftDownUsingComparator()
方法中针对Comparator.compare()
方法的调用
不同的是 , 在 ACC2 Payload 中 , 我们通过 JAVAssist 技术生成恶意字节码 , 并将恶意字节码注入到 TemplatesImpl._bytecode
属性中 ; 服务端在反序列化时会解析 TemplatesImpl 对象 , 因此实现代码执行 .
而在 BeanShell1 中 , 我们无法再使用 JAVAssist , 因此必须另想办法来调用恶意的 compare()
方法 , 我们接着往下看
生成 XThis 实例对象 , 并将 XThis.invocationHandler 属性作为调用处理器
这里用到了一个新类 XThis.Class
, 官方文档中关于该类的解释如下:
This 是 bsh 脚本对象中的一种类型 , 一个 This 对象就是一个 bsh 脚本对象的上下文 , 它拥有一个实现了事件监听器和各种其他接口的命名空间引用 . 并保留了对来自 bsh 外部回调的声明解释器的引用.
而 XThis 是一个动态加载扩展 , 它扩展了 This 类型, 并且添加了对通用接口代理机制的支持 , XThis 允许 bsh 脚本对象实现任意接口 .
回到 YSoSerial 中 , 程序将解释器( i
)与其命名空间( i.getNameSpace()
)传入 XThis 构造方法中 , 生成 XThis 实例对象.
XThis 构造方法会调用其父类构造方法 , 因此这里实际上就是为 This 对象的命名空间( this.namespace
)与声明解释器( this.declaringInterpreter
)赋值.
在实例化后 , 程序会通过 Java 反射机制获取 XThis 对象的 invocationHandler 属性 , 并将其转换成 InvocationHandler 调用处理器类型.
为 Comparator 接口中的方法创建动态代理
上一步返回了调用处理器 , 很容易想到是用于创建动态代理.
动态代理类会拦截所有对 Comparator 接口中方法的调用 , 并将调用请求转发到调用处理器的 invoke()
方法中
创建 PriorityQueue 优先级队列
这一步的目的是为了让服务端在反序列化是能够调用我们刚才自定义的比较器 , 执行 comparator.compare()
方法 , 而这个比较器就是我们刚才创建的动态代理类.
最后 , 程序会返回构造好的优先级队列( PriorityQueue ) . 至此 , BeanShell1 Payload 就已经构造完毕了. 回顾整个 Payload . 我们能够猜到该 Payload 在利用时的大概过程.
-
服务端在反序列化时 , 由于序列化的类为 PriorityQueue 类型 , 因此会调用
PriorityQueue,.readObject()
方法来处理. , -
readObject()
方法中会调用heapify()
方法进行堆排序 , 方法调用过程为 :heapify()
--->siftDown()
--->siftDownUsingComparator
. -
siftDownUsingComparator()
方法会调用自定义的类比较器( Comparator )进行比较 , 而此时的类比较器为我们构造的动态代理类. -
程序在比较时会调用动态代理类的
compare()
方法 , 而动态代理类会把所有对Comparator.compare()
方法的调用转发到调用处理器的invoke()
方法中. -
而调用处理器是我们从 XThis 对象中提取出来的 . 因此 , 我们在分析 BeanShell1 Payload 利用过程时 , 需要重点关注 XThis 实例对象中的操作.
BeanShell1 Payload 利用
下面我们来分析一下 BeanShell1 Payload 在服务端的利用过程.
由于该 Payload 利用时的函数调用栈较长 , 因此我把它分成三段.
-
这一部分与 ACC2 Payload 的调用过程相同 , 上文已经解释过这部分内容了.
-
这一部分需要深入分析 , 它连接了优先级队列比较 和 命令执行 两个部分
-
这一部分与构造 BeanShell1 Payload 时的流程相同 , 上文也已经分析过这部分内容了
因此 , 这里我们重点关注第二个部分 , 来看一看服务端是如何拼接优先级队列比较和命令执行的.
PriorityQueue.siftDownUsingComparator()
程序会调用动态代理类的 compare()
方法 , 该调用请求会被转发到调用处理器的 invoke()
方法中 . 而 XThis 类的 invoke()
方法位于 XThis$Handler 内部类中 , 因此服务端最终会调用 XThis$Handler.compare()
方法
bsh.XThis$Handler.compare()
该方法中会将 "代理类" , "被代理方法" , "被代理方法的参数" 传入 XThis$Handler.invokeImpl()
方法中
bsh.XThis$Handler.invokeImpl()
该方法会首先判断 XThis 对象中是否重写了 equals()
方法和 toString()
方法 , 官方文档中关于这里的解释如下:
为了支持 XThis 动态加载扩展 , Xthis 中的
equals()
方法必须与动态代理对象相同 , 以便 Java 代码的外部调用者能够找到与其本身相等的代理对象.因此 , 如果 XThis 对象中没有明确定义
equals()
方法 , 则覆盖 This 对象中默认实现的equals()
方法以显示代理接口.
同理 , 如果 XThis 对象中未明确定义
toString()
方法 , 则覆盖 This 对象中默认实现的toString()
方法以显示代理接口.
由于我们代理类中均没有明确定义上述两个方法 , 因此程序中 equalsMethod 和 toStringMethod 两个参数值都为 null . 程序最终会调用 Primitive.unwrap()
方法.
在某些 Bsh 命令的实现中会使用原始类型的封装对象 , 而 unwrap()
方法提供解包的操作 , 该方法会解开封装对象 , 获得原始值 , 并将空值映射到 Null.
而我们要关注的是该方法传入的参数 : XThis.this.invokeMethod(methodName, Primitive.wrap(args, ints))
bsh.This.invokeMethod()
由于 XThis 对象中未实现 invokeMethod()
方法 , 因此程序会调用 XThis 的父类 This 对象的 invokeMethod()
方法.
This.invokeMethod()
方法中会添加一些初始化为 null 的参数( 解释器 , 调用堆栈等 ) , 进而调用 This.invokeMethod()
的重载方法
在新的 This.invokeMethod()
方法中会先判断 bsh 中被调用方法( compare()
)的参数是否为空, 以及对前一步定义的解释器 , 调用堆栈等进行实例化操作.
接下来则会获取 bsh 中要执行的方法( compare() ) , 并通过 bshMethod.invoke()
方法来调用它.
bsh.BshMethod.invoke()
该方法会指定 overrideNameSpace 参数值为 Null , 然后调用 bsh.BshMethod.invoke()
的重载方法.
在重载方法中 , 会先判断被调用方法( compare()
)的参数是否为空 , 然后判断该方法是否是 BshObject 中的方法 , 以及该方法是否是同步方法.
而 PriorityQueue.compare()
方法均不满足上述要求 , 因此这里不会进入 if 语句 , 直接调用 bsh.BshMthod.invokeImpl()
方法
bsh.BshMthod.invokeImpl()
该方法会先对传入的参数进行一些判断与赋值操作 , 然后调用 this.methodBody.eval()
方法解析执行.
bsh.BSHBlock.eval()
跟进 bsh.BSHBlock.eval()
方法并查看 BSHBlock 对象 , 发现该对象存在后缀表达式 .
因此 , 后面的内容就和本章开头的demo一样了 , bsh 会调用后缀表达式来处理 Children 节点 , 当所有的 Children 节点都已经被后缀表达式解析后 , bsh 才会去真正解析执行他们.
程序最终会通过 ProcessBuilder 执行系统命令 .
总结
对我来说 , 分析 BeanShell1 Payload 还是比较困难的 , 难点不在原理 , 而在于 BSH 组件中各方法之间的调用 .
由于没有系统的学习 BeanShell , 所以在分析 Payload 时很多观点和表达或许不准确 , 如果您对哪个方面还存在疑问 , 欢迎留言 .