Skip to content
This repository was archived by the owner on May 23, 2023. It is now read-only.

Commit 0f7c0a0

Browse files
scnalechase-45
authored andcommitted
Negative test for a reentrant attack on the core relayer forward mechanism (#83)
* Modifies the relayer simulation to be easier to use in negative tests. * Adds negative test for a reentrancy attack on the forward mechanism. * `forge fmt` run.
1 parent ca71dcb commit 0f7c0a0

File tree

2 files changed

+193
-10
lines changed

2 files changed

+193
-10
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity ^0.8.17;
3+
4+
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5+
6+
import "../interfaces/IWormhole.sol";
7+
import "../interfaces/IWormholeReceiver.sol";
8+
import "../interfaces/ICoreRelayer.sol";
9+
10+
/**
11+
* This contract is a malicious "integration" that attempts to attack the forward mechanism.
12+
*/
13+
contract AttackForwardIntegration is IWormholeReceiver {
14+
mapping(bytes32 => bool) consumedMessages;
15+
address attackerReward;
16+
IWormhole wormhole;
17+
ICoreRelayer core_relayer;
18+
uint32 nonce = 1;
19+
uint16 targetChainId;
20+
21+
// Capture 30k gas for fees
22+
// This just needs to be enough to pay for the call to the destination address.
23+
uint32 SAFE_DELIVERY_GAS_CAPTURE = 30000;
24+
25+
constructor(IWormhole initWormhole, ICoreRelayer initCoreRelayer, uint16 chainId, address initAttackerReward) {
26+
attackerReward = initAttackerReward;
27+
wormhole = initWormhole;
28+
core_relayer = initCoreRelayer;
29+
targetChainId = chainId;
30+
}
31+
32+
// This is the function which receives all messages from the remote contracts.
33+
function receiveWormholeMessages(bytes[] memory vaas, bytes[] memory additionalData) public payable override {
34+
// Do nothing. The attacker doesn't care about this message; he sends it himself.
35+
}
36+
37+
receive() external payable {
38+
// Request forward from the relayer network
39+
// The core relayer could in principle accept the request due to this being the target of the message at the same time as being the refund address.
40+
// Note that, if succesful, this forward request would be processed after the time for processing forwards is past.
41+
// Thus, the request would "linger" in the forward request cache and be attended to in the next delivery.
42+
requestForward(targetChainId, toWormholeFormat(attackerReward));
43+
}
44+
45+
function requestForward(uint16 targetChain, bytes32 attackerRewardAddress) internal {
46+
uint256 computeBudget = core_relayer.quoteGasDeliveryFee(
47+
targetChain, SAFE_DELIVERY_GAS_CAPTURE, core_relayer.getDefaultRelayProvider()
48+
);
49+
50+
ICoreRelayer.DeliveryRequest memory request = ICoreRelayer.DeliveryRequest({
51+
targetChain: targetChain,
52+
targetAddress: attackerRewardAddress,
53+
// All remaining funds will be returned to the attacker
54+
refundAddress: attackerRewardAddress,
55+
computeBudget: computeBudget,
56+
applicationBudget: 0,
57+
relayParameters: core_relayer.getDefaultRelayParams()
58+
});
59+
60+
core_relayer.requestForward{value: computeBudget}(request, nonce, core_relayer.getDefaultRelayProvider());
61+
}
62+
63+
function toWormholeFormat(address addr) public pure returns (bytes32 whFormat) {
64+
return bytes32(uint256(uint160(addr)));
65+
}
66+
}

ethereum/forge-test/CoreRelayer.t.sol

+127-10
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {Wormhole} from "../wormhole/ethereum/contracts/Wormhole.sol";
2323
import {IWormhole} from "../contracts/interfaces/IWormhole.sol";
2424
import {WormholeSimulator} from "./WormholeSimulator.sol";
2525
import {IWormholeReceiver} from "../contracts/interfaces/IWormholeReceiver.sol";
26+
import {AttackForwardIntegration} from "../contracts/mock/AttackForwardIntegration.sol";
2627
import {MockRelayerIntegration} from "../contracts/mock/MockRelayerIntegration.sol";
2728
import "../contracts/libraries/external/BytesLib.sol";
2829

@@ -497,6 +498,106 @@ contract TestCoreRelayer is Test {
497498
assertTrue(keccak256(setup.source.integration.getFirstMessage()) == keccak256(bytes("received!")));
498499
}
499500

501+
function testAttackForwardRequestCache(GasParameters memory gasParams, FeeParameters memory feeParams) public {
502+
// General idea:
503+
// 1. Attacker sets up a malicious integration contract in the target chain.
504+
// 2. Attacker requests a message send to `target` chain.
505+
// The message destination and the refund address are both the malicious integration contract in the target chain.
506+
// 3. The delivery of the message triggers a refund to the malicious integration contract.
507+
// 4. During the refund, the integration contract activates the forwarding mechanism.
508+
// This is allowed due to the integration contract also being the target of the delivery.
509+
// 5. The forward request is left as is in the `CoreRelayer` state.
510+
// 6. The next message (i.e. the victim's message) delivery on `target` chain, from any relayer, using any `RelayProvider` and any integration contract,
511+
// will see the forward request placed by the malicious integration contract and act on it.
512+
// Caveat: the delivery of the victim's message must not invoke the forwarding mechanism for the attack test to be meaningful.
513+
//
514+
// In essence, this tries to attack the shared forwarding request cache present in the contract state.
515+
// This attack doesn't work thanks to the check inside the `requestForward` function that only allows requesting a forward when there is a delivery being processed.
516+
517+
StandardSetupTwoChains memory setup = standardAssumeAndSetupTwoChains(gasParams, feeParams, 1000000);
518+
519+
// Collected funds from the attack are meant to be sent here.
520+
address attackerSourceAddress =
521+
address(uint160(uint256(keccak256(abi.encodePacked(bytes("attackerAddress"), setup.sourceChainId)))));
522+
assertTrue(attackerSourceAddress.balance == 0);
523+
524+
// Borrowed assumes from testForward. They should help since this test is similar.
525+
vm.assume(
526+
uint256(1) * gasParams.targetGasPrice * feeParams.targetNativePrice
527+
> uint256(1) * gasParams.sourceGasPrice * feeParams.sourceNativePrice
528+
);
529+
530+
vm.assume(
531+
setup.source.coreRelayer.quoteGasDeliveryFee(
532+
setup.targetChainId, gasParams.targetGasLimit, setup.source.relayProvider
533+
) < uint256(2) ** 222
534+
);
535+
vm.assume(
536+
setup.target.coreRelayer.quoteGasDeliveryFee(setup.sourceChainId, 500000, setup.target.relayProvider)
537+
< uint256(2) ** 222 / feeParams.targetNativePrice
538+
);
539+
540+
// Estimate the cost based on the initialized values
541+
uint256 computeBudget = setup.source.coreRelayer.quoteGasDeliveryFee(
542+
setup.targetChainId, gasParams.targetGasLimit, setup.source.relayProvider
543+
);
544+
545+
{
546+
AttackForwardIntegration attackerContract =
547+
new AttackForwardIntegration(setup.target.wormhole, setup.target.coreRelayer, setup.targetChainId, attackerSourceAddress);
548+
bytes memory attackMsg = "attack";
549+
550+
vm.recordLogs();
551+
552+
// The attacker requests the message to be sent to the malicious contract.
553+
// It is critical that the refund and destination (aka integrator) addresses are the same.
554+
setup.source.integration.sendMessage{value: computeBudget + 2 * setup.source.wormhole.messageFee()}(
555+
attackMsg, setup.targetChainId, address(attackerContract), address(attackerContract)
556+
);
557+
558+
// The relayer triggers the call to the malicious contract.
559+
genericRelayer(setup.sourceChainId, 2);
560+
561+
// The message delivery should fail
562+
assertTrue(keccak256(setup.target.integration.getMessage()) != keccak256(attackMsg));
563+
}
564+
565+
{
566+
// Now one victim sends their message. It doesn't need to be from the same source chain.
567+
// What's necessary is that a message is delivered to the chain targeted by the attacker.
568+
bytes memory victimMsg = "relay my message";
569+
570+
uint256 victimBalancePreDelivery = setup.target.refundAddress.balance;
571+
572+
// We will reutilize the compute budget estimated for the attacker to simplify the code here.
573+
// The victim requests their message to be sent.
574+
setup.source.integration.sendMessage{value: computeBudget + 2 * setup.source.wormhole.messageFee()}(
575+
victimMsg, setup.targetChainId, address(setup.target.integration), address(setup.target.refundAddress)
576+
);
577+
578+
// The relayer delivers the victim's message.
579+
// During the delivery process, the forward request injected by the malicious contract is acknowledged.
580+
// The victim's refund address is not called due to this.
581+
genericRelayer(setup.sourceChainId, 2);
582+
583+
// Ensures the message was received.
584+
assertTrue(keccak256(setup.target.integration.getMessage()) == keccak256(victimMsg));
585+
// Here we assert that the victim's refund is safe.
586+
assertTrue(victimBalancePreDelivery < setup.target.refundAddress.balance);
587+
}
588+
589+
Vm.Log[] memory entries = relayerWormholeSimulator.fetchWormholeMessageFromLog(vm.getRecordedLogs());
590+
if (entries.length > 0) {
591+
// There was a wormhole message produced.
592+
// If the attack is successful this is a forward.
593+
// We'll invoke the relay simulation here and later assert that the attack wasn't successful.
594+
// Relay from target chain to source chain.
595+
genericRelayerProcessLogs(setup.targetChainId, entries);
596+
}
597+
// Assert that the attack wasn't successful.
598+
assertTrue(attackerSourceAddress.balance == 0);
599+
}
600+
500601
function testRedelivery(GasParameters memory gasParams, FeeParameters memory feeParams, bytes memory message)
501602
public
502603
{
@@ -1238,18 +1339,34 @@ contract TestCoreRelayer is Test {
12381339
mapping(bytes32 => CoreRelayer.TargetDeliveryParametersSingle) pastDeliveries;
12391340

12401341
function genericRelayer(uint16 chainId, uint8 num) internal {
1241-
bytes[] memory encodedVMs = new bytes[](num);
1242-
{
1243-
// Filters all events to just the wormhole messages.
1244-
Vm.Log[] memory entries = relayerWormholeSimulator.fetchWormholeMessageFromLog(vm.getRecordedLogs());
1245-
assertTrue(entries.length >= num);
1246-
for (uint256 i = 0; i < num; i++) {
1247-
encodedVMs[i] = relayerWormholeSimulator.fetchSignedMessageFromLogs(
1248-
entries[i], chainId, address(uint160(uint256(bytes32(entries[i].topics[1]))))
1249-
);
1250-
}
1342+
Vm.Log[] memory entries = truncateRecordedLogs(chainId, num);
1343+
genericRelayerProcessLogs(chainId, entries);
1344+
}
1345+
1346+
/**
1347+
* Discards wormhole events beyond `num` events.
1348+
* Expects at least `num` wormhole events.
1349+
*/
1350+
function truncateRecordedLogs(uint16 chainId, uint8 num) internal returns (Vm.Log[] memory) {
1351+
// Filters all events to just the wormhole messages.
1352+
Vm.Log[] memory entries = relayerWormholeSimulator.fetchWormholeMessageFromLog(vm.getRecordedLogs());
1353+
// We expect at least `num` events.
1354+
assertTrue(entries.length >= num);
1355+
1356+
Vm.Log[] memory firstEntries = new Vm.Log[](num);
1357+
for (uint256 i = 0; i < num; i++) {
1358+
firstEntries[i] = entries[i];
12511359
}
1360+
return firstEntries;
1361+
}
12521362

1363+
function genericRelayerProcessLogs(uint16 chainId, Vm.Log[] memory entries) internal {
1364+
bytes[] memory encodedVMs = new bytes[](entries.length);
1365+
for (uint256 i = 0; i < encodedVMs.length; i++) {
1366+
encodedVMs[i] = relayerWormholeSimulator.fetchSignedMessageFromLogs(
1367+
entries[i], chainId, address(uint160(uint256(bytes32(entries[i].topics[1]))))
1368+
);
1369+
}
12531370
IWormhole.VM[] memory parsed = new IWormhole.VM[](encodedVMs.length);
12541371
for (uint16 i = 0; i < encodedVMs.length; i++) {
12551372
parsed[i] = relayerWormhole.parseVM(encodedVMs[i]);

0 commit comments

Comments
 (0)