Reproduced Exploit
DYAD: `updateXP` can be omitted during liquidation
Chain
Other
Category
untagged
Date
Jan 1970
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: Dyad-security-review. The historical source/toolchain is unavailable; this entry is documentation only and claims no executable Forge PoC.
Vulnerability classes: vuln/logic · vuln/accounting
Reproduction: a faithful minimal reproduction of the vulnerable finding —
VaultManagerV5.liquidateis reproduced verbatim (marked@>) with faithful minimal doubles (real ERC20/collateral accounting and the verbatim auditedDyadXPv2math); local deploy, no fork.
Root cause#
VaultManagerV5.liquidate calls dyad.burn(id, msg.sender, amount), which changes the note's debt. A note's DyadXPv2 XP accrues with a debt bonus that grows with dyadMinted, so the XP snapshot must be resynced with dyadXP.updateXP(id) whenever the debt changes. But updateXP is only called inside the collateral loop, guarded by if (address(vault) == KEROSENE_VAULT). Kerosene lives in a separate bounded vault set, so the general vaults[id] set iterated here holds only non-kerosene collateral (wETH, etc.); the branch never matches, updateXP is skipped, and noteData[id].dyadMinted keeps the pre-liquidation (higher) debt. The vulnerable code, reproduced verbatim:
function liquidate(uint256 id, uint256 to, uint256 amount)
external
isValidDNft(id)
isValidDNft(to)
returns (address[] memory, uint256[] memory)
{
uint256 cr = collatRatio(id);
if (cr >= MIN_COLLAT_RATIO) revert CrTooHigh();
uint256 debt = dyad.mintedDyad(id);
dyad.burn(id, msg.sender, amount); // changes `debt` and `cr`
lastDeposit[to] = block.number; // `move` acts like a deposit
uint256 numberOfVaults = vaults[id].length();
address[] memory vaultAddresses = new address[](https://github.com/sanbir/evm-hack-registry/tree/main/41690-h-03-updatexp-can-be-omitted-during-liquidation-pashov-audit_exp/numberOfVaults);
uint256[] memory vaultAmounts = new uint256[](https://github.com/sanbir/evm-hack-registry/tree/main/41690-h-03-updatexp-can-be-omitted-during-liquidation-pashov-audit_exp/numberOfVaults);
uint256 totalValue = getTotalValue(id);
if (totalValue == 0) return (vaultAddresses, vaultAmounts);
for (uint256 i = 0; i < numberOfVaults; i++) {
Vault vault = Vault(vaults[id].at(i));
vaultAddresses[i] = address(vault);
if (vaultLicenser.isLicensed(address(vault))) {
uint256 depositAmount = vault.id2asset(id);
if (depositAmount == 0) continue;
uint256 value = vault.getUsdValue(id);
uint256 asset;
if (cr < LIQUIDATION_REWARD + 1e18 && debt != amount) {
uint256 cappedCr = cr < 1e18 ? 1e18 : cr;
uint256 liquidationEquityShare = (cappedCr - 1e18).mulWadDown(LIQUIDATION_REWARD);
uint256 liquidationAssetShare = (liquidationEquityShare + 1e18).divWadDown(cappedCr);
uint256 allAsset = depositAmount.mulWadUp(liquidationAssetShare);
asset = allAsset.mulWadDown(amount).divWadDown(debt);
} else {
uint256 share = value.divWadDown(totalValue);
uint256 amountShare = share.mulWadUp(amount);
uint256 reward_rate = amount.divWadDown(debt).mulWadDown(LIQUIDATION_REWARD);
uint256 valueToMove = amountShare + amountShare.mulWadUp(reward_rate);
uint256 cappedValue = valueToMove > value ? value : valueToMove;
asset = cappedValue * (10 ** (vault.oracle().decimals() + vault.asset().decimals()))
/ vault.assetPrice() / 1e18;
}
vaultAmounts[i] = asset;
vault.move(id, to, asset);
@> if (address(vault) == KEROSENE_VAULT) { // updateXP runs ONLY here; a non-kerosene seizure leaves noteData[id].dyadMinted stale after dyad.burn changed the debt
dyadXP.updateXP(id);
dyadXP.updateXP(to);
}
}
}
emit Liquidate(id, msg.sender, to, amount);
return (vaultAddresses, vaultAmounts);
}
Why it's exploitable here#
Following the finding's scenario with a note holding 1000e18 kerosene (the XP driver), 1 wETH ($2000) collateral, and 1600e18 DYAD debt:
- Collateral ratio is
2000e18 / 1600e18 = 1.25e18, below the 150%MIN_COLLAT_RATIO, so the note is liquidatable. - A liquidator burns 800e18 of debt (
dyad.burn) and the loop seizes the wETH collateral viavault.move. - Because the seized vault is wETH, not
KEROSENE_VAULT,updateXP(id)is skipped. On-chaindyad.mintedDyad(id)is now the correct 800e18, butDyadXPv2's storeddyadMintedstays the stale 1600e18. - XP accrues with
bonus = deposited + deposited * dyadMinted / (dyadMinted + deposited). The stale note computes its bonus on 1600e18 (~1615e18) instead of the true 800e18 (~1444e18) — roughly 12% more XP every second, forever. - That excess XP — XP the note should never have earned — dilutes every honest staker's share of kerosene rewards. The PoC quantifies it against an identical, correctly-resynced reference note and marks the over-accrual at a sink.
Attack path#
Marked-line walkthrough (Playground)#
The EVM Playground pins each step to the exact executed source line in 0xd3760b84…:
- L66 — Ceil helper sizes the seizure: Setup: the mulWadUp ceil helper backs the liquidation math that sizes how much wETH collateral gets moved out of the note.
- L117 — Only non-kerosene collateral registered: Setup: adding the wETH vault stores it as the sole entry in vaults[id], a non-kerosene set the KEROSENE_VAULT branch never matches.
- L443 — Seed the note's DYAD debt: Setup: debt bookkeeping routed through the manager seeds the note's minted DYAD, the value that drives its XP debt-bonus.
- L460 — Confirm the note is liquidatable: collatRatio reads the note's minted DYAD debt; with debt 1600e18 against $2000 collateral the ratio is 1.25e18, under the 150% minimum.
- L487 — Load the note's only vault: The liquidate loop loads the note's only collateral vault, the non-kerosene wETH vault, after dyad.burn already halved the debt.
- L503 — Compute the seized wETH amount: The branch computes the reward rate and the amount of wETH to move from the liquidated note to the liquidator.
- L512 — updateXP gated to kerosene branch: Root cause: updateXP runs only when the seized vault is KEROSENE_VAULT, so this wETH seizure skips it and leaves noteData[id].dyadMinted stale.
- L521 — Liquidation returns without XP resync: liquidate returns after burning half the debt without resyncing XP, so the note keeps accruing XP on the stale 1600e18 debt bonus forever.
PoC#
Registry (Foundry, local deploy — verbatim vulnerable source + harm-asserting test):
cd 41690-h-03-updatexp-can-be-omitted-during-liquidation-pashov-audit_exp && forge test -vvv
The browser Playground replays the same synthetic opcode-for-opcode and measures the harm: after a partial liquidation seizes wETH collateral, dyad.mintedDyad(id) drops to 800e18 while DyadXPv2's snapshot stays at 1600e18, so the liquidated note over-accrues XP versus an identical correctly-resynced note — the excess minted to the sink as the quantified integrity loss. Both gates are green (registry forge test PASS + Playground _verify-poc VERDICT: PASS).
Sources & further analysis#
Reproductions & code
- No executable Forge reproduction is claimed; the historical source/toolchain was unavailable for this finding.
- AuditVault finding: Dyad-security-review.
- Upstream DeFiHackLabs PoC directory: src/test.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "DYAD:
updateXPcan be omitted during liquidation". - Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.