Rugproof Security Audit — ReplayableBridge.sol

2026-05-13 · grade F · 2 Critical, 2 High

Summary

Signature-verified bridge contract with no nonce, no chainId, no verifyingContract binding, no zero-address check on ecrecover, and no signature-malleability guard. A single signed payload is replayable indefinitely on any chain where this contract is deployed and against any deployment of this contract. Total loss of bridged liquidity is trivial.

Findings

IDSevTitle
REPLAY-001CriticalNo nonce — every signed withdrawal is replayable forever
REPLAY-002CriticalNo chainId / verifyingContract — sig replays cross-chain and cross-deployment
REPLAY-003Highecrecover zero-address bypass possible
REPLAY-004HighSignature malleability (no s-bound check)

REPLAY-001 — Missing nonce (Critical)


bytes32 hash = keccak256(abi.encode(recipient, amount));

The hash includes only (recipient, amount). Once an attacker has any valid signature, they can replay it until liquidity is exhausted. This is the entire bridge value at risk.

REPLAY-002 — No chainId / verifyingContract (Critical)

The same payload is valid on every EVM chain where this contract is deployed — and on every redeployment. A signature legitimately authorizing a 1 ETH withdrawal on Ethereum can withdraw 1 ETH from the Berachain deployment, then Arbitrum, then Base.

EIP-712 domain separator MUST include both chainId and verifyingContract.

REPLAY-003 — ecrecover zero-address bypass (High)


address signer = ecrecover(hash, v, r, s);
require(signer == relayer, "bad sig");

ecrecover returns address(0) on invalid input (rather than reverting). If relayer is ever set to address(0) — possible during initialization, after a botched rotation, etc. — every malformed signature passes. Add require(signer != address(0)) or use OZ ECDSA.recover which guards this.

REPLAY-004 — Signature malleability (High)


function approveSpend(...) external {
    bytes32 hash = keccak256(abi.encode(spender, amount));
    address signer = ecrecover(hash, v, r, s);   // accepts both s and n-s
}

Even if you add a per-sig "seen" map (which this contract doesn't), an attacker can produce a different-looking but cryptographically valid signature by flipping s to n - s. Replay protection that keys on signature bytes is bypassed. Use OZ ECDSA.tryRecover which enforces s <= n/2.

Recommendation


contract Bridge is EIP712 {
    constructor() EIP712("Rugproof Bridge", "1") {}

    mapping(bytes32 => bool) public used;

    function withdraw(
        address recipient,
        uint256 amount,
        uint256 deadline,
        uint256 nonce,
        bytes calldata sig
    ) external {
        require(block.timestamp <= deadline, "expired");
        bytes32 structHash = keccak256(abi.encode(
            keccak256("Withdraw(address recipient,uint256 amount,uint256 deadline,uint256 nonce)"),
            recipient, amount, deadline, nonce
        ));
        bytes32 digest = _hashTypedDataV4(structHash);  // ← injects chainId + verifyingContract

        require(!used[digest], "replayed");
        used[digest] = true;

        address signer = ECDSA.recover(digest, sig);    // ← handles malleability + zero-addr
        require(signer == relayer, "bad sig");

        token.safeTransfer(recipient, amount);
    }
}

Skill detail: signature-replay, bridge-specialist, crosschain-messaging-specialist.

Generated by Rugproof — https://omermaksutii.github.io/RugProof