Summary
The LLMOracleRegistry contract uses token.approve() instead of token.transfer() when returning staked tokens to users during unregistration. This requires users to perform an additional transaction to retrieve their tokens and can lead to accumulated allowances, making the process inefficient and potentially risky.
Vulnerability Details
The vulnerability exists in the unregister function of the LLMOracleRegistry contract:
https://github.com/Cyfrin/2024-10-swan-dria/blob/c8686b199daadcef3161980022e12b66a5304f8e/contracts/llm/LLMOracleRegistry.sol#L117-L131
function unregister(LLMOracleKind kind) public returns (uint256 amount) {
amount = registrations[msg.sender][kind];
if (amount == 0) {
revert NotRegistered(msg.sender);
}
delete registrations[msg.sender][kind];
emit Unregistered(msg.sender, kind);
token.approve(msg.sender, token.allowance(address(this), msg.sender) + amount);
}
The issue lies in line 130 where token.approve() is used instead of token.transfer(). This creates a two-step process where users must:
https://github.com/Cyfrin/2024-10-swan-dria/blob/c8686b199daadcef3161980022e12b66a5304f8e/contracts/llm/LLMOracleRegistry.sol#L130
Call unregister() to get approval
Call transferFrom() in a separate transaction to actually retrieve their tokens
Impact
Increased Gas Costs: Users must execute two transactions instead of one
Poor User Experience: Additional complexity in token retrieval process
Security Risk: Tokens remain in the contract longer than necessary
Allowance Accumulation: Multiple unregistrations can lead to accumulated allowances
POC Test and Results:
it("POC: demonstrates issue with unregister using approve instead of transfer", async function () {
await token.connect(oracle).approve(oracleRegistryAddress, GENERATOR_STAKE + VALIDATOR_STAKE);
await oracleRegistry.connect(oracle).register(OracleKind.Generator);
await oracleRegistry.connect(oracle).register(OracleKind.Validator);
const registryBalance = await token.balanceOf(oracleRegistryAddress);
const oracleBalance = await token.balanceOf(oracle.address);
expect(registryBalance).to.equal(GENERATOR_STAKE + VALIDATOR_STAKE);
await oracleRegistry.connect(oracle).unregister(OracleKind.Generator);
const registryBalanceAfterUnregister = await token.balanceOf(oracleRegistryAddress);
expect(registryBalanceAfterUnregister).to.equal(registryBalance);
const allowanceBeforeSecondUnregister = await token.allowance(oracleRegistryAddress, oracle.address);
await oracleRegistry.connect(oracle).unregister(OracleKind.Validator);
const finalAllowance = await token.allowance(oracleRegistryAddress, oracle.address);
console.log("Initial allowance:", allowanceBeforeSecondUnregister.toString());
console.log("Final allowance:", finalAllowance.toString());
console.log("VALIDATOR_STAKE:", VALIDATOR_STAKE.toString());
});
Test Results:
Initial allowance: 0
Final allowance: 100000000000000000
VALIDATOR_STAKE: 100000000000000000
Tools Used
Recommendations
Replace the current implementation with direct token transfer:
function unregister(LLMOracleKind kind) public returns (uint256 amount) {
amount = registrations[msg.sender][kind];
if (amount == 0) {
revert NotRegistered(msg.sender);
}
delete registrations[msg.sender][kind];
emit Unregistered(msg.sender, kind);
token.transfer(msg.sender, amount);
}