The Honest View
Briefing
A pool holds ETH and a token and issues shares. A separate lending market accepts pool shares as collateral and prices them with `sharePrice()`.
// Quantix Security Research Network — training challenge.// Intentionally vulnerable. Written for education; do not deploy.// SPDX-License-Identifier: MITpragma solidity 0.8.24; contract EthPool { uint256 public totalShares; uint256 public reserveEth; uint256 public reserveToken; mapping(address => uint256) public shares; bool private locked; modifier nonReentrant() { require(!locked, "reentrant"); locked = true; _; locked = false; } /// Value of one share in ETH terms (1e18 precision). function sharePrice() external view returns (uint256) { return ((reserveEth * 2) * 1e18) / totalShares; // simplified valuation } function removeLiquidity(uint256 amount) external nonReentrant { uint256 ethOut = (reserveEth * amount) / totalShares; uint256 tokenOut = (reserveToken * amount) / totalShares; shares[msg.sender] -= amount; totalShares -= amount; (bool ok, ) = msg.sender.call{value: ethOut}(""); require(ok, "eth transfer failed"); reserveEth -= ethOut; reserveToken -= tokenOut; // token transfer omitted for brevity }}function collateralValue(address user) public view returns (uint256) { return (pool.shares(user) * pool.sharePrice()) / 1e18;} function borrow(uint256 amount) external { require(debt[msg.sender] + amount <= collateralValue(msg.sender) * 70 / 100, "insufficient collateral"); debt[msg.sender] += amount; weth.transfer(msg.sender, amount);}Hints
Reveal one at a time. Each moves a step closer to the answer.
- H1
Hidden
- H2
Hidden
- H3
Hidden
Solution
Try writing your own explanation and fix before opening it.
`removeLiquidity` burns shares before sending ETH but only reduces `reserveEth` afterwards. During the ETH transfer the caller’s `receive` function runs with `totalShares` already reduced and `reserveEth` still at its old value, so `sharePrice()` reports an inflated value.
The guard blocks re-entering `removeLiquidity`, but `sharePrice()` is a view function without the guard. From inside the callback the attacker borrows from `ShareLending` against the shares they still hold, which are now overvalued. When control returns, the pool corrects its reserves; the loan stays.
Fix in the pool: complete all state updates before external calls, and make view functions revert (or expose a guard status) while the lock is held. Fix in the integrator: check the pool’s reentrancy status before reading its price, or use a price that cannot be read mid-update.
function sharePrice() external view returns (uint256) {+ require(!locked, "pool is mid-operation"); return ((reserveEth * 2) * 1e18) / totalShares; } function removeLiquidity(uint256 amount) external nonReentrant { uint256 ethOut = (reserveEth * amount) / totalShares; uint256 tokenOut = (reserveToken * amount) / totalShares; shares[msg.sender] -= amount; totalShares -= amount;+ reserveEth -= ethOut;+ reserveToken -= tokenOut; (bool ok, ) = msg.sender.call{value: ethOut}(""); require(ok, "eth transfer failed");-- reserveEth -= ethOut;- reserveToken -= tokenOut; }