一、前 言
二、特殊语法结构识别
三、CVE-2022-33891:Apache Spark UI远程命令注入
四、CVE-2022-36944:LazyList反序列化漏洞
五、总 结
Scala 是一门多范式的编程语言,集成了面向对象编程和函数式编程的多种特性。函数式编程抽象的理论基础也让这门语言变得抽象起来,初学者需要花更多的时间去理解其特有概念以及众多的语法糖。Scala
是一门运行在JVM
平台上的语言,其源码编译结果符合Java
字节码规范,所以可以被反编译为Java
代码。在进行Scala
代码审计的过程中,审计者很少有机会直面其源码,大多数时候都是被反编译为Java
的代码所支配。
Scala
与Java
毕竟是两门语言,在被反编译为Java代码进行审计时不能进行动态调试(这为审计者带来了不小的麻烦),反编译后产生的中间代码、临时变量等辅助结构更是极大得降低了代码的可读性。本文将带领诸位抽丝剥茧逐步梳理出Scala
各语法结构与Java
语法结构对应关系,最后以两个漏洞案例的分析加以对比说明。
除了循环、条件判断、面向对象等基础语法外,Scala还提供了极具特色的语法结构,如:模式匹配、隐式转换、传名调用、函数柯里化、伴生对象、特质、提取器、函数式编程等。本章不会重点着墨于这些语法的介绍,而是向读者展示在将Scala源程序反编译为Java代码后产生的那些不太常规的语法结构以及一些奇怪的变量(MODULE$/$outer/package/$init$)等。
伴生对象
本节将会涉及到特殊变量
MODULE$
的含义。
Scala中没有static关键字,其通过伴生对象来实现static的效果。伴生对象是类自身定义的单个实例,可以被理解为当前类的一个单例对象(并不显式依赖一个类,可独立存在),以下代码展示了一个类的伴生对象:
在上面提供的代码中,被关键字object修饰的对象被称为类Singletons的伴生对象,类Singletons被称为object关键字修饰的对象的伴生类,两者可互相调用其私有属性以及方法。
Scala语言是运行在jvm上的,其最终编译结果符合Java字节码规范,于是便可以将其反编译成为Java代码进行查看,这样会得到与Scala源代码迥然不同的代码结构,并产生一些中间代码。审计者在进行Scala代码审计时大多数时候面对的都是被反编译成为Java代码的Scala程序,所以如何快速高效地识别Scala代码转换后的语言结构就尤为重要,特别是一些特殊的变量。
以下便是上文Scala源代码反编译成为Java代码后的形态:
在反编译后的代码中,注解@ScalaSignature保存了Scala类的签名信息,包括类的类型参数、构造函数参数类型和返回类型等信息,这些信息对于代码审计并不会产生影响,直接无视即可。
在反编译后的代码中,产生了两个特殊的中间变量MODULE$ 以及 .MODULE$,本节将介绍MODULE$变量,.MODULE$将在下节与包对象一同引出。
在伴生对象的私有构造方法中MODULE$被赋值为this,在Java中this表示当前对象实例的引用,即this乃Singletons$单例对象的引用。
伴生对象被称为伴生类的单例对象乃是通过静态代码块的方式实现。
根据jvm类加载的原理(加载->链接->初始化),在类初始化阶段<clinit>()方法执行时静态代码块中的代码被执行,也就是说这部分代码在jvm的一次运行周期中只会被执行一次,即实现了单例对象的生成。
包对象
本节将介绍
.MODULE$
的含义。
包对象允许在一个包中定义公共的方法、常量以及类型别名,以便在该包的所有 Scala 文件中共享和访问这些成员。如果你在使用IDEA进行审计时发现某个方法不能正常跳转,请到当前类的包目录下找到名为package.class的文件并尝试在其中找到该方法定义。
首先在org.example包下定义包对象:
然后在其子包org.example.subPackage中定义另一个包对象
接着在org.example包下定义类PackageObject,该类定义了一个方法packageGreet分别从上述两处调用共享方法greetExample以及greetTest
以org.example下的包对象为例。在定义该包对象时使用了object关键字修饰,可知包对象是伴生对象,在进行代码编译时会自动生成一个与之相关的伴生类,这也就是下面的代码中多出 package 类的原因。
PackageObject.class
观察上文反编译代码,有两个问题需要回答。其一,greetExample这个方法来自哪里,为何通过IDEA不能进行索引跳转;其二,.MODULE$到底是什么变量,为何在代码中没有任何的声明。
初看.MODULE$.greetExample();是一个有些奇怪的用法,语句竟然以.开头,不过如果将import org.example.package.;与之连接得到org.example.package..MODULE$.greetExample();,这样就与下一行中org.example.testPackage.package..MODULE$.greetTest();的调用方式一样, 于是情况似乎变得合理起来。
同时,一个新的情况出现了,看如下代码:
在Scala包下,Predef 是一个伴生对象,但其并不是包对象,为何其也通过.MODULE$变量来引用方法?
其实包导入末尾的.表示导入当前对象中的所有静态成员,而Predef又是伴生对象,同时其Module$变量也是静态成员,加之在导入位置也可能存在静态成员MODULE$ 故使用.MODULE$加以区分。
初始化代码
本节将涉及方法
$init$
的含义
有代码如下:
其反编译为Java代码后,若类主体、变量初始化代码、类初始化代码块中存在较为复杂的逻辑,Scala编译器将自动生成名为$init$的方法。
另一种情况,若父类存在类主体,则类主体中的代码将被组合为$init$方法。
反编译后结果如下:
在ClassNested的无参构造方法中调用了接口(特质)的$init$方法,而$init$方法则封装了特质的类主体逻辑。
传参匿名类
本节将涉及apply方法
以下是一个需要传参的匿名类:
反编译后的代码如下:
在伴生对象的main方法中,首先构造了一个Function1类型的匿名类对象runnable,创建匿名类后调用其apply方法(注入器)进行传参,如此便完成了有参匿名类的实例化,然后通过反射进行方法调用。匿名类、匿名函数的传参、调用大量使用了apply方法,要加以甄别。
类嵌套
本节将涉及变量$outer的含义
有如下代码:
匿名类a中嵌套匿名类b,在嵌套匿名类b中调用外部类a的方法。
将上文代码反编译为Java代码:
在main方法中,嵌套匿名类b若需要调用外部类a的方法或字段,需借助变量$outer,即$outer表示外部类的引用,即便它没有别显式地赋值。
隐式转换
在Java中,如果开发者需要实现类功能的增强,一般采用继承、代理甚至于使用动态插桩技术,使用这些技术都需要显示地新增或者修改代码,从而提高了代码的耦合性。那么有没有一种更简洁且不具备侵入性的解决方案来实现这些要求呢?Scala为开发者提供了一种解决方案。
隐式转换允许开发者在不改变原有代码的情况下,对现有类型进行扩展或者提供额外的功能。隐式转换通常用于增强现有类的功能、为现有类提供类型转换或者为函数提供额外的参数等。
2.6.1 隐式函数
看下面的例子:
在main方法中,首先创建了Human的实例对象,然后尝试调用其bark方法,而事实上Human类中并没有定义bark方法,按照其他编程语言的逻辑此时将发生编译异常,而在Scala中却能够正确编译执行。
这便是Scala隐式转换的魅力。上文代码中,伴生对象ImplicitTransform中额外定义了一个隐式方法H2D,其负责将Human类型转换为Dog类型,这个过程是开发者不可见的,由编译器帮开发者完成。在Human对象实例尝试调用不存在的bark方法时,会首先尝试寻找当前上下文中是否存在隐式转换函数而不是直接报错退出,若存在则判断转换后的结果是否存在bark方法,若存在则调用该bark方法。
将Scala代码反编译成Java后观察发现隐式调用变成了显式调用。
在上面提供的代码中,观察发现main方法首先调用了H2D方法显式得将Human对象转换为Dog对象,之后再调用Dog对象的bark方法,这个过程在Java代码中是显式的。
2.6.2 隐式参数
看下面的代码:
有了前面隐式函数珠玉在前,理解隐式参数也就不再困难。在main方法中尝试调用add(x)方法,因为该方法并不存在,那么编译器将尝试寻找含有隐式参数的方法调用,即add(x: Int)(implicit y: Int),因为y被声明为implicit ,故尝试当前上下文中寻找Int类型的隐式参数,即k。需要注意的是,在同一作用域中,同一类型的隐式参数只能出现一次,否则将产生编译器编译异常。下例的代码是不被允许的:
反编译后的Java代码如下:
2.6.3 隐式类
看下面的代码:
在main方法中使用了一个不太常用的方法4.times,如此偏门的语法是如何实现的呢?
这就是Scala隐式类的魔法。在Scala中没有Java中类似int 这一类基本数据类型,所有数据类型均是包装类型,即4这个整型字面量乃是Int类型的实例对象,那么4.times()就表示在Int的实例对象上调用times方法。在进行方法调用时,首先会搜索Int类是否定义了times方法,若没有则在当前作用域中搜索是否存在Int类型的隐式类型转换,若存在,则在目标类型中搜索times方法进行调用。
将上述代码的字节码反编译为Java代码后得到:
可以看到调用逻辑与前两小节隐式参数与隐式方法如出一辙。
CVE-2022-33891:Apache Spark UI 远程命令注入
Apache Spark UI 曾经被披露存在远程命令注入漏洞,该漏洞源于程序对用户权限模拟用户名参数处理不当。该漏洞较为简单,便直接在代码中通过注释进行解释说明。
HttpSecurityFilter
SecurityManager#checkUIViewPermissions
SecurityManager#isUserInACL
Utils#getCurrentUserGroups
ShellBasedGroupsMappingProvider#getGroups
ShellBasedGroupsMappingProvider#getUnixGroups
Utils#executeAndGetOutput
Utils#executeCommand
CVE-2022-36944:LazyList反序列化漏洞
3.2.1 LazyList存储与求值原理
Scala
的 LazyList
是一种惰性求值的集合类型,它可以在需要时才计算元素值,而不是像 List
一样在创建时就一次性计算所有元素。LazyList
可以处理无限序列和非常大的数据集,而不会导致内存溢出或性能问题。以下代码展示了如何使用LazyList
进行有限数据存储以及计算。
上面代码首先创建LazyList对象,然后将每个元素都乘以2,然后取出前两个元素输出到标准输出设备中。LazyList除了可以处理有限数据外,还可以处理无限数据,下面的代码创建了一个从1开始步长为2的LazyList对象。
执行结果如下:
那么LazyList是如何实现无限数据的存储的呢?我们将从form方法开始进行解释
上面代码中,start参数为起始值,step为步长。方法体中首先调用了sCons方法,再将其调用结果传入到newLL方法中。sCons的第二个参数递归地调用了from方法,且将from的第一个参数设置为前一次计算的start的值+步长。sCons方法创建了一个Cons对象,其主构造方法第一个参数称为head,表示序列的第一个值,第二个参数称为tail,表示剩余值的计算方法,即LazyList存储的不是所有的序列值,而是存储着序列的计算方法。
而在newLL方法中则创建了LazyList对象,LazyList的构造方法接受的是一个无参匿名函数。
上文解释了LazyList对象是如何构建的,以及其存储无限值的方法,下面看看LazyList是如何取值的。
对LazyList取值可通过foreach进行,该函数接受一个单参数匿名函数用以对遍历的结果进行处理,如println。
在foreach中,首先调用isEmpty方法判断当前LazyList对象是否为空,若为空则直接结束,若不为空则计算第一个值head并进行输出,然后对剩余的值tail递归调用foreach方法继续处理,从而实现了无限取值。isEmpty方法通过比较当前state是否为 State.Empty来判断LazyList对象是否为空。
state变量在LazyList初始化时被创建且其被关键字lazy修饰,lazy关键字赋予了其延迟加载的特性,是实现无限数量序列存储的关键环节。
state变量的值来自于lazyState方法的计算结果,该方法通过lazyList主构造方法的第一个参数进行传递,前文创建LazyList调用newLL方法时传入的sCons方法返回对象即为lazyState()方法,即一个Cons(State的子类)对象,当该State对象不为空(Empty)时,便可一直取值。若要实现固定数量取值,可使用take方法。
在take方法中首先调用knownIsEmpty方法判断当前LazyList是否为空,若为空则响应一个Empty对象,不为空则调用takeImpl方法。
在takeImpl方法中,首先判断n的大小,如果小于0则响应一个Empty对象,该条件是takeImpl递归的基例。如果不为0则继续尝试调用isEmpty方法判断根LazyList对象是否被取空,若被取空则响应一个Empty对象并结束递归,若未被取空则创建一个sCons对象,该对象的第二个参数递归调用takeImpl方法,其参数值随着递归深度增加逐步减1直到最终为0时响应一个Empty对象,此时在调用foreach方法进行值输出时进行isEmpty判断将会返回true,从而实现了固定数量取值。
在take方法中knownIsEmpty会首先判断当前stateEvaluated(表示是否已经取过值,若没有取过值则该变量为false)是否为true,若该值为false则表示还没有开始取值,可以放心得继续取值,若该值为true,则还需判断tail是否为Empty,即值是否已经被取空了。
3.2.2 LazyList序列化与反序列化
LazyList并没有实现readObject/writeObject/readExternalObject/writeExternalObject方法,却实现了writeReplace方法,该方法在序列化对象时将替换正在被序列化的LazyList对象,反序列化时若替换的对象中存在readResolve方法将使用该方法还原LazyList对象。
LazyList的writeReplace方法如下:
当已经被求值且仍有值未被取走,将调用SerializationProxy的序列化以及反序列化方法。
在进行序列化时将调用writeObject方法。
首先将会调用输出流对象的defaultWriteObject方法,然后调用LazyList对象these的knownNonEmpty方法判断是否已经被求值且仍有值未被取走若条件成立则将被取出的值进行常规的序列化,然后将剩余值的计算方法(tail)赋值给these变量。当所有的被计算过的值都被取出后则退出循环,然后插入一个序列化终止标志,最后再将these进行序列化。插入序列化终止标志是为了分割两种不同的序列化对象,终止标志前的部分为被计算过的值,而后面的部分为剩余值的计算方法(tail)。
在进行反序列化时将先调用readObject方法,再调用readResolve方法。
readObject方法中,首先调用输入流对象的defaultReadObject方法,然后创建缓冲列表init用以存储被反序列化的计算值,标志initRead用以判断计算值是否被读取完毕,若读取完毕则反序列化剩余值的计算方法并赋值给tail变量,然后调用init的++:方法,对已计算值与计算方法进行连接。
3.2.3 LazyList反序列化漏洞成因
在调用++:方法时漏洞产生了
prependedAll方法在LazyList中被重写
knownIsEmpty方法用于判断计算方法中是否仍有值未被计算
isEmpty方法判断是否有值仍未被计算
前面已经提到过state来自lazyState方法的计算结果,而lazyState来自于实例化LazyList对象时传的一个无参匿名函数。
那么,如果在创建LazyList对象时给其传任意一个无参匿名函数,岂不是可以实现任意无参匿名函数调用。
3.2.4 LazyList反序列化漏洞利用
在知道了漏洞的成因后,如何寻找可利用的匿名函数是个难点。github中流传着该漏洞的POC。对该POC代码进行分析,入口点为:
在创建payloadGenerator对象时使用的DefaultProviders.FILE_OUTPUT是一个Function对象。
generatePayload方法
function0Provider 即前面传入的FILE_OUTPUT,通过调用其注入器方法apply显式的构造一个Function0类型的对象,然后将该对象传入到createLazyList创建一个LazyList对象,最终使用SerializationProxy进行包装(参考LazyList writerePlace方法)最终将该对象序列化。
在调用createLazyList 创建LazyList对象时首先通过反射的方式创建了LazyList对象,然后设置了其三个属性。
在POC中除了提供scala.sys.process.ProcessBuilderImpl$FileOutput$$anonfun$$lessinit$greater$3外,还提供了另外两个可使用的匿名函数。
那么在Scala源码中着三个匿名函数来自哪里。
在创建Scala匿名函数时,若没有显式地为这些函数命名,那么Scala编译器将自动为这些函数分配一个名称,这些名称的格式为$anonfunc$1,其中$anonfunc表示当前是一个函数,$1为编号用以区分不同的匿名函数。了解了这一点我们尝试对scala.sys.process.ProcessBuilderImpl$FileOutput$$anonfun$$lessinit$greater$3进行解析。ProcessBuilderImpl为特质,而FileOutput是其内部类,anonfun标识了匿名函数,$lessinit$greater标识了名称,$3标识了匿名函数的编号。将$lessinit$greater翻译一下就是<init>。这与前文提到的匿名函数通用格式有所区别,Payload使用的匿名函数多了$lessinit$greater标志。
在Scala 源码中,我们找到了ProcessBuilderImpl内部类FileOutput以及另外两个可用内部类的实现。
在这里,注意到这三个类除了定义了主构造函数外便在没有定义其他的内容了,那么Payload中的匿名函数从哪里来。通过观察上述三个函数的声明形式,可以注意到以下特征:
-
均继承了一个父类
-
在父类的主构造函数中调用了子类参数的方法
跟进IStreamBuilder类,还可以发现该类的第一个参数为传名调用:
通过模拟上述形式得到以下代码:
通过IDEA进行编译后查看生成的类发现确实生成了Son$$anonfun$$lessinit$greater$1
通过javap命令查看该类的字节码:
通过分析字节码发现,Son$$anonfun$$lessinit$greater$1即为Parent类的第一个参数s.length()的引用,同理可知在FileOutput 中 scala.sys.process.ProcessBuilderImpl$FileOutput$$anonfun$$lessinit$greater$3 即为new FileOutputStream(file, append)的引用。换句话说,IStreamBuilder类的第一个参数即为该反序列化漏洞需要的无参匿名函数,也就是POC中提供的三个匿名函数。
当然,该漏洞的Payload绝不止PoC中提到的三个匿名函数,有兴趣的读者可自行寻找更多的可被利用的无参匿名函数。
与Java语言的百花齐放截然相反,在中文互联网中,你甚至找不到一篇正经介绍Scala代码审计的文章,但Scala在一些特定领域(大数据处理)以及一些大公司中(Twitter、LinkedIn、Verizon)却承担着举足轻重的作用。
本文尝试探索Scala代码审计的蓝海,向读者传授一些在Scala代码审计中的小Tricks,以便有需要者能够更快地开始Scala代码审计以及漏洞挖掘工作。
2、Apache Spark UI 命令注入漏洞 CVE-2022-33891
3、Apache Spark UI HttpSecurityFilter 源代码
4、基于LazyList的Scala反序列化漏洞透析(CVE-2022-36944)
【版权说明】
本作品著作权归falc0n_leo所有
未经作者同意,不得转载
天工实验室非著名安全研究员、金融圈韭菜、互联网民工、垃圾话爱好者、老实人本人
研究领域:代码审计、Web安全。
原文始发于微信公众号(奇安信天工实验室):Scala代码审计之痛 — Scala与Java的爱恨情仇