code-423n4 / 2023-11-panoptic-findings

0 stars 0 forks source link

` validateCallback()` is vulnerable to a birthday attack #247

Open c4-bot-7 opened 10 months ago

c4-bot-7 commented 10 months ago

Lines of code

https://github.com/code-423n4/2023-11-panoptic/blob/f75d07c345fd795f907385868c39bafcd6a56624/contracts/libraries/CallbackLib.sol#L28-L50

Vulnerability details

Preface

This is likely to be a contentious issue, so the reviewer will deviate from the usual submission template to make a clear case for this issue.

The birthday paradox refers to the counterintuitive fact that in a room of only 23 people there is a probability exceeding 50% that any two people share a birthday.

ELI5 (as much for my own benefit as for the reader's.)

Where there are random numbers generated there is a much greater likelihood that there will be any number that is drawn at least twice, than a single predetermined number. In other words, for a set of 0 <= x <= 99 possible numbers, if we only make 10 draws (and not excluding this number from subsequent draws) looking for the number x, then there is for each draw a 0.01 probability to get x, and so for a sample of 10 draws the probability of finding x is 0.1.

But, the chance of drawing any two values that are the same, in those 10 draws is about 0.39. For 25 draws the odds are about 0.95. See more about this calculation and this attack vector here.

Hashing

For the keccak256 hashing used in the EVM, it means that there are 2^256 possible hashes for all possible inputs. Of course, all the possible inputs are much greater than 2^256. This brings us to the pigeonhole problem (which we run into when we cast values from uint256 to uint64, as an example). To simplify, where there are functions that have a greater number of valid inputs than the number possible outcomes, then logically there must be multiple inputs that have the same output.

Consider this code snippet from the contest, which follows a well-known pattern:

            address(
                uint160(
                    uint256(
                        keccak256(
                            abi.encodePacked(
                                bytes1(0xff),
                                factory,
                                keccak256(abi.encode(features)),
                                Constants.V3POOL_INIT_CODE_HASH
                            )
                        )
                    )
                )
            )

We know that keccak256 provides a 256-bit result, but then that 256-bit result is truncated to a 160-bit value. The preceeding 96-bit value is lost. Meaning that for every 160-bit address derived by the code above, there would have been 2^96 possible values that could have led to that address. For our N of 160, this means an attacker would need to compute sqrt(2^160), which is 2^80, to be likely to find a collision.

Impact

The validateCallback function validates that a sender address conforms to the expected counterfactual address for a Uni-V3 pool given the specified features and the appropriate FACTORY address.

The check assumes that the only way a sender address can be derived is through deployment via the V3 Factory contract, which uses counterfactual deployments. This is problematic, given that the uniswapV3MintCallback does not check that a pool address exists, only that it conforms to the address derivation rules from CallBackLib::validateCallback().

Given the pigeonhole problem, and an attacker's ability to generate inputs by varying the parameters supplied in the data input of the uniswapV3Callback, it is theoretically feasible that an attacker can generate some combination of token0, token1, and fee, where the token0 is a valid address for a token to be stolen (e.g. USDC or WETH), and the token1 and fee can be varied arbitrarily to create addresses. The attacker would then generate EOA (or deterministic contract) addresses with varying parameters until they generated an address that matches ANY of the possible addresses generated by part 1.

The impact would be that the attacker then deploys the EOA which has been found to collide with a given set of pool parameters. The attacker would then be able to call uniswapV3MintCallback directly, passing in the seed values used to create the collision in the data parameters. Because this will ensure the validateCallback check passes, the attacker would be able to specify ANY address that has given approval to the SemiFungiblePositionManager and transfer the approved amount of token0 to the attacker address. This is problematic as the SemiFungiblePositionManager is likely to accrue significant approvals, as it needs approvals from users to safeTransferFrom from the user to the uniswapV3Pool when creating positions.

Proof of Concept

Consider the following: 1) There are 2^160 possible EVM addresses 2) There are 2^256 possible hash outputs for the keccak256 function

Therefore, there must be hash outputs where the last 160 bits of the 256 bit hash output are the same. There are 2^96 different keccak256 hash outputs that would share the same last 160 bits.

The odds of person creating an address using counterfactual deployment (or normal EOA generation) and so create an account with the same address as a specific target address in normal on-chain use are so small that it's practically impossible.

But the probability of getting a usable address from address deployment is the probability of ANY address derived using the method in the validateCallback function being the same as ANY address (EOA or deterministic) derived by the attacker through some seed values. This greatly reduces the amount of compute necessary, but it is still not trivial.

The attacker would need to compute at least two sets of 2^80 - one set for the EOA and the other for the combination of PoolFeatures that would clash. With that there is a 39% chance of a clash.

As the submission by 0x52 showed, drawing 2^81 samples would raise the success rate to 86%.

A note on feasibility

As the feasibility of this attack is probably the greatest limiting factor, it would be wise to get some real-world bounds for this.

Please note that these are rough estimates to provide context

1) Some Antminers can reach 255 terahashes/second. 2) It costs around USD 4200 for one such machine 3) It has an energy requirement of 5.4kW 4) Per the above there would need to be at least two sets of 2^81 hashes.

Given these parameters it would take a single antminer (at 255Th/s) about 300 years to generate the first set. (2^81 hashes at 255e12 hashes generated per second). So to do it in one year would require about 300 antminers, thus a capital outlay of USD 1.26 million. Then you need the second set, so it would take another year for a total of two sets. In terms of power you would need 5.4kw (single machine power draw) * 300 (number of miners) * 0.12 (cost per kwh) * 8784 (hours in a year) * 2 (using here the average cost to businesses of 0.12 USD per kWh), which gives about USD 3 415 219.2 of energy costs in total.

So here we have an attack that will cost about 4.6 million dollars in capex + energy over a two year period.

The SemifungiblePositionManager requires token approvals from users to allow it to safeTransferFrom the payer to the uniswapV3Pool and would have accrued enough approvals within two years that should be multiples of this cost, thus the attack is expected to be profitable.

The reviewer has here not taken into account the requirement of a reliable power supply of 1.62MWh that 300 antminers running in concert would need, nor the other operational expenses related to such a massive enterprise, nor the storage problem of storing 2^81 outputs to check the second set against. Importantly, it is assumed that the machines used are hashing at equivalent efficiencies for keccak256 hashes.

But the continued improvement of technology makes this more and more feasible within the next few years (there is already a new antminer slated for early 2024 release that is expected to reach 335TH/s without greater power draw).

Considering the broader context of hacks within the DeFi industry and known nation-state actors operating in the space, and, considering that the fix for this is also relatively simple, it seems a prudent idea to add a fix to mitigate this issue that may become very real within the next decade.

Tools Used

Manual review.

Recommended Mitigation Steps

In the callback, obtain the fee and use that to query the FACTORY to ensure the msg.sender is a pool that has been deployed.

    function validateCallback(
        address sender,
        address factory,
        PoolFeatures memory features
    ) internal pure {
        // compute deployed address of pool from claimed features and canonical factory address
        // then, check against the actual address and verify that the callback came from the real, correct pool
-        if (
-            address(
-                uint160(
-                    uint256(
-                        keccak256(
-                            abi.encodePacked(
-                                bytes1(0xff),
-                                factory,
-                                keccak256(abi.encode(features)),
-                                Constants.V3POOL_INIT_CODE_HASH
-                            )
-                        )
-                    )
-                )
-            ) != sender
-        ) revert Errors.InvalidUniswapCallback();
+        if (sender != IFactory(factory).getPool(
+            features.token0,
+            features.token1,
+            features.fee
+        )) revert Errors.InvalidUniswapCallback();
    }

Assessed type

Invalid Validation

c4-sponsor commented 10 months ago

dyedm1 (sponsor) disputed

dyedm1 commented 10 months ago

valid, but dup #128

c4-judge commented 10 months ago

Picodes marked the issue as duplicate of #128

c4-judge commented 9 months ago

Picodes marked the issue as satisfactory

c4-judge commented 9 months ago

Picodes marked the issue as selected for report