一、前言
- CVE-2019-5755 是一个位于 v8 turboFan 的类型信息缺失漏洞。该漏洞将导致 SpeculativeSafeIntegerSubtract 的计算结果缺失 MinusZero (即 -0)这种类型。这将允许 turboFan 计算出错误的 Range 并可进一步构造出越界读写原语,乃至执行 shellcode。
- 复现用的 v8 版本为
7.1.302.28
(或者commit IDa62e9dd69957d9b1d0a56f825506408960a283fc
前的版本也可)
二、环境搭建
- 切换 v8 版本,然后编译:
git checkout 7.1.302.28 gclient sync tools/dev/v8gen.py x64.debug ninja -C out.gn/x64.debug
- 启动 turbolizer。如果原先版本的 turbolizer 无法使用,则可以使用在线版本的 turbolizer
cd tools/turbolizer npm i npm run-script build python -m SimpleHTTPServer 8000& google-chrome http://127.0.0.1:8000
三、漏洞细节
- turboFan 的 Typer 将 SpeculativeSafeIntegerSubtract 的类型设置为与 kSafeInteger 的交集,但这里没有考虑到
-0
(即 MinusZero)的情况。 例如:算式((-0) - 0)
应该返回-0
,但是由于 Typer 取的是两 个类型的交集,因此 typer 将忽略 MinusZero (-0) 的这种情况。而这种 wrong case 可以用来执行错误的范围计算。以下是 SpeculativeSafeIntegerSubtract 函数(漏洞函数)以及 SpeculativeSafeIntegerAdd 函数(对照函数)的源码:Type OperationTyper::SpeculativeSafeIntegerAdd(Type lhs, Type rhs) { Type result = SpeculativeNumberAdd(lhs, rhs); // If we have a Smi or Int32 feedback, the representation selection will // either truncate or it will check the inputs (i.e., deopt if not int32). // In either case the result will be in the safe integer range, so we // can bake in the type here. This needs to be in sync with // SimplifiedLowering::VisitSpeculativeAdditiveOp. return Type::Intersect(result, cache_.kSafeIntegerOrMinusZero, zone()); } Type OperationTyper::SpeculativeSafeIntegerSubtract(Type lhs, Type rhs) { Type result = SpeculativeNumberSubtract(lhs, rhs); // If we have a Smi or Int32 feedback, the representation selection will // either truncate or it will check the inputs (i.e., deopt if not int32). // In either case the result will be in the safe integer range, so we // can bake in the type here. This needs to be in sync with // SimplifiedLowering::VisitSpeculativeAdditiveOp. /* 给左右操作数相减的结果(即变量 result)与 `kSafeInteger`类型 相交,返回 **交集** 。 !!! 注意这里,使用的是 cache_.kSafeInteger 与上面SpeculativeSafeIntegerAdd函数使用的cache_.kSafeIntegerOrMinusZero不一致 */ return result = Type::Intersect(result, cache_.kSafeInteger, zone()); }
- 以下是该漏洞的 PoC:
function foo(trigger) { var idx = Object.is((trigger ? -0 : 0) - 0, -0); return idx; } console.log(foo(false)); %OptimizeFunctionOnNextCall(foo); console.log(foo(true)); // expected: true, got: false
正常来说,
foo(true)
应该始终返回 true (因为 $-0 – 0 = -0$),但优化后产生的结果却是 false。我们可以观察一下 turbolizer 中的信息:
可以看到,对于 {MinusZero | Range(0,0)} – Range(0,0) 这种情况,SpeculativeSafeIntegerSubtract 的 Type 中并没有 MinusZero 这种类型。
因此,turboFan 将始终在 TypedLoweringPhase - TypedOptimization::ReduceSameValue
中,把SameValue 结点优化成 false,因为 $MinusZero \ne Range(0, 0)$。
- SameValue 结点是通过 JS 中
Object.is
函数调用来生成的,其目的是用于判断左右操作数是否相同。具体来说是通过以下调用链生成:void InliningPhase::Run(...) Reduction JSCallReducer::ReduceJSCall(...) Reduction JSCallReducer::ReduceObjectIs(Node* node)
其中,函数 ReduceObjectIs 的源码如下:
// ES section #sec-object.is Reduction JSCallReducer::ReduceObjectIs(Node* node) { DCHECK_EQ(IrOpcode::kJSCall, node->opcode()); CallParameters const& params = CallParametersOf(node->op()); int const argc = static_cast<int>(params.arity() - 2); Node* lhs = (argc >= 1) ? NodeProperties::GetValueInput(node, 2) : jsgraph()->UndefinedConstant(); Node* rhs = (argc >= 2) ? NodeProperties::GetValueInput(node, 3) : jsgraph()->UndefinedConstant(); // 生成 SameValue Node Node* value = graph()->NewNode(simplified()->SameValue(), lhs, rhs); ReplaceWithValue(node, value); return Replace(value); }
Typer 将在 TyperPhase 阶段试着计算出 SameValue 结点的类型,它将沿着以下调用链
Type Typer::Visitor::TypeSameValue(Node* node) Type Typer::Visitor::SameValueTyper(Type lhs, Type rhs, Typer* t) Type OperationTyper::SameValue(Type lhs, Type rhs)
调用到
OperationTyper::SameValue
函数并计算其类型:Type OperationTyper::SameValue(Type lhs, Type rhs) { if (!JSType(lhs).Maybe(JSType(rhs))) return singleton_false(); if (lhs.Is(Type::NaN())) { if (rhs.Is(Type::NaN())) return singleton_true(); if (!rhs.Maybe(Type::NaN())) return singleton_false(); } else if (rhs.Is(Type::NaN())) { if (!lhs.Maybe(Type::NaN())) return singleton_false(); } if (lhs.Is(Type::MinusZero())) { if (rhs.Is(Type::MinusZero())) return singleton_true(); if (!rhs.Maybe(Type::MinusZero())) return singleton_false(); // 如果左右操作数不同时为 MinusZero,则返回 false。 } else if (rhs.Is(Type::MinusZero())) { if (!lhs.Maybe(Type::MinusZero())) return singleton_false(); } if (lhs.Is(Type::OrderedNumber()) && rhs.Is(Type::OrderedNumber()) && (lhs.Max() < rhs.Min() || lhs.Min() > rhs.Max())) { return singleton_false(); } return Type::Boolean(); }
当 SameValue 结点计算出 确定性的类型(即 true / false)后,turboFan 将在 TypedLoweringPhase 阶段中的 ConstantFoldingReducer 对 SameValue 进行结点替换,用之前计算出的 HeapConstant 替换当前的 SameValue 结点:
Reduction ConstantFoldingReducer::Reduce(Node* node) { DisallowHeapAccess no_heap_access; // Check if the output type is a singleton. In that case we already know the // result value and can simply replace the node if it's eliminable. // 如果当前结点的 type 是 singleton,即确定只有一种类型,则开始优化 if (!NodeProperties::IsConstant(node) && NodeProperties::IsTyped(node) && node->op()->HasProperty(Operator::kEliminatable)) { // ... // We can only constant-fold nodes here, that are known to not cause any // side-effect, may it be a JavaScript observable side-effect or a possible // eager deoptimization exit (i.e. {node} has an operator that doesn't have // the Operator::kNoDeopt property). // 获取当前结点的类型 Type upper = NodeProperties::GetType(node); if (!upper.IsNone()) { Node* replacement = nullptr; // 如果当前结点是 HeapConstant if (upper.IsHeapConstant()) { replacement = jsgraph()->Constant(upper.AsHeapConstant()->Ref()); } else if // ... // ... if (replacement) { // Make sure the node has a type. // 使用新类型进行替换 if (!NodeProperties::IsTyped(replacement)) { NodeProperties::SetType(replacement, upper); } ReplaceWithValue(node, replacement); return Changed(replacement); } } } return NoChange(); }
若 SameValue 无法得到确定性的类型,则将在 TypedLoweringPhase 中通过
TypedOptimization::ReduceSameValue
函数进行另一种优化。以下是该函数的源码,在该源码中我们可以了解到 ReduceSameValue 的详细执行过程:Reduction TypedOptimization::ReduceSameValue(Node* node) { DCHECK_EQ(IrOpcode::kSameValue, node->opcode()); Node* const lhs = NodeProperties::GetValueInput(node, 0); Node* const rhs = NodeProperties::GetValueInput(node, 1); Type const lhs_type = NodeProperties::GetType(lhs); Type const rhs_type = NodeProperties::GetType(rhs); if (lhs == rhs) { // SameValue(x,x) => #true return Replace(jsgraph()->TrueConstant()); } else if (lhs_type.Is(Type::Unique()) && rhs_type.Is(Type::Unique())) { // SameValue(x:unique,y:unique) => ReferenceEqual(x,y) NodeProperties::ChangeOp(node, simplified()->ReferenceEqual()); return Changed(node); } else if (lhs_type.Is(Type::String()) && rhs_type.Is(Type::String())) { // SameValue(x:string,y:string) => StringEqual(x,y) NodeProperties::ChangeOp(node, simplified()->StringEqual()); return Changed(node); } else if (lhs_type.Is(Type::MinusZero())) { // SameValue(x:minus-zero,y) => ObjectIsMinusZero(y) node->RemoveInput(0); NodeProperties::ChangeOp(node, simplified()->ObjectIsMinusZero()); return Changed(node); } else if (rhs_type.Is(Type::MinusZero())) { // SameValue(x,y:minus-zero) => ObjectIsMinusZero(x) node->RemoveInput(1); NodeProperties::ChangeOp(node, simplified()->ObjectIsMinusZero()); return Changed(node); } else if (lhs_type.Is(Type::NaN())) { // SameValue(x:nan,y) => ObjectIsNaN(y) node->RemoveInput(0); NodeProperties::ChangeOp(node, simplified()->ObjectIsNaN()); return Changed(node); } else if (rhs_type.Is(Type::NaN())) { // SameValue(x,y:nan) => ObjectIsNaN(x) node->RemoveInput(1); NodeProperties::ChangeOp(node, simplified()->ObjectIsNaN()); return Changed(node); } else if (lhs_type.Is(Type::PlainNumber()) && rhs_type.Is(Type::PlainNumber())) { // SameValue(x:plain-number,y:plain-number) => NumberEqual(x,y) NodeProperties::ChangeOp(node, simplified()->NumberEqual()); return Changed(node); } return NoChange(); }
- 我们再简单了解一下 SpeculativeSafeIntegerSubtract 和 SpeculativeNumberSubtract 结点的生成方式。这两种结点的生成都将通过以下调用链:
bool PipelineImpl::CreateGraph() void GraphBuilderPhase::Run(...) void BytecodeGraphBuilder::CreateGraph(...) void BytecodeGraphBuilder::VisitBytecodes(...) void BytecodeGraphBuilder::VisitSingleBytecode(...) void BytecodeGraphBuilder::VisitSubSmi() void BytecodeGraphBuilder::BuildBinaryOpWithImmediate(...) void BytecodeGraphBuilder::BuildBinaryOp(...) BytecodeGraphBuilder::TryBuildSimplifiedBinaryOp(...) JSTypeHintLowering::LoweringResult JSTypeHintLowering::ReduceBinaryOperation(...) Node* TryBuildNumberBinop() const Operator* SpeculativeNumberOp(NumberOperationHint hint)
调用到最终的目标函数
SpeculativeNumberOp
:const Operator* SpeculativeNumberOp(NumberOperationHint hint) { switch (op_->opcode()) { // ... case IrOpcode::kJSSubtract: if (hint == NumberOperationHint::kSignedSmall || hint == NumberOperationHint::kSigned32) { return simplified()->SpeculativeSafeIntegerSubtract(hint); } else { return simplified()->SpeculativeNumberSubtract(hint); } // ... } UNREACHABLE(); }
在 TryBuildNumberBinop 函数中,turboFan 试图从 feedback_vector 中获取操作数的相关信息。操作数信息一共有以下五种类型:
// A hint for speculative number operations. enum class NumberOperationHint : uint8_t { kSignedSmall, // Inputs were Smi, output was in Smi. kSignedSmallInputs, // Inputs were Smi, output was Number. kSigned32, // Inputs were Signed32, output was Number. kNumber, // Inputs were Number, output was Number. kNumberOrOddball, // Inputs were Number or Oddball, output was Number. };
当且仅当操作数类型为
NumberOperationHint::kSignedSmall
或NumberOperationHint::kSigned32
时,当前减法才会被视为是 Safe 的,因此创建 SpeculativeSafeIntegerSubtract 结点;否则创建保守的 SpeculativeNumberSubtract 结点。 - 最后附带说明一下部分数字类型的范围:
参照源码 src/compiler/types.h
- 一些基础类型
- OtherNumber(ON):$(-\infin, -2^{31}) \cup [2^{32}, \infin)$
- OtherSigned32(OS32) :$[-2^{31}, -2^{30})$
- Negative31(N31):$[-2^{30}, 0)$
- Unsigned30(U30): $[0, 2^{30})$
- OtherUnsigned31(OU31): $[2^{30}, 2^{31})$
- OtherUnsigned32(OU32): $[2^{31}, 2^{32})$
ON OS32 N31 U30 OU31 OU32 ON ______[_______[_______[_______[_______[_______[_______ -2^31 -2^30 0 2^30 2^31 2^32
- 一些基础类型
- Integral32:$[-2^{31}, 2^{32})$
- PlainNumber:任何浮点数,不包括 $-0$
- Number:任何浮点数,包括 $-0$、$NaN$
- Numeric:任何浮点数,包括 $-0$、$NaN$ 以及 $BigInt$
四、漏洞利用
尽管理论上可以通过该漏洞构造越界读取原语,但实际利用起来仍然存在一个无法解决的问题。
即便如此,我们仍然可以在尝试构造漏洞利用中加深对 turboFan 的理解。
初始 Poc 如下
function foo(trigger) {
var idx = Object.is((trigger ? -0 : 0) - 0, -0);
return idx;
}
console.log(foo(false));
%OptimizeFunctionOnNextCall(foo);
console.log(foo(true)); // expected: true, got: false
从 turbolizer 中可以看到,不管传入函数的参数是什么,最后都将会把 SameValue 结点直接优化为 HeapConstant\<false\>,同时运行时 idx 值也是 false,两个结果相同,因此无法利用漏洞。
为什么运行时 idx 值也是 false 呢?因为当生成了 HeapConstant\<false\>之后,turboFan 就会直接优化变量 idx 的计算过程,直接取结果值 false:
我们希望,传入 -0 时(即传入参数 true),编译时SameValue 结点类型为 false,但运行时的结果为 true,这样就会有一个范围差,我们便可以利用它来计算出错误的范围。换句话说,我们需要让 turboFan 认为编译时的 SameValue 结点值为 0,但运行时的值是 1,这样我们才可以利用这个差值搭配乘法进行数组越界。
编译时的值:turboFan 执行 type 时所确认的值/范围,即静态分析时确定的数值。
运行时的值,终端调用 v8 执行 JS 程序时最终计算出的值。
因此,我们就必须禁止 turboFan 为 SameValue 结点生成 HeapConstant\<false\>结点,也就是说我们就必须在执行 simplified lowering 前的所有 ConstantFoldingReducer 时,不精确计算出 SameValue 的类型,即推迟该节点被 type 为 HeapConstant 的时机至执行完所有 ConstantFoldingReducer 之后。否则一旦出现 HeapConstant,则运行时的 idx 变量值就固定为该 HeapConstant,不会再重新计算。
那么,我们该让 SameValue 在什么时候被精确 type 呢?我们先看一下整个 pipeline 中运行 typer 的地方有哪些:
- TyperPhase 阶段
- LoadEliminationPhase 阶段中的 TypeNarrowingReducer 函数
- SimplifiedLoweringPhase 阶段中的 UpdateFeedbackType 函数
后两种是通过以下宏定义来调用 typer(咋一看还没认出来):
switch (node->opcode()) { #define DECLARE_CASE(Name) \ case IrOpcode::k##Name: { \ new_type = op_typer_.Name(input0_type, input1_type); \ break; \ } SIMPLIFIED_NUMBER_BINOP_LIST(DECLARE_CASE) #undef DECLARE_CASE // ... }
而 ConstantFoldingReducer 出现在 TypedLoweringPhase
和 LoadEliminationPhase
。因此我们只能让 SameValue 在 SimplifiedLoweringPhase 阶段被精确 type。
但需要注意的是,TypedOptimization in TypedLoweringPhase 将会对 SameValue 进行一次 reduce 操作。我们必须阻止它将 SameValue 结点优化成 ObjectIsMinusZero 结点,因为该结点将不会在 simplifedLoweringPhase 中进行 type(只会进行节点替换,替换成 Int32Constant)。
综合上面的要求,我们不能让 turboFan 在 EscapeAnalysisPhase 之前的 Phase 中,确认出 SameValue 的第二个 操作数类型为 MinusZero。因此,就需要引入一点点 EscapeAnalysis 的内容 (完整内容请查阅 Escape-Analysis-in-V8):
简单来说,EscapeAnalysis 可以但不限于将一个 LoadField 操作转换成一个栈变量读取操作。这样,在 EscapeAnalysisPhase 之前的 Phase,由于 LoadField 结点的存在,自然就无法获取到对应值的类型。因此笔者一开始将 Poc 修改为如下:
function foo(trigger) {
let obj = { a: -0 }; // Escape Analysis 特供1
let wrongNum = (trigger ? -0 : 0) - 0;
let idx = Object.is(wrongNum, obj.a);
return idx + 1;
}
// Escape Analysis 特供2
for(let a = 0; a < 2; a++)
foo(false);
%OptimizeFunctionOnNextCall(foo);
console.log(foo(true)); // expected: true, got: false
需要注意的是,Escape Analysis 对函数的 type feedback有一定的要求。如果目标函数只运行了一次,那么 escape analysis 分析效果非常的差,基本上无法分析出任何有用的东西,包括刚刚说的 LoadField 替换也无法完成。因此必须在优化前多执行几次目标函数。
同时,Escape Analysis 的目标对象,必须有个修饰符 let / var,否则无法替换 LoadField 结点,这其中主要是因为作用域的关系。
但实际调试发现, LoadField 结点的替换将会被 LoadElimination( 位于 LoadEliminationPhase) 截胡。也就是说,在 LoadEliminationPhase 时,obj.a 就会被替换成 -0。相关代码如下:
Reduction LoadElimination::ReduceLoadField(Node* node) {
FieldAccess const& access = FieldAccessOf(node->op());
Node* object = NodeProperties::GetValueInput(node, 0);
Node* effect = NodeProperties::GetEffectInput(node);
Node* control = NodeProperties::GetControlInput(node);
AbstractState const* state = node_states_.Get(effect);
if (state == nullptr) return NoChange();
if (access.offset == HeapObject::kMapOffset &&
access.base_is_tagged == kTaggedBase) {
// ...
} else {
int field_index = FieldIndexOf(access);
if (field_index >= 0) {
if (Node* replacement = state->LookupField(object, field_index)) {
// Make sure we don't resurrect dead {replacement} nodes.
if (!replacement->IsDead()) {
// Introduce a TypeGuard if the type of the {replacement} node is not
// a subtype of the original {node}'s type.
if (!NodeProperties::GetType(replacement)
.Is(NodeProperties::GetType(node))) {
Type replacement_type = Type::Intersect(
NodeProperties::GetType(node),
NodeProperties::GetType(replacement), graph()->zone());
// 建立新结点
replacement = effect =
graph()->NewNode(common()->TypeGuard(replacement_type),
replacement, effect, control);
// type 设置
NodeProperties::SetType(replacement, replacement_type);
}
// 结点替换
ReplaceWithValue(node, replacement, effect);
return Replace(replacement);
}
}
state = state->AddField(object, field_index, node, access.name, zone());
}
}
// ...
return UpdateState(node, state);
}
但 LoadEliminationPhase 中存在 ConstantFoldingReducer,因此最终 SameValue 结点还是会被替换成 HeapConstant。所以我们还是必须想办法绕过 LoadElimination 的优化,进入 EscapeAnalysis 中的优化。
折腾了相当长的时间,终于找到了绕过的方法,以下是修改后的 PoC,与之前相比,加了一行略微奇怪的 console.log 函数调用:
这个绕过方法是蒙出来的,把代码改复杂一点有时可以非常玄学的绕过某些优化。
function foo(trigger) {
let obj = { a: -0 }; // Escape Analysis 特供1
let wrongNum = (trigger ? -0 : 0) - 0;
console.log(obj.a = -0 ); // 绕过 LoadElimination 特供
let idx = Object.is(wrongNum, obj.a);
return idx + 1;
}
// Escape Analysis 特供2
for(let a = 0; a < 2; a++)
foo(false);
%OptimizeFunctionOnNextCall(foo);
console.log(foo(true)); // expected: true, got: false
因此我们便可以绕过LoadElimination:
在 EscapeAnalysisPhase 完成之后,彻底完成所有的基础工作:
之后笔者稍微修改了一下代码,添加上数组访问操作,看看能否成功优化 checkbounds 结点(原先的代码只是获取索引值):
function foo(trigger) {
let arr = [0.1, 0.2, 0.3, 0.4];
let obj = { a: -0 }; // Escape Analysis 特供1
let wrongNum = (trigger ? -0 : 0) - 0;
console.log(obj.a); // 绕过 LoadElimination 的特供语句
let idx = Object.is(wrongNum, obj.a);
return arr[idx * 1337]; // 试着越界
}
// Escape Analysis 特供2
for(let a = 0; a < 2; a++)
foo(false);
%OptimizeFunctionOnNextCall(foo);
console.log(foo(true));
观察 turbolizer,可以发现 checkbounds 结点被成功优化:
编译生成的汇编代码貌似也没什么问题:
Builtin_SameValue 的函数调用规范:%rdx 和 %rax 分别为左右两个操作数。
看上去应该可以成功越界读取,但实际执行时发现读取出的仍然是索引值为0的数组元素(心态崩了TAT)。
笔者动态调试了一下编译后 JS 函数的汇编代码,发现变量 wrongNum 被截断成整型,之后与 0x1 进行比较:
使用
--trace-turbo
参数 结合 turbolizer ,即时查看编译后函数的内存地址;同时搭配内置函数%SystemDebug()
,便于调试。
而这实际上是 ChangeInt31ToTaggedSigned 结点的锅:
由于这个 ChangeInt31ToTaggedSigned 结点在 Simplified Lowering 阶段中生成,不可优化,因此 exp 编写就没办法继续下去,只能就此终止。
五、后记
- 该漏洞补丁的详细信息请查阅此处
Type OperationTyper::SpeculativeSafeIntegerSubtract(Type lhs, Type rhs) { Type result = SpeculativeNumberSubtract(lhs, rhs); // If we have a Smi or Int32 feedback, the representation selection will // either truncate or it will check the inputs (i.e., deopt if not int32). // In either case the result will be in the safe integer range, so we // can bake in the type here. This needs to be in sync with // SimplifiedLowering::VisitSpeculativeAdditiveOp. - return result = Type::Intersect(result, cache_.kSafeInteger, zone()); + return Type::Intersect(result, cache_.kSafeIntegerOrMinusZero, zone()); }
void VisitSpeculativeIntegerAdditiveOp(Node* node, Truncation truncation, SimplifiedLowering* lowering) { // ... Type left_feedback_type = TypeOf(node->InputAt(0)); Type right_feedback_type = TypeOf(node->InputAt(1)); // Handle the case when no int32 checks on inputs are necessary (but // an overflow check is needed on the output). Note that we do not - // have to do any check if at most one side can be minus zero. - if (left_upper.Is(Type::Signed32OrMinusZero()) && + // have to do any check if at most one side can be minus zero. For + // subtraction we need to handle the case of -0 - 0 properly, since + // that can produce -0. + Type left_constraint_type = + node->opcode() == IrOpcode::kSpeculativeSafeIntegerAdd + ? Type::Signed32OrMinusZero() + : Type::Signed32(); + if (left_upper.Is(left_constraint_type) && right_upper.Is(Type::Signed32OrMinusZero()) && (left_upper.Is(Type::Signed32()) || right_upper.Is(Type::Signed32()))) { VisitBinop(node, UseInfo::TruncatingWord32(), MachineRepresentation::kWord32, Type::Signed32()); } else { // ... } // ... }
- 漏洞修复后,原先 Poc 执行的 turbolizer 视图如下:
可以看到,SpeculativeSafeIntegerSubtra 的 Type 包含了 MinusZero 这种类型,因此下面的 SameValue 的类型也不再固定为 false, 而是不确定的 Boolean。