We do not have a reliable USD price for the recorded assets yet.
0x6f40A3d0c89cFfdC8A1af212A019C220A295E9bBBSC0x1d64327C74d6519afeF54E58730aD6fc797f05BaBSCOn BSC, an adversary-controlled helper contract 0xdD87.. repeatedly deposited 1 ORT into a staking pool via proxy 0x6f40.. and implementation 0x26bc12.., then withdrew the deposit to trigger a 6000 ORT reward mint per index, realizing a deterministic 1-to-6000 ORT payout pattern.
The staking implementation misconfigured per-deposit rewards so that a 1 ORT principal at index 139 was recorded with a fixed 6000 ORT reward and the contract, configured as an authorized ORT minter, minted that full 6000 ORT to the depositor on withdrawal without any proportionality to stake size or duration.
Root cause category: ATTACK. The StakingPool implementation stores an excessively large fixed reward (6000 ORT) for a 1 ORT deposit at index 139 and, when Claim_reward(139) is called, mints the full 6000 ORT to the depositor via ORT::mint, breaking any invariant that rewards must remain proportionate to deposited principal and staking duration.
The intended invariant is: For every staking deposit index i, let principal_i be the ORT principal and reward_i be the configured reward amount. A reasonable economic safety invariant is that reward_i is bounded as a function of principal_i and time, e.g., reward_i ≤ principal_i * R_max * f(time), where R_max is a bounded per-period rate. In particular, for an instantaneous invest- withdraw cycle (zero holding time), reward_i should satisfy reward_i ≤ principal_i (no more than 100% of principal).
The concrete breakpoint is: In the StakingPool::invest path, when helper 0xdD87.. calls invest(0,1) via proxy 0x6f40.., the implementation records principal_i = 1 ORT and reward_i = 6000 ORT for deposit index 139 in storage_map_m and related slots, as shown by the decompiled code and traces. Later, Claim_reward(139) reads this 6000 ORT reward and calls ORT::mint(0xdD87.., 6000e18), minting 6000 ORT to the depositor; this write violates the invariant by setting reward_i = 6000 * principal_i for zero holding time and directly increases ORT totalSupply by that amount.
Decompiled StakingPool code (artifacts/root_cause/data_collector/iter_2/contract/56/0x26bc1245B84760 86E85553E60eE5e3e59FeD9BE0/decompile/...) shows an invest(uint256 planId, uint256 amount) function that, when called via proxy 0x6f40.., increments a global deposit index, transfers ORT from msg.sender to the pool, and populates per-deposit storage entries including depositor address, principal, and a precomputed reward amount. For the observed call invest(0,1) from helper 0xdD87.., traces and logs confirm that ORT::transferFrom(0xdD87.., 0x6f40.., 1) moves exactly 1 ORT into the pool and that storage for index 139 is initialized with principal = 1 ORT and reward = 6000000000000000000000 (6000 * 10^18) ORT. Later, when helper.withdraw(139) forwards to withdraw_amount(139) and Claim_reward(139), the implementation checks ownership (storage_map_f[139 * 6] == 0xdD87..), returns the 1 ORT principal via ORT::transfer(0xdD87.., 1), and then calls ORT::mint(0xdD87.., 6000e18) using the stored reward value. Because ORT is configured to treat the staking implementation (via proxy) as an authorized minter (isMinter[0x26bc12..] = true), this mint succeeds and increases both 0xdD87..'s ORT balance and totalSupply by 6000 ORT. Nothing in the invest or Claim_reward logic scales reward by deposit size or time, so a minimal 1 ORT deposit immediately yields a 6000 ORT reward, and the helper contract can repeat this pattern across indices using invest(0,1) followed by withdraw/Claim_reward. This misconfiguration/bug in reward storage and minting logic is the concrete root cause enabling the exploit.
ORT token mint function (verified source, BSC 0x1d64327C74d6519afeF54E58730aD6fc797f05Ba):
function mint(uint256 amount) external override onlyMinter returns (bool) {
_mint(msg.sender, amount);
return true;
}
This shows that only authorized minters (including the staking implementation) can call mint, and that it mints directly to msg.sender.
Helper contract invest() forwarding into the staking proxy (0xdD87.. on BSC):
function invest() public payable {
require(address(msg.sender) == (address(store_a / 0x01)));
var_a = 0xd87aa64300000000000000000000000000000000000000000000000000000000;
uint256 var_b = 0;
var_c = 0x01;
require(address(0x6f40a3d0c89cffdc8a1af212a019c220a295e9bb).code.length);
(bool success, bytes memory ret0) = address(0x6f40a3d0c89cffdc8a1af212a019c220a295e9bb).{ value: 0 ether }Unresolved_d87aa643(var_b); // call
}
/// @custom:selector 0x2e1a7d4d
/// @custom:signature withdraw(uint256 arg0) public payable
/// @param arg0 ["uint256", "bytes32", "int256"]
This demonstrates that the helper is owner-gated to EOA 0x9bbd.. and forwards invest() calls to proxy 0x6f40.., which delegatecalls the staking implementation.
Helper contract withdrawToken() sweeping accumulated ORT to the owner:
function withdrawToken() public payable {
require(address(msg.sender) == (address(store_a / 0x01)));
var_a = 0x70a0823100000000000000000000000000000000000000000000000000000000;
address var_b = address(this);
require(address(0x1d64327c74d6519afef54e58730ad6fc797f05ba).code.length);
(bool success, bytes memory ret0) = address(0x1d64327c74d6519afef54e58730ad6fc797f05ba).Unresolved_70a08231(var_b); // staticcall
uint256 var_c = var_c + (uint248(ret0.length + 0x1f));
require(!((var_c + ret0.length) - var_c) < 0x20);
require(var_d == (var_d));
var_e = 0xa9059cbb00000000000000000000000000000000000000000000000000000000;
address var_f = address(store_a / 0x01);
var_g = var_d;
This function queries balanceOf(this) on ORT and then transfers the full balance to the stored owner address, which is the EOA 0x9bbd.., explaining how profits are periodically realized.
Seed transaction trace excerpt for withdraw/claim tx 0xa916674f...e97 (BSC 56):
│ │ │ └─ ← [Return] true
│ │ ├─ emit Withdraw(user: 0xdD87D807774c8aA9D70FC6aF5912C97FaDBF531B, lockId: 139, amountWithdrawn: 1)
│ │ ├─ [10018] ORT::mint(0xdD87D807774c8aA9D70FC6aF5912C97FaDBF531B, 6000000000000000000000 [6e21])
│ │ │ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0xdD87D807774c8aA9D70FC6aF5912C97FaDBF531B, value: 6000000000000000000000 [6e21])
│ │ │ ├─ storage changes:
│ │ │ │ @ 2: 0x000000000000000000000000000000000000000000230834b2695fb788af5d05 → 0x000000000000000000000000000000000000000000230979f523725ac06f5d05
This excerpt shows ORT::mint(0xdD87.., 6000000000000000000000) and the corresponding Transfer and Claim events for lockId 139, confirming the 6000 ORT reward mint on withdrawal.
A single EOA 0x9bbd.. deploys an owner-gated helper 0xdD87.., uses it to approve the ORT staking proxy, then repeatedly calls helper.invest() with amount=1 ORT and helper.withdraw(index) for successive indices to mint 6000 ORT rewards per 1 ORT deposit, periodically sweeping accumulated ORT from the helper to the EOA via withdrawToken().
Adversary-related cluster:
Victim candidates:
Lifecycle stages:
Adversary funding and helper deployment
Helper approval and staking deposit
Withdraw, reward mint, and periodic sweeping
For each 1 ORT deposit processed under the misconfigured staking plan, the adversary mints 6000 new ORT to its helper contract and later sweeps those tokens to its EOA, paying only modest BNB gas. Repeating this cycle across many indices allows the adversary cluster to inflate ORT totalSupply by multiples of 6000 ORT per index with no corresponding economic input, diluting other holders and undermining the token's economic integrity.