Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: medium
Valid

`RAACHousePriceOracle` may set invalid house prices

Summary

Prices are updated using asynchronous Chainlink Functions. If multiple price updates are requested, the callback will update the price for the last requested house ID each time. This results in brief periods where a given house may be significantly under or over priced.

Finding Description

Chainlink Functions are used to update house prices. These are processed over 2 transactions, one queuing the request and another executing.

The inherited contract (BaseChainlinkFunctionsOracle) has a callback function for each of these transactions, allowing the oracle to respond appropriately.

On transaction 1, the oracle saves the requested house ID when a Chainlink Function request is queued:

function _beforeFulfill(string[] calldata args) internal override {
lastHouseId = args[0].stringToUint();
}

Source: RAACHousePriceOracle.sol#L34-L36

Then on transaction 2, it uses the saved house ID in order to set the house's price that came back:

function _processResponse(bytes memory response) internal override {
uint256 price = abi.decode(response, (uint256));
housePrices.setHousePrice(lastHouseId, price);
emit HousePriceUpdated(lastHouseId, price);
}

Source: RAACHousePriceOracle.sol#L42-L46

However there is no assurances that when _processResponse is called the stored lastHouseId is correct, and in fact it won't be if there are multiple requests outstanding.

Impact Explanation

  1. When a house for sale is UNDERpriced this way, it may be purchased for less than the expected value. Later they could attempt to sell the house or use it as collateral at its fair market value.

  2. When a house being used as collateral is OVERpriced this way, it allows the borrower to borrow more USD than should be allowed. They are profitable even though the house will later be liquidated.

Both of these could lead to losses for the system, but 2) is more concerning since it is immediately profitable - the attacker walks away with crvUSD (vs 1 where they only have an NFT that still needs to be sold).

The POC below demonstrates both these impact scenarios.

Likelihood Explanation

It seems very likely that multiple house price update requests would be submitted as a batch, causing this issue to surface. Or maybe there is a issue with Chainlink's Decentralized Oracle Network that causes a pending request to take longer than usual and that leads to multiple price updates being requested.

Proof of Concept

The following git patch updates the e2e test file. The first couple "it" blocks demonstrate the issue simply. Then a couple tests show real world impact examples. Run with yarn run test:e2e.

diff --git a/test/e2e/protocols-tests.js b/test/e2e/protocols-tests.js
index 3040a7e..5f6e15e 100644
--- a/test/e2e/protocols-tests.js
+++ b/test/e2e/protocols-tests.js
@@ -345,4 +345,157 @@ describe('Protocol E2E Tests', function () {
expect(await contracts.stabilityPool.getUserDeposit(user1.address)).to.equal(0);
});
});
+
+ describe.only("BUG", () => {
+ const EXPENSIVE_HOUSE_PRICE = ethers.parseEther("3000000"); // $3m
+ const CHEAP_HOUSE_PRICE = ethers.parseEther("150000"); // $150k
+ let testOracle;
+ const CHEAP_HOUSE_TOKEN_ID = 1;
+ const EXPENSIVE_HOUSE_TOKEN_ID = 2;
+ let cheapHouseRequestId, expensiveHouseRequestId;
+
+ beforeEach(async () => {
+ // Create and configure a "Test" oracle, allowing us to simulate the Chainlink Functions.
+ {
+ // Setup test contracts:
+ const MockRouter = await ethers.getContractFactory("MockFunctionsRouter");
+ const mockRouter = await MockRouter.deploy();
+ const donId = ethers.encodeBytes32String("1");
+ const RAACHousePriceOracle = await ethers.getContractFactory("TestRAACHousePriceOracle");
+ testOracle = await RAACHousePriceOracle.deploy(await mockRouter.getAddress(), donId, await contracts.housePrices.getAddress());
+
+ // Change the test suite to use this oracle instead of the `owner` address:
+ await contracts.housePrices.setOracle(await testOracle.getAddress());
+ }
+
+ // Pre-req: multiple price update requests are outstanding.
+ {
+ cheapHouseRequestId = await sendRequestForHousePrice(CHEAP_HOUSE_TOKEN_ID);
+ expensiveHouseRequestId = await sendRequestForHousePrice(EXPENSIVE_HOUSE_TOKEN_ID);
+ }
+ })
+
+ // Queues a Chainlink Functions request and returns the requestId.
+ async function sendRequestForHousePrice(houseId) {
+ // All values other than `houseId` are placeholders taken from another test.
+ await testOracle.sendRequest(
+ "console.log('hello world'); return 129999.99;", // Some JS source
+ 1, // secretsLocation
+ "0x", // no secrets
+ [houseId.toString()], // string[] args - [houseId]
+ [], // bytes[] bytesArgs
+ 1, // subscriptionId
+ 200000 // callbackGasLimit
+ )
+ return await testOracle.s_lastRequestId();
+ }
+
+ // Simulates Chainlink Functions fulfilling the house price request.
+ async function fulfillRequestForHousePrice(requestId, housePrice) {
+ const responseBytes = new ethers.AbiCoder().encode(["uint256"], [housePrice]);
+ await testOracle.publicFulfillRequest(
+ requestId,
+ responseBytes,
+ "0x"
+ );
+ }
+
+ it("Asynchronous requests only populate the last request", async () => {
+ // The price requests are asynchronously fulfilled:
+ await fulfillRequestForHousePrice(cheapHouseRequestId, CHEAP_HOUSE_PRICE); // updates house 2
+ await fulfillRequestForHousePrice(expensiveHouseRequestId, EXPENSIVE_HOUSE_PRICE); // also updates house 2
+
+ const [cheapHousePrice, ] = await contracts.housePrices.getLatestPrice(CHEAP_HOUSE_TOKEN_ID);
+ const [expensiveHousePrice, ] = await contracts.housePrices.getLatestPrice(EXPENSIVE_HOUSE_TOKEN_ID);
+
+ // BUG: House 1 missed a price update:
+ expect(cheapHousePrice).to.equal(0);
+ expect(expensiveHousePrice).to.equal(EXPENSIVE_HOUSE_PRICE);
+ })
+
+ it("Asynchronous requests set invalid interim prices", async () => {
+ // House 1 is fulfilled first, which incorrectly assigns to house 2:
+ await fulfillRequestForHousePrice(cheapHouseRequestId, CHEAP_HOUSE_PRICE);
+
+ const [expensiveHousePrice, ] = await contracts.housePrices.getLatestPrice(EXPENSIVE_HOUSE_TOKEN_ID);
+ // BUG: House 2 has an invalid price:
+ expect(expensiveHousePrice).to.equal(CHEAP_HOUSE_PRICE); // should be 0 | EXPENSIVE_HOUSE_PRICE
+
+ // Later it will be correctly set:
+ await fulfillRequestForHousePrice(expensiveHouseRequestId, EXPENSIVE_HOUSE_PRICE);
+ })
+
+ describe("Purchasing a house NFT", () => {
+ let userBorrower;
+
+ beforeEach(async () => {
+ userBorrower = user2;
+
+ // User is prepared to purchase house 2, the expensive house:
+ await contracts.crvUSD.mint(userBorrower.address, EXPENSIVE_HOUSE_PRICE - await contracts.crvUSD.balanceOf(userBorrower.address));
+ await contracts.crvUSD.connect(userBorrower).approve(contracts.nft.target, EXPENSIVE_HOUSE_PRICE);
+ })
+
+ it("Impact: Could buy at lower than expected price", async () => {
+ // House 1 is fulfilled first, which incorrectly assigns to house 2:
+ await fulfillRequestForHousePrice(cheapHouseRequestId, CHEAP_HOUSE_PRICE);
+
+ // User attempts to purchase the house 2 for the expected expensive price:
+ const tx = await contracts.nft.connect(userBorrower).mint(EXPENSIVE_HOUSE_TOKEN_ID, EXPENSIVE_HOUSE_PRICE);
+
+ // BUG: Borrower paid significantly less than the house's expected price:
+ await expect(tx).to.changeTokenBalance(contracts.crvUSD, userBorrower, -CHEAP_HOUSE_PRICE);
+ })
+
+ describe("Borrowing from the Lending Pool", () => {
+ let userLender;
+
+ beforeEach(async () => {
+ // Resubmit prices so both houses are correctly priced at first:
+ {
+ cheapHouseRequestId = await sendRequestForHousePrice(CHEAP_HOUSE_TOKEN_ID);
+ await fulfillRequestForHousePrice(cheapHouseRequestId, CHEAP_HOUSE_PRICE);
+ expensiveHouseRequestId = await sendRequestForHousePrice(EXPENSIVE_HOUSE_TOKEN_ID);
+ await fulfillRequestForHousePrice(expensiveHouseRequestId, EXPENSIVE_HOUSE_PRICE);
+ }
+
+ // Purchase the house 1, the cheap house, for $150k:
+ await contracts.nft.connect(userBorrower).mint(CHEAP_HOUSE_TOKEN_ID, CHEAP_HOUSE_PRICE);
+
+ // Deposit it as collateral:
+ await contracts.nft.connect(userBorrower).approve(contracts.lendingPool.target, CHEAP_HOUSE_TOKEN_ID);
+ await contracts.lendingPool.connect(userBorrower).depositNFT(CHEAP_HOUSE_TOKEN_ID);
+
+ // Setup `userLender` as a liquidity provider.
+ {
+ userLender = user3;
+ const DEPOSIT_AMOUNT = ethers.parseEther('10000000'); // $10m, more than enough liquidity.
+ await contracts.crvUSD.mint(userLender.address, DEPOSIT_AMOUNT);
+ await contracts.crvUSD.connect(userLender).approve(contracts.lendingPool.target, DEPOSIT_AMOUNT);
+ await contracts.lendingPool.connect(userLender).deposit(DEPOSIT_AMOUNT);
+ }
+
+ // Pre-req: multiple price update requests are outstanding.
+ {
+ expensiveHouseRequestId = await sendRequestForHousePrice(EXPENSIVE_HOUSE_TOKEN_ID); // This time house 2 was requested first.
+ cheapHouseRequestId = await sendRequestForHousePrice(CHEAP_HOUSE_TOKEN_ID);
+ }
+ })
+
+ it("Impact: Could borrow more than expected", async () => {
+ // The expensive house updates price first, which incorrectly assigns to the other house:
+ await fulfillRequestForHousePrice(expensiveHouseRequestId, EXPENSIVE_HOUSE_PRICE);
+
+ // While their house is overpriced, they borrow more than should be allowed:
+ const BORROW_AMOUNT = ethers.parseEther('2000000'); // $2m
+ const tx = await contracts.lendingPool.connect(userBorrower).borrow(BORROW_AMOUNT);
+
+ // BUG: Borrower got a $2m for their house only worth $150k:
+ await expect(tx).to.changeTokenBalance(contracts.crvUSD, userBorrower, BORROW_AMOUNT);
+
+ // Now the borrower can let their NFT be liquidated and just walk away with this $1.85m profit.
+ })
+ })
+ })
+ })
});
\ No newline at end of file

Recommendation

Update RAACHousePriceOracle.sol to parse both the houseId and its price from the message returned. This removes the need to track the lastHouseId and avoids any issues with having more than one outstanding request at a time.

When applied, all the POC tests above fail (since they were expecting bad state).

diff --git a/contracts/core/oracles/RAACHousePriceOracle.sol b/contracts/core/oracles/RAACHousePriceOracle.sol
index 763b56f..7cd6387 100644
--- a/contracts/core/oracles/RAACHousePriceOracle.sol
+++ b/contracts/core/oracles/RAACHousePriceOracle.sol
@@ -14,7 +14,6 @@ contract RAACHousePriceOracle is BaseChainlinkFunctionsOracle {
using StringUtils for string;
RAACHousePrices public housePrices;
- uint256 public lastHouseId;
event HousePriceUpdated(uint256 indexed houseId, uint256 price);
@@ -32,7 +31,7 @@ contract RAACHousePriceOracle is BaseChainlinkFunctionsOracle {
* @param args The arguments passed to sendRequest
*/
function _beforeFulfill(string[] calldata args) internal override {
- lastHouseId = args[0].stringToUint();
+ // no-op
}
/**
@@ -40,8 +39,8 @@ contract RAACHousePriceOracle is BaseChainlinkFunctionsOracle {
* @param response The response from the oracle
*/
function _processResponse(bytes memory response) internal override {
- uint256 price = abi.decode(response, (uint256));
- housePrices.setHousePrice(lastHouseId, price);
- emit HousePriceUpdated(lastHouseId, price);
+ (uint256 houseId, uint256 price) = abi.decode(response, (uint256, uint256));
+ housePrices.setHousePrice(houseId, price);
+ emit HousePriceUpdated(houseId, price);
}
}

When encoding the response in the Chainlink Function, it can respond with this format:

const responseBytes = new ethers.AbiCoder().encode(["uint256", "uint256"], [houseId, housePrice]);

FYI / Alternative

Some of the Chainlink Functions example contracts use the following in order to prevent this problem:

function fulfillRequest(
bytes32 requestId,
bytes memory response,
bytes memory err
) internal override {
if (s_lastRequestId != requestId) {
revert UnexpectedRequestID(requestId);
}
...
}

Source: chainlink-functions/tutorials/simple-computation

That approach causes fulfills to revert if they are not responding to the most recent request. This approach if added to the shared BaseChainlinkFunctionsOracle would ensure that the invalid house prices are never assigned. However this approach would also prevent requesting multiple house price updates in parallel, which may be a desirable feature. Additionally BaseChainlinkFunctionsOracle works well for the RAACPrimeRateOracle use case and would work for this use case as well with the above fix - therefor that base implementation (which is out of scope) is fine so long as it's integrated with correctly for the given use case.

Updates

Lead Judging Commences

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

Oracle Race Condition in RAACHousePriceOracle causes price misassignment between NFTs

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

Oracle Race Condition in RAACHousePriceOracle causes price misassignment between NFTs

Support

FAQs

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