❌ RECHAZADO PR #247 🔍 Revisión Diferencial

NutriChain Protocol — Revisión de Seguridad

StakingVault.sol · Migración 0.7.6 → 0.8.20 + Refactorización de retiros

Repositorionutrichain/contracts
Branchfeat/optimize-staking-v2
Commits4 commits
Revisado porCULTIVA IA · Seguridad
Fecha16 Jun 2026
EstrategiaFOCUSED (4 archivos)
🚨

ACCIÓN REQUERIDA: Este PR contiene 2 vulnerabilidades críticas que permiten el vaciado total del protocolo. El merge debe ser bloqueado hasta corregir los hallazgos F-001 y F-002. El PR también elimina protecciones históricas añadidas tras auditorías anteriores.

📊 Resumen Ejecutivo

2
🔴 Crítico
1
🟠 Alto
2
🟡 Medio
1
🟢 Bajo
Riesgo Global
CRÍTICO
Recomendación
RECHAZAR
Confianza
ALTA
Archivos analizados
4 / 4
100% cobertura
Gaps de tests
3 funciones
emergencyExit(), withdraw() parcial, distributeYield()
Radio de impacto alto
2 funciones
withdraw() 34 llamadores · distributeYield() protocolo-wide
1 Cambios del PR
Rango de commits: main..feat/optimize-staking-v2  |  Timeline: 2026-06-10 → 2026-06-15
Archivo +Líneas −Líneas Riesgo Radio de Impacto
contracts/StakingVault.sol +89 −34 CRÍTICO CRÍTICO — 34 llamadores
contracts/NUTRToken.sol +12 −3 MEDIO MEDIO — 8 llamadores
contracts/PriceOracle.sol +5 −0 BAJO BAJO — solo lectura
test/StakingVault.test.js +45 −20 BAJO N/A
Total: +151 / −57 líneas en 4 archivos
2 Hallazgos Críticos
F-001 · CRÍTICO
🔴 Reentrancia en emergencyExit() — Vaciado total del protocolo
CRÍTICO
Archivo
StakingVault.sol:L78
Commit
4f8a2c1
Radio de Impacto
CRÍTICO (nueva función pública)
Tests
NINGUNO

La nueva función emergencyExit() viola el patrón CEI (Checks-Effects-Interactions): ejecuta el call externo antes de actualizar el estado balances[msg.sender] = 0. Un atacante con un contrato malicioso puede llamar recursivamente a emergencyExit() durante el callback ETH y vaciar el vault completo.

⚡ Contexto histórico
commit def9a23 (2025-11-14) — "Fix critical reentrancy CVE-2025-0847 in withdraw()"
El equipo ya corrigió una vulnerabilidad idéntica en withdraw() hace 7 meses. La nueva función emergencyExit() repite exactamente el mismo error.
🎯 Escenario de ataque
1 Atacante despliega contrato malicioso con fallback que llama de nuevo a emergencyExit()
2 Atacante deposita 1 ETH en StakingVault para obtener balance legítimo
3 Llamada a emergencyExit(): el vault transfiere 1 ETH vía .call{value: bal}("")
4 El fallback del contrato atacante vuelve a llamar emergencyExit()balances[attacker] sigue siendo 1 ETH (no se ha zeroeado)
5 El loop se repite hasta agotar todo el ETH del vault. Con 100 ETH en vault, el atacante drena ~99 ETH adicionales en una sola transacción.
💻 Prueba de concepto
// Contrato atacante
contract ReentrancyAttack {
    StakingVault vault;
    uint public attackCount;
    
    receive() external payable {
        if (attackCount < 10) {
            attackCount++;
            vault.emergencyExit(); // Recursive call — balance no se ha zeroeado
        }
    }
    
    function attack() external payable {
        vault.deposit{value: msg.value}();
        vault.emergencyExit(); // Dispara la cadena
    }
}
✅ Recomendación

Aplicar patrón CEI (Checks-Effects-Interactions): actualizar el estado antes del call externo. Alternativamente, usar el modificador nonReentrant de OpenZeppelin ReentrancyGuard.

function emergencyExit() external nonReentrant {
    uint256 bal = balances[msg.sender];
    require(bal > 0, "No balance");
+   balances[msg.sender] = 0;           // Estado primero (CEI)
    (bool success,) = msg.sender.call{value: bal}("");
    require(success, "Transfer failed");
-   balances[msg.sender] = 0;           // ❌ Estado DESPUÉS del call
}
F-002 · CRÍTICO
🔴 Eliminación de control de acceso en withdraw() — Bypass de autorización
CRÍTICO
Archivo
StakingVault.sol:L45
Commit
7b3e19f
Radio de Impacto
ALTO — 34 llamadores
Tests
PARCIALES

La migración a 0.8.20 eliminó el check require(msg.sender == owner) de la función withdraw(). Aunque la lógica "optimizada" usa balances[msg.sender] para limitar el retiro al balance del caller, la eliminación del check de ownership rompe el modelo de confianza en contratos que delegan en StakingVault asumiendo que sólo el owner puede retirar fondos del protocolo.

⚡ Contexto histórico
commit a91c44b (2025-03-22) — "Add owner-only restriction per audit finding #31 (NutriChain Audit Q1-2025)"
Auditoría externa identificó en Q1-2025 que withdraw() necesitaba restricción por ownership. El PR actual revierte silenciosamente esta corrección sin mencionar la auditoría.
🎯 Escenario de ataque
1 Contrato externo YieldAggregator.sol llama a withdraw(amount) asumiendo restricción de ownership
2 Sin el check, cualquier contrato que tenga balance en StakingVault puede retirar directamente, bypass­ando la lógica de gobernanza del agregador
3 Atacante que haya depositado mínimo puede llamar withdraw() ignorando timelock y restricciones de gobernanza del protocolo padre
💻 Diff problemático
- function withdraw(uint256 amount) external {
-     require(msg.sender == owner, "Unauthorized");  // ❌ ELIMINADO
-     require(amount > 0, "Zero amount");            // ❌ ELIMINADO
-     require(balances[msg.sender] >= amount, "Insufficient");
-     balances[msg.sender] = balances[msg.sender].sub(amount);
-     NUTR.safeTransfer(msg.sender, amount);         // ❌ SafeERC20 eliminado
- }
+ function withdraw(uint256 amount) external {
+     require(balances[msg.sender] >= amount, "Insufficient");
+     balances[msg.sender] -= amount;
+     NUTR.transfer(msg.sender, amount);  // Retorno no verificado
+ }
✅ Recomendación

Restaurar el check de ownership o, si el diseño requiere retiros por cualquier staker, documentar explícitamente el cambio de modelo de confianza y coordinar con todos los contratos integradores. Usar SafeERC20.safeTransfer en lugar de .transfer.

F-003 · ALTO
🟠 Denegación de servicio en distributeYield() — Loop sin límite de gas
ALTO
Archivo
StakingVault.sol:L112
Radio de Impacto
ALTO — afecta a todos los stakers
Tests
NINGUNO

El refactor de distributeYield() introduce un loop for sobre el array stakers[] sin límite de iteraciones. Con 200+ stakers, la función superará el gas limit de Ethereum (30M gas) y se volverá permanentemente inutilizable, bloqueando la distribución de yields del protocolo.

🎯 Escenario de ataque
1 Atacante registra 500 cuentas con depósito mínimo, inflando stakers.length
2 Cada iteración cuesta ~8.000 gas (SLOAD + transfer). 500 × 8.000 = 4M gas extra
3 distributeYield() excede el gas block limit. El owner no puede distribuir rewards → usuarios legítimos pierden yields
✅ Recomendación

Migrar a patrón pull-over-push: almacenar rewards acumulados por usuario y dejar que cada staker llame a claimReward() individualmente. Alternativa rápida: añadir paginación con start/end índices.

3 Cobertura de Tests
Cobertura global de cambios críticos 28%
Función Riesgo Tests existentes Impacto del gap
emergencyExit() CRÍTICO ❌ Ninguno Reentrancia no detectable en CI
withdraw() — retiro parcial CRÍTICO ⚠️ Solo happy path Auth bypass no cubierto
distributeYield() ALTO ❌ Ninguno DoS por gas no detectable
deposit() BAJO ✅ Completo
Bloquear merge: 2 funciones de riesgo CRÍTICO sin ningún test. El pipeline CI/CD no detectaría ninguna de las vulnerabilidades encontradas en esta revisión.
4 Radio de Impacto
withdraw()
34 llamadores
CRÍTICO
distributeYield()
~todos los stakers
ALTO
emergencyExit()
nueva función pública
CRÍTICO
NUTRToken.transfer()
8 llamadores
MEDIO
5 Contexto Histórico — Regresiones Detectadas
StakingVault.sol:L45 — require(msg.sender == owner) ELIMINADO
Añadido en commit a91c44b como corrección de Auditoría Q1-2025, hallazgo #31. El PR revierte esta protección sin justificación documentada.
Añadido: 2025-03-22 · Eliminado: 2026-06-10 (este PR)
StakingVault.sol:L52 — SafeERC20.safeTransfer() → .transfer()
El uso de safeTransfer fue introducido como buena práctica en commit c7d8e12. La migración lo eliminó asumiendo que tokens ERC-20 "normales" no necesitan SafeERC20, ignorando que NUTR puede pausarse.
Añadido: 2025-01-08 · Eliminado: 2026-06-12 (este PR)
StakingVault.sol:L48 — require(amount > 0) ELIMINADO
Validación eliminada "porque es redundante con el check de balance". Sin embargo, permite llamadas con amount=0 que generan eventos de transferencia falsos y contaminan el historial on-chain.
Añadido: 2024-11-15 · Eliminado: 2026-06-10 (este PR)
6 Recomendaciones
🚫 Inmediatas (Bloqueantes — no hacer merge hasta resolver)
  • 🔴 [F-001] Aplicar patrón CEI en emergencyExit(): mover balances[msg.sender] = 0 antes del call externo
  • 🔴 [F-002] Restaurar require(msg.sender == owner) en withdraw() o documentar y coordinar el cambio de modelo de confianza con todos los integradores
  • 🔴 Añadir modificador nonReentrant (OpenZeppelin ReentrancyGuard) a todas las funciones que ejecuten calls externos
  • 🔴 Revertir a SafeERC20.safeTransfer() para transfers de NUTR
⚠️ Antes de producción
  • 🟠 [F-003] Refactorizar distributeYield() a patrón pull-over-push con claimReward()
  • 🟠 Añadir tests de reentrancia para emergencyExit() con contrato mock malicioso
  • 🟠 Restaurar validación require(amount > 0) en withdraw()
  • 🟠 Audit de todos los contratos integradores que dependen del modelo de permisos de withdraw()
📋 Deuda técnica
  • 🟡 Añadir tests de límite de gas para funciones con loops sobre arrays dinámicos
  • 🟡 Documentar modelo de confianza explícito en NatSpec para funciones críticas
  • 🟢 Considerar integrar Slither/Echidna en el pipeline CI para detección automática de reentrancia
7 Metodología de Análisis
Estrategia

FOCUSED — 4 archivos, codebase pequeño. Cobertura 100% en archivos HIGH/CRITICAL, análisis DEEP en StakingVault.sol.

Técnicas aplicadas

Git blame en todas las eliminaciones · Cálculo de radio de impacto · Análisis de cobertura de tests · Modelado adversarial en funciones HIGH RISK

Alcance del análisis

Archivos revisados: 4/4 (100%)
HIGH RISK: 100% cobertura
MEDIUM RISK: 100% cobertura
LOW RISK: Análisis de superficie

Limitaciones

Análisis estático — no se ejecutó el protocolo en testnet. Las dependencias externas (OpenZeppelin, oráculos) no se incluyeron en el scope. Confianza en la revisión: ALTA.