强网杯S9 Real World - monotint
目录
前言
本次强网杯线下和0x300R的师傅们一起打了,其他师傅都太强啦。但最后一天的时间很短,我们最后demo的4题,基本都因为环境问题,没有成功,其中就包括了我这里demo 的monotint,一道浏览器的nday复现
第一个坑是关于v8沙箱的问题。这道题目的chrome版本是139.0.7258.128,启动参数—no-sandbox,意味着render rce之后就可以任意代码执行。但是正常情况下v8沙箱在这个版本下是默认开启的,所以我本地编译了一个开启v8沙箱的chrome,在这个基础上进行利用,但是后来发现其实题目是没有编译v8的沙箱的,所以那一段沙箱逃逸的逻辑根本没有使用到,耽误了很久的时间……(以后还是直接上去测题目给的虚拟机😭
第二个坑是最后弹计算器的问题。公告和题目的信息是分开的,因此没有注意到,公告中提到了为了降低演示的难度,可以自选方法进行验证rce。所以最后花了一段时间去解决弹计算器的问题,其实只需要xauth给个权限可以了,这里需要感谢组里的两位大哥@leommxj和@m4x,帮助我解决了弹计算器的问题
第三个坑是cpu保护的问题。公告中提到了现场演示机器的cpu是intel 14gen,不过我确实没有忘pku内存保护的方向去想,所以最后的利用部分造成了问题。绕过方式也很简单,jit spray或者写一个wasm函数绕过就行
题目信息
题目除了下发了一个虚拟机之外,虚拟机里还有一个启动脚本和一段diff
所以查看了下启动参数,发现没有沙箱,所以只需要考虑render rce,那么主要需要思考的是v8侧的利用
1 | |
接着查看版本信息,chrome版本是139.0.7258.128,v8版本是13.9.205.19

看到了v8的版本不算新,因此看了下commit hash,8月4号的提交,那么之前p0的Big sleep挖出来的CVE-2025-9132是可以直接打的,但是我没准备 :(
由于笔者并没有提前准备1day(科恩的师傅应该是有的,所以最后也只有他们demo成功了,太强了),所以我只能看题目准备了什么diff

diff信息,但是看着就觉得很眼熟,之前似乎在issue tracker上看到过,于是关键词搜索,就发现了issue tracker上公开的报告,接着就搜索到了公开的blog分析和完整的脚本,编号是CVE-2024-12695
1 | |
对于正常的render rce流程还需要一个v8的沙箱逃逸,因为是默认开启的,所以笔者接下来做了两件事情
- 适配完CVE-2024-12695的利用
- v8进程的沙箱逃逸
在比赛的当天下午6 7点钟这样,已经完成了第一个部分,但是此时笔者并不知道题目下发的虚拟机其实是没有的,因此后面花了大部分时间在v8的沙箱逃逸上😭……
环境搭建
搭chrome的环境
1 | |
编译参数
1 | |
搭d8的环境
1 | |
编译参数
1 | |
调试chrome的时候如果需要使用d8的调试函数,需要加上–js-flags=”–allow-natives-syntax”,由于个人习惯,我还会加上–auto-open-devtools-for-tabs,这样会自动打开devtools
漏洞分析
这个nday详细的分析,我觉得看别人已经公开的就好,我这里根据自己的理解,大致再分析了一下
Object.assign缺少类型检查
1 | |
从删去的部分不难理解,这里删去的部分其实是对于properties字段的检查,检查这个字段是否是smi,如果是smi则跳转到slow_path执行后续逻辑;如果不是smi则进入fast_path。
注意到CSA_DCHECK,意思是当前字段如果不是smi,那么必须是一个EmptyFixedArray。被删去之后,言下之意就是说这个字段可以为smi,也可以为FixedArray(object)
所以这个检查的含义可以总结为,删去了对于对象properties字段类型的检查,不再检查properties是否为smi或者对象,无论properties字段为smi或者对象都执行同一个fast_path的流程。此时我们具备了改变对象properties字段的能力
而这个逻辑位于一个builtin方法TF_BUILTIN(ObjectAssign, ObjectBuiltinsAssembler)中,所以我们现在知道Object.assign这个方法存在漏洞
可以通过这个代码演示一下
1 | |
当注册完gc监控对象target之后,unregister_token 产生了hash字段。接着通过Object.assign的快速路径,使得unregister_token的hash字段被破坏,这个被破坏成了一个FixedArray

需要解释一下FinalizationRegistry的使用方法
- 第一个参数target是gc监控的对象
- 第二个参数undefined是回调函数执行时需要用到的值。当 target 被回收,回调函数被调用时,这个值会作为参数传给回调函数。
- 第三个参数是取消注册的令牌。如果稍后想要取消对 target 的监控,你可以调用 registry.unregister(unregister_token)来取消对于回调的执行
- 注册完毕后会为unregister_token生成hash字段
上方的register方法是监控了target对象,当target被gc回收时,会执行自定义的回调函数。上方的第三个参数是注册的token,后续调用了Object.assign破坏了hash字段
SimpleNumberDictionary越界的产生
调试代码如下
1 | |
这里的diff很简洁,将强制CHECK换成了DCHECK,这个函数JSFinalizationRegistry::RemoveCellFromUnregisterTokenMap由上方提到的unregister调用,意味着执行registry.unregister时不再对entry进行检查
1 | |
所以对于的hash值不存在于key_map的情况,entry的值为-1。又由于换成了DCHECK,就会绕过entry.is_found()的检查
接着如果当前的weak_cell prev指针为undefined,则会进入到下方的if循环时,会执行key_map->ClearEntry(-1)
1 | |
继续跟踪调用,首先进入下方的模板函数
1 | |
接着进入SetEntry,从上方的传递值可以发现key和value都是the_hole,也就是0x7d9
1 | |
index的转化逻辑调用了如下函数,如果说一开始的entry是-1,也就是没找到对应的hash值,那么则会计算出index=1
1 | |
接着通过调试验证,同时可以得到Derived::kEntryKeyIndex = 0; Derived::kEntryValueIndex=1;所以这里会将this,也就是key_map index为1和2处都设置为the_hole(0x7d9)

我们需要观察一下key_map的结构,可以看到这里是由FixedArray申请出来的,但是map是SIMPLE_NUMBER_DICTIONARY_TYPE,所以我们找一下这个map类型的定义

这里说明对于header来说没有多余的size,每一个Entry是2项。
1 | |
header位于v8/src/objects/hash-table.h中,slot[0] = kNumberOfElementsIndex,slot[1] = kNumberOfDeletedElementsIndex,slot[2]=kCapacityIndex
1 | |
Entry定义了value在index为1处,那么0处就是key
1 | |
这里涉及到的对象继承关系
1 | |

对应到上方的调试结果就是
1 | |
上方的0x001313d2也就是hash值<<1的内存中的表示,后面紧跟着的是WeakCell
接着经过ClearEntry之后,可以发现

完整的如下,可以发现容量变成了0x000007d9,所以此时产生了一个越界的SimpleNumberDictionary
1 | |
不稳定的越界转化为固定偏移的任意写
上方其实已经得到一个可以越界的SimpleNumberDictionary,那么现在就需要思考如何将这个不稳定的越界转化成稳定的越界读写
首先来看这样一段函数
1 | |
下断点之后会发现执行了这个函数JSFinalizationRegistry::RegisterWeakCellWithUnregisterToken
- 这个函数很短首先会检查finalization_registry的key_map是否存在,不存在的话再初始化一个
- 接着获取weak_cell或者生成weak_cell的hash字段,然后赋值给key
- 根据key去索引entry
- 如果存在,则会获取这个value,也就是之前添加的WeakCell,然后设置这个WeakCell的set_key_list_prev和set_key_list_next
- 最后将更新过的内容写回 finalization_registry
1 | |
接着来调试一下这一段代码,首先这里的weak_cell是新生成的,然后finalization_registry是代码中的registry

此时的unregister_token还只是一个普通对象,没有生成hash值

由于此时的并没有生成表项,所以entry.is_found()过不了,因此第一次register不会执行到if的逻辑
使用同样的对象进行第二次register,可以看到我们之前unregister_token已经生成了hash值,并被保存到了key_map中

没有执行if中的逻辑前,可以看到两个weakcell的list prev/next都是undefined状态

由于这一次是使用了同样的对象,这个表现会被说索引到,所以这里分别设置了weakcell的list prev/next

进一步在内存里查看,对于第一次申请的weakcell对象,在偏移0x1c的位置,写上了第二次申请的weakcell对象的地址

第二次申请的weakcell对象,在偏移0x20的位置,写上了第一次申请的weakcell对象的地址

还需要提一嘴的是Weakcell在active_cells中的组织形式是LIFO,从job的输出也可以看出来,新添加的Weakcell对象采用头插法插入到list头部,所以他的prev为undefined,next为旧的Weakcell,同时旧的Weakcell的prev为新的Weakcell,next为undefined
从上面的方式可以看出如果我们提前知道了key_map中的某一表项的hash值,就可以在对应的weakcell的固定偏移处写入一个的值,而如果我们可控weakcell的值,就可以在我们控制的值基础上进行任意写,也就是 *((&weakcell)+0x1c/0x20)= obj_val,可以知道这个obj_val是一个weakcell的值,默认为很大的值,可以用这个大值去破坏对象的length字段,实现稳定越界读写的效果
漏洞利用
截止到目前我们需要实现上面的思路,同时需要保持利用的稳定性
- 第一步:获取一个存在越界的key_map
- 第二步:预测key_map中对象的hash值
- 第三步:通过预测的hash值实现地址任意写,从而corrupt某些array对象的length
- 第四步:根据获取到的oob_arr,构造出稳定的利用原语
- 第五步:解决弹计算器的问题
如果qwb线下做过这个利用的师傅应该知道,上面除了第一步以外,其他步骤都有点麻烦😭
step1
这部分代码很简单,删去了回调函数的部分代码,后面完整的利用会有
1 | |
这里先利用了patch的漏洞实现了key_map的越界,但是其中我还是布置了一些对象。
在key_map之后,布置了victim_arr,其中初始化了0x1000个kv表项,接着我又构造了一个arr来存储一些hash表项,这两个步骤都是为后续利用提前做的准备
1 | |
同时为了布局的稳定,我在每一个重要对象后面都执行了一个major_gc,最后一次gc是为了触发漏洞
1 | |
step2
这个步骤是预测key_map里的hash值,完整函数如下
1 | |
- 上方的大循环中,我构造了一个0x100000个kv表项,预测hash值从1-0x100000,可以从注释里看到HashField::kMax = 2^20 - 1 = 1048575 = 0xFFFFF,实际肯定不会执行这么多次数的,所以我选了最大次数作为循环体执行的次数
- 接着通过registry.unregister的方法,去遍历构造出来的arr_with_hash_object对象。因为之前构造了越界的key_map,同时vivtim_arr在key_map的后面,所以一部分hash值会越界到victim_arr里,破坏初始值,当遍历结束之后,我就可以得到victim中被破坏位置的idx,成功概率是10000/0x100000
- 上方成功获取到corrupt_vic_idx之后,接着将当前的kv表项用于初始化victim_arr,同时继续依次unregister之前构造的arr_with_hash_object对象,同时判断是victim_arr的内容是否发生变化,如果变化,则说明此时的hash表项修改的范围落在了我们可控的victim_arr里面
如果这个函数执行完毕,那么我们得到了一个可复用的kv表项,如果对于这个kv表项进行register,那么就可以这样 *((&weakcell)+0x1c/0x20)= obj_val,向可控的相对地址写入一个 WeakCell 指针
step3
通过上方获取到的可以hash对象进行进一步的利用,这里是核心逻辑的代码
1 | |
- 我这里初始化了一个oob_arr,类型为double arr,预测的地址空间是[0x1c0000,0x2000000],步长为0x100
- 接着开始构造表项let fake_kv = build_kv(corrupt_obj_hash, i | 1),这里的corrupt_obj_hash为之前得到hash值,然后i就是每次预测的地址,然后初始化oob_arr
- 使用registry.register进行地址预测,利用 JSFinalizationRegistry::RegisterWeakCellWithUnregisterToken 的逻辑,由于 Hash 命中,V8 会获取我们伪造的“旧 WeakCell”地址,并尝试将其作为链表节点,执行 existing_weak_cell->set_key_list_prev(new_weak_cell)。这实际上向 i + 0x1c 的位置写入了新 WeakCell 的指针。如果这个写入落在 oob_arr 范围内,就能被探测到
- 由于oob_arr存在0x100项,通过register的方法可以将i+0x1c位置写为一个WeakCell对象的值,如果落在了oob_arr的element里,那么可以通过遍历发现,接着通过i来反推出oob_arr的element地址
- 反推出oob_arr的element地址后,可以通过register的方法去覆盖oob_arr的element的length字段,这里需要错位字节,不然element的length字段无法解析。同时这里和出题人学到了一个技巧,就是错位字节之后,进行resize,即可保持布局的稳定
step4
上面已经是构造出来了一个越界读写的oob_arr
1 | |
由于此时布局的稳定性还算不错,后续申请的几个对象与target_arr之间的距离是固定的,所以能否准确定位到target_arr是很重要的,他直接影响了后续的原语构造
所以我采用了扫描匹配 target_arr 内容的方式,匹配到target_arr之后,我们可以gdb调试,字节计算出 target_arr 对象本身以及后续 obj_arr 等对象的精确偏移,利用这个偏移,我们通过 oob_arr 修改后续对象的内容,从而实现利用原语的构造。
1 | |
step5
这里需要单独讨论一下如何弹出计算器。
当时现场机器cpu是intel14 gen,在linux平台上有一个pku保护,所以我当时在执行shellcode的时候挂了。解决也很简单,使用jit spray或者wasm函数,我这里为了更加稳定使用了wasm函数。wasm中定义了一些汇编,然后通过传入两个i64的值,来执行execve,然后我每次只需要修改稳定cmd_ptr即可

接着需要解释为什么执行这样命令,这个lll是我自己使用的机器,我使用了vnc。最重要的步骤是export XAUTHORITY=/home/lll/.Xauthority,这个可以避免环境问题导致的 X11 认证失败,后面跟着的export DISPLAY=:100;是我自己vnc开放的端口,自己的机器只用了d8,没放到chrome里

对于下发的虚拟机,采用了动态查找授权文件的方式,后续的步骤和之前一样

移植到chrome里主要需要解决的问题是遍历oob_arr的问题,我这里起始地址设置到了0x1a0000,最后就可以弹出计算器了

exploit
完整的利用脚本,全部代码位于这里https://github.com/f1lyyy/V8-Exploit-Collection/tree/main/qwb_s9_final_monotint
1 | |