Volver al blog

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

Phalcon
6 de junio de 2024
28 min de lectura

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

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 obtener ingresos. Esto anima a los inversores a mantener sus tokens en lugar de negociarlos. Durante la infame temporada de meme coins de 2021, los tokens de reflexión se convirtieron en un mecanismo indispensable, capturando rápidamente la atención del mercado tras ser lanzados en plataformas como DxSale (por ejemplo, SafeMoon V1).

A pesar de que el frenesí 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 el mundo real. Estos ataques de estilo "reaper", aunque relativamente pequeños en escala comparados con otros tipos de ataques DeFi, resultaron en pérdidas no despreciables de activos de usuarios.

En este blog, nuestro enfoque principal es compartir conocimientos relacionados con la seguridad de nuestra investigación. Específicamente, primero proporcionaremos una breve introducción al mecanismo del token de reflexión. A continuación, revisaremos los incidentes de seguridad relacionados con los tokens de reflexión, centrándonos 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 comisiones a todos los poseedores de tokens de manera 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.

Hay dos tipos de espacios: r-space y t-space, que se leen como espacio reflejado y espacio verdadero, respectivamente. Las criptomonedas de ambos espacios tienen tasas de cambio basadas en el volumen de circulación relativo. Además, la moneda en r-space 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.

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

Nótese que no todas las cuentas, como el fondo de liquidez del token, son capaces de negociar en r-space, es decir, ciertas cuentas necesitan ser excluidas de r-space.

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; // reflected token held by user
mapping (address => uint256) private _tOwned; // true token held by user
mapping (address => mapping (address => uint256)) private _allowances;

mapping (address => bool) private _isExcluded; // if user is excluded from r-space
address[] private _excluded; // accounts that are excluded from r-space

Luego, define las constantes esenciales del contrato. Se puede observar que _rTotal se establece en un múltiplo determinado 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 distintiva de reflect. Las dos primeras son compatibles con el estándar ERC-20; sin embargo, la lógica interna varía respecto a otros tokens ERC-20. Cada una de estas funciones se elaborará a continuación.

0x1.2.1 Funciones para 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 con la siguiente fórmula:

El rate (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 a partir del fragmento de código anterior:

0x1.2.2 Funciones para transferencia de tokens

En general, hay cuatro escenarios para transferir activos, de la siguiente manera:

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-space -> r-space
    } else if (!_isExcluded[sender] && _isExcluded[recipient]) {
        _transferToExcluded(sender, recipient, amount); // r-space -> t-space
    } else if (!_isExcluded[sender] && !_isExcluded[recipient]) {
        _transferStandard(sender, recipient, amount); // r-space -> r-space
    } else if (_isExcluded[sender] && _isExcluded[recipient]) {
        _transferBothExcluded(sender, recipient, amount); // t-space -> t-space
    } else {
        _transferStandard(sender, recipient, amount); // r-space -> r-space
    }
}

Para cuentas excluidas, tanto _rOwned como _tOwned deben sumarse o restarse del espacio respectivo. Para 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 de r-space a t-space, 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, transferAmount y fee (comisión) correspondientes (es decir, amount = transferAmount + fee) 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 comisión en r-space. Específicamente, _rTotal se reduce por la comisión en r-space (es decir, rFee), lo que a su vez reduce el rate. Según la fórmula de cálculo de balanceOf(user), las comisiones 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 disparo 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 uno consume los tokens que posee para invocar esta función, el rate disminuirá a medida que rSupply disminuye, 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 la utilización 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 las comisiones de transacción para potenciar una función de "swap and liquify" para evitar estampidas cuando las ballenas decidan vender sus tokens.

0x2 Post-Mortem de los Tokens de Reflexión Rekt

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

Hemos realizado un análisis en profundidad de estos ataques para desmitificar sus causas raíz. Puedes consultar aquí una lista de todos estos incidentes. Descubrimos que la mayoría de ellos son incidentes de seguridad normales causados ​​por vulnerabilidades de código o 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 repasaremos en detalle los anormales.

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 individualmente o en combinación.

  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 el rate:

    • 1.1 Quema de costo cero

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

    • 1.3 Confusión entre los valores de r-space y t-space (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 llevar a inconsistencias entre el suministro real y totalSupply, haciendo vulnerables a los contratos. Sin embargo, esto no necesariamente significa que estas vulnerabilidades sean explotables o, más precisamente, lo suficientemente rentables como para valer la pena explotarlas, ya que puede haber un costo para el atacante al manipular el rate. Para simplificar, en lo siguiente, usaremos el término "explotable" para significar "lo suficientemente rentable como para valer la pena explotarlo". 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 ser explotado 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 discutidos pueden dividirse en dos tipos, Tipo-I y Tipo-II, según estas observaciones. A continuación se muestra una tabla con 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 & 2) 3 (2.38%)
II (b) SHEEP, y 120+ Otros Combinación de ambos (1.3 & 2) 122 (96.83%)

La tabla revela que los incidentes de Tipo-II representan una proporción significativa. Específicamente, hay 2 variaciones dentro de Tipo-II: Tipo-II-a (es decir, problema 1.2 con problema 2) y Tipo-II-b (es decir, problema 1.3 con problema 2). Además, para 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 vulnerables similares. 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 de CATOSHI, una quema de costo cero que afecta al 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 este descuido, 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:

El rate puede manipularse fácilmente a la baja invocando la función reflect, lo que hace que balanceOf(attacker) aumente sustancialmente. Esto permite que los atacantes se beneficien del saldo inflado.

¿Por qué? Nótese que el nuevo saldo del atacante se calcula de la siguiente manera:

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

Como

Por lo tanto

Lo que significa que el atacante cosecha 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 implican la combinación de dos problemas:

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

En Tipo-II-a, hay tres incidentes de ataque, que pueden dividirse aún más en dos subcategorías según las formas de vulnerabilidad en el problema 1.2, de la siguiente manera:

1. Reflect extra en la función _reflectFee

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

Como se presentó en 'Funciones para Transferencia de Tokens' (sección 0x1.2.2), cada transferencia de tokens activa el reflect de una parte de la comisión de transacción. En BEVO, las comisiones se dividen en dos partes adicionales: burn (quema) y charity (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); // rChairty is deducted from _rTotal
    _tFeeTotal = _tFeeTotal.add(tFee);
    _tBurnTotal = _tBurnTotal.add(tBurn);
    _tCharityTotal = _tCharityTotal.add(tCharity);
    _tTotal = _tTotal.sub(tBurn);
}

Nótese 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); // since charity account is excluded, the charity part is burned
    emit Transfer(sender, currentCharity, tCharity);
}

Del fragmento de código anterior, podemos ver que hay dos lugares donde se refleja y se quema la porción de caridad, causando que el suministro real de tokens se vuelva inconsistente con 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 resultar un poco abstracta, así que usemos un ejemplo para clarificar el proceso. Supongamos que Alice quiere transferir 10 tokens a Bob, y se deducen 3 tokens de la siguiente manera: 1 por la comisión, 1 por la quema y 1 por la caridad. Dado que la porción de caridad se refleja y se quema, el desglose real es 2 tokens reflejados (1 fee + 1 charity) y 2 tokens quemados (1 burn + 1 charity). Junto con los 7 tokens restantes que se transferirán a Bob, se involucran un total de 11 tokens en este proceso, lo cual es defectuoso.

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 hemos adquirido previamente algunos tokens del pool (es decir, el par de PancakeSwap), denotados como rAmount en r-space y tAmount en t-space. Dado que el pool no ha sido excluido, denotemos _rOwned[pair] como rReserve, con el valor correspondiente en t-space también denotado como tReserve. Entonces tenemos:

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

Recordemos la sección 'Funciones para Consulta de Saldo' (0x1.2.1), el rate actual puede calcularse 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 la función deliver en este contrato), el rate se convierte en rate':

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 lo 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.

Nótese 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 de 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 is deducted from 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); // However, there is no rTeam deducted from rAmount
    return (rAmount, rTransferAmount, rFee);
}

Podemos ver que tanto la comisión de impuesto (tax fee) como la comisión de equipo (team fee) deberían deducirse durante las transferencias. Sin embargo, en la función _getTvalues, tTransferAmount se resta tanto de tFee como de tTeam, mientras que en la función _getRValues, solo se resta rFee. Esta discrepancia lleva al problema de inconsistencia mencionado anteriormente, que se agrava 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 los de 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 que el atacante intercambie 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, la cual 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 implican la combinación de dos problemas:

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

El problema 1.3 proviene del manejo incorrecto de valores entre r-space y t-space 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 should be minus a r-space value
    _tTotal = _tTotal.sub(_value); // _tTotal should be minus a t-space value
    // For the semantics of the burn function, _rTotal should also be subtracted from a r-space value.
    emit Transfer(_who, address(0), _value);
}

Considerando las constantes esenciales del contrato, el valor en r-space suele ser un múltiplo grande del valor en t-space. Por lo tanto, invocar la función burn con un _value que sea del mismo orden de magnitud que tSupply inflará significativamente el rate.

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

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

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

Entonces el Value puede expresarse aún más como:

El atacante luego ejecuta repetidamente la función burn y eventualmente sincroniza el par. Dado que ni el atacante ni el par están excluidos, sus saldos disminuyen debido a la inflación del rate que mencionamos anteriormente. Así, la relación se ajusta a:

Basándonos en las definiciones anteriores, podemos expresar aún más estas relaciones como:

Donde X representa la suma del _value quemado.

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

De hecho, el atacante aprovechó un problema de pérdida de precisión en el token de reflexión. Para usuarios no excluidos, según la fórmula que hemos proporcionado, el cálculo del saldo 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 ratio' puede ser mayor que la ratio si consideramos 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);
}

Al depurar 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 siguiente tabla:

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

Al analizar el trace dentro de la función sync del par, podemos calcular que los saldos teóricos del atacante y el par son en realidad 27.523 y 2.972, respectivamente, lo que resulta 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 Profit (ganancia) se vuelve un valor positivo.

Finalmente, el atacante puede obtener ganancias realizando un swap inverso.

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 administradores de los tokens FDP y DBALL invocaron funciones privilegiadas problemáticas, que actuaron efectivamente como puertas traseras (backdoors), lo que puso en riesgo los proyectos y finalmente llevó 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 como un rug pull.

Las explotaciones dirigidas a estos dos tokens se parecen mucho 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, salen a la luz algunas actividades sospechosas.

0x2.2.1 El incidente de FDP

La discrepancia entre el suministro real de tokens y totalSupply en el caso de 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 sugiere el nombre, 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, aumenta _rOwned[newOwner] sin alterar totalSupply. Esto claramente viola 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 de DBALL

Las cosas se complican en el caso de DBALL. Usando MetaSleuth para analizar el flujo de fondos de el propietario de DBALL, observamos un desequilibrio en la entrada y salida de tokens DBALL a esta dirección, con el origen de los fondos de esta transacción no registrado.

Al consultar los estados históricos en la cadena, finalmente identificamos que el saldo de 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 t-space. 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 del compilador inferior a 0.8, ocurre un desbordamiento aritmético (underflow) durante la resta de _rOwned[_msgSender()], pasando 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 que se trate de un rug pull intencional. Las razones se resumen a continuación:

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

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

  1. Al analizar los flujos de fondos de estas dos direcciones, se revela que ambas finalmente transfirieron BNB a través de Tornado.Cash.

0x3 Un Problema Potencial en el Cálculo del rate

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

Echemos un vistazo a la función _getCurrentSupply. En esta función, la declaración if final determina si rSupply es menor que el rate 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 la implementación 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 se lanzó y comenzaron las transacciones, la intención original de la declaración if se perdió.

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

Esto hace que el rate se convierta en cero debido a la pérdida de precisión, lo que podría provocar un pánico de división por cero.

0x4 Mitigación y Soluciones

El mecanismo del token de reflexión ofrece una manera de mejorar la estabilidad del mercado al incentivar a los inversores a mantener sus tokens en lugar de negociarlos para recibir recompensas adicionales. Sin embargo, también introduce nuevos desafíos de seguridad y riesgos potenciales, como la confusión entre los valores de r-space y t-space. Por lo tanto, es crucial que los desarrolladores de blockchain y los inversores comprendan mejor el 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 de monitoreo de seguridad continuo y detección de ataques, permitiendo 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 se compromete a mejorar la seguridad y la usabilidad para el mundo emergente de 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 seguimiento e investigación de fondos, y la extensión MetaSuites para que los desarrolladores de web3 naveguen eficientemente en el mundo cripto.

Hasta la fecha, la empresa ha atendido a más de 1,000 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.