Dria

Swan
NFTHardhat
21,000 USDC
View results
Submission Details
Severity: medium
Invalid

Spending Limit Bypass in BuyerAgent Purchase Function

Summary

The BuyerAgent's purchase mechanism allows temporary violation of spending limits during batch purchases. The spending validation occurs after state changes, enabling the total round spending to exceed amountPerRound before the transaction reverts, potentially compromising the protocol's economic guarantees.

Vulnerability Details

The purchase function processes multiple assets in a loop, incrementing the round's total spending before validating limits. This "check-after-act" pattern creates a window where spending can exceed defined limits: https://github.com/Cyfrin/2024-10-swan-dria/blob/c8686b199daadcef3161980022e12b66a5304f8e/contracts/swan/BuyerAgent.sol#L222-L256

// @Issue - State modification before validation allows spending limit violation
function purchase() external onlyAuthorized {
(uint256 round,) = _checkRoundPhase(Phase.Buy);
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; // State modified before validation
if (spendings[round] > amountPerRound) { // Late validation
revert BuyLimitExceeded(spendings[round], amountPerRound);
}
inventory[round].push(asset);
swan.purchase(asset);
}
}

Where the total spending for a round can temporarily exceed the amountPerRound limit during batch purchases. The spending check occurs after incrementing the round's total spending, allowing the contract state to temporarily violate its core invariant.

https://github.com/Cyfrin/2024-10-swan-dria/blob/c8686b199daadcef3161980022e12b66a5304f8e/contracts/swan/BuyerAgent.sol#L237-L252

// @Issue - Spending limit check happens after state change, allowing temporary limit violation
// before reverting, violating a core protocol invariant
for (uint256 i = 0; i < assets.length; i++) {
address asset = assets[i];
uint256 price = swan.getListingPrice(asset);
spendings[round] += price; // State modified before validation
if (spendings[round] > amountPerRound) {
revert BuyLimitExceeded(spendings[round], amountPerRound);
}
inventory[round].push(asset);
swan.purchase(asset);
}
  1. It violates the core spending limit invariant that protects the protocol's economic model

  2. The spending check occurring after increment creates a race condition where multiple assets can be processed before the limit check triggers

  3. While the transaction ultimately reverts, the temporary violation of spending limits could be exploited in cross-contract interactions

  4. The oracle-driven nature means malicious oracle responses could deliberately trigger this condition

  5. Future protocol upgrades or integrations might rely on the spending limit invariant being maintained at all times, not just at transaction boundaries

Assume this POC test.

function testSpendingLimitViolation() public {
// Setup
uint256 amountPerRound = 100;
address[] memory assets = new address[]();
assets[0] = address(0x1); // Price = 60
assets[1] = address(0x2); // Price = 60
// First iteration: spendings = 60 (passes)
// Second iteration: spendings = 120 (exceeds but damage done)
buyerAgent.purchase(assets);
}

Impact

  • Violates core protocol invariant of spending limits

  • Creates potential attack vectors in cross-contract interactions

Recommendations

Consider this remediation

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[]));
+ // Pre-calculate total cost
+ uint256 totalCost;
+ for (uint256 i = 0; i < assets.length; i++) {
+ totalCost += swan.getListingPrice(assets[i]);
+ }
+
+ // Validate total spending upfront
+ if (spendings[round] + totalCost > amountPerRound) {
+ revert BuyLimitExceeded(spendings[round] + totalCost, amountPerRound);
+ }
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;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge
12 months ago
inallhonesty Lead Judge 12 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.