Cheery Mustard Swallow
High
Potential Race Conditions in EthosVouch::vouchByProfileId
can lead to loss of funds, future rewards and vouch related data for users
A critical race condition exists in EthosVouch::vouchByProfileId
where the global state variable vouchCount
is used as the key for storing vouches in the vouches
mapping. During high traffic periods, multiple transactions can observe the same stale vouchCount
value in the mempool. When these transactions execute in subsequent blocks, the later transaction will overwrite the earlier transaction's vouch data at the same mapping position, resulting in permanent vouch data loss and inconsistent protocol state. This is possible because the function lacks checks to prevent multiple vouches from using the same vouchCount
value as their mapping key.
The vulnerability stems from the use of a global vouchCount
state variable as a vouchId
key for storing vouch
data in the vouches mapping without a strict check on whether the vouchId
already exists and belongs to a different authorProfile
when writing to state. When multiple transactions attempt to create vouches simultaneously or in the same timeframe by calling vouchByProfileId() they may read the same vouchCount value before either transaction is mined, leading to a collision/overwriting in the vouches mapping.
No response
No response
- Initial state:
vouchCount = 5
- Transaction A from Alice reads
vouchCount
as 5 - Transaction B from Bob reads
vouchCount
as 5 - Transaction A gets mined:
Creates
vouches[5]
with Alice's data Updates associated mappings IncrementsvouchCount
to 6 - Transaction B gets mined:
Overwrites
vouches[5]
with Bob's data and creates chaos within the system.
- Data Loss: The first vouch's data is permanently lost when overwritten
- Inconsistent State: Array mappings (vouchIdsByAuthor, vouchIdsForSubjectProfileId) contain inconsistent references
- Missing Vouches: Only one vouch is recorded when two should exist
- Fund Loss Risk: The funds tied to vouch data, could get misattributed or lost
- Trust System Corruption: Compromises the entire vouch system
While simulating concurrent vouchByProfileId
transactions in the mempool is complex, examining the function's implementation reveals the vulnerability:
function vouchByProfileId(
uint256 subjectProfileId,
string calldata comment,
string calldata metadata
) public payable whenNotPaused nonReentrant {
// validate author profile
uint256 authorProfileId = IEthosProfile(
contractAddressManager.getContractAddressForName(ETHOS_PROFILE)
).verifiedProfileIdForAddress(msg.sender);
// pls no vouch for yourself
if (authorProfileId == subjectProfileId) {
revert SelfVouch(authorProfileId, subjectProfileId);
}
// users can't exceed the maximum number of vouches
if (vouchIdsByAuthor[authorProfileId].length >= maximumVouches) {
revert MaximumVouchesExceeded(
vouchIdsByAuthor[authorProfileId].length,
"Exceeds author vouch limit"
);
}
// validate subject profile
if (subjectProfileId == 0) {
revert InvalidEthosProfileForVouch(subjectProfileId);
}
(bool verified, bool archived, bool mock) = IEthosProfile(
contractAddressManager.getContractAddressForName(ETHOS_PROFILE)
).profileStatusById(subjectProfileId);
// you may not vouch for archived profiles
// however, you may vouch for verified AND mock profiles
// we allow vouching for mock profiles in case they are later verified
if (archived || (!mock && !verified)) {
revert InvalidEthosProfileForVouch(subjectProfileId);
}
// one vouch per profile per author
_vouchShouldNotExistFor(authorProfileId, subjectProfileId);
// don't exceed maximum vouches per subject profile
if (vouchIdsForSubjectProfileId[subjectProfileId].length >= maximumVouches) {
revert MaximumVouchesExceeded(
vouchIdsForSubjectProfileId[subjectProfileId].length,
"Exceeds subject vouch limit"
);
}
// must meet the minimum vouch amount
if (msg.value < configuredMinimumVouchAmount) {
revert MinimumVouchAmount(configuredMinimumVouchAmount);
}
(uint256 toDeposit, ) = applyFees(msg.value, true, subjectProfileId);
// store vouch details
uint256 count = vouchCount;
vouchIdsByAuthor[authorProfileId].push(count);
vouchIdsByAuthorIndex[authorProfileId][count] = vouchIdsByAuthor[authorProfileId].length - 1;
vouchIdsForSubjectProfileId[subjectProfileId].push(count);
vouchIdsForSubjectProfileIdIndex[subjectProfileId][count] =
vouchIdsForSubjectProfileId[subjectProfileId].length -
1;
vouchIdByAuthorForSubjectProfileId[authorProfileId][subjectProfileId] = count;
vouches[count] = Vouch({
archived: false,
unhealthy: false,
authorProfileId: authorProfileId,
authorAddress: msg.sender,
vouchId: count,
balance: toDeposit,
subjectProfileId: subjectProfileId,
comment: comment,
metadata: metadata,
activityCheckpoints: ActivityCheckpoints({
vouchedAt: block.timestamp,
unvouchedAt: 0,
unhealthyAt: 0
})
});
emit Vouched(count, authorProfileId, subjectProfileId, msg.value);
vouchCount++;
}
It is clear to see that there is no explicit check similar to
if (vouches[vouchId].authorAddress != msg.sender) {
revert AddressNotVouchAuthor(vouchId, msg.sender, vouches[vouchId].authorAddress);
}
that is used in functions like unvouch
although for this function it might look like
if (vouches[count].authorAddress != address(0)) {
revert someError;
}
A simple way to test this, is to comment out this if statement: if (vouches[vouchId].authorAddress != msg.sender) { revert AddressNotVouchAuthor(vouchId, msg.sender, vouches[vouchId].authorAddress); }
from unvouch
and then run a test like
it('should allow any address to unvouch due to missing authorization check', async () => {
// Create vouch from userA to userB
await userA.vouch(userB);
const vouch = await ethosVouch.verifiedVouchByAuthorForSubjectProfileId(
userA.profileId,
userB.profileId,
);
// Create a random attacker address that has no relation to the vouch
const attackerWallet = await deployer.newWallet();
// Attacker should be able to call unvouch() successfully
// Even though they aren't the vouch author
await expect(ethosVouch.connect(attackerWallet).unvouch(vouch.vouchId))
.to.not.be.reverted;
// Verify the vouch was actually unvouched
const updatedVouch = await ethosVouch.vouches(vouch.vouchId);
expect(updatedVouch.activityCheckpoints.unvouchedAt).to.be.greaterThan(0);
expect(updatedVouch.archived).to.be.true;
});
The lack of explicit checks in vouchByProfileId
, which uses vouchCount
as the vouchId
to register vouches, means any caller can potentially overwrite values in the vouches
mapping under the right circumstances. In high-traffic scenarios, a second caller could overwrite another's vouch data due to the stale vouchCount value.
- Add explicit checks that the vouchId exists and belongs to a different author at runtime, and cause the function call to revert e.g
if (vouches[count].authorAddress != address(0)) {
revert someError;
}
- Consider implementing a vouchByProfileId transactions queue for potential high-traffic periods.