Dria

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

The last validator can manipulate the system

Summary

The last validator can manipulate the system in a number of ways

Vulnerability Details

Buyers can are AI agents that have a story and they can request story updates or item to be bought from the swan. This is done mainly with LLMOracleCoordinator::request which makes a request struct. Such request are later fulfilled buy generators and validators using respond and validate.

// push reques & emit status update for the task
requests[taskId] = TaskRequest({
requester: msg.sender,
protocol: protocol,
input: input,
parameters: parameters,
status: TaskStatus.PendingGeneration,
generatorFee: generatorFee,
validatorFee: validatorFee,
platformFee: platformFee,
models: models
});

Where each response is checked for PoW using assertValidNonce.

function validate(uint256 taskId, uint256 nonce, uint256[] calldata scores, bytes calldata metadata)
public
onlyRegistered(LLMOracleKind.Validator)
onlyAtStatus(taskId, TaskStatus.PendingValidation)
{
// more code ...
// check nonce (proof-of-work)
assertValidNonce(taskId, task, nonce);
// more code ...
}
function assertValidNonce(uint256 taskId, TaskRequest storage task, uint256 nonce) internal view {
bytes memory message = abi.encodePacked(taskId, task.input, task.requester, msg.sender, nonce);
if (uint256(keccak256(message)) > type(uint256).max >> uint256(task.parameters.difficulty)) {
revert InvalidNonce(taskId, nonce);
}
}

Later when everything is generated and validated finalizeValidation is called to finalize the task and pay all generators and validators that have passed certain metrics. The metrics are based on the scores provided by each validator for each generator, where the paid parties are the ones with the minimum score deviation from the mean (average).

for (uint256 v_i = 0; v_i < task.parameters.numValidations; ++v_i) {
uint256 score = scores[v_i];
if ((score >= _mean - _stddev) && (score <= _mean + _stddev)) {
innerSum += score;
innerCount++;
_increaseAllowance(validations[taskId][v_i].validator, task.validatorFee);
}
}

With that in mind we can spot an issue inside validate, or more precisely inside assertValidNonce, as this function does not include validator scores inside it's PoW.

function assertValidNonce(uint256 taskId, TaskRequest storage task, uint256 nonce) internal view {
bytes memory message = abi.encodePacked(taskId, task.input, task.requester, msg.sender, nonce);
if (uint256(keccak256(message)) > type(uint256).max >> uint256(task.parameters.difficulty)) {
revert InvalidNonce(taskId, nonce);
}
}

This enables validator to change the score last second and thus do multiple different tricks to manipulate the system.

1 Most simple trick is to position your scores as the most average ones in order to securely enter the for which pays validators. There are some validators who have done the work and submitted their scores, however since they are deviating too much from the mean they would not get paid. You are smart, so your pay is guarantied.

for (uint256 v_i = 0; v_i < task.parameters.numValidations; ++v_i) {
uint256 score = scores[v_i];
if ((score >= _mean - _stddev) && (score <= _mean + _stddev)) {
innerSum += score;
innerCount++;
_increaseAllowance(validations[taskId][v_i].validator, task.validatorFee);
}
}

2 Validators can also manipulate the scores for generators in order for their alt generator friends to get their output selected as the highest one. You see buyers use purchase to purchase the swan assets, purchase relies on oracleResult to return an array of all useful items.

function purchase() external onlyAuthorized {
// check that we are in the Buy phase, and return round
(uint256 round,) = _checkRoundPhase(Phase.Buy);
// check if the task is already processed
uint256 taskId = oraclePurchaseRequests[round];
if (isOracleRequestProcessed[taskId]) {
revert TaskAlreadyProcessed();
}
// read oracle result using the latest task id for this round
bytes memory output = oracleResult(taskId);
address[] memory assets = abi.decode(output, (address[]));

But oracleResult in tern relies on getBestResponse:

function oracleResult(uint256 taskId) public view returns (bytes memory) {
// task id must be non-zero
if (taskId == 0) {
revert TaskNotRequested();
}
return swan.coordinator().getBestResponse(taskId).output;
}

And finally getBestResponse returns the output generated by the highest scored generator.

function getBestResponse(uint256 taskId) external view returns (TaskResponse memory) {
TaskResponse[] storage taskResponses = responses[taskId];
if (requests[taskId].status != LLMOracleTask.TaskStatus.Completed) {
revert InvalidTaskStatus(taskId, requests[taskId].status, LLMOracleTask.TaskStatus.Completed);
}
TaskResponse storage result = taskResponses[0];
uint256 highestScore = result.score;
for (uint256 i = 1; i < taskResponses.length; i++) {
if (taskResponses[i].score > highestScore) {
highestScore = taskResponses[i].score;
result = taskResponses[i];
}
}
return result;
}

This means that the highest scored generator decides which swap assets will be bought. Which means that if we manipulate the scores well enough it's possible in some occasions to generate an expensive list of assets and force the buyer to buy them (or at least make them the highest score list of item).

That is little harder to do, but it's still possible if your validator passes the validator mean check inside finalizeValidation, while at the same time boosting your desired generator and lowering the rest.

for (uint256 v_i = 0; v_i < task.parameters.numValidations; ++v_i) {
uint256 score = scores[v_i];
if ((score >= _mean - _stddev) && (score <= _mean + _stddev)) {
innerSum += score;
innerCount++;
_increaseAllowance(validations[taskId][v_i].validator, task.validatorFee);
}
}

Example

  1. Requests is made and 10 validators compete for 5 validations

  2. it would take validators around 10-15 second to crack the PoW.

  3. One validator correctly completes the POW but waits for 4/5 validations to be submitted in order to correct his scores array

  4. After 4 validators have been submitted he changes his scores and submits the last needed validation

  5. Our validator configured the scores so he will always land closes to the mean (average) and thus get paid every time.

Impact

Validators can manipulate the system and it's buyers, profiting from other users.

Tools Used

Manual review

Recommendations

Include the scores inside the PoW formula.

Updates

Lead Judging Commences

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

Unbounded score values in `validate` function

Support

FAQs

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