chrome cve-2020-16040的一些分析
CVE-2020-16040
前言
这篇文章是我翻译(有一些地方删了或加了点自己的话)的,原文章:https://faraz.faith/2021-01-07-cve-2020-16040-analysis/
由于本人水平有限,不能保证翻译的准确,有疑惑的地方可以折回去看原文章,作者写的是真的细致,orz
漏洞成因
对应issue
https://bugs.chromium.org/p/chromium/issues/detail?id=1150649
该issue也添加了相对应的regress.js:
poc
Test on v8 version 8.6.405
1 | function foo(a) { |
运行后结果:
1 | true |
foo(false)在优化前后的值发生了变化
没有优化前,调用foo(false),y会是0x7fffffff,z = (0x7fffffff + 1) | 0 即 -2147483648,z < 0 是true
优化后,从regress-1150649.js可以看到期望的值是true,但是我们输出出来的却是false,可以猜测是turbofan错误的认为(y + 1)|0;
没有发生溢出,但是实际上溢出了
patch
查看官方的补丁:
1 | diff --git a/src/compiler/simplified-lowering.cc b/src/compiler/simplified-lowering.cc |
漏洞是发生在SimplifiedLowering阶段的VisitSpeculativeIntegerAdditiveOp(Node* node, Truncation truncation,SimplifiedLowering* lowering)
函数,从patch代码中的注释里可以看出是溢出的问题
疑问
为了理解漏洞是如何触发的,我们需要弄清楚以下几个问题:
- 啥叫“static type”,为什么要在一开始widened变量y的“static type”,这是怎么实现的
- 为什么要在一开始要使得y的类型反馈是
SignedSmall
,这是咋收集的 - 为啥z要为(y+1) | 0,为啥会有一个或0的操作
Unpatch vs Patch Turbolizer graphs
先来看看最直观的turbolizer graphs
在simplified lowering阶段前:
simplified lowering阶段后
unpatch:
patch:
最明显的就是Word32Or后的节点,未修补前是Uint32LessThan,修补后是Int32LessThan
生成Uint32LessThan说明TurboFan认为z<0是两个无符号整数的比较,并未注意到整数溢出的可能性
The Simplified Lowering Phase
Simplified Lowering Phase 的核心代码实现在src/compiler/simplified-lowering.cc,查看src/compiler/pipeline.cc里的PipelineImpl::OptimizeGraph
函数,你可以看到Simplified Lowering Phase
是在Escape Analysis Phase
之后(从turbolizer的下拉框里也可看出来):
1 | bool PipelineImpl::OptimizeGraph(Linkage* linkage) { |
最后:
1 | void Run(SimplifiedLowering* lowering) { |
GenerateTraversal
函数会从End节点开始前序遍历整个graph,把节点存放到traversal_nodes_成员里
接着,三个子阶段会被依此调用,在运行的命令行参数中加上--trace-representation
对调试这三个子阶段有很大的帮助
RunPropagatePhase
在这个阶段,traversal_nodes_会被逆序遍历,也就是说我们会从End
节点开始:
1 | for (auto it = traversal_nodes_.crbegin(); it != traversal_nodes_.crend(); |
“truncations” 会从End
开始传播。
Truncations 可以认为是节点的一个标签,它的传播依赖于具体的条件(如节点的类型),所以有可能不会传播到其它节点。Truncations 代表着一个节点被限制为特定的”representation“ ,在UseInfo类的定义注释中,可以看到对truncations的解释:
1 | // src/compiler/representation-change.h |
以下是TruncationKind枚举定义的truncations:
1 | // src/compiler/representation-change.h |
MachineRepresentation枚举定义了一系列的representation:
1 | // src/codegen/machine-type.h |
NodeInfo
对象存放了一个节点的representation,truncation和其它一些必要的信息。每个节点都有一个NodeInfo对象与它相关联,可以通过GetInfo(node)来得到与node相关联的NodeInfo对象
1 | // src/compiler/simplified-lowering.cc |
在Simplified Lowering 阶段开始前,graph中的所有节点的NodeInfo里的成员变量都会被初始化为默认值(在RepresentationSelector的构造函数中),这些字段在每个子阶段完成时会被更新。
我们跟着poc中的代码来跟踪SpeculativeSafeIntegerAdd
节点(bug出现的节点),添加--trace-representation
到d8的命令行参数中,可以清楚的看到truncation的传播:
1 | --{Propagate phase}-- |
可以看到SpeculativeNumberBitwiseOr
传播了 Word32 truncation
给它的前两个输入, 其中一个就是SpeculativeSafeIntegerAdd
节点
RunPropagatePhase
函数会从末尾开始遍历traversal_nodes_,并对每个节点调用PropagateTruncation,而PropagateTruncation最终又会调用到VisitNode<PROPAGATE>
:
1 | // Backward propagation of truncations to a fixpoint. |
VisitSpeculativeIntegerAdditiveOp
对于SpeculativeSafeIntegerAdd节点,VisitNode<PROPAGATE>(node, info->truncation(), nullptr);
会调用到VisitSpeculativeIntegerAdditiveOp
:
1 | case IrOpcode::kSpeculativeSafeIntegerAdd: |
left_upper和right_upper是SpeculativeSafeIntegerAdd
节点两个输入的type,我们可以直接在turbolizer中看他们的type:
- left_upper,Phi节点的Type为UnionType,值为NaN | Range(-1, 2147483647)
- right_upper,NumberConstant[1]节点Type为RangeType,值为Range(1, 1)
由于[1]处的判断需要left_upper和right_upper是type_cache_->kAdditiveSafeIntegerOrMinusZero(UnionType Type::MinusZero() | Range(-4503599627370496, 4503599627370496)),但是很明显left_upper也就是Phi节点的Type不符合要求,因为它的Type里包含了一个NaN
这里第一个疑问就得到了解答,如果不对变量y的static type进行widen,流程就会进入到[1]处,这样就不能触发漏洞代码了。也可以推测出static type应该表示的是节点的Type(在Typer阶段进行typed)。
接下来,流程会来到:
1 | template <Phase T> |
hint存放着SpeculativeSafeIntegerAdd节点的NumberOperationHint,紧接着的DCHECK告诉了我们如果要到达这里,我们需要kSignedSmall或者kSigned32的type feedback,这也就是问题2(为啥一开始要使得y的类型反馈是SignedSmall
)的答案。
如果我们把poc中的收集SignedSmall
类型反馈的代码去掉,并跟踪生成的turbolizer图:
可以看到生成的是SpeculativeNumberAdd而不是SpeculativeSafeIntegerAdd,这样我们就无法触发漏洞啦
所以在一开始收集SignedSmall
类型反馈很重要,决定是使用SpeculativeNumberAdd节点还是SpeculativeSafeIntegerAdd节点的代码在src/compiler/js-type-hint-lowering.cc下的:
1 | const Operator* SpeculativeNumberOp(NumberOperationHint hint) { |
该函数是在BytecodeGraphBuilder阶段visit AddSmi字节码的时候调用的,以下是栈回溯:
feedback是在Ignition 执行AddSmi字节码的时候收集的:
1 | TNode<Object> BinaryOpAssembler::Generate_AddWithFeedback( |
这个函数应该是来自AddSmi的handler:
1 | // AddSmi <imm> |
总结一下就是,为了插入SpeculativeSafeIntegerAdd节点,所以需要在一开始收集SignedSmall的类型反馈,该节点用于在Simplified Lowering阶段的VisitSpeculativeIntegerAdditiveOp函数触发漏洞
在此基础上,来看VisitSpeculativeIntegerAdditiveOp函数的剩下部分:
1 | template <Phase T> |
[2]处的判断会因为left_upper存在NaN而是失败,故不会进入该分支
在继续之前,需要解释一下什么是“identifying zeros”和“distinguishing zeros”和它们的区别
- identifying zeros,这是大多数truncations的默认值
- distinguishing zeros,这个选项似乎是在对一个节点进行截断时使用的,因为有些操作需要区分0和-0
接下来对left_identify_zeros进行赋值,因为我们是第一次visit该节点,所以truncation.identify_zeros();
返回的是默认的值,也就是kIdentifyZeros。紧接着创建两个输入节点的UseInfo,第一个参数hint是SignedSmall:
1 | UseInfo CheckedUseInfoAsWord32FromHint( |
Truncation::Any(identify_zeros)代表SpeculativeSafeIntegerAdd节点并未传播truncation给它的两个输入节点,这与--trace-representation
的输出一致:
1 | visit #43: SpeculativeSafeIntegerAdd (trunc: truncate-to-word32) |
VisitBinop
最后调用VisitBinop函数:
1 | // Helper for binops of the R x L -> O variety. |
EnqueueInput函数主要更新节点的use_info,并检查节点是否已经被visit过了,如果已经visit过了,就会加入到revisit_queue_
,加入到revisit_queue_
的节点后续还会再visit一次
VisitBinop函数最后调用SetOutput函数来设置SpeculativeSafeIntegerAdd节点的restriction_type为Type::Signed32()
,这里值得注意的是patch后的代码会将SpeculativeSafeIntegerAdd节点的restriction_type设置为Type::Any()
这个阶段对每个节点都会进行visit,并对truncation进行传播,如果revisit_queue_
不为空,就再次对revisit_queue_
里的节点进行一次visit,直到revisit_queue_
为空,这样PropagatePhase就结束了,进入到下一个子阶段Retype phase
RunRetypePhase
该阶段,会从头开始遍历traversal_nodes_
里的节点,并且会更新每一个节点的FeedbackType。
让我们来跟踪SpeculativeSafeIntegerAdd节点的执行,首先查看这个阶段--trace-representation
的输出:
1 | --{Retype phase}-- |
在visit SpeculativeSafeIntegerAdd节点前,它的两个输入Phi节点和NumberConstant已经被retype了,并且我们可以注意到它的Static type和Feedback type不一致(这个Feedback type是在Retype阶段更新的)
我们也可以注意到Phi节点的output representation被设置成了kRepFloat64(这是因为NaN type的缘故),NumberConstant节点的output representation被设置成了kRepTaggedSigned(它被当作是uncompressed Smi)
在Retype SpeculativeSafeIntegerAdd前,需要注意的是它的restriction type 是Type::Signed32()
(见Propagate阶段最后的VisitBinop),它的output representation是kWord32
以下是RunRetypePhase的代码:
1 | // Forward propagation of types from type feedback to a fixpoint. |
RetypeNode函数首先会标记该节点已经visited了,接着调用UpdateFeedbackType函数来更新其feedback type:
UpdateFeedbackType
1 | bool UpdateFeedbackType(Node* node) { |
[2]处是判断节点(Phi节点除外)的输入节点是否都已经retype过了
接下来声明了两个Type类型的变量,type来自当前节点的feedback_type(由于这是我们第一次visit该节点,所以为none),new_type来自当前节点的type,也就是我们在Turbolizer graph上看到的类型Range(0,2147483648)
再接着声明了input0_type和input1_type:
- input0_type:
NaN | Range(-1, 2147483647)
-Phi
节点 - input1_type:
Range(1,1)
-NumberConstant[1]
节点
接着流程就来到了一个很大的switch case,上面只截取了我们会走到的部分,new_type的赋值:
1 | new_type = |
其中OperationTyper::SpeculativeSafeIntegerAdd(input0_type, input1_type)
会返回Range(0,2147483648)
info->restriction_type()
是Type::Signd32()
,也就是Range(-2147483648.2147483647)
这两个范围的交集就是Range(0,2147483647)
,所以最后new_type为Range(0,2147483647)
后续的new_type = Type::Intersect(GetUpperBound(node), new_type, graph_zone());
并不影响new_type,因为GetUpperBound(node)
返回的是Range(0,2147483648)
接着因为节点的static_type和feedback_type不一致,--trace-representation
把它们都打印了出来:
1 | void PrintNodeFeedbackType(Node* n) { |
可以看出,由于前面不正确的设置SpeculativeSafeIntegerAdd节点的restriction_type,导致后面计算Feedback type错误
RunLowerPhase
RunLowerPhase从头开始遍历traversal_nodes_
里的节点,并对每个节点都调用VisitNode<LOWER>
函数,该函数会基于前面两个子阶段收集到的信息将当前的节点lower成更lower level的节点(个人理解就是更接近机器表示)
例如SpeculativeSafeIntegerAdd节点会被lower成Int32Add节点(因为前面的bug),但是实际上是要被lower成CheckedInt32Add
的
这里也解释了为什么NumberLessThan
节点会被替换成Uint32LessThan
。由于Turbofan认为不会有溢出会发生,所以它认为不需要去处理有符号整数的情况:
1 | // src/compiler/simplified-lowering.cc |
- lhs_type是SpeculativeNumberBitwiseOr节点的feedback_type:
Range(0, 2147483647)
- rhs_type是NumberConstant[0]节点:
Range(0,0)
由于lhs_type和rhs_type都是unsigned,所以这里选择了Uint32
的比较操作
你可能会注意到SpeculativeNumberBitwiseOr节点的 feedback type是Range(0, 2147483647)
,而在turbolizer graph的Simplified Lowering阶段的表示中,Word32Or节点的type还是Range(INT_MIN, INT_MAX)
。这是因为节点的static types并没有被更新为feedback type,所以在turbolizer graph的表示并没有变换,这进一步证明了在turbolizer graph中看到的就是节点的static types
最后一个问题
还有一个问题我们没有解决,就是为什么要在后面或0
首先我们先去掉这个或0的操作,在查看生成的turbolizer graph:
可以看到在TypedLowering阶段SpeculativeNumberLessThan节点被替换成了false节点
这是因为在TypedLowering阶段,turbofan认为SpeculativeNumberLessThan节点为false(从下图来说就是Range(0,INT_MAX)是不会小于0的):
所以这个或0的操作应该是避免SpeculativeSafeIntegerAdd后续节点被常量折叠,使得SpeculativeSafeIntegerAdd节点错误的feedback type能继续影响后续的节点
但是去掉了这个或0的操作,在Simplified Lowering阶段漏洞还是触发了的,这个可以通过--trace-representation
来看出:
1 | --{Retype phase}-- |
这里SpeculativeSafeIntegerAdd的Static type和 Feedback type还是不一致
参考链接
https://faraz.faith/2021-01-07-cve-2020-16040-analysis/
https://github.com/singularseclab/Slides/blob/main/2021/chrome_exploitation-zer0con2021.pdf
https://bugs.chromium.org/p/chromium/issues/detail?id=1150649