Skip to content
Request an audit

‹ All findings

Signature Replay attack possible on updateWorkerDeploymentConfigWithSig() in Blueprintcore.sol which leads to users lose the funds

MediumCrestal Network·Sherlock · AI agent · 11th March 2025CodebaseM-3

Summary

The lack of replay protection in the updateWorkerDeploymentConfigWithSig function will cause a significant loss of funds for users as a malicious actor will replay a signed transaction to repeatedly transfer funds from the deployment owner to the fee collection wallet. The protocol didn't have the functionality to refund these funds to the respective users if this issue occurs. So anyway user is gonna lose their fund.

Root Cause

In BlueprintCore.sol at the updateWorkerDeploymentConfigWithSig function, the function verifies a signature using getRequestDeploymentDigest but does not include a nonce, timestamp, or chain ID in the signed message. This allows a valid signature to be reused indefinitely, triggering multiple calls to updateWorkerDeploymentConfigCommon and its payWithERC20 payment logic.

solidity
function updateWorkerDeploymentConfigWithSig(
    address tokenAddress,
    bytes32 projectId,
    bytes32 requestID,
    string memory updatedBase64Config,
    bytes memory signature
) public {
    bytes32 digest = getRequestDeploymentDigest(projectId, updatedBase64Config, "app.crestal.network");
    address signerAddr = getSignerAddress(digest, signature);
    updateWorkerDeploymentConfigCommon(tokenAddress, signerAddr, projectId, requestID, updatedBase64Config);
}

Internal Pre-conditions

  1. The updateWorkerDeploymentConfigWithSig function remains public and unchanged in the deployed contract.
  2. A user (deployment owner) has approved the BlueprintCore.sol contract to spend their ERC-20 tokens via approve on the token contract.
  3. The user has signed a valid message (with projectId, updatedBase64Config, "app.crestal.network") and submitted it to update a deployment configuration for a requestID with status not equal to Init or Issued.
  4. The paymentOpCostMp[tokenAddress][UPDATE_AGENT_OP] returns a non-zero cost.

External Pre-conditions

  1. The Base blockchain allows transaction replay if the signature remains valid, which is standard behavior unless mitigated.
  2. The ERC-20 token contract at tokenAddress supports safeTransferFrom and doesn't prevent replay.

Attack Path

  1. A user (deployment owner) signs a message to update a deployment configuration (projectId, requestID, updatedBase64Config) and submits it via updateWorkerDeploymentConfigWithSig, paying $token to feeCollectionWalletAddress via payWithERC20.
  2. The transaction succeeds, updating the configuration and emitting UpdateDeploymentConfig, with the signature recorded on-chain.
  3. A malicious actor captures the signature and replays the transaction by calling updateWorkerDeploymentConfigWithSig with the same parameters (tokenAddress, projectId, requestID, updatedBase64Config, signature).
  4. Each replay re-executes updateWorkerDeploymentConfigCommon, transferring another $token from the user to feeCollectionWalletAddress (if funds/allowance remain) and resetting status to Pickup if it was GeneratedProof, repeatable until the user's funds are drained or allowance is revoked.

Impact

The user suffers an approximate loss of $token per replay. If replayed indefinitely (e.g., 20 times), the loss could reach 20x more, potentially draining 100% of approved funds. The attacker gains no direct funds but indirectly benefits feeCollectionWalletAddress, incurring only gas costs per replay. The protocol didn't have the functionality to refund this token to the respective users if this issue occurs. So anyway user is gonna lose their fund.

Mitigation

Add a nonce to the signed message and track it per user:

Solidity
mapping(address => uint256) public userNonces;
function updateWorkerDeploymentConfigWithSig(
    address tokenAddress,
    bytes32 projectId,
    bytes32 requestID,
    string memory updatedBase64Config,
    bytes memory signature
) public {
    bytes32 digest = keccak256(abi.encode(
        keccak256("UpdateDeploymentConfig(bytes32 projectId,string updatedBase64Config,string domain,uint256 nonce)"),
        projectId,
        keccak256(bytes(updatedBase64Config)),
        keccak256(bytes("app.crestal.network")),
        userNonces[msg.sender]
    ));
    address signerAddr = getSignerAddress(digest, signature);
    updateWorkerDeploymentConfigCommon(tokenAddress, signerAddr, projectId, requestID, updatedBase64Config);
    userNonces[signerAddr]++;
}