Reproduced Exploit
HoldSafe price-manipulation referral drain — oracle pricing of stake value and rewards from a manipulable DEX spot route
Hold_Safe is a BSC staking/"donation" contract: a user calls Stake(usdtAmount, referrer) to "donate" a USDT-denominated amount (capped at maximumDeposit = 2000 USDT). The contract does not actually move USDT. Instead it calls getTokenAmountFromUSDT(usdtAmount), which queries the PancakeSwap router'…
Loss
~4,824.96 USD (6.9668 WBNB net profit, output.txt:1565)
Chain
BNB Chain
Category
Oracle Manipulation
Date
Jun 2025
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: 2025-06-HoldSafe_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/HoldSafe_exp.sol.
Vulnerability classes: vuln/oracle/price-manipulation · vuln/oracle/spot-price · vuln/defi/flash-loan-attack · vuln/logic/price-calculation Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. The vulnerable contract
Hold_Safeis verified on BscScan and was fetched into sources/Hold_Safe_2496b8/Hold_Safe.sol.
Key info#
| Loss | ~4,824.96 USD (6.9668 WBNB net profit, output.txt:1565) |
| Vulnerable contract | Hold_Safe (staking) — 0x2496b87189d5ae18d4d83b8a7039b0c8a07d98d4 |
| Attacker EOA | 0x0a4125690753b6cc82cadbca0f0899eb2025acb0 |
| Attack contract | 0x8b6dc9db598ecc3aa36d3eddebe9a9dd36e2bd7d (historical) |
| Attack tx | 0xa9b4cead820eaa750d72cd4ba4ba4d926c2db867746c637a1642ea4ab9721399 |
| Chain / block / date | BSC (BNB Smart Chain) / 51,758,376 / 2025-06 |
| Compiler | Solidity ^0.8.26 (verified on BscScan) |
| Bug class | Stake size and referral reward are both priced via PancakeSwap getAmountsOut on a spot HS/WBNB/USDT route, so a one-block flash-loan reserve move makes cheap, attacker-controlled HS count as a large USDT position and entitles referral rewards paid back out in HS. |
TL;DR#
Hold_Safe is a BSC staking/"donation" contract: a user calls Stake(usdtAmount, referrer) to "donate" a USDT-denominated amount (capped at maximumDeposit = 2000 USDT). The contract does not actually move USDT. Instead it calls getTokenAmountFromUSDT(usdtAmount), which queries the PancakeSwap router's getAmountsOut over the live HS→WBNB→USDT spot route, pulls that many HS tokens from the staker, and books the full usdtAmount as the stake. Referral rewards are likewise computed in USDT and paid later, in HS, via the same spot oracle.
That single design choice is the whole bug. The attacker flash-borrowed 224 WBNB from the DODO WBNB pool and swapped it all into the tiny HS/WBNB pair. With the HS reserves crushed, getAmountsOut reported that a 2,000-USDT position cost only ~0.0000757 HS per quote (output.txt:1789, Donated param1 = 75696682800000). The attacker then deployed 70 throwaway StakeHelper contracts in a referral chain; each one staked "2,000 USDT" for the price of a few hundred-billionths of an HS token, while the chain paid each referrer 200 / 60 / 40 / 20 / 20... USDT-denominated rewards (RewardPaid, output.txt:1831ff).
Because rewards are claimed in HS via the same still-inflated oracle, the contract handed over large HS token amounts from its own balance, which the attacker immediately sold back to WBNB through the still-distorted pair. Net of the 224 WBNB flash-loan repayment (224.0224 WBNB, FLASH_WBNB_REPAY), the attacker kept 6.966823475297369214 WBNB (output.txt:1565), roughly 4,824.96 USD at the time.
The exploit is fully permissionless: no privileged role, no governance, no waiting period — only a flash loan and the on-chain spot oracle the contract chose to trust.
Background — what Hold_Safe does#
Hold_Safe markets itself as a "decentralized collaborative distribution" / staking contract around the HS token (0xf83Aa05D3D7A6CA2DcE8a5329F7D1BE879b215F0). Its core primitive is Stake(usdtAmount, referrer):
usdtAmountis clamped to[5, 2000]USDT (minimumDeposit,maximumDeposit) but no USDT ever moves. USDT is just the unit of account.- The contract converts the requested USDT amount into an HS token amount on the fly via
getTokenAmountFromUSDT, andtransferFroms that many HS tokens from the staker into itself (Hold_Safe.sol:370-372). - It books a
reward = usdtAmount * 1500 / 10000(15%) and amaxWithdraw = reward * 20(maxClaims), unlocking linearly over 15-day windows throughClaim. - It also runs a 10-level referral tree:
calculateReferrerRewardswalks up the referrer chain, crediting each ancestor a USDT-denominated bonus scaled by a fixedlevels[]table (1000, 300, 200, 100, 100, 100, 50, 50, 50, 50perdenominator=10000), i.e. level 1 gets 10% of the stake, level 2 gets 3%, etc. (Hold_Safe.sol:542-603). These rewards sit inreferrerRewards[addr]and are withdrawn later as HS tokens viaRewards().
The flaw is that both "how many HS tokens equal 2,000 USDT" and "how many HS tokens equal a 200-USDT referral reward" are answered by the same PancakeSwap spot getAmountsOut call over a low-liquidity HS/WBNB pair. Whoever can move that pair's reserves for one block controls both numbers.
The vulnerable code#
Both pricing functions read the live PancakeSwap spot price with no TWAP, no bound, and no sanity check. From sources/Hold_Safe_2496b8/Hold_Safe.sol:
Spot-price conversion used everywhere#
function getTokenAmountFromUSDT(uint256 usdtAmount)
public
view
returns (uint256)
{
IPancakeRouter pancakeRouter = IPancakeRouter(router);
address bnbAddress = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; // WBNB
address usdtAddress = 0x55d398326f99059fF775485246999027B3197955; // USDT
uint256 value1 = quoteUSDTFactor; // 1e16 (a 0.01 USDT probe)
// Conversion path: USDT -> BNB -> TOKEN (the LIVE spot route)
address[] memory path = new address[](https://github.com/sanbir/evm-hack-registry/tree/main/2025-06-HoldSafe_exp/3);
path[0] = usdtAddress;
path[1] = bnbAddress;
path[2] = tokenAddress;
uint256[] memory amounts = pancakeRouter.getAmountsOut(value1, path);
uint256 AmountValue = (amounts[2] * usdtAmount) / quoteUSDTFactor;
return AmountValue;
}
getUSDTFromTokenAmount is the mirror image over the reverse route (tokenAddress → WBNB → USDT, Hold_Safe.sol:385-409). Both are pure spot reads.
Stake books the full USDT figure, pulls tokens at the inflated price#
function Stake(uint256 usdtAmount, address referrer) external whenNotPaused {
require(usdtAmount >= minimumDeposit, "Minimum value reached");
require(usdtAmount <= maximumDeposit, "Maximum value reached");
...
uint256 reward = ((usdtAmount) * rewardPercentage) / denominator; // 15% of 2000 = 300 USDT
address validReferrer = (referrer != address(0) &&
maxWithdraw[referrer] >= (thresholds[0]))
? referrer : defaultWallet;
referrers[msg.sender] = validReferrer;
calculateReferrerRewards(usdtAmount, validReferrer);
uint256 maximumWithdraw = (usdtAmount * rewardPercentage / denominator) * maxClaims;
maxWithdraw[msg.sender] += maximumWithdraw;
...
uint256 tokenAmount = getTokenAmountFromUSDT(usdtAmount); // <-- spot oracle
require((IERC20(tokenAddress).balanceOf(msg.sender) >= tokenAmount), "Do not try to fool me.");
IERC20(tokenAddress).transferFrom(msg.sender, address(this), tokenAmount);
...
}
Note the comment "Do not try to fool me." — the contract tries to guard against cheap-token staking by checking the staker's balance, but it does not verify that the value of the tokens pulled actually corresponds to a real, manipulation-resistant USDT figure. It trusts the spot route entirely.
Rewards are paid back out through the same oracle#
function Rewards() external nonReentrant {
require(maxWithdraw[msg.sender] > 0 || msg.sender == defaultWallet,
"You reached maximum withdraw");
uint256 reward = referrerRewards[msg.sender]; // denominated in USDT
require(reward > 0, "No rewards to claim");
if (msg.sender != defaultWallet) { // deplete maxWithdraw budget
uint256 maxWithdrawal = maxWithdraw[msg.sender];
if (maxWithdrawal >= reward) { maxWithdraw[msg.sender] -= reward; }
else { reward = maxWithdrawal; maxWithdraw[msg.sender] = 0; }
}
safeTransfer(msg.sender, getTokenAmountFromUSDT(reward)); // <-- spot oracle, again
referrerRewards[msg.sender] = 0;
...
}
The attacker's HS-funded referral rewards are converted to an HS transfer amount by querying the same live pair. While the pair is still inflated from the flash swap, the same USDT figure buys many more HS tokens — so the contract ships out its own HS balance at a bargain exchange rate.
Root cause — why it was possible#
- Spot AMM price used as the source of truth for stake sizing.
getTokenAmountFromUSDTcallsrouter.getAmountsOutover the live HS/WBNB/USDT route with no TWAP, no staleness check, and no min/max price bound. Anyone who can move the HS/WBNB reserves for a single block sets the "USDT value" of any HS transfer. - Reward payouts reuse the same spot oracle in the same direction.
Rewards()converts a USDT-denominated referral reward into HS tokens viagetTokenAmountFromUSDT— the identical, manipulable call. There is no temporal separation: the pair can be held distorted across both theStakeandRewardscalls inside one transaction. - Unbounded, attacker-built referral tree funded from cheap stakes.
calculateReferrerRewardswalks up to 10 referrer levels and credits USDT-denominated bonuses (level 1 = 10% of stake, etc.) purely as a function of theusdtAmountargument, with no per-actor identity, KYC, or self-referral guard beyond a single-step "Circular reference detected" check (referrer != previousReferrer). An attacker who mints N fresh staker contracts in a chain controls the entire upline and harvests every level. - No flash-loan defense and no reentrancy/timestamp coupling between price and payout. The whole loop — borrow WBNB, pump HS, stake cheaply, claim inflated-HS rewards, dump HS — completes atomically in one transaction.
nonReentrantonRewardsdoes not help because each helper is a distinct contract andStakeis not even guarded. - "Do not try to fool me" is a balance check, not a value check. It only requires the staker to hold the (now-tiny) computed HS amount; it never asserts that the tokens transferred in are worth anything near the booked USDT figure.
Preconditions#
- Permissionless: any externally owned account or contract can call
StakeandRewards; no role, allowlist, or governance action is needed. - Flash loan required: the attacker needs temporary capital to distort the HS/WBNB reserves. The PoC simulates the DODO WBNB pool flash by having the test impersonate the pool and transfer 224 WBNB (
FLASH_WBNB_AMOUNT) to the attack contract, then repaying 224.0224 WBNB (FLASH_WBNB_REPAY, a 1% fee) at the end. On-chain this was a DODO flash swap. - Low-liquidity HS/WBNB pair: a few hundred WBNB of buy pressure is enough to make
getAmountsOutreport that 2,000 USDT of HS costs only a dust amount of tokens. This was the actual on-chain state at block 51,758,376.
Attack walkthrough (with on-chain numbers from the trace)#
Setup. The test forks BSC at block 51_758_376 and impersonates the DODO WBNB pool (0x172fcD41E0913e95784454622d1c3724f546f849) to lend 224 WBNB to a fresh HoldSafeAttack contract.
| Step | Action | On-chain evidence |
|---|---|---|
| 1 | Receive 224 WBNB flash loan into HoldSafeAttack. | Transfer ... DODO WBNB pool → HoldSafeAttack ... 224000000000000000000 (output.txt:1620) |
| 2 | Swap all 224 WBNB → HS via PancakeSwap swapExactTokensForTokensSupportingFeeOnTransferTokens on WBNB→HS. | swapExactTokensForTokensSupportingFeeOnTransferTokens(224e18, 1, [WBNB, HS], ...) (output.txt:1626); pair emits Sync(reserve0: 256827139864858367155, reserve1: 5080248702341750) (output.txt:1656) |
| 3 | Attacker receives ~33,368.77 HS (after fees/burn). | Transfer ... HS/WBNB pair → HoldSafeAttack ... value 33368767355577024 (output.txt:1644) |
| 4 | Deploy StakeHelper #1. Fund it with FIRST_HELPER_FUNDING = 33,368,767,355,577,024 (≈ all HS). Call Stake(2000 USDT, address(0)) from it. | Donated(param0: StakeHelper#1, param1: 75696682800000 [≈0.0000757 HS], ..., param3: 300000000000000000000 [300 USDT reward], param4: defaultWallet) (output.txt:1789) — i.e. a "2,000 USDT" stake was satisfied by 0.0000757 HS. |
| 5 | Loop 69 more StakeHelpers, each funding itself with the recycled HS and calling Stake(2000 USDT, previousHelper). Each call builds the referral chain and pays upline: RewardPaid(referrer, 1, 200 USDT), (referrer, 2, 60 USDT), (…, 3, 40 USDT), (…, 4, 20 USDT), (…, 5, 20 USDT), … | e.g. RewardPaid(..., 1, 200000000000000000000) (output.txt:1831); full tiered payout at output.txt:2095-2099. 458 RewardPaid events total. |
| 6 | Claim accumulated referrerRewards for the first 57 helpers via Rewards(), each time receiving HS valued against the still-distorted pair, then immediately swapExactTokensForTokensSupportingFeeOnTransferTokens HS→WBNB. | Reward HS converted and dumped back to WBNB across many Rewards() + swap pairs in the trace. |
| 7 | Repay flash loan: 224.0224 WBNB back to DODO pool. | assertEq(WBNB.balanceOf(DODO_WBNB_POOL), dodoWbnbBefore + (FLASH_WBNB_REPAY - FLASH_WBNB_AMOUNT)) passes; pool balance 18325471287397384007032 (output.txt:tail). |
| 8 | Forward the surplus to the attacker EOA. | Attacker After exploit WBNB Balance: 6.966823475297369214 (output.txt:1565) |
Profit/loss accounting (per the PoC's own assertions):
- Flash borrowed: 224.000000000000000000 WBNB
- Flash repaid: 224.022400000000000000 WBNB (fee 0.0224 WBNB)
- Attacker before: 0 WBNB (output.txt:1564)
- Attacker after: 6.966823475297369214 WBNB (output.txt:1565)
- Net profit ≈ 6.97 WBNB ≈ 4,824.96 USD (≈ 693 USD/WBNB at the time)
Diagrams#
Attack sequence:
Why the spot oracle is exploitable:
Remediation#
- Never size stakes or payouts from a manipulable spot AMM price. Use a manipulation-resistant oracle (TWAP over a long window, or a Chainlink-style feed) and bound the acceptable price move per block. If no reliable oracle exists, denominate stakes directly in the token actually being transferred (HS), not in a USDT figure derived from the same pair.
- Decouple the value-in from the value-out. If stake accounting uses a spot price, payout (
Claim,Rewards) must use a different, lagged, or time-averaged price so a one-block distortion cannot be harvested in the same transaction. At minimum, snapshot the price at stake time and require a delay (well beyond one block) before any reward priced off a fresh quote. - Add explicit flash-loan / same-transaction defenses. Reject
Stake/Rewardscalls that occur in the same block as a large reserve move on the pricing pair, or enforce a per-staker cooldown and a global per-block stake cap. - Fix the referral economics. Cap the number of self-referrals and the total referral payout per stake (e.g. total referral bonus must be <
reward), require distinct, pre-registered, non-contract referrers, or remove the multi-level tree. The currentlevels[]table lets a chain of N fresh contracts capture 10% + 3% + 2% + 1% + 1% + … of every stake with no real referral having occurred. - Convert the "fool me" guard into a real value check. Instead of only
balanceOf(msg.sender) >= tokenAmount, verify onStakethat the HS tokens pulled in are worth at leastusdtAmountunder a non-manipulated oracle, and reconcile total HS held by the contract against total USDT-denominated liabilities on every payout.
How to reproduce#
The PoC runs fully offline using the shared anvil harness from the committed anvil_state.json — no RPC needed.
./_shared/run_poc.sh 2025-06-HoldSafe_exp -vvvvv
-
Chain / fork block: BSC (chain id 56), block
51,758,376(loaded from the committedanvil_state.json; the PoC'screateSelectFork("http://127.0.0.1:8546", 51_758_376)targets the local anvil instance spun up byrun_poc.sh). -
Expected result: the suite passes —
[PASS] testExploit()(output.txt:1562), with the balance log:Attacker Before exploit WBNB Balance: 0.000000000000000000 Attacker After exploit WBNB Balance: 6.966823475297369214and the two closing assertions both satisfied: DODO pool balance restored to
original + (224.0224 − 224)WBNB, and attacker profit> 5 WBNB.
The local run matches the on-chain attack tx: flash-borrow WBNB, pump HS, stake 70 cheap "2,000 USDT" positions through a self-built referral chain, claim referral rewards in HS, dump HS back to WBNB, repay the flash loan, and keep the surplus.
Reference: Telegram alert — defimon_alerts/1320.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2025-06-HoldSafe_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
HoldSafe_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "HoldSafe price-manipulation referral drain".
- 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.