Reproduced Exploit

Switchboard: forward spends 5 ETH the caller did not pay

SbFunction.forward loops over caller-supplied transactions and calls transaction.to with transaction.value. It does not check msg.value. A contract that already holds 5 ETH can be told to send all of it. This call attaches 0. The sink receives 5 ETH and the contract balance becomes 0. The corrected…

Jun 2023•Other•logic•4 min read

Chain

Other

Category

logic

Date

Jun 2023

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 47981-unchecked-fund-provision-ottersec-none-switchboard-evm-pdf. Standalone Foundry PoC and full write-up: 47981-unchecked-fund-provision-ottersec-none-switchboard-evm-pdf_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/missing-check · vuln/dependency/unsafe-external-call

Key info#

ProtocolSwitchboard SbFunction
FindingOtterSec, AuditVault 47981
Vulnerable codeforward value call
HarmThe sink is credited 5 ETH. The caller paid 0. The corrected forward reverts unless the caller pays 5 ETH, and that payment does not spend a pre-existing treasury
Chainlocal synthetic

TL;DR#

SbFunction.forward loops over caller-supplied transactions and calls transaction.to with transaction.value. It does not check msg.value. A contract that already holds 5 ETH can be told to send all of it. This call attaches 0. The sink receives 5 ETH and the contract balance becomes 0. The corrected forward reverts with payment required when msg.value is 0. When the caller does send 5 ETH, the sink is credited 5 ETH and a pre-existing 5 ETH treasury is still there.

The vulnerable code#

SOLIDITY
function forward(Transaction[] memory transactions, bytes[] memory signatures) external payable {
    for (uint256 i = 0; i < transactions.length; i++) {
        Transaction memory transaction = transactions[i];
        (bool success, bytes memory returnData) = transaction.to.call{
            value: transaction.value
        }(abi.encodePacked(transaction.data, transaction.from));
    }
}

The report's fix is to pass value: 0 so forward cannot move ETH. The control here instead requires msg.value to equal the sum of transaction.value, which is the check the missing code should have made before any value is forwarded.

Root cause#

transaction.value is taken from the calldata the caller wrote. It is not bounded by msg.value or by a separate pull of funds from that caller. The ETH sent by the call is the contract's own balance. Setting transaction.value to that balance and transaction.to to an attacker address drains it in one call.

Preconditions#

SbFunction holds 5 ETH before the call. That funding is not part of run. The forwarded transaction sets value to 5 ETH and to to the sink. msg.value is 0. One transaction is enough.

Attack walkthrough#

  1. run reads address(sb).balance and requires it to be 5 ETH.
  2. It builds one transaction: sink, value 5 ETH, empty calldata, empty signatures.
  3. forward stores msg.value, which is 0.
  4. The call sends 5 ETH to the sink. It succeeds.
  5. The sink is credited 5 ETH. The contract balance is 0. lastMsgValue is 0.

The control contract starts with the same 5 ETH treasury. forward with msg.value 0 reverts payment required. The treasury is still 5 ETH and the sink is still 0. forward with msg.value 5 ETH credits the sink 5 ETH and the contract still holds its original 5 ETH.

Diagrams#

flowchart TD A["SbFunction already holds 5 ETH"] --> B["Caller builds a transaction with value 5 ETH"] B --> C["forward is called with msg.value 0"] C --> D["VULN: call uses transaction.value from the contract balance"] D --> E["Sink is credited 5 ETH and the contract is empty"] E --> F["Control reverts when msg.value is 0"] F --> G["Paying 5 ETH credits the sink and leaves the old treasury"]

Impact#

Whoever can call forward can move the contract's ETH to an address they choose without paying for it. Here that is 5 ETH, the whole balance. The caller of this transaction paid 0. A call that is required to attach 5 ETH does not touch a pre-existing treasury.

How to reproduce#

BASH
cd 47981-unchecked-fund-provision-ottersec-none-switchboard-evm-pdf_exp
forge test -vv

The forge test deals 5 ETH to SbFunction before run(). run() does not warp and does not deal.

Sources#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.