Reproduced Exploit
Unicly PointFarm Exploit — ERC1155 Reentrancy Inflates Reward Points to Steal a LootRealms NFT
PointFarm is a SushiSwap MasterChef fork (its own header says "Copied from … MasterChef.sol — Modified by 0xLeia") that pays farming rewards as an ERC1155 "points" token instead of a normal ERC20. Users stake a Unicly uToken (here uJENNY) and accrue non-transferable points; those points are later b…
Loss
1 NFT — LootRealms #4689 (a "Realm" NFT, redeemed from the Unicly shop without enough points)
Chain
Ethereum
Category
Reentrancy
Date
Sep 2023
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2023-09-uniclyNFT_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/uniclyNFT_exp.sol.
Vulnerability classes: vuln/reentrancy/cross-function · vuln/logic/reward-calculation
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Verified vulnerable source: PointFarm.sol (the redemption path is in PointShop.sol).
Key info#
| Loss | 1 NFT — LootRealms #4689 (a "Realm" NFT, redeemed from the Unicly shop without enough points) |
| Vulnerable contract | PointFarm — 0xd3C41c85bE295607E8EA5c58487eC5894300ee67 |
| Redemption contract | PointShop — 0xcDCc535503CBA9286489b338b36156b4b75008f6 |
| uToken / pool | uJENNY (0xa499648fD0e80FD911972BbEb069e4c20e68bF22) · uJENNY/WETH pair 0xEC5100AD159F660986E47AFa0CDa1081101b471d |
| Stolen NFT collection | LootRealms (0x7AFe30cB3E53dba6801aa0EA647A0EcEA7cBe18d), token id 4689 |
| Attacker EOA | 0x92cfcb70b2591ceb1e3c6d90e21e8154e7d29832 |
| Attacker contract | 0x9d9820f10772ffcef842770b6581c07a97fed9e4 |
| Attack tx | 0xc42fe1ce2516e125a386d198703b2422aa0190b25ef6a7b0a1d3c6f5d199ffad |
| Chain / block / date | Ethereum mainnet / fork at 18,133,171, attack rolled to 18,149,401 / Sept 2023 |
| Compiler | PointFarm/PointShop/Converter: Solidity v0.6.12 (optimizer off); PoC compiled under 0.8.34 |
| Bug class | ERC1155 reentrancy (read-only-balance / state-update-after-callback) in a MasterChef-style reward accountant |
| Analysis | @DecurityHQ thread |
TL;DR#
PointFarm is a SushiSwap MasterChef fork (its own header says "Copied from … MasterChef.sol — Modified by 0xLeia") that pays farming rewards as an ERC1155 "points" token instead of a normal ERC20. Users stake a Unicly uToken (here uJENNY) and accrue non-transferable points; those points are later burned in PointShop.redeem() to claim NFTs out of a shop.
The reward-settlement logic in
deposit() follows the classic MasterChef pattern: settle the user's pending reward, then update the user's rewardDebt. But because the reward is paid by minting an ERC1155 via _mint(...), the OpenZeppelin ERC1155 _doSafeTransferAcceptanceCheck makes an external onERC1155Received callback to the recipient before user.rewardDebt is updated. That is a textbook checks-effects-interactions violation: the "effect" (rewardDebt = …) happens after the "interaction" (the mint callback).
A malicious recipient re-enters deposit(0,0) from inside its onERC1155Received hook. On each re-entry, pending = user.amount × accPointsPerShare/1e18 − user.rewardDebt is recomputed against the still-stale rewardDebt, so the same pending reward is minted again and again. The attacker tripled its point balance (3,528 → 10,584), enough to clear the NFT redemption price of 10,000 points that its honest single payout of 3,528 could never reach.
With 10,584 points, the attacker called PointShop.redeem(), which burned 10,000 points and shipped out LootRealms #4689 for free. It then withdrew its uJENNY stake and swapped back to WETH, ending roughly capital-neutral (0.5 WETH in, 0.497 WETH out) while walking off with the NFT.
Background — Unicly points farming#
Unicly lets a collection of NFTs be fractionalized into an ERC20 uToken via a Converter. Holders of a uToken can farm "points" and spend those points to redeem specific NFTs from a curated shop. Three contracts cooperate:
PointFarm(source) — the MasterChef-style staking pool. Youdeposit(pid, amount)youruToken; over time you accrue points minted as an ERC1155 (id == pid). Points are non-transferable —safeTransferFrom/safeBatchTransferFromare overridden to requirefrom == this || to == this, so the only way points move is mint/burn.PointShop(source) — holds NFTs that can be redeemed.redeem(uToken, internalID)burnsnfts[uToken][internalID].pricepoints from the caller (viaPointFarm.burn) and transfers the NFT.Converter— theuJENNYERC20 itself, backed by NFTs including the LootRealms collection.
The reward math is standard MasterChef:
pending reward = user.amount * pool.accPointsPerShare / 1e18 - user.rewardDebt
accPointsPerShare grows every block (pointsPerBlock per block, scaled by 1e18 and divided by the pool's uToken balance). rewardDebt is the "already-paid" watermark; after each settle it is reset to user.amount * accPointsPerShare / 1e18 so the next pending starts from zero.
The whole scheme rests on one invariant: once a pending reward is paid, rewardDebt is bumped so it cannot be paid again. The bug breaks exactly that invariant.
The vulnerable code#
1. deposit() settles rewards by minting an ERC1155, then updates rewardDebt last#
function deposit(uint256 _pid, uint256 _amount) public {
PoolInfo storage pool = poolInfo[_pid];
UserInfo storage user = userInfo[_pid][msg.sender];
updatePool(_pid);
if (user.amount > 0) {
uint256 pending = user.amount.mul(pool.accPointsPerShare).div(1e18).sub(user.rewardDebt);
if(pending > 0) {
bytes memory data;
_mint(msg.sender, _pid, pending, data); // ⚠️ external callback to msg.sender HERE
}
}
if(_amount > 0) {
pool.uToken.safeTransferFrom(address(msg.sender), address(this), _amount);
user.amount = user.amount.add(_amount);
}
user.rewardDebt = user.amount.mul(pool.accPointsPerShare).div(1e18); // ⚠️ effect AFTER the interaction
emit Deposit(msg.sender, _pid, _amount);
}
There is no reentrancy guard (no nonReentrant, no inherited ReentrancyGuard). user.rewardDebt — the value that prevents double payment — is written on the last line, after _mint has already handed control back to msg.sender.
2. _mint calls back the recipient before rewardDebt is set#
_mint resolves to OpenZeppelin's ERC1155 _mint, which credits the balance and then runs the acceptance check, which makes the external call:
function _doSafeTransferAcceptanceCheck(...) private {
if (to.isContract()) {
try IERC1155Receiver(to).onERC1155Received(operator, from, id, amount, data) returns (bytes4 response) {
...
(see PointFarm.sol:1230-1251). Because the ERC1155 balance is already incremented but PointFarm.user.rewardDebt is not yet updated, the recipient observes an intermediate, inconsistent state and can act on it.
3. The attacker's onERC1155Received re-enters deposit(0,0)#
function onERC1155Received(address, address, uint256, uint256, bytes calldata) external returns (bytes4) {
uint256 pointFarmBalance = PointFarm.balanceOf(address(this), 0);
if (pointFarmBalance <= 10_000) { // keep re-depositing until we clear the NFT price
PointFarm.deposit(0, 0);
}
return this.onERC1155Received.selector;
}
Each nested deposit(0,0):
- calls
updatePool(0)— butblock.numberhasn't changed, soaccPointsPerShareis unchanged; - recomputes
pending = user.amount * accPointsPerShare/1e18 - user.rewardDebtagainst the still-stalerewardDebt→ identical 3,528 points; - mints another 3,528 points → another callback.
The recursion terminates when the balance exceeds 10,000, then the stack unwinds and the innermost frame finally sets rewardDebt. By then the attacker has been paid the same reward three times.
4. PointShop.redeem() burns the inflated points for an NFT#
function redeem(address _uToken, uint256 internalID) public {
PointFarm(farm).burn(msg.sender, PointFarm(farm).shopIDs(_uToken), nfts[_uToken][internalID].price);
...
IERC721(nfts[_uToken][internalID].contractAddr).transferFrom(address(this), msg.sender, ...tokenId);
}
The NFT price was 10,000 points. An honest single deposit settlement paid only 3,528 — far short. The reentrancy made up the difference.
Root cause#
A reentrancy caused by violating checks-effects-interactions in a reward accountant whose reward token performs an external callback on mint:
- State-update-after-interaction.
deposit()mints the reward (an externalonERC1155Receivedcallback) before writinguser.rewardDebt. The variable that enforces "pay each reward once" is updated last. - The reward token is itself a callback token. Unlike vanilla MasterChef (where the reward is a plain ERC20 mint that never calls back), here points are an ERC1155, so every reward payout hands control to the recipient mid-function. The MasterChef template was safe only because ERC20
_mintdoesn't call out; porting it to ERC1155 silently introduced a reentrancy surface. - No reentrancy guard.
deposit()(andwithdraw(), which has the identical structure) has nononReentrantmodifier, so nested re-entry is permitted. - The pending formula reads only mutated-too-late state. Because
accPointsPerSharedoesn't change within the same block andrewardDebtisn't yet updated,pendingis constant across re-entries — each re-entry re-mints the full reward.
The net effect: the attacker is paid the same pending reward once per re-entry. withdraw() shares the exact same flaw and could be abused the same way.
Preconditions#
- Attacker holds a non-zero
uTokenstake (user.amount > 0) so thatdeposittakes the reward-settlement branch. In the PoC the attacker buys ~3,528 uJENNY with 0.5 WETH and stakes it. - At least some blocks must elapse after the stake so that
pending > 0. The PoCvm.rolls from 18,133,171 to 18,149,401 (≈16,230 blocks, ~2 days) to accrue points. - The attacker is a contract that implements
onERC1155Receivedand re-entersdeposit. (An EOA cannot reenter; this is why the attack runs through attacker contract0x9d98…d9e4.) - The single honest payout must be less than the NFT price (3,528 < 10,000), so reentrancy is needed to clear it — here three payouts (10,584) suffice.
No flash loan, no privileged role, no oracle. The only capital required is enough WETH to buy a small uJENNY stake, and that capital is recovered at the end.
Step-by-step attack walkthrough (with on-chain numbers from the trace)#
All numbers below are taken directly from output.txt. Pool uJENNY/WETH: reserve0 = uJENNY, reserve1 = WETH.
| # | Step | Concrete values (from trace) |
|---|---|---|
| 0 | Fund the attacker with 0.5 WETH | deal(WETH, this, 5e17) |
| 1 | Buy uJENNY — transfer 0.5 WETH to the pair, swap out uJENNY | out = 3,527.995810700000234095 uJENNY (3.527e21); pool after: 1,692,844 uJENNY / 239.696 WETH |
| 2 | Stake — PointFarm.deposit(0, 3527.99e18) | farm's uJENNY balance 10,000 → 13,527.99; sets user.amount, accPointsPerShare, rewardDebt |
| 3 | Wait — vm.roll(18_149_401) | +16,230 blocks (~2 days) of point accrual |
| 4 | Trigger reward + reenter — PointFarm.deposit(0, 0) | see the reentrancy ladder below |
| 4a | outer deposit: pending = 3,528, _mint(this,0,3528) → callback; balance = 3,528 (≤10,000) ⇒ reenter | TransferSingle … value: 3528, balance 3528 |
| 4b | re-entry #1: _mint 3,528 → callback; balance = 7,056 (≤10,000) ⇒ reenter | balance 7056 |
| 4c | re-entry #2: _mint 3,528 → callback; balance = 10,584 (>10,000) ⇒ stop | balance 10584 |
| 4d | stack unwinds; each frame sets rewardDebt (now too late) | final point balance = 10,584 |
| 5 | Unstake — PointFarm.withdraw(0, 3527.99e18) | uJENNY returned to attacker |
| 6 | Swap back — transfer uJENNY to pair, swap out WETH | out = 0.497010711179183339 WETH (4.97e17) |
| 7 | Approve + redeem — setApprovalForAll(PointShop,true) then PointShop.redeem(uJENNY, 0) | burn(this, 0, 10000) → balance 10,584 → 584; Realm.transferFrom(PointShop → this, 4689) |
| 8 | Result | Realm.ownerOf(4689) == attacker; attacker WETH = 0.497 |
Why 3 mints? The legitimate single payout was pending = 3528. The attacker's guard if (balance <= 10_000) deposit(0,0) re-enters as long as the balance is ≤10,000. After 1 mint = 3,528 (≤10,000 → reenter), 2 mints = 7,056 (≤10,000 → reenter), 3 mints = 10,584 (>10,000 → stop). The NFT price is 10,000, so the attacker needs ≥3 payouts. Each re-entry pays the same 3,528 because rewardDebt is never updated until the recursion unwinds.
Profit / loss accounting#
| Item | Amount |
|---|---|
| WETH in (buy uJENNY) | 0.500000000000000000 |
| WETH out (sell uJENNY back) | 0.497010711179183339 |
| Net WETH | −0.00298928882… (swap fees + price impact) |
| Points minted vs. earned | 10,584 minted vs. 3,528 legitimately earned (3×) |
| NFT extracted | LootRealms #4689 (price 10,000 points) — taken essentially for free |
The attacker is roughly capital-neutral on WETH (a few thousandths of an ETH lost to AMM fees) and walks away with the NFT. The economic loss falls on the Unicly shop / uJENNY collateral pool, which gave up a real NFT for points that were conjured out of thin air. (The PoC's reported Attacker WETH balance after attack: 0.497… is the round-trip residue, not a profit — the profit is the NFT.)
Diagrams#
Sequence of the attack#
Why the points inflate: state-update-after-interaction#
Point-balance evolution vs. the redemption gate#
Remediation#
- Add a reentrancy guard. Apply OpenZeppelin
ReentrancyGuard'snonReentranttodeposit,withdraw, andemergencyWithdraw. This alone blocks the nested re-entry. - Follow checks-effects-interactions. Update
user.rewardDebt(the anti-double-pay watermark) before minting the reward, not after. IfrewardDebtis already advanced when the callback fires, a re-entrantdepositcomputespending == 0and mints nothing. - Don't pay rewards with a callback token, or pull-not-push. A MasterChef port should keep rewards as a non-callback ERC20, or move to a pull model (
claim()that the user calls explicitly) so settlement does not hand control to an arbitrary recipient mid-accounting. - Validate the acceptance hook.
onERC1155Receivedhere returns the magic value only whenkeccak256(data) == keccak256("JCNH"); minting passes emptydata, so the standard mint would normally revert. The attacker side-steps this by implementing its own permissive receiver — but the deeper issue is that any external call during settlement is unsafe, regardless of the hook's checks. - Cap or sanity-check per-block reward issuance. A single account receiving the same
pendingmultiple times within one block is an invariant violation; an assertion thatrewardDebtstrictly increases on each payout would have caught it.
How to reproduce#
The PoC is an isolated Foundry project (the umbrella DeFiHackLabs repo does not whole-compile under forge test).
_shared/run_poc.sh 2023-09-uniclyNFT_exp --mt testExploit -vvvvv
- RPC: a mainnet archive endpoint is required (fork block 18,133,171, then
vm.rollto 18,149,401).foundry.toml'smainnetendpoint serves the historical state. - Result:
[PASS] testExploit()— the attacker ends owning LootRealms #4689 with only ~3,528 legitimately-earned points.
Expected tail:
[PASS] testExploit() (gas: 642763)
Attacker Realm NFT balance before attack: 0
Attacker WETH balance after attack: 0.497010711179183339
Attacker Realm NFT balance after attack: 1
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 11.98s
Reference: Decurity analysis — https://twitter.com/DecurityHQ/status/1703096116047421863 (Unicly PointFarm, Ethereum, Sept 2023).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-09-uniclyNFT_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
uniclyNFT_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "Unicly PointFarm Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.