Skip to content

Commit 45f032d

Browse files
authored
AccountERC7579Hooked: prevent bugged or malicious hook module from reverting its own uninstallation (#6390)
1 parent 77e5684 commit 45f032d

File tree

4 files changed

+196
-11
lines changed

4 files changed

+196
-11
lines changed

.changeset/chatty-dryers-joke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'openzeppelin-solidity': minor
3+
---
4+
5+
`AccountERC7579Hooked`: Do not revert if hook checks fail during the hook module uninstallation.

contracts/account/extensions/draft-AccountERC7579Hooked.sol

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ pragma solidity ^0.8.26;
66
import {IERC7579Hook, MODULE_TYPE_HOOK} from "../../interfaces/draft-IERC7579.sol";
77
import {ERC7579Utils, Mode} from "../../account/utils/draft-ERC7579Utils.sol";
88
import {AccountERC7579} from "./draft-AccountERC7579.sol";
9+
import {Bytes} from "../../utils/Bytes.sol";
10+
import {LowLevelCall} from "../../utils/LowLevelCall.sol";
911

1012
/**
1113
* @dev Extension of {AccountERC7579} with support for a single hook module (type 4).
@@ -80,16 +82,52 @@ abstract contract AccountERC7579Hooked is AccountERC7579 {
8082
}
8183

8284
/// @dev Uninstalls a module with support for hook modules. See {AccountERC7579-_uninstallModule}
83-
function _uninstallModule(
84-
uint256 moduleTypeId,
85-
address module,
86-
bytes memory deInitData
87-
) internal virtual override withHook {
85+
function _uninstallModule(uint256 moduleTypeId, address module, bytes memory deInitData) internal virtual override {
86+
// Inline a variant of the `withHook` modifier that doesn't revert if the hook reverts and the moduleTypeId is `MODULE_TYPE_HOOK`.
87+
88+
// === Beginning of the precheck ===
89+
90+
address hook_ = hook();
91+
bytes memory hookData;
92+
bool preCheckSuccess;
93+
94+
// slither-disable-next-line reentrancy-no-eth
95+
if (hook_ != address(0)) {
96+
preCheckSuccess = LowLevelCall.callNoReturn(
97+
hook_,
98+
abi.encodeCall(IERC7579Hook.preCheck, (msg.sender, msg.value, msg.data))
99+
);
100+
if (preCheckSuccess) {
101+
// Note: abi.decode could revert, and we wouldn't be able to catch it.
102+
// If could be leveraged by a malicious hook to force a revert.
103+
// So we have to do the decode manually.
104+
(preCheckSuccess, hookData) = _tryInPlaceAbiDecodeBytes(LowLevelCall.returnData());
105+
} else if (moduleTypeId != MODULE_TYPE_HOOK) {
106+
LowLevelCall.bubbleRevert();
107+
}
108+
}
109+
110+
// === End of the precheck -- Beginning of the body (`_` part of the modifier) ===
111+
88112
if (moduleTypeId == MODULE_TYPE_HOOK) {
89113
require(_hook == module, ERC7579Utils.ERC7579UninstalledModule(moduleTypeId, module));
90114
_hook = address(0);
91115
}
92116
super._uninstallModule(moduleTypeId, module, deInitData);
117+
118+
// === End of the body (`_` part of the modifier) -- Beginning of the postcheck ===
119+
120+
if (hook_ != address(0) && preCheckSuccess) {
121+
bool postCheckSuccess = LowLevelCall.callNoReturn(
122+
hook_,
123+
abi.encodeCall(IERC7579Hook.postCheck, (hookData))
124+
);
125+
if (!postCheckSuccess && moduleTypeId != MODULE_TYPE_HOOK) {
126+
LowLevelCall.bubbleRevert();
127+
}
128+
}
129+
130+
// === End of the postcheck ===
93131
}
94132

95133
/// @dev Hooked version of {AccountERC7579-_execute}.
@@ -104,4 +142,30 @@ abstract contract AccountERC7579Hooked is AccountERC7579 {
104142
function _fallback() internal virtual override withHook returns (bytes memory) {
105143
return super._fallback();
106144
}
145+
146+
/**
147+
* @dev Try to abi.decode a bytes array. If successful, the decoding is done in place, overriding the original
148+
* data. If decoding fails, the original data is left untouched.
149+
*/
150+
function _tryInPlaceAbiDecodeBytes(
151+
bytes memory data
152+
) private pure returns (bool success, bytes memory passthrough) {
153+
unchecked {
154+
if (data.length < 0x20) return (false, data);
155+
uint256 offset = uint256(_unsafeReadBytesOffset(data, 0));
156+
if (data.length - 0x20 < offset) return (false, data);
157+
uint256 length = uint256(_unsafeReadBytesOffset(data, offset));
158+
if (data.length - 0x20 - offset < length) return (false, data);
159+
Bytes.splice(data, 0x20 + offset, 0x20 + offset + length);
160+
return (true, data);
161+
}
162+
}
163+
164+
/// @dev Copied from Bytes.sol
165+
function _unsafeReadBytesOffset(bytes memory buffer, uint256 offset) private pure returns (bytes32 value) {
166+
// This is not memory safe in the general case, but all calls to this private function are within bounds.
167+
assembly ("memory-safe") {
168+
value := mload(add(add(buffer, 0x20), offset))
169+
}
170+
}
107171
}

contracts/mocks/account/modules/ERC7579Mock.sol

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,29 @@ abstract contract ERC7579HookMock is ERC7579ModuleMock(MODULE_TYPE_HOOK), IERC75
4848
event PreCheck(address sender, uint256 value, bytes data);
4949
event PostCheck(bytes hookData);
5050

51+
bool private _shouldRevertOnPreCheck = false;
52+
bool private _shouldRevertOnPostCheck = false;
53+
54+
function revertOnPreCheck(bool shouldRevert) external {
55+
_shouldRevertOnPreCheck = shouldRevert;
56+
}
57+
58+
function revertOnPostCheck(bool shouldRevert) external {
59+
_shouldRevertOnPostCheck = shouldRevert;
60+
}
61+
5162
function preCheck(
5263
address msgSender,
5364
uint256 value,
5465
bytes calldata msgData
5566
) external returns (bytes memory hookData) {
67+
require(!_shouldRevertOnPreCheck, "preCheck reverts");
5668
emit PreCheck(msgSender, value, msgData);
5769
return msgData;
5870
}
5971

6072
function postCheck(bytes calldata hookData) external {
73+
require(!_shouldRevertOnPostCheck, "postCheck reverts");
6174
emit PostCheck(hookData);
6275
}
6376
}

test/account/extensions/AccountERC7579.behavior.js

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const { ethers, predeploy } = require('hardhat');
22
const { expect } = require('chai');
3+
const { setCode } = require('@nomicfoundation/hardhat-network-helpers');
34
const { impersonate } = require('../../helpers/account');
45
const { selector } = require('../../helpers/methods');
56
const { zip } = require('../../helpers/iterate');
@@ -181,7 +182,7 @@ function shouldBehaveLikeAccountERC7579({ withHooks = false } = {}) {
181182
withHooks &&
182183
describe('with hook', function () {
183184
beforeEach(async function () {
184-
await this.mockFromEntrypoint.$_installModule(MODULE_TYPE_HOOK, this.modules[MODULE_TYPE_HOOK], '0x');
185+
await this.mock.$_installModule(MODULE_TYPE_HOOK, this.modules[MODULE_TYPE_HOOK], '0x');
185186
});
186187

187188
it('should call the hook of the installed module when performing an module install', async function () {
@@ -267,7 +268,7 @@ function shouldBehaveLikeAccountERC7579({ withHooks = false } = {}) {
267268
const anotherInstance = await ethers.deployContract('$ERC7579ModuleMock', [MODULE_TYPE_FALLBACK]);
268269
const initData = '0x12345678abcdef';
269270

270-
await this.mockFromEntrypoint.$_installModule(MODULE_TYPE_FALLBACK, instance, initData);
271+
await this.mock.$_installModule(MODULE_TYPE_FALLBACK, instance, initData);
271272
await expect(this.mockFromEntrypoint.uninstallModule(MODULE_TYPE_FALLBACK, anotherInstance, initData))
272273
.to.be.revertedWithCustomError(this.mock, 'ERC7579UninstalledModule')
273274
.withArgs(MODULE_TYPE_FALLBACK, anotherInstance);
@@ -296,7 +297,8 @@ function shouldBehaveLikeAccountERC7579({ withHooks = false } = {}) {
296297
withHooks &&
297298
describe('with hook', function () {
298299
beforeEach(async function () {
299-
await this.mockFromEntrypoint.$_installModule(MODULE_TYPE_HOOK, this.modules[MODULE_TYPE_HOOK], '0x');
300+
await this.mock.$_installModule(MODULE_TYPE_EXECUTOR, this.modules[MODULE_TYPE_EXECUTOR], '0x');
301+
await this.mock.$_installModule(MODULE_TYPE_HOOK, this.modules[MODULE_TYPE_HOOK], '0x');
300302
});
301303

302304
it('should call the hook of the installed module when performing a module uninstall', async function () {
@@ -309,13 +311,114 @@ function shouldBehaveLikeAccountERC7579({ withHooks = false } = {}) {
309311
initData,
310312
]);
311313

312-
await this.mock.$_installModule(MODULE_TYPE_EXECUTOR, instance, initData);
313314
await expect(this.mockFromEntrypoint.uninstallModule(MODULE_TYPE_EXECUTOR, instance, initData))
314315
.to.emit(this.modules[MODULE_TYPE_HOOK], 'PreCheck')
315316
.withArgs(predeploy.entrypoint.v09, 0n, precheckData)
316317
.to.emit(this.modules[MODULE_TYPE_HOOK], 'PostCheck')
317318
.withArgs(precheckData);
318319
});
320+
321+
it('hook revert during the pre-check prevents uninstalling a non-hook module', async function () {
322+
const instance = this.modules[MODULE_TYPE_EXECUTOR];
323+
const initData = ethers.hexlify(ethers.randomBytes(256));
324+
325+
// Set the hook to revert on preCheck
326+
await this.modules[MODULE_TYPE_HOOK].revertOnPreCheck(true);
327+
328+
await expect(
329+
this.mockFromEntrypoint.uninstallModule(MODULE_TYPE_EXECUTOR, instance, initData),
330+
).to.be.revertedWith('preCheck reverts');
331+
});
332+
333+
it('hook revert during the post-check prevents uninstalling a non-hook module', async function () {
334+
const instance = this.modules[MODULE_TYPE_EXECUTOR];
335+
const initData = ethers.hexlify(ethers.randomBytes(256));
336+
337+
// Set the hook to revert on postCheck
338+
await this.modules[MODULE_TYPE_HOOK].revertOnPostCheck(true);
339+
340+
await expect(
341+
this.mockFromEntrypoint.uninstallModule(MODULE_TYPE_EXECUTOR, instance, initData),
342+
).to.be.revertedWith('postCheck reverts');
343+
});
344+
345+
it('can uninstall a hook module that reverts during its pre-check', async function () {
346+
const instance = this.modules[MODULE_TYPE_HOOK];
347+
const initData = ethers.hexlify(ethers.randomBytes(256));
348+
349+
// Set the hook to revert on preCheck
350+
await instance.revertOnPreCheck(true);
351+
352+
// Should uninstall
353+
await expect(this.mockFromEntrypoint.uninstallModule(MODULE_TYPE_HOOK, instance, initData))
354+
.to.emit(this.mock, 'ModuleUninstalled')
355+
.withArgs(MODULE_TYPE_HOOK, instance)
356+
.to.not.emit(instance, 'PreCheck')
357+
.to.not.emit(instance, 'PostCheck');
358+
359+
await expect(this.mock.isModuleInstalled(MODULE_TYPE_HOOK, instance, initData)).to.eventually.equal(false);
360+
});
361+
362+
it('can uninstall a hook module that reverts during its post-check', async function () {
363+
const instance = this.modules[MODULE_TYPE_HOOK];
364+
const initData = ethers.hexlify(ethers.randomBytes(256));
365+
366+
// Set the hook to revert on postCheck
367+
await instance.revertOnPostCheck(true);
368+
369+
// Should uninstall
370+
await expect(this.mockFromEntrypoint.uninstallModule(MODULE_TYPE_HOOK, instance, initData))
371+
.to.emit(this.mock, 'ModuleUninstalled')
372+
.withArgs(MODULE_TYPE_HOOK, instance)
373+
.to.emit(instance, 'PreCheck')
374+
.withArgs(
375+
predeploy.entrypoint.v09,
376+
0n,
377+
this.mock.interface.encodeFunctionData('uninstallModule', [
378+
MODULE_TYPE_HOOK,
379+
instance.target,
380+
initData,
381+
]),
382+
)
383+
.to.not.emit(instance, 'PostCheck');
384+
385+
await expect(this.mock.isModuleInstalled(MODULE_TYPE_HOOK, instance, initData)).to.eventually.equal(false);
386+
});
387+
388+
it('can uninstall a hook module that reverts during both pre-check and post-check', async function () {
389+
const instance = this.modules[MODULE_TYPE_HOOK];
390+
const initData = ethers.hexlify(ethers.randomBytes(256));
391+
392+
// Set the hook to revert on preCheck and postCheck
393+
await instance.revertOnPreCheck(true);
394+
await instance.revertOnPostCheck(true);
395+
396+
// Should uninstall
397+
await expect(this.mockFromEntrypoint.uninstallModule(MODULE_TYPE_HOOK, instance, initData))
398+
.to.emit(this.mock, 'ModuleUninstalled')
399+
.withArgs(MODULE_TYPE_HOOK, instance)
400+
.to.not.emit(instance, 'PreCheck')
401+
.to.not.emit(instance, 'PostCheck');
402+
403+
await expect(this.mock.isModuleInstalled(MODULE_TYPE_HOOK, instance, initData)).to.eventually.equal(false);
404+
});
405+
406+
it('can uninstall a hook module that has no code (removed delegation)', async function () {
407+
const instance = this.modules[MODULE_TYPE_HOOK];
408+
const initData = ethers.hexlify(ethers.randomBytes(256));
409+
410+
// Delete the code of the module to simulate a removed delegation
411+
await setCode(instance.target, '0x');
412+
413+
// Should uninstall
414+
await expect(this.mockFromEntrypoint.uninstallModule(MODULE_TYPE_HOOK, instance, initData))
415+
.to.emit(this.mock, 'ModuleUninstalled')
416+
.withArgs(MODULE_TYPE_HOOK, instance)
417+
.to.not.emit(instance, 'PreCheck')
418+
.to.not.emit(instance, 'PostCheck');
419+
420+
await expect(this.mock.isModuleInstalled(MODULE_TYPE_HOOK, instance, initData)).to.eventually.equal(false);
421+
});
319422
});
320423
});
321424

@@ -515,7 +618,7 @@ function shouldBehaveLikeAccountERC7579({ withHooks = false } = {}) {
515618
withHooks &&
516619
describe('with hook', function () {
517620
beforeEach(async function () {
518-
await this.mockFromEntrypoint.$_installModule(MODULE_TYPE_HOOK, this.modules[MODULE_TYPE_HOOK], '0x');
621+
await this.mock.$_installModule(MODULE_TYPE_HOOK, this.modules[MODULE_TYPE_HOOK], '0x');
519622
});
520623

521624
it(`should call the hook of the installed module when executing ${execFn}`, async function () {
@@ -596,7 +699,7 @@ function shouldBehaveLikeAccountERC7579({ withHooks = false } = {}) {
596699
withHooks &&
597700
describe('with hook', function () {
598701
beforeEach(async function () {
599-
await this.mockFromEntrypoint.$_installModule(MODULE_TYPE_HOOK, this.modules[MODULE_TYPE_HOOK], '0x');
702+
await this.mock.$_installModule(MODULE_TYPE_HOOK, this.modules[MODULE_TYPE_HOOK], '0x');
600703
});
601704

602705
it('should call the hook of the installed module when performing a callback', async function () {

0 commit comments

Comments
 (0)