Summary
Race condition in BuyerAgent.sol allows double processing of oracle tasks, leading to duplicate purchases and state updates.
function updateState() external onlyAuthorized {
uint256 taskId = oracleStateRequests[round];
if (isOracleRequestProcessed[taskId]) {
revert TaskAlreadyProcessed();
}
bytes memory newState = oracleResult(taskId);
state = newState;
isOracleRequestProcessed[taskId] = true;
}
function purchase() external onlyAuthorized {
uint256 taskId = oraclePurchaseRequests[round];
if (isOracleRequestProcessed[taskId]) {
revert TaskAlreadyProcessed();
}
bytes memory output = oracleResult(taskId);
address[] memory assets = abi.decode(output, (address[]));
isOracleRequestProcessed[taskId] = true;
}
This fundamentally breaks the BuyerAgent's core security assumptions
Task processing uniqueness
Spending limits enforcement
State consistency
Asset inventory accuracy
The autonomous nature of the buyer agent makes this particularly dangerous as it could lead to uncontrolled asset purchases and state corruption, directly impacting user funds and protocol stability.
Vulnerability Details
Root Cause:
Task completion flags (isOracleRequestProcessed) are set after executing critical logic
No atomic operation protection between state check and update
Shared state between purchase() and updateState()
contracts/swan/BuyerAgent.sol.updateState# https://github.com/Cyfrin/2024-10-swan-dria/blob/c8686b199daadcef3161980022e12b66a5304f8e/contracts/swan/BuyerAgent.sol#L200-L216
contracts/swan/BuyerAgent.sol.purchase# https://github.com/Cyfrin/2024-10-swan-dria/blob/c8686b199daadcef3161980022e12b66a5304f8e/contracts/swan/BuyerAgent.sol#L222-L256
function updateState() external onlyAuthorized {
(uint256 round,) = _checkRoundPhase(Phase.Withdraw);
uint256 taskId = oracleStateRequests[round];
if (isOracleRequestProcessed[taskId]) {
revert TaskAlreadyProcessed();
}
bytes memory newState = oracleResult(taskId);
state = newState;
isOracleRequestProcessed[taskId] = true;
}
function purchase() external onlyAuthorized {
(uint256 round,) = _checkRoundPhase(Phase.Buy);
uint256 taskId = oraclePurchaseRequests[round];
if (isOracleRequestProcessed[taskId]) {
revert TaskAlreadyProcessed();
}
bytes memory output = oracleResult(taskId);
address[] memory assets = abi.decode(output, (address[]));
for (uint256 i = 0; i < assets.length; i++) {
address asset = assets[i];
uint256 price = swan.getListingPrice(asset);
spendings[round] += price;
if (spendings[round] > amountPerRound) {
revert BuyLimitExceeded(spendings[round], amountPerRound);
}
inventory[round].push(asset);
swan.purchase(asset);
}
isOracleRequestProcessed[taskId] = true;
}
The issue occurs because
Both functions check isOracleRequestProcessed before processing
If called in quick succession, both could pass the initial check
This could lead to double processing of the same task
Proof of Concept
Oracle task is created with taskId X
purchase() is called with taskId X
Before isOracleRequestProcessed[X] is set to true, updateState() is called
Both functions process the same task
Impact
Tools Used
Vs
Recommendations
contract BuyerAgent {
+ using ReentrancyGuard for uint256;
+ mapping(uint256 => TaskStatus) private taskStatus;
+ enum TaskStatus { NONE, PROCESSING, COMPLETED }
function updateState() external onlyAuthorized nonReentrant {
uint256 taskId = oracleStateRequests[round];
- if (isOracleRequestProcessed[taskId]) {
+ if (taskStatus[taskId] != TaskStatus.NONE) {
revert TaskAlreadyProcessed();
}
+ taskStatus[taskId] = TaskStatus.PROCESSING;
bytes memory newState = oracleResult(taskId);
state = newState;
- isOracleRequestProcessed[taskId] = true;
+ taskStatus[taskId] = TaskStatus.COMPLETED;
}
}