年末主机圈又被一枚 exploit 刷了屏。一个叫 Relapse 的项目在 GitHub 上开源,支持 PS5 固件 7.00 到 13.60 共 34 个版本,一周内攒了 1200 多颗星。README 写得极简,但翻开 src/ 目录你会发现这不是玩具:完整的 WebKit 利用链、内核 UAF 竞态、逐固件符号偏移表、ROP 链构造、shellcode 注入,工业级 exploit 框架该有的东西都在。
它把"浏览器漏洞怎么一路打到内核任意读写"拆得很清楚,而且每一层的失败处理都老老实实暴露了出来,这在 exploit 项目里很少见。
目标环境:FreeBSD 内核 + WebKit 浏览器
PS5 的系统是 Orbis OS,内核基于 FreeBSD。用户态这边有个内嵌浏览器,渲染引擎是 WebKit,JS 引擎是 JSC(JavaScriptCore)。所以攻击面是标准的双阶段结构:
- 浏览器阶段:在 JSC/WebKit 里拿到任意地址读写;
- 内核阶段:用内核漏洞把用户态读写能力升级成内核空间读写,最后加载自定义 ELF payload。
Relapse 的 README 把这条链一句话概括了:
Browser stage uses JSC info leaks and a structured clone object pool mismatch to corrupt a typedarray. The kernel stage combines a address leak with an
aio_multi_waituaf race to establish kernel r/w.
这句话每个短语背后都是一段源码,我们逐个拆。
浏览器阶段:对象池错配怎么变成 typed array 越界
WebKit 阶段在 src/webkit.js 里,21000 字节左右,是整个项目最"脏活"的部分。先看它定义的一组常量:
const DUPLICATE_INDEX = 2;
const CONTROL_INDEX = 0xffff;
const LEAK_STRING_LENGTH = 924176;
const CELL_BYTES = 0x30;
const NATIVE_EXECUTABLE_BYTES = 0x38;
const HOLDER_BYTES = 0x40;
const CANARY_OFFSET = 0x20;
const LEAK_SLOT_OFFSET = 0x20;
CELL_BYTES = 0x30、NATIVE_EXECUTABLE_BYTES = 0x38 是 JSC 内部对象的内存布局尺寸。exploit 需要精确知道这些尺寸才能构造可预测的堆布局,差一个字节整个堆风水就崩了。
第一步是地址泄露。leakScopeObject() 用了一个很巧的 JS 技巧:
class Leaker {
leak() { /* ... */ }
}
Leaker.prototype.__proto__ = new Proxy({}, { /* handler */ });
把 Leaker.prototype 的原型设成一个 Proxy,再通过 Object(leakedScope.g) 触发 getter,让 JSC 在作用域链上把不该暴露的内部对象吐出来。拿到 leakedScope 之后,用 LEAK_STRING_LENGTH = 924176 这个精心挑选的长度分配字符串,这个长度能让 JSC 走非 inline 的存储路径,在堆上留下可预测的布局。
真正致命的一步是 structured clone 的对象池错配。structuredClone 处理嵌套对象时会用一个池复用已分配的结构;exploit 构造特定的嵌套深度和类型组合,让池里 DUPLICATE_INDEX = 2 槽位的对象类型和访问时假定的类型对不上,也就是 type confusion。一旦成立,一个被当作普通对象访问的引用实际指向另一个对象的头部。
CONTROL_INDEX = 0xffff 是池的容量控制点:把索引推到 65535,触发池的扩容/复用路径,让错配对象落在可预测位置。
错配的收益是 redirectView:
function redirectView(candidate, address) {
const high = Math.floor(address / 0x100000000);
scratchWords[0] = address - high * 0x100000000;
scratchWords[1] = high;
for (let i = 0; i < 8; ++i) candidate[0x10 + i] = scratchBytes[i];
}
把目标地址的低 32 位、高 32 位拆进 scratchWords,再写进 typed array 对象头部偏移 0x10。在 JSC 里 ArrayBufferView 对象的这个字段就是数据指针,改掉它,candidate[i] 的读写就落到任意地址上。这就是浏览器阶段的"任意读写"来源。
restoreView 把原始的 viewHeader[0x10..0x17] 写回去。利用过程要保持堆状态干净,否则下一次分配就会踩坏。这些 restore 逻辑散落在各处,说明作者踩过不少"用一次就崩"的坑。
内核阶段:aio_multi_wait 的 UAF 竞态
拿到用户态任意读写之后,还差最后一步:内核空间读写。这一步在 src/kexp.js,用的是 aio_multi_wait 的 UAF 竞态。
先看 exploit 怎么描述内核对象布局:
const PIPE = { count: 0x00, in: 0x04, out: 0x08, size: 0x0c, buffer: 0x10, defaultSize: 0x4000 };
const FD_ENTRY = { ofiles: 0x08, stride: 0x30, data: 0x00 };
这是 FreeBSD 内核里 pipe 和 fd 表的结构体字段偏移。FD_ENTRY.stride = 0x30 说明每个 fd 表项 48 字节;PIPE.buffer = 0x10 是 pipe 的内核缓冲区指针。
拿到内核读写后,exploit 做了一件很漂亮的事——用 pipe 重定向来投递 shellcode:
async function prepareShellcodePipes(krw, master, victim) {
const table = await krw.read8(krw.procFdAddr);
const pipeOf = async (fd) => {
const file = await krw.read8(table.add32(FD_ENTRY.ofiles + fd * FD_ENTRY.stride));
return krw.read8(file.add32(FD_ENTRY.data));
};
const masterPipe = await pipeOf(master.readFd);
const victimPipe = await pipeOf(victim.readFd);
await krw.write4(masterPipe.add32(PIPE.count), 0);
await krw.write4(masterPipe.add32(PIPE.in), 0);
await krw.write4(masterPipe.add32(PIPE.out), 0);
await krw.write4(masterPipe.add32(PIPE.size), PIPE.defaultSize);
await krw.write8(masterPipe.add32(PIPE.buffer), victimPipe);
}
从当前进程的 fd 表出发,顺着 ofiles 数组找到目标 fd 的 file 结构,再取到 pipe 对象。然后把 master pipe 的所有计数清零,把它的 buffer 指针指向 victim pipe。
结果就是:往 master 写数据,数据直接落进 victim 的内核对象里。这是一个典型的"内核写原语经 pipe 放大"的技巧,好处是不依赖任何 gadget,纯结构体篡改。
从内核读写到执行任意代码
有了内核 R/W,mapExecutable 负责把 shellcode 放进可执行内存:
const execFd = await chain.syscall(SYS_JITSHM_CREATE, 0, length, PROT_RWX);
const entry = await chain.syscall(SYS_MMAP, 0, length, PROT_RWX, MAP_SHARED, execFd, 0);
jitshm_create 是 PS5 特有的 JIT 共享内存系统调用,SYS_JITSHM_CREATE = 0x21C 这类编号在 src/utils/syscalls.js 里完整列出了 400 多个系统调用号。先创建 RWX 的共享内存,再 mmap 进来。
这里有个很实际的工程细节:如果 RWX 直接拷贝失败(某些固件不允许),exploit 会退化到 SYS_JITSHM_ALIAS 路径——创建一份可写的别名映射,写完之后 munmap:
const writeFd = await chain.syscall(SYS_JITSHM_ALIAS, execFd, PROT_RW);
const writable = await chain.syscall(SYS_MMAP, 0, length, PROT_RW, MAP_SHARED, writeFd, 0);
if (!copyInto(writable) || p.read4(entry) >>> 0 !== readU32(blob, 0))
throw new Error("kexp: shellcode copy failed");
await chain.syscall(SYS_MUNMAP, writable, length);
这就是真实 exploit 和教学 PoC 的差距:同一个动作要有两条路径,还得逐 dword 校验拷贝结果。
shellcode 本身不是裸二进制,是要现场 patch 的:
patchShellcode(blob, symbols);
kexp_2026_05_25.bin 是 18912 字节的模板,里面留了占位符。exploit 通过 resolveSymbols() 解析出 libkernel 和 libc 的基址,再把 getpid、malloc、pthread_create 这些导入函数的真实地址写进 shellcode 的固定偏移:
writeU64(blob, at + 2, addressOf("libkernel", "getpid"));
SHELLCODE.resolverCalls 里连 resolver 调用的字节序列 [0xe8, 0xcf, 0x00, 0x00, 0x00] 都做了完整性校验(matches()),防止模板文件被换掉。这种"模板 + 现场 patch + 校验"的做法,本质上是把 ASLR 当成运行时变量来解。
ROP 链:劫持 libkernel 的 worker 线程
内核 R/W 只能读写内存,要真正调用函数(mmap、pthread_create)还得构造 ROP 链。Relapse 的做法不是去翻栈溢出,而是劫持 libkernel 自己起的一个 worker 线程——src/main.js 里的 findWorkerStack:
const head = libKernelBase.add32(OFFSET_lk__thread_list);
// 遍历 thread_list 链表,按指纹选出目标 worker
const expected = libKernelBase.add32(OFFSET_lk_worker_wait_return);
遍历 libkernel 的 thread_list 链表,用 worker_wait_return 这个返回地址做指纹,找到正在等待的 worker 线程。然后:
chain.push(gadgets["pop rdi"]);
chain.push(originalContext);
chain.push(libSceLibcInternalBase.add32(OFFSET_lc_setjmp));
用 setjmp/longjmp 来保存和恢复上下文——setjmp 先存一份 worker 的执行现场,ROP 链跑完之后用 longjmp 干净地恢复,主线程继续正常跑。这是 exploit 能反复重试而不炸机的关键。
gadget 也是逐固件列好的(offsets/13.60.js):
let wk_gadgetmap = {
"pop rdi": 0x000288B2,
"pop rsi": 0x00014EAD,
"pop rdx": 0x000FA7A2,
"mov [rdi], rsi": 0x000C05B6,
...
};
每个固件一套 gadget 地址 + syscall 号映射表。34 个固件版本 = 34 份偏移表,这也是为什么项目里 offsets/ 目录比 src/ 还大。固件一升级,所有 gadget、所有符号偏移全部偏移,维护成本极高。
加载 payload 与用户可见的结果
最后一环是 ELF 加载器。mapElf() 把 elfldr-ps5-1360.elf 映射进内存:
const base = await chain.syscall(SYS_MMAP, 0, size, PROT_RW, MAP_PRIVATE_ANON, -1, 0);
然后把 ELF 内容按 dword 写进去,再起一个 payload 线程:
const created = await chain.call(base.add32(create), handle, new int64(0, 0), entry, args, p.stringify("payload"));
const joined = await chain.call(base.add32(offsets.pthread_join), p.read8(handle), result);
跑完 payload 之后,ELF loader 会在 9021 端口监听,用户可以往上面推自己的 ELF。README 里提到的 DNS 45.56.67.85 是自助托管入口,让主机在没有本地服务器的情况下也能加载页面。
关于稳定性:作者没有藏
我特别欣赏这个项目的失败处理,几乎每层都明确写出了"这一步可能失败":
if (failed(execFd) || execFd.low >= 0x100000)
throw new Error("kexp: jitshm_create failed (" + hex(execFd) + ")");
README 的 Stability notes 也很坦诚:
Webkit may need several attempts, reload the page if the browser stalls. The kernel exploit may hang or panic the console, so reboot before trying again if that happens.
浏览器阶段要重试几次,内核阶段可能直接 panic 让主机重启——这是竞态型 exploit 的常态。waitForWorker 里还专门做了超时保护:
the rop worker never answered in ${ROP_WAIT_MS / 1000}s - refusing to continue on a chain that never ran. (Reload.)
宁可报错让你重载,也不在一条没跑起来的链上继续写内存。这个设计取舍很成熟。
一个开发者的视角
撇开"主机 jailbreak"这个具体场景,Relapse 里能抽出来复用的东西有三条:
第一,类型错配是这个时代最稳定的浏览器原语。 structuredClone 的池复用、typed array 的头部布局,这些不是 WebKit 独有的问题,V8、SpiderMonkey 都有各自的"对象池 + 类型标签"复杂度。防御方盯着 JIT 编译器的同时,结构化克隆、序列化这些"看起来只是数据搬运"的 API 一样是攻击面。
第二,内核 exploit 的普适套路是"用结构体篡改换原语"。 pipe 重定向那一段——清计数、改 buffer 指针——不依赖任何 gadget,只要拿到内核读写就能用。这类技巧在 Linux 的 msg_msg、pipe_buffer 利用里到处都是。理解一个,就理解了一类。
第三,逐版本偏移表的维护成本是 exploit 工程的真实瓶颈。 34 个固件版本、每个版本一套 gadget 和符号偏移,固件一更新就得重来。所以给开发者的启示是反向的:把符号解析做成运行时的(像 Relapse 的 resolveSymbols),而不是写死在表里,能显著降低升级带来的维护量。
最后,README 自带的免责声明值得引用一下:
This project is intended for educational and security research purposes only. It does not endorse piracy, unauthorized access, or misuse of commercial devices.
⚠️ 网络安全免责声明
本文内容仅供技术研究与安全学习之用。文中描述的漏洞分析、攻击链拆解和代码示例均基于公开披露的安全研究,旨在帮助开发者理解攻击原理、提升安全意识和防御能力。
请勿将本文所述技术用于任何未经授权的安全测试、渗透攻击或非法用途。任何因滥用本文信息而造成的法律后果,由使用者自行承担,与本文作者无关。
如发现系统安全漏洞,请通过合法渠道向相关厂商或平台报告,共同维护网络安全生态。
浙公网安备 33010602011771号