RIP-7614: Expose call stack to contracts
Implement a call stack to record opcodes, addresses and function selectors and expose through a precompiled contract.
Abstract
Implement a call stack to record opcodes, addresses and function selectors and expose through a precompiled contract. If implemented, the precompile will give protocols deeper visibility into addresses involved at any point in execution.
Motivation
This proposal seeks to advance smart contract security in the Ethereum L2 ecosystem by enabling more robust exploit prevention solutions that depend on deeper visibility into the transaction call stack.
Threat detection has advanced a lot in the last year. There are at least a dozen security projects focused on monitoring and exploit detection, and collectively they are proving that attackers can be identified in advance. Early detection hinges on being able to identify malicious smart contracts as soon as they are deployed on-chain using a combination of static and dynamic analysis. Once malicious contracts are flagged, protocols can screen incoming transactions and revert if they include one of these addresses. In parallel, anomaly detection - identifying transactions outside of normal user behavior - is also emerging as a legitimate approach to preventing exploits. Being able to consistently identify attackers and anomalies in advance opens the door to transaction screening, where protocols can choose to automatically revert transactions from high risk entities, or transactions that fall significantly outside "expected behavior".
One technical challenge limiting the long-term effectiveness of transaction screening is address visibility. Today, a smart contract only has visibility into msg.sender
and tx.origin
, not the full call stack. An attacker can use various forms of proxies to "obfuscate" the true source of the call and circumvent detection. While these circumvention techniques are not being used today, we expect hackers to quickly adopt them once transaction screening becomes more pervasive.
This proposal introduces a non-intrusive way to increase visibility into hackers' obfuscation techniques by keeping track of the call stack and exposing the latest list via an EVM precompiled contract when requested at any specific point of EVM execution. This helps the contracts screen more addresses and patterns before proceeding.
Specification
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Constants
Name | Value |
---|---|
PRECOMPILE_ADDRESS | TBD |
CALL_STACK_PER_CALL_COST | 5 |
PRECOMPILE_BASE_GAS_COST | TBD |
PRECOMPILE_PER_CALL_COST | 2 |
Call
A Call is defined by an opcode, an address and a function selector.
The selector MUST be the first four bytes of the call data for CALL-style opcodes, if call input is non-zero. For CREATE-style opcodes it MUST be an empty value.
CallStack
This is proposed as a new type of stack for client implementations to include and is based on the "message-call/contract-creation stack" mentioned in Ethereum Yellow Paper. It is separate from the machine stack used for execution.
CallStack MUST be initialized per transaction simulation, unlike how a new machine stack is initialized per call frame.
All operations that create a new call frame MUST push a Call to the CallStack and MUST pop after the call frame has exited. In the Cancun hard fork specification, this list operations consists of CALL
, CALLCODE
, DELEGATECALL
, STATICCALL
, CREATE
and CREATE2
. The initial transaction call frame MUST always be pushed as a Call when the transaction execution starts and must be popped when the execution finishes.
Precompiled contract
The call stack SHOULD implement the precompiled contract interface defined in the client implementation and MUST encode and return the contents of the call stack.
The encoder logic MUST follow Solidity Contract ABI Specification to encode the list of Calls. While this provides an encoding standard and Solidity friendliness, it does not introduce any difficulty in supporting another language, since the encoded bytes are trivial to parse.
The encoder MUST write each value as a 32-byte padded word as in below pseudocode:
Assuming that the call stack has one call such as
- Opcode:
CALL
(0xf1
) - Address:
0xafafafafafafafafafafafafafafafafafafafaf
- Selector:
0xabcdef12
then the 32-byte words in the encoded output would be:
Please note that the original output is a contiguous array and is not line-delimited.
Gas costs
Call stack gas cost
The call stack implementation adds minimal and negligible overhead to EVM execution. Each call stack item is smaller than 32-byte words pushed to the machine stack. For these reasons, clients MAY charge transaction senders a total of CALL_STACK_PER_CALL_COST
per Call pushed/popped to/from the CallStack. This value is chosen to follow the total cost of PUSH1
and POP
. Given that CALL_STACK_PER_CALL_COST
is very small compared to costs of the opcodes listed in the CallStack section, it is OPTIONAL to charge transaction senders with this amount.
Precompiled contract gas cost
Encoder overhead at the time of precompile execution is at a reasonable level when a reference implementation is benchmarked against readily available precompiled contracts. For this reason, PRECOMPILE_BASE_GAS_COST
SHOULD be introduced to cover any base level overhead from encoding and the call stack.
PRECOMPILE_PER_CALL_COST
value SHOULD either be equal to or greater than the gas cost of CALLER
and ADDRESS
opcodes so that competition would be avoided if PRECOMPILE_BASE_GAS_COST
was chosen as zero and when CallStack size is one. Having a per-call gas cost is also in line with scaling up the precompile cost with every call.
Precompile call example
Rationale
Observing addresses beyond tx.origin
and msg.sender
This functionality is useful in determining the other contracts involved in a transaction up to the point of execution, between tx.origin
and the latest msg.sender
.
Moreover, blackhats deploy attack contracts before launching the attack and that gives a time frame to scan and detect the malicious contract. If the attack transaction does a DELEGATECALL
from a proxy to the attack contract, to call the victim contract, then the msg.sender
observed by the victim contract is the proxy contract (and not the actual malicious attack contract). The call stack breaks this evasion by exposing the DELEGATECALL
ed addresses in the call stack.
Evasion concerns
Logic exploits happen in the form of a malicious contract calling a victim contract, directly or indirectly, one or many times. Off-chain detection mechanisms are able to update on-chain risk/reputation oracles after analyzing deployed contracts. This means that, a screening solution that uses the call stack should ideally check each address either in a whitelist oracle or check contract age and then check in a negative reputation oracle. Combined with such checks, any timing of attack contract deployment and attack transaction/call will not help an attacker evade on-chain detection and this proposal's aim to make extra visibility useful will succeed. However, please note that this proposal does not try to suggest or solve anything whatsoever about how such checks should be implemented since we intend to solve only the visibility disadvantage protocols have and would like to level protocols' transaction visibility with attackers.
Account abstraction support
By design, the call stack and the precompiled contract support the account abstraction outlined in ERC-4337.
In ERC-4337, UserOperations are calls to (non-EOA) smart wallets validated and bundled into a single transaction to be executed. In the reference implementations, each UserOperation is executed sequentially and isolated from each other. The call stack implementation maintains this isolation by not exposing addresses from one UserOperation to another. A smart wallet and any deeper callees are only exposed to the global singleton EntryPoint contract commonly with other UserOperation execution.
Pattern checks
With the help of the opcodes and function selectors, contracts can implement security mechanisms which reason about the call patterns in a transaction. We believe that this is one step further in implementing transparent on-chain anomaly-based threat prevention solutions.
Address checks
While this proposal enhances visibility into addresses involved in a transaction, it does not prescribe how such addresses should be checked. Combining this call stack precompile with complimentary checks for contract reputation and age checks will further enhance transaction screening, but those checks are also outside the scope of this proposal.
Impact on composability
By itself, the precompile does not impact composability. However, if implemented and leveraged in conjunction with a transaction screening solution, composability may be impacted. Nevertheless, this is viewed as an acceptable result as the authors believe each protocol has the right to manage its own risk and decide for itself what transactions it allows/disallows.
Backwards Compatibility
The call stack does not affect any previous execution and requires no consensus changes. The precompiled contract, however, can affect how contracts react during transaction execution and should require all clients to upgrade.
Reference Implementation
https://github.com/ethereum/go-ethereum/pull/28947
Copyright
Copyright and related rights waived via CC0.