Scala反序列化学习
2024-08-09 18:25:11

复现 CVE 的时候遇到了,趁此机会学一下
原 CVE 的 issue
https://github.com/scala/scala/pull/10118
环境链接:
https://github.com/yarocher/lazylist-cve-poc
参考文章:
https://www.freebuf.com/articles/network/375109.html

前置知识

scala 简介

scala 本身就是一门语言,它运行在 JVM 中,并且能够兼容现有的 java 程序。scala 经过编译之后是生成的 java 字节码,这也是它能够运行在 JVM 上的理由,也可以调用 Java 现有的类库。
但是 Scala 也有着自己的语法规则,并且有一些区别于 Java 的特性,对于这些新东西或者特性,接下来的基础笔记会一一写到。

scala 新特性

匹配器 match

一个 scala2 中的例子,整体代码如下(怎么感觉写起来像 python)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
object Main {
def show(result:String):Unit={
println(result)
}
def main(args: Array[String]): Unit = {
val x=11
val y=x match {
case 1 => "one"
case 2 => "two"
case other => s"other: $other" // other是一个变量名,它会接收除了1和2以外的任何值
case _ => s"other: _"
}
show(y)
//other: 11

}
}

这里我们可以看出 match 的主要使用是在某一个变量后,也就是判断该变量为什么情况时,将对应值返回给该变量。
具体用法如下:

1
2
3
4
5
6
7
8
x match {
case 1=>
case 2=>
case other =>//除了1 2情况,的其他情况
case _=>
}

这里other的优先级是要比默认情况的_要高的,我们看运行结果也能看出来

image.png
总之 match 就是一个选择器,返回不同情况下的结果,当然 match 还有更多用法,这里我们只是粗略学习

伴生对象

具体定义:

伴生对象是Scala中一种特殊的单例对象,它与一个同名的类存在于同一个文件中,这个类被称为伴生类

具体实例代码:

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
class Person(val name: String, val age: Int) {
private val secret = "I love Scala"


def sayHello(): Unit = {
println(s"Hello, I am $name, $age years old.")
}
}

// 定义一个Person对象,作为伴生对象
object Person {

var count = 0

def increase(): Unit = {
count += 1
println(s"Person count: $count")
}

def showSec():Unit={
println(apply("test",1).secret)
}

// 定义一个apply方法,用于创建Person类的实例
def apply(name: String, age: Int): Person = {
increase()
new Person(name, age) // 返回新的Person对象
}

// 定义一个unapply方法,用于提取Person类的属性
def unapply(person: Person): Option[(String, Int)] = {
if (person == null) None // 如果person为空,返回None
else Some(person.name, person.age) // 否则返回Some元组
}
}
object Main {

def main(args: Array[String]): Unit = {
// 使用伴生对象的apply方法创建Person类的实例,省略了new关键字
val p1 = Person("Alice", 20)//Person count: 1
val p2 = Person("Bob", 25)//Person count: 2

// 使用伴生对象的字段和方法
println(Person.count) // 输出2
Person.increase() // Person count: 3
Person.showSec()//输出Person count: 4
//I love Scala(伴生对象可以访问伴生类的私有成员)

// 使用伴生类的字段和方法
/*
println(p1.secret)// 无法访问私有成员
*/
p1.sayHello() // 输出Hello, I am Alice, 20 years old.

// 使用模式匹配和提取器,利用伴生对象的unapply方法
val p3=null
p1 match {
case Person(name, age) => println(s"$name is $age years old.") // 输出Alice is 20 years old.
case _ => println("Unknown person.")
}
p3 match {
case Person(name, age) => println(s"$name is $age years old.") // 输出Unknown person.
case _ => println("Unknown person.")
}
}
}

示例代码就 3 个部分,具体类,伴生对象,main 运行逻辑。
这里看到其实伴生对象就是和具体类同名的对象,然后里面有一个 apply 方法和 unapply 方法,我们等下讲,先看其他的具体方法

1
2
3
4
5
6
7
8
9
10
11
def increase(): Unit = {
count += 1
println(s"Person count: $count")
}
//count的初值是1,这里的逻辑就是让count+1,然后讲count输出出来


def showSec():Unit={
println(apply("test",1).secret)
//调用apply方法,并且将调用apply方法的secret值,并将其输出
}

apply 方法

apply 方法为我们提供了一段快速创建实例化对象的手段,也就是我们不需要去写 new person,直接 person("hello"),即可创建对象然后使用,然后将实例化方法的传参传给 apply,并且调用 apply 的逻辑,完成实例化。我们类比 __contruct 构造方法即可
那我们这里的 apply 方法

1
2
3
4
def apply(name: String, age: Int): Person = {
increase()
new Person(name, age) // 返回新的Person对象
}

它返回的就是一个 Person 类的实例
然后记录一下伴生对象的特性:

  • 伴生对象和伴生类可以互相访问对方的私有成员,包括字段和方法。
  • 伴生对象的成员相当于Java中的静态成员,可以直接通过对象名调用,而不需要创建对象实例。
  • 伴生对象可以实现apply方法,用于创建伴生类的实例,这样就可以省略new关键字。
  • 伴生对象可以实现unapply方法,用于实现模式匹配和提取器的功能。
  • 伴生对象可以扩展一个或多个特质(trait),从而实现多重继承和混入(mixin)的效果。

相当于给定义类的一段扩展功能

trait

类似于 java 中的接口,它可以被类或者对象扩展,也可以 mix in混入(scala 中的新特性)
trait 特质,能够被多层继承,也就是说我们一个类或者一个对象能够继承多个 trait,从而获得所有特质中的所有属性和方法
具体定义代码示例:

1
2
3
trait PersonBody {
val height: Int
}

扩展单个属性以及扩展多个属性示例如下:

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
trait PersonBody {
var height: Int
}

trait PersonHobby{
var hobbyGame="Honor of King : World"
def showHobby()
}

//单个扩展继承
class Person3(name : String) extends PersonBody{
override var height: Int = 170
}

//多个扩展继承
class Person2(val name: String,val age: Int) extends PersonBody with PersonHobby{
override var height: Int = 171
var Name: String =name
var Age: Int=age
override def showHobby(): Unit = {
println(s"no hobby,this is test")
}

}
object Main {

def main(args: Array[String]): Unit = {
var person = new Person3("Cloud")
println(person.height)
//170
var person2=new Person2("stoocea",20)
person2.showHobby()
}

}

匿名函数

匿名函数的官方定义:是作为参数传递给高阶函数的代码块。简要讲就是他是一个代码块,能够作为参数写传入函数。作为代码块就一定会执行逻辑,有如下例子:

1
2
val ints = List(1, 2, 3)
val doubledInts = ints.map((i : Int)=>i*2)

我们定义了一段整数 List ints,然后我们还定义了一段 doubleInts,他是 ints 整数 List 经过 map 方法处理之后得到的。观察 map 方法的参数(i : Int)=>i*2,初看有点看不懂,其实它就是匿名函数, =>左边代表我们函数的参数定义,右边代表匿名函数的具体逻辑
所以这里的效果就是将 Ints 整数 List 中的所有变量 i 都乘以 2,然后调用 map 方法创建一个新的列表。当然匿名函数还有许多的形式,这里只是最初始,且最好理解的形式 (我个人认为)

惰性列表 LazyList*

LazyList 是 scala2.13 版本之后引入的新的集合类型。惰性列表是为了惰性求值,而惰性求值的意思是:列表中的元素并不是一开始就会加载和计算好,而是只有在被需要的时候才会被计算。这句话其实包含了两层意思:1.有限元素下的节省内存和时间 2.无限元素下的节省内存和时间,只有用到了才会给你取,并且是无限取。

一些惰性列表的成员:

state 字段 存储LazyList对象的状态,表示惰性序列的结构和计算状态,算是 LazyList 的具体值存储字段
State 特质,以及它的伴生对象 定义LazyList对象的状态的特质,有两个子类:Cons和Empty
tail 方法 返回一个新的LazyList对象,包含除了第一个元素之外的所有元素,惰性求值
head 方法 返回LazyList对象的第一个元素,严格求值

示例代码

1
2
3
4
5
6
object Main{
val ones = LazyList.continually(1)
def main(args: Array[String]): Unit = {
println(ones)
}
}

image.png
会发现 LazyList 里面的内容为空,这个时候我们象征性地将他里面的第一个元素取出来,用到一开始提到的 head 方法

1
2
3
4
5
6
object Main{
val ones = LazyList.continually(1)
def main(args: Array[String]): Unit = {
println(ones.head)
}
}

image.png
从一开始的 LazyList 创建开始,由于我们并没有直接 new 出 LazyList,而是直接调用continually 方法,所以会来到它伴生对象的 continually 方法
image.png
newLL 就是创建一个新的 LazyList,注意此时我们传入的参数 1 被作为参数 elem 传入,流入到 sCons 方法和下一轮的 continually 方法的循环。
先看 sCons 方法的具体内容
image.png
hd 是 1,tl 是通过 continually 创建好的新的一个 LazyList。具体的方法内容创建了一个新的 State trait实例。一开始我们提到 State 是一段特质,并且有它具体的伴生对象,里面共有两个方法,一个是 head 方法,一个是 tail 方法,对于 State 这个 trait 来说,它的 head 方法就是返回当前的元素,tail 方法方法就是返回存有无限个 1 的 LazyList
image.png
再看 newLL 方法,具体内容就是将刚才创建好的 State 特质实例作为 new LazyList实例化构造的参数传入
image.png
所以,整个 Continually 方法的返回值就是这一个装载了 State 实例对象的 LazyList,但是此时里面是空的,这也是为什么我们紧接着将其 println 的话,出来的结果就是LazyList(<not computed>)。因为我们并没有对 state 进行任何的调用或者赋值,那么就看下面的 head 方法的调用
image.png
很简单的一段,直接去调用 state 的 head 参数,我们接着看 state 是怎么定义的
image.png
具体的逻辑是通过调用 lazyState 方法获取到当前的 State,并且通过调用 State 的 head 和 tail 方法获取值。

LazyList 的反序列化

工作流程分析

在研究 LazyList 的时候其实我们发现了它本身是继承 serializable 接口的,所以它能够反序列化,并且有个专门的类来处理反序列化和序列化
image.png
writeObject 内容大致可以概括为:

  • 调用 javaio 原生的序列化流的defaultWriteObject()方法,开一段 Object 的序列化数据流,准备序列化
  • 然后一个 while 循环去遍历 LazyList 已经计算出来的元素,并且将其每一个都进行序列化操作,然后递归到后续的元素,继续重复操作
  • 序列化一个 SerializeEnd 标识符
  • 然后对该 LazyList 剩下的未计算的元素,也就是没有加载的元素也进行一次序列化操作

readObject 的内容大致可以概括为:

  • 调用 javaio 的原生反序列化流的defaultReadObject(),开一段反序列化的数据流,准备反序列化
  • 初始化一段字节数组 init 用来存储已经计算出来的元素
  • while 循环开始反序列化该序列化流种的元素,并且判定是否为特殊的**SerializeEnd **标识符,也就是判断有效计算的元素是否已经反序列化完毕。如果不是,将其存入 init 数组,如果是,说明有效元素已经反序列化完毕了,跳出该循环
  • 反序列化剩下没有计算的元素
  • 使用++方法链接 init 和 tail(噢那真的牛逼,还有这种方法)

漏洞点分析

乍一看好像 LazyList 的 readObject 没有可以利用的地方,我们跟进后续的链接操作,也就是 init 和 tail 链接的逻辑coll = init ++: tail
我们跟进++:方法
image.png
具体的内容是将 prefix,链接操作的后半段 B 作为参数,调用进 prependedAll 方法。而 prependedAll 方法在 LazyList 中是被重构了的,跟进具体定义
image.png
这里首先是一段 if 判断,判断条件是调用 knownIsEmpty 的返回结果,这里接着跟进knownIsEmpty 方法
image.png
具体方法内容是一段 合运算,当这里stateEvaluated为 true 时,后续才会调用 isEmpty 方法,这里继续跟进 isEmpty。方法具体逻辑为 state 字段和 State 的 Empty 实例进行比较,也就是判断 state 是否为空啦
image.png
那么这里就跟我们之前提到过的 state 懒加载机制,当我们调用到 state 的时候会执行 state 的定义逻辑
image.png
这里稳定执行一段 lazyState 方法,这个方法是 LazyList 构造器传入的匿名函数,当我们对其跟进的时候,会发现如下情况:
image.png
他会生成一段伴生对象,并且带有 apply 方法。了解了这点,我们继续跟进,就会发现其本质是一段可控无参匿名函数
image.png
所以我们可以提前将其设置为一段符合条件的匿名函数,就能够进一步利用了。

可利用匿名函数寻找

最终落到了寻找 scala 或者 java 中原生无参匿名函数,且可利用。单论这个条件是很难找到合适的方法的
我们最后多提了一嘴:

他会生成一段伴生对象,并且带有 apply 方法

实际上不是的,在 scala 中,所有的无参匿名函数都会被编译为一段实现了 Function0 接口的实现对象,也就是一个针对于 Function0 的,带有 apply 方法的一个对象(具体这么做的原因我就不清楚了,翻了一下文档也没找到很好的解释)
那么我们又多了一条寻找的线索:寻找实现了 Function0 的所有类或对象,也能够算是找到了一个对应的无参匿名函数。
结合师傅已经在原文中提到关于匿名函数的另外的内容

Scala编译器在编译Scala代码时,会将匿名函数转换成Java字节码,这样就可以在Java虚拟机上运行。为了与Java兼容,Scala编译器会为每个匿名函数生成一个类,并给这个类一个特殊的名字,通常是anonfun加上一些数字和符号。这个类名的作用是唯一地标识这个匿名函数,以便在运行时调用。

所以在 JVM 内识别匿名函数的时候,实际上是在识别这些类,我们的目标也可以改为寻找这些类了

以 POC 中的 scala.sys.process.ProcessBuilderImpl$URLInput$$anonfun$$lessinit$greater$1为例
$URLInput:表示ProcessBuilderImpl的内部类
$$anonfun:表示匿名函数的前缀,表示这是一个自动生成的类。
$$lessinit$greater:是的转义形式,表示这个匿名函数是在构造器中定义的。
$1:是匿名函数的序号,表示这是第一个匿名函数。

具体跟进一下 ProcessBuilderImpl
image.png
如果我们接着跟进 URLInput 的下一个子类,发现找不到了,只有这么多内容
image.png
那这个后续$$anonfun$$lessinit$greater$1是怎么生成的呢?我们一个一个来探究
就比如$anonfun$$lessinit$greater这一段生成,结合ProcessBuilderImpl类中关于 URLInput 的定义部分,我们猜测当一个类继承了一个父类,并且这个被继承的父类的构造方法参数调用了子类的构造参数的方法时,scala 会生成一段$$anonfun$$lessinit$greater$类名的类
来一段与之意思相近的示例代码

1
2
3
4
5
6
7
class a(){
def msg(): String = {
return "i am class a"
}
}
class b (name:String)
class c(url:a) extends b(url.msg())

大致逻辑就是 c 继承自 b,并且 b 的构造方法中调用了 c 构造方法的参数 url
看一下测试结果,好像并不是和我们猜测的那样,只是生成了 3 段 abc 字节码
image.png
具体再到 URLInput 的目标继承类IStreamBuilder看一下,发现他第一个传参的形式其实不是单纯的参数传递,而是**传名参数**,具体也可以算是一种惰性机制,他不会在函数被调用时立即执行作为传参数传入,而是在该函数的代码块执行逻辑时调用到了它,才会进行它的逻辑
image.png
那我们可以修改一下测试代码也给 b 里面设置一段传名参数

1
2
3
4
5
6
7
class a(){
def msg(): String = {
return "i am class a"
}
}
class b (name: =>String)//这里注意冒号和等号之间的空格
class c(url:a) extends b(url.msg())

这次验证了猜想
image.png
看一下该字节码的具体内容javap -verbose -p 21.class这里我把它字节码文件名改成了 21.class,windowsshell 中的$有转义。不影响具体内容:
image.png
发现它对 a 中定义的 msg 方法进行调用,结合传名参数的惰性机制,我们最开始并没有触发 b 中传名参数的逻辑执行,也并没有产生相应的$$anonfun$$lessinit$greater$类名的字节码文件。而当我们执行了传名参数的逻辑之后,才生成的$$anonfun$$lessinit$greater$类名的字节码文件。
也就是说:
scala.sys.process.ProcessBuilderImpl$URLInput$$anonfun$$lessinit$greater$1类的生成,是由url.openStream()的执行而触发的
了解了这个部分,我们就能够知道为什么会找到scala.sys.process.ProcessBuilderImpl$URLInput$$anonfun$$lessinit$greater$1这个类了,接下来进行总结梳理

利用总结

首先,匿名函数本身在经过 scalac 编译器编译之后的字节码,其实是一段类的字节码,类似于$URLInput$$anonfun$$lessinit$greater$1这种,所以我们可以通过寻找这种类,并通过 LazyList 的构造器将其传入。那么当触发 readObject 方法,直到触发该匿名函数时,在 JVM 中实际上就是在调用该类,并且实例化。观察该类的字节码,在该类的 apply 方法中,会调用其子类构造方法中的参数的方法,到达利用的目的,比如 URLInput 链就会调用到 url.openStream()
image.png
其他 scala 反序列化链也是如此