-
Notifications
You must be signed in to change notification settings - Fork 45
feat(nano): Add reentrancy protection by default #1335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,7 @@ | |
| NCAlreadyInitializedContractError, | ||
| NCFail, | ||
| NCForbiddenAction, | ||
| NCForbiddenReentrancy, | ||
| NCInvalidContext, | ||
| NCInvalidContractId, | ||
| NCInvalidInitializeMethodCall, | ||
|
|
@@ -55,6 +56,7 @@ | |
| from hathor.nanocontracts.storage import NCBlockStorage, NCChangesTracker, NCContractStorage, NCStorageFactory | ||
| from hathor.nanocontracts.storage.contract_storage import Balance | ||
| from hathor.nanocontracts.types import ( | ||
| NC_ALLOW_REENTRANCY, | ||
| NC_ALLOWED_ACTIONS_ATTR, | ||
| NC_FALLBACK_METHOD, | ||
| NC_INITIALIZE_METHOD, | ||
|
|
@@ -371,15 +373,18 @@ def syscall_proxy_call_public_method_nc_args( | |
| method_name=method_name, | ||
| actions=actions, | ||
| nc_args=nc_args, | ||
| skip_reentrancy_validation=True, | ||
| ) | ||
|
|
||
| def _unsafe_call_another_contract_public_method( | ||
| self, | ||
| *, | ||
| contract_id: ContractId, | ||
| blueprint_id: BlueprintId, | ||
| method_name: str, | ||
| actions: Sequence[NCAction], | ||
| nc_args: NCArgs, | ||
| skip_reentrancy_validation: bool = False, | ||
| ) -> Any: | ||
| """Invoke another contract's public method without running the usual guard‑safety checks. | ||
|
|
||
|
|
@@ -419,6 +424,7 @@ def _unsafe_call_another_contract_public_method( | |
| method_name=method_name, | ||
| ctx=ctx, | ||
| nc_args=nc_args, | ||
| skip_reentrancy_validation=skip_reentrancy_validation, | ||
| ) | ||
|
|
||
| def _reset_all_change_trackers(self) -> None: | ||
|
|
@@ -527,6 +533,7 @@ def _execute_public_method_call( | |
| method_name: str, | ||
| ctx: Context, | ||
| nc_args: NCArgs, | ||
| skip_reentrancy_validation: bool = False, | ||
| ) -> Any: | ||
| """An internal method that actually execute the public method call. | ||
| It is also used when a contract calls another contract. | ||
|
|
@@ -558,6 +565,9 @@ def _execute_public_method_call( | |
| parser = Method.from_callable(method) | ||
| args = self._validate_nc_args_for_method(parser, nc_args) | ||
|
|
||
| if not skip_reentrancy_validation: | ||
| self._validate_reentrancy(contract_id, called_method_name, method) | ||
|
|
||
| call_record = CallRecord( | ||
| type=CallType.PUBLIC, | ||
| depth=self._call_info.depth, | ||
|
|
@@ -855,11 +865,11 @@ def syscall_create_another_contract( | |
| self._internal_create_contract(child_id, blueprint_id) | ||
| nc_args = NCParsedArgs(args, kwargs) | ||
| ret = self._unsafe_call_another_contract_public_method( | ||
| child_id, | ||
| blueprint_id, | ||
| NC_INITIALIZE_METHOD, | ||
| actions, | ||
| nc_args, | ||
| contract_id=child_id, | ||
| blueprint_id=blueprint_id, | ||
| method_name=NC_INITIALIZE_METHOD, | ||
| actions=actions, | ||
| nc_args=nc_args, | ||
| ) | ||
|
|
||
| assert last_call_record.index_updates is not None | ||
|
|
@@ -973,6 +983,17 @@ def _validate_context(self, ctx: Context) -> None: | |
| if isinstance(action, BaseTokenAction) and action.amount < 0: | ||
| raise NCInvalidContext('amount must be positive') | ||
|
|
||
| def _validate_reentrancy(self, contract_id: ContractId, method_name: str, method: Any) -> None: | ||
| """Check whether a reentrancy is happening and whether it is allowed.""" | ||
| assert self._call_info is not None | ||
| allow_reentrancy = getattr(method, NC_ALLOW_REENTRANCY, False) | ||
| if allow_reentrancy: | ||
| return | ||
|
|
||
| for call_record in self._call_info.stack: | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current code runs in O(n) time, where n is the maximum allowed depth (currently set to 100). We could optimize it to run in O(1) time by maintaining counters in a
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 100 equality comparisons is too cheap, I don't think it's a problem.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It can get up to 1+2+3+...+100 = 100*101/2 = 5050 comparisons, actually. But I guess it's still not much and shouldn't be a problem. We can always optimize it later. Maybe just leave a note about it?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jansegre What do you think? This check runs in O(n) where n is the stack size. So, the overall algorithm runs in O(n^2) where n is the maximum stack size it reaches during execution. is it worth the effort to reduce the time complexity to O(n)? Any DoS attack vectors here?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel like this can be optimized, but I don't think it should be a blocker. Do we have any idea about the order of magnitude of time it takes when n=100? Being proportional to ~10.000 times a single verification should be manageable I think, but certainly not ideal.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To be clear, I don't think this is a blocker to merge this PR. I think it's OK to add a task to optimize it later. I just think it's possible to avoid looking at the whole stack every time, maybe memoizing the last call on the same stack somehow so each check doesn't need to go up the stack again, only up to the last call. |
||
| if call_record.contract_id == contract_id: | ||
| raise NCForbiddenReentrancy(f'reentrancy is forbidden on method `{method_name}`') | ||
|
|
||
| def _validate_actions(self, method: Any, method_name: str, ctx: Context) -> None: | ||
| """Check whether actions are allowed.""" | ||
| allowed_actions: set[NCActionType] = getattr(method, NC_ALLOWED_ACTIONS_ATTR, set()) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.