Skip to content

fix(MultiHookRouter): preserve hook order in removeHook#41

Merged
ariessa merged 1 commit into
erc-8183:mainfrom
failsafesecurity:security/multi-hook-router-order-preserving-remove
May 4, 2026
Merged

fix(MultiHookRouter): preserve hook order in removeHook#41
ariessa merged 1 commit into
erc-8183:mainfrom
failsafesecurity:security/multi-hook-router-order-preserving-remove

Conversation

@failsafesecurity
Copy link
Copy Markdown
Contributor

Summary

MultiHookRouter.removeHook uses a swap-and-pop pattern:

hooks[i] = hooks[len - 1];
hooks.pop();

This silently reorders the remaining hooks. Ordering is semantically meaningful in this contract:

  • beforeAction / afterAction iterate _jobHooks[jobId][selector] in storage order.
  • _splitHookData binds per-hook optParams to hooks by array indexperHookData[i] is dispatched to hooks[i].
  • The contract already exposes reorderHooks and a HooksReordered event, which is itself evidence that order is a first-class part of the configuration surface.

For configured hooks [A, B, C, D], removing B with swap-and-pop produces [A, D, C] instead of [A, C, D]. This breaks intended A → C → D execution and silently misroutes per-hook optParams (the bytes prepared for C are dispatched to D and vice versa).

Fix

Replace swap-and-pop with an order-preserving shift-left, then pop():

for (uint256 j = i; j + 1 < len; ) {
    hooks[j] = hooks[j + 1];
    unchecked { ++j; }
}
hooks.pop();

Gas cost grows linearly with the number of hooks after the removed index, but maxHooksPerJob already bounds the list, so the upper bound is small and admin-controlled.

Severity

Medium. Not a direct theft vector, but order-dependent hook stacks (and per-hook optParams routing) are silently broken. The contract's own ordering API (reorderHooks / HooksReordered) implies callers may rely on the order they configured.

References

  • FailSafe security audit, finding OPS2-H004 ("Silent Execution Order Corruption via swap-and-pop Hook Removal")
  • Affected file: contracts/hooks/MultiHookRouter.sol

Test plan

  • Removing the first hook from [A, B, C] yields [B, C]
  • Removing a middle hook from [A, B, C, D] yields [A, C, D] (regression test for the reported bug)
  • Removing the last hook from [A, B, C] yields [A, B]
  • Removing a non-existent hook still reverts with HookNotFound
  • _splitHookData per-hook optParams routing remains correct after removal

removeHook used a swap-and-pop pattern (hooks[i] = hooks[len-1]; pop())
which silently reorders the remaining hooks. Order is semantically
meaningful here: beforeAction/afterAction iterate _jobHooks in storage
order, and _splitHookData binds per-hook optParams to hooks by array
index. The existing reorderHooks function and HooksReordered event also
indicate ordering is part of the contract.

For [A, B, C, D] removing B with swap-and-pop yields [A, D, C], breaking
A->C->D semantics and silently misrouting per-hook optParams (the bytes
intended for C are dispatched to D and vice versa).

Replace swap-and-pop with an order-preserving shift-left so removal
behaves the way callers and the rest of the contract assume.

Reported by FailSafe security audit (OPS2-H004).

Co-Authored-By: Joshua Medvinsky <76570188+Joshua-Medvinsky@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants