Este artículo sirve como un minicurso sobre seguridad de contratos inteligentes y proporciona una lista extensa de los problemas y vulnerabilidades que tienden a repetirse en los contratos inteligentes de Solidity. Estos son el tipo de problemas que pueden surgir en una auditoría de calidad.
Un problema de seguridad en Solidity se reduce a que los contratos inteligentes no se comportan como deberían.
Fondos siendo robados
Fondos bloqueados o congelados dentro de un contrato
Las personas reciben menos recompensas de lo previsto (las recompensas se retrasan o reducen)
Las personas reciben más recompensas de las previstas (lo que genera inflación y devaluación)
No es posible hacer una lista exhaustiva de todo lo que puede salir mal. Sin embargo, así como la ingeniería de software tradicional tiene temas comunes de vulnerabilidades como inyección de SQL, desbordamiento de búfer y secuencias de comandos entre sitios, los contratos inteligentes tienen antipatrones recurrentes que se pueden documentar.
Piense en esta guía más como una referencia. No es posible discutir todo el concepto en detalle sin convertir esto en un libro (advertencia justa: este artículo tiene más de 10k palabras, así que siéntase libre de marcarlo y leerlo en fragmentos). Sin embargo, sirve como una lista de qué buscar y qué estudiar. Si un tema se siente desconocido, eso debería servir como un indicador de que vale la pena dedicar tiempo a practicar la identificación de esa clase de vulnerabilidad.
Este artículo asume una competencia básica en Solidity . Si es nuevo en Solidity, consulte nuestro tutorial gratuito de Solidity .
Hemos escrito extensamente sobre el reingreso de contratos inteligentes , por lo que no lo repetiremos aquí. Pero aquí hay un resumen rápido:
Cada vez que un contrato inteligente llama a la función de otro contrato inteligente, le envía Ether o le transfiere un token, existe la posibilidad de reingreso.
A pesar de que la reentrada probablemente sea la vulnerabilidad de contrato inteligente más conocida, solo constituye un pequeño porcentaje de los ataques que ocurren en la naturaleza. El investigador de seguridad Pascal Caversaccio (pcaveraccio) mantiene una lista actualizada de github de ataques de reentrada . Hasta abril de 2023, se han documentado 46 ataques de reingreso en ese repositorio.
Parece un simple error, pero olvidarse de imponer restricciones sobre quién puede llamar a una función sensible (como retirar ether o cambiar de propietario) sucede con sorprendente frecuencia.
Incluso si hay un modificador en su lugar, ha habido casos en los que el modificador no se implementó correctamente, como en el ejemplo a continuación, donde falta la instrucción require.
// DO NOT USE! modifier onlyMinter { minters[msg.sender] == true_; }
Este código anterior es un ejemplo real de esta auditoría: https://code4rena.com/reports/2023-01-rabbithole/#h-01-bad-implementation-in-minter-access-control-for-rabbitholereceipt-and- RabbitholeTickets-contratos
Aquí hay otra forma en que el control de acceso puede salir mal
function claimAirdrop(bytes32 calldata proof[]) { bool verified = MerkleProof.verifyCalldata(proof, merkleRoot, keccak256(abi.encode(msg.sender))); require(verified, "not verified"); require(alreadyClaimed[msg.sender], "already claimed"); _transfer(msg.sender, AIRDROP_AMOUNT); }
En este caso, "ya reclamado" nunca se establece en verdadero, por lo que el reclamante puede llamar a la función varias veces.
Un ejemplo bastante reciente de control de acceso insuficiente fue una función desprotegida para recibir préstamos flash por parte de un robot comercial (que se llamaba 0xbad, ya que la dirección comenzaba con esa secuencia). Acumuló más de un millón de dólares en ganancias hasta que un día un atacante notó que cualquier dirección podía llamar a la función de recepción de flashloan, no solo al proveedor de flashloan.
Como suele ser el caso con los bots comerciales, el código de contrato inteligente para ejecutar los intercambios no se verificó, pero el atacante descubrió la debilidad de todos modos. Más información en la cobertura de noticias de rekt .
Si el control de acceso se trata de controlar quién llama a una función, la validación de entrada se trata de controlar con qué llama el contrato.
Por lo general, esto se reduce a olvidarse de poner en su lugar las instrucciones requeridas adecuadas.
He aquí un ejemplo rudimentario:
contract UnsafeBank { mapping(address => uint256) public balances; // allow depositing on other's behalf function deposit(address for) public payable { balances += msg.value; } function withdraw(address from, uint256 amount) public { require(balances[from] <= amount, "insufficient balance"); balances[from] -= amount; msg.sender.call{value: amout}(""); } }
El contrato anterior verifica que no esté retirando más de lo que tiene en su cuenta, pero no le impide retirar dinero de una cuenta arbitraria.
Sushiswap experimentó un hackeo de este tipo debido a que uno de los parámetros de una función externa no se desinfectó.
El control de acceso inadecuado significa que msg.sender no tiene restricciones adecuadas. La validación de entrada incorrecta significa que los argumentos de la función no están suficientemente saneados. También hay un inverso a este antipatrón: colocar demasiadas restricciones en una llamada de función.
La validación excesiva probablemente signifique que los fondos no serán robados, pero podría significar que los fondos queden bloqueados en el contrato. Tener demasiadas medidas de seguridad tampoco es bueno.
Uno de los incidentes de más alto perfil fue el Akutars NFT que terminó con 34 millones de dólares en Eth atrapados dentro del contrato inteligente y no extraíble.
El contrato tenía un mecanismo bien intencionado para evitar que el propietario del contrato se retirara hasta que se hubieran dado todos los reembolsos por pagar por encima del precio de la subasta holandesa. Pero debido a un error documentado en el hilo de Twitter vinculado a continuación, el propietario no pudo retirar los fondos.
Sushiswap le dio demasiado poder a los usuarios que no eran de confianza, y Akutars NFT le dio muy poco poder al administrador. Al diseñar contratos inteligentes, se debe hacer un juicio subjetivo sobre cuánta libertad debe tener cada clase de usuarios, y esta decisión no puede dejarse en manos de pruebas y herramientas automatizadas. Hay compensaciones significativas con la descentralización, la seguridad y la UX que deben tenerse en cuenta.
Para el programador de contratos inteligentes, escribir explícitamente lo que los usuarios deben y no deben poder hacer con ciertas funciones es una parte importante del proceso de desarrollo.
Volveremos a tratar el tema de los administradores dominados más adelante.
Como se indicó en la introducción, hay cuatro formas principales en que se piratean los contratos inteligentes:
"Dinero" aquí significa cualquier cosa de valor, como fichas, no solo criptomonedas. Al codificar o auditar un contrato inteligente, el desarrollador debe ser consciente de las formas en que el valor debe fluir dentro y fuera del contrato. Los problemas enumerados anteriormente son las principales formas en que se piratean los contratos inteligentes, pero hay muchas otras causas fundamentales que pueden desencadenar problemas importantes, que se documentan a continuación.
El uso de tokens estándar ERC20 o NFT como boletos para sopesar el voto no es seguro porque los atacantes pueden votar con una dirección, transferir los tokens a otra dirección y votar nuevamente desde esa dirección.
Aquí hay un ejemplo mínimo:
// A malicious voter can simply transfer their tokens to // another address and vote again. contract UnsafeBallot { uint256 public proposal1VoteCount; uint256 public proposal2VoteCount; IERC20 immutable private governanceToken; constructor(IERC20 _governanceToken) { governanceToken = _governanceToken; } function voteFor1() external notAlreadyVoted { proposal1VoteCount += governanceToken.balanceOf(msg.sender); } function voteFor2() external notAlreadyVoted { proposal2VoteCount += governanceToken.balanceOf(msg.sender); } // prevent the same address from voting twice, // however the attacker can simply // transfer to a new address modifier notAlreadyVoted { require(!alreadyVoted[msg.sender], "already voted"); _; alreadyVoted[msg.sender] = true; } }
Para evitar este ataque, se debe utilizar ERC20 Snapshot o ERC20 Votes . Al tomar una instantánea de un momento en el pasado, los saldos de fichas actuales no se pueden manipular para obtener poder de voto ilícito.
Sin embargo, el uso de un token ERC20 con una instantánea o capacidad de voto no resuelve completamente el problema si alguien puede tomar un préstamo flash para aumentar temporalmente su saldo y luego tomar una instantánea de su saldo en la misma transacción. Si esa instantánea se usa para votar, tendrán una cantidad irrazonablemente grande de votos a su disposición.
Un flashloan presta una gran cantidad de Ether o token a una dirección, pero se revierte si el dinero no se devuelve en la misma transacción.
contract SimpleFlashloan { function borrowERC20Tokens() public { uint256 before = token.balanceOf(address(this)); // send tokens to the borrower token.transfer(msg.sender, amount); // hand control back to the borrower to // let them do something IBorrower(msg.sender).onFlashLoan(); // require that the tokens got returned require(token.balanceOf(address(this) >= before); } }
Un atacante puede usar un préstamo flash para ganar repentinamente muchos votos para cambiar las propuestas a su favor y/o hacer algo malicioso.
Podría decirse que este es el ataque más común (o al menos el más destacado) contra DeFi, y representa la pérdida de cientos de millones de dólares. Aquí hay una lista de los de alto perfil.
El precio de un activo en la cadena de bloques a menudo se calcula como el tipo de cambio actual entre los activos. Por ejemplo, si un contrato actualmente cotiza 1 USDC por 100 k9coin, entonces podría decir que k9coin tiene un precio de 0,01 USDC. Sin embargo, los precios generalmente se mueven en respuesta a la presión de compra y venta, y los préstamos rápidos pueden crear una enorme presión de compra y venta.
Al consultar otro contrato inteligente sobre el precio de un activo, el desarrollador debe tener mucho cuidado porque asume que el contrato inteligente al que está llamando es inmune a la manipulación de préstamos rápidos.
Puede "comprobar" si una dirección es un contrato inteligente observando el tamaño del código de bytes. Las cuentas de propiedad externa (carteras regulares) no tienen ningún código de bytes. Aquí hay algunas maneras de hacerlo
import "@openzeppelin/contracts/utils/Address.sol" contract CheckIfContract { using Address for address; function addressIsContractV1(address _a) { return _a.code.length == 0; } function addressIsContractV2(address _a) { // use the openzeppelin libraryreturn _a.isContract(); } }
Sin embargo, esto tiene algunas limitaciones.
En general, verificar si una dirección es un contrato suele ser (pero no siempre) un antipatrón. Las billeteras de firmas múltiples son contratos inteligentes en sí mismas, y hacer cualquier cosa que pueda romper las billeteras de firmas múltiples rompe la componibilidad.
La excepción a esto es verificar si el objetivo es un contrato inteligente antes de llamar a un gancho de transferencia. Más sobre esto más adelante.
Rara vez hay una buena razón para usar tx.origin. Si se usa tx.origin para identificar al remitente, es posible que se produzca un ataque de intermediario. Si se engaña al usuario para que llame a un contrato inteligente malicioso, entonces el contrato inteligente puede usar toda la autoridad que tx.origin tiene para causar estragos.
Considere este siguiente ejercicio y los comentarios sobre el código.
contract Phish { function phishingFunction() public { // this fails, because this contract does not have approval/allowance token.transferFrom(msg.sender, address(this), token.balanceOf(msg.sender)); // this also fails, because this creates approval for the contract,// not the wallet calling this phishing function token.approve(address(this), type(uint256).max); } }
Esto no significa que esté seguro llamando contratos inteligentes arbitrarios. Pero hay una capa de seguridad integrada en la mayoría de los protocolos que se omitirá si se usa tx.origin para la autenticación.
A veces, es posible que vea un código que se parece a esto:
require(msg.sender == tx.origin, "no contracts");
Cuando un contrato inteligente llama a otro contrato inteligente, msg.sender será el contrato inteligente y tx.origin será la billetera del usuario, lo que brinda una indicación confiable de que la llamada entrante proviene de un contrato inteligente. Esto es cierto incluso si la llamada se realiza desde el constructor.
La mayoría de las veces, este patrón de diseño no es una buena idea. Las billeteras multifirma y las billeteras de EIP 4337 no podrán interactuar con una función que tenga este código. Este patrón se puede ver comúnmente en las casas de moneda NFT, donde es razonable esperar que la mayoría de los usuarios usen una billetera tradicional. Pero a medida que la abstracción de cuentas se vuelve más popular, este patrón obstaculizará más de lo que ayudará.
Un ataque de duelo significa que el hacker está tratando de "causar dolor" a otras personas, incluso si no obtienen ganancias económicas al hacerlo.
Un contrato inteligente puede consumir maliciosamente todo el gas que se le envía entrando en un bucle infinito. Considere el siguiente ejemplo:
contract Mal { fallback() external payable { // infinite loop uses up all the gas while (true) { } } }
Si otro contrato distribuye ether a una lista de direcciones como la siguiente:
contract Distribute { funtion distribute(uint256 total) public nonReentrant { for (uint i; i < addresses.length; ) { (bool ok, ) addresses.call{value: total / addresses.length}(""); // ignore ok, if it reverts we move on // traditional gas saving trick for for loops unchecked { ++i; } } } }
Luego, la función se revertirá cuando envíe ether a Mal. La llamada en el código anterior reenvía 63/64 del gas disponible, por lo que es probable que no haya suficiente gas para completar la operación con solo 1/64 del gas restante.
Un contrato inteligente puede devolver una gran matriz de memoria que consume mucha gasolina
Considere el siguiente ejemplo
function largeReturn() public { // result might be extremely long! (book ok, bytes memory result) = otherContract.call(abi.encodeWithSignature("foo()")); require(ok, "call failed"); }
Las matrices de memoria consumen una cantidad cuadrática de gas después de 724 bytes, por lo que un tamaño de datos de retorno cuidadosamente elegido puede perjudicar a la persona que llama.
Incluso si el resultado de la variable no se usa, todavía se copia en la memoria. Si desea restringir el tamaño de devolución a una cierta cantidad, puede usar el ensamblaje
function largeReturn() public { assembly { let ok := call(gas(), destinationAddress, value, dataOffset, dataSize, 0x00, 0x00); // nothing is copied to memory until you // use returndatacopy() } }
Aunque borrar el almacenamiento es una operación eficiente en el uso de gas, todavía tiene un costo neto. Si una matriz se vuelve demasiado larga, se vuelve imposible eliminarla. Aquí hay un ejemplo mínimo.
contract VulnerableArray { address[] public stuff; function addSomething(address something) public { stuff.push(something); } // if stuff is too long, this will become undeletable due to // the gas cost function deleteEverything() public onlyOwner { delete stuff; } }
Si un contrato inteligente transfiere tokens que tienen enlaces de transferencia, un atacante puede configurar un contrato que no acepte el token (no tiene una función onReceive o programa la función para revertir). Esto hará que el token sea intransferible y hará que se revierta toda la transacción.
Antes de usar safeTransfer o transfer, considere la posibilidad de que el receptor obligue a revertir la transacción.
contract Mal is IERC721Receiver, IERC1155Receiver, IERC777Receiver { // this will intercept any transfer hook fallback() external payable { // infinite loop uses up all the gaswhile (true) { } } // we could also selectively deny transactions function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4) { if (wakeUpChooseViolence()) { revert(); } else { return IERC721Receiver.onERC721Received.selector; } } }
Actualmente no es posible generar aleatoriedad de forma segura con una sola transacción en la cadena de bloques. Las cadenas de bloques deben ser totalmente deterministas; de lo contrario, los nodos distribuidos no podrían llegar a un consenso sobre el estado. Debido a que son totalmente deterministas, se puede predecir cualquier número "aleatorio". Se puede aprovechar la siguiente función de tirada de dados.
contract UnsafeDice { function randomness() internal returns (uint256) { return keccak256(abi.encode(msg.sender, tx.origin, block.timestamp, tx.gasprice, blockhash(block.number - 1); } // our dice can land on one of {0,1,2,3,4,5}function rollDice() public payable { require(msg.value == 1 ether); if (randomness() % 6) == 5) { msg.sender.call{value: 2 ether}(""); } } } contract ExploitDice { function randomness() internal returns (uint256) { return keccak256(abi.encode(msg.sender, tx.origin, block.timestamp, tx.gasprice, blockhash(block.number - 1); } function betSafely(IUnsafeDice game) public payable { if (randomness % 6) == 5)) { game.betSafely{value: 1 ether}() } // else don't do anything } }
No importa cómo genere aleatoriedad porque un atacante puede replicarla exactamente. Lanzar más fuentes de "entropía" como msg.sender, timestamp, etc. no tendrá ningún efecto porque el contrato inteligente puede medirlo dos.
Chainlink es una solución popular para obtener números aleatorios seguros. Lo hace en dos pasos. Primero, los contratos inteligentes envían una solicitud de aleatoriedad al oráculo, luego, algunas cuadras más tarde, el oráculo responde con un número aleatorio.
Dado que un atacante no puede predecir el futuro, no puede predecir el número aleatorio.
A menos que el contrato inteligente use el oráculo incorrectamente.
No existe un SLA (acuerdo de nivel de servicio) para que Chainlink mantenga sus oráculos de precios actualizados dentro de un período de tiempo determinado. Cuando la cadena está severamente congestionada (como cuando Yuga Labs Otherside mint abrumó a Ethereum hasta el punto de que no se realizaron transacciones), las actualizaciones de precios pueden retrasarse.
Un contrato inteligente que utiliza un oráculo de precios debe verificar explícitamente que los datos no estén obsoletos, es decir, se hayan actualizado recientemente dentro de algún umbral. De lo contrario, no puede tomar una decisión confiable con respecto a los precios.
Hay una complicación adicional de que si el precio no cambia más allá de un umbral de desviación , es posible que el oráculo no actualice el precio para ahorrar gasolina, por lo que esto podría afectar el umbral de tiempo que se considera "obsoleto".
Es importante comprender el SLA de un oráculo en el que se basa un contrato inteligente.
No importa cuán seguro parezca un oráculo, es posible que se descubra un ataque en el futuro. La única defensa contra esto es usar múltiples oráculos independientes.
La cadena de bloques puede ser bastante segura, pero poner datos en la cadena en primer lugar requiere algún tipo de operación fuera de la cadena que renuncia a todas las garantías de seguridad que brindan las cadenas de bloques. Incluso si los oráculos siguen siendo honestos, su fuente de datos puede ser manipulada. Por ejemplo, un oráculo puede informar de manera confiable los precios de un intercambio centralizado, pero estos pueden manipularse con grandes órdenes de compra y venta. Del mismo modo, los oráculos que dependen de los datos de los sensores o de alguna API web2 están sujetos a los vectores de piratería tradicionales.
Una buena arquitectura de contrato inteligente evita el uso de oráculos en la medida de lo posible.
Considere el siguiente contrato
contract MixedAccounting { uint256 myBalance; function deposit() public payable { myBalance = myBalance + msg.value; } function myBalanceIntrospect() public view returns (uint256) { return address(this).balance; } function myBalanceVariable() public view returns (uint256) { return myBalance; } function notAlwaysTrue() public view returns (bool) { return myBalanceIntrospect() == myBalanceVariable(); } }
El contrato anterior no tiene una función de recepción o respaldo, por lo que la transferencia directa de Ether se revertirá. Sin embargo, un contrato puede enviarle Ether a la fuerza con autodestrucción.
En ese caso, myBalanceIntrospect() será mayor que myBalanceVariable(). El método de contabilidad Ether está bien, pero si usa ambos, entonces el contrato puede tener un comportamiento inconsistente.
Lo mismo se aplica a los tokens ERC20.
contract MixedAccountingERC20 { IERC20 token; uint256 myTokenBalance; function deposit(uint256 amount) public { token.transferFrom(msg.sender, address(this), amount); myTokenBalance = myTokenBalance + amount; } function myBalanceIntrospect() public view returns (uint256) { return token.balanceOf(address(this)); } function myBalanceVariable() public view returns (uint256) { return myTokenBalance; } function notAlwaysTrue() public view returns (bool) { return myBalanceIntrospect() == myBalanceVariable(); } }
Nuevamente, no podemos asumir que myBalanceIntrospect() y myBalanceVariable() siempre devolverán el mismo valor. Es posible transferir tokens ERC20 directamente a MixedAccountingERC20, sin pasar por la función de depósito y sin actualizar la variable myTokenBalance.
Al verificar los saldos con introspección, se debe evitar el uso estricto de controles de igualdad, ya que un extraño puede cambiar el saldo a voluntad.
Esto no es una peculiaridad de Solidity, sino más bien un malentendido común entre los desarrolladores sobre cómo usar la criptografía para otorgar privilegios especiales a las direcciones. El siguiente código es inseguro.
contract InsecureMerkleRoot { bytes32 merkleRoot; function airdrop(bytes[] calldata proof, bytes32 leaf) external { require(MerkleProof.verifyCalldata(proof, merkleRoot, leaf), "not verified"); require(!alreadyClaimed[leaf], "already claimed airdrop"); alreadyClaimed[leaf] = true; mint(msg.sender, AIRDROP_AMOUNT); } }
Las pruebas criptográficas (árboles Merkle, firmas, etc.) deben vincularse a msg.sender, que un atacante no puede manipular sin adquirir la clave privada.
function limitedMultiply(uint8 a, uint8 b) public pure returns (uint256 product) { product = a * b; }
Aunque el producto es una variable uint256 , el resultado de la multiplicación no puede ser mayor que 255 o el código se revertirá.
Este problema se puede mitigar elevando individualmente cada variable.
function unlimitedMultiply(uint8 a, uint8 b) public pure returns (uint256 product) { product = uint256(a) * uint256(b); }
Una situación como esta puede ocurrir si se multiplican enteros empaquetados en una estructura. Debe tener esto en cuenta al multiplicar valores pequeños que se empaquetaron en una estructura
struct Packed { uint8 time; uint16 rewardRate } //... Packed p; p.time * p.rewardRate; // this might revert!
Solidity no verifica si es seguro convertir un número entero a uno más pequeño. A menos que alguna lógica empresarial asegure que la conversión descendente sea segura, se debe usar una biblioteca como SafeCast .
function test(int256 value) public pure returns (int8) { return int8(value + 1); // overflows and does not revert }
Parece que el código copia los datos de myArray[1] en myArray[0], pero no es así. Si comenta la última línea de la función, el compilador dirá que la función debe convertirse en una función de vista. Write to foo no escribe en el almacenamiento subyacente.
contract DoesNotWrite { struct Foo { uint256 bar; } Foo[] public myArray; function moveToSlot0() external { Foo storage foo = myArray[0]; foo = myArray[1]; // myArray[0] is unchanged // we do this to make the function a state // changing operation // and silence the compiler warning myArray[1] = Foo({bar: 100}); } }
Así que no escriba en punteros de almacenamiento.
Si una asignación (o una matriz dinámica) está dentro de una estructura y la estructura se elimina, la asignación o la matriz no se eliminarán.
Con la excepción de eliminar una matriz, la palabra clave delete solo puede eliminar una ranura de almacenamiento. Si la ranura de almacenamiento contiene referencias a otras ranuras de almacenamiento, no se eliminarán.
contract NestedDelete { mapping(uint256 => Foo) buzz; struct Foo { mapping(uint256 => uint256) bar; } Foo foo; function addToFoo(uint256 i) external { buzz[i].bar[5] = 6; } function getFromFoo(uint256 i) external view returns (uint256) { return buzz[i].bar[5]; } function deleteFoo(uint256 i) external { // internal map still holds the data in the // mapping and array delete buzz[i]; } }
Recuerde, los mapas nunca están "vacíos" en Solidity. Entonces, si alguien accede a un elemento que se eliminó, la transacción no se revertirá, sino que devolverá el valor cero para ese tipo de datos.
Si solo maneja tokens ERC20 confiables, la mayoría de estos problemas no se aplican. Sin embargo, al interactuar con un token ERC20 arbitrario o parcialmente no confiable, aquí hay algunas cosas que debe tener en cuenta.
Cuando se trata de tokens que no son de confianza, no debe asumir que su saldo necesariamente aumenta por la cantidad. Es posible que un token ERC20 implemente su función de transferencia de la siguiente manera:
contract ERC20 { // internally called by transfer() and transferFrom() // balance and approval checks happen in the caller function _transfer(address from, address to, uint256 amount) internal returns (bool) { fee = amount * 100 / 99; balanceOf[from] -= to; balanceOf[to] += (amount - fee); balanceOf[TREASURY] += fee; emit Transfer(msg.sender, to, (amount - fee)); return true; } }
Este token aplica un impuesto del 1% a cada transacción. Entonces, si un contrato inteligente interactúa con el token de la siguiente manera, obtendremos reversiones inesperadas o dinero robado.
contract Stake { mapping(address => uint256) public balancesInContract; function stake(uint256 amount) public { token.transferFrom(msg.sender, address(this), amount); balancesInContract[msg.sender] += amount; // THIS IS WRONG! } function unstake() public { uint256 toSend = balancesInContract[msg.sender]; delete balancesInContract[msg.sender]; // this could revert because toSend is 1% greater than// the amount in the contract. Otherwise, 1% will be "stolen"// from other depositors. token.transfer(msg.sender, toSend); } }
El token de rebase fue popularizado por el token sOhm de Olympus DAO y el token AMPL de Ampleforth . Coingecko mantiene una lista de tokens ERC20 de rebase.
Cuando un token cambia de base, el suministro total cambia y el saldo de todos aumenta o disminuye según la dirección de cambio de base.
Es probable que el siguiente código se rompa cuando se trata de un token de rebase
contract WillBreak { mapping(address => uint256) public balanceHeld; IERC20 private rebasingToken function deposit(uint256 amount) external { balanceHeld[msg.sender] = amount; rebasingToken.transferFrom(msg.sender, address(this), amount); } function withdraw() external { amount = balanceHeld[msg.sender]; delete balanceHeld[msg.sender]; // ERROR, amount might exceed the amount // actually held by the contract rebasingToken.transfer(msg.sender, amount); } }
La solución de muchos contratos es simplemente prohibir el cambio de base de tokens. Sin embargo, se podría modificar el código anterior para verificar balanceOf(address(this)) antes de transferir el saldo de la cuenta al remitente. Entonces seguiría funcionando incluso si cambia el equilibrio.
ERC20, si se implementa de acuerdo con el estándar, los tokens ERC20 no tienen ganchos de transferencia y, por lo tanto, transfer y transferFrom no tienen un problema de reingreso.
Hay ventajas significativas para los tokens con ganchos de transferencia, razón por la cual todos los estándares NFT los implementan y por qué se finalizó ERC777. Sin embargo, ha causado suficiente confusión que Openzeppelin desechó la biblioteca ERC777.
Si desea que su protocolo sea compatible con tokens que se comporten como tokens ERC20 pero que tengan enlaces de transferencia, entonces es una simple cuestión de tratar las funciones transfer y transferFrom como si emitieran una llamada de función al receptor.
Este reingreso de ERC777 le sucedió a Uniswap (Openzeppelin documentó el exploit aquí si tiene curiosidad).
La especificación ERC20 dicta que un token ERC20 debe devolver verdadero cuando una transferencia se realiza correctamente. Debido a que la mayoría de las implementaciones de ERC20 no pueden fallar a menos que la asignación sea insuficiente o la cantidad transferida sea demasiado, la mayoría de los desarrolladores se han acostumbrado a ignorar el valor de retorno de los tokens ERC20 y asumir que una transferencia fallida se revertirá.
Francamente, esto no tiene consecuencias si solo está trabajando con un token ERC20 de confianza del que conoce el comportamiento. Pero cuando se trata de tokens ERC20 arbitrarios, se debe tener en cuenta esta variación en el comportamiento.
Hay una expectativa implícita en muchos contratos de que las transferencias fallidas siempre deben revertirse, no devolver falso porque la mayoría de los tokens ERC20 no tienen un mecanismo para devolver falso, por lo que esto ha generado mucha confusión.
Para complicar aún más este asunto, algunos tokens ERC20 no siguen el protocolo de devolver verdadero, especialmente Tether. Algunos tokens se revierten cuando falla la transferencia, lo que hará que la reversión llegue a la persona que llama. Por lo tanto, algunas bibliotecas envuelven las llamadas de transferencia de token ERC20 para interceptar la reversión y devolver un valor booleano en su lugar.
Transferencia segura de Openzeppelin
Solady SafeTransfer (considerablemente más eficiente en el uso de gas)
Esta no es una vulnerabilidad de contrato inteligente, pero la mencionamos aquí para completar.
La especificación permite transferir cero tokens ERC20. Esto puede generar confusión para las aplicaciones frontend y posiblemente engañar a los usuarios sobre a quién enviaron tokens recientemente. Metamask tiene más información sobre eso en este hilo .
(En la jerga de web3, "resistente" significa "tener la alfombra debajo de ti").
No hay nada que impida que alguien agregue una función a un token ERC20 que le permita crear, transferir y quemar tokens a voluntad, o autodestruirse o actualizarse. Básicamente, hay un límite en cuanto a cuán "no confiable" puede ser un token ERC20.
Al considerar cómo pueden fallar los protocolos DeFi basados en préstamos y préstamos, es útil pensar en los errores que se propagan a nivel de software y afectan el nivel de lógica comercial. Hay muchos pasos para formar y cerrar un contrato de fianza. Aquí hay algunos vectores de ataque a considerar.
Si la garantía se drena del protocolo, tanto el prestamista como el prestatario pierden, ya que el prestatario no tiene ningún incentivo para devolver el préstamo y el prestatario pierde el principal.
Como se puede ver arriba, hay muchos más niveles para que un protocolo DeFi sea "pirateado" que un montón de dinero que se extrae del protocolo (el tipo de eventos que generalmente son noticia). Esta es un área donde los ejercicios de seguridad CTF (capture the flag) pueden ser engañosos. Aunque el robo de los fondos del protocolo es el resultado más catastrófico, de ninguna manera es el único del que hay que defenderse.
Hay dos formas de llamar a un contrato inteligente externo: 1) llamar a la función con una definición de interfaz; 2) utilizando el método .call. Esto se ilustra a continuación
contract A { uint256 public x; function setx(uint256 _x) external { require(_x > 10, "x must be bigger than 10"); x = _x; } } interface IA { function setx(uint256 _x) external; } contract B { function setXV1(IA a, uint256 _x) external { a.setx(_x); } function setXV2(address a, uint256 _x) external { (bool success, ) = a.call(abi.encodeWithSignature("setx(uint256)", _x)); // success is not checked! } }
En el contrato B, setXV2 puede fallar silenciosamente si _x es menor que 10. Cuando se llama a una función a través del método .call, el destinatario puede revertir, pero el padre no revertirá. El valor del éxito debe verificarse y el comportamiento del código debe ramificarse en consecuencia.
Las variables privadas aún son visibles en la cadena de bloques, por lo que la información confidencial nunca debe almacenarse allí. Si no fueran accesibles, ¿cómo podrían los validadores procesar transacciones que dependen de sus valores? Las variables privadas no se pueden leer desde un contrato de Solidity externo, pero se pueden leer fuera de la cadena utilizando un cliente Ethereum.
Para leer una variable, necesita conocer su ranura de almacenamiento. En el siguiente ejemplo, la ranura de almacenamiento de myPrivateVar es 0.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract PrivateVarExample { uint256 private myPrivateVar; constructor(uint256 _initialValue) { myPrivateVar = _initialValue; } }
Aquí está el código javascript para leer la variable privada del contrato inteligente implementado
const Web3 = require("web3"); const PRIVATE_VAR_EXAMPLE_ADDRESS = "0x123..."; // Replace with your contract address async function readPrivateVar() { const web3 = new Web3("http://localhost:8545"); // Replace with your provider's URL // Read storage slot 0 (where 'myPrivateVar' is stored) const storageSlot = 0; const privateVarValue = await web3.eth.getStorageAt( PRIVATE_VAR_EXAMPLE_ADDRESS, storageSlot ); console.log("Value of private variable 'myPrivateVar':", web3.utils.hexToNumberString(privateVarValue)); } readPrivateVar();
Delegatecall nunca debe usarse con contratos que no sean de confianza, ya que entrega todo el control al delegado llamado. En este ejemplo, el contrato que no es de confianza roba todo el éter del contrato.
contract UntrustedDelegateCall { constructor() payable { require(msg.value == 1 ether); } function doDelegateCall(address _delegate, bytes calldata data) public { (bool ok, ) = _delegate.delegatecall(data); require(ok, "delegatecall failed"); } } contract StealEther { function steal() public { // you could also selfdestruct here // if you really wanted to be mean (bool ok,) = tx.origin.call{value: address(this).balance}(""); require(ok); } function attack(address victim) public { UntrustedDelegateCall(victim).doDelegateCall( address(this), abi.encodeWithSignature("steal()")); } }
No podemos hacer justicia a este tema en una sola sección. La mayoría de los errores de actualización generalmente se pueden evitar utilizando el complemento de casco de Openzeppelin y leyendo sobre los problemas contra los que protege. ( https://docs.openzeppelin.com/upgrades-plugins/1.x/ ).
El hecho de que un contrato tenga un propietario o un administrador no significa que su poder deba ser ilimitado. Considere una NFT. Es razonable que solo el propietario retire las ganancias de la venta de NFT, pero poder pausar el contrato (transferencias en bloque) podría causar estragos si las claves privadas del propietario se ven comprometidas. En general, los privilegios de administrador deben ser los mínimos posibles para minimizar riesgos innecesarios.
Hablando de propiedad del contrato...
Técnicamente, esto no es una vulnerabilidad, pero la propiedad de OpenZeppelin puede conducir a la pérdida de la propiedad del contrato si la propiedad se transfiere a una dirección inexistente. Ownable2step requiere que el receptor confirme la propiedad. Esto asegura contra el envío accidental de la propiedad a una dirección mal escrita.
Solidity no tiene flotadores, por lo que los errores de redondeo son inevitables. El diseñador debe ser consciente de si lo correcto es redondear hacia arriba o hacia abajo, y a favor de quién debe ser el redondeo.
La división siempre debe realizarse en último lugar. El siguiente código convierte incorrectamente entre monedas estables que tienen un número diferente de decimales. El siguiente mecanismo de intercambio le permite a un usuario tomar una pequeña cantidad de USDC (que tiene 6 decimales) de forma gratuita al cambiar por dai (que tiene 18 decimales). La variable daiToTake se redondeará a cero, sin quitarle nada al usuario a cambio de una cantidad usdc distinta de cero.
contract Exchange { uint256 private constant CONVERSION = 1e12; function swapDAIForUSDC(uint256 usdcAmount) external pure returns (uint256 a) { uint256 daiToTake = usdcAmount / CONVERSION; conductSwap(daiToTake, usdcAmount); } }
El liderazgo en el contexto de Etheruem (y cadenas similares) significa observar una transacción pendiente y ejecutar otra transacción antes pagando un precio de gas más alto. Es decir, el atacante se ha “pasado por delante” de la transacción. Si la transacción es rentable, entonces tiene sentido copiar la transacción exactamente excepto pagar un precio de gasolina más alto. Este fenómeno a veces se denomina MEV, que significa valor extraíble del minero, pero a veces valor extraíble máximo en otros contextos. Los productores de bloques tienen un poder ilimitado para reordenar transacciones e insertar las suyas propias, e históricamente, los productores de bloques eran mineros antes de que Ethereum pasara a la prueba de participación, de ahí el nombre.
Retirar Ether de un contrato inteligente puede considerarse un "comercio rentable". Ejecuta una transacción de costo cero (aparte del gas) y termina con más criptomonedas de las que tenía al principio.
contract UnprotectedWithdraw { constructor() payable { require(msg.value == 1 ether, "must create with 1 eth"); } function unsafeWithdraw() external { (bool ok, ) = msg.sender.call{value: address(this).value}(""); require(ok, "transfer failed"). } }
Si implementa este contrato e intenta retirarse, un bot pionero notará su llamada a "unsafeWithdraw" en el mempool y lo copiará para obtener el Ether primero.
Hemos escrito en profundidad sobre el ataque de inflación ERC-4626 en nuestro tutorial ERC4626 . Pero la esencia de esto es que un contrato ERC4626 distribuye tokens de "compartir" en función del porcentaje de "activos" que aporta un comerciante.
Aproximadamente, funciona de la siguiente manera:
function getShares(...) external { // code shares_received = assets_contributed / total_assets; // more code }
Por supuesto, nadie contribuirá con activos y no recuperará acciones, pero no pueden predecir que eso sucederá si alguien puede adelantar la operación para obtener las acciones.
Por ejemplo, aporta 200 activos cuando el pool tiene 20, espera obtener 100 acciones. Pero si alguien adelanta la transacción para depositar 200 activos, entonces la fórmula será 200/220, que se redondea a cero, lo que hace que la víctima pierda activos y recupere cero acciones.
Es mejor ilustrar esto con un ejemplo real en lugar de describirlo en abstracto.
Ahora Eve tiene 150 tokens en lugar de 100 o 50. La solución a esto es establecer la aprobación en cero antes de aumentarla o disminuirla, cuando se trata de aprobaciones que no son de confianza.
El precio de un activo se mueve en respuesta a la presión de compra y venta. Si hay un pedido grande en el mempool, los comerciantes tienen un incentivo para copiar el pedido pero con un precio de gasolina más alto. De esa manera, compran el activo, dejan que la orden grande suba el precio y luego venden de inmediato. La orden de venta a veces se denomina "retraso". La orden de venta se puede realizar colocando una orden de venta con un precio de gasolina más bajo para que la secuencia se vea así
La principal defensa contra este ataque es proporcionar un parámetro de "deslizamiento". Si la "compra anticipada" en sí misma eleva el precio más allá de un cierto umbral, la orden de "compra grande" revertirá y hará que el líder falle en la operación.
Se llama sándwich, porque la compra grande está intercalada por la compra anticipada y la venta retrospectiva. Este ataque también funciona con grandes órdenes de venta, solo que en la dirección opuesta.
Frontrunning es un tema masivo. Flashbots ha investigado el tema extensamente y ha publicado varias herramientas y artículos de investigación para ayudar a minimizar sus externalidades negativas.
Si se puede "eliminar el diseño" con la arquitectura blockchain adecuada es un tema de debate que no se ha resuelto de manera concluyente. Los siguientes dos artículos son clásicos perdurables sobre el tema:
Las firmas digitales tienen dos usos en el contexto de los contratos inteligentes:
Aquí hay un ejemplo del uso seguro de firmas digitales para darle a un usuario el privilegio de acuñar un NFT:
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; contract NFT is ERC721("name", "symbol") { function mint(bytes calldata signature) external { address recovered = keccak256(abi.encode(msg.sender)).toEthSignedMessageHash().recover(signature); require(recovered == authorizer, "signature does not match"); } }
Un ejemplo clásico es la funcionalidad Aprobar en ERC20. Para aprobar una dirección para retirar una cierta cantidad de tokens de nuestra cuenta, debemos realizar una transacción real de Ethereum, que cuesta gasolina.
A veces es más eficiente pasar una firma digital al destinatario fuera de la cadena, luego el destinatario proporciona la firma al contrato inteligente para demostrar que estaba autorizado para realizar la transacción.
ERC20Permit permite aprobaciones con firma digital. La función se describe de la siguiente manera
function permit(address owner, address spender, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) public
En lugar de enviar una transacción de aprobación real, el propietario puede "firmar" la aprobación para el gastador (junto con una fecha límite). El gastador aprobado puede llamar a la función de permiso con los parámetros provistos.
Verá las variables v, r y s con frecuencia. Se representan en solidez con los tipos de datos uint8, bytes32 y bytes32 respectivamente. A veces, las firmas se representan como una matriz de 65 bytes que son todos estos valores concatenados como abi.encodePacked(r, s, v);
Los otros dos componentes esenciales de una firma son el hash del mensaje (32 bytes) y la dirección de firma. La secuencia se ve así
Se utiliza una clave privada (privKey) para generar una dirección pública (ethAddress)
Un contrato inteligente almacena ethAddress por adelantado
Un usuario fuera de la cadena procesa un mensaje y firma el hash. Esto produce el par msgHash y la firma (r, s, v)
El contrato inteligente recibe un mensaje, lo codifica para producir msgHash, luego lo combina con (r, s, v) para ver qué dirección sale.
Si la dirección coincide con ethAddress, la firma es válida (¡bajo ciertas suposiciones que veremos pronto!)
Los contratos inteligentes usan el contrato ecrecover precompilado en el paso 4 para hacer lo que llamamos la combinación y recuperar la dirección.
Hay muchos pasos en este proceso en los que las cosas pueden torcerse.
Esto puede conducir a una vulnerabilidad si se compara una variable no inicializada con la salida de ecrecover.
Este código es vulnerable
contract InsecureContract { address signer; // defaults to address(0) // who lets us give the beneficiary the airdrop without them// spending gas function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { // ecrecover returns address(0) if the signature is invalid require(signer == ecrecover(keccak256(abi.encode(who, amount)), v, r, s), "invalid signature"); mint(msg.sender, AIRDROP_AMOUNT); } }
La repetición de la firma ocurre cuando un contrato no rastrea si una firma se usó anteriormente. En el siguiente código, solucionamos el problema anterior, pero aún no es seguro.
contract InsecureContract { address signer; function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { address recovered == ecrecover(keccak256(abi.encode(who, amount)), v, r, s); require(recovered != address(0), "invalid signature"); require(recovered == signer, "recovered signature not equal signer"); mint(msg.sender, amount); } }
¡Las personas pueden reclamar el lanzamiento aéreo tantas veces como quieran!
Podríamos agregar las siguientes líneas
bytes memory signature = abi.encodePacked(v, r, s); require(!used[signature], "signature already used"); // mapping(bytes => bool); used[signature] = true;
¡Ay, el código aún no es seguro!
Dada una firma válida, un atacante puede hacer un poco de aritmética rápida para derivar una diferente. El atacante puede entonces “reproducir” esta firma modificada. Pero primero, proporcionemos un código que demuestre que podemos comenzar con una firma válida, modificarla y mostrar que la nueva firma todavía pasa.
contract Malleable { // v = 28 // r = 0xf8479d94c011613baeffe9239e4ff65e2adbac744c34217ca7d51378e72c5204 // s = 0x57af17590a914b759c45aaeabaf513d5ef72d7da1bdd19d9f2e1bc371ece5b86 // m = 0x0000000000000000000000000000000000000000000000000000000000000003 function foo(bytes calldata msg, uint8 v, bytes32 r, bytes32 s) public pure returns (address, address){ bytes32 h = keccak256(msg); address a = ecrecover(h, v, r, s); // The following is math magic to invert the // signature and create a valid one // flip s bytes32 s2 = bytes32(uint256(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141) - uint256(s)); // invert v uint8 v2; require(v == 27 || v == 28, "invalid v"); v2 = v == 27 ? 28 : 27; address b = ecrecover(h, v2, r, s2); assert(a == b); // different signatures, same address!; return (a, b); } }
Como tal, nuestro ejemplo en ejecución sigue siendo vulnerable. Una vez que alguien presenta una firma válida, se puede producir su firma de imagen especular y omitir la verificación de firma utilizada.
contract InsecureContract { address signer; function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { address recovered == ecrecover(keccak256(abi.encode(who, amount)), v, r, s); require(recovered != address(0), "invalid signature"); require(recovered == signer, "recovered signature not equal signer"); bytes memory signature = abi.encodePacked(v, r, s); require(!used[signature], "signature already used"); // this can be bypassed used[signature] = true; mint(msg.sender, amount); } }
Probablemente estés buscando un código de firma seguro en este momento, ¿verdad? Le remitimos a nuestro tutorial sobre cómo crear firmas en solidez y probarlas en fundición.
Pero aquí está la lista de verificación.
El ataque anterior se puede generalizar aún más si el hashing no se realiza en cadena. En los ejemplos anteriores, el hashing se realizó en el contrato inteligente, por lo que los ejemplos anteriores no son vulnerables al siguiente exploit.
Veamos el código para recuperar firmas.
// this code is vulnerable! function recoverSigner(bytes32 hash, uint8 v, bytes32 r, bytes32 s) public returns (address signer) { require(signer == ecrecover(hash, v, r, s), "signer does not match"); // more actions }
El usuario proporciona tanto el hash como las firmas. Si el atacante ya ha visto una firma válida del firmante, simplemente puede reutilizar el hash y la firma de otro mensaje.
Por eso es muy importante codificar el mensaje en el contrato inteligente , no fuera de la cadena.
Para ver este exploit en acción, vea el CTF que publicamos en Twitter.
Parte 1: https://twitter.com/RareSkills_io/status/1650869999266037760
Parte 2: https://twitter.com/RareSkills_io/status/1650897671543197701
https://twitter.com/RareSkills_io/status/1651527648676573185 https://twitter.com/RareSkills_io/status/1651224817465540611
Las firmas no deben utilizarse para identificar a los usuarios. Debido a su maleabilidad, no se puede suponer que sean únicos. Msg.sender tiene garantías de unicidad mucho más sólidas.
Vea un ejercicio de seguridad que alojamos en Twitter aquí . Al auditar una base de código, verifique la versión de Solidity con los anuncios de lanzamiento en la página de Solidity para ver si hay un error presente.
Los contratos inteligentes se pueden actualizar con el Patrón Proxy (o más raramente, el patrón metamórfico). Los contratos inteligentes no deben depender de la funcionalidad de un contrato inteligente arbitrario para permanecer sin cambios.
No se deben utilizar las funciones de solidez transferir y enviar. Limitan intencionalmente la cantidad de gas enviado con la transacción a 2.300, lo que hará que la mayoría de las operaciones se queden sin gas.
La billetera de firmas múltiples segura de gnosis comúnmente utilizada admite el reenvío de la llamada a otra dirección en la función de respaldo . Si alguien usa la transferencia o el envío para enviar Ether a la billetera multisig, la función de respaldo podría quedarse sin combustible y la transferencia fallaría. A continuación se proporciona una captura de pantalla de la función de respaldo seguro de gnosis. El lector puede ver claramente que hay operaciones más que suficientes para agotar el gas 2300.
Si necesita interactuar con un contrato que utiliza transferencia y envío, consulte nuestro artículo sobre las transacciones de la lista de acceso de Ethereum que le permite reducir el costo del gas de las operaciones de almacenamiento y acceso al contrato.
Solidity 0.8.0 ha incorporado protección contra desbordamiento y subdesbordamiento. Por lo tanto, a menos que esté presente un bloque no verificado o se use un código de bajo nivel en Yul, no hay peligro de desbordamiento. Como tal, las bibliotecas de SafeMath no deben usarse, ya que desperdician gas en las comprobaciones adicionales.
Algunos documentos de literatura que block.timestamp es un vector de vulnerabilidad porque los mineros pueden manipularlo. Esto generalmente se aplica al uso de marcas de tiempo como fuente de aleatoriedad, lo que no debe hacerse de todos modos como se documentó anteriormente. Ethereum posterior a la fusión actualiza la marca de tiempo en intervalos de exactamente 12 segundos (o múltiplos de 12 segundos). Sin embargo, medir el tiempo en granularidad de segundo nivel es un antipatrón. En la escala de un minuto, existe una posibilidad considerable de error si un validador pierde su ranura de bloque y ocurre una brecha de 24 segundos en la producción de bloques.
Los casos de esquina no se pueden definir fácilmente, pero una vez que ha visto suficientes, comienza a desarrollar una intuición para ellos. Un caso de esquina puede ser algo así como alguien que intenta reclamar una recompensa, pero no tiene nada apostado. Esto es válido, deberíamos darles cero recompensa. De manera similar, generalmente queremos dividir las recompensas de manera uniforme, pero ¿qué pasa si solo hay un destinatario y técnicamente no debería ocurrir ninguna división?
Este ejemplo se tomó del hilo de Twitter de Akshay Srivastav y se modificó.
Considere el caso en el que alguien puede realizar una acción privilegiada si un conjunto de direcciones privilegiadas proporciona una firma para ello.
contract VulnerableMultisigAuthorization { struct Authorization { bytes signature; address authorizer; bytes32 hashOfAction; // more fields } // more codef unction takeAction(Authorization[] calldata auths, bytes calldata action) public { // logic for avoiding replay attacks for (uint256 i; i < auths.length; ++i) { require(validateSignature(auths[i].signature, auths[i].authorizer), "invalid signature"); require(authorizers[auths[i].authorizer], "address is not an authorizer"); } doTheAction(action) } }
Si alguna de las firmas no es válida, o las firmas no coinciden con una dirección válida, se revertirá. Pero, ¿y si la matriz está vacía? En ese caso, saltará hasta doTheAction sin necesidad de firmas.
contract ProportionalRewards { mapping(address => uint256) originalId; address[] stakers; function stake(uint256 id) public { nft.transferFrom(msg.sender, address(this), id); stakers.append(msg.sender); } function unstake(uint256 id) public { require(originalId[id] == msg.sender, "not the owner"); removeFromArray(msg.sender, stakers); sendRewards(msg.sender, totalRewardsSinceLastclaim() / stakers.length()); nft.transferFrom(address(this), msg.sender, id); } }
Aunque el código anterior no muestra todas las implementaciones de funciones, incluso si las funciones se comportan como describen sus nombres, todavía hay un error. ¿Puedes distinguirlo? Aquí hay una imagen para darle algo de espacio para que no vea la respuesta antes de desplazarse hacia abajo.
Las funciones removeFromArray y sendRewards están en el orden incorrecto. Si solo hay un usuario en la matriz de participantes, habrá un error de división por cero y el usuario no podrá retirar su NFT. Además, es probable que las recompensas no se dividan de la manera que el autor pretende. Si había cuatro participantes originales y una persona se retira, obtendrá un tercio de las recompensas ya que la longitud de la matriz es 3 en el momento del retiro.
Usemos un ejemplo real que, según algunas estimaciones, causó más de $ 100 millones de dólares en daños. No se preocupe si no comprende completamente el protocolo Compound, solo nos centraremos en las partes relevantes. (Además, el protocolo Compound es uno de los protocolos más importantes y consecuentes en la historia de DeFi, lo enseñamos en nuestro bootcamp de DeFi , así que si esta es su primera impresión del protocolo, no se equivoque).
De todos modos, el objetivo de Compound es recompensar a los usuarios por prestar su criptomoneda inactiva a otros comerciantes que podrían necesitarla. A los prestamistas se les paga tanto en intereses como en tokens COMP (los prestatarios podrían reclamar una recompensa de token COMP, pero no nos centraremos en eso ahora).
Compound Comptroller es un contrato de proxy que delega llamadas a implementaciones que pueden ser establecidas por Compound Governance.
En la propuesta de gobierno 62 del 30 de septiembre de 2021, el contrato de implementación se estableció en un contrato de implementación que tenía la vulnerabilidad. El mismo día que se puso en marcha, se observó en Twitter que algunas transacciones reclamaban recompensas COMP a pesar de apostar cero tokens.
La función vulnerable distribuirSupplierComp()
Aquí está el código original
El error, irónicamente, está en el comentario TODO. "No distribuya COMP de proveedor si el usuario no está en el mercado de proveedores". Pero no hay verificación en el código para eso. Siempre que el usuario tenga un token de participación en su billetera (CToken(cToken).balanceOf(proveedor);), entonces
La Propuesta 64 corrigió el error el 9 de octubre de 2021.
Aunque se podría argumentar que se trata de un error de validación de entrada, los usuarios no enviaron nada malicioso. Si alguien intenta reclamar una recompensa por no apostar nada, el cálculo correcto debería ser cero. Podría decirse que es más una lógica de negocios o un error de esquina.
Los hacks de DeFi que ocurren en el mundo real muchas veces no entran en las categorías agradables anteriores.
La billetera de paridad no estaba destinada a usarse directamente. Era una implementación de referencia a la que apuntarían los clones de contratos inteligentes . La implementación permitía que los clones se autodestruyeran si se deseaba, pero esto requería que todos los propietarios de la billetera lo firmaran.
// throw unless the contract is not yet initialized.modifier only_uninitialized { if (m_numOwners > 0) throw; _; } function initWallet(address[] _owners, uint _required, uint _daylimit) only_uninitialized { initDaylimit(_daylimit); initMultiowned(_owners, _required); }
Se declaran los propietarios de la billetera.
// kills the contract sending everything to `_to`.function kill(address _to) onlymanyowners(sha3(msg.data)) external { suicide(_to); }
Cierta literatura describe esto como una "autodestrucción desprotegida", es decir, una falla de control de acceso, pero esto no es del todo exacto. El problema era que la función initWallet no se invocaba en el contrato de implementación y eso permitía que alguien llamara a la función initWallet y se convirtiera en el propietario. Eso les dio la autoridad para llamar a la función de matar. La causa raíz fue que la implementación no se inicializó. Por lo tanto, el error se introdujo no debido a un código de solidez defectuoso, sino a un proceso de implementación defectuoso.
No se explotó ningún código de Solidity en este truco. En cambio, los atacantes obtienen la clave API de Cloudflare e inyectan un script en la interfaz del sitio web que altera las transacciones de los usuarios para dirigir los retiros a la dirección del atacante. Lea más en este artículo .
La motivación para descubrir direcciones con muchos ceros a la izquierda es que son más eficientes en el uso de gas. A una transacción de Ethereum se le cobra 4 gas por un byte cero en los datos de la transacción y 16 gas por un byte distinto de cero.
Como tal, Wintermute fue pirateado porque usó la dirección de blasfemia ( redacción ). Aquí está el informe de 1 pulgada sobre cómo se comprometió el generador de direcciones de blasfemias.
Trust Wallet tenía una vulnerabilidad similar documentada en este artículo ( https://blog.ledger.com/Funds-of-every-wallet-created-with-the-Trust-Wallet-browser-extension-could-have-been- robado/ )
Tenga en cuenta que esto no se aplica a los contratos inteligentes con ceros a la izquierda descubiertos al cambiar la sal en create2, ya que los contratos inteligentes no tienen claves privadas.
El punto "r" y "s" en la firma de la curva elíptica se genera de la siguiente manera
r = k * G (mod N) s = k^-1 * (h + r * privateKey) (mod N)
G, r, s, h, an N son todos conocidos públicamente. Si "k" se vuelve público, entonces "privateKey" es la única variable desconocida y se puede resolver. Debido a esto, las billeteras necesitan generar k perfectamente al azar y nunca reutilizarlo. Si la aleatoriedad no es perfectamente aleatoria, entonces se puede inferir k.
La generación insegura de aleatoriedad en la biblioteca de Java dejó vulnerables a muchas billeteras de bitcoin de Android en 2013. (Bitcoin usa el mismo algoritmo de firma que Ethereum).
Capacitarse para reconocer rápidamente los antipatrones en esta lista lo convertirá en un programador de contratos inteligentes más efectivo, pero la mayoría de los errores de contratos inteligentes de consecuencia se deben a una falta de coincidencia entre la lógica comercial prevista y lo que realmente hace el código.
Podría decirse que las pruebas unitarias de contratos inteligentes son las salvaguardas más básicas para los contratos inteligentes, pero una cantidad impactante de contratos inteligentes carecen de ellas o tienen una cobertura de prueba insuficiente.
Pero las pruebas unitarias tienden solo a probar el "camino feliz" (comportamiento esperado/diseñado) de los contratos. Para probar los casos sorprendentes, se deben aplicar metodologías de prueba adicionales.
Para aquellos que no están familiarizados con algunas de las metodologías aquí, Patrick Collins de Cyfrin Audits tiene una introducción humorística al fuzzing con estado y sin estado en su video .
Las herramientas para realizar estas tareas se están generalizando rápidamente y son más fáciles de usar.
Algunos autores han compilado una lista de hacks DeFi anteriores en estos repositorios:
Secureum se ha utilizado ampliamente para estudiar y practicar la seguridad, pero tenga en cuenta que el repositorio no se ha actualizado sustancialmente durante 2 años.
Puede practicar cómo explotar las vulnerabilidades de Solidity con nuestro repositorio Solidity Riddles .
DamnVulnerableDeFi es un juego de guerra clásico que todo desarrollador debería practicar
Capture The Ether y Ethernaut son clásicos, pero tenga en cuenta que algunos de los problemas son irrealmente fáciles o enseñan conceptos obsoletos de Solidity
Algunas firmas de seguridad acreditadas de colaboración colectiva tienen una lista útil de auditorías anteriores para estudiar.
Si no domina Solidity, entonces no hay forma de que pueda auditar los contratos inteligentes de Ethereum.
No existe una certificación reconocida por la industria para convertirse en un auditor de contratos inteligentes. Cualquiera puede crear un sitio web y perfiles en las redes sociales afirmando ser un auditor de solidez y comenzar a vender servicios, y muchos lo han hecho. Por lo tanto, tenga cuidado y obtenga referencias antes de contratar uno.
Para convertirse en un auditor de contratos inteligentes, debe ser sustancialmente mejor que el desarrollador de solidez promedio en la detección de errores. Como tal, la "hoja de ruta" para convertirse en auditor no es más que meses y meses de práctica implacable y deliberada hasta que sea mejor que la mayoría para detectar errores de contratos inteligentes.
Si no tiene la determinación para superar a sus pares en la identificación de vulnerabilidades, es poco probable que detecte los problemas críticos antes de que lo hagan los criminales altamente capacitados y motivados.
Recientemente, la auditoría de contratos inteligentes se ha percibido como un campo deseable para trabajar debido a la percepción de que es lucrativo. De hecho, algunos pagos de recompensas por errores han superado el millón de dólares, pero esta es una excepción extremadamente rara, no la norma.
Code4rena tiene una tabla de clasificación pública de pagos de competidores en sus concursos de auditoría, lo que nos brinda algunos datos sobre las tasas de éxito.
Hay 1171 nombres en el tablero, sin embargo
Considere también esto, cuando Openzeppelin abrió una solicitud para una beca de investigación de seguridad (no un trabajo, una selección y capacitación previa al trabajo), recibieron más de 300 solicitudes solo para seleccionar menos de 10 candidatos, de los cuales incluso menos obtendrían una calificación completa. Hora de trabajo.
Esa es una tasa de admisión más baja que Harvard.
La auditoría de contratos inteligentes es un juego competitivo de suma cero. Hay tantos proyectos para auditar, tanto presupuesto para seguridad y tantos errores para encontrar. Si comienza a estudiar seguridad ahora, hay docenas de personas y equipos altamente motivados con una enorme ventaja sobre usted. La mayoría de los proyectos están dispuestos a pagar una prima por un auditor con reputación en lugar de un nuevo auditor sin experiencia.
En este artículo, hemos enumerado al menos 20 categorías diferentes de vulnerabilidades. Si pasó una semana dominando cada uno (lo cual es algo optimista), apenas está comenzando a comprender lo que es conocimiento común para los auditores experimentados. No hemos cubierto la optimización de gas ni la tokenómica en este artículo, los cuales son temas importantes que un auditor debe comprender. Haz los cálculos y verás que esto no es un viaje corto.
Dicho esto, la comunidad generalmente es amigable y útil para los recién llegados y abundan los consejos y trucos. Pero para aquellos que lean este artículo con la esperanza de hacer una carrera con la seguridad de contratos inteligentes, es importante entender claramente que las probabilidades de obtener una carrera lucrativa no están a su favor. El éxito no es el resultado predeterminado.
Por supuesto que se puede hacer, y bastantes personas han pasado de no conocer Solidez a tener una carrera lucrativa en auditoría. Podría decirse que es más fácil conseguir un trabajo como auditor de contratos inteligentes en un período de dos años que ser admitido en la facultad de derecho y aprobar el examen de la barra. Ciertamente tiene más ventajas en comparación con muchas otras opciones de carrera.
Sin embargo, requerirá una perseverancia hercúlea de su parte para dominar la montaña de conocimiento que evoluciona rápidamente y perfeccionar su intuición para detectar errores.
Esto no quiere decir que aprender la seguridad de los contratos inteligentes no sea una actividad que valga la pena. Absolutamente lo es. Pero si se acerca al campo con signos de dólar en los ojos, mantenga sus expectativas bajo control.
Es importante conocer los antipatrones conocidos. Sin embargo, la mayoría de los errores del mundo real son específicos de la aplicación. La identificación de cualquiera de las dos categorías de vulnerabilidades requiere una práctica continua y deliberada.
Aprenda la seguridad de los contratos inteligentes y muchos más temas de desarrollo de Ethereum con nuestra capacitación en solidez líder en la industria.
La imagen principal de este artículo fue generada porAI Image Generator de HackerNoon a través del mensaje "un robot que protege una computadora".