【合约审计实战】破局EVM状态欺骗:深度逆向以太坊高级猜谜蜜罐的“局中局”

起因在日常的链上浏览中,笔者留意到一个VerifiedContract。其中有了5ETH(价值约$10,183)的资金。令人警觉的是,该高净值合约的公开交互记录极为异常,仅存在一条历史交易。分析基本信息首先给出合约相关信息:合约地址:0xf327CC34562ac0857C2Ff0

起因

在日常的链上浏览中,笔者留意到一个Verified Contract。其中有了 5 ETH(价值约 $10,183)的资金。令人警觉的是,该高净值合约的公开交互记录极为异常,仅存在一条历史交易。

分析

基本信息

首先给出合约相关信息:

合约地址:0xf327CC34562ac0857C2Ff033Ce8D8B3713725cFD Etherscan:https://etherscan.io/address/0xf327cc34562ac0857c2ff033ce8d8b3713725cfd 合约代码:

/**
 *Submitted for verification at Etherscan.io on 2026-03-30
*/

pragma solidity ^0.8.0;

contract LETS_Play
{
    function Try(string memory _response) public payable
    {
        require(msg.sender == tx.origin);

        if(responseHash == keccak256(abi.encode(_response)) && msg.value > 1 ether)
        {
            payable(msg.sender).transfer(address(this).balance);
        }
    }

    string public question;

    bytes32 responseHash;

    mapping (bytes32=>bool) admin;

    function Start(string calldata _question, string calldata _response) public payable isAdmin{
        if(responseHash==0x0){
            responseHash = keccak256(abi.encode(_response));
            question = _question;
        }
    }

    function Stop() public payable isAdmin {
        payable(msg.sender).transfer(address(this).balance);
        responseHash = 0x0;
    }

    function New(string calldata _question, bytes32 _responseHash) public payable isAdmin {
        question = _question;
        responseHash = _responseHash;
    }

    constructor(bytes32[] memory admins) {
        for(uint256 i=0; i< admins.length; i++){
            admin[admins[i]] = true;        
        }       
    }

    modifier isAdmin(){
        require(admin[keccak256(abi.encodePacked(msg.sender))]);
        _;
    }

    fallback() external {}
}

代码分析

合约实现:一个竞猜合约,admin可以设置一个问题和答案,参与者可以通过try函数提交答案,

首先看全局变量有三个:

string public question:存储admin提出的问题
bytes32 responseHash:存储admin预设答案的hash值
mapping(bytes32 => bool) admin:使用bytes -> Bool来对应是否为管理员,但是实际存储中是使用的address的hash值作为对应关系也就是这里的bytes,防止找到哪些是管理员

再看合约代码只有4个函数

function Try(string memory _response) public payable
function Start(string calldata _question, string calldata _response) public payable isAdmin
function Stop() public payable isAdmin
function New(string calldata _question,bytes32 _responseHash) public payable isAdmin

逐个对函数逻辑进行分析:

function Try(string memory _response) public payable {

        require(msg.sender == tx.origin);

        if (

            responseHash == keccak256(abi.encode(_response)) &&

            msg.value > 1 ether

        ) {

            payable(msg.sender).transfer(address(this).balance);

        }

    }

可以说是整个合约最重要的一个函数,首先判断了msg.sender == tx.origin,阻止外部合约调用,防止闪电贷,然后就是判断传入的_response是否与存储的responseHash相等,如果相等而且转入的以太币多于1 ether就把整个合约的钱都转给调用者。该函数仅设置了条件成立时的转账逻辑,在未通过校验时没有任何信息给出

function Start(

        string calldata _question,

        string calldata _response

    ) public payable isAdmin {

        if (responseHash == 0x0) {

            responseHash = keccak256(abi.encode(_response));

            question = _question;

        }

    }

Start函数为设置问题,如果当前responseHash为0,则设置responseHash为答案的hash,问题则直接改为新提交的问题

function Stop() public payable isAdmin {

        payable(msg.sender).transfer(address(this).balance);

        responseHash = 0x0;

    }

Stop函数:admin可以随时任意结束游戏,存在极大安全风险

function New(

        string calldata _question,

        bytes32 _responseHash

    ) public payable isAdmin {

        question = _question;

        responseHash = _responseHash;

    }

New函数:admin可以随时任意更改question和答案hash,存在极大安全风险

constructor(bytes32[] memory admins) {

        for (uint256 i = 0; i < admins.length; i++) {

            admin[admins[i]] = true;

        }

    }

构造器中设置admin账户有哪些,将admins数组中的每一个设置true,这里提交的bytes32应是属于admin address的hash

modifier isAdmin() {

        require(admin[keccak256(abi.encodePacked(msg.sender))]);

        _;

    }

检查是否为管理员

对于以上代码逻辑,没有设置任何的event事件

链上分析

由于区块链上一切信息都是公开的,我们可以直接从链上查到问题和答案为:

image.png

image.png

inputdata:0xc76de3e90000...[省略]...206c657474655220572000...

问题为:"What is at the end of a rainbow?" 答案为:" letteR W "

按照常规逻辑,调用者只需构造 Try(" letteR W ") 即可提取合约余额。然而,笔者基于 Anvil Fork 主网状态进行本地测试时,发现交易虽被成功打包(Status: Success),但未触发任何底层 CALL 转账指令。

显然是没有进入if的逻辑,显然问题就出现在responseHash == keccak256(abi.encode(_response)。虽然responseHash是非public的状态变量,但是一切数据都是存储在链上,依然可以用storageAt来查看,由于这里是bytes32变量,位于第二个位置,所以直接查slot 1

slot 0 -> question (string)
slot 1 -> responseHash (bytes32)

image.png

得到了0xf152950bed091c9854229d3eecb07fae4c84127704751a692c8409543dc02bd3

image.png

letteR W 却是得到的0x9f16d1bc522fb263ce6d2593130671f3038b388331c87fa555be65b8979e3513

  1. 链上 slot 1 = 0xf152950b...
  2. keccak256(abi.encode(" letteR W ")) = 0x9f16d1bc... 两者不等 → responseHash 在 Start() 之后被修改过

调用new初始化过responseHash,导致Start修改并未生效

交易Hash:0x62a1c781701c93b4d7a2aace3c42cf75ec540078ff6753824ab65a67a0806b12 image.png

说明链上的responseHash其实是被修改了

既然公开的 Start函数调用 交易并未修改状态,那么真实的 responseHash 是何时被注入的?通过深挖交易执行栈(Call Stack),笔者在 Etherscan 的 Advanced Mode 中提取到了一笔隐蔽的Internal Transaction。

image.png

inputData:

0x9278a35a000000000000000000000000f327cc34562ac0857c2ff033ce8d8b3713725cfd000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000084ed8df1640000000000000000000000000000000000000000000000000000000000000040f152950bed091c9854229d3eecb07fae4c84127704751a692c8409543dc02bd30000000000000000000000000000000000000000000000000000000000000020576861742069732061742074686520656e64206f662061207261696e626f773f00000000000000000000000000000000000000000000000000000000

这就说的通了0xf152950bed091c9854229d3eecb07fae4c84127704751a692c8409543dc02bd3的缘由

本地模拟过程:

  1. 启动anvil:

    anvil --fork-url https://eth-mainnet.g.alchemy.com/v2/token-xxxxxx
  2. 切换到本地账户:

    cast rpc anvil_impersonateAccount 0x038405665520AC945D26eB7936ffe0115B2f2BBd \  --rpc-url http://127.0.0.1:8545
  3. 给自己印钞三个ETH:

    cast rpc anvil_setBalance 0x038405665520AC945D26eB7936ffe0115B2f2BBd 0x29a2241af62c0000 \
    --rpc-url http://127.0.0.1:8545

但是其实这里是无法完成的,因为0xf152950bed091c9854229d3eecb07fae4c84127704751a692c8409543dc02bd3这个哈希值不可能被还原为字符串。所以这个方法肯定是无法实现的。

这个合约的Internal Transaction普通模式下看不到这条交易,只有在Advanced Mode下才能看到,容易被误导只有一次交易,从而错误调用Try给合约转账导致大量损失!

image.png

利用BlockSec Phalcon分析

在start交易中,可以看到,调用栈中,并没有任何storage被修改,其实这里就可以看出这里的start函数只是一个幌子,是这个蜜罐的关键:https://app.blocksec.com/phalcon/explorer/tx/eth/0x2fffcd94fd19e2202223f1ce3f5e0ad44636a18e4d0e6ff960181e4982167e1e

image.png

在Internal Transaction中的New调用:https://app.blocksec.com/phalcon/explorer/tx/eth/0x62a1c781701c93b4d7a2aace3c42cf75ec540078ff6753824ab65a67a0806b12

image.png

可以看到这里是确实修改了question和responseHash

扩展分析

管理员抢跑攻击

由于存在New函数和Stop函数,管理员可随时调用,无条件修改答案哈希,或者随时撤走所有资金,而没有任何限制条件

管理员可监听mempool情况下高Gas随时抢跑退款离场


① 用户发现正确答案,提交 Try("correct_answer") + 2 ETH
        │
        │  交易进入 Mempool(公开可见)
        ▼
② 管理员监控 Mempool,立刻发送高 Gas 交易:
   New("same question", 0xDEAD...)  ← 更改答案哈希
        │
        ▼
③ 管理员交易先被打包  (Gas 更高)
④ 用户交易后被打包 → 哈希不匹配 → 2 ETH 被吞 
⑤ 管理员调用 Stop() 提走所有资金 

明显可疑点

  • 管理员地址被刻意混淆

    constructor(bytes32[] memory admins) {
    for (uint256 i = 0; i < admins.length; i++) {
        admin[admins[i]] = true;
    }
    }
  • 构造函数接收 预计算的哈希值,而非地址。无法从合约代码或构造参数直接反推管理员地址,隐藏管理员身份

  • 阻止用户保护性调用措施

require(msg.sender == tx.origin);  // 禁止合约调用

禁止用户通过保护合约调用,禁止使用闪电贷策略,而只能EOA调用。

强制要求 msg.sender == tx.origin,意味着用户必须使用 EOA直接调用,无法通过编写一个带有 require(balance > oldBalance) 的探测合约来进行安全包裹。

总结

经过以上分析,可以得出这是一个完完全全的有目的精心设计的蜜罐合约,一个猜谜蜜罐/钓鱼合约。

完整攻击场景还原

┌─────────────────────────────────────────────────────┐
│              蜜罐骗局完整生命周期                      │
├─────────────────────────────────────────────────────┤
│                                                     │
│  Phase 1: 布局                                      │
│  ├─ 部署合约,设定简单问题和答案                       │
│  ├─ 向合约注入 5 ETH 作为"奖池"                       │
│  └─ 答案故意设置为容易猜到/链上可查                    │
│                                                     │
│  Phase 2: 引诱                                      │
│  ├─ 在社交媒体/论坛宣传"猜谜赢 ETH"                   │
│  ├─ 受害者分析合约,发现答案似乎可以获取                │
│  └─ 受害者认为这是一个"漏洞"可以利用                   │
│                                                     │
│  Phase 3: 收割                                      │
│  ├─ 受害者调用 Try() 并发送 > 1 ETH                  │
│  ├─ 管理员前端运行 New() 修改答案哈希                  │
│  ├─ 受害者交易完成,ETH 被吞                          │
│  └─ 管理员调用 Stop() 提走所有资金                    │
│                                                     │
│  Phase 4: 重复                                      │
│  └─ 重新注入 ETH,更换问题,寻找下一个受害者            │
│                                                     │

风险等级评估

风险 等级 说明
蜜罐骗局设计 严重 合约本质为诈骗工具
管理员抢跑改答案 严重 用户永远无法真正获胜
管理员身份隐藏 阻碍追责
tx.origin限制 阻止保护性措施
无事件日志 降低透明度
无时间锁/多签   管理员单点控制

蜜罐设计者如何让每个可疑特征都显得"合理"?

蜜罐设计者的伪装逻辑:

Q: 为什么Try()失败不revert? A: "这是猜谜游戏,猜错要赔钱,revert了就没人投注了" ← 合理

Q: 为什么require(msg.sender == tx.origin)? A: "防止闪电贷攻击和合约自动化套利" ← 看似合理

Q: 为什么admin用hash而不是address? A: "增加安全性,防止管理员地址被针对攻击" ← 看似合理

Q: 为什么没有event? A: "节省gas" ← 看似合理

→ 每个单独的设计都有"合理"的解释 → 但组合在一起就形成了完美的蜜罐 → 这正是高级蜜罐区别于低级蜜罐的地方

  • 原创
  • 学分: 1
  • 分类: 安全
  • 标签:
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
vstralcn
vstralcn
0x0384...2BBd
Hello