本文介绍了如何使用 Foundry 和 Python 模拟以太坊交易,并验证智能合约行为,从而保护用户免受欺骗性钱包界面的攻击。文章详细解释了如何使用 Python 验证交易 calldata,模拟 ERC-20 approve 交易的影响,并通过比较预期和实际 calldata 来检测 UI 欺骗攻击。
Valentina Rivas
使用 Foundry 和 Python 模拟以太坊交易并验证智能合约行为,保护用户免受欺骗性钱包界面的侵害。
在本教程中,我们将解释如何使用 Python 以编程方式验证交易 calldata。我们将逐步解码 ERC-20 approve 交易,通过本地主网分叉模拟其影响,并通过比较预期和实际的 calldata 来检测 UI 欺骗攻击。
在第一部分中,我们学习了如何解码以太坊 calldata 以了解交易意图。现在,我们将通过针对实时区块链 state 模拟交易来进一步验证。本指南将帮助你:
所有代码示例都基于我们之前的解码工具包 - 请确保你在继续之前熟悉第一部分。
模拟允许我们:
让我们使用 Foundry 创建一个脚本来模拟我们正在使用的 approval 交易并验证其效果。
为了模拟交易,我们首先需要以太坊主网的本地副本,这样你就可以模拟交易而无需花费真正的 ETH。Anvil(Foundry 工具包的一部分)使这变得容易:
对于 Linux/macOS:
curl -L https://foundry.paradigm.xyz | bash
foundryup
anvil --fork-url https://mainnet.infura.io/v3/YOUR_INFURA_KEY
对于 Windows:
Windows 用户需要使用 Linux 的 Windows 子系统 (WSL) 来运行 Anvil:
一旦你运行了 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"\
}\
]
现在,让我们构建我们的模拟,将其分解为模块化函数,以提高可读性和可维护性:
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 交互。
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 或网络问题提供后备方案。
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,并发现任何意外的变化。
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 量的场景。
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 十六进制进行比较,从而提醒我们注意潜在的欺骗攻击。
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 限制作为安全措施。
重放安全注意事项:模拟仅用于在本地分叉上进行测试,并且不是公共网络上的真实做法。在公共网络(主网)上,你不能模拟你不控制的帐户。尝试这样做会导致交易失败,因为你无法控制签署交易所需的私钥。
此模拟方法仅用于在与公共网络上的真实合约交互之前,在安全的本地环境中进行验证。
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(潜在的安全问题)或其他恶意行为。
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 提供了交易哈希以供参考,如果这是在公共网络上,你可以通过它在区块浏览器上查找交易。
在这些示例中,我们演示了我们的验证工具如何使用两种不同的场景:
在第一个模拟(“Wallet Calldata Match: True
”)中,我们为我们的工具提供了合法的 calldata,该 calldata 与我们打算做的事情相符:批准向我们预期的接收者进行 25 USDC 交易。
在以下示例中,我们通过在 wallet_calldata
参数中提供不同的 calldata 来模拟攻击期间发生的情况。此参数表示在受损的钱包 UI 中显示的内容。
在真实场景中,你将:
下图说明了此过程:
图 1:逐步验证交易
我们的工具会自动执行此比较,并在你钱包中的 calldata 与你期望的不匹配时提醒你。
让我们看看我们的工具将如何检测 UI 欺骗攻击。想象一个钱包 UI 显示:
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:
0x095ea7b30000000000000000000000006a000f20005980200259b80c510200304000106800000000000000000000000000000000000000000000000000000000017d7840
0x095ea7b30000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000000000000000000000000000000000003b9aca00
区别在于金额和接收者的地址:
0x17d7840
= 25,000,000 原始单位),而是批准 1,000 USDC(0x3b9aca00
= 1,000,000,000 原始单位)
0x6A000F
...),而是批准给不同的地址(0x3C44Cd
...)这种类型的攻击可能导致损失远远超过用户打算冒险的金额,并且可能会将资金重定向到攻击者控制的地址。我们的验证工具成功捕获了差异并警告我们 calldata 不匹配,从而避免了经济损失。
实施 calldata 验证时:
uint256
值(2²⁵⁶-1 或 2⁶⁴-1,在原始单位中分别显示为 1157920892....)。始终验证 calldata 中的实际数值。
图 2:MetaMask UI 显示原始最大值 (1.1579...e+77)
图 3:MetaMask UI 显示"无限制" approval
将 calldata 解码与交易模拟相结合,可以构成针对 UI 欺骗攻击的强大防御。这些技术使你能够:
虽然专注于 ERC-20 approval,但这些方法通过修改 ABI 定义和模拟逻辑来适应任何以太坊交易类型。请记住:验证 calldata 所花费的几秒钟可以为你节省数十亿美元。
- 原文链接: cyfrin.io/blog/secure-da...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!