DApp 安全
DApp 前端安全的独特性
传统 Web 应用的安全事故(数据泄露、XSS 等)通常可以通过回滚、封号、数据恢复等手段补救。但 DApp 前端安全有本质不同:
| 维度 | 传统 Web | DApp |
|---|---|---|
| 操作可逆性 | 服务端可回滚、可补偿 | 链上交易不可逆,资产一旦转出无法追回 |
| 资产暴露度 | 资产在服务端,前端不直接接触 | 钱包私钥/资产直接暴露于前端环境 |
| 攻击收益 | 窃取数据、劫持账号 | 直接盗取加密资产,变现即时 |
| 用户认知 | 用户熟悉传统交互 | 用户对签名、授权的含义缺乏理解 |
| 信任模型 | 信任服务端 | 信任用户自己的判断(自托管钱包) |
DApp 前端的每一次签名请求,本质上都是在请求用户授权一笔可能无法撤销的资产操作。前端开发者有责任让用户充分理解自己在做什么。
DApp 前端安全的特殊性? 和传统 Web 完全不一样:
- 链上交易不可逆——资产被转走没法回滚、没法封号补偿。
- 私钥/资产直接暴露在前端环境里,任意签名都可能被利用。
- 用户对签名/授权的含义往往不理解,前端有义务把风险讲清楚。
最常见的几个攻击面?
- 盲签:钱包只显示一串 hash 用户随手签——必须用 EIP-712 结构化签名让钱包能展示可读字段。
- Approve 钓鱼:诱导用户对
MaxUint256无限授权,然后 spender 把所有 Token 抽走。防御:默认按需授权,提供撤销入口(Revoke.cash)。 - Permit 钓鱼:EIP-2612 链下签名后能直接被调,看起来「只是签名」但等价交易。
- eth_sign:能签任意 hash 等于全权委托——绝对不要让用户签
eth_sign。 - 前端被劫持:CDN 投毒、依赖供应链攻击导致 RPC 地址被改。SRI、CSP、依赖锁版本是基础防护。
怎么提升用户安全感?
- 调用前用 eth_simulateV1 / Tenderly Simulation 预演交易,告诉用户「这笔会让你失去 X、得到 Y」。
- 显示授权金额而非
MaxUint256,并显示授权对象的合约名/审计状态。 - 大额操作走二次确认 / 硬件钱包提示。
签名风险
盲签(Blind Signing)
盲签指用户在不理解签名内容的情况下确认签名。早期钱包显示的签名信息往往是一串十六进制哈希,用户根本无法判断自己在签署什么。
// 用户在钱包中看到的内容
签名请求:
0x7f9fade1c0d57a7af66ab4ead79fade1c0d57a7af66ab4ead7c2c2eb7b11a91385
// 用户的反应:这是什么?算了,点确认吧...
攻击者可以构造恶意的签名请求,让用户在不知情的情况下签署:
- 无限授权(Approve)
- 资产转移
- NFT 上架出售(以极低价格)
- Permit 授权(离线签名即可转移代币)
EIP-712 结构化签名
EIP-712 是解决盲签问题的关键标准,它定义了结构化的、人类可读的签名格式:
const typedData = {
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
],
Permit: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
},
// domain 明确了签名的目标合约和链
domain: {
name: 'USD Coin',
version: '2',
chainId: 1,
verifyingContract: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
},
primaryType: 'Permit',
message: {
owner: '0xUserAddress...',
spender: '0xSpenderAddress...',
value: '1000000', // 1 USDC
nonce: 0,
deadline: 1680000000,
},
};
钱包会以结构化方式展示给用户,用户能清楚看到「谁」在「哪条链」的「哪个合约」上「授权多少」给「谁」。
Permit 钓鱼(ERC-2612)
ERC-2612 Permit 允许用户通过离线签名来授权代币,无需发送链上交易。这本是为了节省 Gas 的优秀设计,但被攻击者恶意利用:
Permit 签名是离线操作,不会产生链上交易,因此:
- 用户在区块浏览器上看不到任何授权记录
- revoke.cash 等工具无法检测到 Permit 授权
- 攻击者可以选择最佳时机提交签名并转走资产
eth_sign 的危险性
eth_sign 允许对任意哈希进行签名,是最危险的签名方法:
// 1. eth_sign - 极度危险,可签任意哈希
await provider.send('eth_sign', [address, arbitraryHash]);
// 攻击者可以构造任何交易的哈希让用户签名
// 2. personal_sign - 较安全,有前缀防止冒充交易
await provider.send('personal_sign', [message, address]);
// 自动添加 "\x19Ethereum Signed Message:\n" 前缀
// 3. eth_signTypedData_v4 - 最安全,结构化 + 可读
await provider.send('eth_signTypedData_v4', [address, typedData]);
// EIP-712 结构化签名,钱包可解析展示
| 签名方法 | 安全性 | 可读性 | 建议 |
|---|---|---|---|
eth_sign | 极低 | 不可读 | 禁用,MetaMask 已默认禁用 |
personal_sign | 中等 | 纯文本 | 仅用于消息验证(如登录) |
eth_signTypedData_v4 | 高 | 结构化可读 | 推荐,用于所有链上操作相关签名 |
Approve 授权风险
无限授权的危害
ERC-20 的 approve 机制要求用户先授权再转账。为了减少 Gas 消耗和用户操作次数,许多 DApp 默认请求无限授权(type(uint256).max):
import { parseUnits, MaxUint256 } from 'ethers';
// 危险:无限授权 - 合约可以转走用户所有代币
await token.approve(spenderAddress, MaxUint256);
// 安全:精确授权 - 只授权本次交易所需的数量
await token.approve(spenderAddress, parseUnits('100', 18));
即使 DApp 本身是安全的,无限授权也意味着:
- 合约被攻击后,攻击者可以转走所有已授权的代币
- 合约升级引入恶意逻辑时,用户资产直接暴露
- 授权一旦给出就永久有效,除非用户主动撤销
恶意合约 transferFrom
攻击流程:
授权管理与最佳实践
检查和撤销授权的工具:
- revoke.cash - 查看并撤销 ERC-20/721/1155 授权
- Etherscan Token Approvals - Etherscan 内置授权检查器
前端最佳实践:
import { parseUnits } from 'ethers';
async function safeApprove(
token: Contract,
spender: string,
amount: bigint
) {
// 1. 检查当前授权额度
const currentAllowance = await token.allowance(userAddress, spender);
// 2. 如果当前额度足够,无需再次授权
if (currentAllowance >= amount) return;
// 3. 精确授权(只授权需要的数量)
const tx = await token.approve(spender, amount);
await tx.wait();
}
// 使用 Permit2(Uniswap 推出的统一授权管理)
// 用户只需授权一次给 Permit2 合约,后续通过签名控制每次授权
async function usePermit2(token: Contract, permit2Address: string) {
// 只需对 Permit2 合约做一次授权
await token.approve(permit2Address, MaxUint256);
// 后续每次操作通过 Permit2 签名,可指定:
// - 精确金额
// - 过期时间
// - 目标合约
}
Uniswap Permit2 提供了一个统一的授权管理层:
- 用户只需对 Permit2 合约授权一次
- 每次具体操作通过 EIP-712 签名控制,可设置金额和过期时间
- 支持批量授权和批量撤销
- 大幅减少了无限授权的风险
钓鱼攻击防护
假网站攻击
攻击者创建与真实 DApp 几乎一模一样的假网站,诱导用户连接钱包并签署恶意交易:
| 攻击手段 | 示例 | 防护方式 |
|---|---|---|
| 相似域名 | uniswap.org → un1swap.org | 检查域名拼写,使用书签访问 |
| Unicode 欺骗 | 使用 Cyrillic 字符替换拉丁字符 | 浏览器地址栏仔细检查 |
| Google Ads 钓鱼 | 搜索结果顶部的虚假广告 | 不点击广告链接 |
| DNS 劫持 | 正确域名解析到恶意服务器 | 检查 SSL 证书,使用安全 DNS |
恶意 Token Airdrop
攻击者向大量地址空投假代币,代币合约的 transfer 函数中嵌入恶意逻辑:
// 假代币合约的 transfer 函数
function transfer(address to, uint256 amount) public {
// 看似正常的转账...
// 但实际会引导用户到钓鱼网站
// 或者 require 中消耗大量 Gas
// 或者触发一个授权操作
}
- 永远不要与来历不明的空投代币交互
- 不要尝试在 DEX 上出售来路不明的代币
- 在区块浏览器上验证代币合约是否已验证(Verified)
地址投毒(Address Poisoning)
攻击者监控链上交易,生成与用户常用地址首尾几位相同的地址,然后向用户发送小额转账。用户从交易历史中复制地址时,可能误选攻击者的地址:
用户的真实地址: 0x1234...abcd
攻击者生成的地址: 0x1234...abcd (首尾相同,中间不同)
用户查看交易历史时看到两笔转入记录,
复制了攻击者的地址进行大额转账...
防护措施:始终从地址簿或合约中获取地址,永远不要从交易历史中复制地址。前端应完整显示地址或提供地址簿功能。
社工攻击
- 冒充官方客服(Discord、Telegram 私信)
- 伪造 "紧急安全升级" 通知
- 虚假治理提案投票页面
前端安全措施
交易模拟
在用户确认交易前,通过模拟执行预判交易结果:
import { JsonRpcProvider } from 'ethers';
async function simulateTransaction(tx: TransactionRequest) {
const provider = new JsonRpcProvider(rpcUrl);
try {
// 使用 eth_call 模拟交易执行
const result = await provider.call(tx);
// 解析返回值,展示给用户
return {
success: true,
result,
// 展示:预计资产变动、Gas 消耗等
};
} catch (error) {
// 模拟失败说明交易会 revert
return {
success: false,
reason: '交易模拟失败,执行可能会失败',
};
}
}
- Tenderly - 提供详细的交易模拟和状态变更可视化
- Blowfish - 专注于交易安全扫描和风险评估
- Pocket Universe - 浏览器插件,自动模拟并提示风险
域名白名单与 EIP-712 Domain 校验
// 验证 EIP-712 签名请求的 domain 是否合法
function validateEIP712Domain(domain: {
name: string;
verifyingContract: string;
chainId: number;
}) {
// 白名单:已知安全的合约地址
const TRUSTED_CONTRACTS: Record<string, string> = {
'Uniswap V3 Router': '0xE592427A0AEce92De3Edee1F18E0157C05861564',
'USD Coin': '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
};
// 检查合约地址是否在白名单中
const isTrusted = Object.values(TRUSTED_CONTRACTS).includes(
domain.verifyingContract.toLowerCase()
);
// 检查 chainId 是否匹配当前网络
const isCorrectChain = domain.chainId === expectedChainId;
return { isTrusted, isCorrectChain };
}
交易内容透明展示
前端有责任将用户即将签署的交易内容清晰、准确地展示:
function renderTransactionSummary(tx: TransactionRequest) {
// 解析 calldata,展示人类可读的操作描述
const decoded = decodeFunctionData(tx.data);
return {
action: 'Swap', // 操作类型
from: '100 USDC', // 输入
to: '~0.05 ETH', // 预期输出
minReceived: '0.048 ETH', // 最小接收量
recipient: shortenAddress(tx.to), // 接收地址
estimatedGas: '150,000', // Gas 估算
warnings: checkForRisks(decoded), // 风险提示
};
}
CSP 与 XSS 防护
DApp 前端同样需要防范传统 Web 安全攻击,尤其是 XSS,因为一旦注入恶意脚本可以直接调用钱包 API:
// next.config.js
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self'", // 禁止内联脚本和外部脚本
"connect-src 'self' https://rpc.ankr.com", // 限制网络请求目标
"frame-ancestors 'none'", // 防止 clickjacking
].join('; '),
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
];
- 永远不要在前端代码中处理、存储或传输私钥/助记词
- 永远不要要求用户在网页中输入助记词(这是钱包的职责)
- 使用
window.ethereum或 WalletConnect 等标准接口与钱包通信 - 签名和交易发送必须通过钱包完成,前端只负责构造和展示
合约安全基础(前端视角)
前端开发者虽然不直接编写智能合约,但需要了解常见合约攻击,以便在前端层面提供防护:
| 攻击类型 | 原理 | 前端可做的防护 |
|---|---|---|
| 重入攻击 | 合约在状态更新前回调外部合约 | 交易模拟检测异常状态变更 |
| 闪电贷攻击 | 利用无需抵押的贷款操纵价格 | 显示价格偏差警告,比对多个数据源 |
| 三明治攻击 | MEV 机器人夹击用户交易 | 设置合理的滑点保护,使用 MEV 保护 RPC |
| 预言机操纵 | 操纵链上价格数据源 | 展示多源价格对比,标记异常偏差 |
// 设置滑点保护,防止三明治攻击
const slippageTolerance = 0.5; // 0.5%
const minAmountOut = expectedOutput * (1 - slippageTolerance / 100);
// 使用 Flashbots Protect RPC 防止 MEV 攻击
const FLASHBOTS_RPC = 'https://rpc.flashbots.net';
const provider = new JsonRpcProvider(FLASHBOTS_RPC);
安全工具与资源
交易安全工具
| 工具 | 类型 | 功能 |
|---|---|---|
| Pocket Universe | 浏览器插件 | 交易模拟、风险提示、钓鱼检测 |
| Blowfish | API / 插件 | 交易扫描、资产变动预览 |
| Wallet Guard | 浏览器插件 | 钓鱼网站检测、交易安全分析 |
| Tenderly | 开发工具 | 交易模拟、调试、监控 |
| revoke.cash | Web 工具 | 授权查看与撤销 |
| Flashbots Protect | RPC 节点 | MEV 保护、隐私交易 |
常见面试问题
Q1: 什么是盲签?为什么它是 DApp 安全的核心问题?
答案:
盲签是指用户在不理解签名内容的情况下确认签名操作。在早期钱包中,签名请求通常只显示一串十六进制哈希,用户无法判断自己在授权什么操作。
盲签是 DApp 安全的核心问题,因为:
- 不可逆性:链上操作一旦执行无法撤回
- 资产直接暴露:签名可能直接导致资产被转移
- 用户认知不足:大多数用户不具备解读原始签名数据的能力
EIP-712 通过结构化、人类可读的签名格式来缓解盲签问题,钱包可以清晰展示签名的目标合约、链 ID、操作类型和具体参数。
Q2: eth_sign、personal_sign 和 eth_signTypedData_v4 有什么区别?
答案:
eth_sign:对任意哈希签名,攻击者可构造任何交易哈希让用户签名,极度危险。MetaMask 已默认禁用personal_sign:签名前自动添加"\x19Ethereum Signed Message:\n"前缀,防止签名被冒充为交易。适用于登录验证等场景eth_signTypedData_v4:EIP-712 结构化签名,包含 domain 信息和类型化数据,钱包可解析并展示人类可读内容。是最安全的签名方式
推荐:所有涉及链上操作的签名都使用 eth_signTypedData_v4,消息验证(如登录)使用 personal_sign,完全禁用 eth_sign。
Q3: Permit 钓鱼是如何实现的?为什么特别难防范?
答案:
Permit(ERC-2612)允许用户通过离线签名授权代币转移,无需链上 approve 交易。攻击者利用这一点:
- 创建钓鱼页面诱导用户签署 Permit 签名
- 获取用户签名后,攻击者自行提交到链上
- 调用
transferFrom转走用户代币
特别难防范的原因:
- Permit 是离线操作,不产生链上交易,区块浏览器无记录
- revoke.cash 等工具无法检测已签署但未提交的 Permit
- 攻击者可以选择最佳时机提交签名
- 签名本身看起来可能像正常操作
防护手段:仔细检查 EIP-712 签名中的 spender 地址、value 金额和 deadline 时间。
Q4: 为什么无限 Approve 是危险的?前端应如何处理?
答案:
无限授权(approve(spender, type(uint256).max))意味着被授权的合约可以随时转走用户所有该代币,即使:
- 合约被黑客攻破
- 合约通过代理升级引入恶意逻辑
- 项目方作恶(rug pull)
前端最佳实践:
- 精确授权:只授权本次交易所需的精确数量
- 检查现有额度:如果当前 allowance 足够,不重复授权
- 使用 Permit2:通过 Uniswap Permit2 合约统一管理授权,支持金额和时间限制
- 提供撤销入口:在 DApp 中集成授权管理功能,方便用户撤销
Q5: 什么是地址投毒(Address Poisoning)?如何防护?
答案:
攻击者监控链上交易,使用 vanity address 生成器创建与目标用户常用地址首尾几位相同的地址,然后向用户发送小额(甚至零额)转账。
当用户从交易历史中复制地址时,可能误选攻击者地址进行大额转账。
防护措施:
- 前端:完整显示地址,提供地址簿功能,从地址簿而非交易历史复制地址
- 用户:始终从官方渠道获取地址,转账前仔细核对完整地址(不只看首尾)
- 钱包:对新地址首次转账发出提醒
Q6: DApp 前端如何实现交易模拟?
答案:
交易模拟通过 eth_call 在本地节点上预执行交易,不消耗 Gas 也不改变链上状态:
// 基础方案:eth_call
const result = await provider.call({
to: contractAddress,
data: encodedCalldata,
from: userAddress,
});
// 进阶方案:使用 Tenderly/Blowfish API
const simulation = await fetch('https://api.tenderly.co/simulate', {
method: 'POST',
body: JSON.stringify({ transaction: tx }),
});
// 返回详细的状态变更、资产变动、事件日志
模拟结果应展示给用户:预计的资产变动(代币增减)、Gas 消耗、调用的合约和函数、风险提示。
Q7: CSP 对 DApp 安全为什么特别重要?
答案:
DApp 前端一旦被 XSS 攻击,恶意脚本可以直接调用 window.ethereum API:
// XSS 注入后,攻击者可以直接调起签名请求
window.ethereum.request({
method: 'eth_sendTransaction',
params: [maliciousTransaction],
});
因此 CSP 对 DApp 尤为重要:
script-src 'self':禁止内联脚本和外部脚本注入connect-src白名单:限制网络请求目标,防止数据外泄frame-ancestors 'none':防止 clickjacking- 同时配合输入验证、输出编码等传统 XSS 防护手段
Q8: 什么是三明治攻击?前端如何帮助用户防护?
答案:
三明治攻击是 MEV(最大可提取价值)攻击的一种:攻击者监控 mempool,在用户交易前后各插入一笔交易("夹住"用户),通过操纵价格获利,用户承受更差的成交价格。
前端防护措施:
- 滑点保护:设置合理的
minAmountOut,超出滑点限制则交易回退 - 使用 MEV 保护 RPC:如 Flashbots Protect,交易不进入公共 mempool
- 价格偏差提醒:当成交价格与市场价偏差过大时提示用户
- 小额交易警告:金额较小时 Gas 费占比过高,不划算
Q9: 前端代码中应该如何处理私钥和助记词?
答案:
一个字:不碰。 前端代码中不应出现任何涉及私钥或助记词的逻辑:
- 不存储:不使用 localStorage、sessionStorage、Cookie 存储私钥
- 不传输:不通过任何 API 发送私钥
- 不生成:不在前端生成钱包(除非是专门的钱包应用)
- 不输入:不提供助记词输入框(这是钓鱼网站的特征)
所有签名和交易发送必须通过钱包(MetaMask、WalletConnect 等)完成。前端只负责构造交易参数和展示交易内容。
如果确实需要服务端钱包(如 relayer),私钥应存储在 HSM 或 KMS 中,前端通过认证后的 API 请求签名。
Q10: Permit2 相比传统 Approve 有什么安全优势?
答案:
| 维度 | 传统 Approve | Permit2 |
|---|---|---|
| 授权范围 | 每个 DApp 单独授权,常用无限授权 | 统一授权给 Permit2,通过签名控制每次使用 |
| 时间限制 | 永久有效 | 可设置 deadline 过期时间 |
| 金额控制 | 通常无限 | 每次签名指定精确金额 |
| 撤销管理 | 需逐个 DApp 撤销 | 撤销 Permit2 一个授权即可 |
| Gas 成本 | 每个 DApp 一次链上 approve 交易 | 首次授权 Permit2 后,后续通过签名实现零 Gas 授权 |
Permit2 的核心思想是将"一次性大授权 + 多次使用"改为"一次性大授权给可信中间层 + 每次使用需签名确认"。
Q11: 如何设计一套完整的 DApp 前端安全防护体系?
答案:
分层防护体系:
- 连接层:验证钱包连接状态和网络,参考 钱包连接方案
- 签名层:使用 EIP-712 结构化签名,禁用
eth_sign,清晰展示签名内容 - 授权层:精确授权,集成 Permit2,提供授权管理入口
- 交易层:交易模拟预执行,展示资产变动预览,设置滑点保护
- 网络层:CSP 策略、XSS 防护、域名校验、SSL 强制
- 监控层:异常交易告警、合约安全事件监控、用户行为异常检测
Q12: 从前端角度如何检测用户是否在访问钓鱼网站?
答案:
前端本身难以判断自己是否是钓鱼网站(因为钓鱼网站可以复制所有代码),但可以从以下维度辅助:
- EIP-712 Domain 校验:验证签名请求中的
verifyingContract和chainId是否与预期匹配 - 合约地址白名单:前端硬编码已知安全的合约地址,拒绝与非白名单合约交互
- ENS 验证:通过 ENS 域名反向解析验证合约身份
- 浏览器插件协作:Pocket Universe、Wallet Guard 等插件维护钓鱼网站黑名单
更关键的是用户教育:教导用户使用书签访问、检查域名、不信任未经验证的链接。