本文深入探讨了 Uniswap V4 Hooks 的架构、潜在风险和审计方法。Hooks 允许在流动性池和交易中进行自定义逻辑,但也扩大了攻击面。文章分析了 Hooks 的工作原理,强调了配置错误、Delta 处理不当、异步 Hooks 风险、授权漏洞、中心化风险、抢跑交易风险和服务拒绝攻击等关键问题,并提供了相应的安全实践建议。
Uniswap V4 Hooks 为开发者提供了前所未有的灵活性,能够在流动性池和互换中实现自定义逻辑。这为动态费用、自定义 AMM 策略以及与其他 DeFi 协议更紧密的集成打开了大门。然而,这种灵活性也极大地扩展了潜在的攻击面。一个配置错误或恶意的 Hook 可能会导致重大的财务损失、拒绝服务情况或对池的操纵。
在本文中,我们将分析 Hook 的工作原理、它们引入风险的地方以及如何有效地审计它们。我们将通过一个结构化的过程,从初始配置检查到高级威胁,如抢先交易和中心化风险,使你掌握构建安全且强大的 Hook 的知识。
通过代码分析、图表和实际示例,我们将探讨 Uniswap V4 Hook 如何既可以成为强大的工具,也可以成为潜在的责任——具体取决于它们的实现方式。
Uniswap V4 引入了 Hook,这是一种外部智能合约,可以在不同的执行点修改流动性管理和互换行为。
Hook 开辟了许多可能性:自定义费用、动态流动性调整,甚至完全在链上运行的复杂交易策略。然而,这种能力带来了重大的安全风险。在开始编码之前需要仔细考虑这些风险,在开发过程中进行规划,并在代码编写完成后进行彻底的审计。
在 Uniswap V4 中,每个流动性池都可以附加一个 Hook 合约。在互换期间或当有人添加或移除流动性时,Uniswap 会在特定时刻调用此 Hook。任何人都可以创建池,创建者可以选择使用哪个 Hook(如果有)。对于同一对代币(如 ETH/USDC),可以有多个池,每个池都有不同的设置,包括不同的 Hook。每个唯一的池都有一个唯一的 ID。至关重要的是,一旦创建了池,就无法更改 Hook。
function initialize(PoolKey memory key, uint160 sqrtPriceX96) external noDelegateCall returns (int24 tick) {
…
uint24 lpFee = key.fee.getInitialLPFee();
key.Hooks.beforeInitialize(key, sqrtPriceX96);
PoolId id = key.toId();
tick = _pools[id].initialize(sqrtPriceX96, lpFee);
…
key.Hooks.afterInitialize(key, sqrtPriceX96, tick);
}
function toId(PoolKey memory poolKey) internal pure returns (PoolId poolId) {
assembly ("memory-safe") {
// 0xa0 represents the total size of the poolKey struct (5 slots of 32 bytes)
poolId := keccak256(poolKey, 0xa0)
}
}
来源:PoolId.sol
Hook 可以在关键操作执行之前或之后在特定时刻触发:
beforeInitialize – 在池初始化之前调用,此时设置初始参数(如价格范围、系数等)。
afterInitialize – 在初始化之后调用,此时已创建池并准备好使用。
beforeAddLiquidity – 在添加流动性之前调用,此时仍然可以检查或修改参数。
afterAddLiquidity – 在添加流动性之后调用,此时资产已存入池中并且其状态已更新。
beforeRemoveLiquidity – 在移除流动性之前调用,以检查是否可以执行该操作。
afterRemoveLiquidity – 在移除流动性之后调用,此时资产已返回给用户并且池的储备已更新。
beforeSwap – 在发生互换之前调用,允许验证交易参数,如价格限制或可用流动性。
afterSwap – 在互换完成之后调用,此时已发生交易并且池的余额已更新。
beforeDonate – 在捐赠给池之前调用,此时仍然可以调整交易参数。
afterDonate – 在捐赠之后调用,此时池的余额已更新并且流动性已收到奖励。
Hook 执行的一般流程
为了确保你的 Uniswap V4 Hook 的安全性,我们将遵循结构化的方法,检查潜在漏洞的关键领域。这些步骤涵盖从初始配置到持续运营风险的所有内容。
配置错误的 Hook 可能会由于无效的函数实现或意外类型或大小的返回值而导致交易回滚。下面的流程图说明了 PoolManager 在互换操作之前如何与 Hook 交互。这些区域中的任何错误配置都可能导致意外行为或交易失败。
Hook 配置和执行流程
为了说明常见的错误配置,请考虑以下自定义的 VulnerableConfigurationHook 实现,该实现存在多个配置问题:
contract VulnerableConfigurationHook is Ownable, UUPSUpgradeable {
function getHookPermissions() public pure returns (HookPermissions memory) {
return HookPermissions({
beforeSwap: true,
afterSwap: false,
beforeModifyLiquidity: false,
afterModifyLiquidity: false
});
}
function beforeSwap(...) external returns (uint256 lpFeeOverride) {
return 5000;
}
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
// Future plan: Implement afterSwap() in next upgrade...
}
Uniswap V4 不希望从合约本身获取权限——相反,它使用按位运算直接从 Hook 的地址派生权限。
function hasPermission(IHooks self, uint160 flag) internal pure returns (bool) {
return uint160(address(self)) & flag != 0;
}
来源:Hooks.sol
VulnerableConfigurationHook 合约声称支持 beforeSwap(),但如果它部署在未编码 BEFORE_SWAP_FLAG 的地址,PoolManager 将无法识别它,并且 beforeSwap() 将永远不会被调用。
Checking the Hook's Address Encoding
Hook Address (binary): 0b1011010101101101...0000 (Ends in `0` → does NOT have beforeSwap ❌)
BEFORE_SWAP_FLAG (0x1): 0b0000000000000000...0001
--------------------------------------------------
Result (Bitwise AND `&`): 0b0000000000000000...0000 ❌ No permission!
📌 因此,第一个问题源于 Hook 声明的权限与其编码的地址权限之间的不匹配。如果 Hook 声称支持特定函数,但其地址未编码相应的权限位,则 PoolManager 将永远不会调用它,从而使该函数无效。相反,如果 Hook 的地址包含额外的权限位,则 PoolManager 可能会尝试执行不存在的函数,从而导致交易回滚 (DoS)。
部署 Hook 时,需要进行地址挖掘以找到正确的部署地址。下面是一个地址挖掘的示例,该示例标识了一个 salt 值,该值生成一个 Hook 地址,并在该地址上设置了所需的标志。
uint160 flags = uint160(
Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG | Hooks.BEFORE_ADD_LIQUIDITY_FLAG
| Hooks.BEFORE_REMOVE_LIQUIDITY_FLAG
);
// Mine a salt that will produce a Hook address with the correct flags
bytes memory constructorArgs = abi.encode(POOLMANAGER);
(address HookAddress, bytes32 salt) =
HookMiner.find(CREATE2_DEPLOYER, flags, type(PointsHook).creationCode, constructorArgs);
// Deploy the Hook using CREATE2
vm.broadcast();
PointsHook pointsHook = new PointsHook{salt: salt}(IPoolManager(POOLMANAGER));
📌VulnerableConfigurationHook 中的下一个问题是 beforeSwap() 中的返回类型不正确:
function beforeSwap(...) external returns (uint256 lpFeeOverride) {
return 5000;
}
Uniswap 使用 Hook 库处理返回值,该库期望 beforeSwap 返回以下类型的元组:
function beforeSwap(...) internal returns (int256 amountToSwap, BeforeSwapDelta HookReturn, uint24 lpFeeOverride)
来源:Hooks.sol
错误的返回类型可能导致:
📌VulnerableConfigurationHook 中关注的最后一个问题是可升级性。该合约旨在通过 UUPSUpgradeable 支持未来的升级:
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
合约中的注释表明计划在未来的升级中实现 afterSwap()。
尽管 Hook 可能会在未来的升级中实现新函数,但其部署地址并未对这些函数的权限进行编码。如果在升级中添加了 afterSwap(),则 PoolManager 将无法识别它,因为合约的地址缺少所需的权限位。
为了防止出现此问题,Hook 的地址必须在部署时对所有预期的权限进行编码。实现此目的的一种方法是为未来的 Hook 定义占位符函数。
最终,建议在使用遵循 IHook 接口实现 Hook 时使用 Uniswap 中的 BaseHook。如上所述,编写自定义实现可能会导致配置问题,从而可能导致系统中的不一致或意外行为。
Uniswap V4 引入了一种自定义核算机制,允许 Hook 返回 deltas,从而影响互换执行和流动性修改。它允许 Hook 从互换和流动性修改中收取费用,从而可以精细地控制余额调整,甚至覆盖默认的互换行为。这些修改需要特定的权限标志,这些标志在 Hook 的地址中编码:
uint160 internal constant BEFORE_SWAP_RETURNS_DELTA_FLAG = 1 << 3;
uint160 internal constant AFTER_SWAP_RETURNS_DELTA_FLAG = 1 << 2;
uint160 internal constant AFTER_ADD_LIQUIDITY_RETURNS_DELTA_FLAG = 1 << 1;
uint160 internal constant AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA_FLAG = 1 << 0;
beforeSwap() Hook 返回的参数之一是 BeforeSwapDelta,它是 int256 的别名类型,其中上面的 128 位用于指定代币(例如,token0 或 token1)中的 delta,下面的 128 位用于未指定代币中的 delta(费用调整或其他费用)。
type BeforeSwapDelta is int256;
…
function getSpecifiedDelta(BeforeSwapDelta delta) internal pure returns (int128 deltaSpecified) {
assembly ("memory-safe") {
deltaSpecified := sar(128, delta)
}
}
/// extracts int128 from the lower 128 bits of the BeforeSwapDelta
/// returned by beforeSwap and afterSwap
function getUnspecifiedDelta(BeforeSwapDelta delta) internal pure returns (int128 deltaUnspecified) {
assembly ("memory-safe") {
deltaUnspecified := signextend(15, delta)
}
}
要记住的关键点是,BeforeSwapDelta 是从 Hook 的角度来看的:
其他 Hook 使用 BalanceDelta,它也是 int256 类型的别名。它与 BeforeSwapDelta 共享相同的设计,但顺序不同。上面的 128 位用于 amount0,表示 token0 中的增量,下面的 128 位用于 amount1,表示 token1 中的增量。
type BalanceDelta is int256;
function amount0(BalanceDelta balanceDelta) internal pure returns (int128 _amount0) {
assembly ("memory-safe") {
_amount0 := sar(128, balanceDelta)
}
}
function amount1(BalanceDelta balanceDelta) internal pure returns (int128 _amount1) {
assembly ("memory-safe") {
_amount1 := signextend(15, balanceDelta)
}
}
Uniswap 以以下方式处理 delta:互换金额(amountToSwap)最初设置为 params.amountSpecified。指定的代币 delta(HookDeltaSpecified)将添加到 amountToSwap,从而修改互换执行。
if (self.hasPermission(BEFORE_SWAP_RETURNS_DELTA_FLAG)) {
HookReturn = BeforeSwapDelta.wrap(result.parseReturnDelta());
// any return in unspecified is passed to the afterSwap Hook for handling
int128 HookDeltaSpecified = HookReturn.getSpecifiedDelta();
// Update the swap amount according to the Hook's return, and check that the swap type doesn't change (exact input/output)
if (HookDeltaSpecified != 0) {
bool exactInput = amountToSwap < 0;
amountToSwap += HookDeltaSpecified;
if (exactInput ? amountToSwap > 0 : amountToSwap < 0) {
HookDeltaExceedsSwapAmount.selector.revertWith();
}
}
}
来源:Hooks.sol
为了说明常见的错误配置,请考虑以下 VulnerableDeltaAdjustmentHook 实现:
contract VulnerableDeltaAdjustmentHook is BaseHook {
function beforeSwap(
address sender,
PoolKey calldata key,
SwapParams calldata params,
bytes calldata HookData
) external override returns (bytes4 selector, int256 HookDelta, uint24 lpFeeOverride) {
int128 token0Delta = 500 * 10**18; // fee
int128 token1Delta = 0;
HookDelta = BeforeSwapDelta.toBeforeSwapDelta(token0Delta, token1Delta);
selector = bytes4(keccak256("beforeSwap()"));
return (selector, HookDelta, lpFeeOverride);
}
}
📌 代码中的第一个问题是 token0Delta 旨在表示 Hook 费用。但是,由于 BeforeSwapDelta 是从 Hook 的角度考虑的,因此当 Hook 扣除金额时,费用应为负数。
📌 第二个问题是参数传递给 BeforeSwapDelta.toBeforeSwapDelta() 的顺序。BeforeSwapDelta 中的 delta 顺序不是固定的,必须与由 SwapParams.zeroForOne 参数确定的互换方向对齐。
在 Uniswap V4 中,PoolManager 确保在交易最终确定之前,所有 delta(代币余额变化)都结算为零。如果 delta 未得到正确处理,则可能由于未结算的代币余额而导致交易失败。
要了解 Hook delta 的工作原理,我们需要分析 Uniswap 如何在 PoolManager 中跟踪和强制执行余额结算。
delta 平衡过程从 unlock() 开始,该函数在执行互换或流动性修改之前被调用。
function unlock(bytes calldata data) external override returns (bytes memory result) {
if (Lock.isUnlocked()) AlreadyUnlocked.selector.revertWith();
Lock.unlock();
// the caller does everything in this callback, including paying what they owe via calls to settle
result = IUnlockCallback(msg.sender).unlockCallback(data);
if (NonzeroDeltaCount.read() != 0) CurrencyNotSettled.selector.revertWith();
Lock.lock();
}
unlock() 允许通过 unlockCallback() 执行合约逻辑。执行后,将检查 NonzeroDeltaCount 以确保所有余额都已结算。这意味着在执行结束时,协议和用户都不应欠彼此代币。这同样适用于 Hook——如果 Hook 修改了余额,则必须确保 delta 在交易最终确定之前总和为零。
要了解 delta 调整如何影响交易,我们将分析 Uniswap 如何在存在 Hook 的情况下修改 delta。
BalanceDelta HookDelta;
(swapDelta, HookDelta) = key.Hooks.afterSwap(key, params, swapDelta, HookData, beforeSwapDelta);
// If the Hook returns a nonzero delta, update the pool balance for the Hook contract
if (HookDelta != BalanceDeltaLibrary.ZERO_DELTA) {
_accountPoolBalanceDelta(key, HookDelta, address(key.Hooks));
}
Hook 在 afterSwap() 中返回 HookDelta,从而修改最终余额变化。影响 PoolManager 最终结算的 HookDelta 基于 beforeSwap() 和 afterSwap() 修改计算得出。
if (msg.sender == address(self)) return (swapDelta, BalanceDeltaLibrary.ZERO_DELTA);
int128 HookDeltaSpecified = beforeSwapHookReturn.getSpecifiedDelta();
int128 HookDeltaUnspecified = beforeSwapHookReturn.getUnspecifiedDelta();
// If the Hook has AFTER_SWAP permissions, modify HookDeltaUnspecified
if (self.hasPermission(AFTER_SWAP_FLAG)) {
HookDeltaUnspecified += self.callHookWithReturnDelta(
abi.encodeCall(IHooks.afterSwap, (msg.sender, key, params, swapDelta, HookData)),
self.hasPermission(AFTER_SWAP_RETURNS_DELTA_FLAG)
).toInt128();
}
// Apply Hook deltas
if (HookDeltaUnspecified != 0 || HookDeltaSpecified != 0) {
HookDelta = (params.amountSpecified < 0 == params.zeroForOne)
? toBalanceDelta(HookDeltaSpecified, HookDeltaUnspecified)
: toBalanceDelta(HookDeltaUnspecified, HookDeltaSpecified);
// Ensure the swap amount accounts for Hook delta changes
swapDelta = swapDelta - HookDelta;
}
return (swapDelta, HookDelta);
来源:Hooks.sol
如果在 afterSwap() 中 Hook 修改了 HookDeltaUnspecified,则会更新它。
为了确保正确调整所有余额,Uniswap 使用 _accountPoolBalanceDelta() 跟踪 Hook delta(它在上面分析的互换函数中被调用)。
function _accountPoolBalanceDelta(PoolKey memory key, BalanceDelta delta, address target) internal {
_accountDelta(key.currency0, delta.amount0(), target);
_accountDelta(key.currency1, delta.amount1(), target);
}
function _accountDelta(Currency currency, int128 delta, address target) internal {
if (delta == 0) return;
(int256 previous, int256 next) = currency.applyDelta(target, delta);
if (next == 0) {
NonzeroDeltaCount.decrement();
} else if (previous == 0) {
NonzeroDeltaCount.increment();
}
}
为 Hook 或用户持有的每种货币更新 delta。如果 delta 达到零,则 NonzeroDeltaCount 递减。由于 NonzeroDeltaCount 在 unlock() 中被检查,因此未结算的 Hooks delta 将导致交易回滚。
此 VulnerableDeltaAdjustmentHook 错误地处理了 delta 调整,导致未结算的余额,从而导致交易回滚。
contract VulnerableDeltaAdjustmentHook is BaseHook {
function beforeSwap(
address sender,
PoolKey calldata key,
SwapParams calldata params,
bytes calldata HookData
) external override returns (bytes4 selector, int256 HookDelta, uint24 lpFeeOverride) {
int128 specifiedTokenDelta = 5; // Hook adds 5 tokens to swap
int128 unspecifiedTokenDelta = 0;
if (params.zeroForOne) {
HookDelta = BeforeSwapDelta.toBeforeSwapDelta(specifiedTokenDelta, unspecifiedTokenDelta);
} else {
HookDelta = BeforeSwapDelta.toBeforeSwapDelta(unspecifiedTokenDelta, specifiedTokenDelta);
}
return (BaseHook.beforeSwap.selector, HookDelta, lpFeeOverride);
}
function afterSwap(
address sender,
PoolKey calldata key,
SwapParams calldata params,
BalanceDelta swapDelta,
bytes calldata HookData,
BeforeSwapDelta beforeSwapHookReturn
) external override returns (bytes4 selector, int256 HookDelta, uint24 feeDelta) {
int128 HookDeltaSpecified = beforeSwapHookReturn.getSpecifiedDelta();
int128 HookDeltaUnspecified = beforeSwapHookReturn.getUnspecifiedDelta();
HookDeltaUnspecified += -10; // Hook takes 5 tokens of fee from swap result
HookDelta = BalanceDeltaLibrary.toBalanceDelta(HookDeltaSpecified, HookDeltaUnspecified);
return (BaseHook.afterSwap.selector, HookDelta, feeDelta);
}
}
📌 beforeSwap Hooks 将 5 个指定的代币添加到互换中,afterSwap 从互换结果中获取 10 个代币。因此,指定和未指定的两个 delta 将分别为 +5 和 -10。最后,NonzeroDeltaCount 将等于 2,这将导致解锁回滚。
为了结算 delta,Hook 应将 5 个指定的代币转账到 PoolManager 并调用 settle() 和 take() 以获取 10 个未指定的代币。(还有一个 settleFor 函数允许将代币转账到 PoolManager 而不是 Hook,因此,每次安全审查都应考虑特定的上下文)。
具有解锁和 Hooks Deltas 结算的互换执行流程
📌 Hook 返回的参数可能影响的不仅仅是未结算的 delta;如果它们违反了 Uniswap 的约束,则可能导致其他问题。例如,swap() 中的关键验证确保 Hook 不会更改互换类型(完全输入或完全输出)。
if (HookDeltaSpecified != 0) {
bool exactInput = amountToSwap < 0;
amountToSwap += HookDeltaSpecified;
if (exactInput ? amountToSwap > 0 : amountToSwap < 0) {
HookDeltaExceedsSwapAmount.selector.revertWith();
}
}
📌 在检查之前的 Hook 时,我们观察到它们返回的最后一个参数是 lpFeeOverride,它允许 Uniswap V4 Hook 动态地覆盖 LP 费用。Uniswap V4 在设置费用方面提供了灵活性,但也引入了潜在的风险。如果 Hook 返回过多或无效的费用值,则可能会阻止调用成功执行。
虽然 Uniswap V4 中的标准 Hook 在修改互换参数的同时保持 Uniswap 的核心执行逻辑不变,但异步 Hook 更进一步,完全替换了互换执行过程。异步 Hook 在 Uniswap V4 中引入了独特的安全问题,覆盖了 Uniswap 的原生互换逻辑。与修改互换参数的标准 Hook 不同,异步 Hook 通过完全控制用户发送的代币并执行自己的逻辑来替换核心互换机制。
异步 Hook 通过反转 delta 计算中的 amountToSwap 来替换 Uniswap 的互换逻辑,这意味着 Hook 完全保管了互换金额,而不是 Uniswap 执行互换。
function beforeSwap(
address sender,
PoolKey calldata key,
SwapParams calldata params,
bytes calldata HookData
) external override returns (bytes4 selector, int256 HookDelta, uint24 lpFeeOverride) {
// Async behavior: Hook completely takes the swap amount
int128 specifiedTokenDelta = -int128(params.amountSpecified);
int128 unspecifiedTokenDelta = 0;
// main logic
HookDelta = BeforeSwapDelta.toBeforeSwapDelta(specifiedTokenDelta, unspecifiedTokenDelta);
selector = bytes4(keccak256("beforeSwap()"));
return (selector, HookDelta, lpFeeOverride);
}
📌 这允许完全的灵活性,但也使 Uniswap 将所有互换执行的责任转移给 Hook,如果 Hook 是恶意的或配置错误,则会产生严重的风险。
异步 Hook 承担对互换资金的完全保管能力会产生重大风险。恶意或配置错误的 Hook 可以:
以下是一个恶意异步 Hook,它通过将所有互换的资金发送到合约的部署者而不是处理互换来窃取它们:
contract VulnerableAsyncHook is BaseHook {
constructor() { owner = msg.sender; }
function beforeSwap(
address sender,
PoolKey calldata key,
SwapParams calldata params,
bytes calldata HookData
) external override returns (bytes4 selector, int256 HookDelta, uint24 lpFeeOverride) {
// Async behavior: Hook completely takes the swap amount
int128 specifiedTokenDelta = -int128(params.amountSpecified);
int128 unspecifiedTokenDelta = 0;
HookDelta = BeforeSwapDelta.toBeforeSwapDelta(specifiedTokenDelta, unspecifiedTokenDelta);
IERC20(key.token0).transfer(owner, uint256(params.amountSpecified));
selector = bytes4(keccak256("beforeSwap()"));
return (selector, HookDelta, lpFeeOverride);
}
}
📌 在此攻击中,Hook 被明确编码为将代币转账到攻击者的钱包,而不是执行有效的互换。由于 Uniswap 不在异步 Hook 中强制执行互换逻辑,因此依赖于此类 Hook 的用户可能会永久丢失其资金。
具有异步 Hook 处理的互换执行流程
如果 Hook 可以被未经授权的合约或用户调用,则它很容易受到意外状态更改或外部参与者操纵的影响。
以下是一个有缺陷的 Hook 实现,它表现出多个授权问题。
contract VulnerableAuthorizationHook is BaseHook {
function afterSwap(...) external override returns (bytes4 selector, int256 HookDelta, uint24 feeDelta) {
token.transfer(msg.sender, contractBalance); …
}
function updatePool(address newPool) external {
attachedPool = newPool;
}
}
📌 可以预料的是,只有 PoolManager 才会调用 Hook 函数。但是,在此 Hook 中,对调用者没有限制。因此,缺少访问控制允许任何人执行 Hook 的逻辑,从而可能导致严重漏洞,漏洞的严重程度取决于每个 Hook 的具体实现。
为了防止此漏洞,所有 Hooks’ 访问都应限制为 PoolManager。
直接 Hook 访问和交互
每个池和每个事件一次只能触发一个 Hook。但是,Uniswap V4 的 PoolManager 允许多个流动性池引用同一个 Hook,因为没有内置的限制来阻止 Hook 附加到多个池。
PoolManager.sol 中的 initialize() 函数为给定的流动性池注册一个 Hook,但它不强制执行排他性,这意味着同一个 Hook 可以附加到多个池。
function initialize(PoolKey memory key, uint160 sqrtPriceX96) external noDelegateCall returns (int24 tick) {
if (key.tickSpacing > MAX_TICK_SPACING) TickSpacingTooLarge.selector.revertWith(key.tickSpacing);
if (key.tickSpacing < MIN_TICK_SPACING) TickSpacingTooSmall.selector.revertWith(key.tickSpacing);
if (key.currency0 >= key.currency1) {
CurrenciesOutOfOrderOrEqual.selector.revertWith(
Currency.unwrap(key.currency0), Currency.unwrap(key.currency1)
);
}
if (!key.Hooks.isValidHookAddress(key.fee)) Hooks.HookAddressNotValid.selector.revertWith(address(key.Hooks));
uint24 lpFee = key.fee.getInitialLPFee();
key.Hooks.beforeInitialize(key, sqrtPriceX96); // Hook is executed but not validated for exclusivity
PoolId id = key.toId();
tick = _pools[id].initialize(sqrtPriceX96, lpFee);
emit Initialize(id, key.currency0, key.currency1, key.fee, key.tickSpacing, key.Hooks, sqrtPriceX96, tick);
key.Hooks.afterInitialize(key, sqrtPriceX96, tick);
}
📌 VulnerableAuthorizationHook 仅依赖于 onlyPoolManager,假设它始终由受信任的池调用。由于 PoolManager 不验证池的排他性,因此攻击者可以将此 Hook 附加到任意池并触发其逻辑。
function afterSwap(...) external override onlyPoolManager returns (bytes4 selector, int256 HookDelta, uint24 feeDelta) {
token.transfer(msg.sender, contractBalance);
...
}
为防止未经授权的池使用 Hook,合约应根据预定义的受信任池显式验证 PoolKey。另一种方法是在 Hook 本身中实现 beforeInitialize(),以确保它只能为单个池初始化一次。
未经授权的 Hook 附件
📌 最后,我们将检查可能由外部用户调用的所有其他功能,这些功能可能会影响 Hook 的执行。配置错误的 Hook 会引入漏洞,这些漏洞可能会影响流动性池、互换执行和费用计算。
function updatePool(address newPool) external {
attachedPool = newPool;
}
未经授权的 Hook 参数操纵
在 Uniswap V4 中,Hook 引入了以前版本的协议中不存在的其他治理考虑因素。由于 Hook 是外部合约,因此它们可能是可升级的、集中控制的,或者具有可以修改关键协议参数的特权角色。这些因素引入了风险,可能允许单个实体操纵互换费用、暂停交易或从用户那里提取流动性。
📌 如果 Hook 继承了可升级的代理模式(例如,UUPSUpgradeable),则可以在部署后修改其逻辑,从而可能引入审计时不存在的漏洞或后门。
contract UpgradeableHook is BaseHook, Ownable, UUPSUpgradeable {
function beforeSwap(...) external override returns (bytes4 selector, int256 HookDelta, uint24 lpFeeOverride) {
int128 specifiedTokenDelta = -3000;
int128 unspecifiedTokenDelta = 0;
HookDelta = BeforeSwapDelta.toBeforeSwapDelta(specifiedTokenDelta, unspecifiedTokenDelta);
selector = bytes4(keccak256("beforeSwap()"));
lpFeeOverride = dynamicSwapFee;
return (selector, HookDelta, lpFeeOverride);
}
Hook 在部署后可以被任意修改,允许所有者引入新的安全漏洞。
恶意的升级可能会修改 beforeSwap() 来耗尽用户资金,增加交易费用,或者绕过权限检查。
Hook 的恶意升级
Hooks 可能包括配置参数,例如:
📌如果这些参数可以被单个实体任意修改,则 Hook 会对关键协议机制引入中心化控制。
contract CentralizedHook is BaseHook, Ownable {
uint24 public swapFee;
function setSwapFee(uint24 newFee) external onlyOwner {
swapFee = newFee;
}
function beforeSwap(...) external override returns (bytes4 selector, int256 HookDelta, uint24 lpFeeOverride) {
int128 specifiedTokenDelta = -swapFee
int128 unspecifiedTokenDelta = 0;
HookDelta = BeforeSwapDelta.toBeforeSwapDelta(specifiedTokenDelta, unspecifiedTokenDelta);
selector = bytes4(keccak256("beforeSwap()"));
lpFeeOverride = dynamicSwapFee;
return (selector, HookDelta, lpFeeOverride);
}
}
单个实体可以随意修改交易费用,可能抢先交易用户或提取过高的费用。
如果攻击者获得了所有者地址的控制权,他们可以将交易费用设置为 100%,从而有效地将用户资金锁定在池中。
中心化的 Hook 控制和所有者钱包丢失的风险
Uniswap V4 中的 Hooks 在交易和流动性管理的关键点引入了自定义执行逻辑。如果设计不当,Hooks 可能会容易受到抢先交易和 MEV(最大可提取价值)攻击的影响,其中恶意行为者会为获利而操纵交易顺序。
以下 VulnerablePriceOracleHook 容易受到抢先交易攻击。
contract VulnerablePriceOracleHook is BaseHook {
PriceOracle public oracle;
uint24 public dynamicSwapFee = 3000;
mapping(address => uint256) public lastRecordedPrice;
constructor(address _oracle) {
oracle = PriceOracle(_oracle);
}
function beforeSwap(
address sender,
PoolKey calldata key,
SwapParams calldata params,
bytes calldata HookData
) external override returns (bytes4 selector, int256 HookDelta, uint24 lpFeeOverride) {
uint256 currentPrice = oracle.getPrice(key.token0);
uint256 previousPrice = lastRecordedPrice[key.token0];
if (previousPrice == 0) {
lastRecordedPrice[key.token0] = currentPrice;
return (bytes4(keccak256("beforeSwap()")), 0, dynamicSwapFee);
}
// Adjust fees based on actual price movement over time
if (currentPrice > 1.1 * previousPrice) {
dynamicSwapFee = 500; // Lower fee
}
else if (currentPrice < 0.9 * previousPrice) {
dynamicSwapFee = 10000; // High penalty fee
}
lastRecordedPrice[key.token0] = currentPrice; // Update the price
HookDelta = BeforeSwapDelta.toBeforeSwapDelta(-int128(dynamicSwapFee), 0);
selector = bytes4(keccak256("beforeSwap()"));
lpFeeOverride = dynamicSwapFee;
return (selector, HookDelta, lpFeeOverride);
}
}
MEV 机器人可以观察到内存池中会影响价格的大额交易,然后抢先交易以获得更优惠的费用。
常见的抢先交易漏洞包括:
📌 基于价格的逻辑 – 如果 Hook 根据外部市场数据调整交易费用或流动性,MEV 机器人可以预测变化并加以利用。
📌 时间敏感的执行 – 启用延迟交易或依赖区块时间戳的 Hooks 可能会被操纵。
📌基于预言机的定价 – 获取链上价格的 Hooks 可能容易受到预言机操纵攻击。
易受攻击的 Hooks 中的抢先交易和 MEV 利用
如果实施不当,Hooks 会增加 gas 成本,引入无限循环或导致不必要的回滚,最终导致拒绝服务 (DoS) 攻击。这些问题会通过强制交易持续失败、阻止流动性提供者添加或移除流动性,甚至阻止交易,从而使池无法使用。
当审计 Hooks 的 DoS 风险时,考虑 Hook 是否会导致过多的 gas 使用,从而导致交易失败?
contract VulnerableDoSHook is BaseHook {
address[] public authorizedUsers;
ExternalContract public externalContract;
constructor(address _externalContract) {
externalContract = ExternalContract(_externalContract);
}
function beforeSwap(
address sender,
PoolKey calldata key,
SwapParams calldata params,
bytes calldata HookData
) external override returns (bytes4 selector, int256 HookDelta, uint24 lpFeeOverride) {
// Loop through all authorized users
for (uint256 i = 0; i < authorizedUsers.length; i++) {
require(externalContract.checkAccess(authorizedUsers[i]), "Access check failed");
}
selector = bytes4(keccak256("beforeSwap()"));
return (selector, HookDelta, lpFeeOverride);
}
}
📌如果 authorizedUsers 变得太大,beforeSwap() 函数将消耗过多的 gas,可能会超过区块 gas 限制。这会阻止交易执行,从而导致拒绝服务情况。
为防止 gas 耗尽,Hook 应该限制数组的大小,并使用更有效的数据结构(如映射)来确保交易在所有条件下都保持可执行状态。
📌即使 require() 或 revert() 语句实施不正确,也可能会阻止本应成功的交易。
Hook 中的 DoS 风险
Uniswap V4 Hooks 提供了前所未有的灵活性,解锁了诸如自定义流动性激励、自动化交易策略、增强风险缓解以及改进的 DeFi 可组合性等功能。
然而,这种灵活性显着扩大了攻击面,需要进行彻底的 安全评估。主要漏洞包括:
Uniswap V4 Hooks 具有巨大的潜力,但需要严格的 安全实践。适当的设计和安全审计对于 DeFi 生态系统的功能和安全至关重要。
资源:
文档:
代码:
v4-periphery: https://github.com/Uniswap/v4-periphery
>- 原文链接: [hacken.io/discover/audit...](https://hacken.io/discover/auditing-uniswap-v4-hooks)
>- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~ 如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!