Reproduced Exploit
VTF (Victor the Fortune) Exploit — Compounding Time-Based Mint via Self-Service `updateUserBalance()`
VTF is a deflationary game token with a "hold-to-earn" feature: any address that holds ≥ 100 VTF slowly mints 1% of its own balance per day to itself. That accrual is realized by the permissionless updateUserBalance(address _user) (VTF.sol:1115-1130), which mints the pending amount and
Loss
≈ 58,419 USDT (58,419.254304386568656998 USDT held by the attacker at the end of the run — see output.txt)
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-VTF_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/VTF_exp.sol.
Vulnerability classes: vuln/logic/reward-calculation · vuln/access-control/missing-auth
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). Run log: output.txt. Verified vulnerable source: sources/VTF_c6548c/VTF.sol.
Key info#
| Loss | ≈ 58,419 USDT (58,419.254304386568656998 USDT held by the attacker at the end of the run — see output.txt) |
| Vulnerable contract | VTF ("Victor the Fortune") — 0xc6548caF18e20F88cC437a52B6D388b0D54d830D |
| Victim pool | VTF / USDT P2E pair created by P2EFactory; quote token USDT 0x55d398326f99059fF775485246999027B3197955 |
| Swap venue | P2ERouter — 0x7529740ECa172707D8edBCcdD2Cba3d140ACBd85 |
| Flash-loan source | DODO DVM 0x26d0c625e5F5D6de034495fbDe1F6e9377185618 (100,000 USDT, zero-fee) |
| Attack txs | 0xeeaf7e96…64d9b086, 0xc2d2d716…104eef9b7 |
| Chain / fork block / date | BSC / 22,535,101 / Oct 27, 2022 |
| Compiler | VTF: Solidity v0.8.7 (optimizer, 1 run); Router: v0.7.6 |
| Bug class | Inflationary accounting bug — permissionless, compounding time-based mint; reward accrual decoupled from any cap or authorization |
TL;DR#
VTF is a deflationary game token with a "hold-to-earn" feature: any address that holds ≥ 100 VTF
slowly mints 1% of its own balance per day to itself. That accrual is realized by the
permissionless updateUserBalance(address _user)
(VTF.sol:1115-1130), which mints the pending amount and
resets the per-user accrual timer. Two facts turn this into a free money printer:
- The minted tokens are real, transferable VTF — there is no cap tied to the recipient, no
per-address allowance, and the global cap check (
maxTotal) is far above the live supply. - The accrual is per-address and resets on realization, so freshly minted VTF can be moved into a brand-new address whose timer also starts ticking — the gain compounds across an unbounded number of addresses in a single transaction.
The attacker pre-deploys 400 helper contracts (via CREATE2 salts 0..399), each of which calls
updateUserBalance(self) in its constructor to start its timer. The fork is then warped 2 days
forward so each address has a non-zero pending mint. Inside a 100,000-USDT DODO flash loan the attacker:
- Swaps the 100,000 USDT into VTF and hands the entire VTF balance to helper #0.
- Walks the 400-contract chain: each helper calls
updateUserBalance(self)(minting ≈ 2% of the balance it is holding — 2 days × 1%/day), then forwards its whole balance to the next helper. Compounding 2% across ~400 hops multiplies the VTF balance by roughly1.02^400 ≈ 2,800×. - Swaps the hugely inflated VTF back to USDT, repays the 100,000-USDT flash loan, and keeps the remainder — ≈ 58,419 USDT of pool liquidity.
The "mint" is paid for by the AMM pool: the attacker dumps thousands of times more VTF than they bought, draining the pool's USDT down to a residual and walking off with it.
Background — what VTF does#
VTF (source) is an ERC20 on BSC with a tax/anti-bot transfer pipeline
and a balanceOf that is virtual: it returns the stored balance plus an unrealized,
time-accruing bonus.
- Virtual balance —
balanceOf(account)is overridden tosuper.balanceOf(account) + getUserCanMint(account)(VTF.sol:1094-1097). So a holder's displayed balance grows with time even before any mint happens. - Hold-to-earn accrual —
getUserCanMint(account)(VTF.sol:1102-1112) returns(haveAmount / 100 / 86400) * (block.timestamp - userBalanceTime[account])whenever the account holds ≥10**20(= 100 VTF),maxCanMintandmanagerCanMintare both true, andtokenStartTimeis in the past. The factorhaveAmount/100/86400per second equals 1% of the held balance per day. - Realization —
updateUserBalance(address _user)(VTF.sol:1115-1130) actually_mintsgetUserCanMint(_user)to_userand setsuserBalanceTime[_user] = block.timestamp, restarting the clock. It ispublicwith no access control and the_userargument is fully attacker-chosen. - Auto-realization on transfer — the custom
_transfer(VTF.sol:1247-1336) callsupdateUserBalance(...)for the sender and/or receiver on essentially every transfer (VTF.sol:1310-1320), so an address's accrual is materialized whenever VTF lands in it.
The on-chain accounting parameters (from the constructor, VTF.sol:1067-1088):
| Parameter | Value | Meaning |
|---|---|---|
total (initial supply minted to tokenOwner) | 21 * 10**23 = 2,100,000 VTF | starting circulating supply |
maxTotal | 5 * 10**26 = 500,000,000 VTF | global mint ceiling checked in updateUserBalance |
| daily accrual rate | haveAmount / 100 per day | 1% of holder balance per day |
| min balance to accrue | 10**20 = 100 VTF | accrual disabled below this |
maxCanMint / managerCanMint | both true | accrual switches, on by default |
Because maxTotal (500M) dwarfs the ~2.1M live supply, the only thing throttling the printer is time
and the per-address timer — and the attacker controls both (warp + a fresh address per hop).
The vulnerable code#
1. The unbounded, time-based accrual#
// VTF.sol:1102-1112
function getUserCanMint(address account) public view returns (uint256){
uint256 userStartTime = userBalanceTime[account];
uint256 haveAmount = super.balanceOf(account);
if(userStartTime> 0 && haveAmount >= 10**20 && tokenStartTime < block.timestamp && maxCanMint && managerCanMint){
uint256 secondAmount = haveAmount.div(100).div(86400); // 1% of balance / 86400s
uint256 afterSecond = block.timestamp.sub(userStartTime);
return secondAmount.mul(afterSecond); // = balance * 1%/day * days
}
return 0;
}
2. The permissionless, self-service realization#
// VTF.sol:1115-1130
function updateUserBalance(address _user) public { // ⚠️ public, no auth, arbitrary _user
uint256 totalAmountOver = super.totalSupply();
if(maxTotal <= totalAmountOver){ // 500M cap — never hit here
maxCanMint = false;
}
if(userBalanceTime[_user] > 0){
uint256 canMint = getUserCanMint(_user);
if(canMint > 0){
userBalanceTime[_user] = block.timestamp; // reset clock
_mint(_user, canMint); // ⚠️ mint accrued bonus to _user
}
}else{
userBalanceTime[_user] = block.timestamp; // first touch just starts the clock
}
}
The minted VTF is ordinary, transferable supply (_mint increases _totalSupply and the user's
_balances, VTF.sol:577-585). Nothing ties the accrual to a
locked position, a stake, or the recipient's identity. So the attacker can:
- call
updateUserBalance(self)in a fresh contract's constructor to start the clock (test/VTF_exp.sol:34-36); - after time passes, call
updateUserBalance(self)again to realize ≈ 2% (2 days) of whatever VTF the contract currently holds (test/VTF_exp.sol:41); - then forward the whole (now larger) balance to the next fresh contract and repeat.
Each hop turns a balance B into B × (1 + 2%). The accrual is on the balance being held at claim
time, not on the original deposit, which is exactly what makes it compound.
Root cause — why it was possible#
The protocol meant "hold VTF, earn 1%/day" as a gentle inflationary reward for genuine, long-lived holders. Four design decisions compose into a critical, instant, repeatable theft:
-
Reward accrual is realized by a permissionless function with an attacker-chosen target.
updateUserBalance(address _user)mints to any_userand is callable by anyone. There is noonlyOwner/keeper gate and no check thatmsg.sender == _user. The attacker drives the entire reward machine directly. -
The reward compounds across addresses because it accrues on the current balance and resets on realization. Moving freshly minted VTF into a new address (whose
userBalanceTimewas pre-seeded) lets the next claim accrue on the larger amount. With one realization per address there is no upper bound on total minted per transaction other than the number of addresses the attacker is willing to deploy — here 400 (CREATE2salts 0..399, test/VTF_exp.sol:103-112). -
The only global throttle (
maxTotal = 500,000,000 VTF) is effectively unreachable. With ~2.1M live supply, the attacker can mint orders of magnitude more before themaxCanMint=falselatch ever trips — and even then it only stops future mints, not the value already extracted. -
The minted token is liquid against real value. A VTF/USDT pool exists, so inflated VTF is immediately convertible to USDT. The reward "1%/day" was never funded by anything except the AMM's liquidity — every minted VTF dilutes the pool, and dumping ~2,800× the purchased VTF drains the pool's USDT.
The deeper invariant violated: a balanceOf that grows with wall-clock time, combined with a mint
that can be triggered for an arbitrary address, means total realizable supply per transaction is bounded
only by (#addresses) × (elapsed time) — both attacker-controlled. The accrual rate should have been
tied to a locked, non-transferable position and realization should have been restricted to the position
owner.
Preconditions#
tokenStartTime < block.timestampandmaxCanMint && managerCanMint(both true at the fork block).- Each helper address must hold ≥ 100 VTF and have a non-zero
userBalanceTimebefore its realizing call. The PoC satisfies the timer by callingupdateUserBalance(self)in every helper's constructor (test/VTF_exp.sol:34-36) and satisfies the balance by forwarding VTF down the chain. - Elapsed time so that
getUserCanMint > 0. The PoC warps 2 days forward (cheat.warp(block.timestamp + 2*24*60*60), test/VTF_exp.sol:63). On-chain the attacker simply let real time pass between seeding the timers and the drain (hence the two separate attack transactions on Oct 27, 2022). - Working capital to seed the chain: a 100,000-USDT zero-fee flash loan from DODO (test/VTF_exp.sol:64), fully repaid in the same tx (test/VTF_exp.sol:80). No own capital is at risk.
Attack walkthrough (with concrete numbers)#
The supplied
output.txtrecords only the final balance line, not a full-vvvvvreserve trace, so the intermediate VTF amounts below are reconstructed from the contract math (1%/day × 2 days = 2% per hop, compounded over the 400-contract chain). The terminal USDT figure is ground truth from the run.
path = [USDT, VTF] for the buy and [VTF, USDT] for the sell, both via P2ERouter's
fee-on-transfer swap. The per-hop accrual factor is 1 + (2 days × 1%/day) = 1.02.
| # | Step | Code | Effect |
|---|---|---|---|
| 0 | Deploy 400 helpers via CREATE2 salts 0..399; each constructor calls updateUserBalance(self) | test/VTF_exp.sol:103-112, :34-36 | 400 addresses get userBalanceTime = T0 (clock started, balance still 0) |
| 1 | Warp +2 days | test/VTF_exp.sol:63 | every seeded address now has 2 days of pending accrual once it holds ≥ 100 VTF |
| 2 | Flash-loan 100,000 USDT from DODO; callback DPPFlashLoanCall runs the exploit | test/VTF_exp.sol:64, :69 | attacker holds 100,000 USDT |
| 3 | Buy VTF — swap 100,000 USDT → VTF (fee-on-transfer), recipient = attacker | test/VTF_exp.sol:83-91 | attacker holds B0 VTF (post-tax output of the buy) |
| 4 | Seed helper #0 — transfer entire VTF balance to contractList[0] | test/VTF_exp.sol:71 | helper #0 now holds ~B0 VTF; the transfer's updateUserBalance fires for it |
| 5 | Chain claim×forward — for i = 0..398: contractList[i].claim(contractList[i+1]); each claim calls updateUserBalance(self) (mints ≈ 2% of held VTF) then transfer(next, balanceOf(self)) | test/VTF_exp.sol:38-43, :72-75 | balance grows by ×1.02 each hop; after ~400 hops ≈ B0 × 1.02^400 ≈ 2,800 × B0 VTF |
| 6 | Final claim — contractList[399].claim(address(this)) returns the fully inflated VTF to the attacker | test/VTF_exp.sol:76-78 | attacker holds the giant VTF balance |
| 7 | Sell VTF — swap entire VTF balance → USDT (fee-on-transfer) | test/VTF_exp.sol:93-100 | pool's USDT flows to attacker |
| 8 | Repay flash loan: transfer 100,000 USDT back to DODO | test/VTF_exp.sol:80 | loan closed |
| 9 | Profit | test/VTF_exp.sol:66 | ≈ 58,419 USDT remains with the attacker |
claim itself (test/VTF_exp.sol:38-43) is the engine of the loop:
function claim(address receiver) external {
VTF.updateUserBalance(address(this)); // mint ~2% of this contract's VTF
VTF.transfer(receiver, VTF.balanceOf(address(this)));// forward the whole (grown) balance
}
Note that VTF.balanceOf(self) here is itself the virtual balance (stored + still-pending accrual),
and the receiving transfer also triggers updateUserBalance on the receiver inside VTF's _transfer,
so the accrual machinery is exercised on both ends of every hop.
Profit / loss accounting (USDT)#
| Item | Amount |
|---|---|
| Flash-loaned in (DODO) | 100,000.000000 |
| Flash-loan fee | 0 (DODO DVM, zero-fee) |
| Repaid to DODO | 100,000.000000 |
| USDT held by attacker after repay | 58,419.254304386568656998 |
| Net profit | ≈ +58,419.25 USDT |
The 58,419 USDT is liquidity pulled out of the VTF/USDT pool: the attacker bought a modest amount of VTF with the loaned USDT, multiplied that VTF ~2,800× through the mint chain, and sold the inflated VTF back into the same pool — receiving far more USDT than was spent on the buy. The pool's LPs absorb the loss.
Diagrams#
Sequence of the attack#
Compounding mint across the helper chain#
The flaw inside updateUserBalance / getUserCanMint#
Why each design choice mattered#
- 400 helper contracts: each provides one fresh
userBalanceTimeslot, enabling one compounding hop (×1.02). 400 hops ≈1.02^400 ≈ 2,800×, enough to turn the VTF bought with 100k USDT into a balance whose sale drains ~58k USDT of pool liquidity. More contracts → more multiplication (gas-bounded). - 2-day warp: the accrual is
1%/day, so 2 days = 2% pending per address. Even this tiny per-hop rate compounds to a ~2,800× multiplier over the chain. A longer wait or more addresses would extract even more. - Flash loan, not own capital: the 100,000 USDT is only seed liquidity to acquire the initial VTF
B0; it is repaid in full within the same transaction (DODO DVM charges no fee), so the attack is capital-free. - Discarding nothing / forwarding everything: unlike a typical "buy and dump", the value here is created by minting, not by price impact alone — the helper chain literally manufactures VTF out of the contract's own accrual logic before selling it.
Remediation#
- Tie reward realization to the position owner.
updateUserBalancemust reject calls where the reward target is not the caller (or a privileged keeper), and must mint only to a locked, non-transferable position — not to an arbitrary attacker-chosen address. - Do not compound on the live, transferable balance. Accrue rewards against a snapshot of a staked amount that cannot be moved between addresses to re-trigger fresh accrual. Resetting the timer on every realization while accruing on the current balance is what enables the chain compounding.
- Fund rewards from a real source, not from dilution against an AMM. A "1%/day" mint that is immediately sellable into a liquidity pool is an un-funded inflation that LPs pay for. Either back it with a treasury/emissions budget or make minted tokens vest/lock so they cannot be instantly dumped.
- Make
balanceOfreflect only realized balance. OverridingbalanceOfto add unrealized, time-growinggetUserCanMintlets the displayed balance (and any downstream logic that reads it, including the contract's owntransferofbalanceOf(self)) move value that was never minted. KeepbalanceOfstrictly equal to the stored balance. - Cap per-transaction and per-address mint. Even with the above, bound the mint realizable in a single block/transaction and require a minimum non-resettable holding period, so a one-shot multi-address chain cannot extract a large multiplier.
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-VTF_exp --mt testExploit -vvvvv
- RPC: a BSC archive endpoint is required (fork block 22,535,101 is old).
foundry.tomluseshttps://bsc-mainnet.public.blastapi.io, which serves historical state at that block; most pruned public BSC RPCs fail withheader not found/missing trie node. - The exploit deploys 400
CREATE2helpers, so the test consumes a large amount of gas (gas: 142272238in the recorded run).
Expected tail (output.txt):
Ran 1 test for test/VTF_exp.sol:ContractTest
[PASS] testExploit() (gas: 142272238)
Logs:
[End] Attacker USDT balance after exploit: 58419.254304386568656998
References: BlockSec (https://twitter.com/BlockSecTeam/status/1585575129936977920), PeckShield (https://twitter.com/peckshield/status/1585572694241988609), Beosin (https://twitter.com/BeosinAlert/status/1585587030981218305). VTF "Victor the Fortune", BSC, Oct 27, 2022, ≈ $58K.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2022-10-VTF_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
VTF_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "VTF (Victor the Fortune) 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.