Skip to content

Conversation

@adamegyed
Copy link
Contributor

Motivation

In ERC-6900 v0.8, we’re planning on explicitly defining account self-calls to bypass validation. Previously in 6900 v0.7, this behavior was left up to the runtime validation function, but we depended on this behavior for things that the core account needed to do - like batching internal calls by setting the target to self.

Self-calls are also necessary for 6900 v0.8 to efficiently support two new workflows: executeUserOp, the function introduced in EntryPoint v0.7 to allow accounts to access user op contents in the execution phase, and executeWithAuthorization, a workflow to allow selecting between different runtime validation functions & to provide data to the runtime validation function and its hooks.

In both of those functions, a self-call is used after the “validation” checks are complete. Then, the account is able to execute the function(s) as regular native functions or functions routed through the fallback. Doing so lets us avoid having internal routing for every external function. Internal routing is difficult, un-idiomatic solidity because it involves reimplementing the automatically generated function dispatcher. This would also increase implementation complexity of 6900 v0.8.

However, there is a notable issue with self-call authorization: If a validation function is added to either standard execute functions (execute or executeBatch), then that validation function effectively gets “root” access. Any execution function, including execution functions that the validation function is not allowed to call directly, may instead be called by performing a self-call and encoding the execution function invocation into calldata.

We want to prevent the privilege escalation, while still allowing self-calling for the purposes of batching actions, and handling executeWithAuthorization and executeUserOp.

Solution

When running user op and runtime validation, add an extra check:

  • If the function is execute, disallow a target address of the account. This is unnecessary wrapping, and the inner call may instead be pulled up to the entire calldata itself.
  • If the function is executeBatch, then:
    • for each Call where the target is the account, inspect the selector in calldata:
      • the validation currently being used must apply to that selector
      • the selector must not be another recursing call to the account's execute or executeBatch functions.

This preserves the ability to use self-calls for batched actions, executeUserOp, and executeWIthAuthorization, while preventing privilege escalation.

Also add a test that shows these different use cases with ComprehensivePlugin.

Design Questions

In earlier attempts to solve this problem, I suggested allowing arbitrarily-deep recursion via execute/executeBatch. This would pose a problem to pre-validation hooks looking to inspect calldata, wherein we must either:

  1. require the hooks to also implement arbitrary-depth recursion
  2. have the account unpack all inner calls, and fire the hooks for each invocation.

However, this PR proposes an approach of entirely disallowing execute to self, and only allowing a max recursion depth of 1 for executeBatch. This simplifies things by capping the depth of any required calldata-inspecting pre validation hooks to max 1, and only for the executeBatch selector. Given this constraint, I think it is reasonable to expect pre-validation hooks that fall into this category of "inspecting calldata selectors + params" to either:

  • restrict what selectors a validation may call to exclude executeBatch, and not have to worry about this.
  • allow executeBatch, and unpack + handle the logic for each call in the batch within the hook itself.

Does that seem like a reasonable expectation?

@adamegyed adamegyed requested a review from a team June 19, 2024 21:07
@adamegyed adamegyed force-pushed the adam/self-call-restrictions branch from 2f3cb82 to 2ba7da9 Compare June 19, 2024 21:12
(address target,,) = abi.decode(callData[4:], (address, uint256, bytes));

if (target == address(this)) {
// There is no point to call `execute` to recurse exactly once - this is equivalent to just having
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This effectively prevents execute make self-call, right?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so, because there isn't really a scenario where execute() would do a single self call except for attempted shenanigans, because the call could just be executed regularly too

@fangting-alchemy
Copy link
Collaborator

The solution is simple and makes sense to me. I wonder if there are scenarios we missed where a 2 levels down nested self-calls necessary.
I am for this simplicity approach.

@adamegyed adamegyed force-pushed the adam/sample-allowlist-hook branch from d48a9a4 to 9bc6789 Compare June 20, 2024 17:53
@adamegyed adamegyed force-pushed the adam/self-call-restrictions branch from 2ba7da9 to e0ea804 Compare June 20, 2024 17:53
@adamegyed adamegyed force-pushed the adam/sample-allowlist-hook branch from 9bc6789 to 17906b1 Compare June 20, 2024 20:37
@adamegyed adamegyed force-pushed the adam/self-call-restrictions branch from e0ea804 to 95f32e8 Compare June 20, 2024 20:38
@adamegyed adamegyed force-pushed the adam/sample-allowlist-hook branch from 17906b1 to e3ab9a2 Compare June 20, 2024 20:53
@adamegyed adamegyed force-pushed the adam/self-call-restrictions branch from 95f32e8 to 68995c3 Compare June 20, 2024 20:53
@adamegyed adamegyed force-pushed the adam/sample-allowlist-hook branch from e3ab9a2 to ae5c79a Compare June 20, 2024 21:05
@adamegyed adamegyed force-pushed the adam/self-call-restrictions branch 2 times, most recently from 53ddd40 to 4bcb785 Compare June 20, 2024 21:38
@howydev
Copy link
Collaborator

howydev commented Jun 24, 2024

Hmm, don't think the runtime path is protected. Someone with callPermitted permissions can call any msg.sig. We're deprecating the callPermitted path so not necessary to have a solution here, but if we add any other forms of runtime validation, it would also need to do this selector check. If thats the case, does it make sense to add the check in wrapNativeFunction instead?

Check I was thinking of:
if (msg.sender == address(this) && selector == execute/executeBatch)) => revert

@adamegyed
Copy link
Contributor Author

Someone with callPermitted permissions can call any msg.sig

Ahh shoot, good catch on this. Yes we're deprecating that path, but this PR still has it. I'll make a note to hold off on merging this until we get that change in from #67, and that fix is patched.

As for the inner check - I think within the scope of this PR's change, we're OK without that check, but if we ever re-introduce something like callPermitted (a way to authorize directly calling the account, not through executeWithAuthorization), then we'll need to add a check like that.

Copy link
Collaborator

@howydev howydev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, agree that considering the shape of runtime validation is out of scope of this PR. Additions here LGTM

@adamegyed adamegyed force-pushed the adam/sample-allowlist-hook branch from ae5c79a to 5785dba Compare June 26, 2024 16:20
@adamegyed adamegyed force-pushed the adam/self-call-restrictions branch 2 times, most recently from 5d0e75b to 7b18487 Compare June 26, 2024 16:25
@adamegyed adamegyed force-pushed the adam/sample-allowlist-hook branch from 5785dba to 2a965ac Compare June 26, 2024 16:25
@adamegyed adamegyed force-pushed the adam/self-call-restrictions branch from 7b18487 to 1e415b1 Compare June 26, 2024 18:59
Copy link
Contributor

@huaweigu huaweigu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

function _checkIfValidationAppliesCallData(
bytes calldata callData,
FunctionReference validationFunction,
bool isDefault
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isGlobal? I guess it depends on if renaming PR gets in first

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the rename is on another branch, will merge as soon as possible

_checkIfValidationAppliesSelector(nestedSelector, validationFunction, isDefault);
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we explicitly check executeWithAuthorization and executeUserOp and only allow these to pass?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • executeUserOp doesn't yet exist on this branch, but on the branch where it does, it requires msg.sender to be the entrypoint, so we don't need extra handling here.
  • executeWithAuthorization will re-run a new validation function, so it should be OK to allow - it doesn't really make sense to call it in a nested way via execute/executeBatch due it just adding gas, but it won't break any security guarantees.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I meant reverting on line 609 if random function selector is accidentally calling but not in (executeWithAuthorization, executeUserOp). Maybe it's a bit too defensive.

@adamegyed adamegyed force-pushed the adam/sample-allowlist-hook branch from 2a965ac to 4e0fdc3 Compare June 27, 2024 19:29
@adamegyed adamegyed force-pushed the adam/self-call-restrictions branch from 1e415b1 to 7e76786 Compare June 27, 2024 19:29
@adamegyed adamegyed force-pushed the adam/sample-allowlist-hook branch from 4e0fdc3 to 18abe0f Compare June 27, 2024 20:10
@adamegyed adamegyed force-pushed the adam/self-call-restrictions branch from 7e76786 to 2569ebd Compare June 27, 2024 20:11
@adamegyed adamegyed force-pushed the adam/sample-allowlist-hook branch from 18abe0f to 17ed4e0 Compare June 28, 2024 15:30
@adamegyed adamegyed force-pushed the adam/self-call-restrictions branch from 2569ebd to 1d34450 Compare June 28, 2024 15:31
@adamegyed adamegyed force-pushed the adam/sample-allowlist-hook branch from 17ed4e0 to 78ae1eb Compare June 28, 2024 18:38
@adamegyed adamegyed force-pushed the adam/self-call-restrictions branch from 1d34450 to 5fda9a2 Compare June 28, 2024 18:39
Copy link
Contributor

@huaweigu huaweigu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

_checkIfValidationAppliesSelector(nestedSelector, validationFunction, isDefault);
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I meant reverting on line 609 if random function selector is accidentally calling but not in (executeWithAuthorization, executeUserOp). Maybe it's a bit too defensive.

@adamegyed adamegyed force-pushed the adam/sample-allowlist-hook branch from 78ae1eb to 5c10b0d Compare July 10, 2024 14:45
Base automatically changed from adam/sample-allowlist-hook to v0.8-develop July 10, 2024 14:49
@adamegyed adamegyed force-pushed the adam/self-call-restrictions branch from 5fda9a2 to ce90f55 Compare July 10, 2024 15:23
@adamegyed adamegyed merged commit f376bf0 into v0.8-develop Jul 10, 2024
@adamegyed adamegyed deleted the adam/self-call-restrictions branch July 10, 2024 15:41
// If the selector is executeUserOp, pull the actual selector from the following data,
// and trim the calldata to ensure the self-call decoding is still accurate.
callData = callData[4:];
outerSelector = bytes4(callData[:4]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adamegyed Is this line redundant (same as line 626)? Or do you want to pull the actual selector from bytes4(userOp.callData[4:8])?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do want to pull the actual selector from bytes4(userOp.callData[4:8]), because this is the calling convention of 4337's IAccountExecute interface. This is in this format during validation, but it will look a bit different during execution. Specifically, when using executeUserOp:

  • During validation, userOp.callData will contain:
    • bytes [0:4]: executeUserOp selector
    • bytes[4:end]: actual callData
  • During execution, the EntryPoint will send a call to executeUserOp with the full PackedUserOp struct, and the user op hash.
    • userOp.callData will have the same format as above, which is why we need to skip the first 4 bytes before doing the self-call in the account's implementation of executeUserOp.

It's a bit non-standard, but you can see how it gets encoded here: https://github.com/eth-infinitism/account-abstraction/blob/v0.7.0/contracts/core/EntryPoint.sol#L101-L107

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I think I overlooked the line callData = callData[4:]; (I was probably just following the order in comment here :) ) when I made the previous comment. The current impl is essentially equivalent to

outerSelector = bytes4(callData[4:8]);
callData = callData[4:];

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Improvement] Batch operations with validation reuse

6 participants