Reproduced Exploit
TokenHolder / BorrowerOperationsV6 Exploit — Privileged-Role Confused-Deputy Drain via `sell()`'s Arbitrary Call
TokenHolder is a small lending vault that holds WBNB and lends it out via privilegedLoan() — a function correctly guarded by onlyBorrowerRouter, so only an address holding BORROWER_ROUTER_ROLE may pull funds.
Loss
20 WBNB drained from the protocol's TokenHolder vault per sell() call (attacker netted 19.2 WBNB, 0.8 WBNB si…
Chain
BNB Chain
Category
Centralization / Privilege
Date
Oct 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-10-TokenHolder_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/TokenHolder_exp.sol.
Vulnerability classes: vuln/dependency/unsafe-external-call · vuln/access-control/missing-auth
One-liner:
BorrowerOperationsV6.sell()makes an attacker-controlledinchRouter.call(sellingCode)while holding theBORROWER_ROUTER_ROLEon the protocol'sTokenHoldervault, letting anyone re-enterTokenHolder.privilegedLoan()to pull the vault's WBNB out for free and walk away with it as "profit."
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo does not whole-compile, so this PoC was extracted). Full verbose trace: output.txt. Verified vulnerable sources: BorrowerOperationsV6, TokenHolder.
Key info#
| Loss | 20 WBNB drained from the protocol's TokenHolder vault per sell() call (attacker netted 19.2 WBNB, 0.8 WBNB siphoned to the protocol's own fee address). PoC demonstrates one call. |
| Vulnerable contract | BorrowerOperationsV6 (impl) — 0x8c7f34436C0037742AeCf047e06fD4B27Ad01117, behind proxy 0x616B36265759517AF14300Ba1dD20762241a3828 |
| Victim vault (funds source) | TokenHolder — proxy 0x2EeD3DC9c5134C056825b12388Ee9Be04E522173 (fork-time impl 0x3403f2Ba8aA448c208C2d1A41F2089c5a6f924e4) |
| Attacker EOA | 0x3fee6d8aaea76d06cf1ebeaf6b186af215f14088 |
| Attacker contract | 0xe82Fc275B0e3573115eaDCa465f85c4F96A6c631 |
| Attack tx | 0xc291d70f281dbb6976820fbc4dbb3cfcf56be7bf360f2e823f339af4161f64c6 |
| Chain / block / date | BSC / fork at 63,856,734 (one before 63,856,735) / Oct 2025 |
| Compiler | BorrowerOperationsV6: Solidity v0.8.29, optimizer off (200 runs); TokenHolder: v0.8.29 |
| Bug class | Confused deputy / privileged-role abuse via unrestricted arbitrary external call (no caller auth, no router/token whitelist) |
TL;DR#
TokenHolder is a small lending vault that holds WBNB and lends it out via
privilegedLoan() — a function correctly guarded by onlyBorrowerRouter, so only an address
holding BORROWER_ROUTER_ROLE may pull funds.
BorrowerOperationsV6 is that privileged router (it holds the role). Its sell() function is
the problem (src_BorrowerOperationsV6.sol:144-184):
- Every security check is commented out — no DEX whitelist, no token whitelist, no router whitelist, no access control on the caller.
- It reads the loan to close from a caller-supplied
tokenHolderaddress (tokenHolder.loans(loanId)) — so the attacker fabricates the loan data. - It then performs
inchRouter.call(sellingCode)where bothinchRouterandsellingCodeare attacker-controlled (:160). Because the call originates fromBorrowerOperationsV6(which holds theBORROWER_ROUTER_ROLE), the attacker simply pointsinchRouterat the realTokenHolderand setssellingCode = privilegedLoan(WBNB, 20 ether). TheonlyBorrowerRouterguard passes, and 20 WBNB is transferred out of the vault intoBorrowerOperationsV6. sell()then callstokenHolder.repayLoan(loanId, false)— buttokenHolderis the attacker's own contract, so repayment is a no-op. Nothing is paid back.sell()finally treats the borrowed 20 WBNB as the trader's "profit" and transfers it to the attacker (minus an 8% fee, which is split between the protocol fee address and the attacker-suppliedintegratorFeeAddress— also the attacker).
Net: the attacker calls one permissionless function, supplies its own address everywhere a trusted party is expected, and the vulnerable router obediently uses its privileged role to hand the vault's WBNB to the attacker. Profit = 19.2 WBNB per call; the vault loses 20 WBNB.
Background — what the protocol does#
This is a leveraged-trading / margin protocol split across two contracts:
TokenHolder(source) — a vault that custodies the lendable asset (WBNB here). It exposes:privilegedLoan(token, amount)— an uncollateralized transfer ofamountto the caller, intended as an intra-transaction flash advance to the router. Guarded byonlyBorrowerRouter.loanConfirmation(...)/repayLoan(...)— open / settle a recorded loan, alsoonlyBorrowerRouter.- Admin-only
deposit/withdraw/ collateral configuration.
BorrowerOperationsV6(source) — the "router" that orchestrates opening (buy) and closing (sell) leveraged positions. It is grantedBORROWER_ROUTER_ROLEonTokenHolder, and is supposed to:- borrow the position size from
TokenHolderviaprivilegedLoan, - swap it on a whitelisted DEX through a 1inch-style router (
inchRouter.call(swapCalldata)), - repay
TokenHolder, and - forward the trader's realized profit.
- borrow the position size from
The design hinges on inchRouter, whitelistedDex, tokenHolder, and the collateral token all
being trusted/whitelisted. In the deployed code those checks were disabled (commented out),
turning the privileged router into a generic confused deputy.
On-chain state at the fork block (from the trace):
| Fact | Value |
|---|---|
WBNB held by the TokenHolder vault (0x2EeD3…) | 20.0286 WBNB |
BORROWER_ROUTER_ROLE holder | BorrowerOperationsV6 proxy 0x616B… |
profitFee | 800 bps = 8% |
openingFee | 50 bps = 0.5% |
FEE_ADDRESS | 0x8432CD30C4d72Ee793399E274C482223DCA2bF9e |
The vulnerable code#
1. sell() — no auth, all guards commented out, attacker-controlled arbitrary call#
function sell(uint256 loanId, bytes calldata sellingCode, TokenHolder tokenHolder, address inchRouter, address integratorFeeAddress, address whitelistedDex) external payable nonReentrant {
// Security checks
// require(whitelistedDexes[whitelistedDex], "DEX not whitelisted");
// require(whitelistedDexes[inchRouter], "Router not whitelisted"); // ← DISABLED
// this is memory, won't change even loan is repaid
(,uint256 borrowAmount, Collateral memory collateral, uint256 collateralAmount,,address borrower, uint256 userPaid)
= tokenHolder.loans(loanId); // ← attacker-supplied source
// // require(whitelistedTokens[collateral.collateralAddress], ...); // ← DISABLED
tokenHolder.privilegedLoan(IERC20(collateral.collateralAddress), collateralAmount); // ← attacker stub, no-op (amount=0)
IERC20(collateral.collateralAddress).approve(whitelistedDex, collateralAmount);
(bool success,) = inchRouter.call(sellingCode); // ⚠️ ARBITRARY CALL, attacker-controlled
require(success, "Sell token failed");
uint256 closingPositionSize = weth.balanceOf(address(this));
(bool success2,) = payable(address(weth)).call{value: msg.value}("");
require(success2, "WETH failed");
weth.approve(address(tokenHolder), MAX_INT);
tokenHolder.repayLoan(loanId, false); // ← attacker stub, no-op (no repayment)
// transfer profits
uint256 balance = weth.balanceOf(address(this));
uint256 profit = balance > userPaid ? balance - userPaid : 0; // userPaid=0 ⇒ profit = full 20 WBNB
uint256 totalFee = profit*profitFee/10000 + (borrowAmount+userPaid)*openingFee/10000; // 8% of 20 = 1.6
if (integratorFeeAddress == address(0)) {
require(weth.transfer(FEE_ADDRESS, totalFee), "Fee transfer failed");
} else {
require(weth.transfer(FEE_ADDRESS, totalFee / 2), "..."); // 0.8 WBNB → FEE_ADDRESS
require(weth.transfer(integratorFeeAddress, totalFee - totalFee / 2), "..."); // 0.8 WBNB → attacker
}
weth.transfer(borrower, weth.balanceOf(address(this))); // 18.4 WBNB → attacker (borrower)
emit Sell(borrower, ..., closingPositionSize, profit);
}
src_BorrowerOperationsV6.sol:144-184
The two attacker-controlled "callbacks" into tokenHolder (loans, privilegedLoan, repayLoan)
all resolve to the attacker's own contract because tokenHolder is a parameter, not a fixed
trusted address. The real fund movement happens through the arbitrary call
inchRouter.call(sellingCode).
2. TokenHolder.privilegedLoan() — correctly guarded, but the guard is satisfied by the deputy#
function privilegedLoan(IERC20 flashLoanToken, uint256 amount) public onlyBorrowerRouter { // ← role check
require(amount > 0, "Amount must be greater than 0");
require(flashLoanToken.balanceOf(address(this)) >= amount, "Insufficient funds in the contract");
require(flashLoanToken.transfer(msg.sender, amount), "Token transfer failed"); // ← sends to msg.sender (the router)
emit PrivilegedLoan(msg.sender, amount);
}
onlyBorrowerRouter checks hasRole(BORROWER_ROUTER_ROLE, msg.sender). BorrowerOperationsV6 is
the router and holds that role, so when sell() does inchRouter.call(privilegedLoan(WBNB, 20e18))
the msg.sender seen by TokenHolder is BorrowerOperationsV6 — the check passes, and 20 WBNB is
transferred to BorrowerOperationsV6. The vault's own access control is intact; it is defeated by
the router voluntarily making an attacker-chosen call while wearing its privileged hat.
Root cause — why it was possible#
The vulnerability is a confused deputy: a privileged actor (BorrowerOperationsV6, holder of
BORROWER_ROUTER_ROLE) performs an arbitrary, attacker-specified external call with no
restriction on the target or calldata. Four compounding decisions turn this into a free drain:
- Unrestricted arbitrary call.
inchRouter.call(sellingCode)lets the caller makeBorrowerOperationsV6invoke any function on any contract, withmsg.sender == BorrowerOperationsV6. The attacker aims it at the very vault the router is privileged over and callsprivilegedLoan. The router's role is borrowed wholesale. - All whitelists disabled. The DEX whitelist, token whitelist, and router whitelist checks are
commented out (:146-156).
Had
require(whitelistedDexes[inchRouter])been live, the attacker could not point the call atTokenHolder. - Trusted parameters are caller-supplied.
tokenHolder,inchRouter,integratorFeeAddress,whitelistedDex, and the loan data itself all come from the caller. The contract trusts theloans(loanId)view of an address the attacker chose, souserPaid = 0⇒ the entire borrowed amount is booked as profit. - Repayment is delegated to an untrusted address.
tokenHolder.repayLoan(loanId, false)is the only thing that would pull funds back into the vault — buttokenHolderis the attacker's stub, so it does nothing. The borrowed WBNB is never returned; insteadsell()ships it to the attacker as "profit."
In short: a function meant to settle a real leveraged position was left fully open and was handed a privileged primitive (arbitrary call from a role-holder) that lets anyone instruct the router to loot the vault it guards.
Preconditions#
- The
TokenHoldervault holds WBNB (here 20.03 WBNB). The drain per call is capped at the vault balance (the PoC borrows exactly 20 WBNB). BorrowerOperationsV6(the contract called) holdsBORROWER_ROUTER_ROLEon the targetTokenHolder. (It does — it is the legitimate router.)sell()isexternalwith noonlyAdmin/whitelist gating in the deployed bytecode → callable by anyone.- No working capital needed: the exploit borrows from the vault and never repays, so it is a pure, capital-free drain (not even a flash loan is required). The PoC funds nothing; the attacker starts and the WBNB simply flows out of the vault.
Attack walkthrough (with on-chain numbers from the trace)#
The PoC's contract plays the role of the attacker contract and supplies itself as tokenHolder,
integratorFeeAddress, and whitelistedDex. It crafts sellingCode = privilegedLoan(WBNB, 20 ether) and points inchRouter at the real TokenHolder vault
(0x2EeD3…). Source: test/TokenHolder_exp.sol:31-39.
| # | Step (trace) | Actor → target | WBNB moved | Vault balance after |
|---|---|---|---|---|
| 0 | Initial vault balance | — | — | 20.0286 |
| 1 | borrowerOper.sell(0, code, self, 0x2EeD3…, self, self) | attacker → BorrowerOperationsV6 (0x616B…) | — | 20.0286 |
| 2 | tokenHolder.loans(0) reads attacker's fake loan (collateral = WBNB, userPaid=0, borrower=self) | router → attacker stub | — | 20.0286 |
| 3 | tokenHolder.privilegedLoan(WBNB, 0) (no-op, amount 0) | router → attacker stub | 0 | 20.0286 |
| 4 | inchRouter.call(sellingCode) ⇒ TokenHolder.privilegedLoan(WBNB, 20e18) passes onlyBorrowerRouter | router → vault | 20.0 → router | 0.0286 |
| 5 | repayLoan(0, false) (no-op — attacker stub) | router → attacker stub | 0 | 0.0286 |
| 6 | profit = 20 − userPaid(0) = 20; totalFee = 20·8% = 1.6 | router internal | — | 0.0286 |
| 7 | weth.transfer(FEE_ADDRESS, 0.8) | router → 0x8432… | 0.8 | 0.0286 |
| 8 | weth.transfer(integratorFeeAddress=self, 0.8) | router → attacker | 0.8 | 0.0286 |
| 9 | weth.transfer(borrower=self, 18.4) | router → attacker | 18.4 | 0.0286 |
End state: attacker WBNB balance 0 → 19.2 WBNB; vault drained of 20 WBNB.
Exact storage deltas from output.txt:
- Vault WBNB:
0x115f3fc0be699ddad(20.0286) →0x65b602d2c9ddad(0.0286) — −20.0 WBNB. - Router (
0x616B…) WBNB: receives0x1158e460913d00000(20.0), pays out to FEE_ADDRESS, integrator, borrower, ending at 0. log_named_decimal_uint("Attacker After exploit WBNB Balance", 19.2)(:1621).
Profit / loss accounting (WBNB)#
| Direction | Recipient | Amount |
|---|---|---|
Out of vault (TokenHolder) | BorrowerOperationsV6 | −20.0 |
| Protocol profit fee (50% of 8%) | FEE_ADDRESS 0x8432… | 0.8 |
| Integrator fee (50% of 8%, = attacker) | attacker | 0.8 |
| Residual "profit" | attacker (borrower) | 18.4 |
| Attacker net | +19.2 | |
| Vault net | −20.0 |
The 0.8 WBNB that landed in the protocol's FEE_ADDRESS is also a loss from the vault's
perspective — the vault lost a full 20 WBNB; the attacker simply chose to let the fee split pay the
protocol's own fee address. The header figure of "20 WBNB" is the vault loss; the attacker's
realized take is 19.2 WBNB.
Diagrams#
Sequence of the attack#
Vault balance / privilege-flow evolution#
Why the call is a confused deputy#
Why each parameter#
sellingCode = privilegedLoan(WBNB, 20 ether)— the heart of the exploit. Executed by the privileged router, it makesTokenHoldersend WBNB to the router for free.20 etheris sized to the vault's balance (it held 20.0286 WBNB).inchRouter = 0x2EeD3…(the real TokenHolder) — redirects the "DEX swap" call straight at the vault. With the router whitelist disabled there is nothing to reject it.tokenHolder = address(this)(attacker) — supplies fakeloans(loanId)data souserPaid = 0(entire borrow counts as profit) andborrower = attacker(payout recipient), and makesprivilegedLoan/repayLoancallbacks into no-ops so no real loan accounting or repayment occurs.integratorFeeAddress = address(this)(attacker) — recovers half of the 8% fee (0.8 WBNB) that would otherwise leave the attacker's pocket.loanId = 0— any id works; the loan data is read from the attacker's stub regardless.
Remediation#
- Never make arbitrary external calls from a role-holding contract.
inchRouter.call(sellingCode)with caller-controlled target and calldata is the root cause. Restrict the router to a fixed, audited set of swap targets and validate the selector/arguments — or use a typed interface call, not raw.callwith arbitrary bytes. - Re-enable (and enforce) the whitelists. The deployed code has every guard commented out.
require(whitelistedDexes[inchRouter]),require(whitelistedDexes[whitelistedDex]), andrequire(whitelistedTokens[collateral.collateralAddress])must be live. A whitelistedinchRouterthat cannot be theTokenHoldervault eliminates the drain. - Do not source trusted data from caller-supplied addresses.
tokenHolder, the loan record,userPaid, andborrowermust come from protocol-owned storage keyed bymsg.sender, not from an arbitrarytokenHolderparameter. Bind each loan to its realTokenHolderand verify the caller is the loan's borrower. - Account for and enforce repayment.
repayLoanmust actually return the borrowed principal to the vault (and revert if it does not). Treating an unreturnedprivilegedLoanas "profit" is the immediate cash-out path; the post-conditions of asell()should assert the vault balance is restored. - Scope
privilegedLoantighter than a long-lived role. A standalone, externally-reachable uncollateralized-transfer primitive granted to a contract that also makes arbitrary calls is a loaded gun. Prefer an in-function, single-use advance with a mandatory same-call settlement check, or an ERC-3156-style flash loan that enforces repayment in the callback.
How to reproduce#
_shared/run_poc.sh 2025-10-TokenHolder_exp -vvvvv
- RPC: a BSC archive endpoint is required (
foundry.tomluseshttps://bsc-mainnet.public.blastapi.io, which serves historical state at fork block 63,856,734). - Result:
[PASS] testExploit()with the attacker's WBNB balance going0 → 19.2.
Expected tail:
Ran 1 test for test/TokenHolder_exp.sol:ExploitTemplate
[PASS] testExploit() (gas: 179327)
Logs:
Attacker Before exploit WBNB Balance: 0.000000000000000000
Attacker After exploit WBNB Balance: 19.200000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped
Reference: DeFiHackLabs PoC src/test/2025-10/TokenHolder_exp.sol. Vault loss ≈ 20 WBNB.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2025-10-TokenHolder_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
TokenHolder_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "TokenHolder / BorrowerOperationsV6 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.