QuantAMM

QuantAMM
49,600 OP
View results
Submission Details
Severity: high
Invalid

Plain salt is used to create pool. Vulnerability causes replay attack and allows bad actor to front-run pool creation and deployment.

Summary

In the QuantAMMWeightedPoolFactory contract, there's functions to create and deploy Pools with and without params. Both functions are using a salt to create a unique pool. However, the salt used to create pool is bare and plain that anyone can use to create very same pool.

Vulnerability Details

QuantAMMWeightedPoolFactory::createWithoutArgs:

function createWithoutArgs(NewPoolParams memory params) external returns (address pool) {
// @info: missing params sanitization
if (params.roleAccounts.poolCreator != address(0)) {
revert StandardPoolWithCreator();
}
LiquidityManagement memory liquidityManagement = getDefaultLiquidityManagement();
liquidityManagement.enableDonation = params.enableDonation;
// disableUnbalancedLiquidity must be set to true if a hook has the flag enableHookAdjustedAmounts = true.
liquidityManagement.disableUnbalancedLiquidity = params.disableUnbalancedLiquidity;
// @info: it's a create without args function, so the poolArgs is not needed
pool = _create(
abi.encode(
QuantAMMWeightedPool.NewPoolParams({
name: params.name,
symbol: params.symbol,
numTokens: params.normalizedWeights.length,
version: "version", // @info: hardcoded value, should be params.version or _poolVersion
updateWeightRunner: _updateWeightRunner,
poolRegistry: params.poolRegistry,
poolDetails: params.poolDetails
}),
getVault()
),
@> params.salt
);
QuantAMMWeightedPool(pool).initialize(
params._initialWeights,
params._poolSettings,
params._initialMovingAverages,
params._initialIntermediateValues,
params._oracleStalenessThreshold
);
_registerPoolWithVault(
pool,
params.tokens,
params.swapFeePercentage,
false, // not exempt from protocol fees
params.roleAccounts,
params.poolHooksContract,
liquidityManagement
);
}

QuantAMMWeightedPoolFactory::create:

function create(NewPoolParams memory params) external returns (address pool, bytes memory poolArgs) {
// @info: missing params sanitization
if (params.roleAccounts.poolCreator != address(0)) {
revert StandardPoolWithCreator();
}
LiquidityManagement memory liquidityManagement = getDefaultLiquidityManagement();
liquidityManagement.enableDonation = params.enableDonation;
// disableUnbalancedLiquidity must be set to true if a hook has the flag enableHookAdjustedAmounts = true.
liquidityManagement.disableUnbalancedLiquidity = params.disableUnbalancedLiquidity;
poolArgs = abi.encode(
QuantAMMWeightedPool.NewPoolParams({
name: params.name,
symbol: params.symbol,
numTokens: params.normalizedWeights.length,
version: "version", // @info: hardcoded value, should be params.version or _poolVersion
updateWeightRunner: _updateWeightRunner,
poolRegistry: params.poolRegistry,
poolDetails: params.poolDetails
}),
getVault()
);
@> pool = _create(poolArgs, params.salt);
QuantAMMWeightedPool(pool).initialize(
params._initialWeights,
params._poolSettings,
params._initialMovingAverages,
params._initialIntermediateValues,
params._oracleStalenessThreshold
);
_registerPoolWithVault(
pool,
params.tokens,
params.swapFeePercentage,
false, // not exempt from protocol fees
params.roleAccounts,
params.poolHooksContract,
liquidityManagement
);
}

It's a bare salt anyone can copy the salt and deploy the same pool. There should be a re-calculation of the hash with the addition of block.timestamp because the inherited create function does not hash the salt with block.timestamp. This creates a vulnerability related to front-running by a malicious msg.sender.

The issue arises because the msg.sender in the parent contract is always the same (QuantAMMWeightedPoolFactory), and the block.chainid remains unchanged. As a result, any malicious msg.sender can deploy the same pool using the same salt, block.chainid, and msg.sender. Adding block.timestamp to the salt ensures that the salt is unique and secure.

@balancer-labs/v3-pool-utils/contracts/BasePoolFactory.sol::BasePoolFactory::_create:

function _computeFinalSalt(bytes32 salt) internal view virtual returns (bytes32) {
@> return keccak256(abi.encode(msg.sender, block.chainid, salt));
}
function _create(bytes memory constructorArgs, bytes32 salt) internal returns (address pool) {
bytes memory creationCode = abi.encodePacked(_creationCode, constructorArgs);
@> bytes32 finalSalt = _computeFinalSalt(salt);
pool = Create2.deploy(0, finalSalt, creationCode);
_registerPoolWithFactory(pool);
}

Above is the parent _create function which calling _computeFinalSalt function to compute a final unique salt. Due to the pool factory, the msg.sender will always be the pool factory and in the mempool we will have salt that we can copy and precompute and replay.

Impact

  • Replay Attacks on different chains
    Without a unique salt, attackers could attempt to replay a pool creation transaction on different chains or environments.

  • Precomputation of pool addresses
    If an attacker can predict the pool address (because the salt is plain and deterministic), they might:
    Front-run the pool creation by deploying a malicious contract to the same address.
    Exploit the predictable address to manipulate on-chain interactions with the pool (e.g., feeding fake oracle data).

Tools Used

Manual review

Recommendations

We can use block.number and/or block.timestamp to enhance the final salt with an additional layer of security, ensuring its uniqueness.

We can do something like below:

QuantAMMWeightedPoolFactory::createWithoutArgs:

function createWithoutArgs(NewPoolParams memory params) external returns (address pool) {
if (params.roleAccounts.poolCreator != address(0)) {
revert StandardPoolWithCreator();
}
LiquidityManagement memory liquidityManagement = getDefaultLiquidityManagement();
liquidityManagement.enableDonation = params.enableDonation;
// disableUnbalancedLiquidity must be set to true if a hook has the flag enableHookAdjustedAmounts = true.
liquidityManagement.disableUnbalancedLiquidity = params.disableUnbalancedLiquidity;
+ bytes32 semiFinalSalt = keccak256(abi.encode(block.number, block.timestamp, params.salt));
pool = _create(
abi.encode(
QuantAMMWeightedPool.NewPoolParams({
name: params.name,
symbol: params.symbol,
numTokens: params.normalizedWeights.length,
version: "version",
updateWeightRunner: _updateWeightRunner,
poolRegistry: params.poolRegistry,
poolDetails: params.poolDetails
}),
getVault()
),
+ semiFinalSalt
- params.salt
);
QuantAMMWeightedPool(pool).initialize(
params._initialWeights,
params._poolSettings,
params._initialMovingAverages,
params._initialIntermediateValues,
params._oracleStalenessThreshold
);
_registerPoolWithVault(
pool,
params.tokens,
params.swapFeePercentage,
false, // not exempt from protocol fees
params.roleAccounts,
params.poolHooksContract,
liquidityManagement
);
}

QuantAMMWeightedPoolFactory::create:

function create(NewPoolParams memory params) external returns (address pool, bytes memory poolArgs) {
if (params.roleAccounts.poolCreator != address(0)) {
revert StandardPoolWithCreator();
}
LiquidityManagement memory liquidityManagement = getDefaultLiquidityManagement();
liquidityManagement.enableDonation = params.enableDonation;
// disableUnbalancedLiquidity must be set to true if a hook has the flag enableHookAdjustedAmounts = true.
liquidityManagement.disableUnbalancedLiquidity = params.disableUnbalancedLiquidity;
poolArgs = abi.encode(
QuantAMMWeightedPool.NewPoolParams({
name: params.name,
symbol: params.symbol,
numTokens: params.normalizedWeights.length,
version: "version",
updateWeightRunner: _updateWeightRunner,
poolRegistry: params.poolRegistry,
poolDetails: params.poolDetails
}),
getVault()
);
+ bytes32 semiFinalSalt = keccak256(abi.encode(block.number, block.timestamp, params.salt));
- pool = _create(poolArgs, params.salt);
+ pool = _create(poolArgs, semiFinalSalt);
QuantAMMWeightedPool(pool).initialize(
params._initialWeights,
params._poolSettings,
params._initialMovingAverages,
params._initialIntermediateValues,
params._oracleStalenessThreshold
);
_registerPoolWithVault(
pool,
params.tokens,
params.swapFeePercentage,
false, // not exempt from protocol fees
params.roleAccounts,
params.poolHooksContract,
liquidityManagement
);
}
Updates

Lead Judging Commences

n0kto Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!