Summary
Use of Decimals in MathMasters::sqrt() instead of Hexadecimals representation results in nonequivalence leading to loss of precision.
Vulnerability Details
The MathMasters imlementation is based on the equivalent soladay version. However, the MathMaster implementation uses decimals to represent the constants, while the soladay implementation uses hexadecimals. In Ethereum's Solidity programming language, hexadecimal notation (base-16) is commonly used for specifying literals and values, especially when working with low-level constructs like assembly. Hexadecimal notation is more aligned with the way data is represented in the Ethereum Virtual Machine (EVM). While you can use decimal literals in high-level Solidity code, when writing assembly code in Solidity, it's generally expected to use hexadecimal notation for representing values and memory locations. In short, the EVM is expecting to receive hexadecimal values and MathMaster sqrt function is submitting decimal values instead. This results in a loss of precision in the MathMasters implementation.
POC
Step 1: As a reference implementation refer to solady sqrt: https://github.com/Vectorized/solady/blob/8919f61d14a5e7b32f3d809c9f5fe3ea2ebcbc50/src/utils/FixedPointMathLib.sol#L615
Step 2: Notice that that bottom half of both sqrt implementations (MathMasters and Solady) are identical. Let's place the identical code in a helper function.
function _identicalCodeSqrt(uint256 x, uint256 z) public pure returns (uint256 ret) {
assembly {
z := shr(1, add(z, div(x, z)))
z := shr(1, add(z, div(x, z)))
z := shr(1, add(z, div(x, z)))
z := shr(1, add(z, div(x, z)))
z := shr(1, add(z, div(x, z)))
z := shr(1, add(z, div(x, z)))
z := shr(1, add(z, div(x, z)))
ret := sub(z, lt(div(x, z), z))
}
}
Step 3: We can then create versions of the two sqrt functions with calls to the identical code.
function sharedSoladySqrt(uint256 x) public pure returns (uint256 z) {
assembly {
z := 181
let r := shl(7, lt(0xffffffffffffffffffffffffffffffffff, x))
r := or(r, shl(6, lt(0xffffffffffffffffff, shr(r, x))))
r := or(r, shl(5, lt(0xffffffffff, shr(r, x))))
r := or(r, shl(4, lt(0xffffff, shr(r, x))))
z := shl(shr(1, r), z)
z := shr(18, mul(z, add(shr(r, x), 65536)))
}
z = _identicalCodeSqrt(x, z);
}
function sharedMathMastersSqrt(uint256 x) internal pure returns (uint256 z) {
assembly {
z := 181
let r := shl(7, lt(87112285931760246646623899502532662132735, x))
r := or(r, shl(6, lt(4722366482869645213695, shr(r, x))))
r := or(r, shl(5, lt(1099511627775, shr(r, x))))
r := or(r, shl(4, lt(16777002, shr(r, x))))
z := shl(shr(1, r), z)
z := shr(18, mul(z, add(shr(r, x), 65536)))
}
z = _identicalCodeSqrt(x, z);
}
Step 4: We can then conduct a fuzz test to confirm that the two modified sqrt functions along with the helper function are still working correctly.
function test_fuzz_sharedSqrtFunctions(uint32 fuzzedSolution, uint32 fuzzedRandomNum) public {
uint256 solution = fuzzedSolution;
vm.assume(solution > 0);
uint256 randomNum = fuzzedRandomNum;
uint256 squaredPlusRemainder = solution * solution + (randomNum % solution);
uint256 mathMastersOuput = Base_Test.sharedMathMastersSqrt(squaredPlusRemainder);
uint256 soladyOutput = Base_Test.sharedSoladySqrt(squaredPlusRemainder);
assertEq(solution, mathMastersOuput);
assertEq(solution, soladyOutput);
}
Step 5: Once we've verified that the adjusted square root functions are functioning as intended, we can disregard the duplicated code and focus on testing the essential differences in the remaining code for equivalence. To begin, let's create the two square root functions, each containing only their core code.
function coreSoladySqrt(uint256 x) public pure returns (uint256 z) {
assembly {
z := 181
let r := shl(7, lt(0xffffffffffffffffffffffffffffffffff, x))
r := or(r, shl(6, lt(0xffffffffffffffffff, shr(r, x))))
r := or(r, shl(5, lt(0xffffffffff, shr(r, x))))
r := or(r, shl(4, lt(0xffffff, shr(r, x))))
z := shl(shr(1, r), z)
z := shr(18, mul(z, add(shr(r, x), 65536)))
}
}
function coreMathMasterSqrt(uint256 x) internal pure returns (uint256 z) {
assembly {
z := 181
let r := shl(7, lt(87112285931760246646623899502532662132735, x))
r := or(r, shl(6, lt(4722366482869645213695, shr(r, x))))
r := or(r, shl(5, lt(1099511627775, shr(r, x))))
r := or(r, shl(4, lt(16777002, shr(r, x))))
z := shl(shr(1, r), z)
z := shr(18, mul(z, add(shr(r, x), 65536)))
}
}
Step 6: We can now test the two core sqrt functions for equivalence using Halmos.
function check_coreSqrtFunctions() public {
uint256 x = svm.createUint256("x");
uint256 coreMathMastersOuput = Base_Test.coreMathMasterSqrt(x);
uint256 coreSoladyOutput = Base_Test.coreSoladySqrt(x);
assertEq(coreMathMastersOuput, coreSoladyOutput);
}
Step 7: Having received a counter example from Halmos, let's test it using Foundry's Forge tool. Run it using "forge test --match-test test_poc_sqrt -vv"
function test_poc_sqrt() public {
uint256 x = 105311293498665291426722909308999732236070323463302251608708546560;
uint256 mathMastersOuput = Base_Test.coreMathMasterSqrt(x);
uint256 soladyOutput = Base_Test.coreSoladySqrt(x);
console.log("mathMastersOuput: ", mathMastersOuput);
console.log("soladyOutput: ", soladyOutput);
assertEq(mathMastersOuput, soladyOutput);
}
Impact
The loss of precision can lead to incorrect results when using the MathMasters implementation.
Tools Used
Recommendations
Use hexadecimals to represent the constants in the MathMasters implementation.
- let r := shl(7, lt(87112285931760246646623899502532662132735, x))
- r := or(r, shl(6, lt(4722366482869645213695, shr(r, x))))
- r := or(r, shl(5, lt(1099511627775, shr(r, x))))
- r := or(r, shl(4, lt(16777002, shr(r, x))))
+ let r := shl(7, lt(0xffffffffffffffffffffffffffffffffff, x))
+ r := or(r, shl(6, lt(0xffffffffffffffffff, shr(r, x))))
+ r := or(r, shl(5, lt(0xffffffffff, shr(r, x))))
+ r := or(r, shl(4, lt(0xffffff, shr(r, x))))