Platform: Code4rena
Start Date: 01/04/2024
Pot Size: $120,000 USDC
Total HM: 11
Participants: 55
Period: 21 days
Judge: Picodes
Total Solo HM: 6
Id: 354
League: ETH
Rank: 5/55
Findings: 1
Award: $8,126.32
🌟 Selected for report: 1
🚀 Solo Findings: 1
🌟 Selected for report: Kalogerone
8126.3155 USDC - $8,126.32
https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/PanopticFactory.sol#L237 https://github.com/OpenZeppelin/openzeppelin-contracts/blob/0a25c1940ca220686588c4af3ec526f725fe2582/contracts/proxy/Clones.sol#L53
(NOTE: This report is very highly inspired from this past valid report. Necessary changes have been made to suit the Panoptic Protocol.)
The attack consists of two parts: Finding a collision, and actually draining the lending pool. We describe both here:
Note that in PanopticFactory::deployNewPool
, CREATE2
salt is user-supplied which is then passed to Clones::cloneDeterministic
:
function deployNewPool(address token0, address token1, uint24 fee, bytes32 salt) external returns (PanopticPool newPoolContract) { // sort the tokens, if necessary: (token0, token1) = token0 < token1 ? (token0, token1) : (token1, token0); . . . // This creates a new Panoptic Pool (proxy to the PanopticPool implementation) // Users can specify a salt, the aim is to incentivize the mining of addresses with leading zeros @> newPoolContract = PanopticPool(POOL_REFERENCE.cloneDeterministic(salt)); . . . }
function cloneDeterministic(address implementation, bytes32 salt) internal returns (address instance) { /// @solidity memory-safe-assembly assembly { // Cleans the upper 96 bits of the `implementation` word, then packs the first 3 bytes // of the `implementation` address with the bytecode before the address. mstore(0x00, or(shr(0xe8, shl(0x60, implementation)), 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000)) // Packs the remaining 17 bytes of `implementation` with the bytecode after the address. mstore(0x20, or(shl(0x78, implementation), 0x5af43d82803e903d91602b57fd5bf3)) @> instance := create2(0, 0x09, 0x37, salt) } require(instance != address(0), "ERC1167: create2 failed"); }
The address collision an attacker will need to find are:
Both sets of addresses can be brute-force searched because:
salt
is a user-supplied parameter. By brute-forcing many salt
values, we have obtained many different (undeployed) wallet accounts for (1). The user can know the address of the Panoptic Pool before deploying it, since as shown in the above code snippet, the result is deterministic.CREATE2
, and the salt
is in the attacker's control by definition.An attacker can find any single address collision between (1) and (2) with high probability of success using the following meet-in-the-middle technique, a classic brute-force-based attack in cryptography:
The feasibility, as well as detailed technique and hardware requirements of finding a collision, are sufficiently described in multiple references:
The hashrate of the BTC network has reached $6.5 x 10^{20}$ hashes per second as of time of writing, taking only just 31 minutes to achieve $2^{80}$ hashes. A fraction of this computing power will still easily find a collision in a reasonably short timeline.
Even given EIP-3607 which disables an EOA if a contract is already deployed on top, we show that it's still possible to drain the Panoptic Pool entirely given a contract collision.
Assuming the attacker has already found an address collision against an undeployed Panoptic Pool, let's say 0xCOLLIDED
. The steps for complete draining of the Panoptic Pool are as follow:
First tx:
0xCOLLIDED
.0xCOLLIDED
---> attacker wallet} for any token they want.selfdestruct
.Post Dencun hardfork, selfdestruct
is still possible if the contract was created in the same transaction. The only catch is that all 3 of these steps must be done in one tx.
The attacker now has complete control of any funds sent to 0xCOLLIDED
.
Second tx:
0xCOLLIDED
.The attacker has stolen all funds from the Panoptic Pool.
Address collision can cause all tokens of a Panoptic Pool to be drain.
While we cannot provide an actual hash collision due to infrastructural constraints, we are able to provide a coded PoC to prove the following two properties of the EVM that would enable this attack:
Here is the PoC, as well as detailed steps to recreate it:
Test
.Test.test()
with a salt of your choice, and record the returned address. The result will be:
Test.getAllowance()
for that address will return exactly APPROVE_AMOUNT.Test.getCodeSize()
for that address will return exactly zero.Test.test()
returns the same address as with the first run.Test.getAllowance()
for that address will return twice of APPROVE_AMOUNT.Test.getCodeSize()
for that address will still return zero.The provided PoC has been tested on Remix IDE, on the Remix VM - Mainnet fork environment, as well as testing locally on the Holesky testnet fork, which as of time of writing, has been upgraded with the Dencun hardfork.
Manual Review, Remix IDE
salt
used.block.timestamp
and block.number
combined with the user's salt
. Then the attacker, after they successfully found a hash collision, already has to execute the attack at a fixed block and probably conspire with the sequencer to ensure that also the time is fixed.Other
#0 - c4-judge
2024-04-25T21:41:38Z
Picodes marked the issue as primary issue
#1 - dyedm1
2024-04-26T20:42:13Z
Technically true, but the cost to do this is enormous (with a likely minimal return, given that deposits would first have to be solicited into that pool), and we can add safeguards on the frontend to prevent this kind of attack.
#2 - Picodes
2024-05-06T13:38:25Z
This report is worth medium severity to me, considering:
So it fulfills "hypothetical attack path with stated assumptions, but external requirements".
#3 - c4-judge
2024-05-06T13:38:32Z
Picodes marked the issue as selected for report
#4 - c4-judge
2024-05-06T13:38:34Z
Picodes marked the issue as satisfactory