Dria

Swan
NFTHardhat
21,000 USDC
View results
Submission Details
Severity: high
Valid

`Statistics::variance` calculation could break, leading to disruption in `LLMOracleCoordinator::finalizeValidation` and DoS.

Summary

The calculations within Statistics::variance used in the LLMOracleCoordinator.sol can fail due to an underflow issue when scores contain values smaller than the mean. This leads to reverts during the finalizeValidation process, causing a denial of service (DoS) for tasks requiring validation. As a result, tasks could remain stuck in a PendingValidation state, preventing proper finalization and subsequent task completion.

Description

As the protocol documentation states "For each oracle request, we expect a number of generations and a number of validations afterwards. The validations will return a score for each generation......"
These scores are numerical values that measure the quality or accuracy of the generated outputs. The exact range of these scores isn't however specified in the documentation. Note that in the test files the score is determined using a helper function parseEther, which basically converts the decimal string to a BigInt with 18 decimal places, e.g. parseEther("0.9") is converted to 900000000000000000. However, the scores can be any valid uint256 numbers. For simplicity we will work with smaller uint256 numbers, in the range 0 to 5.
For example, a score of 5 might indicate a very good answer, while a score of 0 might indicate an inadequate answer.

Now lets assume that scores[] array in LLMOracleCoordinator::finalizeValidation (see [https://github.com/Cyfrin/2024-10-swan-dria/blob/main/contracts/llm/LLMOracleCoordinator.sol#L331]) contains 5 values, e.g. [2,3,3,4,3]. In the next step the function will compute the mean and standard deviation, using Statistics.stddev(scores), see [https://github.com/Cyfrin/2024-10-swan-dria/blob/main/contracts/llm/LLMOracleCoordinator.sol#L335]. After the Statistics.stddev(scores) is called the first what will be computed is the mean of the data using Statistics::avg:

// data array contains the following values [2,3,3,4,3]
function avg(uint256[] memory data) internal pure returns (uint256 ans) {
uint256 sum = 0;
for (uint256 i = 0; i < data.length; i++) {
sum += data[i]; // sum (2+3+3+4+3) == 15
}
ans = sum / data.length; // 15 / 5 = 3 --> ans == 3
}

If we stick to our example array [2,3,3,4,3] the Statistics::avg would return for the variable ans the value of 3.
The returned value would be than used in Statistics::variance:

// data array contains the following values [2,3,3,4,3]
function variance(uint256[] memory data) internal pure returns (uint256 ans, uint256 mean) {
mean = avg(data); // mean == 3
uint256 sum = 0;
for (uint256 i = 0; i < data.length; i++) {
@> uint256 diff = data[i] - mean; // diff = data[0]-3 == 2-3!!!
sum += diff * diff;
}
ans = sum / data.length;
}

Line 22 in Statistics.sol is crucial (see [https://github.com/Cyfrin/2024-10-swan-dria/blob/main/contracts/libraries/Statistics.sol#L22]), as with the provided values [2, 3, 3, 4, 3], it leads to an underflow. Specifically, every score value in the data array that is smaller than the computed mean would break the function's logic. This underflow causes the compiler to revert the entire transaction, leading to a denial of service (DoS) in LLMOracleCoordinator::finalizeValidation. Consequently, the latest call to LLMOracleCoordinator::validate would also fail, as only the last validator gets to call finalizeValidation. Since the scores pushed into the TaskValidation array of structs by previous validators can no longer be modified, this issue would result in a task not being able to be set to a Completed state.

PoC

For the proof of concept we will stick to the score values, similar to those in LLMOracleCoordinator.test.ts. Here is the test case and setup, please paste it into the LLMOracleCoordinator.test.ts file and adjust the this.beforeAll(async function () section with additional validators, like this:

// Add validators to the setUp
this.beforeAll(async function () {
// assign roles, full = oracle that can do both generation & validation
const [deployer, dum, req1, gen1, gen2, gen3, gen4, gen5, val1, val2, val3, val4, val5] = await ethers.getSigners();
dria = deployer;
requester = req1;
dummy = dum;
generators = [gen1, gen2, gen3, gen4, gen5];
validators = [val1, val2, val3, val4, val5];
...........
...........
...........
// @audit-test Added new test case
describe("with varied validation scores", function () {
const [numGenerations, numValidations] = [1, 5];
const scores = [
parseEther("0.99"),
parseEther("0.8"),
parseEther("0.6"),
parseEther("0.1"),
parseEther("0.1")
];
let generatorAllowancesBefore: bigint[];
let validatorAllowancesBefore: bigint[];
this.beforeAll(async () => {
taskId++;
generatorAllowancesBefore = await Promise.all(
generators.map((g) => token.allowance(coordinatorAddress, g.address))
);
validatorAllowancesBefore = await Promise.all(
validators.map((v) => token.allowance(coordinatorAddress, v.address))
);
});
it("should make a request", async function () {
await safeRequest(coordinator, token, requester, taskId, input, models, {
difficulty,
numGenerations,
numValidations,
});
});
it("should respond to each generation", async function () {
const availableGenerators = generators.length;
const generationsToRespond = Math.min(numGenerations, availableGenerators);
expect(availableGenerators).to.be.at.least(generationsToRespond);
for (let i = 0; i < generationsToRespond; i++) {
await safeRespond(coordinator, generators[i], output, metadata, taskId, BigInt(i));
}
});
// UNDERFLOW TEST FOR `LLMOracleCoordinator::finalizeValidation`
it("should validate with varied scores and trigger finalizeValidation", async function () {
const requestBefore = await coordinator.requests(taskId);
console.log(`Request status before validation: ${requestBefore.status}`);
for (let i = 0; i < numValidations; i++) {
console.log(`Validating with validator at index ${i} with address: ${validators[i].address}`);
console.log(`Score being used: ${scores[i].toString()}, Task ID: ${taskId}`);
try {
if (i < numValidations - 1) {
await safeValidate(coordinator, validators[i], [scores[i]], metadata, taskId, BigInt(i));
console.log(`Validation succeeded for validator at index ${i}`);
} else {
await safeValidate(coordinator, validators[i], [scores[i]], metadata, taskId, BigInt(i));
console.log(`Validation succeeded for validator at index ${i}`);
}
} catch (error:any) {
if (i < numValidations - 1) {
console.error(`Validation failed for validator at index ${i} with error: ${error.message}`);
} else {
if (error instanceof Error) {
console.error(`Validation failed for validator at index ${i}: ${error.message}`);
} else {
console.error(`Validation failed for validator at index ${i}: ${JSON.stringify(error)}`);
}
}
}
}
// Confirm the tasks status
const finalRequest = await coordinator.requests(taskId);
console.log(`Request status after all validations: ${finalRequest.status}`);
// Confirm the status is still 'PendingValidation'
expect(finalRequest.status).to.equal(TaskStatus.PendingValidation);
});
});

The test can be run with yarn test ./test/LLMOracleCoordinator.test.ts.
Here are the logs:

Request status before validation: 2
Validating with validator at index 0 with address: 0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f
Score being used: 990000000000000000, Task ID: 3
Validation succeeded for validator at index 0
Validating with validator at index 1 with address: 0xa0Ee7A142d267C1f36714E4a8F75612F20a79720
Score being used: 800000000000000000, Task ID: 3
Validation succeeded for validator at index 1
Validating with validator at index 2 with address: 0xBcd4042DE499D14e55001CcbB24a551F3b954096
Score being used: 600000000000000000, Task ID: 3
Validation succeeded for validator at index 2
Validating with validator at index 3 with address: 0x71bE63f3384f5fb98995898A86B02Fb2426c5788
Score being used: 100000000000000000, Task ID: 3
Validation succeeded for validator at index 3
Validating with validator at index 4 with address: 0xFABB0ac9d68B0B445fB7357272Ff202C5651694a
Score being used: 100000000000000000, Task ID: 3
@>Validation failed for validator at index 4: VM Exception while processing transaction: reverted with @>panic code 0x11 (Arithmetic operation overflowed outside of an unchecked block)
Request status after all validations: 2

As you can see from the error logs, the transaction failed with an error message: panic code 0x11 (Arithmetic operation overflowed outside of an unchecked block) and the task status remained at 2 (PendingValidation).

Impact

If LLMOracleCoordinator::finalizeValidation reverts while being called from within the LLMOracleCoordinator::validate function, then the entire execution of the validate function will also revert. That would mean that the latest validation that was pushed into TaskValidation array of structs will be removed (due to the rollback) and the task's status will not change to Completed. Since the scores pushed by previous validators to the TaskValidation array of structs cannot be changed, tasks may become permanently stuck in the PendingValidation status, resulting in a DoS vulnerability.

Tools Used

Manual Code Review, Hardhat

Recommendations

One of the possible solutions would be to refactor the variance function to handle the signed differences between each data point and the mean. This can be achieved using an integer-based signed calculation. Consider making the following changes to the variance function:

function variance(uint256[] memory data) internal pure returns (uint256 ans, uint256 mean) {
mean = avg(data);
- uint256 sum = 0;
+ int256 sum = 0;
for (uint256 i = 0; i < data.length; i++) {
- uint256 diff = data[i] - mean;
+ int256 diff = int256(data[i]) - int256(mean);
sum += diff * diff;
}
- ans = sum / data.length;
+ ans = uint256(sum / int256(data.length));
}

If you paste this modified implementation into Statistics::variance and adjust the last line in the PoC to expect(finalRequest.status).to.equal(TaskStatus.Completed); and run the test with yarn test ./test/LLMOracleCoordinator.test.ts it will pass.

Updates

Lead Judging Commences

inallhonesty Lead Judge 12 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Underflow in computing variance

Support

FAQs

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