EspressoCoffee
今天看了Unknown的博客,他说他学java的时候都是先自己独立思考,实在思考不出来了才去看一点提示,然后继续回来自己思考,虽然这样会花很多时间,但是受益匪浅。我认为这样的学习方式值得借鉴,先从这道题试试这样的学习方法。
用了题目给的jdk和jar包
这是jdk的名字(初现端倪)
运行报错
查了原因,从官网上下载的jdk都是用的hotspot虚拟机,而从这个jdk的名字可以看出使用的是graalvm虚拟机,graalvm虚拟机是支持多种语言的,在此虚拟机中不同的语言有不同的虚拟机实现,例如java字节码就交由graalvm虚拟机中的expresso虚拟机处理(expresso是由java编写的),报错中的truffle是一种语言执行框架,是graavlm的一部分,通俗来说它的作用就是将不同的语言翻译成graalvm能看懂的。从而让graalvm能执行
问了下gpt,这些报错没什么大问题,就是起到提醒你一下的作用,暂且不管它
神奇的地方
注意右边导入了org.graalvm.continuations这个包,可是在左边可以看到根本没有这个包(左边这个包是jdk,但题目给的jar中也没有依赖)。只有
可以看到pom文件中依赖了这个包,但是我在本地的仓库中又没有找到这个包,程序运行时又不会报找不到类的错。所以这个类哪来的?
努力半个小时无果,在wp中看到了这样一句话。
gpt的解释
原来还有这样的导包方式,学到了,以后不知道导入的这个包是哪来的也不用迷茫,只要能运行就好
因为没有任何的外部依赖,且又是jdk高版本,所以之前学的链子都用不了。
源码如下,我也没有发现任何特殊的地方
没有办法了,独立思考进行不下去了,看看题目给的hint
用的是英文,很洋气
去看了continuation的官方文档,全是英文看起来很费劲,看了挺长时间发现还是半懂不的,于是去问了gpt
Continuation
Continuation本质是一种抽象,表示程序在某个时刻尚未完成的后续执行。使用Continuation的程序可以在某个时刻将程序暂停,并将后续的执行过程封装成一种数据结构。以后使用resume()时就可以将程序从暂停时(即suspend)的状态开始执行。
可以将Continuation想象为游戏的存档功能,Suspend就是将你当前的游戏进度存档,resume就是你之后再次点击存档,即从之前存档的状态开始运行。这些是大致功能的介绍。接下来讲讲实现的具体细节。
实现细节
调用suspend后,Graalvm会将当前的调用栈展开,并将这些栈帧(
每调用一个方法就会为这个方法在栈中开辟一处内存,这个内存就是栈帧,其中包含了函数调用的各种状态信息
)
转化为普通的java对象保存到堆中。这样未来要执行的代码就以数据的形式存储在了堆中,调用resume时就将这些对象重新装载到新的调用栈中,直接无缝从suspend的状态恢复运行。
Continuation有一个特点是捕获的Continuation对象是可序列化的,这意味着你可以将当前程序的执行状态写入文件中或者进行网络传输,甚至可以在不同的jvm实例中重新执行该程序
Continuation API使用方式
按照gpt所说,此api的使用方式可以分为两种层次,分别是高层,底层
我感觉源代码用的是低层,高层就不做过多了解了(错误的想法,后面看来必须要用高层,再后来看还是用的底层)
高层
低层
在低层次上,需要自己创建Continuation对象。使用时,先通过传入一个实现了特定接口(例如ContinuationEntrypoint)的lamba或者对象创建Continuation。这个入口点会在运行时获取一个SuspendCapability对象,在你希望其暂停的位置调用suspend即可。之后调用resume(),程序从暂停的地方恢复执行
hint分析
第一条
Focus on the fields of org.graalvm.continuations.ContinuationImpl.FrameRecord
因为在题目所给的jdk中没有找到,所以我从maven中下了跟pom版本一致的jar包
一个FrameRecord类就代表了一个栈帧,pointers储存的是操作数栈的信息,primitives储存的是局部变量的信息,bci(字节码索引)储存的是当前method的一个调用invoke指令
操作数栈是什么?
操作数栈是栈帧中的一个区域,简单来说就是用来保存运算过程中临时产生的值
这是FrameRecord的前情提要
这其中部分内容翻译过来是这样的
在恢复执行时(即从续延恢复),系统会确保记录中每个方法所属的类都已经初始化(如果还未初始化的话,这个初始化会在恢复之前完成)。
需要注意的是,虚拟机在对续延进行反物化(将保存的状态重新加载到堆栈中)时,会验证这个记录是否有效。对于一个记录被认为是有效的,必须满足以下要求:
- bci(字节码索引)必须指向当前方法中的一个调用(invoke)指令。(解释:每个类的每个方法都对应着一个字节码文件,而字节码文件中,每一条字节码语句的前方都有着一个数字,此数字就是字节码索引,一条字节码语句语句对应一个字节码索引,下面有字节码的图。当前方法中的一个调用指令不是指调用本方法的指令,而是本方法中调用别的方法的指令)
- 如果这个帧记录是整个链表中的最后一帧,那么它对应的方法必须是
Continuation.suspend()。- 否则,紧接着的下一帧中的方法,其名称和签名必须与当前帧中 bci 指向的那个 invoke 指令所引用的
CONSTANT_Methodref_info常量保持一致。同时,还会记录一个关于返回类型的加载约束。最后,保存到 pointers 和 primitives 中的堆栈及局部变量信息,必须与该 bci 位置所要求的验证类型保持一致。
感觉第三点可能会是一个突破口
第二条
Abuse org.graalvm.continuations.ContinuationImpl#stackFrameHead => Hijack the Control Flow => “ROP”
利用stackFrameHead劫持控制流实现ROP攻击,搜了一下,发现ROP是pwn的一种攻击手法
ROP
在程序运行过程中,会将下一条要执行的指令地址压到栈上,这样程序就知道执行完当前命令后要去执行哪条指令,我们需要利用将压入的下一条指令地址换成我们所需要的指令地址(这些指令必须是程序本身已有的指令,不能无中生有)。这是非常粗浅的解释,因为具体的我也没看懂。
FrameRecord#bci是触发当前method的invoke指令的地址,很明显bci就是利用点
stackFrameHead在Continuation初始化时,被赋予的值是最顶层的那个FrameRecord,即程序调用的第一个函数。
运行jar包给的Web
审计源码可知要在这里填入序列化数据,但因为处理程序是直接将输入的数据进行deserialize,传入base64编码的字符串要报错,所以只能在程序中将序列化后的字节数组直接传给url
我的源代码
但因为服务端没有我这个test#Chonger类,所以会报错ClassNotFound
所以我要找一个jar包中的原生类来进行实例化Continuation并且suspend
Generator构造方法调用了,这是唯一一处调用点
看了一下源代码,Continuation.creat()返回的其实是一个ContinuationImpl,而ContinuationImpl有着自己的writeObject,readObject方法,会将ContinuationImpl的stackFrameHead字段写入与写出,根据hint
Abuse org.graalvm.continuations.ContinuationImpl#stackFrameHead => Hijack the Control Flow => “ROP”
我觉得应该是要修改stackFrameHead来实现rce。
那我如何获取到stackFrameHead这个字段呢?
Generator()将continuation这个字段赋值为一个ContinuationImpl实例,而stackFrameHead是ContinuationImpl的字段,所以首先要获得continuation字段(因为ContinuationImpl不是public,访问不了)
获得continuation字段后发现因为此字段的编译类型为Continuation,而Continuation没有stackFrameHead
成功获取stackFrameHead,但下一步就是如何利用stackFrameHead实现rop了。
运行了之后发现不对,Generator是抽象方法,并且我没有找到实现它的子类,所以上述操作获取不了stackFrameHead,Continuation也无法挂起
我试着自己编写了一个继承Generator的类Chonger,尝试反序列化这个子类的continuation字段,我以为这样就跟Chonger这个类无关了,没想到反序列化的时候还是报错ClassNotFound test#Chonger.我又不能自己写Generator的子类又是抽象类无法实例化,也没有原生的继承子类。不知道该咋做了,看看wp
我的这个问题有点难度,涉及到了删除栈帧啥的。我的思路有一点错了,不用Generator,还是用continuation API低层的用法(这点我觉得确实有点难想到)
下载了jclasslib插件
源代码
字节码(终于知道字节码长啥样了)
这是我的POC
toDebugString()返回的就是stackFrameHead那个单向链表的信息,如下图
可以看到在Job.start方法的那个栈帧处,bci为9,Job.start字节码在上上上图处,可以看到此字节码索引为调用SuspendCapability.suspend方法
bci记录的是已经调用过的字节码语句,因为毕竟执行到ContinuationImpl.suspend的时候Job.start还是在这一条字节码语句中,这是一条由start引申出来的调用栈
调试
为了不占用篇幅每步的图片我就不放出来了。反序列化的ContinuationImpl$stackFrameHead就是上图展示出来的。对反序列化调用的ContinuationImpl.resume()时,可以看到首先resume()中调用了ensureDematerialized(),将stackFrameHead中的栈帧(其实是java对象)反物化到栈中(ContinuationImpl被挂起后stackFrameHead中的栈帧全部被转化为了java对象保存到堆中,这就叫物化,反物化就是反过来)。之后就是执行挂起状态时未完成的部分,顺序是跟stackFrameHead反过来,先是ContinuationImpl.suspend(),最后是Continuation.run()
注意那个注释说会检查最后一个方法是不是suspend,那我们就不改suspend,改suspend那一帧的next字段
第四条hint是sun.print.UnixPrintJob
我全局查找发现并没有找到UnixPrintJob这个类或者是包,看了wp
于是再去查找PrinterSpooler这个类
发现这是PSPrintJob的内部类,那UnixPrintJob是什么东西?(linux和mac是UnixPrintJob,win是PSPrintJob)
发现PrinterSpooler::run()调用了Runtime::exec
所以尝试修改suspend下一帧的bci为91,suspend那一帧的next为PrinterSpooler::run()这一帧
问题来了,我该如何构造恶意帧pointers,primitives我该填啥
想了想,可以利用现成的FrameRecord,因为上面的图中可以看出挂起的FrameRecord有5个,而我只需要最后一个,剩下四个都可以改成我需要的类。
观察了一下,pointers,primitives的长度是一样的,都是操作数栈最大深度+局部变量最大槽数
搜了一下,pointers的第一个槽位是调用当前方法的实例
如果字节码中只有aload_0,那么pointers中只有调用当前方法的实例,astore_数字,将局部变量表[数字]的值设置为操作数栈顶的值
astore是将操作数栈顶的值存储到局部变量表中,aload是将局部变量表的值加载到操作数栈顶上
审计了一下PrinterSpooler::run的字节码,一共13个槽位,没有基本数据类型变量,所以primitives全部为0,pointers有三个,槽位0是当前对象
可知槽位1为getAbsolutePath地返回值,槽位2是PrintExecCmd的返回值
克服诸多报错艰难写出exp
报新的错
在dematerialize0()这里出问题,但这又是native方法,调试不进去
调试了一下发现我犯蠢了,stackFrameHead是ContinuationImpl::run那一帧,我前文还提到过的。我上面的exp是按stackFrameHead是suspend那一帧来写的,修改一下exp
改好了,不报上面那个错了,报新的错
说是pointers的长度不对,再次看了看wp,我发现我又唐了,continuation恢复运行时是从bci后的开始运行,所以我应该将bci指定为exec前一个invoke(后文有解释为什么长度不对)
由上上图可知我要将bci改为87,但为什么pointers的长度还是不合法,我明明就是按照toDebugString输出的规律来的
不能将bci改为87,因为栈由3个部分组成,操作数栈,局部变量表,动态链接(不重要)。局部变量表是在方法执行前就定义好的,也就是pointers,而操作数栈中有个地方叫操作数栈顶,aload,astore就是在操作数栈顶与局部变量表中进行操作。aload将局部变量表中的东西压入操作数栈顶,astore将栈顶数据压入局部变量表。
我们知道每调用一个方法就会新开一个栈,一旦调用了invoke,此时当前操作数栈顶的值就会被压入新的栈中,此时栈顶值就清空了,而每个方法都有return,return就是将值返回到新开栈之前的那个栈的操作数栈顶处。而因为挂起后恢复执行时方法是重新开始执行的,此时栈为空,这是其他wp的解释,我对此的解释是:因为bci必须在invoke处,所以在挂起时其实当前栈正在调用这个invoke函数,而在当前栈内调用函数是会将当前操作数栈顶的数据全部压入新栈中,所以重新恢复执行时当前栈顶是没有数据的。假如我们bci设为87,重新开始执行第一步就是执行90,将slot_2压入栈顶中,但栈顶此时就只有一个值,即pointers的第三个值,但是exec需要两个参数,一个Runtime实例,一个cmd的string。若bci为83,其实根据函数的调用顺序可以知道83这个函数其实里面还有return没有执行就被挂起了,所以恢复执行后这个PrintExecCmd函数会return一个值,但这个函数不会用到我们构造的pointers,因为它的参数早在挂起的时候就已经被保存了,所以为了控制exec的参数,我看了两篇wp,有两种方法,
第一种:构造PrintExecCmd帧。
我们来理解一下stackFrameHead的逻辑。我们知道在一个栈中调用一个方法会开一个新栈,当新栈运行完毕后会return一个数据到当前栈顶,此时当前栈才会继续运行下去。我们把这个逻辑用到resume()方法上。stackFrameHead是一个单向链表,也是一个FrameRecord,即一个栈帧,这个栈帧的next,我们可以将它理解为当前栈中开了一个新栈,所以当前栈就停在了调用next方法的位置,以此类推,直到最后一个suspend。当进行resume()时,就会从suspend那个FrameRecord的前一个FrameRecord开始执行,而在那个栈帧又是从调用suspend的下一条字节码语句开始执行。就如同老栈调用方法开新栈,新栈会return一个数据给老栈一样,stackFrameHead这个单向链表也是如此,会按照执行顺序依次return。所以第一种方法就是按照PrinterSpooler:run方法字节码中的顺序一样,将PrintExecCmd作为run的新栈,控制PrintExecCmd就可以实现rce。
这是PrintExecCmd的部分字节码
很明显,bci设为492,aastore是将栈顶的值储存到一个变量数组中,执行aastore后栈顶清空,507又有一个aastore,栈顶又空了,最终return局部变量表中的slot_13,即需要局部变量表要有14个slot(因为起始为0)。
第二种:伪造
因为resume()的逻辑不会管你是不是按照字节码的顺序,它默认这个执行流是安全的,不会检查return数据的这个方法是不是你字节码中应该return的那个方法。所以只需要一个return的是一个能控制的字符串的方法,一篇wp找到的是这个方法
很好理解,283调用的这个方法没有返回值,所以此时栈顶只有286从局部变量表中加载的slot_10,我们将slot_10改为我们需要执行的命令的即可。
因为远程没有自定义的Job类,所以要把Continuation::entrypoint改为null,删除job那一帧,反正是倒着执行,对结果没有影响。
因为此题Linux和win的jdk中的PrinterSpooler所在类不同,所以用win无法打通远程脚本,得用linux.
Chain17
看了下依赖,有一个h2,想到了nctf那道h2 revenge,尝试用上次的payload,但是发现POJONode版本不一样,chain17的版本PojoNode的爷爷类BaseJsonNode没有实现Serializeable接口,看了其他的依赖,发现没有啥能触发getter的链子。h2应该打不了。也看到了tomcat的依赖,想到了cve-2025-24813,尝试了一下,也不行。也有Jackson依赖,但第一这是jdk17,版本太高,很多链子用不了,第二pojonode那条通杀链也用不了,jackson也打不了,而且我突然想到只有ObjectMapper.readValue()会调用反序列化类的setter,ObjectInputStream.readObject()是不会调用目标类的setter,所以jackson反序列化的链子应该是用不了的。又看到了hessian-lite依赖,感觉经常看到hessian反序列化但是一直没去学过,看了看相关文章,发现很多都是用rome链子,但是这题又没有rome依赖,
所以我要去找一条这题有的依赖的相关链子,
- Spring AOP链,黑名单有org.springframework.jndi,SimpleJndiFactory用不了。java.security也ban了SignedObejct二次反序列化也不行
- Spring AOP & Context链,黑名单有org.framework.aop.aspectj
会不会是混合链子,AOP链只有最后触发JNDI的SimpleJndiFactory被ban了,找找其他能通过getBean()触发JNDI的类,没找到。而且高版本jdk有jndi限制很麻烦。我得看看wp
但其实这是hessian2反序列化,实现不实现Serializable接口并不是那么重要,看了wp发现我上述的大部分用不了的链子都用上了。
总体链络
是hessian2来进行反序列化,所以最外层要用一个Map,而agent的pom有hutool依赖,这个依赖中有JSONObject类,是个Map,JSONObject#put会调用value的toString方法,但有个限制是value必须是java内部类,AtomicReference#toString会调用自身value字段的toString方法,而POJONode#toString会调用value字段的getter方法。而题目给了一个Bean类,有个getObject方法,会使用Java原生反序列化类ObjectInputStream反序列化data字段并将反序列化的结果返回。
wp给的做法并没有利用readObject,而是通过jackson的一个机制:如果调用类的getter方法返回了一个对象,那就继续调用这个对象的getter方法。
wp采用了hutool依赖的DataSourceWrapper#getConnection,实现了h2的rce
官方wp用了Bean链二次反序列化,但我实测不用bean也可以(但是其他人的wp说Hessian2反序列化是调用的无参构造,而PooledDSFactory无参构造会在后面报错?)
这个地方为什么要套上一层Bean
Bean bean = new Bean();bean.setData(SerializeUtil.serialize(pooledDSFactory));
问题就在于JavaDeserializer实例化PooledDSFactory的时候调用的是无参构造。
1
2
3
4
5
6 createDbSetting:87, GlobalDbConfig (cn.hutool.db)
<init>:50, AbstractDSFactory (cn.hutool.db.ds)
<init>:25, PooledDSFactory (cn.hutool.db.ds.pooled)
<init>:21, PooledDSFactory (cn.hutool.db.ds.pooled)
instantiate:313, JavaDeserializer (com.alibaba.com.caucho.hessian.io)
readObject:202, JavaDeserializer (com.alibaba.com.caucho.hessian.io)若是无参构造,走到这里的时候会去加载配置文件。然后没有配置文件的话就会抛出异常报错。
这也太细了…,这也没啥报错咋能想到的。而且我trycatch了都没有报错输出啊
我知道为什么我不用bean也可以,因为我导入的Hessian2依赖跟题目的Hessian2不一样,题目的是com.alibaba.com.caucho.hessian.io.Hessian2Output我的是com.caucho.hessian.io.Hessian2Output
所以还是必须要用bean
但是我本地就能成功反序列化,远程又不行了java.lang.IllegalArgumentException: Input byte array has wrong 4-byte ending unit又是这个东西,难道又要用Linux输出?
搞了一天,学了怎么打jar和在Linux中改java版本,可是为什么,还是报一样的错呢?我很绝望
霖哥说是题目环境问题,我相信他,不折磨自己了
我知道了。这个接收参数不能加键名,以后真得多调试了,我根本就没想到要调试jar包。
h2语言中create alias 后面的方法一定要throws 异常,如果在函数体内try catch是不符合语法规范,执行不了的。
最终进行连接的函数是PooledDataSource#newConnection
Server
Server公网访问不了,要通过agent来访问。
而agent已经实现了h2rce,可以通过create alias来给server传递反序列化payload.
server反序列化是Java原生类ObjectInputStream,所以找正常readobject链
pom中有两个依赖spring,jooq.lib中还有jackson依赖
我搜了一下pom文件中的内容只对编译,打包,构建时起作用,一般lib文件夹下的jar包是包含在运行时的classpath里面的,所以反序列化payload中包含lib中的类也是可以成功反序列化的。
因为readobject,所以EventListener-PojoNode可用。
通过codeql查询,source为getter,sink点为调用了ClassPathXmlApplicationContext构造函数的方法
1 | /** |
wp说的是配合手筛,我理解的就是从步数最少的开始尝试
ConvertedVal#getValue -> ConvertAll#from
1 | public static void aliyunctf2024_chain17_server_exp() throws Exception{ |
成功复现
About this Post
This post is written by DashingBug, licensed under CC BY-NC 4.0.