feat: add depth-based worst-case attack benchmarks for execute mode#1885
feat: add depth-based worst-case attack benchmarks for execute mode#1885CPerezz wants to merge 2 commits intoethereum:forks/amsterdamfrom
Conversation
This commit introduces a comprehensive worst-case attack benchmark suite that exploits Patricia Merkle Trie computational complexity through: - Pre-mined CREATE2 addresses with configurable prefix sharing depth - Deep storage slots with configurable trie depth - Attack orchestrator contract for efficient batch attacks - Execute mode support with pre-allocation pattern Key changes: - Add test_worst_case_attack_execute.py for execute mode testing - Include AttackOrchestrator.sol for batch attack execution - Add depth_9.sol and depth_10.sol contracts with deep storage - Include pre-mined address data files (s9_acc3/4/5.json) - Dynamic deep slot extraction based on storage_depth parameter - Pre-allocation of all contracts and funds in pre-state - Single-block attack transaction pattern for execute mode The test deploys contracts at addresses with shared prefixes to maximize trie traversal overhead, then uses an orchestrator to efficiently attack deep storage slots, creating worst-case computational scenarios for benchmarking Ethereum client performance.
| for aux_account in auxiliary_accounts: | ||
| aux_address = Address(aux_account) | ||
| # Fund with 1 wei to ensure the account exists in the trie | ||
| pre.fund_eoa(aux_address, value=1) |
There was a problem hiding this comment.
I think this is why the current execute remote flow is failing. From my understanding of the implementation, here we intend to fund 1 wei to aux_address. However, pre.fund_eoa() does not work this way, it creates a new EOA instead of funding an existing account.
I experimented with manually injecting the account, but this only works in fill mode:
pre[aux_address] = Account(address=aux_address, balance=1)The correct solution might be to create funding transactions instead:
funding_tx = Transaction(
to=aux_address,
value=1, # 1 wei to create the account in the trie
gas_limit=21_000,
sender=deployer_eoa,
)
funding_txs.append(funding_tx)- Split transactions across blocks respecting gas_benchmark_value - Deploy orchestrator first with nonce 0 for deterministic address - Fix CREATE2 address derivation in AttackOrchestrator assembly - Scale test to 15,000 contracts
| deploy_tx = Transaction( | ||
| to=NICK_DEPLOYER, | ||
| data=calldata, | ||
| gas_limit=deployment_gas_per_contract + 50000, # Add buffer for CREATE2 operation | ||
| sender=deployer_address, | ||
| max_fee_per_gas=10_000_000_000, # 10 gwei | ||
| max_priority_fee_per_gas=1_000_000_000, # 1 gwei | ||
| ) | ||
| deployment_txs.append(deploy_tx) |
There was a problem hiding this comment.
This sends 15k txs (as in execute mode even if we split them in blocks, they're all sent at the same time).
Thus, killink any RPC endpoint you have even locally.
For 1000 contracts this all works. But for 15k (the goal fo this tests) it just kills Anvil, Geth etc...
Unsure how to proceed..
There was a problem hiding this comment.
We need some rate limit that wait until the first 1000 transactions are complete before proceeding the next 1000 transactions in execute remote. Thanks for sharing these number, i will try with 750 at first
There was a problem hiding this comment.
Can also deploy more contracts in a single tx by creating a contract which calls multiple times in 0x4e59b44847b379578588920ca78fbf26c0b4956c (CREATE2 factory). As calldata we give the starting salt, and for each call into CREATE2 it increases the salt. Alternatively can also not put that in calldata and just store the current salt in storage. Only call into the CREATE2 factory if there is enough gas left. Depending on the size of the contract which we are deploying (this one does not look super big) we will get at least 3x less txs but possibly even more.
| # Extract the deepest storage slot from the contract source | ||
| # The deepest slot is the last sstore in the constructor | ||
| # TODO: This is garbage. But we have no other way to actually get this info. | ||
| # We can't make RPC requests from within the test. | ||
| sol_filename = f"depth_{storage_depth}.sol" | ||
| sol_path = Path(__file__).parent / sol_filename | ||
|
|
||
| with open(sol_path, 'r') as f: | ||
| content = f.read() | ||
| # Find all sstore operations in the constructor | ||
| import re | ||
| sstore_pattern = r'sstore\((0x[0-9a-fA-F]+),\s*1\)' | ||
| sstores = re.findall(sstore_pattern, content) | ||
| if sstores: | ||
| # The last sstore is the deepest slot | ||
| DEEP_SLOT = int(sstores[-1], 16) | ||
| else: | ||
| raise Exception(f"Could not find sstore operations in {sol_filename}") | ||
|
|
||
| print(f"\nDeep storage slot for depth {storage_depth}: {hex(DEEP_SLOT)}") | ||
| print(f"Expected: Changed from initial value of 1 to {attack_value} after attack") |
There was a problem hiding this comment.
I need to do all this crap such that I can get some values from inside the contract (cause I can't call RPCs directly through the tooling).
There was a problem hiding this comment.
Can't we rather store metadata with the solidity source code? RPC calls will not work because filled tests, when consumed by the client test executors don't even spawn an RPC node.
There was a problem hiding this comment.
The depth contracts look alike: the difference is the storage written to in the constructor and the target storage slot. We can encode these as constructor arguments (so there is then also no need for depth_9.sol and depth_10.sol, the storage keys to write to and attack are then in a metadata list, which are thus the values provided currently in those files)
| blockchain_test( | ||
| pre=pre, | ||
| blocks=blocks, | ||
| post=post, | ||
| ) No newline at end of file |
There was a problem hiding this comment.
This is (I assume) useless? As basically it just doesn't propagate blocks as I would expect it to. Neither it waits between blocks to not send all the txs at once.
There was a problem hiding this comment.
Is this to counter the RPC crashing when sending 15k transactions at once? I think we need a proper fix in the execute command machinery to handle extreme cases like this (perhaps setting a max-txs-per-request and split this line into multiple ones?)
| return json.load(f) | ||
|
|
||
|
|
||
| def compile_attack_orchestrator(): |
There was a problem hiding this comment.
We can get rid of this and just have the bytecode file.
|
ping @danceratopz @LouisTsai-Csie Notice that I can simply take a turn and avoid deploying anything here. (Just the orchestator). Thus, on this way, I can just send a single tx. And that would be the whole test. But, this would make this test only runnable on bloatnet. (Which is fine). |
marioevz
left a comment
There was a problem hiding this comment.
Left some comments with improvement suggestions. I'm still not completely sold on the need for compiling solidity, and that's because it's a pain to maintain and make the tests forward compatible.
| max_fee_per_gas=10_000_000_000, # 10 gwei | ||
| max_priority_fee_per_gas=1_000_000_000, # 1 gwei |
There was a problem hiding this comment.
This is automatically calculated by fill/execute, no need to add this.
| max_fee_per_gas=10_000_000_000, # 10 gwei | |
| max_priority_fee_per_gas=1_000_000_000, # 1 gwei |
| # Each SSTORE to a new slot costs ~22100 gas (cold storage) | ||
| constructor_gas = storage_depth * 22100 # Dynamic based on storage depth | ||
| deployment_gas_per_contract = ( | ||
| 32000 # CREATE2 base cost |
There was a problem hiding this comment.
| 32000 # CREATE2 base cost | |
| gas_costs.G_TRANSACTION_CREATE # CREATE2 base cost |
| try: | ||
| create2_data = load_create2_data(storage_depth, account_depth) | ||
| except FileNotFoundError as e: | ||
| pytest.skip(f"Skipping test: {e}") |
There was a problem hiding this comment.
This should be a failure. If we modify parametrization and add a bunch of invalid tests for which json files do not exist we will not notice as they will only show up as skipped tests.
| raise FileNotFoundError(f"Pre-mined data not found: {json_filename}") | ||
|
|
||
| with open(json_path, 'r') as f: | ||
| return json.load(f) |
There was a problem hiding this comment.
These files have a known format, we need to use Pydantic to load these into the proper types:
class ContractInFile(base_model):
salt: int
contract_address: Address
auxiliary_accounts: List[Address]
class ContractsFile(base_model):
deployer: Address
init_code_hash: Hash
target_depth: int
num_contracts: int
total_time: float
contracts: List[ContractInFile]Or something like that, then you can do ContractsFile.model_validate_json to load the file's contents.
| # We need to use transactions, not pre-allocation, for execute mode | ||
| funding_txs = [] | ||
| for i, contract_data in enumerate(contracts): | ||
| auxiliary_accounts = contract_data.get("auxiliary_accounts", []) |
There was a problem hiding this comment.
| auxiliary_accounts = contract_data.get("auxiliary_accounts", []) | |
| auxiliary_accounts = contract_data.auxiliary_accounts |
After we update the loaded files to use Pydantic.
| NICK_DEPLOYER = Address("0x4e59b44847b379578588920ca78fbf26c0b4956c") | ||
|
|
||
|
|
||
| def load_create2_data(storage_depth, account_depth): |
There was a problem hiding this comment.
This function should be a pytest fixture instead, which allows pytest to not have to reload the entire file each time for every test.
| create2_input = ( | ||
| bytes.fromhex("ff") + | ||
| bytes(NICK_DEPLOYER) + | ||
| salt_bytes + | ||
| keccak256(bytes(init_code)) | ||
| ) | ||
| contract_addr = keccak256(create2_input)[-20:] # Last 20 bytes |
| import rlp | ||
| from eth_utils import keccak | ||
| # Nonce = 0 since orchestrator is deployed first | ||
| nonce_for_orchestrator = 0 | ||
| deployer_bytes = bytes.fromhex(str(deployer_address)[2:]) | ||
| orchestrator_address_bytes = keccak(rlp.encode([deployer_bytes, nonce_for_orchestrator]))[12:] | ||
| orchestrator_address = Address("0x" + orchestrator_address_bytes.hex()) |
There was a problem hiding this comment.
| import rlp | |
| from eth_utils import keccak | |
| # Nonce = 0 since orchestrator is deployed first | |
| nonce_for_orchestrator = 0 | |
| deployer_bytes = bytes.fromhex(str(deployer_address)[2:]) | |
| orchestrator_address_bytes = keccak(rlp.encode([deployer_bytes, nonce_for_orchestrator]))[12:] | |
| orchestrator_address = Address("0x" + orchestrator_address_bytes.hex()) | |
| orchestrator_address = orchestrator_deploy_tx.created_contract |
| blockchain_test( | ||
| pre=pre, | ||
| blocks=blocks, | ||
| post=post, | ||
| ) No newline at end of file |
There was a problem hiding this comment.
Is this to counter the RPC crashing when sending 15k transactions at once? I think we need a proper fix in the execute command machinery to handle extreme cases like this (perhaps setting a max-txs-per-request and split this line into multiple ones?)
| # Extract the deepest storage slot from the contract source | ||
| # The deepest slot is the last sstore in the constructor | ||
| # TODO: This is garbage. But we have no other way to actually get this info. | ||
| # We can't make RPC requests from within the test. | ||
| sol_filename = f"depth_{storage_depth}.sol" | ||
| sol_path = Path(__file__).parent / sol_filename | ||
|
|
||
| with open(sol_path, 'r') as f: | ||
| content = f.read() | ||
| # Find all sstore operations in the constructor | ||
| import re | ||
| sstore_pattern = r'sstore\((0x[0-9a-fA-F]+),\s*1\)' | ||
| sstores = re.findall(sstore_pattern, content) | ||
| if sstores: | ||
| # The last sstore is the deepest slot | ||
| DEEP_SLOT = int(sstores[-1], 16) | ||
| else: | ||
| raise Exception(f"Could not find sstore operations in {sol_filename}") | ||
|
|
||
| print(f"\nDeep storage slot for depth {storage_depth}: {hex(DEEP_SLOT)}") | ||
| print(f"Expected: Changed from initial value of 1 to {attack_value} after attack") |
There was a problem hiding this comment.
Can't we rather store metadata with the solidity source code? RPC calls will not work because filled tests, when consumed by the client test executors don't even spawn an RPC node.
jochem-brouwer
left a comment
There was a problem hiding this comment.
Some comments.
Regarding the need to compile solidity, for these kind of setups it certainly lowers the time spent on creating these contracts, because without solidity we have to write everything by hand. That would however be super optimized code (in most cases). For this one, ERC20 contracts with some extra logic have to be created, and it would be rather impossible to create such contract by hand without a compiler.
However, I'm not sure why the code here needs to be an ERC20 contract. If the attack here is a trie-depth based attack then we could also do with minimal code, right? This would write the storage in the constructor and at runtime it would run the attack (writing the value to the target storage slot).
These mined storage keys/account keys, how did you retrieve those? 😄 👍
| // The layout should be: 0xff (1 byte) + deployer (20 bytes) + salt (32 bytes) + codeHash (32 bytes) | ||
| // Total: 85 bytes | ||
| let memPtr := 0x00 | ||
| mstore8(memPtr, 0xff) // 0xff prefix at byte 0 |
There was a problem hiding this comment.
There are constants stored in this for loop which do not need to be there. You can move these constant mstores before the loop (to init the constant parts of memory once)
| let memPtr := 0x00 | ||
| mstore8(memPtr, 0xff) // 0xff prefix at byte 0 | ||
| mstore(add(memPtr, 0x01), shl(96, deployer)) // deployer address at bytes 1-20 | ||
| mstore(add(memPtr, 0x15), i) // salt (i as uint256) at bytes 21-52 |
There was a problem hiding this comment.
For clarity I would not use hex here, but rather ints, so it is easier to check if these constants are correct.
| // Compute CREATE2 address | ||
| let target := and( | ||
| keccak256(0x00, 0x55), // Hash 85 bytes | ||
| 0xffffffffffffffffffffffffffffffffffffffff // Mask to 20 bytes |
There was a problem hiding this comment.
Masking is not necessary. If you CALL something then the EVM will mask it (so hide the topmost 12 bytes)
|
|
||
| This test implements a worst-case scenario for Ethereum block processing that exploits | ||
| the computational complexity of Patricia Merkle Trie operations. It uses CREATE2 to deploy | ||
| contracts at pre-mined addresses with shared prefixes, maximizing trie traversal depth. |
There was a problem hiding this comment.
I think this should be somewhat more explicit: the deployed contracts will write to the storage there, but in such way that with a minimal set of key/values we get a certain trie depth (storage slots all share some prefix).
As a side-note, this is a good way to test the impact of "depth" but it does not test the impact of the actual contract (if it would actually have the depth if we would consider the MPT balanced - here it is completely the opposite and super unbalanced in one direction 😆 ). So if a client reads from a flat database (can directly load storage key without having to traverse the trie first), then if we read a storage key the more keys which "share" some prefix might have impact on the read speed (so the difference between the unbalanced/balanced tree on the state). This is obviously dependent on the database backend used and the db config.
| ) | ||
|
|
||
| # Attack function selector for WorstCaseERC20.attack(uint256) | ||
| ATTACK_SELECTOR = 0x64dd891a # attack(uint256) - verified with: cast sig "attack(uint256)" |
There was a problem hiding this comment.
FYI, this is also keccak256("attack(uint256)")[:4] 😄 👍
| salt_bytes + | ||
| keccak256(bytes(init_code)) | ||
| ) | ||
| contract_addr = keccak256(create2_input)[-20:] # Last 20 bytes |
There was a problem hiding this comment.
Isn't this part of the json already? .contract_address field? Or was that deployed on a different address (so not the 0x4e59b44847b379578588920ca78fbf26c0b4956c)?
| deploy_tx = Transaction( | ||
| to=NICK_DEPLOYER, | ||
| data=calldata, | ||
| gas_limit=deployment_gas_per_contract + 50000, # Add buffer for CREATE2 operation | ||
| sender=deployer_address, | ||
| max_fee_per_gas=10_000_000_000, # 10 gwei | ||
| max_priority_fee_per_gas=1_000_000_000, # 1 gwei | ||
| ) | ||
| deployment_txs.append(deploy_tx) |
There was a problem hiding this comment.
Can also deploy more contracts in a single tx by creating a contract which calls multiple times in 0x4e59b44847b379578588920ca78fbf26c0b4956c (CREATE2 factory). As calldata we give the starting salt, and for each call into CREATE2 it increases the salt. Alternatively can also not put that in calldata and just store the current salt in storage. Only call into the CREATE2 factory if there is enough gas left. Depending on the size of the contract which we are deploying (this one does not look super big) we will get at least 3x less txs but possibly even more.
| attack_txs.append(attack_tx) | ||
|
|
||
| # Create blocks with all transactions in proper order | ||
| # IMPORTANT: Deploy orchestrator FIRST (nonce 0) to have deterministic address |
There was a problem hiding this comment.
Can deploy using Nick's method to be sure the deterministic address is equal 😉
| # Extract the deepest storage slot from the contract source | ||
| # The deepest slot is the last sstore in the constructor | ||
| # TODO: This is garbage. But we have no other way to actually get this info. | ||
| # We can't make RPC requests from within the test. | ||
| sol_filename = f"depth_{storage_depth}.sol" | ||
| sol_path = Path(__file__).parent / sol_filename | ||
|
|
||
| with open(sol_path, 'r') as f: | ||
| content = f.read() | ||
| # Find all sstore operations in the constructor | ||
| import re | ||
| sstore_pattern = r'sstore\((0x[0-9a-fA-F]+),\s*1\)' | ||
| sstores = re.findall(sstore_pattern, content) | ||
| if sstores: | ||
| # The last sstore is the deepest slot | ||
| DEEP_SLOT = int(sstores[-1], 16) | ||
| else: | ||
| raise Exception(f"Could not find sstore operations in {sol_filename}") | ||
|
|
||
| print(f"\nDeep storage slot for depth {storage_depth}: {hex(DEEP_SLOT)}") | ||
| print(f"Expected: Changed from initial value of 1 to {attack_value} after attack") |
There was a problem hiding this comment.
The depth contracts look alike: the difference is the storage written to in the constructor and the target storage slot. We can encode these as constructor arguments (so there is then also no need for depth_9.sol and depth_10.sol, the storage keys to write to and attack are then in a metadata list, which are thus the values provided currently in those files)
| post[contract_address] = Account( | ||
| # Contract should exist with code | ||
| storage={ | ||
| DEEP_SLOT: expected_value, # Exact value from last attack |
There was a problem hiding this comment.
Shouldn't this be attack_value?
|
|
||
| // Set all mined storage slots to 1 | ||
| assembly { | ||
| sstore(0xfeb1bc66963690bd7d902e86ccaf4e0fa1ea72277653d012a3fed288892770fc, 1) |
There was a problem hiding this comment.
Does the address miner also put as a constraint that the preimages have this "nibble by nibble" overlap for the required depth?
Mostly asking because what matters for this to be deep branches is that the hashes of the storage slot numbers satisfy this, not the storage numbers themselves (i.e. the storage slots are hashed to define which is the storage trie address).
(Maybe I'm missing something else from the whole PR setup!)
Edit: or maybe the code that generates this contract is putting the hashes instead of the preimages?
|
Close as PR #1976 done |
This commit introduces a comprehensive worst-case attack benchmark suite that exploits Patricia Merkle Trie computational complexity through:
Key changes:
The test deploys contracts at addresses with shared prefixes to maximize trie traversal overhead, then uses an orchestrator to efficiently attack deep storage slots, creating worst-case computational scenarios for benchmarking Ethereum client performance.