Calculated from recorded token losses using historical USD prices at the incident time.
0xb3ac111d294ea9dedfd99349304a9606df0b572d05da8cedf47ba169d10791ed0x6666625ab26131b490e7015333f97306f05bf816BSC0x333896437125ff680f146f18c8a164be831c4c71BSCOn BSC block 23241441, transaction 0xb3ac111d294ea9dedfd99349304a9606df0b572d05da8cedf47ba169d10791ed used a public flash loan, Pancake swaps, and sDAO staking functions to manufacture an inflated staking payout and exit with 13661918634705551920897 more USDT at the attacker helper contract. The exploit path was permissionless: an unprivileged EOA drove a helper contract that borrowed USDT, bought sDAO, created LP, staked LP, drained the staking contract’s LP balance with withdrawTeam, restored only dust LP, claimed an outsized reward through getReward, sold the reward back into USDT, and repaid the loan.
The root cause is a combination bug in sDAO. First, withdrawTeam(address) is externally callable without access control and transfers the entire balance of any token from sDAO to TEAM. Second, reward distribution uses LPInstance.balanceOf(address(this)) as the denominator in getPerTokenReward(), so draining LP from the contract collapses the denominator while userLPStakeAmount still reflects the attacker’s previously staked position.
sDAO is both the project token and the LP staking contract. LP stakers accrue rewards from transfer taxes collected when sDAO moves to or from the configured LP pair. The verified contract increments totalStakeReward during taxed LP-facing transfers and later distributes newly accumulated reward through getPerTokenReward().
The key accounting split is:
userLPStakeAmount[account] tracks how much LP each staker deposited through stakeLP.getPerTokenReward() divides newly accrued reward by LPInstance.balanceOf(address(this)), which is the contract’s live ERC-20 LP balance rather than an internal total-staked variable.That design means external movement of LP out of the contract changes the reward denominator even if staking records are unchanged. Because Pancake LP tokens are standard ERC-20s, sDAO can transfer them away exactly like any other token balance.
This incident is an ACT attack caused by broken access control plus reward-accounting drift. The verified sDAO source exposes withdrawTeam(address _token) external with no modifier, and the function transfers the entire _token balance from sDAO to TEAM. The same contract computes reward-per-token with (totalStakeReward - lastTotalStakeReward) * 1e18 / LPInstance.balanceOf(address(this)), so the reward denominator is the mutable on-chain LP balance, not the tracked stake ledger.
The safety invariant is straightforward: a staker’s reward share should remain proportional to the staking system’s tracked total LP stake, and unrelated token withdrawals must not let any user amplify reward-per-token. The code-level breakpoint occurs when the attacker calls withdrawTeam(LP) after staking LP. That call drains the LP held by sDAO, but userLPStakeAmount stays high. When the attacker later calls getReward(), pendingToken() multiplies the large recorded stake by a reward-per-token value derived from an attacker-controlled dust denominator, producing an outsized payout from sDAO’s token inventory.
The verified victim code shows the two vulnerable behaviors directly:
function withdrawTeam(address _token) external {
IERC20(_token).transfer(TEAM, IERC20(_token).balanceOf(address(this)));
payable(TEAM).transfer(address(this).balance);
}
function getPerTokenReward() public view returns(uint) {
if ( LPInstance.balanceOf(address(this)) == 0) {
return 0;
}
uint newPerTokenReward =
(totalStakeReward - lastTotalStakeReward) * 1e18 / LPInstance.balanceOf(address(this));
return PerTokenRewardLast + newPerTokenReward;
}
Observed exploit flow from the seed trace:
flashLoan(0, 500000000000000000000, attacker, ...)
swapExactTokensForTokensSupportingFeeOnTransferTokens(250000000000000000000, ...)
addLiquidity(sDAO, USDT, ...)
stakeLP(241980243966583129219)
withdrawTeam(PancakePair)
PancakePair::transfer(TEAM, 1893980243966583129219)
PancakePair::transfer(sDAO, 13000000000000000)
getReward()
swapExactTokensForTokensSupportingFeeOnTransferTokens(3698480387757676166673476, ...)
USDT::transfer(flashLoanPool, 500000000000000000000)
The transaction evidence and balance diff make the accounting failure concrete:
1652000000000000000000 LP.241980243966583129219 LP through stakeLP.withdrawTeam(LP) transferred 1893980243966583129219 LP from sDAO to TEAM.13000000000000000 LP to sDAO before calling getReward().3698480387757676166673476 sDAO into USDT.The balance diff confirms the end state:
{
"token": "0x333896437125ff680f146f18c8a164be831c4c71",
"holder": "0x6666625ab26131b490e7015333f97306f05bf816",
"before": "1652000000000000000000",
"after": "13000000000000000",
"delta": "-1651987000000000000000"
}
{
"token": "0x55d398326f99059ff775485246999027b3197955",
"holder": "0x2b9eff2f254662e0f16b9adc249aaa509b1c58d4",
"before": "0",
"after": "13661918634705551920897",
"delta": "13661918634705551920897"
}
This is sufficient to validate the reported mechanism: the exploit does not depend on privileged access or hidden state, only on public contracts and the faulty interaction between unrestricted LP withdrawal and live-balance reward accounting.
The adversary cluster consists of EOA 0xa1b6d1f23931911ecd1920df49ee7a79cf7b8983 and helper contract 0x2b9eff2f254662e0f16b9adc249aaa509b1c58d4. The EOA submitted the exploit transaction and paid gas. The helper contract executed the flash-loan callback, interacted with PancakeRouter and sDAO, and retained the profit.
The execution stages were:
500 USDT-equivalent units (500000000000000000000) from 0x26d0c625e5f5d6de034495fbde1f6e9377185618, then swapped 250 USDT for sDAO through PancakeRouter.241980243966583129219 LP into sDAO.withdrawTeam(LP), which moved sDAO’s LP inventory to TEAM, then transferred back only 13000000000000000 LP to leave a dust denominator.getReward(), received the inflated sDAO payout, swapped the reward back to USDT, repaid the flash loan principal, and kept the residual USDT balance as profit.Every stage is visible in the trace and uses publicly callable code paths, which is why the incident is properly classified as ACT.
The economically realized loss captured by the attacker helper contract was 13661918634705551920897 USDT units (18 decimals) within the single exploit transaction. The immediate source of value was the inflated transfer of sDAO reward tokens out of the protocol’s token inventory, followed by conversion into USDT through the sDAO/USDT market.
The exploit also moved nearly all LP tokens held by sDAO to the TEAM address during the attack path, demonstrating that the staking contract’s LP custody and reward accounting assumptions were both broken at the same time.
0xb3ac111d294ea9dedfd99349304a9606df0b572d05da8cedf47ba169d10791ed0x6666625ab26131b490e7015333f97306f05bf8160x333896437125ff680f146f18c8a164be831c4c710x26d0c625e5f5d6de034495fbde1f6e93771856180xd9f90567162bcc6999265b1f1d5f77490c2dfeaa