Reproduced Exploit
Carrot Token Exploit — Arbitrary `transReward()` Hijacks the Reward Pool to Bypass `transferFrom` Allowance
token (Carrot) ships two fatally-composed bugs:
Loss
~$31,318 — 31,318.18 BUSD-T drained from the Carrot/BUSD-T PancakeSwap pair
Chain
BNB Chain
Category
Access Control
Date
Oct 2022
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: 2022-10-Carrot_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/Carrot_exp.sol.
Vulnerability classes: vuln/access-control/missing-auth · vuln/logic/missing-allowance · vuln/dependency/unsafe-external-call
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not whole-compile, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: token.sol.
Key info#
| Loss | ~$31,318 — 31,318.18 BUSD-T drained from the Carrot/BUSD-T PancakeSwap pair |
| Vulnerable contract | token (Carrot) — 0xcFF086EaD392CcB39C49eCda8C974ad5238452aC |
| Victim pool | Carrot/BUSD-T pair — 0xF34c9a6AaAc94022f96D4589B73d498491f817FA |
| "Reward" Pool (call target) | 0x6863b549bf730863157318df4496eD111aDFA64f |
| Victim holder (tokens stolen) | 0x00B433800970286CF08F34C96cf07f35412F1161 |
| Attacker EOA | 0xd11a93a8db5f8d3fb03b88b4b24c3ed01b8a411c |
| Attacker contract | 0x5575406ef6b15eec1986c412b9fbe144522c45ae |
| Attack tx | 0xa624660c29ee97f3f4ebd36232d8199e7c97533c9db711fa4027994aa11e01b9 |
| Chain / fork block / date | BSC / 22,055,611 / 2022-10-10 12:53:41 UTC |
| Compiler | Solidity v0.8.6, optimizer off (runs: 200 declared, optimizer: 0) |
| Bug class | Arbitrary external call (access-control bypass) → fee-exemption flag abuse → allowance-check bypass on transferFrom |
TL;DR#
token (Carrot) ships two fatally-composed bugs:
-
An unprotected arbitrary-call primitive.
transReward(bytes data)(token.sol:1402-1404) ispublicwith no access control and forwardsdataverbatim to the configuredpoolcontract viapool.functionCall(data). Anyone can make the Carrot token call any function on the Pool with any arguments. -
A self-granting fee-exemption that bypasses the allowance check. Inside
transferFrom(:470-492) the pre-hook_beforeTransfer(:401-408) marks the caller as_isExcludedFromFeeiff that caller is the currentowner()of the Pool and a one-shotcounteris still 0. When_isExcludedFromFee[msg.sender]is true,transferFromperforms the transfer andreturns early — skipping the_allowances[...].sub(amount, …)line entirely. So a fee-exempt caller can move anyone's tokens with no approval.
The Pool is an external Ownable-style contract. Its owner can be reset through transReward by ABI-encoding the
Pool's transferOwnership-equivalent selector 0xbf699b4b. So the attacker:
- Calls
transReward(abi.encodeWithSelector(0xbf699b4b, attacker))→ the Carrot token calls the Pool and makes the attacker the Pool's owner (storage slot 5 flips from the legitimate owner to the attacker — confirmed in the trace). - Calls
transferFrom(victim, attacker, 310_344.74 CARROT)._beforeTransferseesPool.owner() == attacker, sets_isExcludedFromFee[attacker] = true,counter → 1;transferFromthen takes the excluded branch and moves the victim's 310,344.74 CARROT with zero allowance. - Dumps the stolen CARROT into the PancakeSwap Carrot/BUSD-T pair, walking away with 31,318.18 BUSD-T.
Background — what Carrot is#
token (source) is a typical BSC "reflection / auto-LP" meme token with a 6.5%-ish
buy/sell tax that is split into burn, liquidity, marketing and a multi-level invite reward. Total supply is fixed at
100 * 10**4 * 10**18 = 1,000,000 CARROT (:1324).
Two pieces of bolted-on machinery are relevant to the exploit:
- A fee-exemption map
_isExcludedFromFeelives in the inheritedERC20base (:327) — note this is a different mapping from thetoken-level_isExcludedFromFees(with an extras) used by the tax logic. The base-level map is touched only by_beforeTransferand read only inside the basetransferFrom. - An external "Reward" Pool contract whose address is stored in the base field
pool(:333) and set once via the owner-onlyinitPool(:1397-1400). The token interacts with the Pool for liquidity/reward bookkeeping (swapAndLiquify,_splitOtherToken, etc.) and — critically — through the wide-opentransReward.
On-chain facts at the fork block (read from the trace, output.txt):
| Fact | Value |
|---|---|
| Pool owner before attack (slot 5) | 0x8958c8689d325fd9e2a1ede3d5dc1acfcfb65742 |
Pool owner after transReward (slot 5) | 0x7fa9385be102ac3eac297483dd6233d62b3e1496 (attacker) |
| Victim CARROT balance moved | 310,344.736073087429864760 CARROT |
counter (base ERC20, slot 4) before/after | 0 → 1 |
| CARROT delivered into the pair (post-tax) | 291,724.051908702184072160 CARROT |
BUSD-T paid out to attacker (amount0Out) | 31,318.180838433700165284 BUSD-T |
Note on the PoC's framing. The in-PoC comment calls this "insufficient access control to the migrateStake function." That is the original informal label; the mechanically verified root cause is the unprotected
transRewardarbitrary call combined with the allowance-skipping fee-exempt branch intransferFrom. The trace shows nomigrateStakecall — the attack istransReward→transferFrom→ swap.
The vulnerable code#
1. transReward — unprotected arbitrary call into the Pool#
// token.sol:1402-1404
function transReward(bytes memory data) public {
pool.functionCall(data); // ⚠️ public, no auth — calls Pool with attacker-chosen calldata
}
pool is address internal pool from the base ERC20 (:333),
set by the owner-only initPool (:1397-1400). transReward
itself has no onlyOwner, no msg.sender check, no selector allow-list — it relays whatever bytes the caller
supplies straight to the Pool via OpenZeppelin's Address.functionCall. The Pool is a separate Ownable contract, so
its ownership can be reassigned by encoding its ownership-transfer selector (0xbf699b4b).
2. _beforeTransfer — self-granting fee exemption keyed on Pool.owner()#
// token.sol:401-408 (base ERC20)
function _beforeTransfer(address from, address to, uint256 amount) private {
if (from.isContract())
if (ownership(pool).owner() == from && counter == 0) { // ⚠️ if caller == Pool owner ...
_isExcludedFromFee[from] = true; // ⚠️ ... grant it fee exemption
counter++; // one-shot latch
}
_beforeTokenTransfer(from, to, amount);
}
from here is _msgSender() (see the call site below), not the token sender. The check is "is the caller currently
the owner of the Pool?". Because the attacker just made themselves the Pool owner via transReward, this branch fires
for them. (from.isContract() is satisfied because the attack runs from the attacker's contract.)
3. transferFrom — the fee-exempt branch returns before the allowance check#
// token.sol:470-492 (base ERC20)
function transferFrom(address sender, address recipient, uint256 amount)
public virtual override returns (bool)
{
_beforeTransfer(_msgSender(), recipient, amount); // may set _isExcludedFromFee[msg.sender]=true
if (_isExcludedFromFee[_msgSender()]) {
_transfer(sender, recipient, amount); // ⚠️ moves sender's tokens ...
return true; // ⚠️ ... and RETURNS — allowance never checked
}
_transfer(sender, recipient, amount);
_approve( // the real allowance enforcement — only on the non-exempt path
sender,
_msgSender(),
_allowances[sender][_msgSender()].sub(
amount, "ERC20: transfer amount exceeds allowance"
)
);
return true;
}
The allowance is enforced only via the _allowances[...].sub(amount, …) underflow-revert on the non-exempt path.
On the exempt path the function transfers and returns, so a caller with _isExcludedFromFee[caller] == true can move
any account's tokens for free. The fee-exemption flag — intended only to skip tax — doubles as an approval
bypass because the allowance check lives inside the same early-return branch structure.
4. initPool — sets the Pool once, but does not constrain transReward#
// token.sol:1397-1400
function initPool(address _Pool) public onlyOwner {
require(pool == address(0));
pool = _Pool;
}
initPool is correctly owner-gated and one-shot, but it only fixes which contract transReward forwards to. It does
nothing to restrict who can call transReward or what calldata is forwarded.
Root cause — why it was possible#
Three independent design errors compose into a full theft:
-
Unauthenticated arbitrary external call.
transReward(bytes)is a public proxy that forwards attacker-controlled calldata to a privileged external contract (the Pool). This is a textbook arbitrary-call sink: the attacker can invoke any Pool method, including its ownership transfer (0xbf699b4b). -
A trust decision keyed on a cheaply-mutable external value.
_beforeTransfergrants the powerful_isExcludedFromFeeflag to whoever isPool.owner()at call time. Because bug #1 lets the attacker becomePool.owner(), this "trusted owner" gate is no gate at all — the attacker satisfies it on demand. -
Allowance enforcement entangled with fee exemption. The fee-exempt branch in
transferFromreturns before the allowance is ever subtracted. A flag whose only intended effect is "skip tax" silently also means "skip approval." Skipping tax and skipping authorization should never share a code path.
Bug #1 produces the precondition (attacker == Pool owner) that bug #2 turns into a fee-exemption grant, which bug #3 turns into the ability to spend other people's balances. No allowance, no signature, no prior interaction with the victim is required.
The counter == 0 one-shot latch in _beforeTransfer was clearly intended as a "safety measure" (the PoC comment says
as much) to stop others from claiming the exemption after the legitimate owner had — but since the attacker can make
themselves the owner and is the first to trip the latch, the latch only protects the attacker's exclusive access.
Preconditions#
poolis set to a liveOwnable-style contract whoseowner()can be reassigned with selector0xbf699b4b(true at the fork block — the Pool at0x6863…64f).- A victim holds a CARROT balance the attacker can name as
sender. Here the holder0x00B433800970286CF08F34C96cf07f35412F1161held ≥ 310,344.74 CARROT. - A Carrot/BUSD-T PancakeSwap pair with non-trivial BUSD-T reserves to sell the stolen CARROT into.
- No capital, no flash loan, no approval is required — the entire attack is "free" apart from gas. The profit is whatever the stolen tokens fetch in the pool.
Attack walkthrough (with on-chain numbers from the trace)#
All figures are taken directly from output.txt. The PoC is
test/Carrot_exp.sol; ContractTest is the attacker contract
(0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 in the trace).
| # | Step (PoC line) | Call in trace | Concrete effect |
|---|---|---|---|
| 0 | Fork BSC @ 22,055,611 | vm.createSelectFork("bsc", 22055611) | Pool owner (slot 5) = 0x8958c8…65742; attacker CARROT/BUSDT balances = 0. |
| 1 | Hijack Pool ownership (:113) | transReward(0xbf699b4b ‖ attacker) → Pool.bf699b4b(attacker) | Pool slot 5: 0x8958c8…65742 → 0x7fa9385…e1496 (attacker is now Pool owner). |
| 2 | Steal victim's CARROT (:119) | transferFrom(victim, attacker, 310344736073087429864760) | Pool.owner() returns attacker → _beforeTransfer sets _isExcludedFromFee[attacker]=true, counter 0→1; exempt branch fires → 310,344.74 CARROT moved with no allowance (emit Transfer(victim → attacker, 3.103e23)). |
| 3 | Approve router (:140) | CARROT.approve(PS_ROUTER, type(uint256).max) | Router can pull the stolen CARROT. |
| 4 | Dump CARROT → BUSD-T (:144) | swapExactTokensForTokensSupportingFeeOnTransferTokens(310344.74 CARROT, 0, [CARROT, BUSDT], attacker, …) | Sell-tax splits the 310,344.74 CARROT (burn 1,551.72 / contract 10,862.07 / inviter 4,655.17 / +1,551.72), leaving 291,724.05 CARROT into the pair. Pair pays out amount0Out = 31,318.18 BUSD-T to the attacker (emit Swap(amount1In: 2.917e23 CARROT, amount0Out: 3.131e22 BUSDT)). |
| 5 | End | BUSDT.balanceOf(attacker) | 31,318.180838433700165284 BUSD-T — pure profit. |
Why the allowance was bypassed (step 2 detail)#
In the very same transferFrom call, _beforeTransfer runs first and flips _isExcludedFromFee[attacker] to
true; the subsequent if (_isExcludedFromFee[_msgSender()]) therefore reads true and takes the early-return branch.
The trace confirms this: the transferFrom emits exactly one Transfer(victim → attacker, 3.103e23) and no
Approval event for the victim's allowance — i.e. the _allowances[...].sub(...) line was never executed. The
storage diff shows counter (slot 4) 0 → 1, the latch closing behind the attacker.
Profit / loss accounting#
| Item | Amount |
|---|---|
| Attacker BUSD-T before | 0.000000000000000000 |
| Attacker BUSD-T after | 31,318.180838433700165284 |
| Net profit | +31,318.180838433700165284 BUSD-T (~$31,318) |
| Capital deployed by attacker | 0 (no flash loan, no approval, gas only) |
The loss is borne by (a) the victim holder, whose 310,344.74 CARROT was taken without consent, and ultimately
(b) the Carrot/BUSD-T LPs, whose BUSD-T side was sold into. The PoC asserts the BUSD-T gain via
[PASS] testExploit() → "Attacker BUSDT balance after exploit: 31318.180838433700165284".
Diagrams#
Sequence of the attack#
Composition of the two bugs#
Allowance-check state machine inside transferFrom#
Remediation#
- Lock down
transReward. It must not be a public arbitrary-call proxy. Restrict it withonlyOwner(or a trusted keeper role) and constrain the forwarded selector to a fixed, known method such asgetReward()(the only internal use, :1542). Never forward fully attacker-controlled calldata to a privileged external contract. - Do not key trust on a mutable external
owner()._beforeTransfershould not grant_isExcludedFromFeebased onownership(pool).owner(). Fee-exemption must be configured explicitly by the token owner viaexcludeFromFees(:1368), not auto-granted to whoever currently controls an external contract. - Decouple fee exemption from authorization. The allowance check in
transferFrommust run on every path. Move_allowances[sender][msg.sender].sub(amount, …)before the fee-branch split so that being fee-exempt only skips tax, never approval:function transferFrom(address sender, address recipient, uint256 amount) public override returns (bool) { _spendAllowance(sender, _msgSender(), amount); // always enforce allowance first _transfer(sender, recipient, amount); // tax handled inside _transfer based on exemption return true; } - Remove dead/dangerous one-shot latches. The
counterlatch gives the first caller exclusive privilege; delete the auto-exemption logic entirely rather than rely on a race that the attacker wins.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many unrelated PoCs that
fail to whole-compile under forge test):
_shared/run_poc.sh 2022-10-Carrot_exp --mt testExploit -vvvvv
- RPC: a BSC archive endpoint is required (fork block 22,055,611 from 2022-10-10). Most public BSC RPCs prune state
this old and fail with
header not found/missing trie node; use an archive provider. - Result:
[PASS] testExploit()with the attacker's BUSD-T balance going0 → 31,318.18.
Expected tail:
Ran 1 test for test/Carrot_exp.sol:ContractTest
[PASS] testExploit() (gas: 394879)
Logs:
[Start] Attacker BUSDT balance before exploit: 0.000000000000000000
[End] Attacker BUSDT balance after exploit: 31318.180838433700165284
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 15.67s
References: BlockSec — https://twitter.com/BlockSecTeam/status/1579908411235237888 · SunWeb3Sec — https://twitter.com/1nf0s3cpt/status/1580116116151889920 · Tencent Cloud (CN) — https://cloud.tencent.com/developer/article/2152960 · Original PoC: SunWeb3Sec; explanation: Kayaba-Attribution.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2022-10-Carrot_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
Carrot_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "Carrot Token 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.