RIP-7614: Expose call stack to contracts

Arguments against would be

*- it can break composability and enable censorship. Basically a dapp would be able to censor other dapps along the call chain. *

- it can enable new harder-to-analyze attacks since the attackers would be able to analyze stack info

- complexity of resulting code would make security audits more expensive

Overall, modern languages that target security go into making things more simple vs making them more complex.

Thanks @kladkogex. Can you expand on why security audits would be more expensive as a result of L2s adopting this precompile? The authors were envisioning this being implemented at the chain level, not the dapp level.

Well the price of the security audit depends on the complexity of stack.

If inside the code there are security protection that read information from outside the current function, auditing these protections will be really hard.

It’s definitely a new approach for precompiles in terms of implementation and use-case, congrats on the proposal.

My questions are:

  • It would be really nice to see some example use-cases by dApps to understand your vision for utilizing this precompile. Then, it will be easier to imagine how this is useful. I am curious how generalized precautions can be detected by utilizing CallStack. Also, even if generating CallStack is a negligible overhead, how do you expect the gas costs of utilizing CallStack. As a dApp developer, I can’t imagine how the CallStack can be analyzed or dApps can be designed to predict security issues.
  • How much do you think this development appeals to all developers? I feel like it’s an additional and a bit complex security responsibility.
  • Do you plan to provide the exact specs of the precompile contract itself? It should be defined in the proposal without the need for reference implementation.

My recommendation is:

I expect the precompiles as pure functions to replace some other functions in a smart contract which consumes huge amount of gas or be able to execute some functionalities that cannot be achieved by Solidity etc. Also, the reference implementation itself shows that this precompile should have a divergent implementation than other precompiles.

  • Is there a specific reason that you have used a precompiled contract to expose the CallStack?
  • Can the same functionality be provided by a new Opcode?
3 Likes

Thanks a lot for the feedback @ulerdogan - great questions!

  • It would be really nice to see some example use-cases by dApps to understand your vision for utilizing this precompile. Then, it will be easier to imagine how this is useful. I am curious how generalized precautions can be detected by utilizing CallStack. Also, even if generating CallStack is a negligible overhead, how do you expect the gas costs of utilizing CallStack. As a dApp developer, I can’t imagine how the CallStack can be analyzed or dApps can be designed to predict security issues.
  • How much do you think this development appeals to all developers? I feel like it’s an additional and a bit complex security responsibility.

The screening mechanism implemented in the target/victim protocol contract (e.g. with the help of a Solidity library) would be retrieving the call stack through the precompile interface. So it could either be adopted with custom screening logic or by using a screening library that uses the precompile.

Such screening logic would need to do checks on the addresses i.e. read storage. Transactions of a regular user is simple enough that the amount of addresses and the call stack depth remains at a low level. So the gas cost of screening (precompile call + checks) remains small. If screening cost somehow scales, then it only hurts an attacker that sends a complex transaction. In any case, we consider these checks out of scope of this proposal since the only goal is providing more visibility to the protocol contracts. :slightly_smiling_face:

  • Do you plan to provide the exact specs of the precompile contract itself? It should be defined in the proposal without the need for reference implementation.

The proposal has two technical components: a call stack, and a precompile interface that exposes it. We outline in the Specification:

  • how the call stack should be defined and updated, without definining concrete client implementation details,
  • how the precompile interface should encode the call stack data and return to the caller in EVM.

We could do a little bit more explaining in the Specification if you feel that some bits are unclear.

  • Is there a specific reason that you have used a precompiled contract to expose the CallStack?
  • Can the same functionality be provided by a new Opcode?

Same question crossed my mind during the development of this proposal and, yes, the same functionality could be provided as an opcode but there are two challenges with it:

  • Adopting a new opcode is much harder than adopting a new precompiled contract. Testing custom opcodes are not easy and Solidity compiler would require changes to support it.
  • Evolving a precompiled contract is easier. If a future proposal suggests that the call data should be extended with new fields, then the encoding logic would have to change. Maintaining backwards compatibility after such changes in a precompiled contract is easier as all we have to do would be making the precompile accept a flag to turn on the new fields, and keep the old encoding if the flag is not provided. Maintaining this with an opcode looks harder.
2 Likes

I don’t see how you would even start to prove this in ZK in an efficient manner.

When proving something with dynamic size, you need to prove all code paths, all sizes.

Here, everything is dynamic and as gas cost increases, you would have an exponential amount of possible states to prove.

This looks also like quite tricky to get right, test and fuzz and looks like very bug prone. In regular programming, manipulating the stack is very tricky to get right, see discussions around longjmp and setjmp.

In general, opcodes that are non-deterministic are avoided in the EVM, which is why there are proposal such as EIP-3690: EOF - JUMPDEST Table (EIP-3690: EOF - JUMPDEST Table) to ease execution analysis. This precompile goes in the complete opposite direction.

Finally all the other precompiled contracts are pure functions, with very well understood results. This one looks like even more complex than feu SELFDESTRUCT.

2 Likes

Hi @mratsim, thanks a lot for the feedback! We find the adoption of this precompile by zkEVM rollups very important.

We are proposing a call stack that is:

  • completely separate from the stack used in every call frame,
  • is a singleton per EVM transaction simulation and not redefined every frame,
  • manipulated only by CALL, CALLCODE, DELEGATECALL and STATICCALL opcodes upon enter (push) and exit (pop),
  • exposed through a precompiled contract interface.

When proving something with dynamic size, you need to prove all code paths, all sizes.

Here, everything is dynamic and as gas cost increases, you would have an exponential amount of possible states to prove.

If execution of CALL, CALLCODE, DELEGATECALL and STATICCALL can be proven, I wonder why the call stack could not be, given that the call stack state at any point could be deriven by looking at the opcodes that appear in the trace.

This looks also like quite tricky to get right, test and fuzz and looks like very bug prone. In regular programming, manipulating the stack is very tricky to get right, see discussions around longjmp and setjmp.

We propose that it is only the opcodes which I listed above manipulate the call stack - nothing else. The variety of pushed and popped data is very limited and it is done by the opcodes. Would you still agree that it is still hard to test and bug prone?

Finally all the other precompiled contracts are pure functions, with very well understood results. This one looks like even more complex than feu SELFDESTRUCT.

The “precompile” is an interface that merely reflects the current state of the call stack. It only encodes an array of elements, which is a very predictable process. The reason why we choose a precompile interface is because it looks easier to adopt and evolve.

We are very curious to know if there is anything we can do to make the call stack and the precompile more ZK friendly. Looking forward to hearing more suggestions about this, thanks!

I don’t understand the implementation enough to comment, but after reading the dialogue here I’m also interested in seeing several concrete examples of how widely useful this would be. I’ve spent a lot of time in detection engineering for traditional cyber security and often we look for the OS to expose features that we think will make detection easier and after much effort we find there are actually very few use cases. I would like to see half a dozen or more concrete use cases with teams of developers outside of OpenZeppelin and Forta demonstrating how this would be a game changer vs. what is currently possible.

I’m also quite concerned about potential abuses. I would need a whole lot more detail before being comfortable supporting this.

Some specific feedback on the RIP

  • The scope of the operations needs to be expanded, at least to CREATE and CREATE2, as those also create message frames. Perhaps make it a general statement like “All operations that create a new message frame MUST push a Call to the CallStack. In the Cancun fork specification this list is CALL, CALLCODE, DELEGATECALL, STATICCALL, CREATE, CREATE2, and the initial call frame of a transaction.”
  • There needs to be a way to distinguish create transactions from call transactions. Perhaps a pseudo opcode?
  • What about contracts that are hand written, or Fe, or other languages that do not follow the Solidity ABI? Such as MoveVM cross compiled to EVM, for example? Will signature checks be validated against new languages?
  • How are function signatures identified? There is a large amount of Solidity ABI explicitly required by this spec, which is not good. A section describing how to retrieve that independent of anything outside the EVM needs to be included. such as “The signature is the first four bytes of the call data for the message frame for CALL style opcodes, zero extended if necessary. For Create style opcodes it is <pick a constant>”. But per the prior point, are these reliable function signatures?
  • The function signatures can be faked, or collisions found. Access to the entire input arguments may be needed.
  • Java used to rely on stack checking extensively in it’s Applet sandbox defenses. The string of Applet exploits in the early 2010s usually involved a previously unknown and clever way to overcome the stack checking. Considering how composable Ethereum is I expect a lot of the techniques are portable.
6 Likes

I would like to see several specific examples of how this would be used in practice perhaps with past hacks as an example. It’s not really clear what class of bugs this is meant to prevent and why catching them at runtime is preferable to during development using static and/or dynamic analysis. Potentially, simple mechanisms like the one used by Arbitrum Stylus to detect reentrancy at runtime could be used depending on the motivation.

As it stands, aside from preventing interactions with specific addresses based on off-chain heuristics, I would argue that any security property that is violated is incorrect in it of itself and would warrant fixing instead of relying on this functionality. Adding precompiles that are only enabled on rollups involves rollups e.g. Arbitrum and Optimism each configuring their forks of go-ethereum to activate in hard forks and I haven’t seen any discussion of alternatives that would be more straightforward to maintain and not risk consensus issues e.g. activating the precompile on mainnet or prematurely on the rollups.

Additionally, I think that the added complex of reasoning about call stacks for developers, tooling (fuzzers and formal verification, for instance), and ZK proving (as others already noted) is under appreciated. For instance, the dynamic cost of calling the precompile is just one more consideration that must be examined carefully on top of the already complex gas accounting in the EVM and how it interfaces with Solidity. Now, every tool must implement support for this standard and have the ability to toggle it on/off based off the rollup and EVM version. This has already been error prone coordinating across solc, hardhat/foundry, and rollups for PUSH0; however, this could be said for any RIP and maybe is a separate discussion.

3 Likes

Thanks for the feedback @rpolysec. Just a reminder that the precompile by itself is neutral. It is simply exposing call stack data to protocols that is already available to the caller, block builders, etc.

One exciting use case for the precompile is supporting exploit prevention; however, it must be paired with a screening solution (of which there are several that benefit from this add’l visibility) before it can have an impact. The authors aren’t suggesting a specific approach - there are pros and cons to negative reputation, positive reputation and anomaly-based screening. Screening is possible today, but it’s less robust than it could be due to the limited visibility dapps have into the call stack.

We’ll pull together some material on the impact on exploits. In the meantime, would like to understand some of the abuse patterns you are thinking about. Can you share more?

Some thoughts about how this will improve security:

  1. Efficient Reentrancy guard - Today, reentrancy guard is implemented using modifiers and a dedicated storage slot. Using the precompile will enable a more gas-friendly reentrancy check and will also reduce the problems caused by bad integration of the current reentrancy guard solutions (we’ve summarized some of the less effective reentrancy guard integration here - https://www.spherex.xyz/post/reentrancy-guard-2-0 )
  2. Static Reentrancy Guard - Utilizing the precompile will introduce, for the first time, a countermeasure to combat static reentrancy attacks (which wasn’t possible since reentrancy guard uses a storage slot) - see https://www.youtube.com/watch?v=8D5ZJyU-dX0 )
  3. In the context of SphereX - We have a unique approach of analyzing a given protocol history and checking during execution of future transactions that the transaction effect on the protocol doesn’t differ from how the protocol used to operate (in a nutshell). Let’s take the Poly hack as example. Our on-chain engine would have been able to notice, using the precompile, that this is the first time putCurEpochConPubKeyBytes was called from EthCrossChainManager (of course, this would have been detectable using msg.sender, but think of the generalized case) and actually revert this transaction

We’ve analyzed numerous of previous hacks and proved our solution could have prevented them. Using the precompile increases the number of ways we can check transactions against the protocol history.

2 Likes

Had a chance to listen to the call and Mamy Ratsimbazafy seemed to voice the concerns I’d want to look into more. The potential unintended consequences of introducing additional complexity at this layer. This seems like early days though so I’ll hold further comment. There’s really aren’t enough details here for me to really understand it deeply enough.

I largely agree with the questions and comments raised above. This feels like a complex feature and I’d like to see concrete examples of how it’s expected to improve security. My understanding of the proposal’s intention is that the precompile is used in conjunction with off-chain early detection services as follows:

  • Bot detects newly deployed contract that looks suspicious.
  • Service provider adds that address as an entry in some SuspiciousAccounts contract they maintain.
  • Smart contracts can call the precompile for the accounts in the call stack, and compare them against SuspiciousAccounts entries to determine if they should revert.

If that’s correct, this precompile is based on the assumption that attackers will deploy suspicious looking contracts with enough time for service providers to react and update their SuspiciousAccounts contract. In which case, wouldn’t attackers just execute their exploits in initcode (which is already being done, for example this exploit) rendering this precompile ineffective for it’s primary use case?

Hey all, thank you so much for your feedback! We made some updates to the proposal to improve and clarify some points based on the recent discussions. Please see this this diff.

  • Added CREATE and CREATE2 opcode frames to the call stack as per @shemnon’s suggestion
  • Mentioned the encoder compatibility with different languages that compile to EVM
  • Improved the gas cost explanation
  • Extended the Rationale to address frequently asked evasion cases

Let us know what you think!

1 Like

Thanks @shemnon, super useful suggestions!

  • The scope of the operations needs to be expanded, at least to CREATE and CREATE2, as those also create message frames. Perhaps make it a general statement like “All operations that create a new message frame MUST push a Call to the CallStack. In the Cancun fork specification this list is CALL, CALLCODE, DELEGATECALL, STATICCALL, CREATE, CREATE2, and the initial call frame of a transaction.”

I updated the CallStack description with your feedback, in this diff. I’m curious what you think.

  • There needs to be a way to distinguish create transactions from call transactions. Perhaps a pseudo opcode?

Now that we have added CREATE and CREATE2 frames to the call stack (along with the initial frame, regardless of what kind), the screening mechanism could reason about that based on the oldest call frames in the call stack. If a transaction is checked, that would be the opcode of the first frame in the list. If an ERC-4337 UserOperation is checked, then it would be another intermediary frame.

  • What about contracts that are hand written, or Fe, or other languages that do not follow the Solidity ABI? Such as MoveVM cross compiled to EVM, for example? Will signature checks be validated against new languages?

I put some thought in to this. It seems like Fe would support this encoding. And it doesn’t seem to be problematic for any other language which somehow wouldn’t because the raw bytes returned from the precompiled contract could easily be parsed to form a CallStack array - the encoding is simple.

  • How are function signatures identified? There is a large amount of Solidity ABI explicitly required by this spec, which is not good. A section describing how to retrieve that independent of anything outside the EVM needs to be included. such as “The signature is the first four bytes of the call data for the message frame for CALL style opcodes, zero extended if necessary. For Create style opcodes it is ”. But per the prior point, are these reliable function signatures?

I realized one mistake in naming so renamed “signature” to “selector”. Fe seems to use first four bytes as the selector. Using any other compiler would result in seeing the first four bytes of the call input and I currently don’t have a better idea than defaulting to that.

I updated the explanation with your suggestion regarding the CALL-style and CREATE-style opcodes.

  • The function signatures can be faked, or collisions found. Access to the entire input arguments may be needed.

We could add this but might require some flag in the precompile arguments to turn on and off full call data and might complicate encoding. Let’s wait and see if there is more demand for this.

  • Java used to rely on stack checking extensively in it’s Applet sandbox defenses. The string of Applet exploits in the early 2010s usually involved a previously unknown and clever way to overcome the stack checking. Considering how composable Ethereum is I expect a lot of the techniques are portable.

It’s hard to disagree to this and foresee what exploit patterns will be discovered, but we expect that the attacks will become much harder to design and execute as prevention solutions advance. We believe that having this kind of on-chain visibility improvement is the first step.

Hi @0xalpharush, thanks for the insightful comment. We will provide more explanations but meanwhile, does our Motivation and new Rationale (“Evasion concerns”) in the proposal make sense? We are mainly trying to increase visibility on the contracts that can hide behind proxies. The main problem is that once a contract uses DELEGATECALL to launch the exploit, there is no way of knowing from the victim contract’s perspective that the logic contract could be a flagged one: msg.sender remains as the proxy.

As it stands, aside from preventing interactions with specific addresses based on off-chain heuristics, I would argue that any security property that is violated is incorrect in it of itself and would warrant fixing instead of relying on this functionality.

Totally agreed on fixing the problem at the source - getting a high quality audit is crucial in mitigating exploits. Unfortunately, the real world examples from the industry show that the exploits may happen despite the popularity of pre-chain checks. This is making real time attack detection and prevention more essential everyday.

This has already been error prone coordinating across solc, hardhat/foundry, and rollups for PUSH0; however, this could be said for any RIP and maybe is a separate discussion.

Great point. This is one of the reasons why we believe that a precompiled contract interface is easier to support, adopt and evolve.

Great question. Since this has been asked several times in different forms, I felt the need to update the proposal Rationale to address this concern. Could you please take a look at the new “Evasion concerns” subsection in this diff and let us know what you think about it? Thanks!

Unfortunately, stack manipulation can happen on every opcode - in case of out-of-gas.

The new “call stack” is separate from the machine stack and is only manipulated (push/pop) by certain opcodes. It is expanded when a frame enters and shrunk when a frame exits. Could you elaborate on how opcodes other than the listed in the proposal could affect this?