seguridad-amm-defi-solidity
4 vulnerabilidades críticas/high encontradas. Contrato NO apto para despliegue.
La función withdraw() realiza la transferencia de tokens antes de actualizar el balance del usuario. Un contrato atacante puede re-entrar la función durante el callback de transferencia y drenar el pool completo. Con TVL de 2M USD, este vector es explotable con un flash loan de <100 USD.
function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount, "Insufficient"); // ❌ CRÍTICO: transfer ANTES de actualizar el estado token.transfer(msg.sender, amount); // ← punto de reentrancy balances[msg.sender] -= amount; // ← nunca se ejecuta si hay reentrancy }
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; using SafeERC20 for IERC20; function withdraw(uint256 amount) external nonReentrant { require(balances[msg.sender] >= amount, "Insufficient"); // ✅ CEI: Check → Effect → Interaction balances[msg.sender] -= amount; // Effect primero token.safeTransfer(msg.sender, amount); // Interaction al final }
El cálculo de shares LP usa token.balanceOf(address(this)) directamente. Un atacante puede enviar tokens al contrato fuera del flujo de deposit() para inflar el denominador antes de que otro usuario deposite, robando prácticamente todas sus shares recién minteadas.
function deposit(uint256 assets) external returns (uint256 shares) { // ❌ CRÍTICO: usa balanceOf directamente — manipulable shares = (assets * totalShares) / token.balanceOf(address(this)); token.transferFrom(msg.sender, address(this), assets); totalShares += shares; _mint(msg.sender, shares); }
uint256 private _totalAssets; // ✅ contabilidad interna independiente de balanceOf function deposit(uint256 assets) external nonReentrant returns (uint256 shares) { uint256 balBefore = token.balanceOf(address(this)); token.safeTransferFrom(msg.sender, address(this), assets); uint256 received = token.balanceOf(address(this)) - balBefore; // ✅ medir recibido real shares = totalShares == 0 ? received : (received * totalShares) / _totalAssets; // ✅ denominador interno _totalAssets += received; totalShares += shares; _mint(msg.sender, shares); }
El protocolo usa el precio spot en bloque como referencia para liquidaciones y swaps. Los flash loans permiten manipular este precio dentro de una transacción sin costo neto. Usar TWAP de 30 minutos como mínimo para cualquier lógica sensible a precio.
// ❌ precio spot — flash-loan manipulable en un bloque function getPrice() public view returns (uint256) { return (reserve1 * 1e18) / reserve0; // precio de spot actual }
// ✅ TWAP de 30 minutos — resistente a manipulación flash loan function getTWAP() external view returns (uint160 sqrtPriceX96) { uint32[] memory secondsAgos = new uint32[](2); secondsAgos[0] = 1800; // 30 minutos atrás secondsAgos[1] = 0; (int56[] memory tickCumulatives,) = IUniswapV3Pool(pool).observe(secondsAgos); int24 twapTick = int24( (tickCumulatives[1] - tickCumulatives[0]) / int56(uint56(30 minutes)) ); sqrtPriceX96 = TickMath.getSqrtRatioAtTick(twapTick); }
La función swap() no acepta amountOutMin ni deadline. Los bots MEV pueden hacer sandwich attacks haciendo que el usuario reciba muchos menos tokens del esperado. Con liquidez baja, las pérdidas pueden superar el 10% del swap.
// ❌ sin slippage ni deadline — MEV sandwich attack trivial function swap(uint256 amountIn) external returns (uint256 amountOut) { amountOut = _calculateOut(amountIn); _executeSwap(amountIn, amountOut); }
// ✅ slippage protección + deadline anti-MEV function swap( uint256 amountIn, uint256 amountOutMin, // ← protección slippage uint256 deadline // ← protección MEV / transacciones atascadas ) external nonReentrant returns (uint256 amountOut) { require(block.timestamp <= deadline, "SwapForge: EXPIRED"); amountOut = _calculateOut(amountIn); require(amountOut >= amountOutMin, "SwapForge: INSUFFICIENT_OUTPUT"); _executeSwap(amountIn, amountOut); }
El contrato usa Ownable básico para transferir ownership. Si se envía a una dirección errónea, el control del protocolo se pierde permanentemente. Con funciones de setFee() y pause() en manos del owner, esto representa un riesgo sistémico.
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; contract SwapForgeAMM is Ownable2Step, Pausable, ReentrancyGuard { // setFee requiere acceptOwnership() del nuevo owner — sin pérdida accidental function setFee(uint256 fee) external onlyOwner { require(fee <= 1000, "Fee too high"); // max 10% feeRate = fee; } function pause() external onlyOwner { _pause(); } function unpause() external onlyOwner { _unpause(); } }
Los cálculos de reservas usan a * b / c directamente. Con pools grandes (tokens de 18 decimales y reservas > 1e18), el producto intermedio puede superar type(uint256).max, causando overflow silencioso y precios incorrectos.
import {FullMath} from "@uniswap/v3-core/contracts/libraries/FullMath.sol"; // ✅ safe para reservas grandes — usa uint512 internamente uint256 amountOut = FullMath.mulDiv(amountIn * (10000 - feeRate), reserve1, reserve0 * 10000);
nonReentrant (withdraw, deposit, swap)
RESUELTO
balanceOf(address(this)) — usa _totalAssets interno
RESUELTO
SafeERC20 (protección tokens non-standard)
RESUELTO
amountOutMin y deadline
RESUELTO
FullMath.mulDiv
RESUELTO
onlyOwner + Ownable2Step + Pausable
RESUELTO
// SPDX-License-Identifier: MIT // SwapForge Protocol — Hardened AMM Contract v1.1 // Auditado por: CULTIVA IA Security · 18 Jun 2026 pragma solidity ^0.8.24; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; import {FullMath} from "@uniswap/v3-core/contracts/libraries/FullMath.sol"; contract SwapForgeAMM is ReentrancyGuard, Ownable2Step, Pausable { using SafeERC20 for IERC20; IERC20 public immutable token0; IERC20 public immutable token1; uint256 public feeRate = 30; // 0.3% uint256 private _reserve0; // contabilidad interna — no depende de balanceOf uint256 private _reserve1; mapping(address => uint256) public balances; uint256 public totalShares; event Swap(address indexed user, uint256 amountIn, uint256 amountOut); event Deposit(address indexed user, uint256 assets, uint256 shares); event Withdraw(address indexed user, uint256 shares, uint256 assets); // ─── DEPOSIT ──────────────────────────────────────────────────────────── // Fix SWF-02: contabilidad interna + medir tokens recibidos function deposit(uint256 assets) external nonReentrant whenNotPaused returns (uint256 shares) { uint256 balBefore = token0.balanceOf(address(this)); token0.safeTransferFrom(msg.sender, address(this), assets); uint256 received = token0.balanceOf(address(this)) - balBefore; shares = totalShares == 0 ? received : FullMath.mulDiv(received, totalShares, _reserve0); _reserve0 += received; totalShares += shares; balances[msg.sender] += shares; emit Deposit(msg.sender, received, shares); } // ─── WITHDRAW ─────────────────────────────────────────────────────────── // Fix SWF-01: CEI + nonReentrant + SafeERC20 function withdraw(uint256 shares) external nonReentrant whenNotPaused returns (uint256 assets) { require(balances[msg.sender] >= shares, "SwapForge: INSUFFICIENT_SHARES"); assets = FullMath.mulDiv(shares, _reserve0, totalShares); // CEI: Effect ANTES de Interaction balances[msg.sender] -= shares; totalShares -= shares; _reserve0 -= assets; token0.safeTransfer(msg.sender, assets); // Interaction al final emit Withdraw(msg.sender, shares, assets); } // ─── SWAP ──────────────────────────────────────────────────────────────── // Fix SWF-04: amountOutMin + deadline + nonReentrant function swap( uint256 amountIn, uint256 amountOutMin, uint256 deadline ) external nonReentrant whenNotPaused returns (uint256 amountOut) { require(block.timestamp <= deadline, "SwapForge: EXPIRED"); require(amountIn > 0, "SwapForge: ZERO_INPUT"); uint256 amountInWithFee = amountIn * (10000 - feeRate); amountOut = FullMath.mulDiv(amountInWithFee, _reserve1, _reserve0 * 10000); require(amountOut >= amountOutMin, "SwapForge: INSUFFICIENT_OUTPUT"); uint256 balBefore = token0.balanceOf(address(this)); token0.safeTransferFrom(msg.sender, address(this), amountIn); uint256 received = token0.balanceOf(address(this)) - balBefore; _reserve0 += received; _reserve1 -= amountOut; token1.safeTransfer(msg.sender, amountOut); emit Swap(msg.sender, received, amountOut); } // ─── ADMIN ─────────────────────────────────────────────────────────────── // Fix SWF-05: Ownable2Step + fee cap + Pausable function setFee(uint256 fee) external onlyOwner { require(fee <= 1000, "SwapForge: FEE_TOO_HIGH"); // max 10% feeRate = fee; } function pause() external onlyOwner { _pause(); } function unpause() external onlyOwner { _unpause(); } }