Back to Blog

Reflexionando sobre los Tokens de Reflexión: Una Perspectiva de Seguridad

Phalcon
June 6, 2024
28 min read

Actualizado el 14 de junio de 2024: Un miembro de la comunidad examinó cuidadosamente este blog y proporcionó información sobre el incidente ADU, que es una nueva forma no cubierta en nuestra categorización anterior. ¡Gracias, y todo comentario perspicaz es bienvenido!

Para mejorar la estabilidad del mercado, los tokens de reflexión (también conocidos como tokens de recompensa) están diseñados para ofrecer a los inversores una vía adicional para generar ingresos. Esto incentiva a los inversores a mantener sus tokens en lugar de intercambiarlos. Durante la infame temporada de meme coins de 2021, los tokens de reflexión se convirtieron en un mecanismo indispensable, captando rápidamente la atención del mercado tras ser lanzados en plataformas como DxSale (por ejemplo, SafeMoon V1).

A pesar de que la euforia disminuyó y el mercado se enfrió en 2023, nuestro sistema detectó decenas de miles de incidentes de hackeo que explotaban dichos mecanismos de tokens en la práctica. Estos ataques de tipo reaper, aunque de escala relativamente pequeña en comparación con otros tipos de ataques DeFi, resultaron en pérdidas no despreciables para los activos de los usuarios.

En este blog, nuestro enfoque principal es compartir conocimientos relacionados con la seguridad derivados de nuestra investigación. Específicamente, primero proporcionaremos una breve introducción al mecanismo del token de reflexión. A continuación, revisaremos incidentes de seguridad relacionados con tokens de reflexión, con un enfoque en aquellos que explotan el mecanismo del token de reflexión. Luego, discutiremos un posible problema de seguridad de manera teórica. Finalmente, compartiremos algunas reflexiones sobre mitigación y soluciones.

0x1 Mecanismo del Token de Reflexión

Según nuestro conocimiento, este mecanismo fue introducido por primera vez por Reflect Finance, diseñado para distribuir un porcentaje del monto de la transacción como tarifas a todos los poseedores de tokens de forma no transaccional. En marzo de 2021, el reconocido SafeMoon V1 fue lanzado en la cadena BNB, lo que popularizó aún más el token de reflexión.

0x1.1 Conceptos Básicos

Antes de profundizar en los detalles, se deben introducir algunos conceptos fundamentales para lograr una mejor comprensión.

Existen dos tipos de espacios: r-espacio y t-espacio, leídos como espacio reflejado y espacio verdadero, respectivamente. Las criptomonedas de los dos espacios tienen tasas de cambio basadas en el volumen de circulación relativo. Además, la moneda en el r-espacio es deflacionaria, es decir, en cada transacción se quema un cierto porcentaje y, como resultado, el monto quemado se deduce del volumen de circulación.

Consideremos que Alice, Bob y Eve son capaces de realizar transacciones en ambos espacios, como se muestra en la figura siguiente. Si Alice y Bob realizan transferencias mutuas en el t-espacio, Eve no recibirá ninguna recompensa. Sin embargo, si los tres primero convierten sus tokens al r-espacio y luego dejan que Alice y Bob se transfieran entre sí, Eve eventualmente obtendrá ingresos pasivos al convertir sus tokens de vuelta al t-espacio. Esa es la idea básica del mecanismo del token de reflexión.

Tenga en cuenta que no todas las cuentas, como el pool de provisión de liquidez del token, son capaces de operar en el r-espacio, es decir, ciertas cuentas deben ser excluidas del r-espacio.

0x1.2 Explicación a Nivel de Contrato

Ahora profundicemos en el contrato REFLECT de Reflect Finance para explorar este mecanismo.

Este contrato primero define varias variables para la gestión de cuentas:

mapping (address => uint256) private _rOwned; // token reflejado en poder del usuario
mapping (address => uint256) private _tOwned; // token verdadero en poder del usuario
mapping (address => mapping (address => uint256)) private _allowances;

mapping (address => bool) private _isExcluded; // si el usuario está excluido del r-espacio
address[] private _excluded; // cuentas que están excluidas del r-espacio

Luego, define las constantes esenciales del contrato. Se puede observar que _rTotal se establece en un cierto múltiplo de _tTotal (es decir, el totalSupply del token, que se usa como valor de retorno de la función totalSupply):

uint256 private constant MAX = ~uint256(0);
uint256 private constant _tTotal = 10 * 10**6 * 10**9;
uint256 private _rTotal = (MAX - (MAX % _tTotal));

Desde la perspectiva de la funcionalidad y la interacción del usuario, las funciones de este contrato se dividen en las siguientes tres categorías: consulta de saldo y transferencia de tokens, junto con una función de reflexión distintiva. Las dos primeras son compatibles con el estándar ERC-20; sin embargo, la lógica interna difiere de otros tokens ERC-20. Cada una de estas funciones se detallará a continuación.

0x1.2.1 Funciones para la consulta de saldo

El cálculo del saldo difiere para usuarios excluidos y no excluidos:

function balanceOf(address account) public view override returns (uint256) {
    if (_isExcluded[account]) return _tOwned[account];
    return tokenFromReflection(_rOwned[account]);
}

function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
    require(rAmount <= _rTotal, "Amount must be less than total reflections");
    uint256 currentRate =  _getRate();
    return rAmount.div(currentRate);
}

Se puede expresar mediante la siguiente fórmula:

La tasa en la fórmula anterior se calcula invocando la función _getRate, que en realidad se calcula a partir del valor de retorno de la función _getCurrentSupply dentro del contrato.

function _getRate() private view returns(uint256) {
    (uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
    return rSupply.div(tSupply);
}

function _getCurrentSupply() private view returns(uint256, uint256) {
    uint256 rSupply = _rTotal;
    uint256 tSupply = _tTotal;      
    for (uint256 i = 0; i < _excluded.length; i++) {
        if (_rOwned[_excluded[i]] > rSupply || _tOwned[_excluded[i]] > tSupply) return (_rTotal, _tTotal);
        rSupply = rSupply.sub(_rOwned[_excluded[i]]);
        tSupply = tSupply.sub(_tOwned[_excluded[i]]);
    }
    if (rSupply < _rTotal.div(_tTotal)) return (_rTotal, _tTotal);
    return (rSupply, tSupply);
}

No es difícil derivar la fórmula correspondiente del fragmento de código anterior:

0x1.2.2 Funciones para la transferencia de tokens

En general, existen cuatro escenarios para transferir activos, como se describe a continuación:

function _transfer(address sender, address recipient, uint256 amount) private {
    require(sender != address(0), "ERC20: transfer from the zero address");
    require(recipient != address(0), "ERC20: transfer to the zero address");
    require(amount > 0, "Transfer amount must be greater than zero");
    if (_isExcluded[sender] && !_isExcluded[recipient]) {
        _transferFromExcluded(sender, recipient, amount); // t-espacio -> r-espacio
    } else if (!_isExcluded[sender] && _isExcluded[recipient]) {
        _transferToExcluded(sender, recipient, amount); // r-espacio -> t-espacio
    } else if (!_isExcluded[sender] && !_isExcluded[recipient]) {
        _transferStandard(sender, recipient, amount); // r-espacio -> r-espacio
    } else if (_isExcluded[sender] && _isExcluded[recipient]) {
        _transferBothExcluded(sender, recipient, amount); // t-espacio -> t-espacio
    } else {
        _transferStandard(sender, recipient, amount); // r-espacio -> r-espacio
    }
}

Para las cuentas excluidas, tanto _rOwned como _tOwned deben sumarse o restarse del espacio respectivo. Para las cuentas no excluidas, solo se necesita considerar _rOwned. Por ejemplo, el siguiente fragmento de código muestra la implementación de la transferencia de activos del r-espacio al t-espacio, donde sender es una cuenta no excluida y recipient es una cuenta excluida.

function _transferToExcluded(address sender, address recipient, uint256 tAmount) private {
    (uint256 rAmount, uint256 rTransferAmount, uint256 rFee, uint256 tTransferAmount, uint256 tFee) = _getValues(tAmount);
    _rOwned[sender] = _rOwned[sender].sub(rAmount);
    _tOwned[recipient] = _tOwned[recipient].add(tTransferAmount);
    _rOwned[recipient] = _rOwned[recipient].add(rTransferAmount);           
    _reflectFee(rFee, tFee);
    emit Transfer(sender, recipient, tTransferAmount);
}
  • La función _getValues calcula el monto correspondiente, el monto de transferencia y la tarifa (es decir, monto = montoTransferencia + tarifa) para ambos espacios.

    function _getValues(uint256 tAmount) private view returns (uint256, uint256, uint256, uint256, uint256) {
        (uint256 tTransferAmount, uint256 tFee) = _getTValues(tAmount);
        uint256 currentRate =  _getRate();
        (uint256 rAmount, uint256 rTransferAmount, uint256 rFee) = _getRValues(tAmount, tFee, currentRate);
        return (rAmount, rTransferAmount, rFee, tTransferAmount, tFee);
    }
  • La función _reflectFee refleja la tarifa en el r-espacio. Específicamente, _rTotal se reduce por la tarifa en el r-espacio (es decir, rFee), lo que a su vez reduce la tasa. De acuerdo con la fórmula de cálculo de balanceOf(usuario), las tarifas se reflejan a todos los poseedores de tokens no excluidos de esta manera.

    function _reflectFee(uint256 rFee, uint256 tFee) private {
        _rTotal = _rTotal.sub(rFee);
        _tFeeTotal = _tFeeTotal.add(tFee);
    }

0x1.2.3 La función reflect

Además del desencadenamiento pasivo del mecanismo del token de reflexión durante el proceso de transferencia, los usuarios pueden invocar activamente la función reflect para iniciar este mecanismo. Específicamente, si alguien consume los tokens que posee para invocar esta función, la tasa disminuirá a medida que rSupply disminuya, proporcionando así beneficios a otros poseedores de tokens. En otras palabras, sacrificarse por el bien de los demás. Al hacerlo, los mantenedores del proyecto pueden incentivar a los poseedores de tokens mediante el uso de esta función.

function reflect(uint256 tAmount) public {
    address sender = _msgSender();
    require(!_isExcluded[sender], "Excluded addresses cannot call this function");
    (uint256 rAmount,,,,,,) = _getValues(tAmount);
    _rOwned[sender] = _rOwned[sender].sub(rAmount);
    _rTotal = _rTotal.sub(rAmount);
    _tFeeTotal = _tFeeTotal.add(tAmount);
}

Los códigos proporcionados anteriormente forman el núcleo del mecanismo. Diferentes tokens pueden incorporar funciones adicionales específicas para adaptar su implementación. Por ejemplo, algunos pueden usar tarifas de transacción para impulsar una función de "swap and liquify" para evitar una estampida cuando las ballenas deciden vender sus tokens.

0x2 Análisis Post-Mortem de Tokens de Reflexión Comprometidos

Como se mencionó anteriormente, nuestro enfoque principal es en los ataques que explotan el mecanismo del token de reflexión. Por lo tanto, los incidentes no relacionados con este mecanismo, como el ataque a SafeMoon V2 (un problema común de quema pública ERC20) y el reciente ataque a ZongZi (relacionado con la manipulación de precios al estilo antiguo que explota el precio spot), no están cubiertos.

Hemos realizado un análisis en profundidad de estos ataques para desmitificar sus causas raíz. Puede consultar aquí para obtener una lista de todos estos incidentes. Encontramos que la mayoría de ellos son incidentes de seguridad normales causados por vulnerabilidades de código u operaciones administrativas inadecuadas. Sin embargo, algunos son bastante sospechosos (por ejemplo, la presencia de una puerta trasera), a los que nos referimos como incidentes de seguridad anormales. En las siguientes subsecciones, primero presentaremos los incidentes de seguridad normales y luego analizaremos los anormales en detalle.

0x2.1 Incidentes de seguridad normales

Nuestra investigación sugiere que estos incidentes provienen de dos tipos de problemas, es decir, problemas a nivel de código y problemas a nivel de operación, ya sea de forma individual o combinada.

  1. Problemas a nivel de código. Esto surge de la implementación deficiente del contrato, probablemente debido a que los desarrolladores no comprenden completamente el mecanismo del token de reflexión, lo que lleva a una inconsistencia entre el suministro real de tokens y el valor registrado de totalSupply, que puede usarse para manipular la tasa:

    • 1.1 Quema de costo cero

    • 1.2 Deducción adicional de rSupply durante las transferencias de tokens

    • 1.3 Confusión entre los valores del r-espacio y del t-espacio (con pérdida de precisión para obtener ganancias)

  2. Problemas a nivel de operación. Esto resulta de operaciones inadecuadas por parte de los administradores. Específicamente, en estos incidentes, esto se refiere a la configuración incorrecta de las direcciones del par AMM, que no están correctamente excluidas.

Vale la pena señalar que TODOS los problemas a nivel de código enumerados podrían conducir a inconsistencias entre el suministro real y totalSupply, haciendo que los contratos sean vulnerables. Sin embargo, esto no significa necesariamente que estas vulnerabilidades sean explotables o, más precisamente, lo suficientemente rentables como para valer la pena explotarlas, ya que podría haber un costo para que el atacante manipule la tasa. Para simplificar, en lo siguiente, usaremos el término "explotable" para significar "lo suficientemente rentable como para valer la pena explotar". Como resultado, un problema a nivel de operación en algunos escenarios es necesario para hacer que estas vulnerabilidades sean explotables.

Específicamente, el problema 1.1 puede explotarse directamente, mientras que los problemas 1.2 y 1.3 requieren una combinación con el problema 2 para volverse explotables. Por lo tanto, los incidentes en discusión pueden dividirse en dos tipos, Tipo-I y Tipo-II, basándose en estas observaciones. A continuación se muestra una tabla que describe los datos relevantes:

Tipo Incidente(s) Causa(s) Raíz # (%)
I CATOSHI Solo problema a nivel de código (1.1) 1 (0.79%)
II (a) BEVO, FETA, ADU Combinación de ambos (1.2 y 2) 3 (2.38%)
II (b) SHEEP, y más de 120 otros Combinación de ambos (1.3 y 2) 122 (96.83%)

La tabla revela que los incidentes de Tipo-II representan una proporción significativa. Específicamente, hay 2 variaciones dentro del Tipo-II: Tipo-II-a (es decir, el problema 1.2 con el problema 2) y Tipo-II-b (es decir, el problema 1.3 con el problema 2). Además, para los incidentes similares a SHEEP de la categoría Tipo-II-b, los exploits sugieren que los atacantes (por ejemplo, este) podrían estar usando métodos automatizados para identificar contratos similarmente vulnerables. Los detalles específicos se explorarán en las subsecciones siguientes.

0x2.1.1 Tipo-I: Solo Problema a Nivel de Código (Problema 1.1)

Solo un incidente pertenece al Tipo-I, es decir, el incidente CATOSHI, una quema de costo cero que afecta el suministro total.

Primero echemos un vistazo a la función burnOf en el contrato CATOSHI:

function burnOf(uint256 tAmount) public {
    uint256 currentRate = _getRate();
    uint256 rAmount = tAmount.mul(currentRate);
    _tTotal = _tTotal.sub(tAmount);
    _rTotal = _rTotal.sub(rAmount);
    emit Transfer(_msgSender(), address(0), tAmount);
}

Obviamente, el monto quemado por esta función no se deduce del llamador (es decir, msg.sender). Sin embargo, _rOwned[msg.sender] debería reducirse en rAmount, y si la cuenta está excluida, _tOwned[msg.sender] también debería reducirse en tAmount.

Debido a esta omisión, los atacantes pueden inicialmente quemar una gran cantidad de tokens a costo cero y luego invocar la función reflect del contrato. Dado que tanto _tTotal como _rTotal se han reducido significativamente de manera proporcional:

La tasa puede manipularse fácilmente hacia abajo invocando la función reflect, lo que hace que balanceOf(atacante) aumente sustancialmente. Esto permite a los atacantes obtener ganancias del saldo inflado.

¿Por qué? Tenga en cuenta que el nuevo saldo del atacante se calcula de la siguiente manera:

La relación entre balanceOf(atacante) y balanceOf(atacante)' es:

Como

Por lo tanto

Lo que significa que el atacante obtiene más tokens que pueden intercambiarse por tokens valiosos (WETH en este caso) para obtener ganancias.

0x2.1.2 Tipo-II-a: Combinación del Problema 1.2 y el Problema 2

Los incidentes de Tipo-II-a involucran la combinación de dos problemas:

  • Problema 1.2: Deducción adicional de rSupply durante las transferencias de tokens.
  • Problema 2: El par AMM no está excluido.

En el Tipo-II-a, hay tres incidentes de ataque, que pueden dividirse a su vez en dos subcategorías según las formas de vulnerabilidad en el problema 1.2, como se describe a continuación:

1. Reflexión adicional en la función _reflectFee

Dos incidentes pertenecen a esta subcategoría, es decir, el incidente BEVO y el incidente FETA. A continuación, usaremos el contrato BEVO para ilustración.

Como se introdujo en 'Funciones para la Transferencia de Tokens' (sección 0x1.2.2), cada transferencia de tokens desencadena la reflexión de una parte de la tarifa de transacción. En BEVO, las tarifas se dividen en dos partes adicionales: quema y caridad, además de la original.

function _reflectFee(uint256 rFee, uint256 rBurn, uint256 rCharity, uint256 tFee, uint256 tBurn, uint256 tCharity) private {
    _rTotal = _rTotal.sub(rFee).sub(rBurn).sub(rCharity); // rCharity se deduce de _rTotal
    _tFeeTotal = _tFeeTotal.add(tFee);
    _tBurnTotal = _tBurnTotal.add(tBurn);
    _tCharityTotal = _tCharityTotal.add(tCharity);
    _tTotal = _tTotal.sub(tBurn);
}

Tenga en cuenta que la cuenta de caridad está excluida, lo que significa que el monto enviado a esta cuenta se quema, como se muestra en la función _sendToCharity.

function _sendToCharity(uint256 tCharity, address sender) private {
    uint256 currentRate = _getRate();
    uint256 rCharity = tCharity.mul(currentRate);
    address currentCharity = _charity[0];
    _rOwned[currentCharity] = _rOwned[currentCharity].add(rCharity);
    _tOwned[currentCharity] = _tOwned[currentCharity].add(tCharity); // dado que la cuenta de caridad está excluida, la parte de caridad se quema
    emit Transfer(sender, currentCharity, tCharity);
}

Del fragmento de código anterior, podemos ver que hay dos lugares para reflejar y quemar la parte de caridad, lo que hace que el suministro real de tokens se vuelva inconsistente con el totalSupply durante las transferencias. A medida que se transfieren más tokens, el valor de rSupply será menor que el suministro de tokens en el pool debido a la disminución adicional.

La descripción puramente teórica puede ser un poco abstracta, así que usemos un ejemplo para aclarar el proceso. Supongamos que Alice quiere transferir 10 tokens a Bob, y se deducen 3 tokens de la siguiente manera: 1 para la tarifa, 1 para la quema y 1 para la caridad. Dado que la parte de caridad se refleja y quema, el desglose real es 2 tokens reflejados (1 tarifa + 1 caridad) y 2 tokens quemados (1 quema + 1 caridad). Junto con los 7 tokens restantes a transferir a Bob, un total de 11 tokens están involucrados en este proceso, lo cual es erróneo.

Pero, ¿por qué se puede explotar esta inconsistencia para obtener ganancias? A continuación, agitaremos nuestra varita mágica matemática para derivar las consecuencias.

Supongamos que previamente hemos adquirido algunos tokens del pool (es decir, el par de PancakeSwap), denominados como rAmount en el r-espacio y tAmount en el t-espacio. Dado que el pool no ha sido excluido, denotemos _rOwned[par] como rReserva, con el valor correspondiente en el t-espacio también denominado como tReserva. Entonces tenemos:

Debido a la disminución adicional, rSupply es ahora menor que el suministro de tokens en el pool:

Recordando la sección 'Funciones para la Consulta de Saldo' (0x1.2.1), la tasa actual se puede calcular usando la siguiente fórmula:

En este momento, si reflejamos los tokens que poseemos a través de la función reflect (que se renombra como función deliver en este contrato), la tasa se convierte en tasa':

Como

Entonces tenemos

Combinando las fórmulas 1, 3 y 6, podemos derivar la siguiente desigualdad:

Esto significa que la cantidad de tokens que podemos cosechar directamente del pool (a través de la función skim) es incluso mayor que lo que hemos entregado, lo que la hace rentable porque el costo de invocar la función reflect puede cubrirse. Después de eso, el atacante puede intercambiar los tokens cosechados por tokens valiosos (WBNB en este caso) para obtener ganancias.

Tenga en cuenta que el contrato BEVO también es vulnerable al problema 1.3, que no fue explotado en el ataque.

2. Cálculo incorrecto de rTransferAmount en la función _getRValues

Solo un incidente pertenece a esta forma, es decir, el incidente ADU. Primero echemos un vistazo al siguiente fragmento de código.

function _transferStandard(address sender, address recipient, uint256 tAmount) private {
    (uint256 rAmount, uint256 rTransferAmount, uint256 rFee, uint256 tTransferAmount, uint256 tFee, uint256 tTeam) = _getValues(tAmount);
    _rOwned[sender] = _rOwned[sender].sub(rAmount);
    _rOwned[recipient] = _rOwned[recipient].add(rTransferAmount);
    _takeTeam(tTeam);
    _reflectFee(rFee, tFee);
    emit Transfer(sender, recipient, tTransferAmount);
}

function _getValues(uint256 tAmount) private view returns (uint256, uint256, uint256, uint256, uint256, uint256) {
    (uint256 tTransferAmount, uint256 tFee, uint256 tTeam) = _getTValues(tAmount, _taxFee, _teamFee);
    uint256 currentRate =  _getRate();
    (uint256 rAmount, uint256 rTransferAmount, uint256 rFee) = _getRValues(tAmount, tFee, currentRate);
    return (rAmount, rTransferAmount, rFee, tTransferAmount, tFee, tTeam);
}

function _getTValues(uint256 tAmount, uint256 taxFee, uint256 teamFee) private pure returns (uint256, uint256, uint256) {
    uint256 tFee = tAmount.mul(taxFee).div(100);
    uint256 tTeam = tAmount.mul(teamFee).div(100);
    uint256 tTransferAmount = tAmount.sub(tFee).sub(tTeam); // tTeam se deduce de tAmount
    return (tTransferAmount, tFee, tTeam);
}

function _getRValues(uint256 tAmount, uint256 tFee, uint256 currentRate) private pure returns (uint256, uint256, uint256) {
    uint256 rAmount = tAmount.mul(currentRate);
    uint256 rFee = tFee.mul(currentRate);
    uint256 rTransferAmount = rAmount.sub(rFee); // Sin embargo, no se deduce rTeam de rAmount
    return (rAmount, rTransferAmount, rFee);
}

Podemos ver que tanto la tarifa de impuestos como la tarifa del equipo deben deducirse durante las transferencias. Sin embargo, en la función _getTvalues, el tTransferAmount se resta tanto por tFee como por tTeam, mientras que en la función _getRValues, solo se resta rFee. Esta discrepancia conduce al problema de inconsistencia mencionado anteriormente, que empeora a medida que ocurren más transferencias de tokens.

Dado que el par tampoco está excluido en el token, este token es explotable. Específicamente, un atacante podría usar exploits similares a BEVO para cosechar más tokens ADU a través de la función skim del par después de llamar a la función deliver.

Sin embargo, dado el estado en cadena en ese momento, es imposible para el atacante intercambiar los tokens ADU cosechados por WBNB para obtener ganancias (debido a la declaración require en la función tokenFromReflection). Por lo tanto, el atacante necesitaría emplear una estrategia de explotación más compleja para obtener ganancias, que no se detallará aquí.

function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
    require(rAmount <= _rTotal, "Amount must be less than total reflections");
    uint256 currentRate =  _getRate();
    return rAmount.div(currentRate);
}

0x2.1.3 Tipo-II-b: Combinación del Problema 1.3 y el Problema 2

Los incidentes de Tipo-II-b involucran la combinación de dos problemas:

  • Problema 1.3: Confusión entre los valores del r-espacio y del t-espacio (notando que también debe haber pérdida de precisión para obtener ganancias).
  • Problema 2: El par AMM no está excluido.

El problema 1.3 surge del manejo incorrecto de los valores entre el r-espacio y el t-espacio durante la implementación de la función interna _burn.

function burn(uint256 _value) public {
    _burn(msg.sender, _value);
}

function _burn(address _who, uint256 _value) internal {
    require(_value <= _rOwned[_who]);
    _rOwned[_who] = _rOwned[_who].sub(_value); // _rOwned debería restar un valor del r-espacio
    _tTotal = _tTotal.sub(_value); // _tTotal debería restar un valor del t-espacio
    // Para la semántica de la función burn, _rTotal también debería restarse de un valor del r-espacio.
    emit Transfer(_who, address(0), _value);
}

Considerando las constantes esenciales del contrato, el valor del r-espacio es típicamente un múltiplo grande del valor del t-espacio. Por lo tanto, invocar la función burn con un _value que sea del mismo orden de magnitud que tSupply inflará significativamente la tasa.

Sin embargo, a diferencia de los casos de Tipo-I, el llamador no puede quemar tokens mientras mantiene su propio saldo sin cambios. En otras palabras, es difícil, si no imposible, para el atacante cosechar más tokens. Por lo tanto, ¿cómo podrían ser explotables los casos de Tipo-II-b?

Tomando el incidente del token SHEEP como ejemplo. El valor de los tokens SHEEP en poder del atacante puede denotarse como:

Donde el precio de SHEEP puede expresarse mediante el precio spot en el par de PancakeSwap, calculado como:

Entonces el Valor puede expresarse a su vez como:

El atacante luego ejecuta repetidamente la función burn y finalmente sincroniza el par. Dado que ni el atacante ni el par están excluidos, sus saldos disminuyen debido a la inflación de la tasa que mencionamos anteriormente. Por lo tanto, la relación se ajusta a:

Basándonos en las definiciones anteriores, podemos expresar además estas relaciones como:

Donde X representa la suma de _value quemados.

Aquí viene la magia: la relación posterior es claramente menor que la anterior si simplificamos 8 y 9 más, lo que nos deja preguntándonos porque la ganancia sería un valor negativo en este caso:

En realidad, el atacante aprovechó un problema de pérdida de precisión en el token de reflexión. Para los usuarios no excluidos, según la fórmula que hemos proporcionado, el cálculo del saldo en realidad se redondea hacia abajo en la función tokenFromReflection. Por lo tanto, el valor de retorno de la consulta balanceOf puede ser menor que su valor teórico. Es decir, la relación' puede ser mayor que la relación si tenemos en cuenta este problema.

function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
    require(rAmount <= _rTotal, "Amount must be less than total reflections");
    uint256 currentRate =  _getRate();
    return rAmount.div(currentRate);
}

Depurando la transacción de ataque, podemos calcular el saldo teórico del atacante y el par antes y después de esas manipulaciones. Los resultados de nuestros cálculos se describen en la tabla a continuación:

Δ en la tabla es un valor extremadamente pequeño, mucho menor que 1.

Mediante el análisis del rastro dentro de la función de sincronización del par, podemos calcular que los saldos teóricos del atacante y del par son en realidad 27.523 y 2.972, respectivamente, resultando en una relación de 9.26. Sin embargo, debido a la pérdida de precisión, los saldos se redondean hacia abajo a 27 y 2, respectivamente, inflando la relación a 13.50. Como resultado, la Ganancia se convierte en un valor positivo.

Finalmente, el atacante puede obtener ganancias realizando un intercambio invertido.

0x2.2 Incidentes de seguridad anormales

En esta subsección, compartiremos nuestros hallazgos de la investigación de los tokens FDP y DBALL. Nuestro análisis indica que los gestores de ambos tokens FDP y DBALL invocaron funciones privilegiadas problemáticas, actuando efectivamente como puertas traseras, lo que puso en riesgo los proyectos y finalmente condujo a ataques. Específicamente, en el proyecto DBALL, identificamos una serie de transacciones sospechosas por parte del propietario del token, que proporcionan evidencia clara para considerarlo un rug pull.

Las explotaciones dirigidas a estos dos tokens se asemejan estrechamente a las discutidas en la sección 'Tipo-II-a: Combinación del Problema 1.2 y el Problema 2' descrita en 0x2.1.2. Sin embargo, al analizar las razones por las que el suministro real de tokens puede divergir de totalSupply, surgen algunas actividades sospechosas.

0x2.2.1 El incidente FDP

La discrepancia entre el suministro real de tokens y totalSupply en el caso FDP proviene de la invocación de la función transferOwnership, una función privilegiada que solo puede ser invocada por el propietario del contrato. Como su nombre lo indica, se supone que esta función altera la propiedad del contrato. Sin embargo, en el contrato FDP, esta función no tiene nada que ver con la transferencia de propiedad. En cambio, incrementa _rOwned[newOwner] sin alterar totalSupply. Esto viola claramente los principios de diseño del proceso normal de acuñación de tokens.

function transferOwnership(address newOwner) public virtual onlyOwner {
    (, uint256 rTransferAmount,, uint256 tTransferAmount,,) = _getValues(_tTotal);
    _rOwned[newOwner] = _rOwned[newOwner].add(rTransferAmount);       
    emit Transfer(address(0), newOwner, tTransferAmount);
}

Las transacciones que llamaron a esta función se resumen en la siguiente tabla:

0x2.2.2 El incidente DBALL

Las cosas se complican en el caso DBALL. Usando MetaSleuth para analizar el flujo de fondos del propietario de DBALL, observamos un desequilibrio en la entrada y salida de tokens DBALL a esta dirección, con la fuente de fondos de esta transacción sin estar registrada.

Consultando los estados históricos en cadena, finalmente identificamos que el saldo DBALL del propietario cambió antes y después de esta transacción. Podemos observar que el propietario llamó a la función privilegiada manualDevBurn para quemar 1 token en el t-espacio. La implementación de esta función es la siguiente:

function manualDevBurn (uint256 tAmount) public onlyOwner() {  
    uint256 rAmount = tAmount.mul(_getRate());
    if (_isExcluded[_msgSender()]) {
        _tOwned[_msgSender()] = _tOwned[_msgSender()] - (tAmount);
    }
    _rOwned[_msgSender()] = _rOwned[_msgSender()] - (rAmount);
    
    _tOwned[address(0)] = _tOwned[address(0)] + (tAmount);
    _rOwned[address(0)] = _rOwned[address(0)] + (rAmount);
    
    emit Transfer(_msgSender(), address(0), tAmount);
}

A primera vista, todo parece estar bien. Sin embargo, debido a que el contrato especifica una versión de compilador inferior a 0.8, ocurre un desbordamiento aritmético durante la resta de _rOwned[_msgSender()], transitando de 0 a casi type(uint256).max. Esta sutil manipulación permite al propietario alterar su saldo, pero también resulta en una inconsistencia en el suministro de tokens.

¿Es solo un error accidental? Nuestra investigación sugiere que es más probable un rug pull intencional. Las razones se resumen a continuación:

  1. El propietario pasó solo 1 token a la función manualDevBurn, pero dentro de media hora, una cantidad de DBALL igual al suministro total fue transferida a una dirección asociada a través de esta transacción.

  2. Esa dirección asociada inmediatamente intercambió en el par de PancakeSwap y obtuvo aproximadamente 56 WBNB.

  1. Analizar los flujos de fondos de estas dos direcciones revela que ambas eventualmente transfirieron BNB a través de Tornado.Cash.

0x3 Un Problema Potencial en el Cálculo de la Tasa

Más allá de los incidentes que hemos discutido anteriormente, también encontramos que, teóricamente, existe un problema potencial en el cálculo del saldo de los usuarios no excluidos que merece mayor discusión. Este problema puede surgir durante el cálculo de la tasa.

Echemos un vistazo a la función _getCurrentSupply. En esta función, la declaración if al final determina si rSupply es menor que la tasa inicial (es decir, _rTotal / _tTotal).

function _getRate() private view returns(uint256) {
    (uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
    return rSupply.div(tSupply);
}

function _getCurrentSupply() private view returns(uint256, uint256) {
    uint256 rSupply = _rTotal;
    uint256 tSupply = _tTotal;      
    for (uint256 i = 0; i < _excluded.length; i++) {
        if (_rOwned[_excluded[i]] > rSupply || _tOwned[_excluded[i]] > tSupply) return (_rTotal, _tTotal);
        rSupply = rSupply.sub(_rOwned[_excluded[i]]);
        tSupply = tSupply.sub(_tOwned[_excluded[i]]);
    }
    if (rSupply < _rTotal.div(_tTotal)) return (_rTotal, _tTotal);
    return (rSupply, tSupply);
}

Durante el ciclo de vida desde el despliegue del contrato hasta el lanzamiento del proyecto, si esta declaración es verdadera, los saldos de todos los usuarios no excluidos serían cero. Sin embargo, una vez que el proyecto fue lanzado y las transacciones comenzaron, la intención original de la declaración if se perdió.

Dado que rSupply disminuirá debido al mecanismo del token de reflexión, la tasa disminuirá en consecuencia. Si, después de cierta transacción, rSupply cae por debajo de la tasa inicial, la tasa actual saltará, resultando en pérdidas de saldo para todos los usuarios no excluidos. Además, es teóricamente posible que

Esto hace que la tasa se vuelva cero debido a la pérdida de precisión, potencialmente desencadenando un pánico de división por cero.

0x4 Mitigación y Soluciones

El mecanismo del token de reflexión ofrece una forma de mejorar la estabilidad del mercado al incentivar a los inversores a mantener sus tokens en lugar de intercambiarlos para recibir recompensas adicionales. Sin embargo, también introduce nuevos desafíos de seguridad y riesgos potenciales, como la confusión entre los valores del r-espacio y del t-espacio. Por lo tanto, es crucial que los desarrolladores de blockchain e inversores obtengan una mejor comprensión del mecanismo y sus riesgos potenciales, y busquen soluciones.

BlockSec proporciona servicios y productos de seguridad tanto para las etapas previas como posteriores al lanzamiento. Nuestros servicios de auditoría de seguridad realizan revisiones exhaustivas para garantizar la seguridad y transparencia del código. Nuestro producto Phalcon ofrece capacidades continuas de monitoreo de seguridad y detección de ataques, lo que permite a los operadores e inversores monitorear proyectos y tomar acciones automáticas cuando se detectan riesgos de seguridad.

Lecturas Relacionadas


Acerca de BlockSec

BlockSec es un proveedor de servicios de seguridad Web3 de pila completa. La empresa está comprometida a mejorar la seguridad y usabilidad para el emergente mundo Web3 con el fin de facilitar su adopción masiva. Con este fin, BlockSec proporciona servicios de auditoría de seguridad de contratos inteligentes y cadenas EVM, la plataforma Phalcon para el desarrollo de seguridad y el bloqueo proactivo de amenazas, la plataforma MetaSleuth para el rastreo e investigación de fondos, y la extensión MetaSuites para que los constructores de web3 naveguen eficientemente en el mundo cripto.

Hasta la fecha, la empresa ha prestado servicios a más de 300 clientes como Uniswap Foundation, Compound, Forta y PancakeSwap, y ha recibido decenas de millones de dólares estadounidenses en dos rondas de financiación de inversores preeminentes, incluyendo Matrix Partners, Vitalbridge Capital y Fenbushi Capital.