On May 27, 2026 at 22:41:03 UTC, Joe Agent on BNB Chain was exploited through a single-function reentrancy bug in its protocol-custodied liquidity removal flow. The vulnerable token proxy 0xef0f12d08d66e76e1866e60f30a0daa578e00c04 delegates to JoeAgentToken implementation 0xb12ce0a21f67a9fc3c8ad1c7dbc4b017b7e67319. During the primary exploit transaction, the attacker contract repeatedly re-entered liquidity removal before its internal LP credit was reduced, receiving 62.5 BNB through 25 native-token callbacks and 1,195,918.923198279 JOE. The attacker contract’s native balance increased by 40 BNB in this transaction after PancakeSwap-side liquidity accounting, while the JOE proceeds were later converted in follow-up transactions.

Root Cause

Vulnerable Contract

The vulnerable contract is the Joe Agent token proxy at 0xef0f12d08d66e76e1866e60f30a0daa578e00c04, an ERC1967 proxy for implementation 0xb12ce0a21f67a9fc3c8ad1c7dbc4b017b7e67319. The implementation source is verified as JoeAgentToken. The affected state is the protocol-custodied LP accounting stored in lpInfo[user].lpAmount and lpPrincipalValue[user].

Vulnerable Function

The vulnerable external entry point is removeLiquidityViaContract(uint256,uint256,uint256,uint256), selector 0xacff149d, which calls internal _removeLiquidityViaContract(address,uint256,uint256,uint256,uint256). This path removes LP tokens held by the token contract, transfers the withdrawn JOE payout to user, sends the withdrawn BNB to user with a low-level call, and only then decreases lpInfo[user].lpAmount. Because user is msg.sender for removeLiquidityViaContract, a contract caller can use its receive/fallback function to re-enter the same function before its LP credit is consumed.

Vulnerable Code

function _removeLiquidityViaContract(
    address user,
    uint256 liquidity,
    uint256 amountTokenMin,
    uint256 amountETHMin,
    uint256 deadline
) internal {
    require(liquidity > 0, "zero liquidity");
    require(lpInfo[user].lpAmount >= liquidity, "insufficient lp units");
    require(lpInfo[user].lastAddLpTime != block.timestamp, "cannot add/remove in same block");

    IERC20(mainPair).approve(address(uniswapV2Router), liquidity);

    uint256 joeBefore = balanceOf(address(this));
    uint256 ethBefore = address(this).balance;
    uint256 lpAmountBefore = lpInfo[user].lpAmount;
    uint256 principalBefore = lpPrincipalValue[user];

    uniswapV2Router.removeLiquidityETHSupportingFeeOnTransferTokens(
        address(this),
        liquidity,
        amountTokenMin,
        amountETHMin,
        address(this),
        deadline
    );

    uint256 joeReceived = balanceOf(address(this)) - joeBefore;
    uint256 ethReceived = address(this).balance - ethBefore;
    uint256 feeAmount = _calcFee(removeFee, joeReceived);
    uint256 payout = joeReceived - feeAmount;

    if (payout > 0) {
        _transfer(address(this), user, payout);
    }
    if (ethReceived > 0) {
        (bool ok, ) = user.call{
            value: ethReceived
        }(""); // <-- VULNERABILITY: external BNB call before LP credit is reduced
        require(ok, "eth transfer failed");
    }

    uint256 principalReduction = lpAmountBefore == 0 ? 0 : (principalBefore * liquidity) / lpAmountBefore;
    lpInfo[user].lpAmount = lpAmountBefore - liquidity;
    lpPrincipalValue[user] = principalBefore > principalReduction ? principalBefore - principalReduction : 0;
    _syncLPWeight(user);
}

Why It’s Vulnerable

Expected behavior: A liquidity-removal function must consume or lock the caller’s internal LP credit before making any external call to that caller. For this contract, once liquidity is authorized against lpInfo[user].lpAmount, the user’s LP balance should be reduced before BNB is sent to user, or the function should be guarded by a reentrancy lock that also covers recursive entry into the same function.

Actual behavior: _removeLiquidityViaContract caches lpAmountBefore, removes liquidity through PancakeSwap, transfers JOE, and then sends BNB to user with user.call with ethReceived while lpInfo[user].lpAmount still contains the full pre-call amount. The lockTheSwap modifier only toggles inSwap; it does not reject re-entry into removeLiquidityViaContract. The attacker’s fallback therefore called removeLiquidityViaContract again with the same credited LP amount, allowing multiple removals before the outermost frame finally wrote lpInfo[user].lpAmount = lpAmountBefore - liquidity.

Attack Execution

High-Level Flow

  1. The attacker contract held an internal Joe Agent LP credit of 437.368895272310313834 LP units before the primary exploit transaction.
  2. The attacker EOA called the attacker contract, which invoked Joe Agent removeLiquidityViaContract with that same LP amount and zero minimum-output constraints.
  3. Joe Agent approved the LP pair and called PancakeSwap V2 removeLiquidityETHSupportingFeeOnTransferTokens, receiving JOE and BNB from the JOE-WBNB pair.
  4. Joe Agent transferred the JOE payout to the attacker contract and sent the BNB payout to the same contract before reducing lpInfo[attacker].lpAmount.
  5. The attacker’s fallback re-entered removeLiquidityViaContract repeatedly, reusing the stale LP credit until the transaction completed with the attacker’s LP credit finally set to zero.

Detailed Call Trace

The EOA 0xaa761779945dcc5f26064fc6dcb36ffab6ac7610 called attacker contract 0x31f81fcd91025728f24bd6f0e4efb156e345a4cf, which queried lpInfo(address) and then entered the Joe Agent proxy through removeLiquidityViaContract(uint256,uint256,uint256,uint256). Each successful removal frame delegated from the proxy to JoeAgentToken, approved the JOE-WBNB Pancake LP 0x7e5396a6b56372d1ee42ab6b199bc2d5a8540f9c, and called PancakeSwap V2 router 0x10ed43c718714eb63d5aa57b78b54704e256024e via removeLiquidityETHSupportingFeeOnTransferTokens. Across the completed removals, the router pulled JOE and WBNB from the pair, unwrapped WBNB, and sent BNB back to the Joe Agent proxy; the primary transaction burned 6,997.902324356965 LP units in aggregate. Each reentrant call requested 437.368895272310313834 LP units, the amount still shown in the attacker contract’s internal LP credit before the delayed state write executed.

After each removal, Joe Agent transferred the received JOE to the attacker contract and executed a native BNB call to the attacker contract. The trace shows 25 native-token sends from the Joe Agent proxy to the attacker contract, each for 2.5 BNB, totaling 62.5 BNB. Those callbacks are the reentrancy edge: before the outer frame reduced lpInfo[attacker].lpAmount, the attacker contract recursively invoked removeLiquidityViaContract again. The trace contains 26 calls into removeLiquidityViaContract; the final nested attempts run out of usable execution context and revert, but their reverts are contained inside the attacker’s callback path while the already-completed removals remain effective.

Financial Impact

The primary transaction transferred 1,195,918.923198279 JOE from the Joe Agent proxy to attacker contract 0x31f81fcd91025728f24bd6f0e4efb156e345a4cf and made 25 BNB sends of 2.5 BNB each from the proxy to that contract, for 62.5 BNB in gross repeated BNB payouts. The attacker contract’s BNB balance increased from 7.9896085257995635 BNB before the transaction to 47.98960852579956 BNB after it, a 40 BNB net native-token increase in this transaction; the difference reflects BNB moved through PancakeSwap liquidity operations during execution. The attacker contract’s JOE balance increased from 241,808.69604250006 JOE to 1,437,727.619240779 JOE across the same block, matching the 1,195,918.923198279 JOE receipt. Follow-up transactions later sold accumulated JOE and swept BNB to the attacker EOA.

Evidence

  • Transaction: 0xd16a1c3dcd84427b2c7dcccbe1854c1c5bf65900460e1a44a95c1aaaf140c3a5 on BNB Chain, block 100812531, status success.
  • Attacker: EOA 0xaa761779945dcc5f26064fc6dcb36ffab6ac7610; attacker contract 0x31f81fcd91025728f24bd6f0e4efb156e345a4cf.
  • Vulnerable contract: Joe Agent proxy 0xef0f12d08d66e76e1866e60f30a0daa578e00c04, implementation 0xb12ce0a21f67a9fc3c8ad1c7dbc4b017b7e67319.
  • Impacted pool/token/protocol component: Joe Agent’s protocol-custodied JOE-WBNB Pancake LP position at 0x7e5396a6b56372d1ee42ab6b199bc2d5a8540f9c.
  • Key on-chain fact: before the transaction, lpInfo[0x31f81fcd91025728f24bd6f0e4efb156e345a4cf].lpAmount was 437.368895272310313834; after the transaction it was zero.
  • Key on-chain fact: the transaction emitted JOE transfers totaling 1,195,918.923198279 JOE to the attacker contract and the call trace shows 25 proxy-to-attacker BNB sends totaling 62.5 BNB.

Remediation

Move the LP accounting updates before any external transfer to user: after validating the requested liquidity, decrement lpInfo[user].lpAmount and reduce lpPrincipalValue[user] before the router call and before any user callback can occur. If the router call later fails, the whole transaction will revert and restore the optimistic accounting update; if it succeeds, reentrant frames will see the reduced LP credit. Add a real non-reentrant guard, such as OpenZeppelin ReentrancyGuardUpgradeable, around all LP add/remove/zap entry points that touch the same accounting. Avoid raw native-token callbacks to arbitrary users where possible; if BNB must be returned, use a pull-payment pattern so users withdraw refunds and proceeds in a separate transaction after state is finalized. Add invariant tests that simulate a malicious receiver re-entering removeLiquidityViaContract and assert that one credited LP position can be consumed only once.