Network not being able to confirm new transactions (total network shutdown)
Description
Summary
This report is a straightforward demonstration of submitting a valid transaction to the REST API, which gets inserted into the mempool, sequenced for execution, and finally crashes all nodes executing the transaction.
Impact
All nodes executing a transaction crash into an unrecoverable state.
Is Aptos also vulnerable?
When I discovered the attack vector, I tested it against a local fork of Aptos' mainnet to confirm if Aptos was vulnerable.
Only the Movement Network is affected. Aptos is safe.
Description
The vector causes a runtime panic at a specific point in a transaction's execution, unleashing a loop of more runtime panics. Finally, the node disconnects from the sequencer and stops responding.
To reproduce the bug, an attacker will:
Step 1. Use the network's REST API to deploy a specific malicious move contract.
Step 2. Use the network's REST API to submit a transaction with a "max gas" set to 3 Octas and interact with the malicious contract.
More details below.
Step 1. Deploying the malicious move contract
1.1 Creating the contract
The malicious move contract contains 200 structs that recursively implement each other. (Compiling and deploying such code works perfectly fine in Aptos Network and Movement Network.)
Then, we implement a public entry point named "do_nothing", which initializes a struct S200.
Different operating systems have their own default and maximum stack size limits to prevent system crashes if a process allocates an excessively large stack (e.g., deep recursion); Linux has an 8 MB limit.
Compiling this move contract into bytecode requires a lot of deep recursion. Therefore, the next step for the attacker is to modify his own system's stack size limit. In the CLI he runs the following:
You can verify your system's stack size limit running ulimit -s. An output of "8192" means the stack size limit is 8MB.
Finally, the attacker compiles the move contract. A machine with 64 GB of RAM is more than enough for this step. Optionally, you can set the limit of the stack size back to what it was with:
1.2 Deploying the contract
The attacker deploys the contract using:
The transaction will succeed.
This binary can be deployed to either Aptos Network or Movement Network without issues. Both blockchains accept and deploy the bytecode.
Step 2. Submit a transaction with a "max gas" set to 3 Octas and interact with the malicious contract
The minimum gas required for a transaction is 2_760_000 gas units (~2.7 octas).
Assuming the address of the contract is 670c9d8c9ca3b3c938fcf0fe80abc3c9a06944a6e3f9104755a05baf2c2ef85d, use movement's CLI to submit a transaction that interacts with the do_nothing function, and set the max-gas parameter to the minimum gas required for a transaction to be accepted; 3 octas.
To crash everything, the max-gas parameter must be 3.
A transaction with a max-gas of 2 octas or less does not crash any node. They skip processing the transaction and return:
A transaction with a max-gas of 4 octas or more does not crash any node. It returns the following error and continues operations as usual:
Submitting the transaction with a max-gas of 3 octas to Aptos Network returns the error above ("Execution failed at code offset 128") without crashing. Nodes crash only when submitting the transaction to Movement Network with a max-gas of 3 octas.
After submitting the transaction
First, the AptosVM of Movement Labs crashes with the error:
Part of the stack backtrace:
For a complete stack backtrace, jump to the Proof of Concept section and follow the steps to reproduce the report. Then, review the stack backtrace in the movement-full-node process.
The runtime panic unwinds and panics again in a loop that lasts a few iterations.
Finally, the Movement Light Node stops streaming DA blobs.
Review the logs in the movement-celestia-da-light-node process
Proof of Concept
Start the network
Follow the steps in the README.md of the Movement Network repo to start a local network with:
Build and deploy the contract
Create a Move contract as usual with the following Move.toml file:
The code for the Move contract (inside ./sources/addr.move) is the following:
Use movement init to initialize a profile. My profile (inside .movement/config.yaml) is:
This step should create and fund your account using the Faucet, giving you enough funds to deploy the Move contract and interact with it later.
In a new terminal split, enter sudo:
Then, change the stack limit of the system, allowing you to compile the code into binary without overflowing your system stack due to recursion:
Deploy the binary to the chain:
Finally, interact with it by sending a transaction with a max-gas of 3 octas:
module addr::my_module {
struct S0 has drop, key, store { variable: bool, }
struct S1 has drop, key, store { variable: S0, }
struct S2 has drop, key, store { variable: S1, }
struct S3 has drop, key, store { variable: S2, }
// .... more structs here
struct S200 has drop, key, store { variable: S199, }
// .... more code here
movement move publish --url http://localhost:30731/v1/
movement move run --function-id 670c9d8c9ca3b3c938fcf0fe80abc3c9a06944a6e3f9104755a05baf2c2ef85d::my_module::do_nothing --gas-unit-price 100 --max-gas 3
{
"Error": "API error: Unknown error Transaction committed on chain, but failed execution: Execution failed in 0x670c9d8c9ca3b3c938fcf0fe80abc3c9a06944a6e3f9104755a05baf2c2ef85d::my_module::do_nothing at code of
fset 128"
}
movement move publish --url http://localhost:30731/v1/
movement move run --function-id 670c9d8c9ca3b3c938fcf0fe80abc3c9a06944a6e3f9104755a05baf2c2ef85d::my_module::do_nothing --gas-unit-price 100 --max-gas 3