Signature Replay attack possible on updateWorkerDeploymentConfigWithSig() in Blueprintcore.sol which leads to users lose the funds
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.
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
- The
updateWorkerDeploymentConfigWithSigfunction remains public and unchanged in the deployed contract. - A user (deployment owner) has approved the
BlueprintCore.solcontract to spend their ERC-20 tokens viaapproveon the token contract. - The user has signed a valid message (with
projectId,updatedBase64Config, "app.crestal.network") and submitted it to update a deployment configuration for arequestIDwithstatusnot equal toInitorIssued. - The
paymentOpCostMp[tokenAddress][UPDATE_AGENT_OP]returns a non-zerocost.
External Pre-conditions
- The Base blockchain allows transaction replay if the signature remains valid, which is standard behavior unless mitigated.
- The ERC-20 token contract at
tokenAddresssupportssafeTransferFromand doesn't prevent replay.
Attack Path
- A user (deployment owner) signs a message to update a deployment configuration (projectId, requestID, updatedBase64Config) and submits it via
updateWorkerDeploymentConfigWithSig, paying $token tofeeCollectionWalletAddressviapayWithERC20. - The transaction succeeds, updating the configuration and emitting
UpdateDeploymentConfig, with the signature recorded on-chain. - A malicious actor captures the signature and replays the transaction by calling
updateWorkerDeploymentConfigWithSigwith the same parameters (tokenAddress, projectId, requestID, updatedBase64Config, signature). - Each replay re-executes
updateWorkerDeploymentConfigCommon, transferring another $token from the user tofeeCollectionWalletAddress(if funds/allowance remain) and resetting status toPickupif it wasGeneratedProof, 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:
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]++;
}