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
| ID | Sev | Title |
|---|---|---|
| REPLAY-001 | Critical | No nonce — every signed withdrawal is replayable forever |
| REPLAY-002 | Critical | No chainId / verifyingContract — sig replays cross-chain and cross-deployment |
| REPLAY-003 | High | ecrecover zero-address bypass possible |
| REPLAY-004 | High | Signature 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