draft-ERC7579Utils.t.sol 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. // SPDX-License-Identifier: UNLICENSED
  2. pragma solidity ^0.8.24;
  3. // Parts of this test file are adapted from Adam Egyed (@adamegyed) proof of concept available at:
  4. // https://github.com/adamegyed/erc7579-execute-vulnerability/tree/4589a30ff139e143d6c57183ac62b5c029217a90
  5. //
  6. // solhint-disable no-console
  7. import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
  8. import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
  9. import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
  10. import {PackedUserOperation, IAccount, IEntryPoint} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol";
  11. import {ERC4337Utils} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";
  12. import {
  13. ERC7579Utils,
  14. Mode,
  15. CallType,
  16. ExecType,
  17. ModeSelector,
  18. ModePayload,
  19. Execution
  20. } from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol";
  21. import {Test, Vm, console} from "forge-std/Test.sol";
  22. contract SampleAccount is IAccount, Ownable {
  23. using ECDSA for *;
  24. using MessageHashUtils for *;
  25. using ERC4337Utils for *;
  26. using ERC7579Utils for *;
  27. event Log(bool duringValidation, Execution[] calls);
  28. error UnsupportedCallType(CallType callType);
  29. constructor(address initialOwner) Ownable(initialOwner) {}
  30. function validateUserOp(
  31. PackedUserOperation calldata userOp,
  32. bytes32 userOpHash,
  33. uint256 missingAccountFunds
  34. ) external override returns (uint256 validationData) {
  35. require(msg.sender == address(ERC4337Utils.ENTRYPOINT_V07), "only from EP");
  36. // Check signature
  37. if (userOpHash.toEthSignedMessageHash().recover(userOp.signature) != owner()) {
  38. revert OwnableUnauthorizedAccount(_msgSender());
  39. }
  40. // If this is an execute call with a batch operation, log the call details from the calldata
  41. if (bytes4(userOp.callData[0x00:0x04]) == this.execute.selector) {
  42. (CallType callType, , , ) = Mode.wrap(bytes32(userOp.callData[0x04:0x24])).decodeMode();
  43. if (callType == ERC7579Utils.CALLTYPE_BATCH) {
  44. // Remove the selector
  45. bytes calldata params = userOp.callData[0x04:];
  46. // Use the same vulnerable assignment technique here, but assert afterwards that the checks aren't
  47. // broken here by comparing to the result of `abi.decode(...)`.
  48. bytes calldata executionCalldata;
  49. assembly ("memory-safe") {
  50. let dataptr := add(params.offset, calldataload(add(params.offset, 0x20)))
  51. executionCalldata.offset := add(dataptr, 32)
  52. executionCalldata.length := calldataload(dataptr)
  53. }
  54. // Check that this decoding step is done correctly.
  55. (, bytes memory executionCalldataMemory) = abi.decode(params, (bytes32, bytes));
  56. require(
  57. keccak256(executionCalldata) == keccak256(executionCalldataMemory),
  58. "decoding during validation failed"
  59. );
  60. // Now, we know that we have `bytes calldata executionCalldata` as would be decoded by the solidity
  61. // builtin decoder for the `execute` function.
  62. // This is where the vulnerability from ExecutionLib results in a different result between validation
  63. // and execution.
  64. emit Log(true, executionCalldata.decodeBatch());
  65. }
  66. }
  67. if (missingAccountFunds > 0) {
  68. (bool success, ) = payable(msg.sender).call{value: missingAccountFunds}("");
  69. success; // Silence warning. The entrypoint should validate the result.
  70. }
  71. return ERC4337Utils.SIG_VALIDATION_SUCCESS;
  72. }
  73. function execute(Mode mode, bytes calldata executionCalldata) external payable {
  74. require(msg.sender == address(this) || msg.sender == address(ERC4337Utils.ENTRYPOINT_V07), "not auth");
  75. (CallType callType, ExecType execType, , ) = mode.decodeMode();
  76. // check if calltype is batch or single
  77. if (callType == ERC7579Utils.CALLTYPE_SINGLE) {
  78. executionCalldata.execSingle(execType);
  79. } else if (callType == ERC7579Utils.CALLTYPE_BATCH) {
  80. executionCalldata.execBatch(execType);
  81. emit Log(false, executionCalldata.decodeBatch());
  82. } else if (callType == ERC7579Utils.CALLTYPE_DELEGATECALL) {
  83. executionCalldata.execDelegateCall(execType);
  84. } else {
  85. revert UnsupportedCallType(callType);
  86. }
  87. }
  88. }
  89. contract ERC7579UtilsTest is Test {
  90. using MessageHashUtils for *;
  91. using ERC4337Utils for *;
  92. using ERC7579Utils for *;
  93. address private _owner;
  94. uint256 private _ownerKey;
  95. address private _account;
  96. address private _beneficiary;
  97. address private _recipient1;
  98. address private _recipient2;
  99. constructor() {
  100. // EntryPoint070
  101. vm.etch(
  102. 0x0000000071727De22E5E9d8BAf0edAc6f37da032,
  103. vm.readFileBinary("node_modules/hardhat-predeploy/bin/0x0000000071727De22E5E9d8BAf0edAc6f37da032.bytecode")
  104. );
  105. // SenderCreator070
  106. vm.etch(
  107. 0xEFC2c1444eBCC4Db75e7613d20C6a62fF67A167C,
  108. vm.readFileBinary("node_modules/hardhat-predeploy/bin/0xEFC2c1444eBCC4Db75e7613d20C6a62fF67A167C.bytecode")
  109. );
  110. // signing key
  111. (_owner, _ownerKey) = makeAddrAndKey("owner");
  112. // ERC-4337 account
  113. _account = address(new SampleAccount(_owner));
  114. vm.deal(_account, 1 ether);
  115. // other
  116. _beneficiary = makeAddr("beneficiary");
  117. _recipient1 = makeAddr("recipient1");
  118. _recipient2 = makeAddr("recipient2");
  119. }
  120. function testExecuteBatchDecodeCorrectly() public {
  121. Execution[] memory calls = new Execution[](2);
  122. calls[0] = Execution({target: _recipient1, value: 1 wei, callData: ""});
  123. calls[1] = Execution({target: _recipient2, value: 1 wei, callData: ""});
  124. PackedUserOperation[] memory userOps = new PackedUserOperation[](1);
  125. userOps[0] = PackedUserOperation({
  126. sender: _account,
  127. nonce: 0,
  128. initCode: "",
  129. callData: abi.encodeCall(
  130. SampleAccount.execute,
  131. (
  132. ERC7579Utils.encodeMode(
  133. ERC7579Utils.CALLTYPE_BATCH,
  134. ERC7579Utils.EXECTYPE_DEFAULT,
  135. ModeSelector.wrap(0x00),
  136. ModePayload.wrap(0x00)
  137. ),
  138. ERC7579Utils.encodeBatch(calls)
  139. )
  140. ),
  141. accountGasLimits: _packGas(500_000, 500_000),
  142. preVerificationGas: 0,
  143. gasFees: _packGas(1, 1),
  144. paymasterAndData: "",
  145. signature: ""
  146. });
  147. (uint8 v, bytes32 r, bytes32 s) = vm.sign(
  148. _ownerKey,
  149. this.hashUserOperation(userOps[0]).toEthSignedMessageHash()
  150. );
  151. userOps[0].signature = abi.encodePacked(r, s, v);
  152. vm.recordLogs();
  153. ERC4337Utils.ENTRYPOINT_V07.handleOps(userOps, payable(_beneficiary));
  154. assertEq(_recipient1.balance, 1 wei);
  155. assertEq(_recipient2.balance, 1 wei);
  156. _collectAndPrintLogs(false);
  157. }
  158. function testExecuteBatchDecodeEmpty() public {
  159. bytes memory fakeCalls = abi.encodePacked(
  160. uint256(1), // Length of execution[]
  161. uint256(0x20), // offset
  162. uint256(uint160(_recipient1)), // target
  163. uint256(1), // value: 1 wei
  164. uint256(0x60), // offset of data
  165. uint256(0) // length of
  166. );
  167. PackedUserOperation[] memory userOps = new PackedUserOperation[](1);
  168. userOps[0] = PackedUserOperation({
  169. sender: _account,
  170. nonce: 0,
  171. initCode: "",
  172. callData: abi.encodeCall(
  173. SampleAccount.execute,
  174. (
  175. ERC7579Utils.encodeMode(
  176. ERC7579Utils.CALLTYPE_BATCH,
  177. ERC7579Utils.EXECTYPE_DEFAULT,
  178. ModeSelector.wrap(0x00),
  179. ModePayload.wrap(0x00)
  180. ),
  181. abi.encodePacked(
  182. uint256(0x70) // fake offset pointing to paymasterAndData
  183. )
  184. )
  185. ),
  186. accountGasLimits: _packGas(500_000, 500_000),
  187. preVerificationGas: 0,
  188. gasFees: _packGas(1, 1),
  189. paymasterAndData: abi.encodePacked(address(0), fakeCalls),
  190. signature: ""
  191. });
  192. (uint8 v, bytes32 r, bytes32 s) = vm.sign(
  193. _ownerKey,
  194. this.hashUserOperation(userOps[0]).toEthSignedMessageHash()
  195. );
  196. userOps[0].signature = abi.encodePacked(r, s, v);
  197. vm.expectRevert(
  198. abi.encodeWithSelector(
  199. IEntryPoint.FailedOpWithRevert.selector,
  200. 0,
  201. "AA23 reverted",
  202. abi.encodeWithSelector(ERC7579Utils.ERC7579DecodingError.selector)
  203. )
  204. );
  205. ERC4337Utils.ENTRYPOINT_V07.handleOps(userOps, payable(_beneficiary));
  206. _collectAndPrintLogs(false);
  207. }
  208. function testExecuteBatchDecodeDifferent() public {
  209. bytes memory execCallData = abi.encodePacked(
  210. uint256(0x20), // offset pointing to the next segment
  211. uint256(5), // Length of execution[]
  212. uint256(0), // offset of calls[0], and target (!!)
  213. uint256(0x20), // offset of calls[1], and value (!!)
  214. uint256(0), // offset of calls[2], and rel offset of data (!!)
  215. uint256(0) // offset of calls[3].
  216. // There is one more to read by the array length, but it's not present here. This will be
  217. // paymasterAndData.length during validation, pointing to an all-zero call.
  218. // During execution, the offset will be 0, pointing to a call with value.
  219. );
  220. PackedUserOperation[] memory userOps = new PackedUserOperation[](1);
  221. userOps[0] = PackedUserOperation({
  222. sender: _account,
  223. nonce: 0,
  224. initCode: "",
  225. callData: abi.encodePacked(
  226. SampleAccount.execute.selector,
  227. ERC7579Utils.encodeMode(
  228. ERC7579Utils.CALLTYPE_BATCH,
  229. ERC7579Utils.EXECTYPE_DEFAULT,
  230. ModeSelector.wrap(0x00),
  231. ModePayload.wrap(0x00)
  232. ),
  233. uint256(0x5c), // offset pointing to the next segment
  234. uint224(type(uint224).max), // Padding to align the `bytes` types
  235. // type(uint256).max, // unknown padding
  236. uint256(execCallData.length), // Length of the data
  237. execCallData
  238. ),
  239. accountGasLimits: _packGas(500_000, 500_000),
  240. preVerificationGas: 0,
  241. gasFees: _packGas(1, 1),
  242. paymasterAndData: abi.encodePacked(uint256(0), uint256(0)), // padding length to create an offset
  243. signature: ""
  244. });
  245. (uint8 v, bytes32 r, bytes32 s) = vm.sign(
  246. _ownerKey,
  247. this.hashUserOperation(userOps[0]).toEthSignedMessageHash()
  248. );
  249. userOps[0].signature = abi.encodePacked(r, s, v);
  250. vm.expectRevert(
  251. abi.encodeWithSelector(
  252. IEntryPoint.FailedOpWithRevert.selector,
  253. 0,
  254. "AA23 reverted",
  255. abi.encodeWithSelector(ERC7579Utils.ERC7579DecodingError.selector)
  256. )
  257. );
  258. ERC4337Utils.ENTRYPOINT_V07.handleOps(userOps, payable(_beneficiary));
  259. _collectAndPrintLogs(true);
  260. }
  261. function testDecodeBatch() public {
  262. // BAD: buffer empty
  263. vm.expectRevert(ERC7579Utils.ERC7579DecodingError.selector);
  264. this.callDecodeBatch("");
  265. // BAD: buffer too short
  266. vm.expectRevert(ERC7579Utils.ERC7579DecodingError.selector);
  267. this.callDecodeBatch(abi.encodePacked(uint128(0)));
  268. // GOOD
  269. this.callDecodeBatch(abi.encode(0));
  270. // Note: Solidity also supports this even though it's odd. Offset 0 means array is at the same location, which
  271. // is interpreted as an array of length 0, which doesn't require any more data
  272. // solhint-disable-next-line var-name-mixedcase
  273. uint256[] memory _1 = abi.decode(abi.encode(0), (uint256[]));
  274. _1;
  275. // BAD: offset is out of bounds
  276. vm.expectRevert(ERC7579Utils.ERC7579DecodingError.selector);
  277. this.callDecodeBatch(abi.encode(1));
  278. // GOOD
  279. this.callDecodeBatch(abi.encode(32, 0));
  280. // BAD: reported array length extends beyond bounds
  281. vm.expectRevert(ERC7579Utils.ERC7579DecodingError.selector);
  282. this.callDecodeBatch(abi.encode(32, 1));
  283. // GOOD
  284. this.callDecodeBatch(abi.encode(32, 1, 0));
  285. // GOOD
  286. //
  287. // 0000000000000000000000000000000000000000000000000000000000000020 (32) offset
  288. // 0000000000000000000000000000000000000000000000000000000000000001 ( 1) array length
  289. // 0000000000000000000000000000000000000000000000000000000000000020 (32) element 0 offset
  290. // 000000000000000000000000xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (recipient) target for element #0
  291. // 000000000000000000000000000000000000000000000000000000000000002a (42) value for element #0
  292. // 0000000000000000000000000000000000000000000000000000000000000060 (96) offset to calldata for element #0
  293. // 000000000000000000000000000000000000000000000000000000000000000c (12) length of the calldata for element #0
  294. // 48656c6c6f20576f726c64210000000000000000000000000000000000000000 (..) buffer for the calldata for element #0
  295. assertEq(
  296. bytes("Hello World!"),
  297. this.callDecodeBatchAndGetFirstBytes(
  298. abi.encode(32, 1, 32, _recipient1, 42, 96, 12, bytes12("Hello World!"))
  299. )
  300. );
  301. // This is invalid, the first element of the array points is out of bounds
  302. // but we allow it past initial validation, because solidity will validate later when the bytes field is accessed
  303. //
  304. // 0000000000000000000000000000000000000000000000000000000000000020 (32) offset
  305. // 0000000000000000000000000000000000000000000000000000000000000001 ( 1) array length
  306. // 0000000000000000000000000000000000000000000000000000000000000020 (32) element 0 offset
  307. // <missing element>
  308. bytes memory invalid = abi.encode(32, 1, 32);
  309. this.callDecodeBatch(invalid);
  310. vm.expectRevert();
  311. this.callDecodeBatchAndGetFirst(invalid);
  312. // this is invalid: the bytes field of the first element of the array is out of bounds
  313. // but we allow it past initial validation, because solidity will validate later when the bytes field is accessed
  314. //
  315. // 0000000000000000000000000000000000000000000000000000000000000020 (32) offset
  316. // 0000000000000000000000000000000000000000000000000000000000000001 ( 1) array length
  317. // 0000000000000000000000000000000000000000000000000000000000000020 (32) element 0 offset
  318. // 000000000000000000000000xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (recipient) target for element #0
  319. // 000000000000000000000000000000000000000000000000000000000000002a (42) value for element #0
  320. // 0000000000000000000000000000000000000000000000000000000000000060 (96) offset to calldata for element #0
  321. // <missing data>
  322. bytes memory invalidDeeply = abi.encode(32, 1, 32, _recipient1, 42, 96);
  323. this.callDecodeBatch(invalidDeeply);
  324. // Note that this is ok because we don't return the value. Returning it would introduce a check that would fails.
  325. this.callDecodeBatchAndGetFirst(invalidDeeply);
  326. vm.expectRevert();
  327. this.callDecodeBatchAndGetFirstBytes(invalidDeeply);
  328. }
  329. function callDecodeBatch(bytes calldata executionCalldata) public pure {
  330. ERC7579Utils.decodeBatch(executionCalldata);
  331. }
  332. function callDecodeBatchAndGetFirst(bytes calldata executionCalldata) public pure {
  333. ERC7579Utils.decodeBatch(executionCalldata)[0];
  334. }
  335. function callDecodeBatchAndGetFirstBytes(bytes calldata executionCalldata) public pure returns (bytes calldata) {
  336. return ERC7579Utils.decodeBatch(executionCalldata)[0].callData;
  337. }
  338. function hashUserOperation(PackedUserOperation calldata useroperation) public view returns (bytes32) {
  339. return useroperation.hash(address(ERC4337Utils.ENTRYPOINT_V07));
  340. }
  341. function _collectAndPrintLogs(bool includeTotalValue) internal {
  342. Vm.Log[] memory logs = vm.getRecordedLogs();
  343. for (uint256 i = 0; i < logs.length; i++) {
  344. if (logs[i].emitter == _account) {
  345. _printDecodedCalls(logs[i].data, includeTotalValue);
  346. }
  347. }
  348. }
  349. function _printDecodedCalls(bytes memory logData, bool includeTotalValue) internal pure {
  350. (bool duringValidation, Execution[] memory calls) = abi.decode(logData, (bool, Execution[]));
  351. console.log(
  352. string.concat(
  353. "Batch execute contents, as read during ",
  354. duringValidation ? "validation" : "execution",
  355. ": "
  356. )
  357. );
  358. console.log(" Execution[] length: %s", calls.length);
  359. uint256 totalValue = 0;
  360. for (uint256 i = 0; i < calls.length; ++i) {
  361. console.log(string.concat(" calls[", vm.toString(i), "].target = ", vm.toString(calls[i].target)));
  362. console.log(string.concat(" calls[", vm.toString(i), "].value = ", vm.toString(calls[i].value)));
  363. console.log(string.concat(" calls[", vm.toString(i), "].data = ", vm.toString(calls[i].callData)));
  364. totalValue += calls[i].value;
  365. }
  366. if (includeTotalValue) {
  367. console.log(" Total value: %s", totalValue);
  368. }
  369. }
  370. function _packGas(uint256 upper, uint256 lower) internal pure returns (bytes32) {
  371. return bytes32(uint256((upper << 128) | uint128(lower)));
  372. }
  373. }