Root Cause
DHC's award contract implements a staking/award lifecycle with numeric status codes, but the claimAward() function lacked a persistent claimed-flag. An attacker could call participateAward() with a 0-wei pledge to reset an award's status from 2 (claimed) back to 1 (claimable), then call claimAward() again — indefinitely, with no elapsed-time check and no per-position guard.
// Vulnerable pattern (simplified from DHC award contract at 0x5abb3fe2...)
function participateAward(uint256 awardId, uint256 amount) external {
Award storage award = awards[awardId];
// BUG: no guard against resetting an already-claimed award
if (amount == 0) {
award.status = STATUS_ACTIVE; // silently resets to claimable
return;
}
// ... normal pledge logic
}
function claimAward(uint256 awardId) external {
Award storage award = awards[awardId];
require(award.status == STATUS_ACTIVE, "not active");
_distributeDHC(msg.sender, award.rewardAmount); // pays out DHC
award.status = STATUS_CLAIMED; // can be reset again immediately
// BUG: no mapping(awardId => bool) claimed that survives participateAward resets
}
The contract runs behind an EIP-1967 upgradeable proxy (0xe2A047aADbac51b0116Af1cE91eBDAe4B4202094), but no upgrade was deployed before the drain — the guard must exist in the business logic, not the upgrade mechanism.
Attack Steps
| Step | TX (nonce) | Action |
|---|---|---|
| 1 | nonce 0 | CREATE: deploy exploit contract 0x226923D3... |
| 2 | nonce 1 | Setup: create award position, prime internal state |
| 3 | nonce 2 | Setup: initial pledge + participateAward to activate position |
| 4 | nonce 3 (drain) | 18× loop: participateAward(awardId, 0) reset → claimAward(awardId) |
| 5 | nonce 3 | Accumulated DHC tokens swapped → USDT via PancakeSwap pair 0x4b92a34a... |
| — | — | All 4 txs in single block 120,055,460; $71,851 USDT extracted |
On-chain addresses (BNB Chain):
- Attacker EOA:
0xD3A8D0A9F55cf679fff6F277E49AfC95B49D2B07 - Exploit contract:
0x226923D34A10f3D54B57b9F4b685E82c6Cba968A - Drain tx:
0xb4c89845d95fd721e6931dd223e24e8b78a9de8c3b4428251a36d9213e44c496 - Vulnerable proxy:
0xe2A047aADbac51b0116Af1cE91eBDAe4B4202094 - Vulnerable impl:
0x5abb3fe2a02e5d4320862944cd3a0b8f6af28ce1 - DHC token:
0x743F15f4d2481774d970f286f0EbAD9C3Daed6E9
Impact
| Asset | Amount |
|---|---|
| USDT (BNB Chain) | ~$71,851 |
All 4 attack transactions fit inside a single block (120,055,460), completing the drain atomically. DHC is a reflection token, which complicated exact per-transaction accounting but provided no protection against the exploit.
Lessons for Auditors
-
State machine completeness. Every transition must be exhaustively guarded. If
STATUS_CLAIMED → STATUS_ACTIVEis reachable through any code path — even an edge case withamount = 0— treat it as a security-critical transition requiring the same checks as the initial claim. -
Claim idempotency via persistent flags. Reward claim functions must use a non-resettable flag (e.g.,
mapping(uint256 => bool) private claimed) that is stored independently of the mutable award lifecycle status. Resettable status codes alone are insufficient. -
Zero-value inputs as state-reset vectors. Audit all functions accepting
uint amountfor whetheramount == 0reaches an unintended branch. Explicitlyreverton zero-value calls where they have no legitimate purpose. -
Proxies do not substitute for logic-layer guards. An upgradeable proxy that was not upgraded before a drain offers no protection; business-logic invariants must be enforced in the implementation contract itself.
-
Single-block multi-transaction exploits. The attack fit entirely in one block with 4 sequential EOA transactions — no flashloan required. Per-block or per-epoch claim rate limits, or commitment schemes, can mitigate atomic looping attacks even without flashloan protection.