模拟以太坊交易以检测 UI 欺骗

  • cyfrin
  • 发布于 2天前
  • 阅读 55

本文介绍了如何使用 Foundry 和 Python 模拟以太坊交易,并验证智能合约行为,从而保护用户免受欺骗性钱包界面的攻击。文章详细解释了如何使用 Python 验证交易 calldata,模拟 ERC-20 approve 交易的影响,并通过比较预期和实际 calldata 来检测 UI 欺骗攻击。

Valentina Rivas

保护 dApp 免受 UI 欺骗(第二部分):模拟交易

使用 Foundry 和 Python 模拟以太坊交易并验证智能合约行为,保护用户免受欺骗性钱包界面的侵害。

在本教程中,我们将解释如何使用 Python 以编程方式验证交易 calldata。我们将逐步解码 ERC-20 approve 交易,通过本地主网分叉模拟其影响,并通过比较预期和实际的 calldata 来检测 UI 欺骗攻击。

第一部分中,我们学习了如何解码以太坊 calldata 以了解交易意图。现在,我们将通过针对实时区块链 state 模拟交易来进一步验证。本指南将帮助你:

  • 使用 Anvil 设置原生以太坊主网分叉
  • 模拟 ERC-20 授权交易
  • 检测 UI 显示与实际执行之间的不匹配

所有代码示例都基于我们之前的解码工具包 - 请确保你在继续之前熟悉第一部分。

模拟交易以进行验证

为什么我们要模拟交易

模拟允许我们:

  • 查看区块链上将发生的实际 state 变化。
  • 验证交易是否按其声称的那样执行。
  • 在签署交易之前检测潜在问题。

让我们使用 Foundry 创建一个脚本来模拟我们正在使用的 approval 交易并验证其效果。

使用 Anvil 设置本地主网分叉

为了模拟交易,我们首先需要以太坊主网的本地副本,这样你就可以模拟交易而无需花费真正的 ETH。Anvil(Foundry 工具包的一部分)使这变得容易:

对于 Linux/macOS:

  1. 安装 Foundry:

curl -L https://foundry.paradigm.xyz | bash
foundryup

  1. 启动主网的本地分叉:
anvil --fork-url https://mainnet.infura.io/v3/YOUR_INFURA_KEY

对于 Windows:

Windows 用户需要使用 Linux 的 Windows 子系统 (WSL) 来运行 Anvil:

  1. 按照此分步视频指南安装 WSL。

  1. 安装 WSL 后,打开终端并按照上面的 Linux 说明进行操作。

  1. 默认情况下,你的 Anvil 实例可以从 Windows 访问,地址为 http://localhost:8545

设置环境

一旦你运行了 Anvil(或其他本地分叉解决方案),我们就可以连接到我们的本地分叉并定义 ERC-20 ABI

from web3 import Web3
import json
from eth_abi import decode
From eth_utils import to_checksum_address

## 连接到主网的本地分叉(使用 Anvil、Hardhat 或 Ganache)
w3 = Web3(Web3.HTTPProvider('http://localhost:8545'))

## ERC-20 ABI(用于 approval/allowance 检查的最小集合)
ERC20_ABI = [\
    {\
        "constant": False,\
        "inputs": [\
            {"name": "spender", "type": "address"},\
            {"name": "value", "type": "uint256"}\
        ],\
        "name": "approve",\
        "outputs": [{"name": "", "type": "bool"}],\
        "type": "function"\
    },\
    {\
        "constant": True,\
        "inputs": [\
            {"name": "owner", "type": "address"},\
            {"name": "spender", "type": "address"}\
        ],\
        "name": "allowance",\
        "outputs": [{"name": "", "type": "uint256"}],\
        "type": "function"\
    },\
    {\
        "constant": True,\
        "inputs": [],\
        "name": "decimals",\
        "outputs": [{"name": "", "type": "uint8"}],\
        "type": "function"\
    }\
]

构建模拟函数

现在,让我们构建我们的模拟,将其分解为模块化函数,以提高可读性和可维护性:

1. 设置地址并创建合约

def setup_addresses(user_address, token_address, spender_address):
    """将地址转换为校验和格式并创建合约实例"""
    user = Web3.to_checksum_address(user_address)
    token = Web3.to_checksum_address(token_address)
    spender = Web3.to_checksum_address(spender_address)
    contract = w3.eth.contract(address=token, abi=ERC20_ABI)

    return user, token, spender, contract

此函数处理初始设置,将所有地址转换为正确的校验和格式,并创建一个合约实例以与 token 交互。

2. 获取 token 信息

def get_token_info(contract, token_address, token_decimals=None):
    """获取 token 小数位数并计算归一化金额"""
    if token_decimals is None:
        try:
            token_decimals = contract.functions.decimals().call()
        except:
            # 根据 token 地址使用适当的后备方案
            if token_address.lower() == "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".lower():  # USDC
                token_decimals = 6
            else:
                token_decimals = 18  # 默认后备方案

    return token_decimals

此函数检索 token 的小数位数,并为常见 token 或网络问题提供后备方案。

3. 检查初始 state

def check_initial_state(contract, user, spender, token_decimals):
    """检查初始 allowance"""
    initial_allowance = contract.functions.allowance(user, spender).call()
    initial_allowance_normalized = initial_allowance / (10 ** token_decimals)

    return initial_allowance, initial_allowance_normalized

在模拟交易之前,我们在 ERC-20 合约上调用 allowance(owner, spender) 以检索当前链上 allowance。这样,在运行我们的模拟交易后,我们可以比较“之前与之后”的 allowance,并发现任何意外的变化。

4. 构建交易

  def build_transaction(contract, user, spender, amount):
    """构建 approval 交易"""
    tx = contract.functions.approve(spender, amount).build_transaction({
        'from': user,
        'nonce': w3.eth.get_transaction_count(user),
        'gas': 200000, # 初始安全估计
        'gasPrice': w3.eth.gas_price,
        'chainId': w3.eth.chain_id
    })

    # 获取 gas 估计值(稍后将限制 gas 以确保安全)
    gas_estimate = w3.eth.estimate_gas(tx)
    tx['gas'] = gas_estimate

    return tx, gas_estimate

此函数使用所有必要参数以及 gas 估计值构造交易对象。请注意,虽然我们在此处计算出准确的 gas 估计值,但我们将在主网执行期间应用额外的安全上限,以减轻合约可能表现异常并需要过高 gas 量的场景。

5. 将模拟的 calldata 与钱包提供的 calldata 进行比较

def compare_calldata(tx_data, wallet_calldata):
    """将生成的 calldata 与钱包 calldata 进行比较"""
    if not wallet_calldata:
        return None

    # 规范化两个 calldata 字符串以进行比较
    norm_generated = tx_data.lower()
    norm_wallet = wallet_calldata.lower()
    if norm_wallet.startswith('0x'):
        norm_wallet = norm_wallet[2:]
    if norm_generated.startswith('0x'):
        norm_generated = norm_generated[2:]

    calldata_matches = (norm_generated == norm_wallet)

    if not calldata_matches:
        print("警告:钱包 calldata 与预期的 calldata 不匹配!")
        print("这可能表明存在恶意交易或 UI 欺骗攻击。")

    return calldata_matches

此函数将我们预期的 calldata 与从钱包 UI 提取的原始 calldata 十六进制进行比较,从而提醒我们注意潜在的欺骗攻击。

6. 执行交易

def execute_transaction(tx, user):
    """使用模拟 gas 安全限制执行交易"""
    w3.provider.make_request("anvil_impersonateAccount", [user])
    try:
        # 发送具有 gas 上限的交易以确保安全
        safe_tx = {**tx, 'gas': min(tx.get('gas', 0), 300000)}
        tx_hash = w3.eth.send_transaction(safe_tx)
    finally:
        w3.provider.make_request("anvil_stopImpersonatingAccount", [user])

    return tx_hash

出于模拟目的,我们使用帐户模拟(本地开发环境中提供的功能)执行交易。我们添加 gas 限制作为安全措施。

重放安全注意事项:模拟用于在本地分叉上进行测试,并且不是公共网络上的真实做法。在公共网络(主网)上,你不能模拟你不控制的帐户。尝试这样做会导致交易失败,因为你无法控制签署交易所需的私钥。

此模拟方法仅用于在与公共网络上的真实合约交互之前,在安全的本地环境中进行验证。

7. 模拟后检查最终链上 allowance

def check_final_state(contract, user, spender, amount, token_decimals):
    """检查最终 allowance 和交易成功情况"""
    final_allowance = contract.functions.allowance(user, spender).call()
    final_allowance_normalized = final_allowance / (10 ** token_decimals)

    # 检查是否为无限 approval
    infinite_approval = amount >= (2**256 - 1) or amount >= (2**64 - 1)

    return final_allowance, final_allowance_normalized, infinite_approval

交易之后,我们验证新的 allowance,并检查这是否是无限 approval(潜在的安全问题)或其他恶意行为。

8. 返回结果(主模拟函数)

def simulate_approval(
    user_address: str,
    token_address: str,
    spender_address: str,
    amount: int,
    token_decimals: int = None,
    wallet_calldata: str = None
) -> dict:
    """
    模拟 ERC-20 approval 交易并验证 state 更改。
    """
    try:
        # 设置地址和合约 (...)

        # 获取 token 信息 (...)

        # 检查初始 state (...)

        # 构建预期交易 (...)

        # 比较 calldata 并检查钱包数据中是否存在无限 approval
        calldata_matches = compare_calldata(tx['data'], wallet_calldata)

        # 钱包 calldata 的其他安全检查
        wallet_amount = None
        wallet_infinite = False
        if wallet_calldata:
            try:
                # 解码钱包 calldata 以验证 spender 和 amount
                # 检查是否存在无限 approval 和 spender 不匹配
                # 继续...

        # 执行交易 (...)

        # 检查最终 state (...)

        return {
            "success": True,
            # 继续使用其他字段...
        }
    except Exception as e:
        return {"success": False, "error": str(e)}

此主函数通过依次调用我们的每个专用函数来安排模拟,并返回结果。

将所有内容放在一起

完整的代码可作为 GitHub Gist 提供。为了了解其工作原理,让我们使用我们完整的模拟脚本来验证我们在第一部分中解码的同一 approval 交易。

输出

让我们分解模拟脚本的完整输出并解释每个部分:

Simulation Result:
Success: True
Generated Calldata: 0x095ea7b30000000000000000000000006a000f20005980200259b80c510200304000106800000000000000000000000000000000000000000000000000000000017d7840
Wallet Calldata Match: True

此部分确认我们的模拟已成功,并且我们生成的 calldata 与钱包中提供的 calldata 完全匹配。这是一个好兆头 - 这意味着交易正在执行其声称的操作。

Transaction Details:
Spender: 0x6A000F20005980200259B80c5102003040001068
Approval Requested: 25.0 USDC (25000000 raw)
Actual Approval: 25.0 USDC
Gas Estimate: 55901
Infinite Approval: False

在这里,我们看到了交易的关键参数。spender 地址与我们预期的接收者匹配,金额为预期的 25 USDC,这不是无限 approval。gas 估计值让我们了解了交易成本。

State Changes:
Initial Allowance: 0.0 USDC
Final Allowance: 25.0 USDC
Transaction Successful: True
Transaction Hash: 39fc7619e61bb70859b9e41de87821ad069b81c58dd7990b84f1674d60166ab7

此部分显示了区块链上发生的实际 state 更改。allowance 从 0 USDC 开始,并在交易后增加到 25 USDC,确认 approval 已成功。 hash 提供了交易哈希以供参考,如果这是在公共网络上,你可以通过它在区块浏览器上查找交易。

检测 UI 欺骗攻击

在这些示例中,我们演示了我们的验证工具如何使用两种不同的场景:

  1. 合法交易: calldata 完全匹配

在第一个模拟(“Wallet Calldata Match: True”)中,我们为我们的工具提供了合法的 calldata,该 calldata 与我们打算做的事情相符:批准向我们预期的接收者进行 25 USDC 交易。

  1. 欺骗的 UI: calldata 与用户期望的不同

在以下示例中,我们通过在 wallet_calldata 参数中提供不同的 calldata 来模拟攻击期间发生的情况。此参数表示在受损的钱包 UI 中显示的内容。

在真实场景中,你将:

  • 使用你的参数(接收者、金额等)生成预期的 calldata。

  • 从你的钱包 UI 中提取实际的 calldata(通过单击数据字段图标)。

  • 比较它们以检测任何篡改。

下图说明了此过程:

显示六步交易验证过程的视觉流程图,以便通过比较和模拟以太坊 calldata 来检测 UI 欺骗。

图 1:逐步验证交易

我们的工具会自动执行此比较,并在你钱包中的 calldata 与你期望的不匹配时提醒你。

让我们看看我们的工具将如何检测 UI 欺骗攻击。想象一个钱包 UI 显示:

  • “批准向 ParaSwap ( 0x6A000F20005980200259B80c5102003040001068)" 支付 25 USDC

但实际的 approval calldata 是:

0x095ea7b30000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000000000000000000000000000000000003b9aca00

使用我们的模拟解码器脚本:

result = simulate_approval(
    user_address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",  # Anvil 帐户 0
    token_address="0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",  # USDC
    spender_address="0x6A000F20005980200259B80c5102003040001068",  # 示例 spender
    amount=25 * 10**6,  # 25 USDC(6 位小数)
    token_decimals=6,  # USDC 有 6 位小数
    wallet_calldata="0x095ea7b30000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000000000000000000000000000000000003b9aca00" # 在此处复制粘贴 UI 中显示的十六进制数据,以查看其是否匹配
)

输出

WARNING: Wallet calldata doesn't match expected calldata!
This could indicate a malicious transaction or UI spoofing attack.
CRITICAL: Spender address in wallet calldata doesn't match expected spender!

Simulation Result:
Success: True
Generated Calldata: 0x095ea7b30000000000000000000000006a000f20005980200259b80c510200304000106800000000000000000000000000000000000000000000000000000000017d7840
Wallet Calldata Match: False

Transaction Details:
(...)
Approval Requested: 25.0 USDC (25000000 raw)
Actual Approval: 1000.0 USDC
Gas Estimate: 55901
Infinite Approval: False

此输出揭示了一个安全问题。警告表明来自钱包 UI 的 calldata 与模拟交易生成的 calldata 不匹配。查看两条 calldata:

  1. 我们期望的内容(批准 25 USDC):

0x095ea7b30000000000000000000000006a000f20005980200259b80c510200304000106800000000000000000000000000000000000000000000000000000000017d7840 ‍

  1. 钱包尝试执行的操作(批准 1,000 USDC):

0x095ea7b30000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000000000000000000000000000000000003b9aca00 ‍

区别在于金额和接收者的地址:

  • 该交易不是批准 25 USDC(0x17d7840 = 25,000,000 原始单位),而是批准 1,000 USDC(0x3b9aca00 = 1,000,000,000 原始单位)

  • 不是批准给我们预期的接收者(0x6A000F...),而是批准给不同的地址(0x3C44Cd...)

这种类型的攻击可能导致损失远远超过用户打算冒险的金额,并且可能会将资金重定向到攻击者控制的地址。我们的验证工具成功捕获了差异并警告我们 calldata 不匹配,从而避免了经济损失。

安全最佳实践

实施 calldata 验证时:

  1. 始终验证交易,尤其是 approval、转账和交换。

  1. 检查接收者和金额:许多攻击仅更改一个参数。

  1. 警惕无限 approval:许多钱包以不同的方式显示无限 approval - 有些将其显示为“无限制”,而另一些则显示原始最大 uint256 值(2²⁵⁶-1 或 2⁶⁴-1,在原始单位中分别显示为 1157920892....)。始终验证 calldata 中的实际数值。

MetaMask approval 屏幕显示一个 spender 地址和一个以科学记数法表示的大值,表明 token approval 异常高。

图 2:MetaMask UI 显示原始最大值 (1.1579...e+77)

MetaMask 确认屏幕警告用户正在授予另一个地址无限的 USDC 支出权限。

图 3:MetaMask UI 显示"无限制" approval

  1. 比较来自多个来源的 calldata:如果可能,生成预期的 calldata 并将其与你的钱包显示的内容进行比较。

  1. 在签署之前进行模拟:对于高价值交易,请先进行模拟。

  1. 使用 硬件钱包,它会显示交易详细信息以进行手动验证。

结论

将 calldata 解码与交易模拟相结合,可以构成针对 UI 欺骗攻击的强大防御。这些技术使你能够:

  • 在签署之前验证交易。
  • 检测隐藏的参数更改。
  • 预防简单和复杂的攻击。

虽然专注于 ERC-20 approval,但这些方法通过修改 ABI 定义和模拟逻辑来适应任何以太坊交易类型。请记住:验证 calldata 所花费的几秒钟可以为你节省数十亿美元

  • 原文链接: cyfrin.io/blog/secure-da...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
cyfrin
cyfrin
Securing the blockchain and its users. Industry-leading smart contract audits, tools, and education.