后端与合约交互安全点梳理
常见安全点&最佳实践
1. 私钥安全
不要在代码、GitHub或云端明文存储私钥,避免暴露资产。
尽量用环境变量、硬件钱包(Ledger、Trezor)、或专门的密钥管理服务(如 HashiCorp Vault)。
不要将助记词/私钥硬编码在脚本中。
2. RPC 端点安全
不要将敏感资产连到公开或不信任的节点。选用受信任的节点服务商(如 Infura/Alchemy)。
如果自己搭节点,注意加密/白名单访问。
3. 合约 ABI 与地址校验
指定的 ABI 必须与部署的合约匹配,否则会导致调用异常甚至损失资金。
合约地址必须核对清楚,防止钓鱼攻击。
合约地址校验方法
地址格式基本校验
Ethereum 地址为
0x
开头的 40 位十六进制字符串。使用 web3 工具内置的
isAddress
方法:
from web3 import Web3
Web3.isAddress("0x...") # 返回 True/False
地址是否合约地址判断
查询该地址在链上有没有已部署合约(即代码长度是否大于 0):
code = web3.eth.get_code("0x...") # 返回 Bytecode
is_contract = len(code) > 2 # 空合约返回 '0x'
可以用 Etherscan、区块浏览器多重确认是不是已部署的合约。
聚合认证:
查区块浏览器(如 Etherscan、OKLink)、项目官网、官方文档或公告的合约地址,避免钓鱼或山寨合约。
ABI 校验方法
与合约实际接口对比
拿到的 ABI 应与链上实际合约源代码匹配(可对比函数名、参数数量和类型)。
优选来自可信渠道的 ABI(Etherscan 验证过的合约有“合约 ABI”公开页面)。
建立自己的热点合约库,保证数据的独立性。
动态 ABI 验证(接口探查法)
用 web3 或 ethers 连接合约后,尝试静态 call 函数看能否正常返回。
调用已知无风险 view/pure 方法,验证函数响应情况。
hash 校验(针对升级合约/源码校验)
可用编译工具对源码输出的 ABI 进行 hash(如 SHA256),与团队或社区公开 hash 校验一致性。
Sourcify 可自动校验某地址代码和公开源码一致性。
补充
Sourcify 是一个智能合约源码验证和合约溯源的开源服务和生态,由 Ethereum Foundation 支持,其核心目标是提升智能合约链上透明度和可审计性。它对于开发者安全地确认、比对和追溯链上合约的源代码有重要作用。下面详细说明其功能和作用:
一、Sourcify 的核心功能和作用
1. 智能合约源码验证与溯源
用户可以上传自己合约的源码、编译器参数、ABI 等元信息,Sourcify 系统会将其与链上已部署的合约字节码(Bytecode)做自动比对。
如果完全一致,就“验证”通过,表明这个链上地址确实就是由该源码编译部署的。
2. 链上“溯源标签”
成功验证的合约会获得一个“verified”或“perfect”标记,任何人都可以公开查阅其对应的源代码。
这种机制防止开发者发布无法追溯的合约,从而降低对用户和外部审计的透明度风险。
3. 去中心化开源验证
支持包括以太坊主网、测试网和很多 EVM 兼容链,由社区共同维护并可自建验证节点。
4. 一键识别 & 检索
任何人可以在 Sourcify 上输入一个合约地址自动检索其是否已被验证,并查看源代码和合约依赖(import 路径等)。
集成了“合约元数据”(metadata),便于开发者与安全人员更方便地查阅所有历史版本和依赖库。
5. 自动生成 ABI、调用参数等开发辅助
已验证的合约可一键下载对应 ABI/interface,加快开发与检测流程,也易于安全分析和自动化调用。
6. 跨链和代理合约检测
支持代理合约实际逻辑地址的解析和比对,可提升安全工程师和用户对 proxy pattern 的可验证性。
二、Sourcify 和 Etherscan 有何区别?
Etherscan 是中心化区块链浏览器,提供源码验证等服务,但其数据库和审核归网站所有。
Sourcify 是完全开源、分布式的生态系统,主打社区驱动的验证和索引,且支持多条链(很多链原生不依赖 Etherscan)。
如果一个合约被 Sourcify 验证过,其他任何 DApp、前端或安全工具都可以更自动化地“溯源”其源码,重现部署过程。
三、典型使用场景
合约开发者 / DApp 团队发布合约时上传源码进行验证 (促进透明可信)。
普通用户与审计员查询任意地址对应的源码和版本,核实项目安全性。
安全工具集成 Sourcify 查询,自动判断合约与源码匹配,防止调用未知/假合约。
4. 调用方式与 Gas 策略
明确调用 read(view/pure)方法与 write(非 view 方法);写操作需消耗 Gas。
合理设置 gas limit,避免 OOG(Out of Gas)。
注意重入与 re-entrancy 风险(比如 ERC-20 approve+transferFrom 执行时)。
重入攻击原理:
核心原理
重入攻击是攻击者通过外部合约调用链上的目标合约,在目标合约还没完成本次操作(如还未更新余额、状态还没变)时,反复重新进入目标合约关键函数,致使一些代码被重复执行,从而造成意料之外的结果(如大量盗取资金)。
攻击发生的典型场景
1. 目标合约中的典型漏洞代码
// 不安全的提款函数
function withdraw(uint256 _amount) public {
require(balances[msg.sender] >= _amount, "Insufficient balance");
// 步骤1:先给调用者转账
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
// 步骤2:再扣除余额(状态更新)
balances[msg.sender] -= _amount;
}
关键问题:先给用户打钱,然后才扣余额。如果
msg.sender
是一个合约账户,这个转账会触发对方的receive
或者fallback
函数。
2. 攻击者构造的钓鱼合约结构
contract Attack {
address public victim;
constructor(address _victim) { victim = _victim; }
// 用于收到ETH时反复调用victim合约
receive() external payable {
if (victim.balance >= 1 ether) {
// 重入调用漏洞函数
Victim(victim).withdraw(1 ether);
}
}
function attack() external payable {
// 先存钱到目标合约
Victim(victim).deposit{value: 1 ether}();
// 然后发起提取
Victim(victim).withdraw(1 ether);
}
}
interface Victim {
function deposit() external payable;
function withdraw(uint256) external;
}
3. 攻击流程详解
假如攻击者在Attack.attack()
中存入1 ETH,然后调用withdraw(1 ether)
:
步骤解析(每一步是 EVM 执行序列):
Victim.withdraw(1)
检查攻击者余额(满足)。执行转账
(bool sent, ) = msg.sender.call{value: 1 ether}("");
这里
msg.sender
其实就是攻击者的合约,EVM 在转账时调用了Attack.receive()
。
Attack.receive()
里,发现victim.balance >= 1 ether
,又调用了一遍目标合约的withdraw(1 ether)
。由于Victim 的 balances 还没“减1”,检查通过,重复步骤2…
如此循环N次(取决于Gas限制),每次都转1 ETH走,相当于攻击者1 ETH能多次提取(而实际上只有1 ETH本金)。
最后:
状态变量
balances[msg.sender] -= _amount
总是在所有交易转账完成之后才执行,这就是被反复利用的“窗口”。
可视化流程
一张简单的流程图:
Attack合约.attack()
|
|--> Victim.withdraw()
|
|-----> Attack合约.receive()
|
|-----> Victim.withdraw()
|
...(嵌套继续)
为什么攻击会得逞?
EVM 允许递归外部调用(合约对合约),并且转账时可以执行fallback/receive逻辑。
只要
Victim
合约的状态更新排在“外部调用”之后,就让攻击者有机可乘。Solidity 在低版本强烈建议用
transfer
,因其固定位gas 2300,限制复杂逻辑执行。但call
没有这个限制,若直接用call
+外部调用,重入风险极高。
5. 代币授权 Approve/Allowance
避免将 allowance 设置为最大值(如 2**256-1),授予最小必要权限。
经常 revoke(0 allowance 重置)已不再使用的 DApp 授权。
6. 重放攻击与事务签名
使用 chainId 避免跨链重放攻击(EIP-155)。
签名数据要检查使用的 domain separator,防范签名钓鱼。
补充Domain Separator
Domain Separator(域分隔符),本质上是一段哈希数据,相当于用户签名“属于哪个DApp、哪个合约、哪个网络”的数字标签。
在 EIP-712(结构化数据签名标准)中,签名不是单纯的消息文本,而是“域数据”+“实际内容数据”打包后的哈希,结构大致如下:
EIP-712 结构签名 = keccak256(
"\x19\x01"
domainSeparator // 用于分隔“域”
structHash // 具体消息内容的Hash
)
domainSeparator 通常包含:
合约名称 (
name
)合约版本 (
version
)合约地址 (
verifyingContract
)链ID (
chainId
)
如:MyDapp,v1,0x5101...,chainId 1
为何要有 Domain Separator?
1. 防止签名钓鱼
举例说明:
假如用户在A DApp网站签名了“我同意转账100 USDT给 XX 地址”的结构化数据。
如果没有 domainSeparator,B DApp或恶意合约可能获取到你原始签名,重新拼装到自己环境里“验过签”,实现完全不同的行为(比如A上只是抽奖签名,B上变成授权提币),即“签名重放”。
domainSeparator强制把DApp/合约/链等信息和数据混合后打包签名,导致签名只能在原来的场景解包生效,跨协议(DApp、链、合约)就自然验证失败。
2. 提升签名的数据唯一性/安全隔离
这样即便签名的数据内容完全一样,如果是在不同合约的 domain 下,签名 hash 也完全不同。
开发和审计上如何“检查 domain separator”
1. 对前端/合约开发者
前端准备标准 EIP-712 消息对象时,domain 字段要覆盖 name/version/chainId/verifyingContract 这些关键信息,并且与合约实际地址对应。
后端/合约端验证签名时,也要按照同样 domain 生成 domainSeparator,确保和前端一直。
示例 EIP-712 TypeScript签名配置:
const domain = {
name: "MyDapp",
version: "1",
chainId: 1,
verifyingContract: "0x123456...789"
};
const types = { /* ... */ };
const value = { /* ... */ };
// 用户签名出去的数据(MetaMask等会弹窗展示Domain信息,提升用户识别风险)
signTypedData(domain, types, value);
2. 对安全审计/内核开发:
明确合约端 verifySignature 函数里用的 domain separator 和前端保持一致。
避免项目只用简单 hash 消息或 EIP-191 非结构化签名,容易遇见被复用的“签名钓鱼”手法。
3. 对普通用户:
签名前注意钱包弹窗的“域名确认”内容(如 MetaMask 现在会弹出:你正在为哪个 DApp,哪个链,哪个合约地址签名),
如果出现“你签名的不是本 DApp/本合约”的内容,一定要警惕。
典型的签名钓鱼风险案例
钓鱼网站获取用户签名后,恶意拼装到其他平台上乱用。
或如未加 domain 的普通 eth_sign、personal_sign,被黑客利用伪造授权。
也有NFT市场、DAO投票方案历史上因为没用 EIP-712 domain,导致用户签名容易被复用或被盗。
7. 数据验证
与合约交互的输入参数要做本地校验,避免参数注入、数据溢出等风险。
8. 防止钓鱼合约
确认证实合约来源和代码开源可读,因为前端可以随意伪造 ABI 诱导操作危险合约。(校验或建立合约库)
9. 交易回执核查
要检查合约交互返回的交易 receipts 和状态,否则可能以为执行成功实际失败。
10. 多线程/批量操作处理
并行交易时要处理 nonce 分配、并发写冲突问题,避免意外的 nonce 重复或者交易失败。
11. 链上数据同步延迟
监听事件/读取状态时,要考虑到区块确认延迟及链上状态不一致。
12. 权限管理/合约升级
与代理合约交互时确认当前逻辑合约的实现(防范升级漏洞)。
调用 sensitive 方法需确保对方身份和权限。
13. 接口限额/防 DDoS
自建 RPC 需防止批量恶意调用造成资产/服务损耗。